import os
import uuid
import secrets
import hashlib
import re
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
import bcrypt
import httpx
import psycopg
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
DATABASE_URL = os.environ["DATABASE_URL"]
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.join(
BASE_DIR,
"static",
"uploads",
"flyers"
)
PHOTO_DIR = os.path.join(
BASE_DIR,
"static",
"uploads",
"photos"
)
AVATAR_DIR = os.path.join(
BASE_DIR,
"static",
"uploads",
"avatars"
)
PATCH_DIR = os.path.join(
BASE_DIR,
"static",
"uploads",
"patches"
)
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(PHOTO_DIR, exist_ok=True)
os.makedirs(AVATAR_DIR, exist_ok=True)
os.makedirs(PATCH_DIR, exist_ok=True)
SESSION_COOKIE = "pingu_session"
SESSION_DAYS = 30
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
MAX_IMAGE_BYTES = 10 * 1024 * 1024
INITIAL_ADMIN_USERNAME = os.environ.get("INITIAL_ADMIN_USERNAME")
INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD")
INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL")
BADGE_DEFINITIONS = (
("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
("regular", "Stammgast", "🤘", 5, "5 Konzerte besucht", "attendance"),
("ten_gigs", "Zehnerrunde", "🔥", 10, "10 Konzerte besucht", "attendance"),
("tour_veteran", "Tourveteran", "⚡", 25, "25 Konzerte besucht", "attendance"),
)
ATTENDANCE_BADGE_CODES = tuple(
badge_code
for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
if category == "attendance"
)
BETA_REGISTRATION_DEADLINE = datetime(2026, 9, 16)
BADGE_BY_CODE = {
badge_code: (name, icon, threshold, description, category)
for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS
}
def get_db_connection():
return psycopg.connect(DATABASE_URL)
def ensure_schema():
statements = [
"""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name VARCHAR(100),
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE
""",
"""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS avatar_path TEXT
""",
"""
CREATE TABLE IF NOT EXISTS registration_invites (
id SERIAL PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP,
used_by INTEGER REFERENCES users(id),
used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
ALTER TABLE concerts
ADD COLUMN IF NOT EXISTS created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
""",
"""
CREATE TABLE IF NOT EXISTS concert_comments (
id SERIAL PRIMARY KEY,
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS concert_photos (
id SERIAL PRIMARY KEY,
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
path TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""",
"""
CREATE TABLE IF NOT EXISTS concert_attendance (
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL CHECK (
status IN ('attending', 'maybe', 'ticket_search')
),
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (concert_id, user_id)
)
""",
"""
CREATE TABLE IF NOT EXISTS user_badges (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
badge_code VARCHAR(50) NOT NULL,
awarded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, badge_code)
)
""",
"""
CREATE TABLE IF NOT EXISTS badge_assets (
badge_code VARCHAR(50) PRIMARY KEY,
path TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
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 connection.cursor() as cursor:
for statement in statements:
cursor.execute(statement)
if INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD:
cursor.execute(
"SELECT id FROM users WHERE username = %s",
(INITIAL_ADMIN_USERNAME,),
)
existing_user = cursor.fetchone()
if not existing_user:
cursor.execute(
"""
INSERT INTO users (
username,
email,
password_hash,
display_name,
is_admin
)
VALUES (%s, %s, %s, %s, TRUE)
""",
(
INITIAL_ADMIN_USERNAME,
INITIAL_ADMIN_EMAIL
or f"{INITIAL_ADMIN_USERNAME}@local.invalid",
bcrypt.hashpw(
INITIAL_ADMIN_PASSWORD.encode("utf-8"),
bcrypt.gensalt(),
).decode("utf-8"),
INITIAL_ADMIN_USERNAME,
),
)
connection.commit()
@asynccontextmanager
async def lifespan(_app: FastAPI):
ensure_schema()
yield
app = FastAPI(title="Pingu Concerts", lifespan=lifespan)
@app.middleware("http")
async def require_login(request: Request, call_next):
if (
request.url.path == "/login"
or request.url.path == "/register"
or request.url.path.startswith("/register/")
or request.url.path.startswith("/static/")
):
return await call_next(request)
if get_current_user(request):
return await call_next(request)
return login_redirect("/")
# ============================================================
# Templates
# ============================================================
templates = Environment(
loader=FileSystemLoader(
os.path.join(BASE_DIR, "templates")
),
autoescape=select_autoescape(["html"])
)
# ============================================================
# Static files
# ============================================================
app.mount(
"/static",
StaticFiles(
directory=os.path.join(BASE_DIR, "static")
),
name="static"
)
# ============================================================
# Templates
# ============================================================
def hash_token(token: str) -> str:
return hashlib.sha256(
token.encode("utf-8")
).hexdigest()
def generate_token() -> str:
return secrets.token_urlsafe(32)
def user_from_row(row):
if not row:
return None
return {
"id": row[0],
"username": row[1],
"display_name": row[2] or row[1],
"is_admin": bool(row[3]),
}
def get_current_user(request: Request):
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
users.id,
users.username,
users.display_name,
users.is_admin
FROM sessions
JOIN users
ON users.id = sessions.user_id
WHERE sessions.token_hash = %s
AND sessions.expires_at > CURRENT_TIMESTAMP
""",
(hash_token(token),),
)
row = cursor.fetchone()
return user_from_row(row)
def create_session(user_id: int) -> str:
token = generate_token()
expires_at = datetime.now() + timedelta(days=SESSION_DAYS)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO sessions (
user_id,
token_hash,
expires_at
)
VALUES (%s, %s, %s)
""",
(user_id, hash_token(token), expires_at),
)
connection.commit()
return token
def attach_session(response: RedirectResponse, token: str) -> RedirectResponse:
response.set_cookie(
SESSION_COOKIE,
token,
max_age=SESSION_DAYS * 24 * 60 * 60,
httponly=True,
samesite="lax",
)
return response
def login_redirect(next_path: str = "/"):
return RedirectResponse(
f"/login?next={next_path}",
status_code=303,
)
def concert_is_past(start_datetime, end_datetime) -> bool:
end = end_datetime or start_datetime
return end < datetime.now()
def load_concert(concert_id: int):
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
concerts.id,
concerts.artist,
concerts.start_datetime,
concerts.end_datetime,
concerts.description,
concerts.ticket_url,
concerts.ticket_price,
concerts.flyer_path,
concerts.created_by,
venues.id,
venues.name,
venues.street,
venues.postal_code,
venues.city,
venues.country,
venues.latitude,
venues.longitude
FROM concerts
LEFT JOIN venues
ON concerts.venue_id = venues.id
WHERE concerts.id = %s
""",
(concert_id,),
)
row = cursor.fetchone()
if not row:
return None
start_datetime = row[2]
end_datetime = row[3]
is_past = concert_is_past(start_datetime, end_datetime)
return {
"id": row[0],
"artist": row[1],
"start_datetime": start_datetime,
"end_datetime": end_datetime,
"description": row[4],
"ticket_url": row[5],
"ticket_price": row[6],
"flyer_path": row[7],
"created_by": row[8],
"is_past": is_past,
"date": start_datetime.strftime("%d.%m.%Y"),
"time": start_datetime.strftime("%H:%M"),
"start_local": start_datetime.strftime("%Y-%m-%dT%H:%M"),
"end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None,
"end_time": end_datetime.strftime("%H:%M") if end_datetime else None,
"end_local": end_datetime.strftime("%Y-%m-%dT%H:%M") if end_datetime else "",
"venue": {
"id": row[9],
"name": row[10] or "Veranstaltungsort unbekannt",
"street": row[11],
"postal_code": row[12],
"city": row[13],
"country": row[14],
"latitude": row[15],
"longitude": row[16],
},
}
def can_edit_title(user, concert) -> bool:
if not user:
return False
if concert["is_past"]:
return user["is_admin"]
return user["is_admin"] or user["id"] == concert["created_by"]
def can_edit_details(user, concert) -> bool:
if not user:
return False
if concert["is_past"]:
return user["is_admin"]
return True
def can_edit_concert(user, concert) -> bool:
return can_edit_title(user, concert) or can_edit_details(user, concert)
def can_delete_concert(user, concert) -> bool:
if not user:
return False
if concert["is_past"]:
return user["is_admin"]
return user["is_admin"] or user["id"] == concert["created_by"]
def serialize_concert_card(row):
concert_id, artist, start_datetime, venue, city = row
venue_text = venue or "Veranstaltungsort unbekannt"
if city:
venue_text += f", {city}"
return {
"id": concert_id,
"artist": artist,
"date": start_datetime.strftime("%d.%m.%Y"),
"time": start_datetime.strftime("%H:%M"),
"venue": venue_text,
}
def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
if not upload or not upload.filename:
return None, None
extension = os.path.splitext(upload.filename)[1].lower()
if extension not in ALLOWED_IMAGE_EXTENSIONS:
return None, HTMLResponse(
"Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP.",
status_code=400,
)
filename = str(uuid.uuid4()) + extension
destination = os.path.join(destination_dir, filename)
contents = upload.file.read()
if len(contents) > MAX_IMAGE_BYTES:
return None, HTMLResponse(
"Die Datei darf maximal 10 MB groß sein.",
status_code=400,
)
with open(destination, "wb") as file:
file.write(contents)
return f"{url_prefix}{filename}", None
def attended_concert_count(user_id: int) -> int:
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT COUNT(*)
FROM concert_attendance
JOIN concerts ON concerts.id = concert_attendance.concert_id
WHERE concert_attendance.user_id = %s
AND concert_attendance.status = 'attending'
AND COALESCE(
concerts.end_datetime,
concerts.start_datetime
) < CURRENT_TIMESTAMP
""",
(user_id,),
)
return cursor.fetchone()[0]
def grant_earned_badges(user_id: int, attended_count: int, registered_at):
highest_attendance_badge = None
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
if category == "attendance" and attended_count >= threshold:
highest_attendance_badge = badge_code
with get_db_connection() as connection:
with connection.cursor() as cursor:
if registered_at < BETA_REGISTRATION_DEADLINE:
cursor.execute(
"""
INSERT INTO user_badges (user_id, badge_code)
VALUES (%s, 'beta_tester')
ON CONFLICT (user_id, badge_code) DO NOTHING
""",
(user_id,),
)
if highest_attendance_badge:
cursor.execute(
"DELETE FROM user_badges WHERE user_id = %s AND badge_code = ANY(%s)",
(user_id, list(ATTENDANCE_BADGE_CODES)),
)
cursor.execute(
"""
INSERT INTO user_badges (user_id, badge_code)
VALUES (%s, %s)
ON CONFLICT (user_id, badge_code) DO NOTHING
""",
(user_id, highest_attendance_badge),
)
connection.commit()
def load_badge_assets():
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT badge_code, path FROM badge_assets")
return {row[0]: row[1] for row in cursor.fetchall()}
def load_profile(username: str):
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, username, display_name, avatar_path, created_at
FROM users
WHERE LOWER(username) = LOWER(%s)
""",
(username,),
)
row = cursor.fetchone()
if not row:
return None
cursor.execute(
"SELECT badge_code FROM user_badges WHERE user_id = %s",
(row[0],),
)
earned_codes = {badge_row[0] for badge_row in cursor.fetchall()}
return {
"id": row[0],
"username": row[1],
"display_name": row[2] or row[1],
"avatar_path": row[3],
"created_at": row[4].strftime("%d.%m.%Y"),
"registered_at": row[4],
"earned_codes": earned_codes,
}
def resolve_venue(
cursor,
venue_id: str,
venue_name: str,
city: str,
street: str,
postal_code: str,
country: str,
latitude: str,
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:"):
external_id = venue_id.split(":", 1)[1]
cursor.execute(
"""
SELECT id
FROM venues
WHERE external_id IN (%s, %s)
AND source = 'nominatim'
LIMIT 1
""",
(external_id, external_id.rsplit(":", 1)[-1]),
)
existing_venue = cursor.fetchone()
if existing_venue:
selected_venue_id = existing_venue[0]
else:
cursor.execute(
"""
INSERT INTO venues (
name,
street,
postal_code,
city,
country,
latitude,
longitude,
external_id,
source
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
venue_name,
street or None,
postal_code or None,
city or None,
country or None,
float(latitude) if latitude else None,
float(longitude) if longitude else None,
external_id,
"nominatim",
),
)
selected_venue_id = cursor.fetchone()[0]
else:
try:
selected_venue_id = int(venue_id)
except ValueError:
selected_venue_id = None
if not selected_venue_id and venue_name:
cursor.execute(
"""
SELECT id
FROM venues
WHERE LOWER(name) = LOWER(%s)
AND LOWER(COALESCE(city, '')) = LOWER(%s)
LIMIT 1
""",
(venue_name, city),
)
existing_venue = cursor.fetchone()
if existing_venue:
selected_venue_id = existing_venue[0]
else:
cursor.execute(
"""
INSERT INTO venues (
name,
street,
postal_code,
city,
country,
latitude,
longitude
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
venue_name,
street or None,
postal_code or None,
city or None,
country or None,
float(latitude) if latitude else None,
float(longitude) if longitude else None,
),
)
selected_venue_id = cursor.fetchone()[0]
return selected_venue_id
def require_admin(request: Request):
user = get_current_user(request)
if not user or not user["is_admin"]:
return None
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")
def admin_page(request: Request):
if not require_admin(request):
return HTMLResponse("
Nicht erlaubt
", status_code=403)
return RedirectResponse("/admin/users", status_code=303)
@app.get("/admin/users", response_class=HTMLResponse)
def admin_users_page(request: Request):
user = require_admin(request)
if not user:
return HTMLResponse("Nicht erlaubt
", status_code=403)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, username, email, display_name, is_admin, created_at
FROM users
ORDER BY is_admin DESC, username ASC
"""
)
rows = cursor.fetchall()
users = [
{
"id": row[0],
"username": row[1],
"email": row[2],
"display_name": row[3] or row[1],
"is_admin": bool(row[4]),
"created_at": row[5].strftime("%d.%m.%Y"),
}
for row in rows
]
template = templates.get_template("admin_users.html")
return template.render(
user=user,
users=users,
invite_url=None,
)
@app.get("/admin/venues", response_class=HTMLResponse)
def admin_venues_page(request: Request):
user = require_admin(request)
if not user:
return HTMLResponse("Nicht erlaubt
", status_code=403)
template = templates.get_template("admin_venues.html")
return template.render(user=user, venues=get_admin_venues())
@app.get("/admin/patches", response_class=HTMLResponse)
def admin_patches_page(request: Request):
user = require_admin(request)
if not user:
return HTMLResponse("Nicht erlaubt
", status_code=403)
assets = load_badge_assets()
patches = [
{
"code": code,
"name": name,
"icon": icon,
"description": description,
"image_path": assets.get(code),
}
for code, name, icon, _threshold, description, _category in BADGE_DEFINITIONS
]
template = templates.get_template("admin_patches.html")
return template.render(user=user, patches=patches)
@app.post("/admin/invites", response_class=HTMLResponse)
def create_invite(request: Request):
user = require_admin(request)
if not user:
return HTMLResponse("Nicht erlaubt
", status_code=403)
token = secrets.token_urlsafe(32)
token_hash = hash_token(token)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
INSERT INTO registration_invites (
token_hash
)
VALUES (%s)
RETURNING id
""", (
token_hash,
))
invite_id = cursor.fetchone()[0]
connection.commit()
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, username, email, display_name, is_admin, created_at
FROM users
ORDER BY is_admin DESC, username ASC
"""
)
rows = cursor.fetchall()
users = [
{
"id": row[0],
"username": row[1],
"email": row[2],
"display_name": row[3] or row[1],
"is_admin": bool(row[4]),
"created_at": row[5].strftime("%d.%m.%Y"),
}
for row in rows
]
template = templates.get_template("admin_users.html")
return template.render(
user=user,
users=users,
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/venues/{venue_id}/delete")
def delete_venue(request: Request, venue_id: int):
if not require_admin(request):
return HTMLResponse("Nicht erlaubt
", status_code=403)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM venues WHERE id = %s", (venue_id,))
connection.commit()
return RedirectResponse("/admin/venues", status_code=303)
def remove_patch_file(path: str | None):
if not path:
return
filename = os.path.basename(path)
file_path = os.path.join(PATCH_DIR, filename)
if os.path.isfile(file_path):
os.remove(file_path)
@app.post("/admin/patches/{badge_code}")
async def update_patch_image(
request: Request,
badge_code: str,
image: UploadFile | None = File(None),
):
user = require_admin(request)
valid_codes = {definition[0] for definition in BADGE_DEFINITIONS}
if not user:
return HTMLResponse("Nicht erlaubt
", status_code=403)
if badge_code not in valid_codes:
return HTMLResponse("Patch nicht gefunden
", status_code=404)
image_path, error = save_image(image, PATCH_DIR, "/static/uploads/patches/")
if error:
return error
if not image_path:
return HTMLResponse("Bitte ein Bild auswählen.
", status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT path FROM badge_assets WHERE badge_code = %s", (badge_code,))
old_asset = cursor.fetchone()
cursor.execute(
"""
INSERT INTO badge_assets (badge_code, path, updated_by, updated_at)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (badge_code) DO UPDATE
SET path = EXCLUDED.path, updated_by = EXCLUDED.updated_by,
updated_at = CURRENT_TIMESTAMP
""",
(badge_code, image_path, user["id"]),
)
connection.commit()
if old_asset:
remove_patch_file(old_asset[0])
return RedirectResponse("/admin/patches", status_code=303)
@app.post("/admin/patches/{badge_code}/delete")
def delete_patch_image(request: Request, badge_code: str):
if not require_admin(request):
return HTMLResponse("Nicht erlaubt
", status_code=403)
if badge_code not in {definition[0] for definition in BADGE_DEFINITIONS}:
return HTMLResponse("Patch nicht gefunden
", status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM badge_assets WHERE badge_code = %s RETURNING path", (badge_code,))
deleted_asset = cursor.fetchone()
connection.commit()
if deleted_asset:
remove_patch_file(deleted_asset[0])
return RedirectResponse("/admin/patches", status_code=303)
@app.post("/admin/users/{user_id}")
def update_user_role(
request: Request,
user_id: int,
role: str = Form(...),
):
user = require_admin(request)
if not user:
return HTMLResponse("Nicht erlaubt
", status_code=403)
if role not in {"admin", "user"}:
return HTMLResponse("Ungültige Rolle
", status_code=400)
if user_id == user["id"] and role != "admin":
return HTMLResponse(
"Die eigenen Adminrechte können nicht entfernt werden.
",
status_code=400,
)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"UPDATE users SET is_admin = %s WHERE id = %s",
(role == "admin", user_id),
)
connection.commit()
return RedirectResponse("/admin/users", status_code=303)
@app.post("/admin/users/{user_id}/delete")
def delete_user(request: Request, user_id: int):
user = require_admin(request)
if not user:
return HTMLResponse("Nicht erlaubt
", status_code=403)
if user_id == user["id"]:
return HTMLResponse(
"Der eigene Account kann nicht gelöscht werden.
",
status_code=400,
)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
connection.commit()
return RedirectResponse("/admin/users", status_code=303)
@app.post("/register")
def register_user(
token: str = Form(...),
username: str = Form(...),
display_name: str = Form(""),
email: str = Form(...),
password: str = Form(...)
):
username = username.strip()
display_name = display_name.strip()
email = email.strip().lower()
if len(username) < 3:
return HTMLResponse(
"Fehler
Der Benutzername muss mindestens 3 Zeichen lang sein.
",
status_code=400
)
if len(password) < 8:
return HTMLResponse(
"Fehler
Das Passwort muss mindestens 8 Zeichen lang sein.
",
status_code=400
)
with get_db_connection() as connection:
with connection.cursor() as cursor:
# Einladung prüfen
cursor.execute("""
SELECT
id,
expires_at,
used_at
FROM registration_invites
WHERE token_hash = %s
""", (
hash_token(token),
))
invite = cursor.fetchone()
if not invite:
return HTMLResponse(
"Ungültige Einladung
",
status_code=404
)
invite_id, expires_at, used_at = invite
if used_at:
return HTMLResponse(
"Diese Einladung wurde bereits verwendet.
",
status_code=410
)
if expires_at and datetime.now() > expires_at:
return HTMLResponse(
"Diese Einladung ist abgelaufen.
",
status_code=410
)
# Prüfen ob Username bereits existiert
cursor.execute("""
SELECT id
FROM users
WHERE LOWER(username) = LOWER(%s)
""", (
username,
))
if cursor.fetchone():
return HTMLResponse(
"Fehler
Dieser Benutzername ist bereits vergeben.
",
status_code=400
)
# Prüfen ob E-Mail bereits existiert
cursor.execute("""
SELECT id
FROM users
WHERE LOWER(email) = LOWER(%s)
""", (
email,
))
if cursor.fetchone():
return HTMLResponse(
"Fehler
Diese E-Mail-Adresse ist bereits registriert.
",
status_code=400
)
password_hash = bcrypt.hashpw(
password.encode("utf-8"),
bcrypt.gensalt()
).decode("utf-8")
cursor.execute("SELECT COUNT(*) FROM users")
is_first_user = cursor.fetchone()[0] == 0
cursor.execute("""
INSERT INTO users (
username,
email,
password_hash,
display_name,
is_admin
)
VALUES (
%s,
%s,
%s,
%s,
%s
)
RETURNING id
""", (
username,
email,
password_hash,
display_name or username,
is_first_user
))
user_id = cursor.fetchone()[0]
# Einladung verbrauchen
cursor.execute("""
UPDATE registration_invites
SET
used_by = %s,
used_at = CURRENT_TIMESTAMP
WHERE id = %s
""", (
user_id,
invite_id
))
connection.commit()
response = RedirectResponse("/", status_code=303)
return attach_session(response, create_session(user_id))
# ============================================================
# Registration
# ============================================================
@app.get(
"/register/{token}",
response_class=HTMLResponse
)
def register_page(token: str):
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT
id,
expires_at,
used_at
FROM registration_invites
WHERE token_hash = %s
""", (
hash_token(token),
))
invite = cursor.fetchone()
if not invite:
return HTMLResponse(
"Ungültige Einladung
",
status_code=404
)
invite_id, expires_at, used_at = invite
if used_at:
return HTMLResponse(
"Diese Einladung wurde bereits verwendet.
",
status_code=410
)
if expires_at:
from datetime import datetime
if datetime.now() > expires_at:
return HTMLResponse(
"Diese Einladung ist abgelaufen.
",
status_code=410
)
template = templates.get_template(
"register.html"
)
return template.render(
token=token
)
# ============================================================
# Login
# ============================================================
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request, next: str = "/"):
if get_current_user(request):
return RedirectResponse(next or "/", status_code=303)
template = templates.get_template("login.html")
return template.render(next_path=next or "/", error=None)
@app.post("/login")
def login(
username: str = Form(...),
password: str = Form(...),
next: str = Form("/"),
):
username = username.strip()
next_path = next if next.startswith("/") else "/"
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, password_hash
FROM users
WHERE LOWER(username) = LOWER(%s)
OR LOWER(email) = LOWER(%s)
""",
(username, username),
)
row = cursor.fetchone()
if not row or not bcrypt.checkpw(
password.encode("utf-8"),
row[1].encode("utf-8"),
):
template = templates.get_template("login.html")
return HTMLResponse(
template.render(
next_path=next_path,
error="Benutzername oder Passwort ist falsch.",
),
status_code=401,
)
response = RedirectResponse(next_path, status_code=303)
return attach_session(response, create_session(row[0]))
@app.post("/logout")
def logout(request: Request):
token = request.cookies.get(SESSION_COOKIE)
if token:
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"DELETE FROM sessions WHERE token_hash = %s",
(hash_token(token),),
)
connection.commit()
response = RedirectResponse("/", status_code=303)
response.delete_cookie(SESSION_COOKIE)
return response
# ============================================================
# Home
# ============================================================
@app.get("/", response_class=HTMLResponse)
def home(request: Request):
concert_query = """
SELECT
concerts.id,
concerts.artist,
concerts.start_datetime,
venues.name,
venues.city
FROM concerts
LEFT JOIN venues
ON concerts.venue_id = venues.id
WHERE COALESCE(concerts.end_datetime, concerts.start_datetime)
{operator} CURRENT_TIMESTAMP
ORDER BY concerts.start_datetime {direction}
"""
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
concert_query.format(operator=">=", direction="ASC")
)
upcoming_rows = cursor.fetchall()
cursor.execute(
concert_query.format(operator="<", direction="DESC")
)
past_rows = cursor.fetchall()
template = templates.get_template("index.html")
return template.render(
user=get_current_user(request),
upcoming_concerts=[serialize_concert_card(row) for row in upcoming_rows],
past_concerts=[serialize_concert_card(row) for row in past_rows],
)
# ============================================================
# Profiles
# ============================================================
def render_profile(request: Request, username: str):
viewer = get_current_user(request)
profile = load_profile(username)
if not profile:
return HTMLResponse("Benutzer nicht gefunden
", status_code=404)
attended_count = attended_concert_count(profile["id"])
grant_earned_badges(
profile["id"],
attended_count,
profile["registered_at"],
)
profile = load_profile(username)
badge_assets = load_badge_assets()
badges = [
{
"code": code,
"name": name,
"icon": icon,
"threshold": threshold,
"description": description,
"category": category,
"earned": code in profile["earned_codes"],
"image_path": badge_assets.get(code),
}
for code, name, icon, threshold, description, category in BADGE_DEFINITIONS
]
template = templates.get_template("profile.html")
return template.render(
user=viewer,
profile=profile,
attended_count=attended_count,
badges=badges,
is_own_profile=viewer["id"] == profile["id"],
)
@app.get("/profile", response_class=HTMLResponse)
def own_profile(request: Request):
user = get_current_user(request)
return render_profile(request, user["username"])
@app.get("/users/{username}", response_class=HTMLResponse)
def user_profile(request: Request, username: str):
return render_profile(request, username)
@app.post("/profile")
async def update_profile(
request: Request,
display_name: str = Form(""),
avatar: UploadFile | None = File(None),
):
user = get_current_user(request)
avatar_path, error = save_image(
avatar,
AVATAR_DIR,
"/static/uploads/avatars/",
)
if error:
return error
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
UPDATE users
SET
display_name = COALESCE(NULLIF(%s, ''), display_name),
avatar_path = COALESCE(%s, avatar_path)
WHERE id = %s
""",
(display_name.strip(), avatar_path, user["id"]),
)
connection.commit()
return RedirectResponse("/profile", status_code=303)
# ============================================================
# New concert
# ============================================================
@app.get(
"/concerts/new",
response_class=HTMLResponse
)
def new_concert(request: Request):
user = get_current_user(request)
if not user:
return login_redirect("/concerts/new")
template = templates.get_template("new_concert.html")
return template.render(user=user)
# ============================================================
# Concert detail
# ============================================================
@app.get(
"/concerts/{concert_id}",
response_class=HTMLResponse
)
def concert_detail(request: Request, concert_id: int):
concert = load_concert(concert_id)
if not concert:
return HTMLResponse(
"Konzert nicht gefunden
",
status_code=404
)
user = get_current_user(request)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT
concert_comments.id,
concert_comments.body,
concert_comments.created_at,
users.display_name,
users.username
FROM concert_comments
JOIN users
ON users.id = concert_comments.user_id
WHERE concert_comments.concert_id = %s
ORDER BY concert_comments.created_at ASC
""",
(concert_id,),
)
comment_rows = cursor.fetchall()
cursor.execute(
"""
SELECT
concert_photos.id,
concert_photos.path,
concert_photos.created_at,
users.display_name,
users.username
FROM concert_photos
JOIN users
ON users.id = concert_photos.user_id
WHERE concert_photos.concert_id = %s
ORDER BY concert_photos.created_at DESC
""",
(concert_id,),
)
photo_rows = cursor.fetchall()
cursor.execute(
"""
SELECT
concert_attendance.user_id,
concert_attendance.status,
users.display_name,
users.username
FROM concert_attendance
JOIN users ON users.id = concert_attendance.user_id
WHERE concert_attendance.concert_id = %s
ORDER BY users.display_name NULLS LAST, users.username
""",
(concert_id,),
)
attendance_rows = cursor.fetchall()
comments = [
{
"id": row[0],
"body": row[1],
"created_at": row[2].strftime("%d.%m.%Y %H:%M"),
"author": row[3] or row[4],
"username": row[4],
}
for row in comment_rows
]
photos = [
{
"id": row[0],
"path": row[1],
"created_at": row[2].strftime("%d.%m.%Y %H:%M"),
"author": row[3] or row[4],
}
for row in photo_rows
]
attendance = [
{
"user_id": row[0],
"status": row[1],
"name": row[2] or row[3],
"username": row[3],
}
for row in attendance_rows
]
attending_users = [item for item in attendance if item["status"] == "attending"]
ticket_seekers = [item for item in attendance if item["status"] == "ticket_search"]
maybe_users = [item for item in attendance if item["status"] == "maybe"]
current_attendance = next(
(item["status"] for item in attendance if item["user_id"] == user["id"]),
None,
)
template = templates.get_template("concert_detail.html")
return template.render(
user=user,
concert=concert,
comments=comments,
photos=photos,
can_edit=can_edit_concert(user, concert),
can_delete=can_delete_concert(user, concert),
current_attendance=current_attendance,
attending_users=attending_users,
attending_count=len(attending_users),
ticket_seekers=ticket_seekers,
ticket_seeker_count=len(ticket_seekers),
maybe_users=maybe_users,
maybe_count=len(maybe_users),
)
# ============================================================
# Create concert
# ============================================================
@app.post("/concerts")
async def create_concert(
request: Request,
artist: str = Form(...),
venue_id: str = Form(""),
venue_name: str = Form(""),
city: str = Form(""),
street: str = Form(""),
postal_code: str = Form(""),
country: str = Form(""),
latitude: str = Form(""),
longitude: str = Form(""),
start_datetime: str = Form(...),
end_datetime: str = Form(""),
description: str = Form(""),
ticket_url: str = Form(""),
ticket_price: str = Form(""),
flyer: UploadFile | None = File(None)
):
user = get_current_user(request)
if not user:
return login_redirect("/concerts/new")
flyer_path, error = save_image(
flyer,
UPLOAD_DIR,
"/static/uploads/flyers/",
)
if error:
return error
with get_db_connection() as connection:
with connection.cursor() as cursor:
selected_venue_id = resolve_venue(
cursor,
venue_id,
venue_name,
city,
street,
postal_code,
country,
latitude,
longitude,
)
cursor.execute(
"""
INSERT INTO concerts (
artist,
venue_id,
start_datetime,
end_datetime,
description,
ticket_url,
ticket_price,
flyer_path,
created_by
)
VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s
)
RETURNING id
""",
(
artist,
selected_venue_id,
start_datetime,
end_datetime or None,
description or None,
ticket_url or None,
ticket_price or None,
flyer_path,
user["id"],
),
)
concert_id = cursor.fetchone()[0]
connection.commit()
return RedirectResponse(
f"/concerts/{concert_id}",
status_code=303
)
# ============================================================
# Edit concert
# ============================================================
@app.get(
"/concerts/{concert_id}/edit",
response_class=HTMLResponse
)
def edit_concert_page(request: Request, concert_id: int):
user = get_current_user(request)
if not user:
return login_redirect(f"/concerts/{concert_id}/edit")
concert = load_concert(concert_id)
if not concert:
return HTMLResponse(
"Konzert nicht gefunden
",
status_code=404
)
if not can_edit_concert(user, concert):
return HTMLResponse(
"Vergangene Veranstaltungen dürfen nur Admins bearbeiten.
",
status_code=403
)
template = templates.get_template("edit_concert.html")
return template.render(
user=user,
concert=concert,
can_edit_title=can_edit_title(user, concert),
can_edit_details=can_edit_details(user, concert),
can_delete=can_delete_concert(user, concert),
)
@app.post("/concerts/{concert_id}/edit")
async def edit_concert(
request: Request,
concert_id: int,
artist: str = Form(""),
venue_id: str = Form(""),
venue_name: str = Form(""),
city: str = Form(""),
street: str = Form(""),
postal_code: str = Form(""),
country: str = Form(""),
latitude: str = Form(""),
longitude: str = Form(""),
start_datetime: str = Form(""),
end_datetime: str = Form(""),
description: str = Form(""),
ticket_url: str = Form(""),
ticket_price: str = Form(""),
flyer: UploadFile | None = File(None)
):
user = get_current_user(request)
if not user:
return login_redirect(f"/concerts/{concert_id}/edit")
concert = load_concert(concert_id)
if not concert:
return HTMLResponse(
"Konzert nicht gefunden
",
status_code=404
)
if not can_edit_concert(user, concert):
return HTMLResponse(
"Nicht erlaubt
",
status_code=403
)
next_artist = concert["artist"]
if can_edit_title(user, concert) and artist.strip():
next_artist = artist.strip()
next_start = concert["start_datetime"]
next_end = concert["end_datetime"]
next_description = concert["description"]
next_ticket_url = concert["ticket_url"]
next_ticket_price = concert["ticket_price"]
next_flyer = concert["flyer_path"]
next_venue_id = concert["venue"]["id"]
if can_edit_details(user, concert):
flyer_path, error = save_image(
flyer,
UPLOAD_DIR,
"/static/uploads/flyers/",
)
if error:
return error
with get_db_connection() as connection:
with connection.cursor() as cursor:
resolved_venue_id = resolve_venue(
cursor,
venue_id,
venue_name or concert["venue"]["name"],
city,
street,
postal_code,
country,
latitude,
longitude,
)
connection.commit()
next_start = start_datetime or concert["start_datetime"]
next_end = end_datetime or None
next_description = description or None
next_ticket_url = ticket_url or None
next_ticket_price = ticket_price or None
next_flyer = flyer_path or concert["flyer_path"]
next_venue_id = resolved_venue_id or concert["venue"]["id"]
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
UPDATE concerts
SET
artist = %s,
venue_id = %s,
start_datetime = %s,
end_datetime = %s,
description = %s,
ticket_url = %s,
ticket_price = %s,
flyer_path = %s
WHERE id = %s
""",
(
next_artist,
next_venue_id,
next_start,
next_end or None,
next_description,
next_ticket_url,
next_ticket_price,
next_flyer,
concert_id,
),
)
connection.commit()
return RedirectResponse(
f"/concerts/{concert_id}",
status_code=303
)
@app.post("/concerts/{concert_id}/delete")
def delete_concert(request: Request, concert_id: int):
user = get_current_user(request)
if not user:
return login_redirect(f"/concerts/{concert_id}")
concert = load_concert(concert_id)
if not concert:
return HTMLResponse(
"Konzert nicht gefunden
",
status_code=404
)
if not can_delete_concert(user, concert):
return HTMLResponse(
"Nicht erlaubt
",
status_code=403
)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"DELETE FROM concerts WHERE id = %s",
(concert_id,),
)
connection.commit()
return RedirectResponse("/", status_code=303)
@app.post("/concerts/{concert_id}/attendance")
def set_attendance(
request: Request,
concert_id: int,
status: str = Form(...),
):
user = get_current_user(request)
if not user:
return login_redirect(f"/concerts/{concert_id}")
if status not in {"attending", "maybe", "ticket_search"}:
return HTMLResponse("Ungültige Auswahl
", status_code=400)
if not load_concert(concert_id):
return HTMLResponse("Konzert nicht gefunden
", status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO concert_attendance (concert_id, user_id, status)
VALUES (%s, %s, %s)
ON CONFLICT (concert_id, user_id)
DO UPDATE SET
status = EXCLUDED.status,
updated_at = CURRENT_TIMESTAMP
""",
(concert_id, user["id"], status),
)
connection.commit()
return RedirectResponse(f"/concerts/{concert_id}#attendance", status_code=303)
@app.post("/concerts/{concert_id}/comments")
def add_comment(
request: Request,
concert_id: int,
body: str = Form(...),
):
user = get_current_user(request)
if not user:
return login_redirect(f"/concerts/{concert_id}")
concert = load_concert(concert_id)
if not concert:
return HTMLResponse(
"Konzert nicht gefunden
",
status_code=404
)
text = body.strip()
if not text:
return RedirectResponse(
f"/concerts/{concert_id}",
status_code=303
)
if len(text) > 2000:
return HTMLResponse(
"Kommentar ist zu lang (maximal 2000 Zeichen).
",
status_code=400
)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO concert_comments (
concert_id,
user_id,
body
)
VALUES (%s, %s, %s)
""",
(concert_id, user["id"], text),
)
connection.commit()
return RedirectResponse(
f"/concerts/{concert_id}#comments",
status_code=303
)
@app.post("/concerts/{concert_id}/photos")
async def add_photo(
request: Request,
concert_id: int,
photo: UploadFile | None = File(None),
):
user = get_current_user(request)
if not user:
return login_redirect(f"/concerts/{concert_id}")
concert = load_concert(concert_id)
if not concert:
return HTMLResponse(
"Konzert nicht gefunden
",
status_code=404
)
if not photo or not photo.filename:
return RedirectResponse(
f"/concerts/{concert_id}",
status_code=303
)
path, error = save_image(
photo,
PHOTO_DIR,
"/static/uploads/photos/",
)
if error:
return error
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO concert_photos (
concert_id,
user_id,
path
)
VALUES (%s, %s, %s)
""",
(concert_id, user["id"], path),
)
connection.commit()
return RedirectResponse(
f"/concerts/{concert_id}#photos",
status_code=303
)
# ============================================================
# Venue search
# ============================================================
def legacy_search_venues(q: str):
q = q.strip()
if len(q) < 2:
return []
results = []
query_tokens = [
token
for token in re.findall(r"[a-z0-9]+", q.lower())
if len(token) >= 3
]
token_match = " AND ".join("name ILIKE %s" for _ in query_tokens) or "FALSE"
# ========================================================
# Local database
# ========================================================
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(f"""
SELECT
id,
name,
street,
postal_code,
city,
country,
latitude,
longitude,
external_id,
source
FROM venues
WHERE
name ILIKE %s
OR city ILIKE %s
OR street ILIKE %s
OR ({token_match})
ORDER BY
CASE
WHEN LOWER(name) = LOWER(%s)
THEN 0
WHEN LOWER(name) LIKE LOWER(%s)
THEN 1
WHEN LOWER(name) LIKE LOWER(%s)
THEN 2
ELSE 3
END,
name
LIMIT 10
""", (
f"%{q}%",
f"%{q}%",
f"%{q}%",
*[f"%{token}%" for token in query_tokens],
q,
f"{q}%",
f"%{q}%",
))
rows = cursor.fetchall()
for row in rows:
(
venue_id,
name,
street,
postal_code,
city,
country,
latitude,
longitude,
external_id,
source
) = row
results.append({
"id": venue_id,
"name": name,
"street": street,
"postal_code": postal_code,
"city": city,
"country": country,
"latitude": latitude,
"longitude": longitude,
"external_id": external_id,
"source": source,
"local": True
})
# ========================================================
# Nominatim
# ========================================================
if not results:
headers = {
"User-Agent": "PinguConcerts/1.0"
}
external_query = q
params = {
"q": external_query,
"format": "jsonv2",
"addressdetails": 1,
"limit": 20
}
try:
response = httpx.get(
"https://nominatim.openstreetmap.org/search",
params=params,
headers=headers,
timeout=8
)
response.raise_for_status()
data = response.json()
candidates = []
venue_types = {
"music_venue",
"concert_hall",
"stadium",
"sports_centre",
"theatre",
"arts_centre",
"exhibition_hall",
"conference_centre",
"events_venue",
"nightclub",
"community_centre",
"social_centre",
"festival",
"arena",
"auditorium",
"dance",
"cinema"
}
venue_keywords = [
"halle",
"arena",
"stadion",
"stadium",
"club",
"klub",
"theater",
"theatre",
"bühne",
"buehne",
"concert",
"konzert",
"music",
"musik",
"festival",
"event",
"venue",
"zentrum",
"center",
"centre",
"matrix",
"turbinenhalle",
"westfalenhalle"
]
excluded_types = {
"street",
"road",
"residential",
"postcode",
"house",
"railway",
"bus_stop",
"station",
"person"
}
for item in data:
address = item.get(
"address",
{}
)
name = (
item.get("name")
or ""
).strip()
display_name = (
item.get("display_name")
or ""
)
if not name:
name = display_name.split(
","
)[0].strip()
if not name:
continue
osm_type = (
item.get("type")
or ""
).lower()
osm_class = (
item.get("class")
or ""
).lower()
if osm_type in excluded_types:
continue
name_lower = name.lower()
query_lower = q.lower()
display_lower = display_name.lower()
score = 0
name_tokens = re.findall(r"[a-z0-9]+", name_lower)
matched_tokens = sum(
any(candidate.startswith(token) or token.startswith(candidate)
for candidate in name_tokens)
for token in query_tokens
)
score += matched_tokens * 30
if name_lower == query_lower:
score += 120
elif name_lower.startswith(
query_lower
):
score += 100
elif query_lower in name_lower:
score += 80
elif query_lower in display_lower:
score += 40
if osm_type in venue_types:
score += 70
for keyword in venue_keywords:
if keyword in name_lower:
score += 40
break
if osm_class in {
"amenity",
"leisure",
"tourism"
}:
score += 20
if osm_type in {
"street",
"road",
"residential",
"person",
"postcode",
"house"
}:
score -= 200
if score < 50:
continue
candidates.append({
"id": None,
"name": name,
"street":
address.get("road")
or address.get("pedestrian")
or address.get("footway"),
"postal_code":
address.get("postcode"),
"city":
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality"),
"country":
address.get(
"country",
"Deutschland"
),
"latitude":
float(item["lat"])
if item.get("lat")
else None,
"longitude":
float(item["lon"])
if item.get("lon")
else None,
"external_id":
item.get("osm_id"),
"source":
"nominatim",
"local":
False,
"_score":
score
})
unique = {}
for candidate in candidates:
key = (
candidate["external_id"],
candidate["name"],
candidate["city"]
)
if key not in unique:
unique[key] = candidate
elif (
candidate["_score"]
>
unique[key]["_score"]
):
unique[key] = candidate
sorted_candidates = sorted(
unique.values(),
key=lambda item:
item["_score"],
reverse=True
)
for candidate in sorted_candidates[:10]:
candidate.pop(
"_score",
None
)
results.append(candidate)
except Exception as error:
print(
f"Nominatim search failed: {error}"
)
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]