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