- Freitext wird jetzt direkt als venue_name übertragen und somit auch ohne Suchtreffer gespeichert.

- Die Suche ist international und kombiniert lokale Ergebnisse mit Nominatim.
  - Externe IDs werden korrekt als nominatim:<Typ>:<ID> gespeichert und dedupliziert.
  - Straße, PLZ, Stadt und Land können beim Anlegen manuell ergänzt werden.
  - Im Adminbereich können Orte korrigiert, bestätigt, mit Aliasen versehen und zusammengeführt werden.
This commit is contained in:
kai
2026-08-26 10:02:52 +02:00
parent caddac9eab
commit 4035176370
7 changed files with 490 additions and 79 deletions
+345 -13
View File
@@ -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("<h1>Nicht erlaubt</h1>", status_code=403)
name = name.strip()
if not name:
return HTMLResponse("<h1>Der Name darf nicht leer sein.</h1>", 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("<h1>Nicht erlaubt</h1>", status_code=403)
if venue_id == target_venue_id:
return HTMLResponse("<h1>Ein Ort kann nicht mit sich selbst zusammengeführt werden.</h1>", 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("<h1>Veranstaltungsort nicht gefunden.</h1>", 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]