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 time
import threading
import unicodedata
from difflib import SequenceMatcher
from collections import defaultdict, deque
from html import escape
from io import BytesIO
from urllib.parse import urlparse
from contextlib import asynccontextmanager
@@ -1065,6 +1068,72 @@ def normalize_external_url(value: str, field_name: str):
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):
if not upload or not upload.filename:
return None, None
@@ -3004,6 +3073,14 @@ def new_concert(request: Request):
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
# ============================================================
@@ -3208,6 +3285,7 @@ async def create_concert(
ticket_url: str = Form(""),
ticket_price: str = Form(""),
flyer_url: str = Form(""),
duplicate_confirmed: bool = Form(False),
flyer: UploadFile | None = File(None)
):
user = get_current_user(request)
@@ -3215,6 +3293,9 @@ async def create_concert(
if not user:
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:
return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
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)
if event_type != "other":
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):
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
try:
+96 -13
View File
@@ -64,6 +64,11 @@
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>
</head>
@@ -154,7 +159,10 @@
<input
type="text"
name="artist"
id="artist"
required
minlength="2"
maxlength="255"
placeholder="z. B. Iron Maiden"
>
@@ -238,6 +246,7 @@
<input
type="datetime-local"
name="start_datetime"
id="start-datetime"
required
>
@@ -245,6 +254,9 @@
</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>
<label>
Enddatum und Uhrzeit<br>
@@ -882,6 +894,67 @@ flyerInput.addEventListener(
// Request unter typischen Proxy-Limits und der Server muss keine riesigen
// Originaldateien verarbeiten.
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) {
const bitmap = await createImageBitmap(file);
const maxSide = 1800;
@@ -903,21 +976,31 @@ async function optimizeFlyerUpload(file) {
}
concertForm.addEventListener("submit", async event => {
if (concertForm.dataset.flyerOptimized === "true") return;
const file = flyerInput.files[0];
if (!file) return;
if (concertForm.dataset.readyToSubmit === "true") return;
event.preventDefault();
try {
const transfer = new DataTransfer();
transfer.items.add(await optimizeFlyerUpload(file));
flyerInput.files = transfer.files;
concertForm.dataset.flyerOptimized = "true";
concertForm.requestSubmit();
} catch (error) {
console.error("Flyer optimization failed:", error);
concertForm.dataset.flyerOptimized = "true";
concertForm.requestSubmit();
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 {
const transfer = new DataTransfer();
transfer.items.add(await optimizeFlyerUpload(file));
flyerInput.files = transfer.files;
} catch (error) {
console.error("Flyer optimization failed:", error);
}
concertForm.dataset.flyerOptimized = "true";
}
concertForm.dataset.readyToSubmit = "true";
concertForm.requestSubmit();
});