From 72ff659ac41f192db1eae0d495d55294199b5f4f Mon Sep 17 00:00:00 2001 From: kai Date: Tue, 25 Aug 2026 14:39:47 +0200 Subject: [PATCH] =?UTF-8?q?User=20Profile=20sind=20nun=20verf=C3=BCgbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 177 ++++++++++++++++++++++++++++++ app/templates/concert_detail.html | 8 +- app/templates/index.html | 4 + app/templates/profile.html | 88 +++++++++++++++ db/init/01_initial.sql | 8 ++ db/migrations/03_community.sql | 10 ++ 6 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 app/templates/profile.html diff --git a/app/main.py b/app/main.py index 1b0f2f2..38ca41d 100644 --- a/app/main.py +++ b/app/main.py @@ -35,8 +35,16 @@ PHOTO_DIR = os.path.join( "photos" ) +AVATAR_DIR = os.path.join( + BASE_DIR, + "static", + "uploads", + "avatars" +) + os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(PHOTO_DIR, exist_ok=True) +os.makedirs(AVATAR_DIR, exist_ok=True) SESSION_COOKIE = "pingu_session" SESSION_DAYS = 30 @@ -46,6 +54,13 @@ 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 = ( + ("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert"), + ("regular", "Stammgast", "🤘", 5, "5 Konzerte besucht"), + ("ten_gigs", "Zehnerrunde", "🔥", 10, "10 Konzerte besucht"), + ("tour_veteran", "Tourveteran", "⚡", 25, "25 Konzerte besucht"), +) + def get_db_connection(): return psycopg.connect(DATABASE_URL) @@ -69,6 +84,10 @@ def ensure_schema(): ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE """, """ + 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, @@ -120,6 +139,14 @@ def ensure_schema(): PRIMARY KEY (concert_id, user_id) ) """, + """ + 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) + ) + """, ] with get_db_connection() as connection: @@ -449,6 +476,74 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str): return f"{url_prefix}{filename}", None +def attended_concert_count(user_id: int) -> int: + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT COUNT(*) + 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 + """, + (user_id,), + ) + return cursor.fetchone()[0] + + +def grant_earned_badges(user_id: int, attended_count: int): + with get_db_connection() as connection: + with connection.cursor() as cursor: + for badge_code, _name, _icon, threshold, _description in BADGE_DEFINITIONS: + if attended_count >= threshold: + cursor.execute( + """ + INSERT INTO user_badges (user_id, badge_code) + VALUES (%s, %s) + ON CONFLICT (user_id, badge_code) DO NOTHING + """, + (user_id, badge_code), + ) + connection.commit() + + +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 + FROM users + WHERE LOWER(username) = LOWER(%s) + """, + (username,), + ) + row = cursor.fetchone() + + if not row: + return None + + cursor.execute( + "SELECT badge_code FROM user_badges WHERE user_id = %s", + (row[0],), + ) + earned_codes = {badge_row[0] for badge_row in cursor.fetchall()} + + 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"), + "earned_codes": earned_codes, + } + + def resolve_venue( cursor, venue_id: str, @@ -1027,6 +1122,86 @@ def home(request: Request): ) +# ============================================================ +# Profiles +# ============================================================ + +def render_profile(request: Request, username: str): + viewer = get_current_user(request) + profile = load_profile(username) + + if not profile: + return HTMLResponse("

Benutzer nicht gefunden

", status_code=404) + + attended_count = attended_concert_count(profile["id"]) + grant_earned_badges(profile["id"], attended_count) + profile = load_profile(username) + badges = [ + { + "code": code, + "name": name, + "icon": icon, + "threshold": threshold, + "description": description, + "earned": code in profile["earned_codes"], + } + for code, name, icon, threshold, description in BADGE_DEFINITIONS + ] + + template = templates.get_template("profile.html") + return template.render( + user=viewer, + profile=profile, + attended_count=attended_count, + badges=badges, + is_own_profile=viewer["id"] == profile["id"], + ) + + +@app.get("/profile", response_class=HTMLResponse) +def own_profile(request: Request): + user = get_current_user(request) + return render_profile(request, user["username"]) + + +@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(""), + avatar: UploadFile | None = File(None), +): + user = get_current_user(request) + 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( + """ + UPDATE users + SET + display_name = COALESCE(NULLIF(%s, ''), display_name), + avatar_path = COALESCE(%s, avatar_path) + WHERE id = %s + """, + (display_name.strip(), avatar_path, user["id"]), + ) + connection.commit() + + return RedirectResponse("/profile", status_code=303) + + # ============================================================ # New concert # ============================================================ @@ -1124,6 +1299,7 @@ def concert_detail(request: Request, concert_id: int): "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 ] @@ -1143,6 +1319,7 @@ def concert_detail(request: Request, concert_id: int): "user_id": row[0], "status": row[1], "name": row[2] or row[3], + "username": row[3], } for row in attendance_rows ] diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html index e04e04b..1c1d0e5 100644 --- a/app/templates/concert_detail.html +++ b/app/templates/concert_detail.html @@ -255,7 +255,7 @@ {% if attending_users %} {% else %} @@ -269,7 +269,7 @@ {% if maybe_users %} {% else %} @@ -283,7 +283,7 @@ {% if ticket_seekers %} {% else %} @@ -323,7 +323,7 @@ {% for comment in comments %}
- {{ comment.author }} + {{ comment.author }} {{ comment.created_at }}

{{ comment.body }}

diff --git a/app/templates/index.html b/app/templates/index.html index 3948b71..de5e403 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -167,6 +167,10 @@
+ + 👤 Mein Profil + + {% if user.is_admin %} diff --git a/app/templates/profile.html b/app/templates/profile.html new file mode 100644 index 0000000..b5e6dc1 --- /dev/null +++ b/app/templates/profile.html @@ -0,0 +1,88 @@ + + + + + + {{ profile.display_name }} · Pingu Concerts + + + + +
+ +
+
+
+

Profil

+

Deine Konzertgeschichte und deine Kutte.

+
+
+
+ {% if profile.avatar_path %} + Profilbild von {{ profile.display_name }} + {% else %} +
{{ profile.display_name[:1] }}
+ {% endif %} +

{{ profile.display_name }}

+

@{{ profile.username }}

+

Dabei seit {{ profile.created_at }}

+
{{ attended_count }}besuchte Konzerte
+
+
+

🥋 Virtuelle Kutte

+

Für besuchte Konzerte schaltest du neue Patches frei.

+
+
+ {% for badge in badges %} +
+ {{ badge.icon }} + {{ badge.name }} + {% if badge.earned %}Freigeschaltet{% else %}ab {{ badge.threshold }} Konzerten{% endif %} +
+ {% endfor %} +
+
+
+
+ {% if is_own_profile %} +
+

Profil bearbeiten

+
+ + + + + +
+
+ {% endif %} +
+ + diff --git a/db/init/01_initial.sql b/db/init/01_initial.sql index 9d47f82..246a619 100644 --- a/db/init/01_initial.sql +++ b/db/init/01_initial.sql @@ -4,6 +4,7 @@ CREATE TABLE users ( email VARCHAR(255) NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name VARCHAR(100), + avatar_path TEXT, is_admin BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -79,6 +80,13 @@ CREATE TABLE concert_attendance ( PRIMARY KEY (concert_id, user_id) ); +CREATE TABLE 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 INDEX idx_concerts_start_datetime ON concerts(start_datetime); diff --git a/db/migrations/03_community.sql b/db/migrations/03_community.sql index 1f13d3a..4cb24c3 100644 --- a/db/migrations/03_community.sql +++ b/db/migrations/03_community.sql @@ -13,6 +13,9 @@ CREATE TABLE IF NOT EXISTS users ( ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE; +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, @@ -61,3 +64,10 @@ CREATE TABLE IF NOT EXISTS concert_attendance ( CREATE INDEX IF NOT EXISTS idx_concert_attendance_concert ON concert_attendance(concert_id, status); + +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) +);