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 %}
+
+
+ {% if venues|length > 1 %}
+
+ {% endif %}
+
+ {% else %}
+
Noch keine Veranstaltungsorte vorhanden.
+ {% endfor %}
+
+