import os import uuid import secrets import hashlib 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" ) os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(PHOTO_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") 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 """, """ 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) ) """, ] 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 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 if venue_id: if venue_id.startswith("nominatim:"): external_id = venue_id.split(":", 1)[1] cursor.execute( """ SELECT id FROM venues WHERE external_id = %s AND source = 'nominatim' LIMIT 1 """, (external_id,), ) 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 "Deutschland", 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 "Deutschland", 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 @app.get("/admin", response_class=HTMLResponse) def admin_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.html") return template.render(user=user, users=users, invite_url=None) @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.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/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", 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", 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], ) # ============================================================ # 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], } 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], } 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"] 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), ) # ============================================================ # 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("Deutschland"), 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("Deutschland"), 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 # ============================================================ @app.get("/api/venues/search") def search_venues(q: str): q = q.strip() if len(q) < 2: return [] results = [] # ======================================================== # Local database # ======================================================== with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute(""" 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 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}%", 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" } params = { "q": q, "format": "jsonv2", "addressdetails": 1, "limit": 20, "countrycodes": "de" } 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 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