{{ entry.artist }}
{% if entry.favorite_song %}🎵 Lieblingssong: {{ entry.favorite_song }}
{% endif %}{% if entry.notes %}{{ entry.notes }}
{% endif %}diff --git a/app/main.py b/app/main.py index 6de2e6a..bb56448 100644 --- a/app/main.py +++ b/app/main.py @@ -25,7 +25,7 @@ except ImportError: Image = ImageOps = UnidentifiedImageError = None from fastapi import FastAPI, File, Form, Request, UploadFile -from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse from fastapi.encoders import jsonable_encoder from fastapi.staticfiles import StaticFiles @@ -64,10 +64,16 @@ PATCH_DIR = os.path.join( "patches" ) +DIARY_PHOTO_DIR = os.path.join( + os.environ.get("PRIVATE_UPLOAD_DIR", os.path.join(BASE_DIR, "private_uploads")), + "diary" +) + os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(PHOTO_DIR, exist_ok=True) os.makedirs(AVATAR_DIR, exist_ok=True) os.makedirs(PATCH_DIR, exist_ok=True) +os.makedirs(DIARY_PHOTO_DIR, exist_ok=True) SESSION_COOKIE = "pingu_session" SESSION_DAYS = 30 @@ -224,11 +230,15 @@ def ensure_schema(): CREATE TABLE IF NOT EXISTS user_badges ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, badge_code VARCHAR(50) NOT NULL, + trigger_concert_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL, awarded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, badge_code) ) """, """ + ALTER TABLE user_badges ADD COLUMN IF NOT EXISTS trigger_concert_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL + """, + """ CREATE TABLE IF NOT EXISTS badge_assets ( badge_code VARCHAR(50) PRIMARY KEY, path TEXT NOT NULL, @@ -419,12 +429,16 @@ def ensure_schema(): rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), favorite_song VARCHAR(255), notes TEXT, + photo_path TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, concert_id) ) """, """ + ALTER TABLE concert_diary ADD COLUMN IF NOT EXISTS photo_path TEXT + """, + """ CREATE TABLE IF NOT EXISTS concert_bands ( concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, band_key VARCHAR(255) NOT NULL, @@ -1313,14 +1327,15 @@ def attended_concert_stats(user_id: int) -> dict: with connection.cursor() as cursor: cursor.execute( """ - SELECT concerts.venue_id, concerts.event_type, - concerts.start_datetime::date, COALESCE(venues.country, '') + SELECT concerts.id, concerts.venue_id, concerts.event_type, + concerts.start_datetime, COALESCE(venues.country, '') FROM concert_attendance JOIN concerts ON concerts.id = concert_attendance.concert_id LEFT JOIN venues ON venues.id = concerts.venue_id WHERE concert_attendance.user_id = %s AND concert_attendance.status = 'attending' AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP + ORDER BY concerts.start_datetime, concerts.id """, (user_id,), ) @@ -1330,16 +1345,42 @@ def attended_concert_stats(user_id: int) -> dict: countries = set() dates = [] weekend_counts = {} - for venue_id, event_type, concert_date, country_value in rows: + badge_triggers = {} + seen_countries = set() + venue_event_ids = {} + weekend_event_ids = {} + previous_date = None + for concert_id, venue_id, event_type, concert_datetime, country_value in rows: + concert_date = concert_datetime.date() if venue_id is not None and event_type != "other": venue_counts[venue_id] = venue_counts.get(venue_id, 0) + 1 + venue_event_ids.setdefault(venue_id, []).append(concert_id) + if venue_counts[venue_id] == 5: + badge_triggers.setdefault("regular", concert_id) country = normalize_country(country_value) if country: countries.add(country) + if country not in seen_countries: + seen_countries.add(country) + if country != "deutschland": + badge_triggers.setdefault("border_breaker", concert_id) + if len(seen_countries) == 5: + badge_triggers.setdefault("globe_banger", concert_id) dates.append(concert_date) + if previous_date and concert_date - previous_date == timedelta(days=1): + badge_triggers.setdefault("double_trouble", concert_id) + previous_date = concert_date if concert_date.isoweekday() >= 5: weekend_key = concert_date.isocalendar()[:2] weekend_counts[weekend_key] = weekend_counts.get(weekend_key, 0) + 1 + weekend_event_ids.setdefault(weekend_key, []).append(concert_id) + if weekend_counts[weekend_key] == 3: + badge_triggers.setdefault("iron_weekend", concert_id) + + attendance_number = len(dates) + for code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS: + if category == "attendance" and threshold == attendance_number: + badge_triggers[code] = concert_id distinct_dates = sorted(set(dates)) return { @@ -1352,6 +1393,7 @@ def attended_concert_stats(user_id: int) -> dict: for earlier, later in zip(distinct_dates, distinct_dates[1:]) ), "has_iron_weekend": max(weekend_counts.values(), default=0) >= 3, + "badge_triggers": badge_triggers, } @@ -1376,25 +1418,25 @@ def grant_earned_badges(user_id: int, stats: dict, registered_at): (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 + VENUE_BADGE_CODES)), - ) + for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS: + if category == "attendance" and threshold is not None and stats["total"] >= threshold: + cursor.execute( + """ + INSERT INTO user_badges (user_id, badge_code, trigger_concert_id) + VALUES (%s, %s, %s) ON CONFLICT (user_id, badge_code) DO UPDATE SET + trigger_concert_id = COALESCE(user_badges.trigger_concert_id, EXCLUDED.trigger_concert_id) + """, + (user_id, badge_code, stats["badge_triggers"].get(badge_code)), + ) if highest_attendance_badge: cursor.execute( - """ - INSERT INTO user_badges (user_id, badge_code) - VALUES (%s, %s) - ON CONFLICT (user_id, badge_code) DO NOTHING - """, - (user_id, highest_attendance_badge), + "UPDATE user_badges SET trigger_concert_id = COALESCE(trigger_concert_id, %s) WHERE user_id = %s AND badge_code = %s", + (stats["badge_triggers"].get(highest_attendance_badge), user_id, highest_attendance_badge), ) if venue_badge: cursor.execute( - "INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING", - (user_id, venue_badge), + "INSERT INTO user_badges (user_id, badge_code, trigger_concert_id) VALUES (%s, %s, %s) ON CONFLICT (user_id, badge_code) DO UPDATE SET trigger_concert_id = COALESCE(user_badges.trigger_concert_id, EXCLUDED.trigger_concert_id)", + (user_id, venue_badge, stats["badge_triggers"].get(venue_badge)), ) achievement_codes = [] if stats["has_foreign_concert"]: @@ -1407,8 +1449,8 @@ def grant_earned_badges(user_id: int, stats: dict, registered_at): achievement_codes.append("iron_weekend") for badge_code in achievement_codes: cursor.execute( - "INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING", - (user_id, badge_code), + "INSERT INTO user_badges (user_id, badge_code, trigger_concert_id) VALUES (%s, %s, %s) ON CONFLICT (user_id, badge_code) DO UPDATE SET trigger_concert_id = COALESCE(user_badges.trigger_concert_id, EXCLUDED.trigger_concert_id)", + (user_id, badge_code, stats["badge_triggers"].get(badge_code)), ) connection.commit() @@ -1438,12 +1480,13 @@ def load_profile(username: str): return None cursor.execute( - "SELECT badge_code, awarded_at FROM user_badges WHERE user_id = %s", + "SELECT badge_code, awarded_at, trigger_concert_id FROM user_badges WHERE user_id = %s", (row[0],), ) badge_rows = cursor.fetchall() earned_codes = {badge_row[0] for badge_row in badge_rows} badge_awarded_at = {badge_row[0]: badge_row[1] for badge_row in badge_rows} + badge_triggers = {badge_row[0]: badge_row[2] for badge_row in badge_rows} is_founder = row[1].casefold() == "kai" if is_founder: @@ -1467,6 +1510,7 @@ def load_profile(username: str): "is_founder": is_founder, "earned_codes": earned_codes, "badge_awarded_at": badge_awarded_at, + "badge_triggers": badge_triggers, } @@ -2566,6 +2610,11 @@ def render_profile( ) profile = load_profile(username) badge_assets = load_badge_assets() + highest_visible_attendance = next( + (code for code, _name, _icon, threshold, _description, category in reversed(BADGE_DEFINITIONS) + if category == "attendance" and threshold is not None and attendance_stats["total"] >= threshold), + None, + ) badges = [ { "code": code, @@ -2574,12 +2623,23 @@ def render_profile( "threshold": threshold, "description": description, "category": category, - "earned": code in profile["earned_codes"], + "earned": code in profile["earned_codes"] and (category != "attendance" or code == highest_visible_attendance), "image_path": badge_assets.get(code), + "awarded_at": (profile["badge_awarded_at"].get(code) or profile["registered_at"]).strftime("%d.%m.%Y"), + "trigger_concert": None, "sort_key": (0, datetime.min) if code == "founder" else (1, datetime.min) if code == "admin" else (2, profile["badge_awarded_at"].get(code) or datetime.max), } for code, name, icon, threshold, description, category in BADGE_DEFINITIONS ] + for badge in badges: + trigger_id = profile["badge_triggers"].get(badge["code"]) + if trigger_id: + trigger_concert = load_concert(trigger_id) + if trigger_concert and can_view_event(viewer, trigger_concert): + badge["trigger_concert"] = { + "id": trigger_id, "artist": trigger_concert["artist"], + "date": trigger_concert["start_datetime"].strftime("%d.%m.%Y"), + } badges.sort(key=lambda badge: badge["sort_key"]) template = templates.get_template("profile.html") @@ -2729,14 +2789,14 @@ def export_profile_data(request: Request): ) followed_venues = cursor.fetchall() cursor.execute( - "SELECT concert_id, rating, favorite_song, notes, created_at, updated_at FROM concert_diary WHERE user_id = %s ORDER BY updated_at", + "SELECT concert_id, rating, favorite_song, notes, photo_path, created_at, updated_at FROM concert_diary WHERE user_id = %s ORDER BY updated_at", (user_id,), ) diary_entries = cursor.fetchall() cursor.execute( """ - SELECT badge_code, awarded_at FROM user_badges + SELECT badge_code, awarded_at, trigger_concert_id FROM user_badges WHERE user_id = %s ORDER BY awarded_at """, (user_id,), @@ -2769,8 +2829,8 @@ def export_profile_data(request: Request): "photos": rows_to_dicts(photos, ("id", "concert_id", "path", "created_at")), "followed_bands": rows_to_dicts(followed_bands, ("band_key", "display_name", "created_at")), "followed_venues": rows_to_dicts(followed_venues, ("venue_id", "created_at")), - "concert_diary": rows_to_dicts(diary_entries, ("concert_id", "rating", "favorite_song", "notes", "created_at", "updated_at")), - "badges": rows_to_dicts(badges, ("badge_code", "awarded_at")), + "concert_diary": rows_to_dicts(diary_entries, ("concert_id", "rating", "favorite_song", "notes", "photo_path", "created_at", "updated_at")), + "badges": rows_to_dicts(badges, ("badge_code", "awarded_at", "trigger_concert_id")), } filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"]) return JSONResponse( @@ -2794,11 +2854,13 @@ def delete_own_account(request: Request): photo_paths = [row[0] for row in cursor.fetchall()] cursor.execute("SELECT flyer_path FROM concerts WHERE created_by = %s AND flyer_path IS NOT NULL", (user_id,)) flyer_paths = [row[0] for row in cursor.fetchall()] + cursor.execute("SELECT photo_path FROM concert_diary WHERE user_id = %s AND photo_path IS NOT NULL", (user_id,)) + diary_photo_paths = [row[0] for row in cursor.fetchall()] cursor.execute("UPDATE registration_invites SET used_by = NULL WHERE used_by = %s", (user_id,)) cursor.execute("UPDATE concerts SET flyer_path = NULL WHERE created_by = %s", (user_id,)) cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) connection.commit() - for path, directory, prefix in [(avatar_path, AVATAR_DIR, "/static/uploads/avatars/")] + [(p, PHOTO_DIR, "/static/uploads/photos/") for p in photo_paths] + [(p, UPLOAD_DIR, "/static/uploads/flyers/") for p in flyer_paths]: + for path, directory, prefix in [(avatar_path, AVATAR_DIR, "/static/uploads/avatars/")] + [(p, PHOTO_DIR, "/static/uploads/photos/") for p in photo_paths] + [(p, UPLOAD_DIR, "/static/uploads/flyers/") for p in flyer_paths] + [(p, DIARY_PHOTO_DIR, "/diary/photo/") for p in diary_photo_paths]: remove_uploaded_file(path, directory, prefix) response = RedirectResponse("/login", status_code=303) response.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax") @@ -3337,7 +3399,7 @@ def diary_page(request: Request): """ SELECT d.concert_id, c.artist, c.start_datetime, d.rating, COALESCE(d.favorite_song, ''), COALESCE(d.notes, ''), - COALESCE(v.name, ''), COALESCE(v.city, '') + COALESCE(v.name, ''), COALESCE(v.city, ''), d.photo_path FROM concert_diary d JOIN concerts c ON c.id = d.concert_id LEFT JOIN venues v ON v.id = c.venue_id WHERE d.user_id = %s ORDER BY c.start_datetime DESC @@ -3347,16 +3409,57 @@ def diary_page(request: Request): entries = [ {"concert_id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"), "rating": row[3], "favorite_song": row[4], "notes": row[5], - "venue": ", ".join(filter(None, (row[6], row[7])))} + "venue": ", ".join(filter(None, (row[6], row[7]))), "photo_path": row[8]} for row in cursor.fetchall() ] + cursor.execute( + """ + SELECT ub.badge_code, ub.trigger_concert_id, ub.awarded_at, ba.path + FROM user_badges ub LEFT JOIN badge_assets ba ON ba.badge_code = ub.badge_code + WHERE ub.user_id = %s AND ub.trigger_concert_id IS NOT NULL + ORDER BY ub.awarded_at + """, + (user["id"],), + ) + diary_badges = {} + for badge_code, concert_id, awarded_at, image_path in cursor.fetchall(): + definition = BADGE_BY_CODE.get(badge_code) + if definition: + name, icon, _threshold, description, _category = definition + diary_badges.setdefault(concert_id, []).append({ + "name": name, "icon": icon, "description": description, + "image_path": image_path, "awarded_at": awarded_at.strftime("%d.%m.%Y"), + }) + for entry in entries: + entry["badges"] = diary_badges.get(entry["concert_id"], []) return templates.get_template("diary.html").render(user=user, entries=entries) +@app.get("/diary/photo/{filename}") +def diary_photo(request: Request, filename: str): + user = get_current_user(request) + if not filename or os.path.basename(filename) != filename: + return HTMLResponse("Bild nicht gefunden.", status_code=404) + photo_path = f"/diary/photo/{filename}" + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "SELECT 1 FROM concert_diary WHERE user_id = %s AND photo_path = %s", + (user["id"], photo_path), + ) + if not cursor.fetchone(): + return HTMLResponse("Bild nicht gefunden.", status_code=404) + file_path = os.path.join(DIARY_PHOTO_DIR, filename) + if not os.path.isfile(file_path): + return HTMLResponse("Bild nicht gefunden.", status_code=404) + return FileResponse(file_path) + + @app.post("/concerts/{concert_id}/diary") def save_diary_entry( request: Request, concert_id: int, rating: int = Form(...), - favorite_song: str = Form(""), notes: str = Form("") + favorite_song: str = Form(""), notes: str = Form(""), + photo: UploadFile | None = File(None), remove_photo: str = Form("") ): user = get_current_user(request) concert = load_concert(concert_id) @@ -3376,17 +3479,30 @@ def save_diary_entry( ) if not cursor.fetchone(): return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403) + cursor.execute( + "SELECT photo_path FROM concert_diary WHERE user_id = %s AND concert_id = %s", + (user["id"], concert_id), + ) + previous_row = cursor.fetchone() + previous_photo = previous_row[0] if previous_row else None + new_photo, error = save_image(photo, DIARY_PHOTO_DIR, "/diary/photo/") + if error: + return error + photo_path = new_photo or (None if remove_photo else previous_photo) cursor.execute( """ - INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes) - VALUES (%s, %s, %s, %s, %s) + INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes, photo_path) + VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (user_id, concert_id) DO UPDATE SET rating = EXCLUDED.rating, favorite_song = EXCLUDED.favorite_song, - notes = EXCLUDED.notes, updated_at = CURRENT_TIMESTAMP + notes = EXCLUDED.notes, photo_path = EXCLUDED.photo_path, + updated_at = CURRENT_TIMESTAMP """, - (user["id"], concert_id, rating, favorite_song or None, notes or None), + (user["id"], concert_id, rating, favorite_song or None, notes or None, photo_path), ) connection.commit() + if previous_photo and previous_photo != photo_path: + remove_uploaded_file(previous_photo, DIARY_PHOTO_DIR, "/diary/photo/") return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303) @@ -3395,8 +3511,12 @@ def delete_diary_entry(request: Request, concert_id: int): user = get_current_user(request) with get_db_connection() as connection: with connection.cursor() as cursor: + cursor.execute("SELECT photo_path FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) + row = cursor.fetchone() cursor.execute("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) connection.commit() + if row and row[0]: + remove_uploaded_file(row[0], DIARY_PHOTO_DIR, "/diary/photo/") return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303) @@ -3533,7 +3653,7 @@ def concert_detail(request: Request, concert_id: int): else: follows_venue = False cursor.execute( - "SELECT rating, COALESCE(favorite_song, ''), COALESCE(notes, '') FROM concert_diary WHERE user_id = %s AND concert_id = %s", + "SELECT rating, COALESCE(favorite_song, ''), COALESCE(notes, ''), photo_path FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id), ) diary_row = cursor.fetchone() @@ -3577,7 +3697,8 @@ def concert_detail(request: Request, concert_id: int): None, ) diary_entry = { - "rating": diary_row[0], "favorite_song": diary_row[1], "notes": diary_row[2] + "rating": diary_row[0], "favorite_song": diary_row[1], "notes": diary_row[2], + "photo_path": diary_row[3] } if diary_row else None concert_bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"]) for band in concert_bands: diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html index 6fed4e3..f0cce29 100644 --- a/app/templates/concert_detail.html +++ b/app/templates/concert_detail.html @@ -392,7 +392,7 @@
Dieser Eintrag ist privat und nur für dich sichtbar.
- {% if diary_entry %}{% endif %} diff --git a/app/templates/diary.html b/app/templates/diary.html index 12404ee..89e1ffa 100644 --- a/app/templates/diary.html +++ b/app/templates/diary.html @@ -1,7 +1,7 @@Deine privaten Erinnerungen an besuchte Konzerte.
🎵 Lieblingssong: {{ entry.favorite_song }}
{% endif %}{% if entry.notes %}{{ entry.notes }}
{% endif %}🎵 Lieblingssong: {{ entry.favorite_song }}
{% endif %}{% if entry.notes %}{{ entry.notes }}
{% endif %}{% if entry.badges %}Noch keine Einträge. Markiere ein vergangenes Konzert als besucht und halte dort deine Erinnerung fest.