diff --git a/app/main.py b/app/main.py index 5235143..93bfa15 100644 --- a/app/main.py +++ b/app/main.py @@ -174,6 +174,22 @@ def ensure_schema(): updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """, + """ + ALTER TABLE venues + ADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT FALSE + """, + """ + CREATE TABLE IF NOT EXISTS venue_aliases ( + id SERIAL PRIMARY KEY, + venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE, + alias VARCHAR(255) NOT NULL, + UNIQUE (venue_id, alias) + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_venue_aliases_alias + ON venue_aliases (LOWER(alias)) + """, ] with get_db_connection() as connection: @@ -610,6 +626,11 @@ def resolve_venue( longitude: str, ): selected_venue_id = None + venue_name = venue_name.strip() + city = city.strip() + street = street.strip() + postal_code = postal_code.strip() + country = country.strip() if venue_id: if venue_id.startswith("nominatim:"): @@ -619,11 +640,11 @@ def resolve_venue( """ SELECT id FROM venues - WHERE external_id = %s + WHERE external_id IN (%s, %s) AND source = 'nominatim' LIMIT 1 """, - (external_id,), + (external_id, external_id.rsplit(":", 1)[-1]), ) existing_venue = cursor.fetchone() @@ -651,7 +672,7 @@ def resolve_venue( street or None, postal_code or None, city or None, - country or "Deutschland", + country or None, float(latitude) if latitude else None, float(longitude) if longitude else None, external_id, @@ -700,7 +721,7 @@ def resolve_venue( street or None, postal_code or None, city or None, - country or "Deutschland", + country or None, float(latitude) if latitude else None, float(longitude) if longitude else None, ), @@ -719,6 +740,48 @@ def require_admin(request: Request): return user +def get_admin_venues(): + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT + venues.id, + venues.name, + venues.street, + venues.postal_code, + venues.city, + venues.country, + venues.source, + venues.is_verified, + COALESCE(string_agg(venue_aliases.alias, ', ' ORDER BY venue_aliases.alias), ''), + COUNT(DISTINCT concerts.id) + FROM venues + LEFT JOIN venue_aliases ON venue_aliases.venue_id = venues.id + LEFT JOIN concerts ON concerts.venue_id = venues.id + GROUP BY venues.id + ORDER BY venues.is_verified ASC, venues.name ASC, venues.city ASC + """ + ) + rows = cursor.fetchall() + + return [ + { + "id": row[0], + "name": row[1], + "street": row[2] or "", + "postal_code": row[3] or "", + "city": row[4] or "", + "country": row[5] or "", + "source": row[6] or "manuell", + "is_verified": bool(row[7]), + "aliases": row[8], + "concert_count": row[9], + } + for row in rows + ] + + @app.get("/admin", response_class=HTMLResponse) def admin_page(request: Request): user = require_admin(request) @@ -750,7 +813,12 @@ def admin_page(request: Request): ] template = templates.get_template("admin.html") - return template.render(user=user, users=users, invite_url=None) + return template.render( + user=user, + users=users, + venues=get_admin_venues(), + invite_url=None, + ) @app.post("/admin/invites", response_class=HTMLResponse) @@ -808,11 +876,108 @@ def create_invite(request: Request): return template.render( user=user, users=users, + venues=get_admin_venues(), invite_url=f"{str(request.base_url).rstrip('/')}/register/{token}", invite_id=invite_id, ) +@app.post("/admin/venues/{venue_id}") +def update_venue( + request: Request, + venue_id: int, + name: str = Form(...), + street: str = Form(""), + postal_code: str = Form(""), + city: str = Form(""), + country: str = Form(""), + aliases: str = Form(""), + is_verified: str = Form(""), +): + if not require_admin(request): + return HTMLResponse("

Nicht erlaubt

", status_code=403) + + name = name.strip() + if not name: + return HTMLResponse("

Der Name darf nicht leer sein.

", status_code=400) + + normalized_aliases = sorted({ + alias.strip() + for alias in aliases.split(",") + if alias.strip() and alias.strip().casefold() != name.casefold() + }) + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + UPDATE venues + SET name = %s, street = %s, postal_code = %s, city = %s, + country = %s, is_verified = %s + WHERE id = %s + """, + ( + name, + street.strip() or None, + postal_code.strip() or None, + city.strip() or None, + country.strip() or None, + is_verified == "on", + venue_id, + ), + ) + cursor.execute("DELETE FROM venue_aliases WHERE venue_id = %s", (venue_id,)) + cursor.executemany( + "INSERT INTO venue_aliases (venue_id, alias) VALUES (%s, %s)", + [(venue_id, alias) for alias in normalized_aliases], + ) + connection.commit() + + return RedirectResponse("/admin#venues", status_code=303) + + +@app.post("/admin/venues/{venue_id}/merge") +def merge_venue( + request: Request, + venue_id: int, + target_venue_id: int = Form(...), +): + if not require_admin(request): + return HTMLResponse("

Nicht erlaubt

", status_code=403) + if venue_id == target_venue_id: + return HTMLResponse("

Ein Ort kann nicht mit sich selbst zusammengeführt werden.

", status_code=400) + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("SELECT id FROM venues WHERE id IN (%s, %s)", (venue_id, target_venue_id)) + if len(cursor.fetchall()) != 2: + return HTMLResponse("

Veranstaltungsort nicht gefunden.

", status_code=404) + cursor.execute( + """ + INSERT INTO venue_aliases (venue_id, alias) + SELECT %s, name FROM venues WHERE id = %s + ON CONFLICT DO NOTHING + """, + (target_venue_id, venue_id), + ) + cursor.execute( + """ + INSERT INTO venue_aliases (venue_id, alias) + SELECT %s, alias FROM venue_aliases WHERE venue_id = %s + ON CONFLICT DO NOTHING + """, + (target_venue_id, venue_id), + ) + cursor.execute( + "UPDATE concerts SET venue_id = %s WHERE venue_id = %s", + (target_venue_id, venue_id), + ) + cursor.execute("DELETE FROM venues WHERE id = %s", (venue_id,)) + connection.commit() + + return RedirectResponse("/admin#venues", status_code=303) + + @app.post("/admin/users/{user_id}") def update_user_role( request: Request, @@ -1423,7 +1588,7 @@ async def create_concert( city: str = Form(""), street: str = Form(""), postal_code: str = Form(""), - country: str = Form("Deutschland"), + country: str = Form(""), latitude: str = Form(""), longitude: str = Form(""), start_datetime: str = Form(...), @@ -1552,7 +1717,7 @@ async def edit_concert( city: str = Form(""), street: str = Form(""), postal_code: str = Form(""), - country: str = Form("Deutschland"), + country: str = Form(""), latitude: str = Form(""), longitude: str = Form(""), start_datetime: str = Form(""), @@ -1839,8 +2004,7 @@ async def add_photo( # Venue search # ============================================================ -@app.get("/api/venues/search") -def search_venues(q: str): +def legacy_search_venues(q: str): q = q.strip() @@ -1968,7 +2132,7 @@ def search_venues(q: str): "User-Agent": "PinguConcerts/1.0" } - external_query = re.sub(r"\bhall\b", "halle", q, flags=re.IGNORECASE) + external_query = q params = { @@ -1978,9 +2142,7 @@ def search_venues(q: str): "addressdetails": 1, - "limit": 20, - - "countrycodes": "de" + "limit": 20 } @@ -2310,3 +2472,173 @@ def search_venues(q: str): return results + + +def venue_search_tokens(value: str): + return [token for token in re.findall(r"[^\W_]+", value.casefold()) if len(token) >= 2] + + +def venue_result_key(venue): + return ( + (venue.get("name") or "").strip().casefold(), + (venue.get("city") or "").strip().casefold(), + (venue.get("country") or "").strip().casefold(), + ) + + +@app.get("/api/venues/search") +def search_venues(q: str): + query = q.strip() + if len(query) < 2: + return [] + + tokens = venue_search_tokens(query) + token_clauses = [] + token_values = [] + for token in tokens: + token_clauses.append( + """( + venues.name ILIKE %s OR venues.city ILIKE %s OR venues.country ILIKE %s + OR EXISTS ( + SELECT 1 FROM venue_aliases + WHERE venue_aliases.venue_id = venues.id + AND venue_aliases.alias ILIKE %s + ) + )""" + ) + token_values.extend([f"%{token}%"] * 4) + token_match = " AND ".join(token_clauses) or "FALSE" + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + f""" + SELECT venues.id, venues.name, venues.street, venues.postal_code, + venues.city, venues.country, venues.latitude, venues.longitude, + venues.external_id, venues.source, venues.is_verified + FROM venues + WHERE venues.name ILIKE %s OR venues.city ILIKE %s + OR venues.country ILIKE %s OR venues.street ILIKE %s + OR EXISTS ( + SELECT 1 FROM venue_aliases + WHERE venue_aliases.venue_id = venues.id + AND venue_aliases.alias ILIKE %s + ) + OR ({token_match}) + ORDER BY venues.is_verified DESC, + CASE WHEN LOWER(venues.name) = LOWER(%s) THEN 0 + WHEN venues.name ILIKE %s THEN 1 ELSE 2 END, + venues.name + LIMIT 15 + """, + [f"%{query}%"] * 5 + + token_values + + [query, f"{query}%"], + ) + rows = cursor.fetchall() + + ranked_results = [] + for row in rows: + ranked_results.append({ + "id": row[0], + "name": row[1], + "street": row[2], + "postal_code": row[3], + "city": row[4], + "country": row[5], + "latitude": row[6], + "longitude": row[7], + "external_id": row[8], + "source": row[9], + "local": True, + "verified": bool(row[10]), + "_score": 300 + (50 if row[10] else 0), + }) + + try: + response = httpx.get( + "https://nominatim.openstreetmap.org/search", + params={ + "q": query, + "format": "jsonv2", + "addressdetails": 1, + "namedetails": 1, + "limit": 25, + }, + headers={"User-Agent": "PinguConcerts/1.0"}, + timeout=8, + ) + response.raise_for_status() + + venue_types = { + "music_venue", "concert_hall", "stadium", "sports_centre", "theatre", + "arts_centre", "exhibition_hall", "conference_centre", "events_venue", + "nightclub", "community_centre", "festival", "arena", "auditorium", + "cinema", "event_venue", "recreation_ground", + } + venue_keywords = { + "arena", "club", "festival", "halle", "hall", "stadion", "stadium", + "theater", "theatre", "concert", "konzert", "music", "venue", "centre", + "center", "festivalgelände", "festivalterrein", + } + excluded_types = { + "street", "road", "residential", "postcode", "house", "bus_stop", "person", + } + + for item in response.json(): + address = item.get("address") or {} + display_name = (item.get("display_name") or "").strip() + name = (item.get("name") or display_name.split(",", 1)[0]).strip() + osm_type = (item.get("type") or "").casefold() + osm_class = (item.get("class") or item.get("category") or "").casefold() + if not name or osm_type in excluded_types: + continue + + searchable = " ".join((name, display_name)).casefold() + matched_tokens = sum(token in searchable for token in tokens) + score = matched_tokens * 35 + if name.casefold() == query.casefold(): + score += 120 + elif name.casefold().startswith(query.casefold()): + score += 90 + elif query.casefold() in searchable: + score += 60 + if osm_type in venue_types: + score += 80 + if any(keyword in name.casefold() for keyword in venue_keywords): + score += 35 + if osm_class in {"amenity", "leisure", "tourism"}: + score += 20 + if score < 50: + continue + + osm_id = item.get("osm_id") + osm_kind = (item.get("osm_type") or "object").casefold() + external_id = f"{osm_kind}:{osm_id}" if osm_id is not None else None + ranked_results.append({ + "id": f"nominatim:{external_id}" if external_id else None, + "name": name, + "street": address.get("road") or address.get("pedestrian"), + "postal_code": address.get("postcode"), + "city": address.get("city") or address.get("town") or address.get("village") + or address.get("municipality") or address.get("county"), + "country": address.get("country"), + "latitude": float(item["lat"]) if item.get("lat") else None, + "longitude": float(item["lon"]) if item.get("lon") else None, + "external_id": external_id, + "source": "nominatim", + "local": False, + "verified": False, + "_score": score, + }) + except (httpx.HTTPError, ValueError) as error: + print(f"Nominatim search failed: {error}") + + unique_results = {} + for venue in sorted(ranked_results, key=lambda item: item["_score"], reverse=True): + key = venue_result_key(venue) + if key not in unique_results: + venue.pop("_score", None) + unique_results[key] = venue + + return list(unique_results.values())[:10] diff --git a/app/static/css/style.css b/app/static/css/style.css index 33cbfc3..010a072 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -264,10 +264,7 @@ form .button { } .venue-results { - position: absolute; - top: 100%; - left: 0; - right: 0; + position: relative; background: var(--surface); border: 1px solid var(--border); @@ -279,6 +276,31 @@ form .button { z-index: 50; } +.venue-manual-details { + margin-top: 10px; +} + +.venue-manual-details small { + color: var(--muted); +} + +.venue-address-grid { + display: grid; + grid-template-columns: 2fr 0.7fr 1.4fr 1.2fr; + gap: 8px; + margin-top: 6px; +} + +.venue-result-manual { + background: rgba(124, 58, 237, 0.08); +} + +@media (max-width: 700px) { + .venue-address-grid { + grid-template-columns: 1fr 1fr; + } +} + .venue-result { width: 100%; display: flex; diff --git a/app/templates/admin.html b/app/templates/admin.html index 8e9f9c6..67c223c 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -14,7 +14,16 @@ .role-form, .delete-form { margin: 0; display: flex; align-items: center; gap: 8px; } .role-form select { padding: 9px; background: #0b0f19; color: var(--text); border: 1px solid var(--border); border-radius: 8px; } .delete-button { padding: 9px 12px; color: #fecaca; border: 1px solid #7f1d1d; background: #450a0a; border-radius: 8px; cursor: pointer; } + .venue-list { display: grid; gap: 14px; } + .venue-card { padding: 16px; background: #0f1420; border: 1px solid var(--border); border-radius: 10px; } + .venue-fields { display: grid; grid-template-columns: 2fr 2fr .8fr 1.4fr 1.2fr; gap: 8px; } + .venue-fields input, .merge-form select { min-width: 0; width: 100%; } + .venue-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 10px; } + .venue-actions label { display: flex; align-items: center; gap: 6px; } + .venue-aliases { margin-top: 8px; } + .merge-form { display: grid; grid-template-columns: minmax(180px, 1fr) auto; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); } @media (max-width: 700px) { .user-row { grid-template-columns: 1fr; } .role-form, .delete-form { width: 100%; } } + @media (max-width: 900px) { .venue-fields { grid-template-columns: 1fr 1fr; } } @@ -64,6 +73,46 @@ {% endfor %} +
+

Veranstaltungsorte

+

Namen und Adressen korrigieren, Such-Aliase ergänzen oder Dubletten zusammenführen.

+
+ {% for venue in venues %} +
+
+
+ + + + + +
+ +
+ + Quelle: {{ venue.source }} · {{ venue.concert_count }} Konzert(e) · ID {{ venue.id }} + +
+
+ {% if venues|length > 1 %} +
+ + +
+ {% endif %} +
+ {% else %} +

Noch keine Veranstaltungsorte vorhanden.

+ {% endfor %} +
+
diff --git a/app/templates/edit_concert.html b/app/templates/edit_concert.html index 7d7ec57..40b463a 100644 --- a/app/templates/edit_concert.html +++ b/app/templates/edit_concert.html @@ -41,7 +41,11 @@

- +

+ +

diff --git a/app/templates/new_concert.html b/app/templates/new_concert.html index a15ccbd..8917f88 100644 --- a/app/templates/new_concert.html +++ b/app/templates/new_concert.html @@ -138,8 +138,10 @@

+
+ Optional für eine bessere Zuordnung: +
+ + + + +
+
+ - - - - - - - - - - { @@ -583,6 +541,8 @@ async function searchVenues( } ); + appendManualVenueResult(query, venues.length === 0); + } catch (error) { @@ -595,11 +555,38 @@ async function searchVenues( error ); + venueResults.innerHTML = ""; + appendManualVenueResult(query, true); + } } +function appendManualVenueResult(query, isOnlyResult) { + const result = document.createElement("button"); + result.type = "button"; + result.className = "venue-result venue-result-manual"; + result.innerHTML = ` + ✏️ „${escapeHtml(query)}“ verwenden + ${isOnlyResult ? "Keine passenden Treffer – Eingabe trotzdem speichern" : "Als eigenen Veranstaltungsort anlegen"} + `; + result.addEventListener("click", () => { + selectVenue({ + id: "", + name: query, + street: document.getElementById("venue-street").value, + postal_code: document.getElementById("venue-postal-code").value, + city: document.getElementById("venue-city").value, + country: document.getElementById("venue-country").value, + latitude: "", + longitude: "" + }); + }); + venueResults.appendChild(result); +} + + /* ============================================================ VENUE SELECT ============================================================ */ @@ -614,12 +601,6 @@ function selectVenue( venue.id ?? ""; - document.getElementById( - "venue-name" - ).value = - venue.name ?? ""; - - document.getElementById( "venue-city" ).value = diff --git a/db/init/01_initial.sql b/db/init/01_initial.sql index 246a619..72402b1 100644 --- a/db/init/01_initial.sql +++ b/db/init/01_initial.sql @@ -37,9 +37,17 @@ CREATE TABLE venues ( longitude DOUBLE PRECISION, external_id VARCHAR(255), source VARCHAR(50), + is_verified BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE venue_aliases ( + id SERIAL PRIMARY KEY, + venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE, + alias VARCHAR(255) NOT NULL, + UNIQUE (venue_id, alias) +); + CREATE TABLE concerts ( id SERIAL PRIMARY KEY, artist VARCHAR(255) NOT NULL, @@ -96,6 +104,9 @@ CREATE INDEX idx_venues_name CREATE INDEX idx_venues_external_id ON venues(external_id); +CREATE INDEX idx_venue_aliases_alias + ON venue_aliases (LOWER(alias)); + CREATE INDEX idx_concert_comments_concert ON concert_comments(concert_id, created_at); diff --git a/db/migrations/04_venue_curation.sql b/db/migrations/04_venue_curation.sql new file mode 100644 index 0000000..bd8294d --- /dev/null +++ b/db/migrations/04_venue_curation.sql @@ -0,0 +1,12 @@ +ALTER TABLE venues +ADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS venue_aliases ( + id SERIAL PRIMARY KEY, + venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE, + alias VARCHAR(255) NOT NULL, + UNIQUE (venue_id, alias) +); + +CREATE INDEX IF NOT EXISTS idx_venue_aliases_alias + ON venue_aliases (LOWER(alias));