- 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
|
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:
|
with get_db_connection() as connection:
|
||||||
@@ -610,6 +626,11 @@ def resolve_venue(
|
|||||||
longitude: str,
|
longitude: str,
|
||||||
):
|
):
|
||||||
selected_venue_id = None
|
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:
|
||||||
if venue_id.startswith("nominatim:"):
|
if venue_id.startswith("nominatim:"):
|
||||||
@@ -619,11 +640,11 @@ def resolve_venue(
|
|||||||
"""
|
"""
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM venues
|
FROM venues
|
||||||
WHERE external_id = %s
|
WHERE external_id IN (%s, %s)
|
||||||
AND source = 'nominatim'
|
AND source = 'nominatim'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(external_id,),
|
(external_id, external_id.rsplit(":", 1)[-1]),
|
||||||
)
|
)
|
||||||
existing_venue = cursor.fetchone()
|
existing_venue = cursor.fetchone()
|
||||||
|
|
||||||
@@ -651,7 +672,7 @@ def resolve_venue(
|
|||||||
street or None,
|
street or None,
|
||||||
postal_code or None,
|
postal_code or None,
|
||||||
city or None,
|
city or None,
|
||||||
country or "Deutschland",
|
country or None,
|
||||||
float(latitude) if latitude else None,
|
float(latitude) if latitude else None,
|
||||||
float(longitude) if longitude else None,
|
float(longitude) if longitude else None,
|
||||||
external_id,
|
external_id,
|
||||||
@@ -700,7 +721,7 @@ def resolve_venue(
|
|||||||
street or None,
|
street or None,
|
||||||
postal_code or None,
|
postal_code or None,
|
||||||
city or None,
|
city or None,
|
||||||
country or "Deutschland",
|
country or None,
|
||||||
float(latitude) if latitude else None,
|
float(latitude) if latitude else None,
|
||||||
float(longitude) if longitude else None,
|
float(longitude) if longitude else None,
|
||||||
),
|
),
|
||||||
@@ -719,6 +740,48 @@ def require_admin(request: Request):
|
|||||||
return user
|
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)
|
@app.get("/admin", response_class=HTMLResponse)
|
||||||
def admin_page(request: Request):
|
def admin_page(request: Request):
|
||||||
user = require_admin(request)
|
user = require_admin(request)
|
||||||
@@ -750,7 +813,12 @@ def admin_page(request: Request):
|
|||||||
]
|
]
|
||||||
|
|
||||||
template = templates.get_template("admin.html")
|
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)
|
@app.post("/admin/invites", response_class=HTMLResponse)
|
||||||
@@ -808,11 +876,108 @@ def create_invite(request: Request):
|
|||||||
return template.render(
|
return template.render(
|
||||||
user=user,
|
user=user,
|
||||||
users=users,
|
users=users,
|
||||||
|
venues=get_admin_venues(),
|
||||||
invite_url=f"{str(request.base_url).rstrip('/')}/register/{token}",
|
invite_url=f"{str(request.base_url).rstrip('/')}/register/{token}",
|
||||||
invite_id=invite_id,
|
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}")
|
@app.post("/admin/users/{user_id}")
|
||||||
def update_user_role(
|
def update_user_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1423,7 +1588,7 @@ async def create_concert(
|
|||||||
city: str = Form(""),
|
city: str = Form(""),
|
||||||
street: str = Form(""),
|
street: str = Form(""),
|
||||||
postal_code: str = Form(""),
|
postal_code: str = Form(""),
|
||||||
country: str = Form("Deutschland"),
|
country: str = Form(""),
|
||||||
latitude: str = Form(""),
|
latitude: str = Form(""),
|
||||||
longitude: str = Form(""),
|
longitude: str = Form(""),
|
||||||
start_datetime: str = Form(...),
|
start_datetime: str = Form(...),
|
||||||
@@ -1552,7 +1717,7 @@ async def edit_concert(
|
|||||||
city: str = Form(""),
|
city: str = Form(""),
|
||||||
street: str = Form(""),
|
street: str = Form(""),
|
||||||
postal_code: str = Form(""),
|
postal_code: str = Form(""),
|
||||||
country: str = Form("Deutschland"),
|
country: str = Form(""),
|
||||||
latitude: str = Form(""),
|
latitude: str = Form(""),
|
||||||
longitude: str = Form(""),
|
longitude: str = Form(""),
|
||||||
start_datetime: str = Form(""),
|
start_datetime: str = Form(""),
|
||||||
@@ -1839,8 +2004,7 @@ async def add_photo(
|
|||||||
# Venue search
|
# Venue search
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
@app.get("/api/venues/search")
|
def legacy_search_venues(q: str):
|
||||||
def search_venues(q: str):
|
|
||||||
|
|
||||||
q = q.strip()
|
q = q.strip()
|
||||||
|
|
||||||
@@ -1968,7 +2132,7 @@ def search_venues(q: str):
|
|||||||
"User-Agent": "PinguConcerts/1.0"
|
"User-Agent": "PinguConcerts/1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
external_query = re.sub(r"\bhall\b", "halle", q, flags=re.IGNORECASE)
|
external_query = q
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
|
|
||||||
@@ -1978,9 +2142,7 @@ def search_venues(q: str):
|
|||||||
|
|
||||||
"addressdetails": 1,
|
"addressdetails": 1,
|
||||||
|
|
||||||
"limit": 20,
|
"limit": 20
|
||||||
|
|
||||||
"countrycodes": "de"
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2310,3 +2472,173 @@ def search_venues(q: str):
|
|||||||
|
|
||||||
|
|
||||||
return results
|
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]
|
||||||
|
|||||||
@@ -264,10 +264,7 @@ form .button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.venue-results {
|
.venue-results {
|
||||||
position: absolute;
|
position: relative;
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -279,6 +276,31 @@ form .button {
|
|||||||
z-index: 50;
|
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 {
|
.venue-result {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -14,7 +14,16 @@
|
|||||||
.role-form, .delete-form { margin: 0; display: flex; align-items: center; gap: 8px; }
|
.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; }
|
.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; }
|
.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: 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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -64,6 +73,46 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -41,7 +41,11 @@
|
|||||||
<input type="text" name="city" value="{{ concert.venue.city or '' }}" placeholder="Ort">
|
<input type="text" name="city" value="{{ concert.venue.city or '' }}" placeholder="Ort">
|
||||||
</label>
|
</label>
|
||||||
</p>
|
</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="latitude" value="{{ concert.venue.latitude or '' }}">
|
||||||
<input type="hidden" name="longitude" value="{{ concert.venue.longitude or '' }}">
|
<input type="hidden" name="longitude" value="{{ concert.venue.longitude or '' }}">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -138,8 +138,10 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="venue-search"
|
id="venue-search"
|
||||||
|
name="venue_name"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
placeholder="z. B. Turbinenhalle"
|
placeholder="z. B. Turbinenhalle"
|
||||||
|
required
|
||||||
>
|
>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -153,6 +155,16 @@
|
|||||||
class="selected-venue"
|
class="selected-venue"
|
||||||
></div>
|
></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
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
@@ -160,36 +172,6 @@
|
|||||||
id="venue-id"
|
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
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
name="latitude"
|
name="latitude"
|
||||||
@@ -396,7 +378,6 @@ function clearVenueSelection() {
|
|||||||
|
|
||||||
[
|
[
|
||||||
"venue-id",
|
"venue-id",
|
||||||
"venue-name",
|
|
||||||
"venue-city",
|
"venue-city",
|
||||||
"venue-street",
|
"venue-street",
|
||||||
"venue-postal-code",
|
"venue-postal-code",
|
||||||
@@ -433,7 +414,6 @@ venueSearch.addEventListener(
|
|||||||
|
|
||||||
clearVenueSelection();
|
clearVenueSelection();
|
||||||
|
|
||||||
|
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -482,7 +462,7 @@ async function searchVenues(
|
|||||||
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
appendManualVenueResult(query, true);
|
||||||
return;
|
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(
|
venues.forEach(
|
||||||
venue => {
|
venue => {
|
||||||
|
|
||||||
@@ -583,6 +541,8 @@ async function searchVenues(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
appendManualVenueResult(query, venues.length === 0);
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
||||||
@@ -595,11 +555,38 @@ async function searchVenues(
|
|||||||
error
|
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
|
VENUE SELECT
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -614,12 +601,6 @@ function selectVenue(
|
|||||||
venue.id ?? "";
|
venue.id ?? "";
|
||||||
|
|
||||||
|
|
||||||
document.getElementById(
|
|
||||||
"venue-name"
|
|
||||||
).value =
|
|
||||||
venue.name ?? "";
|
|
||||||
|
|
||||||
|
|
||||||
document.getElementById(
|
document.getElementById(
|
||||||
"venue-city"
|
"venue-city"
|
||||||
).value =
|
).value =
|
||||||
|
|||||||
@@ -37,9 +37,17 @@ CREATE TABLE venues (
|
|||||||
longitude DOUBLE PRECISION,
|
longitude DOUBLE PRECISION,
|
||||||
external_id VARCHAR(255),
|
external_id VARCHAR(255),
|
||||||
source VARCHAR(50),
|
source VARCHAR(50),
|
||||||
|
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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 (
|
CREATE TABLE concerts (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
artist VARCHAR(255) NOT NULL,
|
artist VARCHAR(255) NOT NULL,
|
||||||
@@ -96,6 +104,9 @@ CREATE INDEX idx_venues_name
|
|||||||
CREATE INDEX idx_venues_external_id
|
CREATE INDEX idx_venues_external_id
|
||||||
ON venues(external_id);
|
ON venues(external_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_venue_aliases_alias
|
||||||
|
ON venue_aliases (LOWER(alias));
|
||||||
|
|
||||||
CREATE INDEX idx_concert_comments_concert
|
CREATE INDEX idx_concert_comments_concert
|
||||||
ON concert_comments(concert_id, created_at);
|
ON concert_comments(concert_id, created_at);
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
Reference in New Issue
Block a user