feat: warn about duplicate events

This commit is contained in:
kai
2026-08-28 14:23:15 +02:00
parent f05bae9ab1
commit f0d40f5fd4
2 changed files with 190 additions and 13 deletions
+94
View File
@@ -5,7 +5,10 @@ import hashlib
import re import re
import time import time
import threading import threading
import unicodedata
from difflib import SequenceMatcher
from collections import defaultdict, deque from collections import defaultdict, deque
from html import escape
from io import BytesIO from io import BytesIO
from urllib.parse import urlparse from urllib.parse import urlparse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -1065,6 +1068,72 @@ def normalize_external_url(value: str, field_name: str):
return value return value
def normalize_artist_name(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value or "")
without_accents = "".join(character for character in normalized if not unicodedata.combining(character))
return " ".join(re.findall(r"[a-z0-9]+", without_accents.casefold()))
def artist_names_similar(first: str, second: str) -> bool:
left = normalize_artist_name(first)
right = normalize_artist_name(second)
if not left or not right:
return False
if left == right:
return True
if min(len(left), len(right)) >= 4 and (left in right or right in left):
return True
if min(len(left), len(right)) < 5:
return False
return SequenceMatcher(None, left, right).ratio() >= 0.78
def find_duplicate_concerts(user, artist: str, start_date: str):
try:
concert_date = datetime.strptime(start_date[:10], "%Y-%m-%d").date()
except (TypeError, ValueError):
return []
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT c.id, c.artist, c.start_datetime, c.event_type,
COALESCE(v.name, ''), COALESCE(v.city, '')
FROM concerts c
LEFT JOIN venues v ON v.id = c.venue_id
WHERE c.start_datetime::date = %s
AND (
c.visibility = 'public' OR c.created_by = %s OR %s
OR EXISTS (
SELECT 1 FROM event_invitations ei
WHERE ei.concert_id = c.id AND ei.user_id = %s
)
OR (c.visibility = 'friends' AND EXISTS (
SELECT 1 FROM friendships f
WHERE f.status = 'accepted'
AND ((f.requester_id = c.created_by AND f.addressee_id = %s)
OR (f.addressee_id = c.created_by AND f.requester_id = %s))
))
)
ORDER BY c.start_datetime, c.id
""",
(concert_date, user["id"], user["is_admin"], user["id"], user["id"], user["id"]),
)
rows = cursor.fetchall()
return [
{
"id": row[0],
"artist": row[1],
"date": row[2].strftime("%d.%m.%Y"),
"time": row[2].strftime("%H:%M"),
"event_type": row[3],
"venue": ", ".join(part for part in (row[4], row[5]) if part),
}
for row in rows
if artist_names_similar(artist, row[1])
]
def save_image(upload: UploadFile, destination_dir: str, url_prefix: str): def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
if not upload or not upload.filename: if not upload or not upload.filename:
return None, None return None, None
@@ -3004,6 +3073,14 @@ def new_concert(request: Request):
invitable_users=get_invitable_users(user["id"])) invitable_users=get_invitable_users(user["id"]))
@app.get("/api/concerts/duplicates")
def duplicate_concerts(request: Request, artist: str = "", start_date: str = ""):
user = get_current_user(request)
if len(artist.strip()) < 2 or len(artist) > 300:
return JSONResponse({"matches": []})
return JSONResponse({"matches": find_duplicate_concerts(user, artist, start_date)})
# ============================================================ # ============================================================
# Concert detail # Concert detail
# ============================================================ # ============================================================
@@ -3208,6 +3285,7 @@ async def create_concert(
ticket_url: str = Form(""), ticket_url: str = Form(""),
ticket_price: str = Form(""), ticket_price: str = Form(""),
flyer_url: str = Form(""), flyer_url: str = Form(""),
duplicate_confirmed: bool = Form(False),
flyer: UploadFile | None = File(None) flyer: UploadFile | None = File(None)
): ):
user = get_current_user(request) user = get_current_user(request)
@@ -3215,6 +3293,9 @@ async def create_concert(
if not user: if not user:
return login_redirect("/concerts/new") return login_redirect("/concerts/new")
artist = artist.strip()
if len(artist) < 2 or len(artist) > 255:
return HTMLResponse("<h1>Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.</h1>", status_code=400)
if event_type not in EVENT_TYPES: if event_type not in EVENT_TYPES:
return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400) return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
if event_type == "festival" and not end_datetime: if event_type == "festival" and not end_datetime:
@@ -3225,6 +3306,19 @@ async def create_concert(
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400) return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
if event_type != "other": if event_type != "other":
visibility = "public" visibility = "public"
duplicate_matches = find_duplicate_concerts(user, artist, start_datetime)
if duplicate_matches and not duplicate_confirmed:
match_items = "".join(
f'<li><a href="/concerts/{match["id"]}">{escape(match["artist"])} · {match["date"]} {match["time"]}</a></li>'
for match in duplicate_matches
)
return HTMLResponse(
"<h1>Mögliche doppelte Veranstaltung</h1>"
"<p>Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.</p>"
f"<ul>{match_items}</ul>"
"<p>Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.</p>",
status_code=409,
)
if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime): if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime):
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400) return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
try: try:
+90 -7
View File
@@ -64,6 +64,11 @@
display: none; display: none;
} }
.duplicate-warning { display:none; margin:10px 0; padding:12px 14px; color:#fde68a; background:#422006; border:1px solid #d97706; border-radius:9px; }
.duplicate-warning strong { display:block; margin-bottom:6px; }
.duplicate-warning ul { margin:6px 0 0; padding-left:20px; }
.duplicate-warning a { color:#fef3c7; text-decoration:underline; }
</style> </style>
</head> </head>
@@ -154,7 +159,10 @@
<input <input
type="text" type="text"
name="artist" name="artist"
id="artist"
required required
minlength="2"
maxlength="255"
placeholder="z. B. Iron Maiden" placeholder="z. B. Iron Maiden"
> >
@@ -238,6 +246,7 @@
<input <input
type="datetime-local" type="datetime-local"
name="start_datetime" name="start_datetime"
id="start-datetime"
required required
> >
@@ -245,6 +254,9 @@
</p> </p>
<div id="duplicate-warning" class="duplicate-warning" role="status" aria-live="polite"></div>
<input type="hidden" name="duplicate_confirmed" id="duplicate-confirmed" value="false">
<p id="festival-end-field" hidden> <p id="festival-end-field" hidden>
<label> <label>
Enddatum und Uhrzeit<br> Enddatum und Uhrzeit<br>
@@ -882,6 +894,67 @@ flyerInput.addEventListener(
// Request unter typischen Proxy-Limits und der Server muss keine riesigen // Request unter typischen Proxy-Limits und der Server muss keine riesigen
// Originaldateien verarbeiten. // Originaldateien verarbeiten.
const concertForm = flyerInput.form; const concertForm = flyerInput.form;
const artistInput = document.getElementById("artist");
const startDatetimeInput = document.getElementById("start-datetime");
const duplicateWarning = document.getElementById("duplicate-warning");
const duplicateConfirmed = document.getElementById("duplicate-confirmed");
let duplicateTimeout = null;
let duplicateRequestController = null;
function showDuplicateMatches(matches) {
duplicateWarning.replaceChildren();
if (!matches.length) {
duplicateWarning.style.display = "none";
return;
}
const heading = document.createElement("strong");
heading.textContent = "⚠️ Möglicherweise existiert diese Veranstaltung bereits:";
const list = document.createElement("ul");
matches.forEach(match => {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = `/concerts/${match.id}`;
link.target = "_blank";
link.rel = "noopener";
link.textContent = `${match.artist} · ${match.date} ${match.time}${match.venue ? ` · ${match.venue}` : ""}`;
item.appendChild(link);
list.appendChild(item);
});
duplicateWarning.append(heading, list);
duplicateWarning.style.display = "block";
}
async function checkForDuplicates(showWarning = true) {
const artist = artistInput.value.trim();
const startDate = startDatetimeInput.value;
if (artist.length < 2 || !startDate) {
if (showWarning) showDuplicateMatches([]);
return [];
}
if (duplicateRequestController) duplicateRequestController.abort();
duplicateRequestController = new AbortController();
try {
const params = new URLSearchParams({artist, start_date: startDate});
const response = await fetch(`/api/concerts/duplicates?${params}`, {
signal: duplicateRequestController.signal
});
if (!response.ok) return [];
const matches = (await response.json()).matches || [];
if (showWarning) showDuplicateMatches(matches);
return matches;
} catch (error) {
if (error.name !== "AbortError") console.error("Duplicate check failed:", error);
return [];
}
}
[artistInput, startDatetimeInput].forEach(input => input.addEventListener("input", () => {
duplicateConfirmed.value = "false";
concertForm.dataset.readyToSubmit = "false";
clearTimeout(duplicateTimeout);
duplicateTimeout = setTimeout(() => checkForDuplicates(true), 450);
}));
async function optimizeFlyerUpload(file) { async function optimizeFlyerUpload(file) {
const bitmap = await createImageBitmap(file); const bitmap = await createImageBitmap(file);
const maxSide = 1800; const maxSide = 1800;
@@ -903,21 +976,31 @@ async function optimizeFlyerUpload(file) {
} }
concertForm.addEventListener("submit", async event => { concertForm.addEventListener("submit", async event => {
if (concertForm.dataset.flyerOptimized === "true") return; if (concertForm.dataset.readyToSubmit === "true") return;
const file = flyerInput.files[0];
if (!file) return;
event.preventDefault(); event.preventDefault();
const matches = await checkForDuplicates(true);
if (matches.length && duplicateConfirmed.value !== "true") {
const proceed = window.confirm(
"Am selben Tag gibt es bereits eine Veranstaltung mit einem sehr ähnlichen Bandnamen. Trotzdem speichern?"
);
if (!proceed) return;
duplicateConfirmed.value = "true";
}
const file = flyerInput.files[0];
if (file && concertForm.dataset.flyerOptimized !== "true") {
try { try {
const transfer = new DataTransfer(); const transfer = new DataTransfer();
transfer.items.add(await optimizeFlyerUpload(file)); transfer.items.add(await optimizeFlyerUpload(file));
flyerInput.files = transfer.files; flyerInput.files = transfer.files;
concertForm.dataset.flyerOptimized = "true";
concertForm.requestSubmit();
} catch (error) { } catch (error) {
console.error("Flyer optimization failed:", error); console.error("Flyer optimization failed:", error);
concertForm.dataset.flyerOptimized = "true";
concertForm.requestSubmit();
} }
concertForm.dataset.flyerOptimized = "true";
}
concertForm.dataset.readyToSubmit = "true";
concertForm.requestSubmit();
}); });