feat: warn about duplicate events
This commit is contained in:
+94
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user