4949 lines
178 KiB
Python
4949 lines
178 KiB
Python
import os
|
||
import uuid
|
||
import secrets
|
||
import hashlib
|
||
import re
|
||
import time
|
||
import threading
|
||
import unicodedata
|
||
from difflib import SequenceMatcher
|
||
from collections import defaultdict, deque
|
||
from html import escape
|
||
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 FileResponse, 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"
|
||
)
|
||
|
||
DIARY_PHOTO_DIR = os.path.join(
|
||
os.environ.get("PRIVATE_UPLOAD_DIR", os.path.join(BASE_DIR, "private_uploads")),
|
||
"diary"
|
||
)
|
||
|
||
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)
|
||
os.makedirs(DIARY_PHOTO_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"),
|
||
("border_breaker", "BORDER BREAKER", "🛂", 1, "Erstes Konzert im Ausland", "travel"),
|
||
("globe_banger", "GLOBE BANGER", "🌍", 5, "Konzerte in 5 Ländern", "travel"),
|
||
("double_trouble", "DOUBLE TROUBLE", "⚡", 2, "Zwei Konzerte an zwei aufeinanderfolgenden Tagen", "streak"),
|
||
("iron_weekend", "IRON WEEKEND", "🤘", 3, "Drei Konzerte innerhalb eines Wochenendes", "streak"),
|
||
)
|
||
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
|
||
}
|
||
BADGE_CATEGORY_LABELS = {
|
||
"special": "⚔️ Spezial-Patches",
|
||
"beta": "🧪 Community",
|
||
"attendance": "🎸 Konzert-Meilensteine",
|
||
"venue": "🤘 Stammorte",
|
||
"travel": "🌍 Unterwegs",
|
||
"streak": "⚡ Tourmodus",
|
||
}
|
||
|
||
|
||
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,
|
||
trigger_concert_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL,
|
||
awarded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (user_id, badge_code)
|
||
)
|
||
""",
|
||
"""
|
||
ALTER TABLE user_badges ADD COLUMN IF NOT EXISTS trigger_concert_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL
|
||
""",
|
||
"""
|
||
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)
|
||
""",
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS followed_bands (
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
band_key VARCHAR(255) NOT NULL,
|
||
display_name VARCHAR(255) NOT NULL,
|
||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (user_id, band_key)
|
||
)
|
||
""",
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS followed_venues (
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,
|
||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (user_id, venue_id)
|
||
)
|
||
""",
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS concert_diary (
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
|
||
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||
favorite_song VARCHAR(255),
|
||
notes TEXT,
|
||
photo_path TEXT,
|
||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (user_id, concert_id)
|
||
)
|
||
""",
|
||
"""
|
||
ALTER TABLE concert_diary ADD COLUMN IF NOT EXISTS photo_path TEXT
|
||
""",
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS concert_bands (
|
||
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
|
||
band_key VARCHAR(255) NOT NULL,
|
||
display_name VARCHAR(255) NOT NULL,
|
||
position SMALLINT NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (concert_id, band_key)
|
||
)
|
||
""",
|
||
]
|
||
|
||
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 normalize_artist_name(value: str) -> str:
|
||
normalized = unicodedata.normalize("NFKD", value or "")
|
||
without_accents = "".join(character for character in normalized if not unicodedata.combining(character))
|
||
return " ".join(re.findall(r"[a-z0-9]+", without_accents.casefold()))
|
||
|
||
|
||
def artist_names_similar(first: str, second: str) -> bool:
|
||
left = normalize_artist_name(first)
|
||
right = normalize_artist_name(second)
|
||
if not left or not right:
|
||
return False
|
||
if left == right:
|
||
return True
|
||
if min(len(left), len(right)) >= 4 and (left in right or right in left):
|
||
return True
|
||
if min(len(left), len(right)) < 5:
|
||
return False
|
||
return SequenceMatcher(None, left, right).ratio() >= 0.78
|
||
|
||
|
||
def inferred_band_names(title: str, event_type: str = "concert") -> list[str]:
|
||
if event_type != "concert":
|
||
return []
|
||
primary = re.split(r"\s+(?:-|–|—)\s+|:\s+", title.strip(), maxsplit=1)[0]
|
||
return [part.strip() for part in re.split(r"\s+(?:\+|/|&|and|und)\s+", primary) if part.strip()]
|
||
|
||
|
||
def parse_band_names(value: str, fallback_title: str = "", event_type: str = "concert") -> list[dict]:
|
||
names = [line.strip() for line in (value or "").splitlines() if line.strip()]
|
||
if not names:
|
||
names = inferred_band_names(fallback_title, event_type)
|
||
bands = []
|
||
seen = set()
|
||
for name in names[:30]:
|
||
key = normalize_artist_name(name)[:255]
|
||
if key and key not in seen:
|
||
seen.add(key)
|
||
bands.append({"key": key, "name": name[:255]})
|
||
return bands
|
||
|
||
|
||
def load_concert_bands(concert_id: int, title: str = "", event_type: str = "concert") -> list[dict]:
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
"SELECT band_key, display_name FROM concert_bands WHERE concert_id = %s ORDER BY position, display_name",
|
||
(concert_id,),
|
||
)
|
||
bands = [{"key": row[0], "name": row[1]} for row in cursor.fetchall()]
|
||
return bands or parse_band_names("", title, event_type)
|
||
|
||
|
||
def replace_concert_bands(cursor, concert_id: int, bands: list[dict]):
|
||
cursor.execute("DELETE FROM concert_bands WHERE concert_id = %s", (concert_id,))
|
||
for position, band in enumerate(bands):
|
||
cursor.execute(
|
||
"INSERT INTO concert_bands (concert_id, band_key, display_name, position) VALUES (%s, %s, %s, %s)",
|
||
(concert_id, band["key"], band["name"], position),
|
||
)
|
||
|
||
|
||
def find_duplicate_concerts(user, artist: str, start_date: str, band_names: str = ""):
|
||
try:
|
||
concert_date = datetime.strptime(start_date[:10], "%Y-%m-%d").date()
|
||
except (TypeError, ValueError):
|
||
return []
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
"""
|
||
SELECT c.id, c.artist, c.start_datetime, c.event_type,
|
||
COALESCE(v.name, ''), COALESCE(v.city, '')
|
||
FROM concerts c
|
||
LEFT JOIN venues v ON v.id = c.venue_id
|
||
WHERE c.start_datetime::date = %s
|
||
AND (
|
||
c.visibility = 'public' OR c.created_by = %s OR %s
|
||
OR EXISTS (
|
||
SELECT 1 FROM event_invitations ei
|
||
WHERE ei.concert_id = c.id AND ei.user_id = %s
|
||
)
|
||
OR (c.visibility = 'friends' AND EXISTS (
|
||
SELECT 1 FROM friendships f
|
||
WHERE f.status = 'accepted'
|
||
AND ((f.requester_id = c.created_by AND f.addressee_id = %s)
|
||
OR (f.addressee_id = c.created_by AND f.requester_id = %s))
|
||
))
|
||
)
|
||
ORDER BY c.start_datetime, c.id
|
||
""",
|
||
(concert_date, user["id"], user["is_admin"], user["id"], user["id"], user["id"]),
|
||
)
|
||
rows = cursor.fetchall()
|
||
row_ids = [row[0] for row in rows]
|
||
cursor.execute(
|
||
"SELECT concert_id, band_key, display_name FROM concert_bands WHERE concert_id = ANY(%s) ORDER BY position",
|
||
(row_ids or [0],),
|
||
)
|
||
stored_bands = {}
|
||
for concert_id, band_key, display_name in cursor.fetchall():
|
||
stored_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name})
|
||
submitted_bands = parse_band_names(band_names, artist, "concert")
|
||
return [
|
||
{
|
||
"id": row[0],
|
||
"artist": row[1],
|
||
"date": row[2].strftime("%d.%m.%Y"),
|
||
"time": row[2].strftime("%H:%M"),
|
||
"event_type": row[3],
|
||
"venue": ", ".join(part for part in (row[4], row[5]) if part),
|
||
}
|
||
for row in rows
|
||
if any(
|
||
artist_names_similar(submitted["name"], existing["name"])
|
||
for submitted in submitted_bands
|
||
for existing in (stored_bands.get(row[0]) or parse_band_names("", row[1], row[3]))
|
||
) or artist_names_similar(artist, row[1])
|
||
]
|
||
|
||
|
||
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 normalize_country(value: str) -> str:
|
||
country = (value or "").strip().casefold()
|
||
aliases = {"de": "deutschland", "deu": "deutschland", "germany": "deutschland"}
|
||
return aliases.get(country, country)
|
||
|
||
|
||
def attended_concert_stats(user_id: int) -> dict:
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
"""
|
||
SELECT concerts.id, concerts.venue_id, concerts.event_type,
|
||
concerts.start_datetime, COALESCE(venues.country, '')
|
||
FROM concert_attendance
|
||
JOIN concerts ON concerts.id = concert_attendance.concert_id
|
||
LEFT JOIN venues ON venues.id = concerts.venue_id
|
||
WHERE concert_attendance.user_id = %s
|
||
AND concert_attendance.status = 'attending'
|
||
AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP
|
||
ORDER BY concerts.start_datetime, concerts.id
|
||
""",
|
||
(user_id,),
|
||
)
|
||
rows = cursor.fetchall()
|
||
|
||
venue_counts = {}
|
||
countries = set()
|
||
dates = []
|
||
weekend_counts = {}
|
||
badge_triggers = {}
|
||
seen_countries = set()
|
||
venue_event_ids = {}
|
||
weekend_event_ids = {}
|
||
previous_date = None
|
||
for concert_id, venue_id, event_type, concert_datetime, country_value in rows:
|
||
concert_date = concert_datetime.date()
|
||
if venue_id is not None and event_type != "other":
|
||
venue_counts[venue_id] = venue_counts.get(venue_id, 0) + 1
|
||
venue_event_ids.setdefault(venue_id, []).append(concert_id)
|
||
if venue_counts[venue_id] == 5:
|
||
badge_triggers.setdefault("regular", concert_id)
|
||
country = normalize_country(country_value)
|
||
if country:
|
||
countries.add(country)
|
||
if country not in seen_countries:
|
||
seen_countries.add(country)
|
||
if country != "deutschland":
|
||
badge_triggers.setdefault("border_breaker", concert_id)
|
||
if len(seen_countries) == 5:
|
||
badge_triggers.setdefault("globe_banger", concert_id)
|
||
dates.append(concert_date)
|
||
if previous_date and concert_date - previous_date == timedelta(days=1):
|
||
badge_triggers.setdefault("double_trouble", concert_id)
|
||
previous_date = concert_date
|
||
if concert_date.isoweekday() >= 5:
|
||
weekend_key = concert_date.isocalendar()[:2]
|
||
weekend_counts[weekend_key] = weekend_counts.get(weekend_key, 0) + 1
|
||
weekend_event_ids.setdefault(weekend_key, []).append(concert_id)
|
||
if weekend_counts[weekend_key] == 3:
|
||
badge_triggers.setdefault("iron_weekend", concert_id)
|
||
|
||
attendance_number = len(dates)
|
||
for code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
|
||
if category == "attendance" and threshold == attendance_number:
|
||
badge_triggers[code] = concert_id
|
||
|
||
distinct_dates = sorted(set(dates))
|
||
return {
|
||
"total": len(rows),
|
||
"max_same_venue": max(venue_counts.values(), default=0),
|
||
"country_count": len(countries),
|
||
"has_foreign_concert": any(country != "deutschland" for country in countries),
|
||
"has_consecutive_days": any(
|
||
later - earlier == timedelta(days=1)
|
||
for earlier, later in zip(distinct_dates, distinct_dates[1:])
|
||
),
|
||
"has_iron_weekend": max(weekend_counts.values(), default=0) >= 3,
|
||
"badge_triggers": badge_triggers,
|
||
}
|
||
|
||
|
||
def grant_earned_badges(user_id: int, stats: dict, registered_at):
|
||
highest_attendance_badge = None
|
||
venue_badge = "regular" if stats["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 stats["total"] >= 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,),
|
||
)
|
||
|
||
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
|
||
if category == "attendance" and threshold is not None and stats["total"] >= threshold:
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO user_badges (user_id, badge_code, trigger_concert_id)
|
||
VALUES (%s, %s, %s) ON CONFLICT (user_id, badge_code) DO UPDATE SET
|
||
trigger_concert_id = COALESCE(user_badges.trigger_concert_id, EXCLUDED.trigger_concert_id)
|
||
""",
|
||
(user_id, badge_code, stats["badge_triggers"].get(badge_code)),
|
||
)
|
||
if highest_attendance_badge:
|
||
cursor.execute(
|
||
"UPDATE user_badges SET trigger_concert_id = COALESCE(trigger_concert_id, %s) WHERE user_id = %s AND badge_code = %s",
|
||
(stats["badge_triggers"].get(highest_attendance_badge), user_id, highest_attendance_badge),
|
||
)
|
||
if venue_badge:
|
||
cursor.execute(
|
||
"INSERT INTO user_badges (user_id, badge_code, trigger_concert_id) VALUES (%s, %s, %s) ON CONFLICT (user_id, badge_code) DO UPDATE SET trigger_concert_id = COALESCE(user_badges.trigger_concert_id, EXCLUDED.trigger_concert_id)",
|
||
(user_id, venue_badge, stats["badge_triggers"].get(venue_badge)),
|
||
)
|
||
achievement_codes = []
|
||
if stats["has_foreign_concert"]:
|
||
achievement_codes.append("border_breaker")
|
||
if stats["country_count"] >= 5:
|
||
achievement_codes.append("globe_banger")
|
||
if stats["has_consecutive_days"]:
|
||
achievement_codes.append("double_trouble")
|
||
if stats["has_iron_weekend"]:
|
||
achievement_codes.append("iron_weekend")
|
||
for badge_code in achievement_codes:
|
||
cursor.execute(
|
||
"INSERT INTO user_badges (user_id, badge_code, trigger_concert_id) VALUES (%s, %s, %s) ON CONFLICT (user_id, badge_code) DO UPDATE SET trigger_concert_id = COALESCE(user_badges.trigger_concert_id, EXCLUDED.trigger_concert_id)",
|
||
(user_id, badge_code, stats["badge_triggers"].get(badge_code)),
|
||
)
|
||
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, trigger_concert_id 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}
|
||
badge_triggers = {badge_row[0]: badge_row[2] 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,
|
||
"badge_triggers": badge_triggers,
|
||
}
|
||
|
||
|
||
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("<h1>Nicht erlaubt</h1>", 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("<h1>Nicht erlaubt</h1>", 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("<h1>Nicht erlaubt</h1>", 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("<h1>Nicht erlaubt</h1>", status_code=403)
|
||
assets = load_badge_assets()
|
||
patches = [
|
||
{
|
||
"code": code,
|
||
"name": name,
|
||
"icon": icon,
|
||
"description": description,
|
||
"image_path": assets.get(code),
|
||
"category": category,
|
||
}
|
||
for code, name, icon, _threshold, description, category in BADGE_DEFINITIONS
|
||
]
|
||
patch_groups = [
|
||
{
|
||
"code": category,
|
||
"label": label,
|
||
"patches": [patch for patch in patches if patch["category"] == category],
|
||
}
|
||
for category, label in BADGE_CATEGORY_LABELS.items()
|
||
if any(patch["category"] == category for patch in patches)
|
||
]
|
||
template = templates.get_template("admin_patches.html")
|
||
return template.render(user=user, patch_groups=patch_groups)
|
||
|
||
|
||
@app.get("/admin/statistics", response_class=HTMLResponse)
|
||
def admin_statistics_page(request: Request):
|
||
user = require_admin(request)
|
||
if not user:
|
||
return HTMLResponse("<h1>Nicht erlaubt</h1>", 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("<h1>Nicht erlaubt</h1>", 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("<h1>Nicht erlaubt</h1>", status_code=403)
|
||
|
||
name = name.strip()
|
||
if not name:
|
||
return HTMLResponse("<h1>Der Name darf nicht leer sein.</h1>", status_code=400)
|
||
|
||
normalized_aliases = sorted({
|
||
alias.strip()
|
||
for alias in aliases.split(",")
|
||
if alias.strip() and alias.strip().casefold() != name.casefold()
|
||
})
|
||
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
"""
|
||
UPDATE venues
|
||
SET name = %s, street = %s, postal_code = %s, city = %s,
|
||
country = %s, is_verified = %s
|
||
WHERE id = %s
|
||
""",
|
||
(
|
||
name,
|
||
street.strip() or None,
|
||
postal_code.strip() or None,
|
||
city.strip() or None,
|
||
country.strip() or None,
|
||
is_verified == "on",
|
||
venue_id,
|
||
),
|
||
)
|
||
cursor.execute("DELETE FROM venue_aliases WHERE venue_id = %s", (venue_id,))
|
||
cursor.executemany(
|
||
"INSERT INTO venue_aliases (venue_id, alias) VALUES (%s, %s)",
|
||
[(venue_id, alias) for alias in normalized_aliases],
|
||
)
|
||
connection.commit()
|
||
|
||
return RedirectResponse("/admin/venues", status_code=303)
|
||
|
||
|
||
@app.post("/admin/venues/{venue_id}/merge")
|
||
def merge_venue(
|
||
request: Request,
|
||
venue_id: int,
|
||
target_venue_id: int = Form(...),
|
||
):
|
||
if not require_admin(request):
|
||
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
|
||
if venue_id == target_venue_id:
|
||
return HTMLResponse("<h1>Ein Ort kann nicht mit sich selbst zusammengeführt werden.</h1>", status_code=400)
|
||
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute("SELECT id FROM venues WHERE id IN (%s, %s)", (venue_id, target_venue_id))
|
||
if len(cursor.fetchall()) != 2:
|
||
return HTMLResponse("<h1>Veranstaltungsort nicht gefunden.</h1>", status_code=404)
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO venue_aliases (venue_id, alias)
|
||
SELECT %s, name FROM venues WHERE id = %s
|
||
ON CONFLICT DO NOTHING
|
||
""",
|
||
(target_venue_id, venue_id),
|
||
)
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO venue_aliases (venue_id, alias)
|
||
SELECT %s, alias FROM venue_aliases WHERE venue_id = %s
|
||
ON CONFLICT DO NOTHING
|
||
""",
|
||
(target_venue_id, venue_id),
|
||
)
|
||
cursor.execute(
|
||
"UPDATE concerts SET venue_id = %s WHERE venue_id = %s",
|
||
(target_venue_id, venue_id),
|
||
)
|
||
cursor.execute("DELETE FROM venues WHERE id = %s", (venue_id,))
|
||
connection.commit()
|
||
|
||
return RedirectResponse("/admin/venues", status_code=303)
|
||
|
||
|
||
@app.post("/admin/venues/{venue_id}/delete")
|
||
def delete_venue(request: Request, venue_id: int):
|
||
if not require_admin(request):
|
||
return HTMLResponse("<h1>Nicht erlaubt</h1>", 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("<h1>Nicht erlaubt</h1>", status_code=403)
|
||
if badge_code not in valid_codes:
|
||
return HTMLResponse("<h1>Patch nicht gefunden</h1>", status_code=404)
|
||
|
||
image_path, error = save_image(image, PATCH_DIR, "/static/uploads/patches/")
|
||
if error:
|
||
return error
|
||
if not image_path:
|
||
return HTMLResponse("<h1>Bitte ein Bild auswählen.</h1>", 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("<h1>Nicht erlaubt</h1>", status_code=403)
|
||
if badge_code not in {definition[0] for definition in BADGE_DEFINITIONS}:
|
||
return HTMLResponse("<h1>Patch nicht gefunden</h1>", 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("<h1>Nicht erlaubt</h1>", status_code=403)
|
||
if role not in {"admin", "user"}:
|
||
return HTMLResponse("<h1>Ungültige Rolle</h1>", status_code=400)
|
||
if user_id == user["id"] and role != "admin":
|
||
return HTMLResponse(
|
||
"<h1>Die eigenen Adminrechte können nicht entfernt werden.</h1>",
|
||
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(
|
||
"<h1>Der Gründer Kai muss Admin bleiben.</h1>",
|
||
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("<h1>Nicht erlaubt</h1>", status_code=403)
|
||
if user_id == user["id"]:
|
||
return HTMLResponse(
|
||
"<h1>Der eigene Account kann nicht gelöscht werden.</h1>",
|
||
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(
|
||
"<h1>Der Gründer Kai kann nicht gelöscht werden.</h1>",
|
||
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("<h1>Nicht erlaubt</h1>", status_code=403)
|
||
if purpose != "password_reset":
|
||
return HTMLResponse("<h1>Ungültiger Linktyp</h1>", 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("<h1>Benutzer nicht gefunden</h1>", 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("<h1>Reset-Link ungültig oder abgelaufen.</h1>", 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("<h1>Reset-Link ungültig oder abgelaufen.</h1>", 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(
|
||
"<h1>Fehler</h1><p>Der Benutzername muss mindestens 3 Zeichen lang sein.</p>",
|
||
status_code=400
|
||
)
|
||
|
||
if len(password) < 10:
|
||
return HTMLResponse(
|
||
"<h1>Fehler</h1><p>Das Passwort muss mindestens 10 Zeichen lang sein.</p>",
|
||
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(
|
||
"<h1>Ungültige Einladung</h1>",
|
||
status_code=404
|
||
)
|
||
|
||
invite_id, expires_at, used_at = invite
|
||
|
||
if used_at:
|
||
return HTMLResponse(
|
||
"<h1>Diese Einladung wurde bereits verwendet.</h1>",
|
||
status_code=410
|
||
)
|
||
|
||
if expires_at and datetime.now() > expires_at:
|
||
return HTMLResponse(
|
||
"<h1>Diese Einladung ist abgelaufen.</h1>",
|
||
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(
|
||
"<h1>Fehler</h1><p>Dieser Benutzername ist bereits vergeben.</p>",
|
||
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(
|
||
"<h1>Fehler</h1><p>Diese E-Mail-Adresse ist bereits registriert.</p>",
|
||
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(
|
||
"<h1>Ungültige Einladung</h1>",
|
||
status_code=404
|
||
)
|
||
|
||
invite_id, expires_at, used_at = invite
|
||
|
||
if used_at:
|
||
return HTMLResponse(
|
||
"<h1>Diese Einladung wurde bereits verwendet.</h1>",
|
||
status_code=410
|
||
)
|
||
|
||
if expires_at:
|
||
from datetime import datetime
|
||
|
||
if datetime.now() > expires_at:
|
||
return HTMLResponse(
|
||
"<h1>Diese Einladung ist abgelaufen.</h1>",
|
||
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("<h1>Benutzer nicht gefunden</h1>", 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()
|
||
]
|
||
attendance_stats = attended_concert_stats(profile["id"])
|
||
grant_earned_badges(
|
||
profile["id"],
|
||
attendance_stats,
|
||
profile["registered_at"],
|
||
)
|
||
profile = load_profile(username)
|
||
badge_assets = load_badge_assets()
|
||
highest_visible_attendance = next(
|
||
(code for code, _name, _icon, threshold, _description, category in reversed(BADGE_DEFINITIONS)
|
||
if category == "attendance" and threshold is not None and attendance_stats["total"] >= threshold),
|
||
None,
|
||
)
|
||
badges = [
|
||
{
|
||
"code": code,
|
||
"name": name,
|
||
"icon": icon,
|
||
"threshold": threshold,
|
||
"description": description,
|
||
"category": category,
|
||
"earned": code in profile["earned_codes"] and (category != "attendance" or code == highest_visible_attendance),
|
||
"image_path": badge_assets.get(code),
|
||
"awarded_at": (profile["badge_awarded_at"].get(code) or profile["registered_at"]).strftime("%d.%m.%Y"),
|
||
"trigger_concert": None,
|
||
"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
|
||
]
|
||
for badge in badges:
|
||
trigger_id = profile["badge_triggers"].get(badge["code"])
|
||
if trigger_id:
|
||
trigger_concert = load_concert(trigger_id)
|
||
if trigger_concert and can_view_event(viewer, trigger_concert):
|
||
badge["trigger_concert"] = {
|
||
"id": trigger_id, "artist": trigger_concert["artist"],
|
||
"date": trigger_concert["start_datetime"].strftime("%d.%m.%Y"),
|
||
}
|
||
badges.sort(key=lambda badge: badge["sort_key"])
|
||
|
||
template = templates.get_template("profile.html")
|
||
return HTMLResponse(
|
||
template.render(
|
||
user=viewer,
|
||
profile=profile,
|
||
attended_count=attendance_stats["total"],
|
||
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 band_key, display_name, created_at FROM followed_bands WHERE user_id = %s ORDER BY created_at",
|
||
(user_id,),
|
||
)
|
||
followed_bands = cursor.fetchall()
|
||
cursor.execute(
|
||
"SELECT venue_id, created_at FROM followed_venues WHERE user_id = %s ORDER BY created_at",
|
||
(user_id,),
|
||
)
|
||
followed_venues = cursor.fetchall()
|
||
cursor.execute(
|
||
"SELECT concert_id, rating, favorite_song, notes, photo_path, created_at, updated_at FROM concert_diary WHERE user_id = %s ORDER BY updated_at",
|
||
(user_id,),
|
||
)
|
||
diary_entries = cursor.fetchall()
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT badge_code, awarded_at, trigger_concert_id 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")),
|
||
"followed_bands": rows_to_dicts(followed_bands, ("band_key", "display_name", "created_at")),
|
||
"followed_venues": rows_to_dicts(followed_venues, ("venue_id", "created_at")),
|
||
"concert_diary": rows_to_dicts(diary_entries, ("concert_id", "rating", "favorite_song", "notes", "photo_path", "created_at", "updated_at")),
|
||
"badges": rows_to_dicts(badges, ("badge_code", "awarded_at", "trigger_concert_id")),
|
||
}
|
||
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("SELECT photo_path FROM concert_diary WHERE user_id = %s AND photo_path IS NOT NULL", (user_id,))
|
||
diary_photo_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] + [(p, DIARY_PHOTO_DIR, "/diary/photo/") for p in diary_photo_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("<h1>Profil konnte nicht gespeichert werden.</h1>", 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"]))
|
||
|
||
|
||
@app.get("/api/concerts/duplicates")
|
||
def duplicate_concerts(request: Request, artist: str = "", start_date: str = "", band_names: str = ""):
|
||
user = get_current_user(request)
|
||
if len(artist.strip()) < 2 or len(artist) > 300:
|
||
return JSONResponse({"matches": []})
|
||
return JSONResponse({"matches": find_duplicate_concerts(user, artist, start_date, band_names)})
|
||
|
||
|
||
@app.get("/following", response_class=HTMLResponse)
|
||
def following_page(request: Request):
|
||
user = get_current_user(request)
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
"SELECT band_key, display_name FROM followed_bands WHERE user_id = %s ORDER BY display_name",
|
||
(user["id"],),
|
||
)
|
||
followed_bands = [{"key": row[0], "name": row[1]} for row in cursor.fetchall()]
|
||
cursor.execute(
|
||
"""
|
||
SELECT v.id, v.name, COALESCE(v.city, '') FROM followed_venues fv
|
||
JOIN venues v ON v.id = fv.venue_id
|
||
WHERE fv.user_id = %s ORDER BY v.name, v.city
|
||
""",
|
||
(user["id"],),
|
||
)
|
||
followed_venues = [{"id": row[0], "name": row[1], "city": row[2]} for row in cursor.fetchall()]
|
||
cursor.execute(
|
||
"""
|
||
SELECT c.id, c.artist, c.start_datetime, c.venue_id,
|
||
COALESCE(v.name, ''), COALESCE(v.city, ''), c.visibility, c.created_by,
|
||
c.event_type
|
||
FROM concerts c LEFT JOIN venues v ON v.id = c.venue_id
|
||
WHERE COALESCE(c.end_datetime, c.start_datetime) >= CURRENT_TIMESTAMP
|
||
AND (c.visibility = 'public' OR c.created_by = %s OR %s
|
||
OR EXISTS (SELECT 1 FROM event_invitations ei WHERE ei.concert_id = c.id AND ei.user_id = %s)
|
||
OR (c.visibility = 'friends' AND EXISTS (
|
||
SELECT 1 FROM friendships f WHERE f.status = 'accepted'
|
||
AND ((f.requester_id = c.created_by AND f.addressee_id = %s)
|
||
OR (f.addressee_id = c.created_by AND f.requester_id = %s)))))
|
||
ORDER BY c.start_datetime
|
||
""",
|
||
(user["id"], user["is_admin"], user["id"], user["id"], user["id"]),
|
||
)
|
||
candidates = cursor.fetchall()
|
||
candidate_ids = [row[0] for row in candidates]
|
||
cursor.execute(
|
||
"SELECT concert_id, band_key, display_name FROM concert_bands WHERE concert_id = ANY(%s) ORDER BY position",
|
||
(candidate_ids or [0],),
|
||
)
|
||
candidate_bands = {}
|
||
for concert_id, band_key, display_name in cursor.fetchall():
|
||
candidate_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name})
|
||
band_names = [band["name"] for band in followed_bands]
|
||
venue_ids = {venue["id"] for venue in followed_venues}
|
||
events = [
|
||
{
|
||
"id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"),
|
||
"time": row[2].strftime("%H:%M"), "venue": ", ".join(filter(None, (row[4], row[5]))),
|
||
"matched_band": any(
|
||
artist_names_similar(event_band["name"], followed_band)
|
||
for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8]))
|
||
for followed_band in band_names
|
||
),
|
||
"matched_venue": row[3] in venue_ids,
|
||
}
|
||
for row in candidates
|
||
if row[3] in venue_ids or any(
|
||
artist_names_similar(event_band["name"], followed_band)
|
||
for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8]))
|
||
for followed_band in band_names
|
||
)
|
||
]
|
||
return templates.get_template("following.html").render(
|
||
user=user, followed_bands=followed_bands, followed_venues=followed_venues, events=events
|
||
)
|
||
|
||
|
||
@app.post("/following/bands/remove")
|
||
def remove_followed_band(request: Request, band_key: str = Form(...)):
|
||
user = get_current_user(request)
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute("DELETE FROM followed_bands WHERE user_id = %s AND band_key = %s", (user["id"], band_key[:255]))
|
||
connection.commit()
|
||
return RedirectResponse("/following", status_code=303)
|
||
|
||
|
||
@app.post("/following/venues/remove")
|
||
def remove_followed_venue(request: Request, venue_id: int = Form(...)):
|
||
user = get_current_user(request)
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute("DELETE FROM followed_venues WHERE user_id = %s AND venue_id = %s", (user["id"], venue_id))
|
||
connection.commit()
|
||
return RedirectResponse("/following", status_code=303)
|
||
|
||
|
||
@app.post("/concerts/{concert_id}/follow-band")
|
||
def follow_band(request: Request, concert_id: int, band_key: str = Form(...), action: str = Form("follow")):
|
||
user = get_current_user(request)
|
||
concert = load_concert(concert_id)
|
||
if not concert or not can_view_event(user, concert):
|
||
return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404)
|
||
bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"])
|
||
selected_band = next((band for band in bands if band["key"] == band_key), None)
|
||
if not selected_band:
|
||
return HTMLResponse("Band nicht gefunden.", status_code=404)
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
if action == "unfollow":
|
||
cursor.execute("DELETE FROM followed_bands WHERE user_id = %s AND band_key = %s", (user["id"], band_key))
|
||
elif action == "follow":
|
||
cursor.execute(
|
||
"INSERT INTO followed_bands (user_id, band_key, display_name) VALUES (%s, %s, %s) ON CONFLICT (user_id, band_key) DO UPDATE SET display_name = EXCLUDED.display_name",
|
||
(user["id"], band_key, selected_band["name"]),
|
||
)
|
||
else:
|
||
return HTMLResponse("Ungültige Aktion.", status_code=400)
|
||
connection.commit()
|
||
return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303)
|
||
|
||
|
||
@app.post("/concerts/{concert_id}/follow-venue")
|
||
def follow_venue(request: Request, concert_id: int, action: str = Form("follow")):
|
||
user = get_current_user(request)
|
||
concert = load_concert(concert_id)
|
||
if not concert or not can_view_event(user, concert):
|
||
return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404)
|
||
venue_id = concert["venue"]["id"]
|
||
if not venue_id:
|
||
return HTMLResponse("Diese Veranstaltung hat keine zugeordnete Location.", status_code=400)
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
if action == "unfollow":
|
||
cursor.execute("DELETE FROM followed_venues WHERE user_id = %s AND venue_id = %s", (user["id"], venue_id))
|
||
elif action == "follow":
|
||
cursor.execute("INSERT INTO followed_venues (user_id, venue_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", (user["id"], venue_id))
|
||
else:
|
||
return HTMLResponse("Ungültige Aktion.", status_code=400)
|
||
connection.commit()
|
||
return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303)
|
||
|
||
|
||
@app.get("/diary", response_class=HTMLResponse)
|
||
def diary_page(request: Request):
|
||
user = get_current_user(request)
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
"""
|
||
SELECT d.concert_id, c.artist, c.start_datetime, d.rating,
|
||
COALESCE(d.favorite_song, ''), COALESCE(d.notes, ''),
|
||
COALESCE(v.name, ''), COALESCE(v.city, ''), d.photo_path
|
||
FROM concert_diary d JOIN concerts c ON c.id = d.concert_id
|
||
LEFT JOIN venues v ON v.id = c.venue_id
|
||
WHERE d.user_id = %s ORDER BY c.start_datetime DESC
|
||
""",
|
||
(user["id"],),
|
||
)
|
||
entries = [
|
||
{"concert_id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"),
|
||
"rating": row[3], "favorite_song": row[4], "notes": row[5],
|
||
"venue": ", ".join(filter(None, (row[6], row[7]))), "photo_path": row[8]}
|
||
for row in cursor.fetchall()
|
||
]
|
||
cursor.execute(
|
||
"""
|
||
SELECT ub.badge_code, ub.trigger_concert_id, ub.awarded_at, ba.path
|
||
FROM user_badges ub LEFT JOIN badge_assets ba ON ba.badge_code = ub.badge_code
|
||
WHERE ub.user_id = %s AND ub.trigger_concert_id IS NOT NULL
|
||
ORDER BY ub.awarded_at
|
||
""",
|
||
(user["id"],),
|
||
)
|
||
diary_badges = {}
|
||
for badge_code, concert_id, awarded_at, image_path in cursor.fetchall():
|
||
definition = BADGE_BY_CODE.get(badge_code)
|
||
if definition:
|
||
name, icon, _threshold, description, _category = definition
|
||
diary_badges.setdefault(concert_id, []).append({
|
||
"name": name, "icon": icon, "description": description,
|
||
"image_path": image_path, "awarded_at": awarded_at.strftime("%d.%m.%Y"),
|
||
})
|
||
for entry in entries:
|
||
entry["badges"] = diary_badges.get(entry["concert_id"], [])
|
||
return templates.get_template("diary.html").render(user=user, entries=entries)
|
||
|
||
|
||
@app.get("/diary/photo/{filename}")
|
||
def diary_photo(request: Request, filename: str):
|
||
user = get_current_user(request)
|
||
if not filename or os.path.basename(filename) != filename:
|
||
return HTMLResponse("Bild nicht gefunden.", status_code=404)
|
||
photo_path = f"/diary/photo/{filename}"
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
"SELECT 1 FROM concert_diary WHERE user_id = %s AND photo_path = %s",
|
||
(user["id"], photo_path),
|
||
)
|
||
if not cursor.fetchone():
|
||
return HTMLResponse("Bild nicht gefunden.", status_code=404)
|
||
file_path = os.path.join(DIARY_PHOTO_DIR, filename)
|
||
if not os.path.isfile(file_path):
|
||
return HTMLResponse("Bild nicht gefunden.", status_code=404)
|
||
return FileResponse(file_path)
|
||
|
||
|
||
@app.post("/concerts/{concert_id}/diary")
|
||
def save_diary_entry(
|
||
request: Request, concert_id: int, rating: int = Form(...),
|
||
favorite_song: str = Form(""), notes: str = Form(""),
|
||
photo: UploadFile | None = File(None), remove_photo: str = Form("")
|
||
):
|
||
user = get_current_user(request)
|
||
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 concert["is_past"] or rating not in range(1, 6):
|
||
return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer Bewertung von 1 bis 5 verfügbar.", status_code=400)
|
||
favorite_song = favorite_song.strip()
|
||
notes = notes.strip()
|
||
if len(favorite_song) > 255 or len(notes) > 5000:
|
||
return HTMLResponse("Tagebucheintrag ist zu lang.", status_code=400)
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute(
|
||
"SELECT 1 FROM concert_attendance WHERE concert_id = %s AND user_id = %s AND status = 'attending'",
|
||
(concert_id, user["id"]),
|
||
)
|
||
if not cursor.fetchone():
|
||
return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403)
|
||
cursor.execute(
|
||
"SELECT photo_path FROM concert_diary WHERE user_id = %s AND concert_id = %s",
|
||
(user["id"], concert_id),
|
||
)
|
||
previous_row = cursor.fetchone()
|
||
previous_photo = previous_row[0] if previous_row else None
|
||
new_photo, error = save_image(photo, DIARY_PHOTO_DIR, "/diary/photo/")
|
||
if error:
|
||
return error
|
||
photo_path = new_photo or (None if remove_photo else previous_photo)
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes, photo_path)
|
||
VALUES (%s, %s, %s, %s, %s, %s)
|
||
ON CONFLICT (user_id, concert_id) DO UPDATE SET
|
||
rating = EXCLUDED.rating, favorite_song = EXCLUDED.favorite_song,
|
||
notes = EXCLUDED.notes, photo_path = EXCLUDED.photo_path,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
""",
|
||
(user["id"], concert_id, rating, favorite_song or None, notes or None, photo_path),
|
||
)
|
||
connection.commit()
|
||
if previous_photo and previous_photo != photo_path:
|
||
remove_uploaded_file(previous_photo, DIARY_PHOTO_DIR, "/diary/photo/")
|
||
return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303)
|
||
|
||
|
||
@app.post("/concerts/{concert_id}/diary/delete")
|
||
def delete_diary_entry(request: Request, concert_id: int):
|
||
user = get_current_user(request)
|
||
with get_db_connection() as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute("SELECT photo_path FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
|
||
row = cursor.fetchone()
|
||
cursor.execute("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
|
||
connection.commit()
|
||
if row and row[0]:
|
||
remove_uploaded_file(row[0], DIARY_PHOTO_DIR, "/diary/photo/")
|
||
return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303)
|
||
|
||
|
||
# ============================================================
|
||
# 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(
|
||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||
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()
|
||
cursor.execute(
|
||
"SELECT band_key FROM followed_bands WHERE user_id = %s",
|
||
(user["id"],),
|
||
)
|
||
followed_band_keys = {row[0] for row in cursor.fetchall()}
|
||
venue_id = concert["venue"]["id"]
|
||
if venue_id:
|
||
cursor.execute(
|
||
"SELECT EXISTS (SELECT 1 FROM followed_venues WHERE user_id = %s AND venue_id = %s)",
|
||
(user["id"], venue_id),
|
||
)
|
||
follows_venue = cursor.fetchone()[0]
|
||
else:
|
||
follows_venue = False
|
||
cursor.execute(
|
||
"SELECT rating, COALESCE(favorite_song, ''), COALESCE(notes, ''), photo_path FROM concert_diary WHERE user_id = %s AND concert_id = %s",
|
||
(user["id"], concert_id),
|
||
)
|
||
diary_row = cursor.fetchone()
|
||
|
||
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,
|
||
)
|
||
diary_entry = {
|
||
"rating": diary_row[0], "favorite_song": diary_row[1], "notes": diary_row[2],
|
||
"photo_path": diary_row[3]
|
||
} if diary_row else None
|
||
concert_bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"])
|
||
for band in concert_bands:
|
||
band["is_following"] = band["key"] in followed_band_keys
|
||
|
||
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),
|
||
concert_bands=concert_bands,
|
||
follows_venue=follows_venue,
|
||
diary_entry=diary_entry,
|
||
can_write_diary=concert["is_past"] and current_attendance == "attending",
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# Create concert
|
||
# ============================================================
|
||
|
||
@app.post("/concerts")
|
||
async def create_concert(
|
||
request: Request,
|
||
artist: str = Form(...),
|
||
band_names: 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(""),
|
||
duplicate_confirmed: bool = Form(False),
|
||
flyer: UploadFile | None = File(None)
|
||
):
|
||
user = get_current_user(request)
|
||
|
||
if not user:
|
||
return login_redirect("/concerts/new")
|
||
|
||
artist = artist.strip()
|
||
if len(artist) < 2 or len(artist) > 255:
|
||
return HTMLResponse("<h1>Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.</h1>", status_code=400)
|
||
if event_type not in EVENT_TYPES:
|
||
return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
|
||
parsed_bands = parse_band_names(band_names, artist, event_type)
|
||
if event_type == "festival" and not end_datetime:
|
||
return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400)
|
||
if event_type != "festival":
|
||
end_datetime = ""
|
||
if visibility not in {"public", "friends", "private"}:
|
||
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
|
||
if event_type != "other":
|
||
visibility = "public"
|
||
duplicate_matches = find_duplicate_concerts(user, artist, start_datetime, band_names)
|
||
if duplicate_matches and not duplicate_confirmed:
|
||
match_items = "".join(
|
||
f'<li><a href="/concerts/{match["id"]}">{escape(match["artist"])} · {match["date"]} {match["time"]}</a></li>'
|
||
for match in duplicate_matches
|
||
)
|
||
return HTMLResponse(
|
||
"<h1>Mögliche doppelte Veranstaltung</h1>"
|
||
"<p>Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.</p>"
|
||
f"<ul>{match_items}</ul>"
|
||
"<p>Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.</p>",
|
||
status_code=409,
|
||
)
|
||
if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime):
|
||
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
|
||
try:
|
||
normalized_flyer_url = normalize_external_url(flyer_url, "den Flyer")
|
||
except ValueError as error:
|
||
return HTMLResponse(f"<h1>{error}</h1>", 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"<h1>{error}</h1>", 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]
|
||
replace_concert_bands(cursor, concert_id, parsed_bands)
|
||
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(
|
||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||
status_code=404
|
||
)
|
||
|
||
if not can_edit_concert(user, concert):
|
||
return HTMLResponse(
|
||
"<h1>Vergangene Veranstaltungen dürfen nur Admins bearbeiten.</h1>",
|
||
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),
|
||
band_names="\n".join(band["name"] for band in load_concert_bands(concert_id, concert["artist"], concert["event_type"])),
|
||
)
|
||
|
||
|
||
@app.post("/concerts/{concert_id}/edit")
|
||
async def edit_concert(
|
||
request: Request,
|
||
concert_id: int,
|
||
artist: str = Form(""),
|
||
band_names: 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(
|
||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||
status_code=404
|
||
)
|
||
|
||
if not can_edit_concert(user, concert):
|
||
return HTMLResponse(
|
||
"<h1>Nicht erlaubt</h1>",
|
||
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("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
|
||
if event_type == "festival" and not end_datetime:
|
||
return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", 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("<h1>Ungültige Sichtbarkeit.</h1>", 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("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
|
||
try:
|
||
next_flyer_url = normalize_external_url(flyer_url, "den Flyer")
|
||
except ValueError as error:
|
||
return HTMLResponse(f"<h1>{error}</h1>", 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"<h1>{error}</h1>", 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,))
|
||
if can_edit_title(user, concert):
|
||
replace_concert_bands(
|
||
cursor, concert_id, parse_band_names(band_names, next_artist, next_event_type)
|
||
)
|
||
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(
|
||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||
status_code=404
|
||
)
|
||
|
||
if not can_delete_concert(user, concert):
|
||
return HTMLResponse(
|
||
"<h1>Nicht erlaubt</h1>",
|
||
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("<h1>Ungültige Auswahl</h1>", status_code=400)
|
||
concert = load_concert(concert_id)
|
||
if not concert or not can_view_event(user, concert):
|
||
return HTMLResponse("<h1>Veranstaltung nicht gefunden</h1>", 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(
|
||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||
status_code=404
|
||
)
|
||
|
||
text = body.strip()
|
||
|
||
if not text:
|
||
return RedirectResponse(
|
||
f"/concerts/{concert_id}",
|
||
status_code=303
|
||
)
|
||
|
||
if len(text) > 2000:
|
||
return HTMLResponse(
|
||
"<h1>Kommentar ist zu lang (maximal 2000 Zeichen).</h1>",
|
||
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(
|
||
"<h1>Veranstaltung nicht gefunden</h1>",
|
||
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]
|