- 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]
+26 -4
View File
@@ -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;
+49
View File
@@ -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; } }
</style>
</head>
<body>
@@ -64,6 +73,46 @@
{% endfor %}
</div>
</section>
<section class="admin-section" id="venues">
<h2>Veranstaltungsorte</h2>
<p>Namen und Adressen korrigieren, Such-Aliase ergänzen oder Dubletten zusammenführen.</p>
<div class="venue-list">
{% for venue in venues %}
<div class="venue-card">
<form method="post" action="/admin/venues/{{ venue.id }}">
<div class="venue-fields">
<input name="name" value="{{ venue.name }}" aria-label="Name" required>
<input name="street" value="{{ venue.street }}" placeholder="Straße" aria-label="Straße">
<input name="postal_code" value="{{ venue.postal_code }}" placeholder="PLZ" aria-label="PLZ">
<input name="city" value="{{ venue.city }}" placeholder="Ort" aria-label="Ort">
<input name="country" value="{{ venue.country }}" placeholder="Land" aria-label="Land">
</div>
<input class="venue-aliases" name="aliases" value="{{ venue.aliases }}" placeholder="Aliase, durch Komma getrennt" aria-label="Aliase">
<div class="venue-actions">
<label><input type="checkbox" name="is_verified" {% if venue.is_verified %}checked{% endif %}> bestätigt</label>
<span class="user-meta">Quelle: {{ venue.source }} · {{ venue.concert_count }} Konzert(e) · ID {{ venue.id }}</span>
<button class="button" type="submit">Ort speichern</button>
</div>
</form>
{% if venues|length > 1 %}
<form class="merge-form" method="post" action="/admin/venues/{{ venue.id }}/merge" onsubmit="return confirm('Dieser Ort wird gelöscht und alle Konzerte werden dem Zielort zugeordnet. Fortfahren?');">
<select name="target_venue_id" required aria-label="Zielort">
<option value="">Mit anderem Ort zusammenführen …</option>
{% for target in venues %}
{% if target.id != venue.id %}
<option value="{{ target.id }}">{{ target.name }}{% if target.city %}, {{ target.city }}{% endif %}</option>
{% endif %}
{% endfor %}
</select>
<button class="delete-button" type="submit">Zusammenführen</button>
</form>
{% endif %}
</div>
{% else %}
<p>Noch keine Veranstaltungsorte vorhanden.</p>
{% endfor %}
</div>
</section>
</main>
</body>
</html>
+5 -1
View File
@@ -41,7 +41,11 @@
<input type="text" name="city" value="{{ concert.venue.city or '' }}" placeholder="Ort">
</label>
</p>
<input type="hidden" name="country" value="{{ concert.venue.country or 'Deutschland' }}">
<p>
<label>Land<br>
<input type="text" name="country" value="{{ concert.venue.country or '' }}" placeholder="Land">
</label>
</p>
<input type="hidden" name="latitude" value="{{ concert.venue.latitude or '' }}">
<input type="hidden" name="longitude" value="{{ concert.venue.longitude or '' }}">
<p>
+42 -61
View File
@@ -138,8 +138,10 @@
<input
type="text"
id="venue-search"
name="venue_name"
autocomplete="off"
placeholder="z. B. Turbinenhalle"
required
>
<div
@@ -153,6 +155,16 @@
class="selected-venue"
></div>
<div class="venue-manual-details">
<small>Optional für eine bessere Zuordnung:</small>
<div class="venue-address-grid">
<input type="text" name="street" id="venue-street" placeholder="Straße">
<input type="text" name="postal_code" id="venue-postal-code" placeholder="PLZ">
<input type="text" name="city" id="venue-city" placeholder="Ort / Stadt">
<input type="text" name="country" id="venue-country" placeholder="Land">
</div>
</div>
<input
type="hidden"
@@ -160,36 +172,6 @@
id="venue-id"
>
<input
type="hidden"
name="venue_name"
id="venue-name"
>
<input
type="hidden"
name="city"
id="venue-city"
>
<input
type="hidden"
name="street"
id="venue-street"
>
<input
type="hidden"
name="postal_code"
id="venue-postal-code"
>
<input
type="hidden"
name="country"
id="venue-country"
>
<input
type="hidden"
name="latitude"
@@ -396,7 +378,6 @@ function clearVenueSelection() {
[
"venue-id",
"venue-name",
"venue-city",
"venue-street",
"venue-postal-code",
@@ -433,7 +414,6 @@ venueSearch.addEventListener(
clearVenueSelection();
if (query.length < 2) {
return;
@@ -482,7 +462,7 @@ async function searchVenues(
if (!response.ok) {
appendManualVenueResult(query, true);
return;
}
@@ -496,28 +476,6 @@ async function searchVenues(
"";
if (!venues.length) {
const empty =
document.createElement(
"div"
);
empty.className =
"venue-result";
empty.textContent =
"Keine Veranstaltungsorte gefunden";
venueResults.appendChild(
empty
);
return;
}
venues.forEach(
venue => {
@@ -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 = `
<strong>✏️ „${escapeHtml(query)}“ verwenden</strong>
<span>${isOnlyResult ? "Keine passenden Treffer Eingabe trotzdem speichern" : "Als eigenen Veranstaltungsort anlegen"}</span>
`;
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 =