- 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:
+345
-13
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user