Add diary photos and linked patch history

This commit is contained in:
kai
2026-08-29 10:46:48 +02:00
parent 159515b0eb
commit 0b0b19b53a
8 changed files with 190 additions and 41 deletions
+155 -34
View File
@@ -25,7 +25,7 @@ except ImportError:
Image = ImageOps = UnidentifiedImageError = None Image = ImageOps = UnidentifiedImageError = None
from fastapi import FastAPI, File, Form, Request, UploadFile 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.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -64,10 +64,16 @@ PATCH_DIR = os.path.join(
"patches" "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(UPLOAD_DIR, exist_ok=True)
os.makedirs(PHOTO_DIR, exist_ok=True) os.makedirs(PHOTO_DIR, exist_ok=True)
os.makedirs(AVATAR_DIR, exist_ok=True) os.makedirs(AVATAR_DIR, exist_ok=True)
os.makedirs(PATCH_DIR, exist_ok=True) os.makedirs(PATCH_DIR, exist_ok=True)
os.makedirs(DIARY_PHOTO_DIR, exist_ok=True)
SESSION_COOKIE = "pingu_session" SESSION_COOKIE = "pingu_session"
SESSION_DAYS = 30 SESSION_DAYS = 30
@@ -224,11 +230,15 @@ def ensure_schema():
CREATE TABLE IF NOT EXISTS user_badges ( CREATE TABLE IF NOT EXISTS user_badges (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
badge_code VARCHAR(50) NOT NULL, 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, awarded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, badge_code) 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 ( CREATE TABLE IF NOT EXISTS badge_assets (
badge_code VARCHAR(50) PRIMARY KEY, badge_code VARCHAR(50) PRIMARY KEY,
path TEXT NOT NULL, path TEXT NOT NULL,
@@ -419,12 +429,16 @@ def ensure_schema():
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
favorite_song VARCHAR(255), favorite_song VARCHAR(255),
notes TEXT, notes TEXT,
photo_path TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, concert_id) 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 ( CREATE TABLE IF NOT EXISTS concert_bands (
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
band_key VARCHAR(255) NOT NULL, band_key VARCHAR(255) NOT NULL,
@@ -1313,14 +1327,15 @@ def attended_concert_stats(user_id: int) -> dict:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
""" """
SELECT concerts.venue_id, concerts.event_type, SELECT concerts.id, concerts.venue_id, concerts.event_type,
concerts.start_datetime::date, COALESCE(venues.country, '') concerts.start_datetime, COALESCE(venues.country, '')
FROM concert_attendance FROM concert_attendance
JOIN concerts ON concerts.id = concert_attendance.concert_id JOIN concerts ON concerts.id = concert_attendance.concert_id
LEFT JOIN venues ON venues.id = concerts.venue_id LEFT JOIN venues ON venues.id = concerts.venue_id
WHERE concert_attendance.user_id = %s WHERE concert_attendance.user_id = %s
AND concert_attendance.status = 'attending' AND concert_attendance.status = 'attending'
AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP
ORDER BY concerts.start_datetime, concerts.id
""", """,
(user_id,), (user_id,),
) )
@@ -1330,16 +1345,42 @@ def attended_concert_stats(user_id: int) -> dict:
countries = set() countries = set()
dates = [] dates = []
weekend_counts = {} 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": if venue_id is not None and event_type != "other":
venue_counts[venue_id] = venue_counts.get(venue_id, 0) + 1 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) country = normalize_country(country_value)
if country: if country:
countries.add(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) 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: if concert_date.isoweekday() >= 5:
weekend_key = concert_date.isocalendar()[:2] weekend_key = concert_date.isocalendar()[:2]
weekend_counts[weekend_key] = weekend_counts.get(weekend_key, 0) + 1 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)) distinct_dates = sorted(set(dates))
return { return {
@@ -1352,6 +1393,7 @@ def attended_concert_stats(user_id: int) -> dict:
for earlier, later in zip(distinct_dates, distinct_dates[1:]) for earlier, later in zip(distinct_dates, distinct_dates[1:])
), ),
"has_iron_weekend": max(weekend_counts.values(), default=0) >= 3, "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,), (user_id,),
) )
# Konzert-Patches sind eine Level-Leiste: immer nur die höchste for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
# erreichte Stufe anzeigen (ältere Stufen werden ersetzt). if category == "attendance" and threshold is not None and stats["total"] >= threshold:
cursor.execute( cursor.execute(
"DELETE FROM user_badges WHERE user_id = %s AND badge_code = ANY(%s)", """
(user_id, list(ATTENDANCE_BADGE_CODES + VENUE_BADGE_CODES)), 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: if highest_attendance_badge:
cursor.execute( cursor.execute(
""" "UPDATE user_badges SET trigger_concert_id = COALESCE(trigger_concert_id, %s) WHERE user_id = %s AND badge_code = %s",
INSERT INTO user_badges (user_id, badge_code) (stats["badge_triggers"].get(highest_attendance_badge), user_id, highest_attendance_badge),
VALUES (%s, %s)
ON CONFLICT (user_id, badge_code) DO NOTHING
""",
(user_id, highest_attendance_badge),
) )
if venue_badge: if venue_badge:
cursor.execute( cursor.execute(
"INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING", "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), (user_id, venue_badge, stats["badge_triggers"].get(venue_badge)),
) )
achievement_codes = [] achievement_codes = []
if stats["has_foreign_concert"]: 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") achievement_codes.append("iron_weekend")
for badge_code in achievement_codes: for badge_code in achievement_codes:
cursor.execute( cursor.execute(
"INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING", "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), (user_id, badge_code, stats["badge_triggers"].get(badge_code)),
) )
connection.commit() connection.commit()
@@ -1438,12 +1480,13 @@ def load_profile(username: str):
return None return None
cursor.execute( 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],), (row[0],),
) )
badge_rows = cursor.fetchall() badge_rows = cursor.fetchall()
earned_codes = {badge_row[0] for badge_row in badge_rows} 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_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" is_founder = row[1].casefold() == "kai"
if is_founder: if is_founder:
@@ -1467,6 +1510,7 @@ def load_profile(username: str):
"is_founder": is_founder, "is_founder": is_founder,
"earned_codes": earned_codes, "earned_codes": earned_codes,
"badge_awarded_at": badge_awarded_at, "badge_awarded_at": badge_awarded_at,
"badge_triggers": badge_triggers,
} }
@@ -2566,6 +2610,11 @@ def render_profile(
) )
profile = load_profile(username) profile = load_profile(username)
badge_assets = load_badge_assets() 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 = [ badges = [
{ {
"code": code, "code": code,
@@ -2574,12 +2623,23 @@ def render_profile(
"threshold": threshold, "threshold": threshold,
"description": description, "description": description,
"category": category, "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), "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), "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 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"]) badges.sort(key=lambda badge: badge["sort_key"])
template = templates.get_template("profile.html") template = templates.get_template("profile.html")
@@ -2729,14 +2789,14 @@ def export_profile_data(request: Request):
) )
followed_venues = cursor.fetchall() followed_venues = cursor.fetchall()
cursor.execute( 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,), (user_id,),
) )
diary_entries = cursor.fetchall() diary_entries = cursor.fetchall()
cursor.execute( 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 WHERE user_id = %s ORDER BY awarded_at
""", """,
(user_id,), (user_id,),
@@ -2769,8 +2829,8 @@ def export_profile_data(request: Request):
"photos": rows_to_dicts(photos, ("id", "concert_id", "path", "created_at")), "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_bands": rows_to_dicts(followed_bands, ("band_key", "display_name", "created_at")),
"followed_venues": rows_to_dicts(followed_venues, ("venue_id", "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")), "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")), "badges": rows_to_dicts(badges, ("badge_code", "awarded_at", "trigger_concert_id")),
} }
filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"]) filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"])
return JSONResponse( return JSONResponse(
@@ -2794,11 +2854,13 @@ def delete_own_account(request: Request):
photo_paths = [row[0] for row in cursor.fetchall()] 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,)) 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()] 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 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("UPDATE concerts SET flyer_path = NULL WHERE created_by = %s", (user_id,))
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
connection.commit() 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) remove_uploaded_file(path, directory, prefix)
response = RedirectResponse("/login", status_code=303) response = RedirectResponse("/login", status_code=303)
response.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax") 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, SELECT d.concert_id, c.artist, c.start_datetime, d.rating,
COALESCE(d.favorite_song, ''), COALESCE(d.notes, ''), 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 FROM concert_diary d JOIN concerts c ON c.id = d.concert_id
LEFT JOIN venues v ON v.id = c.venue_id LEFT JOIN venues v ON v.id = c.venue_id
WHERE d.user_id = %s ORDER BY c.start_datetime DESC WHERE d.user_id = %s ORDER BY c.start_datetime DESC
@@ -3347,16 +3409,57 @@ def diary_page(request: Request):
entries = [ entries = [
{"concert_id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"), {"concert_id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"),
"rating": row[3], "favorite_song": row[4], "notes": row[5], "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() 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) 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") @app.post("/concerts/{concert_id}/diary")
def save_diary_entry( def save_diary_entry(
request: Request, concert_id: int, rating: int = Form(...), 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) user = get_current_user(request)
concert = load_concert(concert_id) concert = load_concert(concert_id)
@@ -3376,17 +3479,30 @@ def save_diary_entry(
) )
if not cursor.fetchone(): if not cursor.fetchone():
return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403) 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( cursor.execute(
""" """
INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes) INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes, photo_path)
VALUES (%s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (user_id, concert_id) DO UPDATE SET ON CONFLICT (user_id, concert_id) DO UPDATE SET
rating = EXCLUDED.rating, favorite_song = EXCLUDED.favorite_song, 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() 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) 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) user = get_current_user(request)
with get_db_connection() as connection: with get_db_connection() as connection:
with connection.cursor() as cursor: 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)) cursor.execute("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
connection.commit() 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) return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303)
@@ -3533,7 +3653,7 @@ def concert_detail(request: Request, concert_id: int):
else: else:
follows_venue = False follows_venue = False
cursor.execute( 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), (user["id"], concert_id),
) )
diary_row = cursor.fetchone() diary_row = cursor.fetchone()
@@ -3577,7 +3697,8 @@ def concert_detail(request: Request, concert_id: int):
None, None,
) )
diary_entry = { 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 } if diary_row else None
concert_bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"]) concert_bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"])
for band in concert_bands: for band in concert_bands:
+7 -1
View File
@@ -392,7 +392,7 @@
<h2>📓 Mein Konzerttagebuch</h2> <h2>📓 Mein Konzerttagebuch</h2>
{% if can_write_diary %} {% if can_write_diary %}
<p>Dieser Eintrag ist privat und nur für dich sichtbar.</p> <p>Dieser Eintrag ist privat und nur für dich sichtbar.</p>
<form class="diary-form" method="post" action="/concerts/{{ concert.id }}/diary"> <form class="diary-form" method="post" action="/concerts/{{ concert.id }}/diary" enctype="multipart/form-data">
<label for="diary-rating">Bewertung</label> <label for="diary-rating">Bewertung</label>
<select id="diary-rating" name="rating" required> <select id="diary-rating" name="rating" required>
{% for value in range(1, 6) %}<option value="{{ value }}" {% if diary_entry and diary_entry.rating == value %}selected{% endif %}>{{ value }} von 5 Sternen</option>{% endfor %} {% for value in range(1, 6) %}<option value="{{ value }}" {% if diary_entry and diary_entry.rating == value %}selected{% endif %}>{{ value }} von 5 Sternen</option>{% endfor %}
@@ -401,6 +401,12 @@
<input id="favorite-song" name="favorite_song" maxlength="255" value="{{ diary_entry.favorite_song if diary_entry else '' }}"> <input id="favorite-song" name="favorite_song" maxlength="255" value="{{ diary_entry.favorite_song if diary_entry else '' }}">
<label for="diary-notes">Erinnerungen <small>(optional)</small></label> <label for="diary-notes">Erinnerungen <small>(optional)</small></label>
<textarea id="diary-notes" name="notes" maxlength="5000" placeholder="Was ist dir von diesem Abend geblieben?">{{ diary_entry.notes if diary_entry else '' }}</textarea> <textarea id="diary-notes" name="notes" maxlength="5000" placeholder="Was ist dir von diesem Abend geblieben?">{{ diary_entry.notes if diary_entry else '' }}</textarea>
<label for="diary-photo">Erinnerungsfoto <small>(optional, JPG, PNG oder WEBP, max. 10 MB)</small></label>
{% if diary_entry and diary_entry.photo_path %}
<img src="{{ diary_entry.photo_path }}" alt="Erinnerungsfoto zu {{ concert.artist }}" style="display:block;max-width:min(100%,520px);max-height:420px;object-fit:cover;border-radius:10px;margin-bottom:8px">
<label><input type="checkbox" name="remove_photo" value="1" style="width:auto"> Vorhandenes Foto entfernen</label>
{% endif %}
<input id="diary-photo" name="photo" type="file" accept="image/jpeg,image/png,image/webp">
<button class="attendance-option" type="submit">{% if diary_entry %}Eintrag aktualisieren{% else %}Im Tagebuch speichern{% endif %}</button> <button class="attendance-option" type="submit">{% if diary_entry %}Eintrag aktualisieren{% else %}Im Tagebuch speichern{% endif %}</button>
</form> </form>
{% if diary_entry %}<form method="post" action="/concerts/{{ concert.id }}/diary/delete" onsubmit="return confirm('Tagebucheintrag wirklich löschen?');"><button class="button button-secondary" type="submit">Tagebucheintrag löschen</button></form>{% endif %} {% if diary_entry %}<form method="post" action="/concerts/{{ concert.id }}/diary/delete" onsubmit="return confirm('Tagebucheintrag wirklich löschen?');"><button class="button button-secondary" type="submit">Tagebucheintrag löschen</button></form>{% endif %}
+2 -2
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Konzerttagebuch · MetalCircle</title><link rel="stylesheet" href="/static/css/style.css"><style> <!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Konzerttagebuch · MetalCircle</title><link rel="stylesheet" href="/static/css/style.css"><style>
.diary-list{display:grid;gap:14px}.diary-entry{padding:18px;background:linear-gradient(145deg,#101010,#211111);border:1px solid var(--border);border-left:3px solid #b91c1c;border-radius:12px}.diary-entry h2{margin:0 0 5px}.diary-meta{color:var(--muted)}.diary-rating{color:#fbbf24;font-size:1.2rem}.diary-notes{white-space:pre-wrap}.diary-song{color:#fca5a5} .diary-list{display:grid;gap:14px}.diary-entry{padding:18px;background:linear-gradient(145deg,#101010,#211111);border:1px solid var(--border);border-left:3px solid #b91c1c;border-radius:12px}.diary-entry h2{margin:0 0 5px}.diary-meta{color:var(--muted)}.diary-rating{color:#fbbf24;font-size:1.2rem}.diary-notes{white-space:pre-wrap}.diary-song{color:#fca5a5}.diary-photo{display:block;width:100%;max-width:680px;max-height:520px;object-fit:cover;border-radius:10px;margin:12px 0}.diary-patches{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.diary-patch{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#080808;border:1px solid #7f1d1d;border-radius:9px}.diary-patch img{width:48px;height:48px;object-fit:contain}.diary-patch-icon{font-size:1.7rem}
</style></head><body><header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header><main> </style></head><body><header><div class="header-inner"><a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>{% include '_user_menu.html' %}</div></header><main>
<div class="page-title"><h1>📓 Konzerttagebuch</h1><p>Deine privaten Erinnerungen an besuchte Konzerte.</p></div><div class="diary-list"> <div class="page-title"><h1>📓 Konzerttagebuch</h1><p>Deine privaten Erinnerungen an besuchte Konzerte.</p></div><div class="diary-list">
{% for entry in entries %}<article class="diary-entry"><h2><a href="/concerts/{{ entry.concert_id }}">{{ entry.artist }}</a></h2><p class="diary-meta">{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}</p><div class="diary-rating" aria-label="{{ entry.rating }} von 5 Sternen">{% for _ in range(entry.rating) %}★{% endfor %}{% for _ in range(5-entry.rating) %}☆{% endfor %}</div>{% if entry.favorite_song %}<p class="diary-song">🎵 Lieblingssong: {{ entry.favorite_song }}</p>{% endif %}{% if entry.notes %}<p class="diary-notes">{{ entry.notes }}</p>{% endif %}</article> {% for entry in entries %}<article class="diary-entry"><h2><a href="/concerts/{{ entry.concert_id }}">{{ entry.artist }}</a></h2><p class="diary-meta">{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}</p><div class="diary-rating" aria-label="{{ entry.rating }} von 5 Sternen">{% for _ in range(entry.rating) %}★{% endfor %}{% for _ in range(5-entry.rating) %}☆{% endfor %}</div>{% if entry.photo_path %}<img class="diary-photo" src="{{ entry.photo_path }}" alt="Erinnerungsfoto zu {{ entry.artist }}" loading="lazy">{% endif %}{% if entry.favorite_song %}<p class="diary-song">🎵 Lieblingssong: {{ entry.favorite_song }}</p>{% endif %}{% if entry.notes %}<p class="diary-notes">{{ entry.notes }}</p>{% endif %}{% if entry.badges %}<div class="diary-patches">{% for badge in entry.badges %}<div class="diary-patch" title="{{ badge.description }}">{% if badge.image_path %}<img src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">{% else %}<span class="diary-patch-icon">{{ badge.icon }}</span>{% endif %}<span><strong>{{ badge.name }}</strong><br><small>Hier erhalten · {{ badge.awarded_at }}</small></span></div>{% endfor %}</div>{% endif %}</article>
{% else %}<section class="empty"><p>Noch keine Einträge. Markiere ein vergangenes Konzert als besucht und halte dort deine Erinnerung fest.</p></section>{% endfor %}</div> {% else %}<section class="empty"><p>Noch keine Einträge. Markiere ein vergangenes Konzert als besucht und halte dort deine Erinnerung fest.</p></section>{% endfor %}</div>
</main></body></html> </main></body></html>
+15 -2
View File
@@ -34,6 +34,11 @@
.patch-image { width: auto; height: auto; max-width: 140px; max-height: 140px; object-fit: contain; display: block; } .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.locked { opacity: .38; filter: grayscale(1); }
.patch.earned { border: 0; background: transparent; box-shadow: 2px 3px 7px rgba(0,0,0,.7); } .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; } .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-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); } .account-delete { flex:1; color:var(--muted); }
@@ -127,13 +132,21 @@
<div class="patch-grid"> <div class="patch-grid">
{% for badge in badges %} {% for badge in badges %}
{% if badge.earned %} {% if badge.earned %}
<div class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name }} {{ badge.description }}" aria-label="{{ badge.name }}: {{ badge.description }}"> <button class="patch-button" type="button" onclick="document.getElementById('patch-{{ badge.code }}').showModal()" aria-label="Details zu {{ badge.name }} anzeigen">
<span class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name }} {{ badge.description }}">
{% if badge.image_path %} {% if badge.image_path %}
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}"> <img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">
{% else %} {% else %}
<span class="patch-icon">{{ badge.icon }}</span> <span class="patch-icon">{{ badge.icon }}</span>
{% endif %} {% endif %}
</div> </span></button>
<dialog class="patch-dialog" id="patch-{{ badge.code }}">
<form method="dialog"><button class="button button-secondary patch-dialog-close" aria-label="Schließen">×</button></form>
{% if badge.image_path %}<img class="patch-dialog-preview" src="{{ badge.image_path }}" alt="Patch {{ badge.name }}">{% else %}<div class="patch-icon">{{ badge.icon }}</div>{% endif %}
<h3>{{ badge.name }}</h3><p>{{ badge.description }}</p>
<p><strong>Erhalten am:</strong> {{ badge.awarded_at }}</p>
{% if badge.trigger_concert %}<p><strong>Ausgelöst durch:</strong> <a href="/concerts/{{ badge.trigger_concert.id }}">{{ badge.trigger_concert.artist }} · {{ badge.trigger_concert.date }}</a></p>{% else %}<p><strong>Grund:</strong> {{ badge.description }}</p>{% endif %}
</dialog>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
+3
View File
@@ -29,9 +29,11 @@ services:
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD} INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL} INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL}
COOKIE_SECURE: ${COOKIE_SECURE:-true} COOKIE_SECURE: ${COOKIE_SECURE:-true}
PRIVATE_UPLOAD_DIR: /app/private_uploads
volumes: volumes:
- concert_uploads:/app/static/uploads - concert_uploads:/app/static/uploads
- private_uploads:/app/private_uploads
depends_on: depends_on:
- db - db
@@ -40,3 +42,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
concert_uploads: concert_uploads:
private_uploads:
+2
View File
@@ -166,6 +166,7 @@ CREATE TABLE concert_attendance (
CREATE TABLE user_badges ( CREATE TABLE user_badges (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
badge_code VARCHAR(50) NOT NULL, 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, awarded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, badge_code) PRIMARY KEY (user_id, badge_code)
); );
@@ -215,6 +216,7 @@ CREATE TABLE concert_diary (
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
favorite_song VARCHAR(255), favorite_song VARCHAR(255),
notes TEXT, notes TEXT,
photo_path TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, concert_id) PRIMARY KEY (user_id, concert_id)
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE concert_diary
ADD COLUMN IF NOT EXISTS photo_path TEXT;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE user_badges
ADD COLUMN IF NOT EXISTS trigger_concert_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL;