import os import uuid import secrets import hashlib import re import time import threading from collections import defaultdict, deque from io import BytesIO from urllib.parse import urlparse from contextlib import asynccontextmanager from datetime import datetime, timedelta from zoneinfo import ZoneInfo import bcrypt import httpx import psycopg try: from PIL import Image, ImageOps, UnidentifiedImageError except ImportError: Image = ImageOps = UnidentifiedImageError = None from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse from fastapi.encoders import jsonable_encoder 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 COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "false").lower() in {"1", "true", "yes"} ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} MAX_IMAGE_BYTES = 10 * 1024 * 1024 MAX_IMAGE_PIXELS = 25_000_000 rate_limit_buckets = defaultdict(deque) rate_limit_lock = threading.Lock() 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 = ( ("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und MetalCircle aufgebaut", "special"), ("admin", "Admin", "🏴‍☠️", None, "Verantwortung für MetalCircle", "special"), ("captns_mate", "Captns Mate", "☠️", None, "Die treue Gefährtin des Captains", "special"), ("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 am selben Veranstaltungsort besucht", "venue"), ("ten_gigs", "10 Gigs", "🔥", 10, "10 besuchte Konzerte", "attendance"), ("tour_veteran", "25 Gigs", "⚡", 25, "25 besuchte Konzerte", "attendance"), ("fifty_gigs", "50 Gigs", "💀", 50, "50 besuchte Konzerte", "attendance"), ("hundred_gigs", "100 Gigs", "👑", 100, "100 besuchte Konzerte", "attendance"), ) ATTENDANCE_BADGE_CODES = tuple( badge_code for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS if category == "attendance" ) VENUE_BADGE_CODES = tuple( badge_code for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS if category == "venue" ) 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 """, """ UPDATE users SET is_admin = TRUE WHERE LOWER(username) = 'kai' """, """ 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', 'ticket_offer') ), updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (concert_id, user_id) ) """, """ DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'concert_attendance_status_check' AND NOT ( pg_get_constraintdef(oid) LIKE '%ticket_search%' AND pg_get_constraintdef(oid) LIKE '%ticket_offer%' ) ) THEN ALTER TABLE concert_attendance DROP CONSTRAINT concert_attendance_status_check; ALTER TABLE concert_attendance ADD CONSTRAINT concert_attendance_status_check CHECK (status IN ('attending', 'maybe', 'ticket_search', 'ticket_offer')); END IF; END $$ """, """ 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)) """, """ ALTER TABLE concerts ADD COLUMN IF NOT EXISTS event_type VARCHAR(20) NOT NULL DEFAULT 'concert' CHECK (event_type IN ('concert', 'festival', 'other')) """, """ ALTER TABLE concerts ADD COLUMN IF NOT EXISTS parent_event_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL """, """ CREATE INDEX IF NOT EXISTS idx_concerts_parent_event_id ON concerts (parent_event_id) """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS instagram_url VARCHAR(500) """, """ ALTER TABLE users DROP COLUMN IF EXISTS email_verified """, """ CREATE TABLE IF NOT EXISTS account_tokens ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, purpose VARCHAR(30) NOT NULL CHECK (purpose = 'password_reset'), token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, used_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) """, """ DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'account_tokens_purpose_check' AND pg_get_constraintdef(oid) LIKE '%verify_email%' ) THEN DELETE FROM account_tokens WHERE purpose = 'verify_email'; ALTER TABLE account_tokens DROP CONSTRAINT account_tokens_purpose_check; ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_purpose_check CHECK (purpose = 'password_reset'); END IF; END $$ """, """ CREATE INDEX IF NOT EXISTS idx_account_tokens_lookup ON account_tokens (token_hash, purpose, expires_at) """, """ ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_visibility VARCHAR(20) NOT NULL DEFAULT 'friends' CHECK (profile_visibility IN ('public', 'friends', 'nobody')) """, """ ALTER TABLE users ALTER COLUMN profile_visibility SET DEFAULT 'friends' """, """ DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'users_profile_visibility_check' AND pg_get_constraintdef(oid) LIKE '%nobody%' ) THEN ALTER TABLE users DROP CONSTRAINT IF EXISTS users_profile_visibility_check; ALTER TABLE users ADD CONSTRAINT users_profile_visibility_check CHECK (profile_visibility IN ('public', 'friends', 'nobody')); END IF; END $$ """, """ ALTER TABLE concerts ADD COLUMN IF NOT EXISTS flyer_url TEXT """, """ ALTER TABLE concerts ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'friends', 'private')) """, """ CREATE TABLE IF NOT EXISTS event_invitations ( concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL, viewed_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (concert_id, user_id) ) """, """ CREATE INDEX IF NOT EXISTS idx_event_invitations_user ON event_invitations (user_id, viewed_at) """, """ CREATE TABLE IF NOT EXISTS friendships ( id SERIAL PRIMARY KEY, requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, addressee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted')), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK (requester_id <> addressee_id), UNIQUE (requester_id, addressee_id) ) """, """ CREATE UNIQUE INDEX IF NOT EXISTS idx_friendships_pair ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id)) """, """ CREATE TABLE IF NOT EXISTS user_blocks ( blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (blocker_id, blocked_id), CHECK (blocker_id <> blocked_id) ) """, """ CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks (blocked_id, blocker_id) """, """ CREATE TABLE IF NOT EXISTS direct_messages ( id SERIAL PRIMARY KEY, sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, body TEXT NOT NULL, read_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK (sender_id <> recipient_id) ) """, """ CREATE INDEX IF NOT EXISTS idx_direct_messages_conversation ON direct_messages (sender_id, recipient_id, created_at) """, """ CREATE INDEX IF NOT EXISTS idx_direct_messages_unread ON direct_messages (recipient_id, read_at) """, ] 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 EXISTS (SELECT 1 FROM users)", ) users_exist = cursor.fetchone()[0] if not users_exist: 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="MetalCircle", lifespan=lifespan) @app.middleware("http") async def security_controls(request: Request, call_next): if COOKIE_SECURE and request.url.path not in {"/impressum", "/datenschutz"}: forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme).split(",", 1)[0].strip() if forwarded_proto != "https": target = str(request.url).replace("http://", "https://", 1) return RedirectResponse(target, status_code=308) if request.method in {"POST", "PUT", "PATCH", "DELETE"}: origin = request.headers.get("origin") fetch_site = request.headers.get("sec-fetch-site") origin_host = urlparse(origin).netloc if origin else None expected_host = request.headers.get("host", request.url.netloc) referer = request.headers.get("referer") referer_host = urlparse(referer).netloc if referer else None if fetch_site == "cross-site" or (origin_host and origin_host != expected_host) or (referer_host and referer_host != expected_host): return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", status_code=403) if request.url.path not in {"/login", "/register"} and request.url.path.startswith("/password-reset") is False and not origin and not referer: return HTMLResponse("CSRF-Prüfung fehlgeschlagen.", status_code=403) path = request.url.path if path == "/login": bucket_name, limit, window = "login", 10, 15 * 60 elif path == "/register" or path.startswith("/password-reset"): bucket_name, limit, window = "account", 10, 60 * 60 elif path.startswith("/messages/"): bucket_name, limit, window = "messages", 30, 60 elif any(part in path for part in ("/photos", "/patches")) or path in {"/profile", "/concerts"}: bucket_name, limit, window = "uploads", 20, 60 * 60 else: bucket_name, limit, window = "writes", 120, 60 client_host = request.client.host if request.client else "unknown" key = (client_host, bucket_name) now = time.monotonic() with rate_limit_lock: bucket = rate_limit_buckets[key] while bucket and bucket[0] <= now - window: bucket.popleft() if len(bucket) >= limit: return HTMLResponse("Zu viele Anfragen. Bitte später erneut versuchen.", status_code=429, headers={"Retry-After": str(window)}) bucket.append(now) response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" if COOKIE_SECURE: response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response @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("/password-reset") or request.url.path in {"/impressum", "/datenschutz"} or request.url.path == "/profile/export" 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 create_account_token(user_id: int, purpose: str, hours: int = 24) -> str: token = generate_token() with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( "DELETE FROM account_tokens WHERE user_id = %s AND purpose = %s AND used_at IS NULL", (user_id, purpose), ) cursor.execute( """ INSERT INTO account_tokens (user_id, purpose, token_hash, expires_at) VALUES (%s, %s, %s, %s) """, (user_id, purpose, hash_token(token), datetime.now() + timedelta(hours=hours)), ) connection.commit() return token 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]), "pending_friend_count": row[4], "unread_message_count": row[5], "event_invitation_count": row[6], "notification_count": row[4] + row[5] + row[6], } 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, (SELECT COUNT(*) FROM friendships WHERE addressee_id = users.id AND status = 'pending'), (SELECT COUNT(*) FROM direct_messages WHERE recipient_id = users.id AND read_at IS NULL), (SELECT COUNT(*) FROM event_invitations WHERE user_id = users.id AND viewed_at IS NULL) 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", secure=COOKIE_SECURE, path="/", ) 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() EVENT_TYPES = { "concert": "Konzert", "festival": "Festival", "other": "Sonstiges", } def get_linkable_events(exclude_id: int | None = None): with get_db_connection() as connection: with connection.cursor() as cursor: exclude_clause = "AND id <> %s" if exclude_id is not None else "" parameters = (exclude_id,) if exclude_id is not None else () cursor.execute( f""" SELECT id, artist, start_datetime, event_type FROM concerts WHERE event_type IN ('concert', 'festival') AND DATE( CASE WHEN event_type = 'festival' THEN COALESCE(end_datetime, start_datetime) ELSE start_datetime END ) >= CURRENT_DATE {exclude_clause} ORDER BY start_datetime DESC """, parameters, ) rows = cursor.fetchall() return [ { "id": row[0], "title": row[1], "date": row[2].strftime("%d.%m.%Y"), "event_type": row[3], "event_type_label": EVENT_TYPES[row[3]], } for row in rows ] def resolve_event_relationship(cursor, event_type: str, parent_event_id: str, event_id=None): if event_type not in EVENT_TYPES: raise ValueError("Ungültige Veranstaltungskategorie.") if event_type != "other" or not parent_event_id: return None try: parent_id = int(parent_event_id) except ValueError as error: raise ValueError("Ungültige Hauptveranstaltung.") from error if event_id is not None and parent_id == event_id: raise ValueError("Eine Veranstaltung kann nicht mit sich selbst verknüpft werden.") cursor.execute( """ SELECT event_type, DATE( CASE WHEN event_type = 'festival' THEN COALESCE(end_datetime, start_datetime) ELSE start_datetime END ) >= CURRENT_DATE AS is_linkable FROM concerts WHERE id = %s """, (parent_id,), ) parent = cursor.fetchone() if not parent or parent[0] not in {"concert", "festival"}: raise ValueError("Die Hauptveranstaltung muss ein Konzert oder Festival sein.") if not parent[1]: raise ValueError("Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden.") return parent_id 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, concerts.event_type, concerts.parent_event_id, parent_event.artist, parent_event.event_type, concerts.flyer_url, concerts.visibility FROM concerts LEFT JOIN venues ON concerts.venue_id = venues.id LEFT JOIN concerts AS parent_event ON concerts.parent_event_id = parent_event.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], "flyer_url": row[21], "visibility": row[22], "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 "", "event_type": row[17], "event_type_label": EVENT_TYPES[row[17]], "parent_event": { "id": row[18], "title": row[19], "event_type": row[20], "event_type_label": EVENT_TYPES.get(row[20]), } if row[18] else None, "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 can_manage_event_access(user, concert) -> bool: return bool(user and (user["is_admin"] or user["id"] == concert["created_by"])) def serialize_concert_card(row): concert_id, artist, start_datetime, end_datetime, venue, city, country, event_type, parent_event_id, visibility, is_invited = 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"), "end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None, "venue": venue_text, "city": city or "", "country": country or "", "event_type": event_type, "event_type_label": EVENT_TYPES[event_type], "parent_event_id": parent_event_id, "visibility": visibility, "is_invited": bool(is_invited), "children": [], "_start_datetime": start_datetime, "_end_datetime": end_datetime, } def build_event_overview(rows): cards = {row[0]: serialize_concert_card(row) for row in rows} roots = [] for card in cards.values(): parent = cards.get(card["parent_event_id"]) if card["event_type"] == "other" and parent: parent["children"].append(card) else: roots.append(card) for card in roots: card["children"].sort(key=lambda child: child["_start_datetime"]) upcoming = [card for card in roots if not concert_is_past( card["_start_datetime"], card["_end_datetime"] )] past = [card for card in roots if card not in upcoming] upcoming.sort(key=lambda card: card["_start_datetime"]) past.sort(key=lambda card: card["_start_datetime"], reverse=True) return upcoming, past def load_event_overview(user, search_query: str = "", date_from: str = "", date_to: str = "", venue_filter: str = "", country_filter: str = "", category_filter: str = ""): query = """ SELECT concerts.id, concerts.artist, concerts.start_datetime, concerts.end_datetime, venues.name, venues.city, venues.country, concerts.event_type, concerts.parent_event_id, concerts.visibility, EXISTS (SELECT 1 FROM event_invitations ei WHERE ei.concert_id = concerts.id AND ei.user_id = %s) FROM concerts LEFT JOIN venues ON concerts.venue_id = venues.id WHERE %s OR concerts.visibility = 'public' OR concerts.created_by = %s OR ( concerts.visibility = 'friends' AND EXISTS ( SELECT 1 FROM friendships f WHERE f.status = 'accepted' AND ((f.requester_id = concerts.created_by AND f.addressee_id = %s) OR (f.addressee_id = concerts.created_by AND f.requester_id = %s)) ) ) OR ( concerts.visibility = 'private' AND EXISTS ( SELECT 1 FROM event_invitations ei WHERE ei.concert_id = concerts.id AND ei.user_id = %s ) ) ORDER BY concerts.start_datetime ASC """ with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute(query, (user["id"], user["is_admin"], user["id"], user["id"], user["id"], user["id"])) upcoming, past = build_event_overview(cursor.fetchall()) term = search_query.strip().casefold() if not any((term, date_from.strip(), date_to.strip(), venue_filter.strip(), country_filter.strip(), category_filter.strip())): return upcoming, past def matches(card): text = f"{card['artist']} {card['venue']} {card['event_type_label']} {card['country']}".casefold() own = (not term or term in text) own = own and (not venue_filter or venue_filter.casefold() in card['venue'].casefold()) own = own and (not country_filter or country_filter.casefold() in card['country'].casefold()) own = own and (not category_filter or card['event_type'] == category_filter) event_date = card['_start_datetime'].date().isoformat() if date_from and date_to: own = own and date_from <= event_date <= date_to elif date_from: own = own and event_date == date_from elif date_to: own = own and event_date == date_to return own or any(matches(child) for child in card["children"]) return ( [card for card in upcoming if matches(card)], [card for card in past if matches(card)], ) def can_view_event(user, concert) -> bool: if user["is_admin"] or concert["visibility"] == "public" or concert["created_by"] == user["id"]: return True with get_db_connection() as connection: with connection.cursor() as cursor: if concert["visibility"] == "private": cursor.execute( "SELECT 1 FROM event_invitations WHERE concert_id = %s AND user_id = %s", (concert["id"], user["id"]), ) else: cursor.execute( """ SELECT 1 FROM friendships WHERE status = 'accepted' AND ((requester_id = %s AND addressee_id = %s) OR (addressee_id = %s AND requester_id = %s)) """, (concert["created_by"], user["id"], concert["created_by"], user["id"]), ) return cursor.fetchone() is not None def get_invitable_users(exclude_user_id: int): with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ SELECT id, username, COALESCE(display_name, username) FROM users WHERE id <> %s ORDER BY COALESCE(display_name, username), username """, (exclude_user_id,), ) return [{"id": row[0], "username": row[1], "display_name": row[2]} for row in cursor.fetchall()] def get_event_invitee_ids(concert_id: int): with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT user_id FROM event_invitations WHERE concert_id = %s", (concert_id,)) return {row[0] for row in cursor.fetchall()} def search_users(search_query: str): term = search_query.strip() if not term: return [] with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ SELECT username, COALESCE(display_name, username), avatar_path FROM users WHERE username ILIKE %s OR COALESCE(display_name, '') ILIKE %s ORDER BY COALESCE(display_name, username), username LIMIT 30 """, (f"%{term}%", f"%{term}%"), ) return [ {"username": row[0], "display_name": row[1], "avatar_path": row[2]} for row in cursor.fetchall() ] def normalize_instagram_url(value: str): value = value.strip() if not value: return None if value.startswith("@"): value = value[1:] if re.fullmatch(r"[A-Za-z0-9._]{1,30}", value): return f"https://www.instagram.com/{value}/" candidate = value if "://" in value else f"https://{value}" parsed = urlparse(candidate) if (parsed.hostname or "").lower() not in {"instagram.com", "www.instagram.com"}: raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.") username = parsed.path.strip("/").split("/", 1)[0] if not re.fullmatch(r"[A-Za-z0-9._]{1,30}", username): raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.") return f"https://www.instagram.com/{username}/" def normalize_external_url(value: str, field_name: str): value = value.strip() if not value: return None parsed = urlparse(value) if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: raise ValueError(f"Bitte für {field_name} eine vollständige HTTP- oder HTTPS-Adresse eingeben.") return value 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, ) contents = upload.file.read() if len(contents) > MAX_IMAGE_BYTES: return None, HTMLResponse( "Die Datei darf maximal 10 MB groß sein.", status_code=400, ) if Image is None: return None, HTMLResponse( "Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen.", status_code=503, ) try: Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS with Image.open(BytesIO(contents)) as candidate: candidate.verify() with Image.open(BytesIO(contents)) as candidate: image = ImageOps.exif_transpose(candidate) if image.width * image.height > MAX_IMAGE_PIXELS: raise ValueError("Bildauflösung zu groß") image.thumbnail((2400, 2400)) has_alpha = image.mode in {"RGBA", "LA"} or ( image.mode == "P" and "transparency" in image.info ) if image.mode not in {"RGB", "RGBA"}: image = image.convert("RGBA" if has_alpha else "RGB") output = BytesIO() # Nicht jedes Browser-/Proxy-Setup verarbeitet serverseitig # erzeugte WebP-Dateien zuverlässig. Nach der Validierung bleiben # transparente Bilder PNG, normale Bilder werden als JPEG gespeichert. output_format = "PNG" if image.mode == "RGBA" else "JPEG" if output_format == "PNG": image.save(output, format=output_format, optimize=True) else: image.save(output, format=output_format, quality=88, optimize=True) safe_contents = output.getvalue() except (ValueError, OSError, UnidentifiedImageError, Image.DecompressionBombError): return None, HTMLResponse( "Die Datei ist kein gültiges oder unterstütztes Bild.", status_code=400 ) filename = str(uuid.uuid4()) + (".png" if output_format == "PNG" else ".jpg") destination = os.path.join(destination_dir, filename) with open(destination, "wb") as file: file.write(safe_contents) return f"{url_prefix}{filename}", None def remove_uploaded_file(path: str | None, destination_dir: str, url_prefix: str): if not path or not path.startswith(url_prefix): return False relative_name = path.removeprefix(url_prefix) if not relative_name or os.path.basename(relative_name) != relative_name: return False file_path = os.path.join(destination_dir, relative_name) if not os.path.isfile(file_path): return False os.remove(file_path) return True def attended_concert_stats(user_id: int) -> tuple[int, int]: with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ WITH attended AS ( SELECT concerts.venue_id , concerts.event_type 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 ), venue_counts AS ( SELECT venue_id, COUNT(*) AS visit_count FROM attended WHERE venue_id IS NOT NULL AND event_type <> 'other' GROUP BY venue_id ) SELECT (SELECT COUNT(*) FROM attended), COALESCE((SELECT MAX(visit_count) FROM venue_counts), 0) """, (user_id,), ) total, max_same_venue = cursor.fetchone() return total, max_same_venue def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, registered_at): highest_attendance_badge = None venue_badge = "regular" if max_same_venue >= 5 else None for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS: qualifies = category == "attendance" and threshold is not None and attended_count >= threshold if qualifies: 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,), ) # Konzert-Patches sind eine Level-Leiste: immer nur die höchste # erreichte Stufe anzeigen (ältere Stufen werden ersetzt). cursor.execute( "DELETE FROM user_badges WHERE user_id = %s AND badge_code = ANY(%s)", (user_id, list(ATTENDANCE_BADGE_CODES + VENUE_BADGE_CODES)), ) if highest_attendance_badge: 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), ) if venue_badge: cursor.execute( "INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING", (user_id, venue_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, instagram_url, profile_visibility, is_admin FROM users WHERE LOWER(username) = LOWER(%s) """, (username,), ) row = cursor.fetchone() if not row: return None cursor.execute( "SELECT badge_code, awarded_at FROM user_badges WHERE user_id = %s", (row[0],), ) badge_rows = cursor.fetchall() earned_codes = {badge_row[0] for badge_row in badge_rows} badge_awarded_at = {badge_row[0]: badge_row[1] for badge_row in badge_rows} is_founder = row[1].casefold() == "kai" if is_founder: earned_codes.add("founder") if row[7]: earned_codes.add("admin") if row[1].casefold() == "bianca": earned_codes.add("captns_mate") 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], "instagram_url": row[5], "instagram_handle": row[5].rstrip("/").rsplit("/", 1)[-1] if row[5] else None, "profile_visibility": row[6], "is_admin": bool(row[7]), "is_founder": is_founder, "earned_codes": earned_codes, "badge_awarded_at": badge_awarded_at, } 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 users.id, users.username, users.email, users.display_name, users.is_admin, users.created_at, EXISTS ( SELECT 1 FROM user_badges WHERE user_badges.user_id = users.id AND user_badges.badge_code = 'founder' ) 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"), "has_founder_badge": bool(row[6]), } 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.get("/admin/statistics", response_class=HTMLResponse) def admin_statistics_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: queries = { "users": "SELECT COUNT(*) FROM users", "events": "SELECT COUNT(*) FROM concerts", "upcoming": "SELECT COUNT(*) FROM concerts WHERE start_datetime >= NOW()", "comments": "SELECT COUNT(*) FROM concert_comments", "attendance": "SELECT COUNT(*) FROM concert_attendance", "friendships": "SELECT COUNT(*) FROM friendships WHERE status = 'accepted'", "messages": "SELECT COUNT(*) FROM direct_messages", } stats = {} for key, query in queries.items(): try: cursor.execute(query) stats[key] = cursor.fetchone()[0] except Exception: connection.rollback() stats[key] = 0 template = templates.get_template("admin_statistics.html") return template.render(user=user, stats=stats) @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 users.id, users.username, users.email, users.display_name, users.is_admin, users.created_at, EXISTS ( SELECT 1 FROM user_badges WHERE user_badges.user_id = users.id AND user_badges.badge_code = 'founder' ) 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"), "has_founder_badge": bool(row[6]), } 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): remove_uploaded_file(path, PATCH_DIR, "/static/uploads/patches/") @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("SELECT username FROM users WHERE id = %s", (user_id,)) target_user = cursor.fetchone() if target_user and (target_user[0] or "").casefold() == "kai" and role != "admin": return HTMLResponse( "

Der Gründer Kai muss Admin bleiben.

", status_code=400, ) 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("SELECT username, avatar_path FROM users WHERE id = %s", (user_id,)) target_user = cursor.fetchone() if target_user and (target_user[0] or "").casefold() == "kai": return HTMLResponse( "

Der Gründer Kai kann nicht gelöscht werden.

", status_code=400, ) cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,)) photo_paths = [row[0] for row in cursor.fetchall()] cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) connection.commit() if target_user: remove_uploaded_file(target_user[1], AVATAR_DIR, "/static/uploads/avatars/") for photo_path in photo_paths: remove_uploaded_file(photo_path, PHOTO_DIR, "/static/uploads/photos/") return RedirectResponse("/admin/users", status_code=303) @app.post("/admin/users/{user_id}/account-link", response_class=HTMLResponse) def create_user_account_link(request: Request, user_id: int, purpose: str = Form(...)): user = require_admin(request) if not user: return HTMLResponse("

Nicht erlaubt

", status_code=403) if purpose != "password_reset": return HTMLResponse("

Ungültiger Linktyp

", status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT username, email FROM users WHERE id = %s", (user_id,)) target = cursor.fetchone() if not target: return HTMLResponse("

Benutzer nicht gefunden

", status_code=404) token = create_account_token(user_id, purpose, 2) path = f"/password-reset/{token}" template = templates.get_template("account_link.html") return template.render( user=user, target_username=target[0], target_email=target[1], purpose=purpose, account_url=f"{str(request.base_url).rstrip('/')}{path}", ) @app.get("/password-reset/{token}", response_class=HTMLResponse) def password_reset_page(token: str): with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ SELECT 1 FROM account_tokens WHERE token_hash = %s AND purpose = 'password_reset' AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP """, (hash_token(token),), ) valid = cursor.fetchone() if not valid: return HTMLResponse("

Reset-Link ungültig oder abgelaufen.

", status_code=410) return templates.get_template("password_reset.html").render(token=token, error=None) @app.post("/password-reset/{token}", response_class=HTMLResponse) def password_reset(token: str, password: str = Form(...), password_repeat: str = Form(...)): if len(password) < 10 or password != password_repeat: return templates.get_template("password_reset.html").render( token=token, error="Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein." ) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ SELECT id, user_id FROM account_tokens WHERE token_hash = %s AND purpose = 'password_reset' AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP FOR UPDATE """, (hash_token(token),), ) account_token = cursor.fetchone() if not account_token: return HTMLResponse("

Reset-Link ungültig oder abgelaufen.

", status_code=410) password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, account_token[1])) cursor.execute("UPDATE account_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = %s", (account_token[0],)) cursor.execute("DELETE FROM sessions WHERE user_id = %s", (account_token[1],)) connection.commit() return RedirectResponse("/login?reset=1", 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) < 10: return HTMLResponse( "

Fehler

Das Passwort muss mindestens 10 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 ) # ============================================================ # Rechtliche Hinweise @app.get("/impressum", response_class=HTMLResponse) def impressum_page(): return templates.get_template("impressum.html").render() @app.get("/datenschutz", response_class=HTMLResponse) def privacy_page(): return templates.get_template("datenschutz.html").render() # 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, path="/", secure=COOKIE_SECURE, samesite="lax") return response # ============================================================ # Home # ============================================================ @app.get("/", response_class=HTMLResponse) def home(request: Request, q: str = "", date_from: str = "", date_to: str = "", venue: str = "", country: str = "", category: str = ""): user = get_current_user(request) upcoming_concerts, _past_concerts = load_event_overview(user, q, date_from, date_to, venue, country, category) template = templates.get_template("index.html") return template.render( user=user, upcoming_concerts=upcoming_concerts, past_concerts=[], archive=False, search_query=q.strip(), user_results=search_users(q), filters={"date_from": date_from, "date_to": date_to, "venue": venue, "country": country, "category": category}, ) @app.get("/events/past", response_class=HTMLResponse) def past_events(request: Request, q: str = "", date_from: str = "", date_to: str = "", venue: str = "", country: str = "", category: str = ""): user = get_current_user(request) _upcoming_concerts, past_concerts = load_event_overview(user, q, date_from, date_to, venue, country, category) template = templates.get_template("index.html") return template.render( user=user, upcoming_concerts=[], past_concerts=past_concerts, archive=True, search_query=q.strip(), user_results=search_users(q), filters={"date_from": date_from, "date_to": date_to, "venue": venue, "country": country, "category": category}, ) # ============================================================ # Profiles # ============================================================ def render_profile( request: Request, username: str, force_own: bool = False, form_error: str | None = None, form_success: str | None = None, instagram_input: str | None = None, status_code: int = 200, ): viewer = get_current_user(request) profile = load_profile(username) if not profile: return HTMLResponse("

Benutzer nicht gefunden

", status_code=404) is_own_profile = bool(viewer and viewer["id"] == profile["id"]) friendship = None block_status = {"blocked_by_viewer": False, "blocked_viewer": False} if viewer and not is_own_profile: with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ SELECT id, requester_id, addressee_id, status FROM friendships WHERE (requester_id = %s AND addressee_id = %s) OR (requester_id = %s AND addressee_id = %s) """, (viewer["id"], profile["id"], profile["id"], viewer["id"]), ) row = cursor.fetchone() if row: friendship = { "id": row[0], "requester_id": row[1], "addressee_id": row[2], "status": row[3], } cursor.execute( """ SELECT blocker_id, blocked_id FROM user_blocks WHERE (blocker_id = %s AND blocked_id = %s) OR (blocker_id = %s AND blocked_id = %s) """, (viewer["id"], profile["id"], profile["id"], viewer["id"]), ) for blocker_id, _blocked_id in cursor.fetchall(): if blocker_id == viewer["id"]: block_status["blocked_by_viewer"] = True else: block_status["blocked_viewer"] = True can_view_details = ( profile["profile_visibility"] == "public" or is_own_profile or bool(viewer and viewer["is_admin"]) or bool(friendship and friendship["status"] == "accepted") ) connections = {"incoming": [], "outgoing": [], "friends": [], "blocked": []} if is_own_profile: with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ SELECT f.id, f.requester_id, f.addressee_id, f.status, other.username, COALESCE(other.display_name, other.username), other.avatar_path FROM friendships f JOIN users other ON other.id = CASE WHEN f.requester_id = %s THEN f.addressee_id ELSE f.requester_id END WHERE f.requester_id = %s OR f.addressee_id = %s ORDER BY f.updated_at DESC """, (viewer["id"], viewer["id"], viewer["id"]), ) for row in cursor.fetchall(): item = { "id": row[0], "requester_id": row[1], "addressee_id": row[2], "status": row[3], "username": row[4], "display_name": row[5], "avatar_path": row[6], } if row[3] == "accepted": connections["friends"].append(item) elif row[2] == viewer["id"]: connections["incoming"].append(item) else: connections["outgoing"].append(item) cursor.execute( """ SELECT u.username, COALESCE(u.display_name, u.username), u.avatar_path FROM user_blocks b JOIN users u ON u.id = b.blocked_id WHERE b.blocker_id = %s ORDER BY COALESCE(u.display_name, u.username), u.username """, (viewer["id"],), ) connections["blocked"] = [ {"username": row[0], "display_name": row[1], "avatar_path": row[2]} for row in cursor.fetchall() ] attended_count, max_same_venue = attended_concert_stats(profile["id"]) grant_earned_badges( profile["id"], attended_count, max_same_venue, 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), "sort_key": (0, datetime.min) if code == "founder" else (1, datetime.min) if code == "admin" else (2, profile["badge_awarded_at"].get(code) or datetime.max), } for code, name, icon, threshold, description, category in BADGE_DEFINITIONS ] badges.sort(key=lambda badge: badge["sort_key"]) template = templates.get_template("profile.html") return HTMLResponse( template.render( user=viewer, profile=profile, attended_count=attended_count, badges=badges, is_own_profile=force_own or is_own_profile, can_view_details=can_view_details, friendship=friendship, block_status=block_status, connections=connections, form_error=form_error, form_success=form_success, instagram_input=instagram_input, ), status_code=status_code, ) @app.get("/profile", response_class=HTMLResponse) def own_profile(request: Request, saved: str = ""): user = get_current_user(request) saved_items = set(saved.split(",")) messages = [] if "profile" in saved_items: messages.append("Profil gespeichert") if "avatar" in saved_items: messages.append("Profilbild aktualisiert") if "instagram" in saved_items: messages.append("Instagram verknüpft") return render_profile( request, user["username"], force_own=True, form_success=" · ".join(messages) if messages else None, ) @app.get("/profile/export") def export_profile_data(request: Request): """Download the authenticated user's application data as JSON.""" user = get_current_user(request) if not user: return login_redirect("/profile/export") user_id = user["id"] with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ SELECT id, username, email, display_name, avatar_path, instagram_url, profile_visibility, is_admin, created_at FROM users WHERE id = %s """, (user_id,), ) account = cursor.fetchone() cursor.execute( """ SELECT c.id, c.artist, c.event_type, c.start_datetime, c.end_datetime, c.venue_id, c.description, c.ticket_url, c.ticket_price, c.flyer_path, c.flyer_url, c.visibility, c.created_at FROM concerts c WHERE c.created_by = %s ORDER BY c.start_datetime """, (user_id,), ) created_events = cursor.fetchall() cursor.execute( """ SELECT concert_id, status, updated_at FROM concert_attendance WHERE user_id = %s ORDER BY updated_at """, (user_id,), ) attendance = cursor.fetchall() cursor.execute( """ SELECT id, requester_id, addressee_id, status, created_at, updated_at FROM friendships WHERE requester_id = %s OR addressee_id = %s ORDER BY created_at """, (user_id, user_id), ) friendships = cursor.fetchall() cursor.execute( """ SELECT blocker_id, blocked_id, created_at FROM user_blocks WHERE blocker_id = %s OR blocked_id = %s ORDER BY created_at """, (user_id, user_id), ) blocks = cursor.fetchall() cursor.execute( """ SELECT id, sender_id, recipient_id, body, read_at, created_at FROM direct_messages WHERE sender_id = %s OR recipient_id = %s ORDER BY created_at """, (user_id, user_id), ) messages = cursor.fetchall() cursor.execute( """ SELECT concert_id, invited_by, viewed_at, created_at FROM event_invitations WHERE user_id = %s ORDER BY created_at """, (user_id,), ) invitations = cursor.fetchall() cursor.execute( """ SELECT id, concert_id, body, created_at FROM concert_comments WHERE user_id = %s ORDER BY created_at """, (user_id,), ) comments = cursor.fetchall() cursor.execute( """ SELECT id, concert_id, path, created_at FROM concert_photos WHERE user_id = %s ORDER BY created_at """, (user_id,), ) photos = cursor.fetchall() cursor.execute( """ SELECT badge_code, awarded_at FROM user_badges WHERE user_id = %s ORDER BY awarded_at """, (user_id,), ) badges = cursor.fetchall() def rows_to_dicts(rows, keys): return [dict(zip(keys, row)) for row in rows] data = { "export_version": 1, "exported_at": datetime.now(), "account": dict(zip( ("id", "username", "email", "display_name", "avatar_path", "instagram_url", "profile_visibility", "is_admin", "created_at"), account, )) if account else None, "created_events": rows_to_dicts( created_events, ("id", "artist", "event_type", "start_datetime", "end_datetime", "venue_id", "description", "ticket_url", "ticket_price", "flyer_path", "flyer_url", "visibility", "created_at"), ), "attendance": rows_to_dicts(attendance, ("concert_id", "status", "updated_at")), "friendships": rows_to_dicts(friendships, ("id", "requester_id", "addressee_id", "status", "created_at", "updated_at")), "blocks": rows_to_dicts(blocks, ("blocker_id", "blocked_id", "created_at")), "messages": rows_to_dicts(messages, ("id", "sender_id", "recipient_id", "body", "read_at", "created_at")), "event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")), "comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")), "photos": rows_to_dicts(photos, ("id", "concert_id", "path", "created_at")), "badges": rows_to_dicts(badges, ("badge_code", "awarded_at")), } filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"]) return JSONResponse( content=jsonable_encoder(data), headers={"Content-Disposition": f'attachment; filename="pingu-concerts-{filename}-daten.json"'}, ) @app.post("/profile/delete") def delete_own_account(request: Request): """Permanently remove the authenticated user's account and personal data.""" user = get_current_user(request) if not user: return login_redirect("/profile") user_id = user["id"] with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT avatar_path FROM users WHERE id = %s", (user_id,)) avatar_path = cursor.fetchone()[0] cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,)) photo_paths = [row[0] for row in cursor.fetchall()] cursor.execute("SELECT flyer_path FROM concerts WHERE created_by = %s AND flyer_path IS NOT NULL", (user_id,)) flyer_paths = [row[0] for row in cursor.fetchall()] cursor.execute("UPDATE registration_invites SET used_by = NULL WHERE used_by = %s", (user_id,)) cursor.execute("UPDATE concerts SET flyer_path = NULL WHERE created_by = %s", (user_id,)) cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) connection.commit() for path, directory, prefix in [(avatar_path, AVATAR_DIR, "/static/uploads/avatars/")] + [(p, PHOTO_DIR, "/static/uploads/photos/") for p in photo_paths] + [(p, UPLOAD_DIR, "/static/uploads/flyers/") for p in flyer_paths]: remove_uploaded_file(path, directory, prefix) response = RedirectResponse("/login", status_code=303) response.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax") return response @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(""), instagram_url: str = Form(""), profile_visibility: str = Form("friends"), avatar: UploadFile | None = File(None), ): user = get_current_user(request) try: normalized_instagram_url = normalize_instagram_url(instagram_url) except ValueError as error: return render_profile( request, user["username"], force_own=True, form_error=str(error), instagram_input=instagram_url, status_code=400, ) if profile_visibility not in {"public", "friends", "nobody"}: return HTMLResponse("Ungültige Profilsichtbarkeit.", status_code=400) 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("SELECT avatar_path FROM users WHERE id = %s", (user["id"],)) previous_profile = cursor.fetchone() cursor.execute( """ UPDATE users SET display_name = COALESCE(NULLIF(%s, ''), display_name), avatar_path = COALESCE(%s, avatar_path), instagram_url = %s, profile_visibility = %s WHERE id = %s RETURNING avatar_path, instagram_url """, (display_name.strip(), avatar_path, normalized_instagram_url, profile_visibility, user["id"]), ) saved_profile = cursor.fetchone() connection.commit() if not saved_profile: remove_uploaded_file(avatar_path, AVATAR_DIR, "/static/uploads/avatars/") return HTMLResponse("

Profil konnte nicht gespeichert werden.

", status_code=500) if avatar_path and previous_profile and previous_profile[0] != avatar_path: remove_uploaded_file(previous_profile[0], AVATAR_DIR, "/static/uploads/avatars/") saved_parts = ["profile"] if avatar_path and saved_profile[0] == avatar_path: saved_parts.append("avatar") if normalized_instagram_url and saved_profile[1] == normalized_instagram_url: saved_parts.append("instagram") return RedirectResponse( f"/profile?saved={','.join(saved_parts)}", status_code=303, ) @app.post("/users/{username}/friend-request") def send_friend_request(request: Request, username: str): user = get_current_user(request) profile = load_profile(username) if not profile: return HTMLResponse("Benutzer nicht gefunden.", status_code=404) if profile["id"] == user["id"]: return HTMLResponse("Du kannst dir nicht selbst eine Anfrage schicken.", status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ SELECT 1 FROM user_blocks WHERE (blocker_id = %s AND blocked_id = %s) OR (blocker_id = %s AND blocked_id = %s) """, (user["id"], profile["id"], profile["id"], user["id"]), ) if cursor.fetchone(): return HTMLResponse("Freundschaftsanfrage wegen einer Blockierung nicht möglich.", status_code=403) cursor.execute( """ INSERT INTO friendships (requester_id, addressee_id) VALUES (%s, %s) ON CONFLICT DO NOTHING """, (user["id"], profile["id"]), ) connection.commit() return RedirectResponse(f"/users/{profile['username']}", status_code=303) @app.post("/users/{username}/block") def block_user(request: Request, username: str): user = get_current_user(request) profile = load_profile(username) if not profile: return HTMLResponse("Benutzer nicht gefunden.", status_code=404) if profile["id"] == user["id"]: return HTMLResponse("Du kannst dich nicht selbst blockieren.", status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ INSERT INTO user_blocks (blocker_id, blocked_id) VALUES (%s, %s) ON CONFLICT DO NOTHING """, (user["id"], profile["id"]), ) cursor.execute( """ DELETE FROM friendships WHERE (requester_id = %s AND addressee_id = %s) OR (requester_id = %s AND addressee_id = %s) """, (user["id"], profile["id"], profile["id"], user["id"]), ) connection.commit() return RedirectResponse(f"/users/{profile['username']}", status_code=303) @app.post("/users/{username}/unblock") def unblock_user(request: Request, username: str): user = get_current_user(request) profile = load_profile(username) if not profile: return HTMLResponse("Benutzer nicht gefunden.", status_code=404) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( "DELETE FROM user_blocks WHERE blocker_id = %s AND blocked_id = %s", (user["id"], profile["id"]), ) connection.commit() return RedirectResponse(f"/users/{profile['username']}", status_code=303) @app.post("/friendships/{friendship_id}/{action}") def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")): user = get_current_user(request) if action not in {"accept", "decline", "remove"}: return HTMLResponse("Ungültige Aktion.", status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( "SELECT requester_id, addressee_id, status FROM friendships WHERE id = %s", (friendship_id,), ) friendship = cursor.fetchone() if not friendship or user["id"] not in friendship[:2]: return HTMLResponse("Freundschaft nicht gefunden.", status_code=404) if action == "accept": if friendship[1] != user["id"] or friendship[2] != "pending": return HTMLResponse("Diese Anfrage kann nicht angenommen werden.", status_code=403) cursor.execute( "UPDATE friendships SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s", (friendship_id,), ) else: if action == "decline" and (friendship[1] != user["id"] or friendship[2] != "pending"): return HTMLResponse("Diese Anfrage kann nicht abgelehnt werden.", status_code=403) cursor.execute("DELETE FROM friendships WHERE id = %s", (friendship_id,)) connection.commit() other_id = friendship[1] if friendship[0] == user["id"] else friendship[0] with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT username FROM users WHERE id = %s", (other_id,)) other = cursor.fetchone() if return_to == "/messages": return RedirectResponse("/messages", status_code=303) return RedirectResponse(f"/users/{other[0]}" if other else "/", status_code=303) # ============================================================ # Direct messages # ============================================================ def load_chat_partner(cursor, user, username: str): cursor.execute( """ SELECT u.id, u.username, COALESCE(u.display_name, u.username), u.avatar_path, u.is_admin FROM users u WHERE LOWER(u.username) = LOWER(%s) AND u.id <> %s AND NOT EXISTS ( SELECT 1 FROM user_blocks b WHERE (b.blocker_id = %s AND b.blocked_id = u.id) OR (b.blocker_id = u.id AND b.blocked_id = %s) ) AND (%s OR u.is_admin OR EXISTS ( SELECT 1 FROM friendships f WHERE f.status = 'accepted' AND ((f.requester_id = %s AND f.addressee_id = u.id) OR (f.addressee_id = %s AND f.requester_id = u.id)) )) """, (username, user["id"], user["id"], user["id"], user["is_admin"], user["id"], user["id"]), ) row = cursor.fetchone() if not row: return None return {"id": row[0], "username": row[1], "display_name": row[2], "avatar_path": row[3], "is_admin": bool(row[4])} @app.get("/messages", response_class=HTMLResponse) def message_inbox(request: Request): user = get_current_user(request) conversations = [] friend_requests = [] event_invitations = [] with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ SELECT f.id, u.username, COALESCE(u.display_name, u.username), f.created_at FROM friendships f JOIN users u ON u.id = f.requester_id WHERE f.addressee_id = %s AND f.status = 'pending' ORDER BY f.created_at DESC """, (user["id"],), ) friend_requests = [ {"id": row[0], "username": row[1], "display_name": row[2], "created_at": row[3].strftime("%d.%m.%Y %H:%M")} for row in cursor.fetchall() ] cursor.execute( """ SELECT ei.concert_id, c.artist, c.start_datetime, COALESCE(u.display_name, u.username) FROM event_invitations ei JOIN concerts c ON c.id = ei.concert_id LEFT JOIN users u ON u.id = ei.invited_by WHERE ei.user_id = %s AND ei.viewed_at IS NULL ORDER BY ei.created_at DESC """, (user["id"],), ) event_invitations = [ {"concert_id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y %H:%M"), "invited_by": row[3] or "Ein Mitglied"} for row in cursor.fetchall() ] cursor.execute( """ SELECT u.id, u.username, COALESCE(u.display_name, u.username), u.avatar_path FROM users u WHERE u.id <> %s AND NOT EXISTS ( SELECT 1 FROM user_blocks b WHERE (b.blocker_id = %s AND b.blocked_id = u.id) OR (b.blocker_id = u.id AND b.blocked_id = %s) ) AND (%s OR u.is_admin OR EXISTS ( SELECT 1 FROM friendships f WHERE f.status = 'accepted' AND ((f.requester_id = %s AND f.addressee_id = u.id) OR (f.addressee_id = %s AND f.requester_id = u.id)) )) ORDER BY COALESCE(u.display_name, u.username) """, (user["id"], user["id"], user["id"], user["is_admin"], user["id"], user["id"]), ) for row in cursor.fetchall(): cursor.execute( """ SELECT body, created_at, sender_id, (SELECT COUNT(*) FROM direct_messages WHERE sender_id = %s AND recipient_id = %s AND read_at IS NULL) FROM direct_messages WHERE (sender_id = %s AND recipient_id = %s) OR (sender_id = %s AND recipient_id = %s) ORDER BY created_at DESC LIMIT 1 """, (row[0], user["id"], user["id"], row[0], row[0], user["id"]), ) latest = cursor.fetchone() conversations.append({ "id": row[0], "username": row[1], "display_name": row[2], "avatar_path": row[3], "last_message": latest[0] if latest else None, "last_at": latest[1].strftime("%d.%m.%Y %H:%M") if latest else None, "last_at_raw": latest[1] if latest else None, "last_from_me": bool(latest and latest[2] == user["id"]), "unread_count": latest[3] if latest else 0, }) conversations.sort(key=lambda item: (item["unread_count"] > 0, item["last_at_raw"] or datetime.min), reverse=True) template = templates.get_template("messages.html") return template.render(user=user, conversations=conversations, friend_requests=friend_requests, event_invitations=event_invitations, partner=None, messages=[]) @app.get("/messages/{username}", response_class=HTMLResponse) def message_conversation(request: Request, username: str): user = get_current_user(request) with get_db_connection() as connection: with connection.cursor() as cursor: partner = load_chat_partner(cursor, user, username) if not partner: return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403) cursor.execute( """ UPDATE direct_messages SET read_at = CURRENT_TIMESTAMP WHERE sender_id = %s AND recipient_id = %s AND read_at IS NULL """, (partner["id"], user["id"]), ) cursor.execute( """ SELECT sender_id, body, created_at FROM direct_messages WHERE (sender_id = %s AND recipient_id = %s) OR (sender_id = %s AND recipient_id = %s) ORDER BY created_at ASC, id ASC LIMIT 500 """, (user["id"], partner["id"], partner["id"], user["id"]), ) messages = [ {"from_me": row[0] == user["id"], "body": row[1], "created_at": row[2].strftime("%d.%m.%Y %H:%M")} for row in cursor.fetchall() ] connection.commit() template = templates.get_template("messages.html") return template.render(user=get_current_user(request), conversations=[], partner=partner, messages=messages) @app.post("/messages/{username}") def send_message(request: Request, username: str, body: str = Form(...)): user = get_current_user(request) body = body.strip() if not body or len(body) > 2000: return HTMLResponse("Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein.", status_code=400) with get_db_connection() as connection: with connection.cursor() as cursor: partner = load_chat_partner(cursor, user, username) if not partner: return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403) cursor.execute( "INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s)", (user["id"], partner["id"], body), ) connection.commit() return RedirectResponse(f"/messages/{partner['username']}#latest", 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, linkable_events=get_linkable_events(), invitable_users=get_invitable_users(user["id"])) # ============================================================ # Concert detail # ============================================================ def _ics_escape(value: str) -> str: return str(value or "").replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n") @app.get("/concerts/{concert_id}.ics") def concert_ics(request: Request, concert_id: int): concert = load_concert(concert_id) if not concert: return PlainTextResponse("Veranstaltung nicht gefunden", status_code=404) # Kalenderdaten unterliegen denselben Sichtbarkeitsregeln wie die Detailseite. # Ohne Sitzung bzw. ohne Berechtigung keine Informationen preisgeben. user = get_current_user(request) if not user or not can_view_event(user, concert): return PlainTextResponse("Veranstaltung nicht gefunden", status_code=404) local_zone = ZoneInfo("Europe/Berlin") start = concert["start_datetime"].replace(tzinfo=local_zone) end_value = concert.get("end_datetime") end = end_value.replace(tzinfo=local_zone) if end_value else start + timedelta(hours=2) if end <= start: end = start + timedelta(hours=2) venue = concert.get("venue", {}) or {} location = ", ".join(filter(None, [venue.get("name"), venue.get("city"), venue.get("country")])) stamp = datetime.now(ZoneInfo("UTC")).strftime("%Y%m%dT%H%M%SZ") body = "\r\n".join([ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//MetalCircle//Concerts//DE", "CALSCALE:GREGORIAN", "BEGIN:VEVENT", f"UID:metalcircle-{concert_id}@konzerte.pinguholic.de", f"DTSTAMP:{stamp}", f"DTSTART;TZID=Europe/Berlin:{start.strftime('%Y%m%dT%H%M%S')}", f"DTEND;TZID=Europe/Berlin:{end.strftime('%Y%m%dT%H%M%S')}", f"SUMMARY:{_ics_escape(concert['artist'])}", f"LOCATION:{_ics_escape(location)}", f"URL:https://konzerte.pinguholic.de/concerts/{concert_id}", "END:VEVENT", "END:VCALENDAR", "" ]) return PlainTextResponse(body, media_type="text/calendar; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="metalcircle-{concert_id}.ics"'}) @app.get( "/concerts/{concert_id}", response_class=HTMLResponse ) def concert_detail(request: Request, concert_id: int): concert = load_concert(concert_id) user = get_current_user(request) if not concert or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung nicht gefunden

", status_code=404 ) with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( "UPDATE event_invitations SET viewed_at = CURRENT_TIMESTAMP WHERE concert_id = %s AND user_id = %s AND viewed_at IS NULL", (concert_id, user["id"]), ) connection.commit() 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 AND NOT EXISTS ( SELECT 1 FROM user_blocks b WHERE (b.blocker_id = %s AND b.blocked_id = concert_attendance.user_id) OR (b.blocker_id = concert_attendance.user_id AND b.blocked_id = %s) ) ORDER BY users.display_name NULLS LAST, users.username """, (concert_id, user["id"], user["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"] ticket_offers = [item for item in attendance if item["status"] == "ticket_offer"] 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), ticket_offers=ticket_offers, ticket_offer_count=len(ticket_offers), maybe_users=maybe_users, maybe_count=len(maybe_users), ) # ============================================================ # Create concert # ============================================================ @app.post("/concerts") async def create_concert( request: Request, artist: str = Form(...), event_type: str = Form("concert"), parent_event_id: str = Form(""), visibility: str = Form("public"), invited_user_ids: list[int] = Form(default=[]), 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_url: str = Form(""), flyer: UploadFile | None = File(None) ): user = get_current_user(request) if not user: return login_redirect("/concerts/new") if event_type not in EVENT_TYPES: return HTMLResponse("

Ungültige Veranstaltungskategorie.

", status_code=400) if event_type == "festival" and not end_datetime: return HTMLResponse("

Bei Festivals ist ein Enddatum erforderlich.

", status_code=400) if event_type != "festival": end_datetime = "" if visibility not in {"public", "friends", "private"}: return HTMLResponse("

Ungültige Sichtbarkeit.

", status_code=400) if event_type != "other": visibility = "public" if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime): return HTMLResponse("

Das Enddatum darf nicht vor dem Beginn liegen.

", status_code=400) try: normalized_flyer_url = normalize_external_url(flyer_url, "den Flyer") except ValueError as error: return HTMLResponse(f"

{error}

", status_code=400) 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: try: selected_parent_event_id = resolve_event_relationship( cursor, event_type, parent_event_id ) except ValueError as error: return HTMLResponse(f"

{error}

", status_code=400) selected_venue_id = resolve_venue( cursor, venue_id, venue_name, city, street, postal_code, country, latitude, longitude, ) cursor.execute( """ INSERT INTO concerts ( artist, event_type, parent_event_id, venue_id, start_datetime, end_datetime, description, ticket_url, ticket_price, flyer_path, flyer_url, visibility, created_by ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) RETURNING id """, ( artist, event_type, selected_parent_event_id, selected_venue_id, start_datetime, end_datetime or None, description or None, ticket_url or None, ticket_price or None, flyer_path, normalized_flyer_url, visibility, user["id"], ), ) concert_id = cursor.fetchone()[0] if visibility == "private" and invited_user_ids: cursor.execute( """ INSERT INTO event_invitations (concert_id, user_id, invited_by) SELECT %s, id, %s FROM users WHERE id = ANY(%s) AND id <> %s ON CONFLICT (concert_id, user_id) DO NOTHING """, (concert_id, user["id"], invited_user_ids, user["id"]), ) 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 or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung 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, linkable_events=get_linkable_events(exclude_id=concert_id), can_edit_title=can_edit_title(user, concert), can_edit_details=can_edit_details(user, concert), can_delete=can_delete_concert(user, concert), invitable_users=get_invitable_users(user["id"]), invited_user_ids=get_event_invitee_ids(concert_id), can_manage_access=can_manage_event_access(user, concert), ) @app.post("/concerts/{concert_id}/edit") async def edit_concert( request: Request, concert_id: int, artist: str = Form(""), event_type: str = Form("concert"), parent_event_id: str = Form(""), visibility: str = Form("public"), invited_user_ids: list[int] = Form(default=[]), 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_url: 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 or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung 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_flyer_url = concert["flyer_url"] next_venue_id = concert["venue"]["id"] next_event_type = concert["event_type"] next_visibility = concert["visibility"] next_parent_event_id = concert["parent_event"]["id"] if concert["parent_event"] else None if can_edit_details(user, concert): if not can_manage_event_access(user, concert): event_type = concert["event_type"] if event_type not in EVENT_TYPES: return HTMLResponse("

Ungültige Veranstaltungskategorie.

", status_code=400) if event_type == "festival" and not end_datetime: return HTMLResponse("

Bei Festivals ist ein Enddatum erforderlich.

", status_code=400) if event_type != "festival": end_datetime = "" if can_manage_event_access(user, concert): if visibility not in {"public", "friends", "private"}: return HTMLResponse("

Ungültige Sichtbarkeit.

", status_code=400) if event_type != "other": visibility = "public" else: visibility = concert["visibility"] effective_start = start_datetime or concert["start_local"] if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(effective_start): return HTMLResponse("

Das Enddatum darf nicht vor dem Beginn liegen.

", status_code=400) try: next_flyer_url = normalize_external_url(flyer_url, "den Flyer") except ValueError as error: return HTMLResponse(f"

{error}

", status_code=400) 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: try: next_parent_event_id = resolve_event_relationship( cursor, event_type, parent_event_id, concert_id ) except ValueError as error: return HTMLResponse(f"

{error}

", status_code=400) resolved_venue_id = resolve_venue( cursor, venue_id, venue_name or ( concert["venue"]["name"] if concert["venue"]["id"] else "" ), city, street, postal_code, country, latitude, longitude, ) connection.commit() next_start = start_datetime or concert["start_datetime"] next_end = end_datetime or None next_event_type = event_type next_visibility = visibility 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, event_type = %s, parent_event_id = %s, venue_id = %s, start_datetime = %s, end_datetime = %s, description = %s, ticket_url = %s, ticket_price = %s, flyer_path = %s, flyer_url = %s , visibility = %s WHERE id = %s """, ( next_artist, next_event_type, next_parent_event_id, next_venue_id, next_start, next_end or None, next_description, next_ticket_url, next_ticket_price, next_flyer, next_flyer_url, next_visibility, concert_id, ), ) if can_manage_event_access(user, concert) and next_visibility == "private": cursor.execute( "DELETE FROM event_invitations WHERE concert_id = %s AND NOT (user_id = ANY(%s))", (concert_id, invited_user_ids or [0]), ) if invited_user_ids: cursor.execute( """ INSERT INTO event_invitations (concert_id, user_id, invited_by) SELECT %s, id, %s FROM users WHERE id = ANY(%s) AND id <> %s ON CONFLICT (concert_id, user_id) DO NOTHING """, (concert_id, user["id"], invited_user_ids, user["id"]), ) elif can_manage_event_access(user, concert): cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (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 or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung 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("SELECT path FROM concert_photos WHERE concert_id = %s", (concert_id,)) photo_paths = [row[0] for row in cursor.fetchall()] cursor.execute( "DELETE FROM concerts WHERE id = %s", (concert_id,), ) connection.commit() remove_uploaded_file(concert["flyer_path"], UPLOAD_DIR, "/static/uploads/flyers/") for photo_path in photo_paths: remove_uploaded_file(photo_path, PHOTO_DIR, "/static/uploads/photos/") 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", "ticket_offer"}: return HTMLResponse("

Ungültige Auswahl

", status_code=400) concert = load_concert(concert_id) if not concert or not can_view_event(user, concert): return HTMLResponse("

Veranstaltung 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 or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung 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 or not can_view_event(user, concert): return HTMLResponse( "

Veranstaltung 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": "MetalCircle/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": "MetalCircle/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]