Add diary photos and linked patch history
This commit is contained in:
+157
-36
@@ -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:
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE concert_diary
|
||||||
|
ADD COLUMN IF NOT EXISTS photo_path TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE user_badges
|
||||||
|
ADD COLUMN IF NOT EXISTS trigger_concert_id INTEGER REFERENCES concerts(id) ON DELETE SET NULL;
|
||||||
Reference in New Issue
Block a user