4072 lines
134 KiB
Python
4072 lines
134 KiB
Python
import os
|
|
import uuid
|
|
import secrets
|
|
import hashlib
|
|
import re
|
|
import time
|
|
import threading
|
|
from collections import defaultdict, deque
|
|
from io import BytesIO
|
|
from urllib.parse import urlparse
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timedelta
|
|
|
|
import bcrypt
|
|
import httpx
|
|
import psycopg
|
|
|
|
try:
|
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
|
except ImportError:
|
|
Image = ImageOps = UnidentifiedImageError = None
|
|
|
|
from fastapi import FastAPI, File, Form, Request, UploadFile
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
from fastapi.encoders import jsonable_encoder
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
|
|
|
|
DATABASE_URL = os.environ["DATABASE_URL"]
|
|
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
UPLOAD_DIR = os.path.join(
|
|
BASE_DIR,
|
|
"static",
|
|
"uploads",
|
|
"flyers"
|
|
)
|
|
|
|
PHOTO_DIR = os.path.join(
|
|
BASE_DIR,
|
|
"static",
|
|
"uploads",
|
|
"photos"
|
|
)
|
|
|
|
AVATAR_DIR = os.path.join(
|
|
BASE_DIR,
|
|
"static",
|
|
"uploads",
|
|
"avatars"
|
|
)
|
|
|
|
PATCH_DIR = os.path.join(
|
|
BASE_DIR,
|
|
"static",
|
|
"uploads",
|
|
"patches"
|
|
)
|
|
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
os.makedirs(PHOTO_DIR, exist_ok=True)
|
|
os.makedirs(AVATAR_DIR, exist_ok=True)
|
|
os.makedirs(PATCH_DIR, exist_ok=True)
|
|
|
|
SESSION_COOKIE = "pingu_session"
|
|
SESSION_DAYS = 30
|
|
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
|
|
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
|
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
|
MAX_IMAGE_PIXELS = 25_000_000
|
|
rate_limit_buckets = defaultdict(deque)
|
|
rate_limit_lock = threading.Lock()
|
|
INITIAL_ADMIN_USERNAME = os.environ.get("INITIAL_ADMIN_USERNAME")
|
|
INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD")
|
|
INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL")
|
|
|
|
BADGE_DEFINITIONS = (
|
|
("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und MetalCircle mit aufgebaut", "special"),
|
|
("admin", "Admin", "🏴☠️", None, "Verantwortung für MetalCircle", "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", "attendance"),
|
|
("ten_gigs", "Stammgast · Level 10", "🔥", 10, "10 Konzerte am selben Veranstaltungsort besucht", "attendance"),
|
|
("tour_veteran", "Stammgast · Level 25", "⚡", 25, "25 Konzerte am selben Veranstaltungsort besucht", "attendance"),
|
|
("fifty_gigs", "Stammgast · Level 50", "💀", 50, "50 Konzerte am selben Veranstaltungsort besucht", "attendance"),
|
|
("hundred_gigs", "Stammgast · Level 100", "👑", 100, "100 Konzerte am selben Veranstaltungsort besucht", "attendance"),
|
|
)
|
|
ATTENDANCE_BADGE_CODES = tuple(
|
|
badge_code
|
|
for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
|
|
if category == "attendance"
|
|
)
|
|
BETA_REGISTRATION_DEADLINE = datetime(2026, 9, 16)
|
|
BADGE_BY_CODE = {
|
|
badge_code: (name, icon, threshold, description, category)
|
|
for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS
|
|
}
|
|
|
|
|
|
def get_db_connection():
|
|
return psycopg.connect(DATABASE_URL)
|
|
|
|
|
|
def ensure_schema():
|
|
statements = [
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
username VARCHAR(50) NOT NULL UNIQUE,
|
|
email VARCHAR(255) NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
display_name VARCHAR(100),
|
|
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""",
|
|
"""
|
|
ALTER TABLE users
|
|
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE
|
|
""",
|
|
"""
|
|
UPDATE users
|
|
SET is_admin = TRUE
|
|
WHERE LOWER(username) = 'kai'
|
|
""",
|
|
"""
|
|
ALTER TABLE users
|
|
ADD COLUMN IF NOT EXISTS avatar_path TEXT
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS registration_invites (
|
|
id SERIAL PRIMARY KEY,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
expires_at TIMESTAMP,
|
|
used_by INTEGER REFERENCES users(id),
|
|
used_at TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""",
|
|
"""
|
|
ALTER TABLE concerts
|
|
ADD COLUMN IF NOT EXISTS created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS concert_comments (
|
|
id SERIAL PRIMARY KEY,
|
|
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
body TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS concert_photos (
|
|
id SERIAL PRIMARY KEY,
|
|
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
path TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS concert_attendance (
|
|
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
status VARCHAR(20) NOT NULL CHECK (
|
|
status IN ('attending', 'maybe', 'ticket_search', 'ticket_offer')
|
|
),
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (concert_id, user_id)
|
|
)
|
|
""",
|
|
"""
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conname = 'concert_attendance_status_check'
|
|
AND NOT (
|
|
pg_get_constraintdef(oid) LIKE '%ticket_search%'
|
|
AND pg_get_constraintdef(oid) LIKE '%ticket_offer%'
|
|
)
|
|
) THEN
|
|
ALTER TABLE concert_attendance DROP CONSTRAINT concert_attendance_status_check;
|
|
ALTER TABLE concert_attendance ADD CONSTRAINT concert_attendance_status_check
|
|
CHECK (status IN ('attending', 'maybe', 'ticket_search', 'ticket_offer'));
|
|
END IF;
|
|
END $$
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS user_badges (
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
badge_code VARCHAR(50) NOT NULL,
|
|
awarded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (user_id, badge_code)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS badge_assets (
|
|
badge_code VARCHAR(50) PRIMARY KEY,
|
|
path TEXT NOT NULL,
|
|
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""",
|
|
"""
|
|
ALTER TABLE venues
|
|
ADD COLUMN IF NOT EXISTS is_verified BOOLEAN NOT NULL DEFAULT FALSE
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS venue_aliases (
|
|
id SERIAL PRIMARY KEY,
|
|
venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,
|
|
alias VARCHAR(255) NOT NULL,
|
|
UNIQUE (venue_id, alias)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_venue_aliases_alias
|
|
ON venue_aliases (LOWER(alias))
|
|
""",
|
|
"""
|
|
ALTER TABLE concerts
|
|
ADD COLUMN IF NOT EXISTS event_type VARCHAR(20) NOT NULL DEFAULT 'concert'
|
|
CHECK (event_type IN ('concert', 'festival', 'other'))
|
|
""",
|
|
"""
|
|
ALTER TABLE concerts
|
|
ADD COLUMN IF NOT EXISTS parent_event_id INTEGER
|
|
REFERENCES concerts(id) ON DELETE SET NULL
|
|
""",
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_concerts_parent_event_id
|
|
ON concerts (parent_event_id)
|
|
""",
|
|
"""
|
|
ALTER TABLE users
|
|
ADD COLUMN IF NOT EXISTS instagram_url VARCHAR(500)
|
|
""",
|
|
"""
|
|
ALTER TABLE users DROP COLUMN IF EXISTS email_verified
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS account_tokens (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
purpose VARCHAR(30) NOT NULL CHECK (purpose = 'password_reset'),
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
used_at TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""",
|
|
"""
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conname = 'account_tokens_purpose_check'
|
|
AND pg_get_constraintdef(oid) LIKE '%verify_email%'
|
|
) THEN
|
|
DELETE FROM account_tokens WHERE purpose = 'verify_email';
|
|
ALTER TABLE account_tokens DROP CONSTRAINT account_tokens_purpose_check;
|
|
ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_purpose_check
|
|
CHECK (purpose = 'password_reset');
|
|
END IF;
|
|
END $$
|
|
""",
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_account_tokens_lookup
|
|
ON account_tokens (token_hash, purpose, expires_at)
|
|
""",
|
|
"""
|
|
ALTER TABLE users
|
|
ADD COLUMN IF NOT EXISTS profile_visibility VARCHAR(20) NOT NULL DEFAULT 'friends'
|
|
CHECK (profile_visibility IN ('public', 'friends', 'nobody'))
|
|
""",
|
|
"""
|
|
ALTER TABLE users ALTER COLUMN profile_visibility SET DEFAULT 'friends'
|
|
""",
|
|
"""
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conname = 'users_profile_visibility_check'
|
|
AND pg_get_constraintdef(oid) LIKE '%nobody%'
|
|
) THEN
|
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_profile_visibility_check;
|
|
ALTER TABLE users ADD CONSTRAINT users_profile_visibility_check
|
|
CHECK (profile_visibility IN ('public', 'friends', 'nobody'));
|
|
END IF;
|
|
END $$
|
|
""",
|
|
"""
|
|
ALTER TABLE concerts ADD COLUMN IF NOT EXISTS flyer_url TEXT
|
|
""",
|
|
"""
|
|
ALTER TABLE concerts ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) NOT NULL DEFAULT 'public'
|
|
CHECK (visibility IN ('public', 'friends', 'private'))
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS event_invitations (
|
|
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
viewed_at TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (concert_id, user_id)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_event_invitations_user
|
|
ON event_invitations (user_id, viewed_at)
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS friendships (
|
|
id SERIAL PRIMARY KEY,
|
|
requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
addressee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
|
CHECK (status IN ('pending', 'accepted')),
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CHECK (requester_id <> addressee_id),
|
|
UNIQUE (requester_id, addressee_id)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_friendships_pair
|
|
ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id))
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS direct_messages (
|
|
id SERIAL PRIMARY KEY,
|
|
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
body TEXT NOT NULL,
|
|
read_at TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CHECK (sender_id <> recipient_id)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_direct_messages_conversation
|
|
ON direct_messages (sender_id, recipient_id, created_at)
|
|
""",
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_direct_messages_unread
|
|
ON direct_messages (recipient_id, read_at)
|
|
""",
|
|
]
|
|
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
for statement in statements:
|
|
cursor.execute(statement)
|
|
|
|
if INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD:
|
|
cursor.execute(
|
|
"SELECT EXISTS (SELECT 1 FROM users)",
|
|
)
|
|
users_exist = cursor.fetchone()[0]
|
|
|
|
if not users_exist:
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO users (
|
|
username,
|
|
email,
|
|
password_hash,
|
|
display_name,
|
|
is_admin
|
|
)
|
|
VALUES (%s, %s, %s, %s, TRUE)
|
|
""",
|
|
(
|
|
INITIAL_ADMIN_USERNAME,
|
|
INITIAL_ADMIN_EMAIL
|
|
or f"{INITIAL_ADMIN_USERNAME}@local.invalid",
|
|
bcrypt.hashpw(
|
|
INITIAL_ADMIN_PASSWORD.encode("utf-8"),
|
|
bcrypt.gensalt(),
|
|
).decode("utf-8"),
|
|
INITIAL_ADMIN_USERNAME,
|
|
),
|
|
)
|
|
connection.commit()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI):
|
|
ensure_schema()
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="MetalCircle", lifespan=lifespan)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def security_controls(request: Request, call_next):
|
|
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
|
|
origin = request.headers.get("origin")
|
|
fetch_site = request.headers.get("sec-fetch-site")
|
|
origin_host = urlparse(origin).netloc if origin else None
|
|
expected_host = request.headers.get("host", request.url.netloc)
|
|
if fetch_site == "cross-site" or (origin_host and origin_host != expected_host):
|
|
return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", status_code=403)
|
|
|
|
path = request.url.path
|
|
if path == "/login":
|
|
bucket_name, limit, window = "login", 10, 15 * 60
|
|
elif path == "/register" or path.startswith("/password-reset"):
|
|
bucket_name, limit, window = "account", 10, 60 * 60
|
|
elif path.startswith("/messages/"):
|
|
bucket_name, limit, window = "messages", 30, 60
|
|
elif any(part in path for part in ("/photos", "/patches")) or path in {"/profile", "/concerts"}:
|
|
bucket_name, limit, window = "uploads", 20, 60 * 60
|
|
else:
|
|
bucket_name, limit, window = "writes", 120, 60
|
|
client_host = request.client.host if request.client else "unknown"
|
|
key = (client_host, bucket_name)
|
|
now = time.monotonic()
|
|
with rate_limit_lock:
|
|
bucket = rate_limit_buckets[key]
|
|
while bucket and bucket[0] <= now - window:
|
|
bucket.popleft()
|
|
if len(bucket) >= limit:
|
|
return HTMLResponse("Zu viele Anfragen. Bitte später erneut versuchen.", status_code=429,
|
|
headers={"Retry-After": str(window)})
|
|
bucket.append(now)
|
|
|
|
response = await call_next(request)
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
if COOKIE_SECURE:
|
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
return response
|
|
|
|
|
|
@app.middleware("http")
|
|
async def require_login(request: Request, call_next):
|
|
if (
|
|
request.url.path == "/login"
|
|
or request.url.path == "/register"
|
|
or request.url.path.startswith("/register/")
|
|
or request.url.path.startswith("/password-reset")
|
|
or request.url.path 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, 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,
|
|
"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 = ""):
|
|
query = """
|
|
SELECT concerts.id, concerts.artist, concerts.start_datetime,
|
|
concerts.end_datetime, venues.name, venues.city,
|
|
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 term:
|
|
return upcoming, past
|
|
|
|
def matches(card):
|
|
text = f"{card['artist']} {card['venue']} {card['event_type_label']}".casefold()
|
|
return term in text or any(matches(child) for child in card["children"])
|
|
|
|
return (
|
|
[card for card in upcoming if matches(card)],
|
|
[card for card in past if matches(card)],
|
|
)
|
|
|
|
|
|
def can_view_event(user, concert) -> bool:
|
|
if user["is_admin"] or concert["visibility"] == "public" or concert["created_by"] == user["id"]:
|
|
return True
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
if concert["visibility"] == "private":
|
|
cursor.execute(
|
|
"SELECT 1 FROM event_invitations WHERE concert_id = %s AND user_id = %s",
|
|
(concert["id"], user["id"]),
|
|
)
|
|
else:
|
|
cursor.execute(
|
|
"""
|
|
SELECT 1 FROM friendships WHERE status = 'accepted'
|
|
AND ((requester_id = %s AND addressee_id = %s)
|
|
OR (addressee_id = %s AND requester_id = %s))
|
|
""",
|
|
(concert["created_by"], user["id"], concert["created_by"], user["id"]),
|
|
)
|
|
return cursor.fetchone() is not None
|
|
|
|
|
|
def get_invitable_users(exclude_user_id: int):
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT id, username, COALESCE(display_name, username)
|
|
FROM users WHERE id <> %s
|
|
ORDER BY COALESCE(display_name, username), username
|
|
""",
|
|
(exclude_user_id,),
|
|
)
|
|
return [{"id": row[0], "username": row[1], "display_name": row[2]} for row in cursor.fetchall()]
|
|
|
|
|
|
def get_event_invitee_ids(concert_id: int):
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute("SELECT user_id FROM event_invitations WHERE concert_id = %s", (concert_id,))
|
|
return {row[0] for row in cursor.fetchall()}
|
|
|
|
|
|
def search_users(search_query: str):
|
|
term = search_query.strip()
|
|
if not term:
|
|
return []
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT username, COALESCE(display_name, username), avatar_path
|
|
FROM users
|
|
WHERE username ILIKE %s OR COALESCE(display_name, '') ILIKE %s
|
|
ORDER BY COALESCE(display_name, username), username
|
|
LIMIT 30
|
|
""",
|
|
(f"%{term}%", f"%{term}%"),
|
|
)
|
|
return [
|
|
{"username": row[0], "display_name": row[1], "avatar_path": row[2]}
|
|
for row in cursor.fetchall()
|
|
]
|
|
|
|
|
|
def normalize_instagram_url(value: str):
|
|
value = value.strip()
|
|
if not value:
|
|
return None
|
|
if value.startswith("@"):
|
|
value = value[1:]
|
|
if re.fullmatch(r"[A-Za-z0-9._]{1,30}", value):
|
|
return f"https://www.instagram.com/{value}/"
|
|
|
|
candidate = value if "://" in value else f"https://{value}"
|
|
parsed = urlparse(candidate)
|
|
if (parsed.hostname or "").lower() not in {"instagram.com", "www.instagram.com"}:
|
|
raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.")
|
|
username = parsed.path.strip("/").split("/", 1)[0]
|
|
if not re.fullmatch(r"[A-Za-z0-9._]{1,30}", username):
|
|
raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.")
|
|
return f"https://www.instagram.com/{username}/"
|
|
|
|
|
|
def normalize_external_url(value: str, field_name: str):
|
|
value = value.strip()
|
|
if not value:
|
|
return None
|
|
parsed = urlparse(value)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
|
|
raise ValueError(f"Bitte für {field_name} eine vollständige HTTP- oder HTTPS-Adresse eingeben.")
|
|
return value
|
|
|
|
|
|
def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
|
|
if not upload or not upload.filename:
|
|
return None, None
|
|
|
|
extension = os.path.splitext(upload.filename)[1].lower()
|
|
|
|
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
return None, HTMLResponse(
|
|
"Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP.",
|
|
status_code=400,
|
|
)
|
|
|
|
contents = upload.file.read()
|
|
|
|
if len(contents) > MAX_IMAGE_BYTES:
|
|
return None, HTMLResponse(
|
|
"Die Datei darf maximal 10 MB groß sein.",
|
|
status_code=400,
|
|
)
|
|
|
|
if Image is None:
|
|
return None, HTMLResponse(
|
|
"Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen.",
|
|
status_code=503,
|
|
)
|
|
|
|
try:
|
|
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
|
|
with Image.open(BytesIO(contents)) as candidate:
|
|
candidate.verify()
|
|
with Image.open(BytesIO(contents)) as candidate:
|
|
image = ImageOps.exif_transpose(candidate)
|
|
if image.width * image.height > MAX_IMAGE_PIXELS:
|
|
raise ValueError("Bildauflösung zu groß")
|
|
image.thumbnail((2400, 2400))
|
|
has_alpha = image.mode in {"RGBA", "LA"} or (
|
|
image.mode == "P" and "transparency" in image.info
|
|
)
|
|
if image.mode not in {"RGB", "RGBA"}:
|
|
image = image.convert("RGBA" if has_alpha else "RGB")
|
|
output = BytesIO()
|
|
# Nicht jedes Browser-/Proxy-Setup verarbeitet serverseitig
|
|
# erzeugte WebP-Dateien zuverlässig. Nach der Validierung bleiben
|
|
# transparente Bilder PNG, normale Bilder werden als JPEG gespeichert.
|
|
output_format = "PNG" if image.mode == "RGBA" else "JPEG"
|
|
if output_format == "PNG":
|
|
image.save(output, format=output_format, optimize=True)
|
|
else:
|
|
image.save(output, format=output_format, quality=88, optimize=True)
|
|
safe_contents = output.getvalue()
|
|
except (ValueError, OSError, UnidentifiedImageError, Image.DecompressionBombError):
|
|
return None, HTMLResponse(
|
|
"Die Datei ist kein gültiges oder unterstütztes Bild.", status_code=400
|
|
)
|
|
|
|
filename = str(uuid.uuid4()) + (".png" if output_format == "PNG" else ".jpg")
|
|
destination = os.path.join(destination_dir, filename)
|
|
with open(destination, "wb") as file:
|
|
file.write(safe_contents)
|
|
|
|
return f"{url_prefix}{filename}", None
|
|
|
|
|
|
def remove_uploaded_file(path: str | None, destination_dir: str, url_prefix: str):
|
|
if not path or not path.startswith(url_prefix):
|
|
return False
|
|
relative_name = path.removeprefix(url_prefix)
|
|
if not relative_name or os.path.basename(relative_name) != relative_name:
|
|
return False
|
|
file_path = os.path.join(destination_dir, relative_name)
|
|
if not os.path.isfile(file_path):
|
|
return False
|
|
os.remove(file_path)
|
|
return True
|
|
|
|
|
|
def attended_concert_stats(user_id: int) -> tuple[int, int]:
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
WITH attended AS (
|
|
SELECT concerts.venue_id
|
|
, concerts.event_type
|
|
FROM concert_attendance
|
|
JOIN concerts ON concerts.id = concert_attendance.concert_id
|
|
WHERE concert_attendance.user_id = %s
|
|
AND concert_attendance.status = 'attending'
|
|
AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP
|
|
), venue_counts AS (
|
|
SELECT venue_id, COUNT(*) AS visit_count
|
|
FROM attended
|
|
WHERE venue_id IS NOT NULL AND event_type <> 'other'
|
|
GROUP BY venue_id
|
|
)
|
|
SELECT
|
|
(SELECT COUNT(*) FROM attended),
|
|
COALESCE((SELECT MAX(visit_count) FROM venue_counts), 0)
|
|
""",
|
|
(user_id,),
|
|
)
|
|
total, max_same_venue = cursor.fetchone()
|
|
return total, max_same_venue
|
|
|
|
|
|
def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, registered_at):
|
|
highest_attendance_badge = None
|
|
|
|
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
|
|
qualifies = category == "attendance" and attended_count >= threshold
|
|
if category == "attendance" and threshold >= 5:
|
|
qualifies = max_same_venue >= threshold
|
|
if qualifies:
|
|
highest_attendance_badge = badge_code
|
|
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
if registered_at < BETA_REGISTRATION_DEADLINE:
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO user_badges (user_id, badge_code)
|
|
VALUES (%s, 'beta_tester')
|
|
ON CONFLICT (user_id, badge_code) DO NOTHING
|
|
""",
|
|
(user_id,),
|
|
)
|
|
|
|
# Konzert-Patches sind eine Level-Leiste: immer nur die höchste
|
|
# erreichte Stufe anzeigen (ältere Stufen werden ersetzt).
|
|
cursor.execute(
|
|
"DELETE FROM user_badges WHERE user_id = %s AND badge_code = ANY(%s)",
|
|
(user_id, list(ATTENDANCE_BADGE_CODES)),
|
|
)
|
|
if highest_attendance_badge:
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO user_badges (user_id, badge_code)
|
|
VALUES (%s, %s)
|
|
ON CONFLICT (user_id, badge_code) DO NOTHING
|
|
""",
|
|
(user_id, highest_attendance_badge),
|
|
)
|
|
connection.commit()
|
|
|
|
|
|
def load_badge_assets():
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute("SELECT badge_code, path FROM badge_assets")
|
|
return {row[0]: row[1] for row in cursor.fetchall()}
|
|
|
|
|
|
def load_profile(username: str):
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT id, username, display_name, avatar_path, created_at, instagram_url,
|
|
profile_visibility, is_admin
|
|
FROM users
|
|
WHERE LOWER(username) = LOWER(%s)
|
|
""",
|
|
(username,),
|
|
)
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
return None
|
|
|
|
cursor.execute(
|
|
"SELECT badge_code, awarded_at FROM user_badges WHERE user_id = %s",
|
|
(row[0],),
|
|
)
|
|
badge_rows = cursor.fetchall()
|
|
earned_codes = {badge_row[0] for badge_row in badge_rows}
|
|
badge_awarded_at = {badge_row[0]: badge_row[1] for badge_row in badge_rows}
|
|
|
|
is_founder = row[1].casefold() == "kai"
|
|
if is_founder:
|
|
earned_codes.add("founder")
|
|
if row[7]:
|
|
earned_codes.add("admin")
|
|
|
|
return {
|
|
"id": row[0],
|
|
"username": row[1],
|
|
"display_name": row[2] or row[1],
|
|
"avatar_path": row[3],
|
|
"created_at": row[4].strftime("%d.%m.%Y"),
|
|
"registered_at": row[4],
|
|
"instagram_url": row[5],
|
|
"instagram_handle": row[5].rstrip("/").rsplit("/", 1)[-1] if row[5] else None,
|
|
"profile_visibility": row[6],
|
|
"is_admin": bool(row[7]),
|
|
"is_founder": is_founder,
|
|
"earned_codes": earned_codes,
|
|
"badge_awarded_at": badge_awarded_at,
|
|
}
|
|
|
|
|
|
def resolve_venue(
|
|
cursor,
|
|
venue_id: str,
|
|
venue_name: str,
|
|
city: str,
|
|
street: str,
|
|
postal_code: str,
|
|
country: str,
|
|
latitude: str,
|
|
longitude: str,
|
|
):
|
|
selected_venue_id = None
|
|
venue_name = venue_name.strip()
|
|
city = city.strip()
|
|
street = street.strip()
|
|
postal_code = postal_code.strip()
|
|
country = country.strip()
|
|
|
|
if venue_id:
|
|
if venue_id.startswith("nominatim:"):
|
|
external_id = venue_id.split(":", 1)[1]
|
|
|
|
cursor.execute(
|
|
"""
|
|
SELECT id
|
|
FROM venues
|
|
WHERE external_id IN (%s, %s)
|
|
AND source = 'nominatim'
|
|
LIMIT 1
|
|
""",
|
|
(external_id, external_id.rsplit(":", 1)[-1]),
|
|
)
|
|
existing_venue = cursor.fetchone()
|
|
|
|
if existing_venue:
|
|
selected_venue_id = existing_venue[0]
|
|
else:
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO venues (
|
|
name,
|
|
street,
|
|
postal_code,
|
|
city,
|
|
country,
|
|
latitude,
|
|
longitude,
|
|
external_id,
|
|
source
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
venue_name,
|
|
street or None,
|
|
postal_code or None,
|
|
city or None,
|
|
country or None,
|
|
float(latitude) if latitude else None,
|
|
float(longitude) if longitude else None,
|
|
external_id,
|
|
"nominatim",
|
|
),
|
|
)
|
|
selected_venue_id = cursor.fetchone()[0]
|
|
else:
|
|
try:
|
|
selected_venue_id = int(venue_id)
|
|
except ValueError:
|
|
selected_venue_id = None
|
|
|
|
if not selected_venue_id and venue_name:
|
|
cursor.execute(
|
|
"""
|
|
SELECT id
|
|
FROM venues
|
|
WHERE LOWER(name) = LOWER(%s)
|
|
AND LOWER(COALESCE(city, '')) = LOWER(%s)
|
|
LIMIT 1
|
|
""",
|
|
(venue_name, city),
|
|
)
|
|
existing_venue = cursor.fetchone()
|
|
|
|
if existing_venue:
|
|
selected_venue_id = existing_venue[0]
|
|
else:
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO venues (
|
|
name,
|
|
street,
|
|
postal_code,
|
|
city,
|
|
country,
|
|
latitude,
|
|
longitude
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
venue_name,
|
|
street or None,
|
|
postal_code or None,
|
|
city or None,
|
|
country or None,
|
|
float(latitude) if latitude else None,
|
|
float(longitude) if longitude else None,
|
|
),
|
|
)
|
|
selected_venue_id = cursor.fetchone()[0]
|
|
|
|
return selected_venue_id
|
|
|
|
|
|
def require_admin(request: Request):
|
|
user = get_current_user(request)
|
|
|
|
if not user or not user["is_admin"]:
|
|
return None
|
|
|
|
return user
|
|
|
|
|
|
def get_admin_venues():
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT
|
|
venues.id,
|
|
venues.name,
|
|
venues.street,
|
|
venues.postal_code,
|
|
venues.city,
|
|
venues.country,
|
|
venues.source,
|
|
venues.is_verified,
|
|
COALESCE(string_agg(venue_aliases.alias, ', ' ORDER BY venue_aliases.alias), ''),
|
|
COUNT(DISTINCT concerts.id)
|
|
FROM venues
|
|
LEFT JOIN venue_aliases ON venue_aliases.venue_id = venues.id
|
|
LEFT JOIN concerts ON concerts.venue_id = venues.id
|
|
GROUP BY venues.id
|
|
ORDER BY venues.is_verified ASC, venues.name ASC, venues.city ASC
|
|
"""
|
|
)
|
|
rows = cursor.fetchall()
|
|
|
|
return [
|
|
{
|
|
"id": row[0],
|
|
"name": row[1],
|
|
"street": row[2] or "",
|
|
"postal_code": row[3] or "",
|
|
"city": row[4] or "",
|
|
"country": row[5] or "",
|
|
"source": row[6] or "manuell",
|
|
"is_verified": bool(row[7]),
|
|
"aliases": row[8],
|
|
"concert_count": row[9],
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
|
|
@app.get("/admin")
|
|
def admin_page(request: Request):
|
|
if not require_admin(request):
|
|
return HTMLResponse("<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),
|
|
}
|
|
for code, name, icon, _threshold, description, _category in BADGE_DEFINITIONS
|
|
]
|
|
template = templates.get_template("admin_patches.html")
|
|
return template.render(user=user, patches=patches)
|
|
|
|
|
|
@app.post("/admin/invites", response_class=HTMLResponse)
|
|
def create_invite(request: Request):
|
|
user = require_admin(request)
|
|
|
|
if not user:
|
|
return HTMLResponse("<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 = ""):
|
|
user = get_current_user(request)
|
|
upcoming_concerts, _past_concerts = load_event_overview(user, q)
|
|
|
|
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),
|
|
)
|
|
|
|
|
|
@app.get("/events/past", response_class=HTMLResponse)
|
|
def past_events(request: Request, q: str = ""):
|
|
user = get_current_user(request)
|
|
_upcoming_concerts, past_concerts = load_event_overview(user, q)
|
|
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),
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# 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
|
|
if viewer and not is_own_profile:
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT id, requester_id, addressee_id, status
|
|
FROM friendships
|
|
WHERE (requester_id = %s AND addressee_id = %s)
|
|
OR (requester_id = %s AND addressee_id = %s)
|
|
""",
|
|
(viewer["id"], profile["id"], profile["id"], viewer["id"]),
|
|
)
|
|
row = cursor.fetchone()
|
|
if row:
|
|
friendship = {
|
|
"id": row[0], "requester_id": row[1],
|
|
"addressee_id": row[2], "status": row[3],
|
|
}
|
|
can_view_details = (
|
|
profile["profile_visibility"] == "public"
|
|
or is_own_profile
|
|
or bool(viewer and viewer["is_admin"])
|
|
or bool(friendship and friendship["status"] == "accepted")
|
|
)
|
|
connections = {"incoming": [], "outgoing": [], "friends": []}
|
|
if is_own_profile:
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT f.id, f.requester_id, f.addressee_id, f.status,
|
|
other.username, COALESCE(other.display_name, other.username),
|
|
other.avatar_path
|
|
FROM friendships f
|
|
JOIN users other ON other.id = CASE
|
|
WHEN f.requester_id = %s THEN f.addressee_id
|
|
ELSE f.requester_id
|
|
END
|
|
WHERE f.requester_id = %s OR f.addressee_id = %s
|
|
ORDER BY f.updated_at DESC
|
|
""",
|
|
(viewer["id"], viewer["id"], viewer["id"]),
|
|
)
|
|
for row in cursor.fetchall():
|
|
item = {
|
|
"id": row[0], "requester_id": row[1], "addressee_id": row[2],
|
|
"status": row[3], "username": row[4],
|
|
"display_name": row[5], "avatar_path": row[6],
|
|
}
|
|
if row[3] == "accepted":
|
|
connections["friends"].append(item)
|
|
elif row[2] == viewer["id"]:
|
|
connections["incoming"].append(item)
|
|
else:
|
|
connections["outgoing"].append(item)
|
|
attended_count, max_same_venue = attended_concert_stats(profile["id"])
|
|
grant_earned_badges(
|
|
profile["id"],
|
|
attended_count,
|
|
max_same_venue,
|
|
profile["registered_at"],
|
|
)
|
|
profile = load_profile(username)
|
|
badge_assets = load_badge_assets()
|
|
badges = [
|
|
{
|
|
"code": code,
|
|
"name": name,
|
|
"icon": icon,
|
|
"threshold": threshold,
|
|
"description": description,
|
|
"category": category,
|
|
"earned": code in profile["earned_codes"],
|
|
"image_path": badge_assets.get(code),
|
|
"sort_key": (0, datetime.min) if code == "founder" else (1, datetime.min) if code == "admin" else (2, profile["badge_awarded_at"].get(code) or datetime.max),
|
|
}
|
|
for code, name, icon, threshold, description, category in BADGE_DEFINITIONS
|
|
]
|
|
badges.sort(key=lambda badge: badge["sort_key"])
|
|
|
|
template = templates.get_template("profile.html")
|
|
return HTMLResponse(
|
|
template.render(
|
|
user=viewer,
|
|
profile=profile,
|
|
attended_count=attended_count,
|
|
badges=badges,
|
|
is_own_profile=force_own or is_own_profile,
|
|
can_view_details=can_view_details,
|
|
friendship=friendship,
|
|
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 id, sender_id, recipient_id, body, read_at, created_at
|
|
FROM direct_messages WHERE sender_id = %s OR recipient_id = %s
|
|
ORDER BY created_at
|
|
""",
|
|
(user_id, user_id),
|
|
)
|
|
messages = cursor.fetchall()
|
|
|
|
cursor.execute(
|
|
"""
|
|
SELECT concert_id, invited_by, viewed_at, created_at
|
|
FROM event_invitations WHERE user_id = %s ORDER BY created_at
|
|
""",
|
|
(user_id,),
|
|
)
|
|
invitations = cursor.fetchall()
|
|
|
|
cursor.execute(
|
|
"""
|
|
SELECT id, concert_id, body, created_at
|
|
FROM concert_comments WHERE user_id = %s ORDER BY created_at
|
|
""",
|
|
(user_id,),
|
|
)
|
|
comments = cursor.fetchall()
|
|
|
|
cursor.execute(
|
|
"""
|
|
SELECT id, concert_id, path, created_at
|
|
FROM concert_photos WHERE user_id = %s ORDER BY created_at
|
|
""",
|
|
(user_id,),
|
|
)
|
|
photos = cursor.fetchall()
|
|
|
|
cursor.execute(
|
|
"""
|
|
SELECT badge_code, awarded_at FROM user_badges
|
|
WHERE user_id = %s ORDER BY awarded_at
|
|
""",
|
|
(user_id,),
|
|
)
|
|
badges = cursor.fetchall()
|
|
|
|
def rows_to_dicts(rows, keys):
|
|
return [dict(zip(keys, row)) for row in rows]
|
|
|
|
data = {
|
|
"export_version": 1,
|
|
"exported_at": datetime.now(),
|
|
"account": dict(zip(
|
|
("id", "username", "email", "display_name", "avatar_path",
|
|
"instagram_url", "profile_visibility", "is_admin", "created_at"),
|
|
account,
|
|
)) if account else None,
|
|
"created_events": rows_to_dicts(
|
|
created_events,
|
|
("id", "artist", "event_type", "start_datetime", "end_datetime", "venue_id",
|
|
"description", "ticket_url", "ticket_price", "flyer_path", "flyer_url",
|
|
"visibility", "created_at"),
|
|
),
|
|
"attendance": rows_to_dicts(attendance, ("concert_id", "status", "updated_at")),
|
|
"friendships": rows_to_dicts(friendships, ("id", "requester_id", "addressee_id", "status", "created_at", "updated_at")),
|
|
"messages": rows_to_dicts(messages, ("id", "sender_id", "recipient_id", "body", "read_at", "created_at")),
|
|
"event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")),
|
|
"comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")),
|
|
"photos": rows_to_dicts(photos, ("id", "concert_id", "path", "created_at")),
|
|
"badges": rows_to_dicts(badges, ("badge_code", "awarded_at")),
|
|
}
|
|
filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"])
|
|
return JSONResponse(
|
|
content=jsonable_encoder(data),
|
|
headers={"Content-Disposition": f'attachment; filename="pingu-concerts-{filename}-daten.json"'},
|
|
)
|
|
|
|
|
|
@app.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(
|
|
"""
|
|
INSERT INTO friendships (requester_id, addressee_id)
|
|
VALUES (%s, %s)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
(user["id"], profile["id"]),
|
|
)
|
|
connection.commit()
|
|
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
|
|
|
|
|
|
@app.post("/friendships/{friendship_id}/{action}")
|
|
def manage_friendship(request: Request, friendship_id: int, action: str, 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 (%s OR u.is_admin OR EXISTS (
|
|
SELECT 1 FROM friendships f
|
|
WHERE f.status = 'accepted'
|
|
AND ((f.requester_id = %s AND f.addressee_id = u.id)
|
|
OR (f.addressee_id = %s AND f.requester_id = u.id))
|
|
))
|
|
""",
|
|
(username, user["id"], user["is_admin"], user["id"], user["id"]),
|
|
)
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return {"id": row[0], "username": row[1], "display_name": row[2],
|
|
"avatar_path": row[3], "is_admin": bool(row[4])}
|
|
|
|
|
|
@app.get("/messages", response_class=HTMLResponse)
|
|
def message_inbox(request: Request):
|
|
user = get_current_user(request)
|
|
conversations = []
|
|
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 (%s OR u.is_admin OR EXISTS (
|
|
SELECT 1 FROM friendships f
|
|
WHERE f.status = 'accepted'
|
|
AND ((f.requester_id = %s AND f.addressee_id = u.id)
|
|
OR (f.addressee_id = %s AND f.requester_id = u.id))
|
|
))
|
|
ORDER BY COALESCE(u.display_name, u.username)
|
|
""",
|
|
(user["id"], user["is_admin"], user["id"], user["id"]),
|
|
)
|
|
for row in cursor.fetchall():
|
|
cursor.execute(
|
|
"""
|
|
SELECT body, created_at, sender_id,
|
|
(SELECT COUNT(*) FROM direct_messages
|
|
WHERE sender_id = %s AND recipient_id = %s AND read_at IS NULL)
|
|
FROM direct_messages
|
|
WHERE (sender_id = %s AND recipient_id = %s)
|
|
OR (sender_id = %s AND recipient_id = %s)
|
|
ORDER BY created_at DESC LIMIT 1
|
|
""",
|
|
(row[0], user["id"], user["id"], row[0], row[0], user["id"]),
|
|
)
|
|
latest = cursor.fetchone()
|
|
conversations.append({
|
|
"id": row[0], "username": row[1], "display_name": row[2],
|
|
"avatar_path": row[3], "last_message": latest[0] if latest else None,
|
|
"last_at": latest[1].strftime("%d.%m.%Y %H:%M") if latest else None,
|
|
"last_at_raw": latest[1] if latest else None,
|
|
"last_from_me": bool(latest and latest[2] == user["id"]),
|
|
"unread_count": latest[3] if latest else 0,
|
|
})
|
|
conversations.sort(key=lambda item: (item["unread_count"] > 0, item["last_at_raw"] or datetime.min), reverse=True)
|
|
template = templates.get_template("messages.html")
|
|
return template.render(user=user, conversations=conversations, friend_requests=friend_requests,
|
|
event_invitations=event_invitations, partner=None, messages=[])
|
|
|
|
|
|
@app.get("/messages/{username}", response_class=HTMLResponse)
|
|
def message_conversation(request: Request, username: str):
|
|
user = get_current_user(request)
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
partner = load_chat_partner(cursor, user, username)
|
|
if not partner:
|
|
return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403)
|
|
cursor.execute(
|
|
"""
|
|
UPDATE direct_messages SET read_at = CURRENT_TIMESTAMP
|
|
WHERE sender_id = %s AND recipient_id = %s AND read_at IS NULL
|
|
""",
|
|
(partner["id"], user["id"]),
|
|
)
|
|
cursor.execute(
|
|
"""
|
|
SELECT sender_id, body, created_at
|
|
FROM direct_messages
|
|
WHERE (sender_id = %s AND recipient_id = %s)
|
|
OR (sender_id = %s AND recipient_id = %s)
|
|
ORDER BY created_at ASC, id ASC
|
|
LIMIT 500
|
|
""",
|
|
(user["id"], partner["id"], partner["id"], user["id"]),
|
|
)
|
|
messages = [
|
|
{"from_me": row[0] == user["id"], "body": row[1],
|
|
"created_at": row[2].strftime("%d.%m.%Y %H:%M")}
|
|
for row in cursor.fetchall()
|
|
]
|
|
connection.commit()
|
|
template = templates.get_template("messages.html")
|
|
return template.render(user=get_current_user(request), conversations=[], partner=partner, messages=messages)
|
|
|
|
|
|
@app.post("/messages/{username}")
|
|
def send_message(request: Request, username: str, body: str = Form(...)):
|
|
user = get_current_user(request)
|
|
body = body.strip()
|
|
if not body or len(body) > 2000:
|
|
return HTMLResponse("Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein.", status_code=400)
|
|
with get_db_connection() as connection:
|
|
with connection.cursor() as cursor:
|
|
partner = load_chat_partner(cursor, user, username)
|
|
if not partner:
|
|
return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403)
|
|
cursor.execute(
|
|
"INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s)",
|
|
(user["id"], partner["id"], body),
|
|
)
|
|
connection.commit()
|
|
return RedirectResponse(f"/messages/{partner['username']}#latest", status_code=303)
|
|
|
|
|
|
# ============================================================
|
|
# New concert
|
|
# ============================================================
|
|
|
|
@app.get(
|
|
"/concerts/new",
|
|
response_class=HTMLResponse
|
|
)
|
|
def new_concert(request: Request):
|
|
user = get_current_user(request)
|
|
|
|
if not user:
|
|
return login_redirect("/concerts/new")
|
|
|
|
template = templates.get_template("new_concert.html")
|
|
return template.render(user=user, linkable_events=get_linkable_events(),
|
|
invitable_users=get_invitable_users(user["id"]))
|
|
|
|
|
|
# ============================================================
|
|
# Concert detail
|
|
# ============================================================
|
|
|
|
@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
|
|
ORDER BY users.display_name NULLS LAST, users.username
|
|
""",
|
|
(concert_id,),
|
|
)
|
|
attendance_rows = cursor.fetchall()
|
|
|
|
comments = [
|
|
{
|
|
"id": row[0],
|
|
"body": row[1],
|
|
"created_at": row[2].strftime("%d.%m.%Y %H:%M"),
|
|
"author": row[3] or row[4],
|
|
"username": row[4],
|
|
}
|
|
for row in comment_rows
|
|
]
|
|
|
|
photos = [
|
|
{
|
|
"id": row[0],
|
|
"path": row[1],
|
|
"created_at": row[2].strftime("%d.%m.%Y %H:%M"),
|
|
"author": row[3] or row[4],
|
|
}
|
|
for row in photo_rows
|
|
]
|
|
|
|
attendance = [
|
|
{
|
|
"user_id": row[0],
|
|
"status": row[1],
|
|
"name": row[2] or row[3],
|
|
"username": row[3],
|
|
}
|
|
for row in attendance_rows
|
|
]
|
|
attending_users = [item for item in attendance if item["status"] == "attending"]
|
|
ticket_seekers = [item for item in attendance if item["status"] == "ticket_search"]
|
|
ticket_offers = [item for item in attendance if item["status"] == "ticket_offer"]
|
|
maybe_users = [item for item in attendance if item["status"] == "maybe"]
|
|
current_attendance = next(
|
|
(item["status"] for item in attendance if item["user_id"] == user["id"]),
|
|
None,
|
|
)
|
|
|
|
template = templates.get_template("concert_detail.html")
|
|
return template.render(
|
|
user=user,
|
|
concert=concert,
|
|
comments=comments,
|
|
photos=photos,
|
|
can_edit=can_edit_concert(user, concert),
|
|
can_delete=can_delete_concert(user, concert),
|
|
current_attendance=current_attendance,
|
|
attending_users=attending_users,
|
|
attending_count=len(attending_users),
|
|
ticket_seekers=ticket_seekers,
|
|
ticket_seeker_count=len(ticket_seekers),
|
|
ticket_offers=ticket_offers,
|
|
ticket_offer_count=len(ticket_offers),
|
|
maybe_users=maybe_users,
|
|
maybe_count=len(maybe_users),
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Create concert
|
|
# ============================================================
|
|
|
|
@app.post("/concerts")
|
|
async def create_concert(
|
|
request: Request,
|
|
artist: str = Form(...),
|
|
event_type: str = Form("concert"),
|
|
parent_event_id: str = Form(""),
|
|
visibility: str = Form("public"),
|
|
invited_user_ids: list[int] = Form(default=[]),
|
|
venue_id: str = Form(""),
|
|
venue_name: str = Form(""),
|
|
city: str = Form(""),
|
|
street: str = Form(""),
|
|
postal_code: str = Form(""),
|
|
country: str = Form(""),
|
|
latitude: str = Form(""),
|
|
longitude: str = Form(""),
|
|
start_datetime: str = Form(...),
|
|
end_datetime: str = Form(""),
|
|
description: str = Form(""),
|
|
ticket_url: str = Form(""),
|
|
ticket_price: str = Form(""),
|
|
flyer_url: str = Form(""),
|
|
flyer: UploadFile | None = File(None)
|
|
):
|
|
user = get_current_user(request)
|
|
|
|
if not user:
|
|
return login_redirect("/concerts/new")
|
|
|
|
if event_type not in EVENT_TYPES:
|
|
return HTMLResponse("<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 visibility not in {"public", "friends", "private"}:
|
|
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
|
|
if event_type != "other":
|
|
visibility = "public"
|
|
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]
|
|
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),
|
|
)
|
|
|
|
|
|
@app.post("/concerts/{concert_id}/edit")
|
|
async def edit_concert(
|
|
request: Request,
|
|
concert_id: int,
|
|
artist: str = Form(""),
|
|
event_type: str = Form("concert"),
|
|
parent_event_id: str = Form(""),
|
|
visibility: str = Form("public"),
|
|
invited_user_ids: list[int] = Form(default=[]),
|
|
venue_id: str = Form(""),
|
|
venue_name: str = Form(""),
|
|
city: str = Form(""),
|
|
street: str = Form(""),
|
|
postal_code: str = Form(""),
|
|
country: str = Form(""),
|
|
latitude: str = Form(""),
|
|
longitude: str = Form(""),
|
|
start_datetime: str = Form(""),
|
|
end_datetime: str = Form(""),
|
|
description: str = Form(""),
|
|
ticket_url: str = Form(""),
|
|
ticket_price: str = Form(""),
|
|
flyer_url: str = Form(""),
|
|
flyer: UploadFile | None = File(None)
|
|
):
|
|
user = get_current_user(request)
|
|
|
|
if not user:
|
|
return login_redirect(f"/concerts/{concert_id}/edit")
|
|
|
|
concert = load_concert(concert_id)
|
|
|
|
if not concert or not can_view_event(user, concert):
|
|
return HTMLResponse(
|
|
"<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,))
|
|
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]
|