From 293777ff7fe580f2f8f802b7c5ac1dcb9055c0cd Mon Sep 17 00:00:00 2001 From: kai Date: Tue, 25 Aug 2026 11:41:57 +0200 Subject: [PATCH] =?UTF-8?q?Bearbeiten=20Button=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Dockerfile | 2 +- app/main.py | 1496 ++++++++++++++++++----------- app/static/style.css | 20 + app/templates/concert_detail.html | 8 + app/templates/edit_concert.html | 90 ++ compose.yml | 3 + db/init/01_initial.sql | 55 ++ db/migrations/03_community.sql | 50 + 8 files changed, 1188 insertions(+), 536 deletions(-) create mode 100644 app/templates/edit_concert.html create mode 100644 db/migrations/03_community.sql diff --git a/app/Dockerfile b/app/Dockerfile index 6174b32..7bb86f1 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13-slim WORKDIR /app -RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx +RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt COPY . . diff --git a/app/main.py b/app/main.py index 4162e97..c318d64 100644 --- a/app/main.py +++ b/app/main.py @@ -2,20 +2,20 @@ import os import uuid import secrets import hashlib +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +import bcrypt import httpx import psycopg -from datetime import datetime -from fastapi import FastAPI, Form, UploadFile, File +from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader, select_autoescape -app = FastAPI(title="Pingu Concerts") - DATABASE_URL = os.environ["DATABASE_URL"] BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -27,11 +27,101 @@ UPLOAD_DIR = os.path.join( "flyers" ) -os.makedirs( - UPLOAD_DIR, - exist_ok=True +PHOTO_DIR = os.path.join( + BASE_DIR, + "static", + "uploads", + "photos" ) +os.makedirs(UPLOAD_DIR, exist_ok=True) +os.makedirs(PHOTO_DIR, exist_ok=True) + +SESSION_COOKIE = "pingu_session" +SESSION_DAYS = 30 +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +MAX_IMAGE_BYTES = 10 * 1024 * 1024 + + +def get_db_connection(): + return psycopg.connect(DATABASE_URL) + + +def ensure_schema(): + statements = [ + """ + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name VARCHAR(100), + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + ALTER TABLE users + ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE + """, + """ + CREATE TABLE IF NOT EXISTS registration_invites ( + id SERIAL PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMP, + used_by INTEGER REFERENCES users(id), + used_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + ALTER TABLE concerts + ADD COLUMN IF NOT EXISTS created_by INTEGER REFERENCES users(id) ON DELETE SET NULL + """, + """ + CREATE TABLE IF NOT EXISTS concert_comments ( + id SERIAL PRIMARY KEY, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS concert_photos ( + id SERIAL PRIMARY KEY, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + path TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + ] + + with get_db_connection() as connection: + with connection.cursor() as cursor: + for statement in statements: + cursor.execute(statement) + connection.commit() + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + ensure_schema() + yield + + +app = FastAPI(title="Pingu Concerts", lifespan=lifespan) + # ============================================================ # Templates @@ -59,14 +149,7 @@ app.mount( # ============================================================ -# Database -# ============================================================ - -def get_db_connection(): - return psycopg.connect(DATABASE_URL) - -# ============================================================ -# Token helpers +# Templates # ============================================================ def hash_token(token: str) -> str: @@ -78,8 +161,353 @@ def hash_token(token: str) -> str: def generate_token() -> str: return secrets.token_urlsafe(32) + +def user_from_row(row): + if not row: + return None + + return { + "id": row[0], + "username": row[1], + "display_name": row[2] or row[1], + "is_admin": bool(row[3]), + } + + +def get_current_user(request: Request): + token = request.cookies.get(SESSION_COOKIE) + + if not token: + return None + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT + users.id, + users.username, + users.display_name, + users.is_admin + FROM sessions + JOIN users + ON users.id = sessions.user_id + WHERE sessions.token_hash = %s + AND sessions.expires_at > CURRENT_TIMESTAMP + """, + (hash_token(token),), + ) + row = cursor.fetchone() + + return user_from_row(row) + + +def create_session(user_id: int) -> str: + token = generate_token() + expires_at = datetime.now() + timedelta(days=SESSION_DAYS) + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO sessions ( + user_id, + token_hash, + expires_at + ) + VALUES (%s, %s, %s) + """, + (user_id, hash_token(token), expires_at), + ) + connection.commit() + + return token + + +def attach_session(response: RedirectResponse, token: str) -> RedirectResponse: + response.set_cookie( + SESSION_COOKIE, + token, + max_age=SESSION_DAYS * 24 * 60 * 60, + httponly=True, + samesite="lax", + ) + return response + + +def login_redirect(next_path: str = "/"): + return RedirectResponse( + f"/login?next={next_path}", + status_code=303, + ) + + +def concert_is_past(start_datetime, end_datetime) -> bool: + end = end_datetime or start_datetime + return end < datetime.now() + + +def load_concert(concert_id: int): + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT + concerts.id, + concerts.artist, + concerts.start_datetime, + concerts.end_datetime, + concerts.description, + concerts.ticket_url, + concerts.ticket_price, + concerts.flyer_path, + concerts.created_by, + venues.id, + venues.name, + venues.street, + venues.postal_code, + venues.city, + venues.country, + venues.latitude, + venues.longitude + FROM concerts + LEFT JOIN venues + ON concerts.venue_id = venues.id + WHERE concerts.id = %s + """, + (concert_id,), + ) + row = cursor.fetchone() + + if not row: + return None + + start_datetime = row[2] + end_datetime = row[3] + is_past = concert_is_past(start_datetime, end_datetime) + + return { + "id": row[0], + "artist": row[1], + "start_datetime": start_datetime, + "end_datetime": end_datetime, + "description": row[4], + "ticket_url": row[5], + "ticket_price": row[6], + "flyer_path": row[7], + "created_by": row[8], + "is_past": is_past, + "date": start_datetime.strftime("%d.%m.%Y"), + "time": start_datetime.strftime("%H:%M"), + "start_local": start_datetime.strftime("%Y-%m-%dT%H:%M"), + "end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None, + "end_time": end_datetime.strftime("%H:%M") if end_datetime else None, + "end_local": end_datetime.strftime("%Y-%m-%dT%H:%M") if end_datetime else "", + "venue": { + "id": row[9], + "name": row[10] or "Veranstaltungsort unbekannt", + "street": row[11], + "postal_code": row[12], + "city": row[13], + "country": row[14], + "latitude": row[15], + "longitude": row[16], + }, + } + + +def can_edit_title(user, concert) -> bool: + if not user: + return False + if concert["is_past"]: + return user["is_admin"] + return user["is_admin"] or user["id"] == concert["created_by"] + + +def can_edit_details(user, concert) -> bool: + if not user: + return False + if concert["is_past"]: + return user["is_admin"] + return True + + +def can_edit_concert(user, concert) -> bool: + return can_edit_title(user, concert) or can_edit_details(user, concert) + + +def can_delete_concert(user, concert) -> bool: + if not user: + return False + if concert["is_past"]: + return user["is_admin"] + return user["is_admin"] or user["id"] == concert["created_by"] + + +def serialize_concert_card(row): + concert_id, artist, start_datetime, venue, city = row + venue_text = venue or "Veranstaltungsort unbekannt" + if city: + venue_text += f", {city}" + + return { + "id": concert_id, + "artist": artist, + "date": start_datetime.strftime("%d.%m.%Y"), + "time": start_datetime.strftime("%H:%M"), + "venue": venue_text, + } + + +def save_image(upload: UploadFile, destination_dir: str, url_prefix: str): + if not upload or not upload.filename: + return None, None + + extension = os.path.splitext(upload.filename)[1].lower() + + if extension not in ALLOWED_IMAGE_EXTENSIONS: + return None, HTMLResponse( + "Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP.", + status_code=400, + ) + + filename = str(uuid.uuid4()) + extension + destination = os.path.join(destination_dir, filename) + contents = upload.file.read() + + if len(contents) > MAX_IMAGE_BYTES: + return None, HTMLResponse( + "Die Datei darf maximal 10 MB groß sein.", + status_code=400, + ) + + with open(destination, "wb") as file: + file.write(contents) + + return f"{url_prefix}{filename}", None + + +def resolve_venue( + cursor, + venue_id: str, + venue_name: str, + city: str, + street: str, + postal_code: str, + country: str, + latitude: str, + longitude: str, +): + selected_venue_id = None + + if venue_id: + if venue_id.startswith("nominatim:"): + external_id = venue_id.split(":", 1)[1] + + cursor.execute( + """ + SELECT id + FROM venues + WHERE external_id = %s + AND source = 'nominatim' + LIMIT 1 + """, + (external_id,), + ) + existing_venue = cursor.fetchone() + + if existing_venue: + selected_venue_id = existing_venue[0] + else: + cursor.execute( + """ + INSERT INTO venues ( + name, + street, + postal_code, + city, + country, + latitude, + longitude, + external_id, + source + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + venue_name, + street or None, + postal_code or None, + city or None, + country or "Deutschland", + float(latitude) if latitude else None, + float(longitude) if longitude else None, + external_id, + "nominatim", + ), + ) + selected_venue_id = cursor.fetchone()[0] + else: + try: + selected_venue_id = int(venue_id) + except ValueError: + selected_venue_id = None + + if not selected_venue_id and venue_name: + cursor.execute( + """ + SELECT id + FROM venues + WHERE LOWER(name) = LOWER(%s) + AND LOWER(COALESCE(city, '')) = LOWER(%s) + LIMIT 1 + """, + (venue_name, city), + ) + existing_venue = cursor.fetchone() + + if existing_venue: + selected_venue_id = existing_venue[0] + else: + cursor.execute( + """ + INSERT INTO venues ( + name, + street, + postal_code, + city, + country, + latitude, + longitude + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + venue_name, + street or None, + postal_code or None, + city or None, + country or "Deutschland", + float(latitude) if latitude else None, + float(longitude) if longitude else None, + ), + ) + selected_venue_id = cursor.fetchone()[0] + + return selected_venue_id + + @app.get("/admin/invites") -def create_invite(): +def create_invite(request: Request): + user = get_current_user(request) + + if not user or not user["is_admin"]: + return HTMLResponse( + "

Nicht erlaubt

", + status_code=403, + ) token = secrets.token_urlsafe(32) @@ -201,26 +629,27 @@ def register_user( status_code=400 ) - # Passwort hashen - import bcrypt - password_hash = bcrypt.hashpw( password.encode("utf-8"), bcrypt.gensalt() ).decode("utf-8") - # Benutzer anlegen + cursor.execute("SELECT COUNT(*) FROM users") + is_first_user = cursor.fetchone()[0] == 0 + cursor.execute(""" INSERT INTO users ( username, email, password_hash, - display_name + display_name, + is_admin ) VALUES ( %s, %s, %s, + %s, %s ) RETURNING id @@ -228,7 +657,8 @@ def register_user( username, email, password_hash, - display_name or username + display_name or username, + is_first_user )) user_id = cursor.fetchone()[0] @@ -247,16 +677,9 @@ def register_user( connection.commit() - return HTMLResponse( - f""" -

Account erstellt 🎸

-

Willkommen bei Pingu Concerts, {display_name or username}!

-

Dein Account wurde erfolgreich erstellt.

-

- Zu Pingu Concerts -

- """ - ) + response = RedirectResponse("/", status_code=303) + return attach_session(response, create_session(user_id)) + # ============================================================ # Registration @@ -316,63 +739,115 @@ def register_page(token: str): token=token ) + +# ============================================================ +# Login +# ============================================================ + +@app.get("/login", response_class=HTMLResponse) +def login_page(request: Request, next: str = "/"): + if get_current_user(request): + return RedirectResponse(next or "/", status_code=303) + + template = templates.get_template("login.html") + return template.render(next_path=next or "/", error=None) + + +@app.post("/login") +def login( + username: str = Form(...), + password: str = Form(...), + next: str = Form("/"), +): + username = username.strip() + next_path = next if next.startswith("/") else "/" + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, password_hash + FROM users + WHERE LOWER(username) = LOWER(%s) + OR LOWER(email) = LOWER(%s) + """, + (username, username), + ) + row = cursor.fetchone() + + if not row or not bcrypt.checkpw( + password.encode("utf-8"), + row[1].encode("utf-8"), + ): + template = templates.get_template("login.html") + return HTMLResponse( + template.render( + next_path=next_path, + error="Benutzername oder Passwort ist falsch.", + ), + status_code=401, + ) + + response = RedirectResponse(next_path, status_code=303) + return attach_session(response, create_session(row[0])) + + +@app.post("/logout") +def logout(request: Request): + token = request.cookies.get(SESSION_COOKIE) + + if token: + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "DELETE FROM sessions WHERE token_hash = %s", + (hash_token(token),), + ) + connection.commit() + + response = RedirectResponse("/", status_code=303) + response.delete_cookie(SESSION_COOKIE) + return response + + # ============================================================ # Home # ============================================================ @app.get("/", response_class=HTMLResponse) -def home(): +def home(request: Request): + concert_query = """ + SELECT + concerts.id, + concerts.artist, + concerts.start_datetime, + venues.name, + venues.city + FROM concerts + LEFT JOIN venues + ON concerts.venue_id = venues.id + WHERE COALESCE(concerts.end_datetime, concerts.start_datetime) + {operator} CURRENT_TIMESTAMP + ORDER BY concerts.start_datetime {direction} + """ with get_db_connection() as connection: - with connection.cursor() as cursor: + cursor.execute( + concert_query.format(operator=">=", direction="ASC") + ) + upcoming_rows = cursor.fetchall() - cursor.execute(""" - SELECT - concerts.id, - concerts.artist, - concerts.start_datetime, - venues.name, - venues.city - FROM concerts - LEFT JOIN venues - ON concerts.venue_id = venues.id - ORDER BY concerts.start_datetime - """) - - rows = cursor.fetchall() - - concerts = [] - - for row in rows: - - ( - concert_id, - artist, - start_datetime, - venue, - city - ) = row - - venue_text = venue or "Veranstaltungsort unbekannt" - - if city: - venue_text += f", {city}" - - concerts.append({ - "id": concert_id, - "artist": artist, - "date": start_datetime.strftime("%d.%m.%Y"), - "time": start_datetime.strftime("%H:%M"), - "venue": venue_text - }) - - template = templates.get_template( - "index.html" - ) + cursor.execute( + concert_query.format(operator="<", direction="DESC") + ) + past_rows = cursor.fetchall() + template = templates.get_template("index.html") return template.render( - concerts=concerts + user=get_current_user(request), + upcoming_concerts=[serialize_concert_card(row) for row in upcoming_rows], + past_concerts=[serialize_concert_card(row) for row in past_rows], ) @@ -384,13 +859,14 @@ def home(): "/concerts/new", response_class=HTMLResponse ) -def new_concert(): +def new_concert(request: Request): + user = get_current_user(request) - template = templates.get_template( - "new_concert.html" - ) + if not user: + return login_redirect("/concerts/new") - return template.render() + template = templates.get_template("new_concert.html") + return template.render(user=user) # ============================================================ @@ -401,139 +877,83 @@ def new_concert(): "/concerts/{concert_id}", response_class=HTMLResponse ) -def concert_detail(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, - - venues.name, - venues.street, - venues.postal_code, - venues.city, - venues.country - - FROM concerts - - LEFT JOIN venues - ON concerts.venue_id = venues.id - - WHERE concerts.id = %s - """, ( - concert_id, - )) - - row = cursor.fetchone() - - - if not row: +def concert_detail(request: Request, concert_id: int): + concert = load_concert(concert_id) + if not concert: return HTMLResponse( "

Konzert nicht gefunden

", status_code=404 ) + user = get_current_user(request) - ( - concert_id, - artist, - start_datetime, - end_datetime, - description, - ticket_url, - ticket_price, - flyer_path, - - venue_name, - venue_street, - venue_postal_code, - venue_city, - venue_country - ) = row - - - concert = { - - "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" + 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,), ) - if end_datetime - else None, + comment_rows = cursor.fetchall() - "end_time": - end_datetime.strftime( - "%H:%M" + 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,), ) - if end_datetime - else None, - - "description": - description, - - "ticket_url": - ticket_url, - - "ticket_price": - ticket_price, - - "flyer_path": - flyer_path, - - "venue": { - - "name": - venue_name - or "Veranstaltungsort unbekannt", - - "street": - venue_street, - - "postal_code": - venue_postal_code, - - "city": - venue_city, - - "country": - venue_country + photo_rows = cursor.fetchall() + comments = [ + { + "id": row[0], + "body": row[1], + "created_at": row[2].strftime("%d.%m.%Y %H:%M"), + "author": row[3] or row[4], } + for row in comment_rows + ] - } - - - template = templates.get_template( - "concert_detail.html" - ) + 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 + ] + template = templates.get_template("concert_detail.html") return template.render( - concert=concert + user=user, + concert=concert, + comments=comments, + photos=photos, + can_edit=can_edit_concert(user, concert), + can_delete=can_delete_concert(user, concert), ) @@ -543,326 +963,53 @@ def concert_detail(concert_id: int): @app.post("/concerts") async def create_concert( - + request: Request, artist: str = Form(...), - venue_id: str = Form(""), - venue_name: str = Form(""), - city: str = Form(""), - street: str = Form(""), - postal_code: str = Form(""), - - country: str = Form( - "Deutschland" - ), - + country: str = Form("Deutschland"), latitude: str = Form(""), - longitude: str = Form(""), - start_datetime: str = Form(...), - end_datetime: str = Form(""), - description: str = Form(""), - ticket_url: str = Form(""), - ticket_price: str = Form(""), - flyer: UploadFile | None = File(None) - ): + user = get_current_user(request) - flyer_path = None + if not user: + return login_redirect("/concerts/new") + flyer_path, error = save_image( + flyer, + UPLOAD_DIR, + "/static/uploads/flyers/", + ) - # ======================================================== - # Flyer speichern - # ======================================================== - - if flyer and flyer.filename: - - allowed_extensions = { - ".jpg", - ".jpeg", - ".png", - ".webp" - } - - original_name = flyer.filename - - extension = os.path.splitext( - original_name - )[1].lower() - - - if extension not in allowed_extensions: - - return HTMLResponse( - "Ungültiges Flyer-Format. " - "Erlaubt sind JPG, JPEG, PNG und WEBP.", - status_code=400 - ) - - - filename = ( - str(uuid.uuid4()) - + extension - ) - - - destination = os.path.join( - UPLOAD_DIR, - filename - ) - - - contents = await flyer.read() - - - # 10 MB Limit - if len(contents) > 10 * 1024 * 1024: - - return HTMLResponse( - "Der Flyer darf maximal 10 MB groß sein.", - status_code=400 - ) - - - with open( - destination, - "wb" - ) as file: - - file.write(contents) - - - flyer_path = ( - "/static/uploads/flyers/" - + filename - ) - - - # ======================================================== - # Datenbank - # ======================================================== + if error: + return error with get_db_connection() as connection: - with connection.cursor() as cursor: + selected_venue_id = resolve_venue( + cursor, + venue_id, + venue_name, + city, + street, + postal_code, + country, + latitude, + longitude, + ) - selected_venue_id = None - - - # ================================================== - # Venue auswählen - # ================================================== - - if venue_id: - - if venue_id.startswith( - "nominatim:" - ): - - external_id = venue_id.split( - ":", - 1 - )[1] - - - cursor.execute(""" - SELECT id - FROM venues - WHERE - external_id = %s - AND source = 'nominatim' - LIMIT 1 - """, ( - external_id, - )) - - - existing_venue = ( - cursor.fetchone() - ) - - - if existing_venue: - - selected_venue_id = ( - existing_venue[0] - ) - - else: - - cursor.execute(""" - INSERT INTO venues ( - name, - street, - postal_code, - city, - country, - latitude, - longitude, - external_id, - source - ) - VALUES ( - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s - ) - RETURNING id - """, ( - - venue_name, - - street or None, - - postal_code or None, - - city or None, - - country - or "Deutschland", - - float(latitude) - if latitude - else None, - - float(longitude) - if longitude - else None, - - external_id, - - "nominatim" - - )) - - - selected_venue_id = ( - cursor.fetchone()[0] - ) - - - else: - - try: - - selected_venue_id = int( - venue_id - ) - - except ValueError: - - selected_venue_id = None - - - # ================================================== - # Manueller Venue-Fallback - # ================================================== - - if ( - not selected_venue_id - and venue_name - ): - - cursor.execute(""" - SELECT id - FROM venues - WHERE - LOWER(name) - = LOWER(%s) - AND LOWER( - COALESCE(city, '') - ) - = LOWER(%s) - LIMIT 1 - """, ( - venue_name, - city - )) - - - existing_venue = ( - cursor.fetchone() - ) - - - if existing_venue: - - selected_venue_id = ( - existing_venue[0] - ) - - else: - - cursor.execute(""" - INSERT INTO venues ( - name, - street, - postal_code, - city, - country, - latitude, - longitude - ) - VALUES ( - %s, - %s, - %s, - %s, - %s, - %s, - %s - ) - RETURNING id - """, ( - - venue_name, - - street or None, - - postal_code or None, - - city or None, - - country - or "Deutschland", - - float(latitude) - if latitude - else None, - - float(longitude) - if longitude - else None - - )) - - - selected_venue_id = ( - cursor.fetchone()[0] - ) - - - # ================================================== - # Konzert speichern - # ================================================== - - cursor.execute(""" + cursor.execute( + """ INSERT INTO concerts ( artist, venue_id, @@ -871,51 +1018,189 @@ async def create_concert( description, ticket_url, ticket_price, - flyer_path + flyer_path, + created_by ) VALUES ( - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s + %s, %s, %s, %s, %s, %s, %s, %s, %s ) RETURNING id - """, ( - - artist, - - selected_venue_id, - - start_datetime, - - end_datetime - or None, - - description - or None, - - ticket_url - or None, - - ticket_price - or None, - - flyer_path - - )) - - - concert_id = ( - cursor.fetchone()[0] + """, + ( + artist, + selected_venue_id, + start_datetime, + end_datetime or None, + description or None, + ticket_url or None, + ticket_price or None, + flyer_path, + user["id"], + ), ) + concert_id = cursor.fetchone()[0] connection.commit() + return RedirectResponse( + f"/concerts/{concert_id}", + status_code=303 + ) + + + + +# ============================================================ +# Edit concert +# ============================================================ + +@app.get( + "/concerts/{concert_id}/edit", + response_class=HTMLResponse +) +def edit_concert_page(request: Request, concert_id: int): + user = get_current_user(request) + + if not user: + return login_redirect(f"/concerts/{concert_id}/edit") + + concert = load_concert(concert_id) + + if not concert: + return HTMLResponse( + "

Konzert nicht gefunden

", + status_code=404 + ) + + if not can_edit_concert(user, concert): + return HTMLResponse( + "

Vergangene Veranstaltungen dürfen nur Admins bearbeiten.

", + status_code=403 + ) + + template = templates.get_template("edit_concert.html") + return template.render( + user=user, + concert=concert, + can_edit_title=can_edit_title(user, concert), + can_edit_details=can_edit_details(user, concert), + can_delete=can_delete_concert(user, concert), + ) + + +@app.post("/concerts/{concert_id}/edit") +async def edit_concert( + request: Request, + concert_id: int, + artist: str = Form(""), + venue_id: str = Form(""), + venue_name: str = Form(""), + city: str = Form(""), + street: str = Form(""), + postal_code: str = Form(""), + country: str = Form("Deutschland"), + latitude: str = Form(""), + longitude: str = Form(""), + start_datetime: str = Form(""), + end_datetime: str = Form(""), + description: str = Form(""), + ticket_url: str = Form(""), + ticket_price: str = Form(""), + flyer: UploadFile | None = File(None) +): + user = get_current_user(request) + + if not user: + return login_redirect(f"/concerts/{concert_id}/edit") + + concert = load_concert(concert_id) + + if not concert: + return HTMLResponse( + "

Konzert nicht gefunden

", + status_code=404 + ) + + if not can_edit_concert(user, concert): + return HTMLResponse( + "

Nicht erlaubt

", + status_code=403 + ) + + next_artist = concert["artist"] + if can_edit_title(user, concert) and artist.strip(): + next_artist = artist.strip() + + next_start = concert["start_datetime"] + next_end = concert["end_datetime"] + next_description = concert["description"] + next_ticket_url = concert["ticket_url"] + next_ticket_price = concert["ticket_price"] + next_flyer = concert["flyer_path"] + next_venue_id = concert["venue"]["id"] + + if can_edit_details(user, concert): + flyer_path, error = save_image( + flyer, + UPLOAD_DIR, + "/static/uploads/flyers/", + ) + if error: + return error + + with get_db_connection() as connection: + with connection.cursor() as cursor: + resolved_venue_id = resolve_venue( + cursor, + venue_id, + venue_name or concert["venue"]["name"], + city, + street, + postal_code, + country, + latitude, + longitude, + ) + connection.commit() + + next_start = start_datetime or concert["start_datetime"] + next_end = end_datetime or None + next_description = description or None + next_ticket_url = ticket_url or None + next_ticket_price = ticket_price or None + next_flyer = flyer_path or concert["flyer_path"] + next_venue_id = resolved_venue_id or concert["venue"]["id"] + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + UPDATE concerts + SET + artist = %s, + venue_id = %s, + start_datetime = %s, + end_datetime = %s, + description = %s, + ticket_url = %s, + ticket_price = %s, + flyer_path = %s + WHERE id = %s + """, + ( + next_artist, + next_venue_id, + next_start, + next_end or None, + next_description, + next_ticket_url, + next_ticket_price, + next_flyer, + concert_id, + ), + ) + connection.commit() return RedirectResponse( f"/concerts/{concert_id}", @@ -923,6 +1208,147 @@ async def create_concert( ) +@app.post("/concerts/{concert_id}/delete") +def delete_concert(request: Request, concert_id: int): + user = get_current_user(request) + + if not user: + return login_redirect(f"/concerts/{concert_id}") + + concert = load_concert(concert_id) + + if not concert: + return HTMLResponse( + "

Konzert nicht gefunden

", + status_code=404 + ) + + if not can_delete_concert(user, concert): + return HTMLResponse( + "

Nicht erlaubt

", + status_code=403 + ) + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "DELETE FROM concerts WHERE id = %s", + (concert_id,), + ) + connection.commit() + + return RedirectResponse("/", status_code=303) + + +@app.post("/concerts/{concert_id}/comments") +def add_comment( + request: Request, + concert_id: int, + body: str = Form(...), +): + user = get_current_user(request) + + if not user: + return login_redirect(f"/concerts/{concert_id}") + + concert = load_concert(concert_id) + + if not concert: + return HTMLResponse( + "

Konzert nicht gefunden

", + status_code=404 + ) + + text = body.strip() + + if not text: + return RedirectResponse( + f"/concerts/{concert_id}", + status_code=303 + ) + + if len(text) > 2000: + return HTMLResponse( + "

Kommentar ist zu lang (maximal 2000 Zeichen).

", + status_code=400 + ) + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO concert_comments ( + concert_id, + user_id, + body + ) + VALUES (%s, %s, %s) + """, + (concert_id, user["id"], text), + ) + connection.commit() + + return RedirectResponse( + f"/concerts/{concert_id}#comments", + status_code=303 + ) + + +@app.post("/concerts/{concert_id}/photos") +async def add_photo( + request: Request, + concert_id: int, + photo: UploadFile | None = File(None), +): + user = get_current_user(request) + + if not user: + return login_redirect(f"/concerts/{concert_id}") + + concert = load_concert(concert_id) + + if not concert: + return HTMLResponse( + "

Konzert nicht gefunden

", + status_code=404 + ) + + if not photo or not photo.filename: + return RedirectResponse( + f"/concerts/{concert_id}", + status_code=303 + ) + + path, error = save_image( + photo, + PHOTO_DIR, + "/static/uploads/photos/", + ) + + if error: + return error + + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO concert_photos ( + concert_id, + user_id, + path + ) + VALUES (%s, %s, %s) + """, + (concert_id, user["id"], path), + ) + connection.commit() + + return RedirectResponse( + f"/concerts/{concert_id}#photos", + status_code=303 + ) + + # ============================================================ # Venue search # ============================================================ diff --git a/app/static/style.css b/app/static/style.css index ede13f0..4c8d4ed 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -30,6 +30,21 @@ a { color: #ffffff; } +.edit-button { + float: right; + display: inline-block; + padding: 10px 14px; + background: #238636; + color: #ffffff; + border-radius: 9px; + text-decoration: none; + font-weight: bold; +} + +.edit-button:hover { + background: #2ea043; +} + .concert-detail { background: #161b22; border: 1px solid #30363d; @@ -150,6 +165,11 @@ a { padding: 22px; } + .edit-button { + float: none; + margin: 0 0 20px; + } + .flyer-container { padding: 12px; } diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html index c0deedf..51394cb 100644 --- a/app/templates/concert_detail.html +++ b/app/templates/concert_detail.html @@ -27,6 +27,14 @@ ← Zurück zum Kalender + {% if can_edit %} + + + ✏️ Konzert bearbeiten + + + {% endif %} +
diff --git a/app/templates/edit_concert.html b/app/templates/edit_concert.html new file mode 100644 index 0000000..7d7ec57 --- /dev/null +++ b/app/templates/edit_concert.html @@ -0,0 +1,90 @@ + + + + + + {{ concert.artist }} bearbeiten · Pingu Concerts + + + +
+ +
+
+
+

✏️ Konzert bearbeiten

+

Änderungen werden direkt beim Konzert gespeichert.

+
+
+
+

+ +

+ {% if can_edit_details %} +

+ +

+

+ +

+

+ +

+ + + +

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ + Ohne neue Datei bleibt der bisherige Flyer erhalten. +

+ {% if concert.flyer_path %} +

Aktueller Flyer

+ {% endif %} + {% endif %} +
+ + Abbrechen +
+
+
+
+ + diff --git a/compose.yml b/compose.yml index 7ca1e94..f6a5119 100644 --- a/compose.yml +++ b/compose.yml @@ -19,8 +19,11 @@ services: - "8080:8000" environment: DATABASE_URL: postgresql://concerts:change-me-later@db:5432/concerts + volumes: + - concert_uploads:/app/static/uploads depends_on: - db volumes: postgres_data: + concert_uploads: diff --git a/db/init/01_initial.sql b/db/init/01_initial.sql index 6190f98..2b8703e 100644 --- a/db/init/01_initial.sql +++ b/db/init/01_initial.sql @@ -1,3 +1,30 @@ +CREATE TABLE 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 +); + +CREATE TABLE 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 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 +); + CREATE TABLE venues ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, @@ -7,6 +34,8 @@ CREATE TABLE venues ( country VARCHAR(100) DEFAULT 'Deutschland', latitude DOUBLE PRECISION, longitude DOUBLE PRECISION, + external_id VARCHAR(255), + source VARCHAR(50), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -20,6 +49,23 @@ CREATE TABLE concerts ( ticket_url TEXT, ticket_price NUMERIC(10,2), flyer_path TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE 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 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 ); @@ -28,3 +74,12 @@ CREATE INDEX idx_concerts_start_datetime CREATE INDEX idx_venues_name ON venues(name); + +CREATE INDEX idx_venues_external_id + ON venues(external_id); + +CREATE INDEX idx_concert_comments_concert + ON concert_comments(concert_id, created_at); + +CREATE INDEX idx_concert_photos_concert + ON concert_photos(concert_id, created_at); diff --git a/db/migrations/03_community.sql b/db/migrations/03_community.sql new file mode 100644 index 0000000..3b4c83d --- /dev/null +++ b/db/migrations/03_community.sql @@ -0,0 +1,50 @@ +-- Bestehende Datenbanken nachziehen (wird auch beim App-Start ausgeführt). + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name VARCHAR(100), + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS registration_invites ( + id SERIAL PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMP, + used_by INTEGER REFERENCES users(id), + used_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE concerts +ADD COLUMN IF NOT EXISTS created_by INTEGER REFERENCES users(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS concert_comments ( + id SERIAL PRIMARY KEY, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS concert_photos ( + id SERIAL PRIMARY KEY, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + path TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +);