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 @@

📓 Mein Konzerttagebuch

{% if can_write_diary %}

Dieser Eintrag ist privat und nur für dich sichtbar.

-
+ + + {% if diary_entry and diary_entry.photo_path %} + Erinnerungsfoto zu {{ concert.artist }} + + {% endif %} +
{% 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 @@ Konzerttagebuch · MetalCircle
{% include '_user_menu.html' %}

📓 Konzerttagebuch

Deine privaten Erinnerungen an besuchte Konzerte.

-{% for entry in entries %}

{{ entry.artist }}

{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}

{% for _ in range(entry.rating) %}★{% endfor %}{% for _ in range(5-entry.rating) %}☆{% endfor %}
{% if entry.favorite_song %}

🎵 Lieblingssong: {{ entry.favorite_song }}

{% endif %}{% if entry.notes %}

{{ entry.notes }}

{% endif %}
+{% for entry in entries %}

{{ entry.artist }}

{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}

{% for _ in range(entry.rating) %}★{% endfor %}{% for _ in range(5-entry.rating) %}☆{% endfor %}
{% if entry.photo_path %}Erinnerungsfoto zu {{ entry.artist }}{% endif %}{% if entry.favorite_song %}

🎵 Lieblingssong: {{ entry.favorite_song }}

{% endif %}{% if entry.notes %}

{{ entry.notes }}

{% endif %}{% if entry.badges %}
{% for badge in entry.badges %}
{% if badge.image_path %}Patch {{ badge.name }}{% else %}{{ badge.icon }}{% endif %}{{ badge.name }}
Hier erhalten · {{ badge.awarded_at }}
{% endfor %}
{% endif %}
{% else %}

Noch keine Einträge. Markiere ein vergangenes Konzert als besucht und halte dort deine Erinnerung fest.

{% endfor %}
diff --git a/app/templates/profile.html b/app/templates/profile.html index a16918f..e0f8f16 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -34,6 +34,11 @@ .patch-image { width: auto; height: auto; max-width: 140px; max-height: 140px; object-fit: contain; display: block; } .patch.locked { opacity: .38; filter: grayscale(1); } .patch.earned { border: 0; background: transparent; box-shadow: 2px 3px 7px rgba(0,0,0,.7); } + .patch-button { padding:0; border:0; background:transparent; color:inherit; cursor:pointer; } + .patch-dialog { width:min(92vw,460px); padding:24px; color:#f8fafc; background:#171717; border:1px solid var(--border); border-radius:14px; box-shadow:0 20px 60px #000; } + .patch-dialog::backdrop { background:rgba(0,0,0,.78); } + .patch-dialog-preview { max-width:150px; max-height:150px; object-fit:contain; } + .patch-dialog-close { float:right; margin:0; } .profile-edit { margin-top: 24px; } .account-actions { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-top:22px; padding-top:16px; border-top:1px solid var(--border); } .account-delete { flex:1; color:var(--muted); } @@ -127,13 +132,21 @@
{% for badge in badges %} {% if badge.earned %} -
+
+ + +
+ {% if badge.image_path %}Patch {{ badge.name }}{% else %}
{{ badge.icon }}
{% endif %} +

{{ badge.name }}

{{ badge.description }}

+

Erhalten am: {{ badge.awarded_at }}

+ {% if badge.trigger_concert %}

Ausgelöst durch: {{ badge.trigger_concert.artist }} · {{ badge.trigger_concert.date }}

{% else %}

Grund: {{ badge.description }}

{% endif %} +
{% endif %} {% endfor %} diff --git a/compose.yml b/compose.yml index 0894675..5558d7a 100644 --- a/compose.yml +++ b/compose.yml @@ -29,9 +29,11 @@ services: INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD} INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL} COOKIE_SECURE: ${COOKIE_SECURE:-true} + PRIVATE_UPLOAD_DIR: /app/private_uploads volumes: - concert_uploads:/app/static/uploads + - private_uploads:/app/private_uploads depends_on: - db @@ -40,3 +42,4 @@ services: volumes: postgres_data: concert_uploads: + private_uploads: diff --git a/db/init/01_initial.sql b/db/init/01_initial.sql index d4ab920..66e46b6 100644 --- a/db/init/01_initial.sql +++ b/db/init/01_initial.sql @@ -166,6 +166,7 @@ CREATE TABLE concert_attendance ( CREATE TABLE 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) ); @@ -215,6 +216,7 @@ CREATE TABLE concert_diary ( 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) diff --git a/db/migrations/15_diary_photos.sql b/db/migrations/15_diary_photos.sql new file mode 100644 index 0000000..4b04573 --- /dev/null +++ b/db/migrations/15_diary_photos.sql @@ -0,0 +1,2 @@ +ALTER TABLE concert_diary +ADD COLUMN IF NOT EXISTS photo_path TEXT; diff --git a/db/migrations/16_badge_triggers.sql b/db/migrations/16_badge_triggers.sql new file mode 100644 index 0000000..b89ec7a --- /dev/null +++ b/db/migrations/16_badge_triggers.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_badges +ADD COLUMN IF NOT EXISTS trigger_concert_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL;