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.
+
+
+
+
+
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
+);