diff --git a/.gitignore b/.gitignore index 73ce422..357235e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ env/ .env .env.* !.env.example +compose.dev.yml # Logs *.log diff --git a/app/Dockerfile b/app/Dockerfile index 7bb86f1..8e391ed 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13-slim WORKDIR /app -RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt +RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt Pillow COPY . . diff --git a/app/main.py b/app/main.py index bac8473..b25f54f 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,11 @@ 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 @@ -10,6 +15,11 @@ 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, RedirectResponse from fastapi.staticfiles import StaticFiles @@ -56,13 +66,18 @@ 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 Pingu Concerts mit aufgebaut", "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 besucht", "attendance"), @@ -152,13 +167,30 @@ def ensure_schema(): 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') + 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, @@ -190,6 +222,118 @@ def ensure_schema(): 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 + """, + """ + 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 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: @@ -199,12 +343,11 @@ def ensure_schema(): if INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD: cursor.execute( - "SELECT id FROM users WHERE username = %s", - (INITIAL_ADMIN_USERNAME,), + "SELECT EXISTS (SELECT 1 FROM users)", ) - existing_user = cursor.fetchone() + users_exist = cursor.fetchone()[0] - if not existing_user: + if not users_exist: cursor.execute( """ INSERT INTO users ( @@ -239,12 +382,56 @@ async def lifespan(_app: FastAPI): app = FastAPI(title="Pingu Concerts", lifespan=lifespan) +@app.middleware("http") +async def security_controls(request: Request, call_next): + 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) + if fetch_site == "cross-site" or (origin_host and origin_host != expected_host): + return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", 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.startswith("/static/") ): return await call_next(request) @@ -294,6 +481,25 @@ 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 @@ -303,6 +509,9 @@ def user_from_row(row): "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], + "notification_count": row[4] + row[5], } @@ -320,7 +529,11 @@ def get_current_user(request: Request): users.id, users.username, users.display_name, - users.is_admin + 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) FROM sessions JOIN users ON users.id = sessions.user_id @@ -363,6 +576,8 @@ def attach_session(response: RedirectResponse, token: str) -> RedirectResponse: max_age=SESSION_DAYS * 24 * 60 * 60, httponly=True, samesite="lax", + secure=COOKIE_SECURE, + path="/", ) return response @@ -379,6 +594,85 @@ def concert_is_past(start_datetime, end_datetime) -> bool: 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: @@ -401,10 +695,17 @@ def load_concert(concert_id: int): venues.city, venues.country, venues.latitude, - venues.longitude + venues.longitude, + concerts.event_type, + concerts.parent_event_id, + parent_event.artist, + parent_event.event_type, + concerts.flyer_url 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,), @@ -427,6 +728,7 @@ def load_concert(concert_id: int): "ticket_url": row[5], "ticket_price": row[6], "flyer_path": row[7], + "flyer_url": row[21], "created_by": row[8], "is_past": is_past, "date": start_datetime.strftime("%d.%m.%Y"), @@ -435,6 +737,14 @@ def load_concert(concert_id: int): "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", @@ -477,7 +787,7 @@ def can_delete_concert(user, concert) -> bool: def serialize_concert_card(row): - concert_id, artist, start_datetime, venue, city = row + concert_id, artist, start_datetime, end_datetime, venue, city, event_type, parent_event_id = row venue_text = venue or "Veranstaltungsort unbekannt" if city: venue_text += f", {city}" @@ -487,10 +797,118 @@ def serialize_concert_card(row): "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, + "event_type": event_type, + "event_type_label": EVENT_TYPES[event_type], + "parent_event_id": parent_event_id, + "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(search_query: str = ""): + query = """ + SELECT concerts.id, concerts.artist, concerts.start_datetime, + concerts.end_datetime, venues.name, venues.city, + concerts.event_type, concerts.parent_event_id + FROM concerts + LEFT JOIN venues ON concerts.venue_id = venues.id + ORDER BY concerts.start_datetime ASC + """ + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute(query) + upcoming, past = build_event_overview(cursor.fetchall()) + + term = search_query.strip().casefold() + if not term: + return upcoming, past + + def matches(card): + text = f"{card['artist']} {card['venue']} {card['event_type_label']}".casefold() + return term in text 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 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 @@ -503,8 +921,6 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str): status_code=400, ) - filename = str(uuid.uuid4()) + extension - destination = os.path.join(destination_dir, filename) contents = upload.file.read() if len(contents) > MAX_IMAGE_BYTES: @@ -513,12 +929,52 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str): 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)) + if image.mode not in {"RGB", "RGBA"}: + image = image.convert("RGBA" if "transparency" in image.info else "RGB") + output = BytesIO() + image.save(output, format="WEBP", quality=88, method=6) + 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()) + ".webp" + destination = os.path.join(destination_dir, filename) with open(destination, "wb") as file: - file.write(contents) + 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_count(user_id: int) -> int: with get_db_connection() as connection: with connection.cursor() as cursor: @@ -586,7 +1042,8 @@ def load_profile(username: str): with connection.cursor() as cursor: cursor.execute( """ - SELECT id, username, display_name, avatar_path, created_at + SELECT id, username, display_name, avatar_path, created_at, instagram_url, + profile_visibility, is_admin FROM users WHERE LOWER(username) = LOWER(%s) """, @@ -610,6 +1067,10 @@ def load_profile(username: str): "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]), "earned_codes": earned_codes, } @@ -800,7 +1261,13 @@ def admin_users_page(request: Request): with connection.cursor() as cursor: cursor.execute( """ - SELECT id, username, email, display_name, is_admin, created_at + 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 """ @@ -815,6 +1282,7 @@ def admin_users_page(request: Request): "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 ] @@ -889,7 +1357,13 @@ def create_invite(request: Request): with connection.cursor() as cursor: cursor.execute( """ - SELECT id, username, email, display_name, is_admin, created_at + 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 """ @@ -904,6 +1378,7 @@ def create_invite(request: Request): "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 ] @@ -1026,12 +1501,7 @@ def delete_venue(request: Request, venue_id: int): def remove_patch_file(path: str | None): - if not path: - return - filename = os.path.basename(path) - file_path = os.path.join(PATCH_DIR, filename) - if os.path.isfile(file_path): - os.remove(file_path) + remove_uploaded_file(path, PATCH_DIR, "/static/uploads/patches/") @app.post("/admin/patches/{badge_code}") @@ -1097,6 +1567,7 @@ def update_user_role( request: Request, user_id: int, role: str = Form(...), + founder_badge: str = Form(""), ): user = require_admin(request) @@ -1116,6 +1587,20 @@ def update_user_role( "UPDATE users SET is_admin = %s WHERE id = %s", (role == "admin", user_id), ) + if founder_badge == "on": + cursor.execute( + """ + INSERT INTO user_badges (user_id, badge_code) + VALUES (%s, 'founder') + ON CONFLICT (user_id, badge_code) DO NOTHING + """, + (user_id,), + ) + else: + cursor.execute( + "DELETE FROM user_badges WHERE user_id = %s AND badge_code = 'founder'", + (user_id,), + ) connection.commit() return RedirectResponse("/admin/users", status_code=303) @@ -1135,11 +1620,87 @@ def delete_user(request: Request, user_id: int): with get_db_connection() as connection: with connection.cursor() as cursor: + cursor.execute("SELECT avatar_path FROM users WHERE id = %s", (user_id,)) + target_user = cursor.fetchone() + 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[0], 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(...), @@ -1159,9 +1720,9 @@ def register_user( status_code=400 ) - if len(password) < 8: + if len(password) < 10: return HTMLResponse( - "

Fehler

Das Passwort muss mindestens 8 Zeichen lang sein.

", + "

Fehler

Das Passwort muss mindestens 10 Zeichen lang sein.

", status_code=400 ) @@ -1410,7 +1971,7 @@ def logout(request: Request): connection.commit() response = RedirectResponse("/", status_code=303) - response.delete_cookie(SESSION_COOKIE) + response.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax") return response @@ -1419,39 +1980,31 @@ def logout(request: Request): # ============================================================ @app.get("/", response_class=HTMLResponse) -def home(request: Request): - concert_query = """ - SELECT - concerts.id, - concerts.artist, - concerts.start_datetime, - venues.name, - venues.city - FROM concerts - LEFT JOIN venues - ON concerts.venue_id = venues.id - WHERE COALESCE(concerts.end_datetime, concerts.start_datetime) - {operator} CURRENT_TIMESTAMP - ORDER BY concerts.start_datetime {direction} - """ - - with get_db_connection() as connection: - with connection.cursor() as cursor: - cursor.execute( - concert_query.format(operator=">=", direction="ASC") - ) - upcoming_rows = cursor.fetchall() - - cursor.execute( - concert_query.format(operator="<", direction="DESC") - ) - past_rows = cursor.fetchall() +def home(request: Request, q: str = ""): + upcoming_concerts, _past_concerts = load_event_overview(q) template = templates.get_template("index.html") return template.render( user=get_current_user(request), - upcoming_concerts=[serialize_concert_card(row) for row in upcoming_rows], - past_concerts=[serialize_concert_card(row) for row in past_rows], + upcoming_concerts=upcoming_concerts, + past_concerts=[], + archive=False, + search_query=q.strip(), + user_results=search_users(q), + ) + + +@app.get("/events/past", response_class=HTMLResponse) +def past_events(request: Request, q: str = ""): + _upcoming_concerts, past_concerts = load_event_overview(q) + template = templates.get_template("index.html") + return template.render( + user=get_current_user(request), + upcoming_concerts=[], + past_concerts=past_concerts, + archive=True, + search_query=q.strip(), + user_results=search_users(q), ) @@ -1459,13 +2012,78 @@ def home(request: Request): # Profiles # ============================================================ -def render_profile(request: Request, username: str): +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 + 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], + } + 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": []} + 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) attended_count = attended_concert_count(profile["id"]) grant_earned_badges( profile["id"], @@ -1489,19 +2107,41 @@ def render_profile(request: Request, username: str): ] template = templates.get_template("profile.html") - return template.render( - user=viewer, - profile=profile, - attended_count=attended_count, - badges=badges, - is_own_profile=viewer["id"] == profile["id"], + 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, + 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): +def own_profile(request: Request, saved: str = ""): user = get_current_user(request) - return render_profile(request, user["username"]) + 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("/users/{username}", response_class=HTMLResponse) @@ -1513,9 +2153,24 @@ def user_profile(request: Request, username: str): 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, @@ -1527,19 +2182,223 @@ async def update_profile( 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) + 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, user["id"]), + (display_name.strip(), avatar_path, normalized_instagram_url, profile_visibility, user["id"]), ) + saved_profile = cursor.fetchone() connection.commit() - return RedirectResponse("/profile", status_code=303) + 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( + """ + 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("/friendships/{friendship_id}/{action}") +def manage_friendship(request: Request, friendship_id: int, action: str): + 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() + 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 (%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["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 = [] + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT u.id, u.username, COALESCE(u.display_name, u.username), u.avatar_path + FROM users u + WHERE u.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["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_from_me": bool(latest and latest[2] == user["id"]), + "unread_count": latest[3] if latest else 0, + }) + template = templates.get_template("messages.html") + return template.render(user=user, conversations=conversations, 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) # ============================================================ @@ -1557,7 +2416,7 @@ def new_concert(request: Request): return login_redirect("/concerts/new") template = templates.get_template("new_concert.html") - return template.render(user=user) + return template.render(user=user, linkable_events=get_linkable_events()) # ============================================================ @@ -1573,7 +2432,7 @@ def concert_detail(request: Request, concert_id: int): if not concert: return HTMLResponse( - "

Konzert nicht gefunden

", + "

Veranstaltung nicht gefunden

", status_code=404 ) @@ -1665,6 +2524,7 @@ def concert_detail(request: Request, concert_id: int): ] 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"]), @@ -1684,6 +2544,8 @@ def concert_detail(request: Request, concert_id: int): 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), ) @@ -1697,6 +2559,8 @@ def concert_detail(request: Request, concert_id: int): async def create_concert( request: Request, artist: str = Form(...), + event_type: str = Form("concert"), + parent_event_id: str = Form(""), venue_id: str = Form(""), venue_name: str = Form(""), city: str = Form(""), @@ -1710,6 +2574,7 @@ async def create_concert( 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) @@ -1717,6 +2582,19 @@ async def create_concert( 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 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, @@ -1728,6 +2606,13 @@ async def create_concert( 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, @@ -1744,6 +2629,8 @@ async def create_concert( """ INSERT INTO concerts ( artist, + event_type, + parent_event_id, venue_id, start_datetime, end_datetime, @@ -1751,15 +2638,18 @@ async def create_concert( ticket_url, ticket_price, flyer_path, + flyer_url, created_by ) VALUES ( - %s, %s, %s, %s, %s, %s, %s, %s, %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, @@ -1767,6 +2657,7 @@ async def create_concert( ticket_url or None, ticket_price or None, flyer_path, + normalized_flyer_url, user["id"], ), ) @@ -1801,7 +2692,7 @@ def edit_concert_page(request: Request, concert_id: int): if not concert: return HTMLResponse( - "

Konzert nicht gefunden

", + "

Veranstaltung nicht gefunden

", status_code=404 ) @@ -1815,6 +2706,7 @@ def edit_concert_page(request: Request, concert_id: int): 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), @@ -1826,6 +2718,8 @@ async def edit_concert( request: Request, concert_id: int, artist: str = Form(""), + event_type: str = Form("concert"), + parent_event_id: str = Form(""), venue_id: str = Form(""), venue_name: str = Form(""), city: str = Form(""), @@ -1839,6 +2733,7 @@ async def edit_concert( 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) @@ -1850,7 +2745,7 @@ async def edit_concert( if not concert: return HTMLResponse( - "

Konzert nicht gefunden

", + "

Veranstaltung nicht gefunden

", status_code=404 ) @@ -1870,9 +2765,26 @@ async def edit_concert( 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_parent_event_id = concert["parent_event"]["id"] if concert["parent_event"] else None if can_edit_details(user, concert): + 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 = "" + 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, @@ -1883,10 +2795,19 @@ async def edit_concert( 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"], + venue_name or ( + concert["venue"]["name"] + if concert["venue"]["id"] else "" + ), city, street, postal_code, @@ -1898,6 +2819,7 @@ async def edit_concert( next_start = start_datetime or concert["start_datetime"] next_end = end_datetime or None + next_event_type = event_type next_description = description or None next_ticket_url = ticket_url or None next_ticket_price = ticket_price or None @@ -1911,17 +2833,22 @@ async def edit_concert( 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_path = %s, + flyer_url = %s WHERE id = %s """, ( next_artist, + next_event_type, + next_parent_event_id, next_venue_id, next_start, next_end or None, @@ -1929,6 +2856,7 @@ async def edit_concert( next_ticket_url, next_ticket_price, next_flyer, + next_flyer_url, concert_id, ), ) @@ -1951,7 +2879,7 @@ def delete_concert(request: Request, concert_id: int): if not concert: return HTMLResponse( - "

Konzert nicht gefunden

", + "

Veranstaltung nicht gefunden

", status_code=404 ) @@ -1963,12 +2891,18 @@ def delete_concert(request: Request, concert_id: int): 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) @@ -1982,10 +2916,10 @@ def set_attendance( if not user: return login_redirect(f"/concerts/{concert_id}") - if status not in {"attending", "maybe", "ticket_search"}: + if status not in {"attending", "maybe", "ticket_search", "ticket_offer"}: return HTMLResponse("

Ungültige Auswahl

", status_code=400) if not load_concert(concert_id): - return HTMLResponse("

Konzert nicht gefunden

", status_code=404) + return HTMLResponse("

Veranstaltung nicht gefunden

", status_code=404) with get_db_connection() as connection: with connection.cursor() as cursor: @@ -2020,7 +2954,7 @@ def add_comment( if not concert: return HTMLResponse( - "

Konzert nicht gefunden

", + "

Veranstaltung nicht gefunden

", status_code=404 ) @@ -2074,7 +3008,7 @@ async def add_photo( if not concert: return HTMLResponse( - "

Konzert nicht gefunden

", + "

Veranstaltung nicht gefunden

", status_code=404 ) diff --git a/app/static/css/style.css b/app/static/css/style.css index 0824b01..73a853e 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1,12 +1,12 @@ :root { - --bg: #0b0f19; - --surface: #151b29; - --surface-hover: #1b2333; - --border: #293449; - --text: #f3f4f6; - --muted: #9ca3af; - --accent: #8b5cf6; - --accent-hover: #7c3aed; + --bg: #050505; + --surface: #101010; + --surface-hover: #1a1111; + --border: #3a3030; + --text: #f5f5f4; + --muted: #a8a29e; + --accent: #b91c1c; + --accent-hover: #dc2626; } * { @@ -23,7 +23,7 @@ body { "Segoe UI", sans-serif; - background: var(--bg); + background: radial-gradient(circle at 50% -15%, #320909 0, transparent 32rem), var(--bg); color: var(--text); } @@ -33,7 +33,7 @@ a { } header { - background: rgba(21, 27, 41, 0.95); + background: rgba(8, 8, 8, 0.96); border-bottom: 1px solid var(--border); position: sticky; top: 0; @@ -54,12 +54,27 @@ header { .logo { font-size: 1.4rem; font-weight: 800; + font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif; + letter-spacing: .06em; + text-transform: uppercase; } .logo span { color: var(--accent); } +.user-menu { position: relative; margin-left: auto; } +.user-menu summary { padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; cursor: pointer; list-style: none; font-weight: 700; } +.user-menu summary::-webkit-details-marker { display: none; } +.user-menu[open] summary { border-color: var(--accent); background: var(--surface-hover); } +.user-menu-panel { position: absolute; top: calc(100% + 7px); right: 0; z-index: 100; min-width: 170px; padding: 7px; background: #090909; border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 14px 35px rgba(0,0,0,.6); } +.user-menu-panel a, .user-menu-panel button { display: block; width: 100%; padding: 10px 11px; color: var(--text); background: transparent; border: 0; border-radius: 7px; text-align: left; font: inherit; cursor: pointer; } +.user-menu-panel a:hover, .user-menu-panel button:hover { background: var(--surface-hover); color: #f87171; } +.user-menu-panel form { max-width: none; margin: 0; } +.metal-notification { position: relative; display: inline-block; margin-left: 5px; color: #ef4444; filter: saturate(1.5); } +.metal-notification b { position: absolute; top: -7px; right: -9px; min-width: 16px; padding: 1px 4px; color: #fff; background: #b91c1c; border-radius: 999px; font: 700 .65rem/1.3 system-ui, sans-serif; text-align: center; } +.menu-count { float: right; min-width: 22px; padding: 1px 6px; color: #fff; background: #b91c1c; border-radius: 999px; text-align: center; } + main { max-width: 1100px; margin: 0 auto; @@ -73,6 +88,8 @@ main { .page-title h1 { margin: 0; font-size: 2rem; + font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif; + letter-spacing: .04em; } .page-title p { diff --git a/app/static/images/black-denim-texture.webp b/app/static/images/black-denim-texture.webp new file mode 100644 index 0000000..42f138c Binary files /dev/null and b/app/static/images/black-denim-texture.webp differ diff --git a/app/static/style.css b/app/static/style.css index d22d593..d6a8d3c 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -93,6 +93,8 @@ a { width: 100%; background: #0a0d12; display: flex; + flex-direction: column; + align-items: center; justify-content: center; padding: 25px; } @@ -363,3 +365,61 @@ a { } } + +/* Dark metal theme */ +body { + background: radial-gradient(circle at 50% -15%, #320909 0, transparent 34rem), #050505; + color: #f5f5f4; +} + +.concert-detail, +.comment, +.attendance-option { + background-color: #101010; + border-color: #3a3030; +} + +.info-item, +.attendance-section, +.comments-section, +.coming-soon { + background-color: #090909; + border-color: #3a3030; +} + +.concert-content h1, +.description h2, +.attendance-section h2, +.comments-section h2 { + font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif; + letter-spacing: .04em; +} + +.concert-action-edit, +.ticket-button, +.attendance-option:hover, +.attendance-option.is-selected { + background: #7f1d1d; + border-color: #dc2626; +} + +.concert-action:hover, +.concert-action-edit:hover, +.ticket-button:hover { + background: #991b1b; + border-color: #ef4444; +} + +.attendance-list summary { color: #ef4444; } + +.user-menu { position: relative; margin-left: auto; } +.user-menu summary { padding: 9px 12px; border: 1px solid #3a3030; border-radius: 9px; cursor: pointer; list-style: none; font-weight: 700; } +.user-menu summary::-webkit-details-marker { display: none; } +.user-menu[open] summary { border-color: #dc2626; background: #1a1111; } +.user-menu-panel { position: absolute; top: calc(100% + 7px); right: 0; z-index: 100; min-width: 170px; padding: 7px; background: #090909; border: 1px solid #3a3030; border-radius: 10px; box-shadow: 0 14px 35px rgba(0,0,0,.6); } +.user-menu-panel a, .user-menu-panel button { display: block; width: 100%; padding: 10px 11px; color: #f5f5f4; background: transparent; border: 0; border-radius: 7px; text-align: left; font: inherit; cursor: pointer; } +.user-menu-panel a:hover, .user-menu-panel button:hover { background: #1a1111; color: #f87171; } +.user-menu-panel form { margin: 0; } +.metal-notification { position: relative; display: inline-block; margin-left: 5px; color: #ef4444; filter: saturate(1.5); } +.metal-notification b { position: absolute; top: -7px; right: -9px; min-width: 16px; padding: 1px 4px; color: #fff; background: #b91c1c; border-radius: 999px; font: 700 .65rem/1.3 system-ui, sans-serif; text-align: center; } +.menu-count { float: right; min-width: 22px; padding: 1px 6px; color: #fff; background: #b91c1c; border-radius: 999px; text-align: center; } diff --git a/app/templates/_user_menu.html b/app/templates/_user_menu.html new file mode 100644 index 0000000..ddf4b0e --- /dev/null +++ b/app/templates/_user_menu.html @@ -0,0 +1,10 @@ +
+ ☠ {{ user.display_name }}{% if user.notification_count %} 🤘{{ user.notification_count }}{% endif %} +
+ Mein Profil + Nachrichten{% if user.unread_message_count %} {{ user.unread_message_count }}{% endif %} +
+ +
+
+
diff --git a/app/templates/account_link.html b/app/templates/account_link.html new file mode 100644 index 0000000..5d2cb19 --- /dev/null +++ b/app/templates/account_link.html @@ -0,0 +1,4 @@ +Account-Link · Pingu Concerts +
{% include '_user_menu.html' %}
+

Passwort zurücksetzen

Reset-Link für @{{ target_username }} · {{ target_email }}

+

Diesen persönlichen Link sicher an den User senden. Er ist zwei Stunden gültig und nur einmal verwendbar.

Zurück

diff --git a/app/templates/admin_patches.html b/app/templates/admin_patches.html index 8cfe396..97680ed 100644 --- a/app/templates/admin_patches.html +++ b/app/templates/admin_patches.html @@ -15,7 +15,7 @@ -
{{ user.display_name }}
+
{% include '_user_menu.html' %}

🧵 Patch-Bilder

Grafiken für die Patches auf der virtuellen Kutte verwalten.

{% set admin_section = 'patches' %}{% include '_admin_nav.html' %} diff --git a/app/templates/admin_users.html b/app/templates/admin_users.html index 0b71314..35584f5 100644 --- a/app/templates/admin_users.html +++ b/app/templates/admin_users.html @@ -7,14 +7,16 @@ -
{{ user.display_name }}
+
{% include '_user_menu.html' %}

⚙️ Verwaltung

Benutzerkonten und Einladungen verwalten.

{% set admin_section = 'users' %}{% include '_admin_nav.html' %} @@ -32,8 +34,12 @@
{{ managed_user.display_name }}
@{{ managed_user.username }} · {{ managed_user.email }} · seit {{ managed_user.created_at }}
+
+
+
+
{% if managed_user.id != user.id %}
{% endif %} {% endfor %} diff --git a/app/templates/admin_venues.html b/app/templates/admin_venues.html index cb3f5f2..f9af748 100644 --- a/app/templates/admin_venues.html +++ b/app/templates/admin_venues.html @@ -13,19 +13,37 @@ .venue-actions label { display: flex; align-items: center; gap: 6px; } .merge-form { display: grid; grid-template-columns: minmax(180px, 1fr) auto; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } .delete-form { margin-top: 10px; text-align: right; } + .venue-card { padding: 0; overflow: hidden; } + .venue-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 16px; cursor: pointer; list-style: none; } + .venue-summary::-webkit-details-marker { display: none; } + .venue-summary::after { content: "▾"; color: var(--muted); transition: transform .15s ease; } + .venue-card[open] .venue-summary::after { transform: rotate(180deg); } + .venue-card[open] .venue-summary { border-bottom: 1px solid var(--border); background: var(--surface-hover); } + .venue-summary-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .venue-summary-meta { display: block; margin-top: 3px; color: var(--muted); font-size: .83rem; } + .venue-verified { color: #86efac; } + .venue-unverified { color: #fca5a5; } + .venue-editor { padding: 16px; } @media (max-width: 900px) { .venue-fields { grid-template-columns: 1fr 1fr; } } @media (max-width: 560px) { .venue-fields, .merge-form { grid-template-columns: 1fr; } } -
{{ user.display_name }}
+
{% include '_user_menu.html' %}

📍 Veranstaltungsorte

Orte korrigieren, bestätigen, zusammenführen oder entfernen.

{% set admin_section = 'venues' %}{% include '_admin_nav.html' %}
{% for venue in venues %} -
+
+ + + {{ venue.name }}{% if venue.city %} · {{ venue.city }}{% endif %}{% if venue.country %} · {{ venue.country }}{% endif %} + {% if venue.is_verified %}✓ bestätigt{% else %}● unbestätigt{% endif %} · {{ venue.concert_count }} Veranstaltung(en) · ID {{ venue.id }} + + +
@@ -50,7 +68,8 @@ -
+
+ {% else %}

Noch keine Veranstaltungsorte vorhanden.

{% endfor %}
diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html index 1c1d0e5..773fc8a 100644 --- a/app/templates/concert_detail.html +++ b/app/templates/concert_detail.html @@ -17,28 +17,44 @@ href="/static/style.css" > + +
- - ← Zurück zum Kalender - +
+ ← Zurück zum Kalender + {% include '_user_menu.html' %} +
- {% if concert.flyer_path %} + {% if concert.flyer_url or concert.flyer_path %}
Flyer {{ concert.artist }} + {% if concert.flyer_url %} +

QuelleExtern eingebundener Flyer. Rechte liegen beim jeweiligen Veranstalter bzw. Rechteinhaber. Hinweise zur Entfernung bitte an die Administration.

+ {% endif %} +
{% endif %} @@ -46,6 +62,8 @@
+ {{ concert.event_type_label }} +

{{ concert.artist }}

@@ -69,6 +87,11 @@ {{ concert.time }} Uhr + {% if concert.event_type == 'festival' and concert.end_date %} +
+ bis {{ concert.end_date }}{% if concert.end_time %}, {{ concert.end_time }} Uhr{% endif %} + {% endif %} +
@@ -96,12 +119,19 @@ {% if concert.venue.postal_code - or concert.venue.city %} + or concert.venue.city + or (concert.venue.country + and concert.venue.country not in ('Deutschland', 'Germany')) %}
{{ concert.venue.postal_code }} {{ concert.venue.city }} + {% if concert.venue.country + and concert.venue.country not in ('Deutschland', 'Germany') %} + {% if concert.venue.postal_code or concert.venue.city %} · {% endif %} + {{ concert.venue.country }} + {% endif %} {% endif %} @@ -136,13 +166,22 @@ + {% if concert.parent_event %} +

+ Gehört zu: + + {{ concert.parent_event.event_type_label }} · {{ concert.parent_event.title }} + +

+ {% endif %} + {% if concert.description %}

- Über das Konzert + Über die Veranstaltung

@@ -173,9 +212,9 @@ {% if can_edit %} -

+
- ✏️ Konzert bearbeiten + ✏️ Veranstaltung bearbeiten {% if user.is_admin and can_delete %} @@ -183,10 +222,10 @@
@@ -234,7 +273,17 @@ type="submit" class="attendance-option {% if current_attendance == 'ticket_search' %}is-selected{% endif %}" > - 🎟️ Suche Ticket + 🔎 Suche Ticket + + + +
+ +
@@ -245,6 +294,7 @@ {{ attending_count }} Zusage{% if attending_count != 1 %}n{% endif %} · {{ maybe_count }} Vielleicht · {{ ticket_seeker_count }} sucht Ticket + · {{ ticket_offer_count }} bietet Ticket
@@ -278,7 +328,7 @@
-

🎟️ Sucht ein Ticket

+

🔎 Sucht ein Ticket

{% if ticket_seekers %}
    @@ -291,6 +341,20 @@ {% endif %}
+
+

🎟️ Bietet ein Ticket

+ + {% if ticket_offers %} + + {% else %} +

Aktuell bietet niemand ein Ticket an.

+ {% endif %} +
+
diff --git a/app/templates/edit_concert.html b/app/templates/edit_concert.html index 40b463a..19108d8 100644 --- a/app/templates/edit_concert.html +++ b/app/templates/edit_concert.html @@ -10,17 +10,39 @@
+ {% include '_user_menu.html' %}
-

✏️ Konzert bearbeiten

-

Änderungen werden direkt beim Konzert gespeichert.

+

✏️ Veranstaltung bearbeiten

+

Änderungen werden direkt bei der Veranstaltung gespeichert.

+ {% if can_edit_details %} +

+ +

+

+ +

+ {% endif %}

-

@@ -53,9 +75,9 @@

-

+

@@ -73,14 +95,20 @@

+

+ + Bitte möglichst die offizielle Quelle verwenden. Ein externer Link wird gegenüber einem Upload bevorzugt und unter dem Bild genannt. Feld leeren, um wieder den Upload zu verwenden. +

Ohne neue Datei bleibt der bisherige Flyer erhalten.

- {% if concert.flyer_path %} -

Aktueller Flyer

+ {% if concert.flyer_url or concert.flyer_path %} +

Aktueller Flyer

{% endif %} {% endif %}
@@ -90,5 +118,26 @@
+{% if can_edit_details %} + +{% endif %} diff --git a/app/templates/index.html b/app/templates/index.html index de5e403..b9db149 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -78,6 +78,33 @@ gap: 15px; } + .event-group { display: grid; gap: 8px; } + + .linked-events { + display: grid; + gap: 7px; + margin-left: 30px; + } + + .concert-card.linked-event { + padding: 14px 18px; + border-left: 3px solid #a78bfa; + background: #121720; + } + + .linked-event .concert-artist { font-size: 1.05rem; margin-bottom: 5px; } + .linked-event .concert-meta { font-size: .9rem; } + + .event-category { + display: inline-block; + margin-bottom: 7px; + color: #aab4c3; + font-size: .78rem; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + } + .past-concerts-title { margin: 28px 0 0; } @@ -127,6 +154,33 @@ color: #8b949e; } + .search-panel { margin-bottom: 26px; padding: 16px; background: rgba(16,16,16,.92); border: 1px solid #3a3030; border-radius: 14px; } + .search-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; } + .search-form input { margin: 0; } + .search-results-title { margin: 24px 0 12px; font-size: 1.15rem; } + .user-results { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; margin-bottom: 24px; } + .user-result { display: flex; align-items: center; gap: 11px; padding: 12px; background: #101010; border: 1px solid #3a3030; border-radius: 11px; } + .user-result:hover { border-color: #dc2626; } + .user-result-avatar { width: 44px; height: 44px; flex: 0 0 44px; object-fit: cover; border-radius: 50%; background: #292524; display: grid; place-items: center; font-weight: 800; } + .user-result-name { min-width: 0; } + .user-result-name strong, .user-result-name span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .user-result-name span { color: #a8a29e; font-size: .86rem; } + + body { + background: radial-gradient(circle at 50% -20%, #3a0909 0, transparent 34rem), #050505; + min-height: 100vh; + } + + .page-header { padding-bottom: 22px; border-bottom: 1px solid #3f2424; } + .page-header h1 { font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif; letter-spacing: .05em; text-transform: uppercase; text-shadow: 0 2px 18px rgba(185, 28, 28, .45); } + .new-concert-button { background: #991b1b; border: 1px solid #dc2626; } + .new-concert-button:hover { background: #b91c1c; } + .admin-button:hover { border-color: #dc2626; } + .concert-card { background: linear-gradient(145deg, #101010, #181313); border-color: #3a3030; } + .concert-card:hover { border-color: #dc2626; background: #1c1212; } + .concert-date { color: #ef4444; } + .concert-card.linked-event { border-left-color: #dc2626; background: #0d0d0d; } + @media (max-width: 600px) { .page-header { @@ -139,12 +193,31 @@ text-align: center; } + .linked-events { margin-left: 16px; } + .search-form { grid-template-columns: 1fr; } + } +{% macro event_card(concert, linked=false) %} + + {{ concert.event_type_label }} +
+ {% if concert.event_type == 'festival' %}🎪{% elif concert.event_type == 'other' %}🥂{% else %}🎸{% endif %} + {{ concert.artist }} +
+
+
+ 📅 {{ concert.date }}{% if concert.end_date %} – {{ concert.end_date }}{% else %} um {{ concert.time }} Uhr{% endif %} +
+
📍 {{ concert.venue }}
+
+
+{% endmacro %} + @@ -155,11 +228,11 @@

- 🐧 Pingu Concerts + {% if archive %}☠ Vergangene Veranstaltungen{% else %}🐧 Pingu Concerts{% endif %}

- Unser Konzertkalender + {% if archive %}Das Archiv vergangener Nächte{% else %}Konzerte, Festivals und alles davor{% endif %}
@@ -167,8 +240,8 @@
- - 👤 Mein Profil + + {% if archive %}⚡ Bevorstehend{% else %}☠ Vergangene{% endif %} {% if user.is_admin %} @@ -183,79 +256,69 @@ href="/concerts/new" class="new-concert-button" > - + Konzert hinzufügen + + Veranstaltung hinzufügen + {% include '_user_menu.html' %} +
+
+
+ + +
+
+ + {% if search_query %} +

User zu „{{ search_query }}“

+ {% if user_results %} + + {% else %}

Keine passenden User gefunden.

{% endif %} +

Veranstaltungen zu „{{ search_query }}“

+ {% endif %} + {% if upcoming_concerts or past_concerts %}
{% for concert in upcoming_concerts %} - - - -
- 🎸 {{ concert.artist }} -
- - -
- - {% endfor %} - {% if past_concerts %} + {% if archive and past_concerts %}

- Vergangene Konzerte + Archiv

{% for concert in past_concerts %} - - - -
- 🎸 {{ concert.artist }} -
- -
- - {% endfor %} @@ -268,11 +331,11 @@

- Noch keine Konzerte 🎸 + {% if archive %}Noch keine vergangenen Veranstaltungen ☠{% else %}Noch keine Veranstaltungen 🎸{% endif %}

- Leg das erste Konzert an! + {% if archive %}Hier erscheinen abgeschlossene Veranstaltungen.{% else %}Leg die erste Veranstaltung an!{% endif %}

diff --git a/app/templates/login.html b/app/templates/login.html index 0b8e3f7..560a1d1 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -32,6 +32,7 @@

+

Passwort vergessen? Bitte einen Admin um einen zeitlich begrenzten Reset-Link.

Abbrechen
diff --git a/app/templates/messages.html b/app/templates/messages.html new file mode 100644 index 0000000..48cdb05 --- /dev/null +++ b/app/templates/messages.html @@ -0,0 +1,60 @@ + + + + + + Nachrichten · Pingu Concerts + + + + +
{% include '_user_menu.html' %}
+
+ {% if partner %} +
+ {% if partner.avatar_path %}{% else %}{{ partner.display_name[:1] }}{% endif %} +

{{ partner.display_name }}

@{{ partner.username }}
+
+
+ {% for message in messages %}
{{ message.body }}{{ message.created_at }}
+ {% else %}

Noch keine Nachrichten. Schreib die erste!

{% endfor %} +
+
+ + +
+

← Alle Nachrichten

+ {% else %} +

🤘 Nachrichten

Private Chats mit deinen Freunden.

+ + {% endif %} +
+ + diff --git a/app/templates/new_concert.html b/app/templates/new_concert.html index dcb20e4..42dcc83 100644 --- a/app/templates/new_concert.html +++ b/app/templates/new_concert.html @@ -10,7 +10,7 @@ content="width=device-width, initial-scale=1.0" > - Konzert hinzufügen · Pingu Concerts + Veranstaltung hinzufügen · Pingu Concerts Concerts + {% include '_user_menu.html' %} + @@ -87,7 +89,7 @@
-

🎤 Konzert hinzufügen

+

🎤 Veranstaltung hinzufügen

Trag ein, worauf wir uns freuen. @@ -104,6 +106,26 @@ enctype="multipart/form-data" > +

+
+ +

+ + + @@ -111,7 +133,7 @@

+ + @@ -221,7 +249,7 @@ @@ -277,9 +305,13 @@
+ + +
Bitte möglichst den offiziellen Flyer des Veranstalters oder der Band verlinken. Das Bild wird nicht auf Pingu Concerts kopiert; der Link wird als Quelle ausgewiesen.
+ @@ -294,7 +326,7 @@
- JPG, PNG oder WEBP · maximal 10 MB + Alternative, falls kein offizieller externer Link verfügbar ist: JPG, PNG oder WEBP · maximal 10 MB. Ist ein Link angegeben, wird er bevorzugt dargestellt.
@@ -330,7 +362,7 @@ type="submit" class="button" > - 💾 Konzert speichern + 💾 Veranstaltung speichern @@ -352,6 +384,25 @@ diff --git a/app/templates/register.html b/app/templates/register.html index 47274cf..0f025fc 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -75,6 +75,8 @@ type="password" name="password" required + minlength="10" + autocomplete="new-password" >