From f0d40f5fd4509243b477403d26d080e0a13935ad Mon Sep 17 00:00:00 2001 From: kai Date: Fri, 28 Aug 2026 14:23:15 +0200 Subject: [PATCH] feat: warn about duplicate events --- app/main.py | 94 ++++++++++++++++++++++++++++ app/templates/new_concert.html | 109 +++++++++++++++++++++++++++++---- 2 files changed, 190 insertions(+), 13 deletions(-) diff --git a/app/main.py b/app/main.py index f925c27..89ba274 100644 --- a/app/main.py +++ b/app/main.py @@ -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("

Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.

", status_code=400) if event_type not in EVENT_TYPES: return HTMLResponse("

Ungültige Veranstaltungskategorie.

", status_code=400) if event_type == "festival" and not end_datetime: @@ -3225,6 +3306,19 @@ async def create_concert( return HTMLResponse("

Ungültige Sichtbarkeit.

", 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'
  • {escape(match["artist"])} · {match["date"]} {match["time"]}
  • ' + for match in duplicate_matches + ) + return HTMLResponse( + "

    Mögliche doppelte Veranstaltung

    " + "

    Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.

    " + f"" + "

    Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.

    ", + status_code=409, + ) if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime): return HTMLResponse("

    Das Enddatum darf nicht vor dem Beginn liegen.

    ", status_code=400) try: diff --git a/app/templates/new_concert.html b/app/templates/new_concert.html index a7ad237..c8c7d93 100644 --- a/app/templates/new_concert.html +++ b/app/templates/new_concert.html @@ -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; } + @@ -154,7 +159,10 @@ @@ -238,6 +246,7 @@ @@ -245,6 +254,9 @@

    +
    + +