From b2af6c68575ee49e97f3fd1ca1c0e7af0df027ef Mon Sep 17 00:00:00 2001 From: kai Date: Sat, 29 Aug 2026 11:58:56 +0200 Subject: [PATCH] Automate diary entries and add photo galleries --- app/main.py | 137 ++++++++++++++----- app/templates/_user_menu.html | 4 +- app/templates/concert_detail.html | 22 +-- app/templates/diary.html | 98 ++++++++++++- db/init/01_initial.sql | 13 +- db/migrations/17_automatic_diary_entries.sql | 10 ++ db/migrations/18_diary_photo_gallery.sql | 16 +++ 7 files changed, 238 insertions(+), 62 deletions(-) create mode 100644 db/migrations/17_automatic_diary_entries.sql create mode 100644 db/migrations/18_diary_photo_gallery.sql diff --git a/app/main.py b/app/main.py index bb56448..ffe6638 100644 --- a/app/main.py +++ b/app/main.py @@ -426,7 +426,7 @@ def ensure_schema(): CREATE TABLE IF NOT EXISTS concert_diary ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, - rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + rating SMALLINT CHECK (rating BETWEEN 1 AND 5), favorite_song VARCHAR(255), notes TEXT, photo_path TEXT, @@ -439,6 +439,27 @@ def ensure_schema(): ALTER TABLE concert_diary ADD COLUMN IF NOT EXISTS photo_path TEXT """, """ + ALTER TABLE concert_diary ALTER COLUMN rating DROP NOT NULL + """, + """ + CREATE TABLE IF NOT EXISTS concert_diary_photos ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + path TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_concert_diary_photos_entry + ON concert_diary_photos (user_id, concert_id, created_at) + """, + """ + INSERT INTO concert_diary_photos (user_id, concert_id, path) + SELECT user_id, concert_id, photo_path FROM concert_diary WHERE photo_path IS NOT NULL + ON CONFLICT (path) DO NOTHING + """, + """ CREATE TABLE IF NOT EXISTS concert_bands ( concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, band_key VARCHAR(255) NOT NULL, @@ -2793,6 +2814,11 @@ def export_profile_data(request: Request): (user_id,), ) diary_entries = cursor.fetchall() + cursor.execute( + "SELECT id, concert_id, path, created_at FROM concert_diary_photos WHERE user_id = %s ORDER BY created_at", + (user_id,), + ) + diary_photos = cursor.fetchall() cursor.execute( """ @@ -2830,6 +2856,7 @@ def export_profile_data(request: Request): "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", "photo_path", "created_at", "updated_at")), + "concert_diary_photos": rows_to_dicts(diary_photos, ("id", "concert_id", "path", "created_at")), "badges": rows_to_dicts(badges, ("badge_code", "awarded_at", "trigger_concert_id")), } filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"]) @@ -2854,7 +2881,7 @@ 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,)) + cursor.execute("SELECT path FROM concert_diary_photos WHERE user_id = %s", (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,)) @@ -3395,11 +3422,22 @@ def diary_page(request: Request): user = get_current_user(request) with get_db_connection() as connection: with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO concert_diary (user_id, concert_id) + SELECT ca.user_id, ca.concert_id + FROM concert_attendance ca JOIN concerts c ON c.id = ca.concert_id + WHERE ca.user_id = %s AND ca.status = 'attending' + AND COALESCE(c.end_datetime, c.start_datetime) < CURRENT_TIMESTAMP + ON CONFLICT (user_id, concert_id) DO NOTHING + """, + (user["id"],), + ) cursor.execute( """ SELECT d.concert_id, c.artist, c.start_datetime, d.rating, COALESCE(d.favorite_song, ''), COALESCE(d.notes, ''), - COALESCE(v.name, ''), COALESCE(v.city, ''), d.photo_path + COALESCE(v.name, ''), COALESCE(v.city, '') 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 @@ -3409,9 +3447,17 @@ 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]))), "photo_path": row[8]} + "venue": ", ".join(filter(None, (row[6], row[7]))), "photos": []} for row in cursor.fetchall() ] + entry_by_concert = {entry["concert_id"]: entry for entry in entries} + cursor.execute( + "SELECT id, concert_id, path FROM concert_diary_photos WHERE user_id = %s ORDER BY created_at, id", + (user["id"],), + ) + for photo_id, concert_id, path in cursor.fetchall(): + if concert_id in entry_by_concert: + entry_by_concert[concert_id]["photos"].append({"id": photo_id, "path": path}) cursor.execute( """ SELECT ub.badge_code, ub.trigger_concert_id, ub.awarded_at, ba.path @@ -3432,6 +3478,7 @@ def diary_page(request: Request): }) for entry in entries: entry["badges"] = diary_badges.get(entry["concert_id"], []) + connection.commit() return templates.get_template("diary.html").render(user=user, entries=entries) @@ -3444,7 +3491,7 @@ def diary_photo(request: Request, filename: str): 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", + "SELECT 1 FROM concert_diary_photos WHERE user_id = %s AND path = %s", (user["id"], photo_path), ) if not cursor.fetchone(): @@ -3457,16 +3504,20 @@ def diary_photo(request: Request, filename: str): @app.post("/concerts/{concert_id}/diary") def save_diary_entry( - request: Request, concert_id: int, rating: int = Form(...), + request: Request, concert_id: int, rating: str = Form(""), favorite_song: str = Form(""), notes: str = Form(""), - photo: UploadFile | None = File(None), remove_photo: str = Form("") + photos: list[UploadFile] = File(default=[]) ): user = get_current_user(request) concert = load_concert(concert_id) if not concert or not can_view_event(user, concert): return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404) - if not concert["is_past"] or rating not in range(1, 6): - return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer Bewertung von 1 bis 5 verfügbar.", status_code=400) + try: + rating_value = int(rating) if rating else None + except ValueError: + rating_value = None + if not concert["is_past"] or (rating_value is not None and rating_value not in range(1, 6)): + return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen Bewertung von 1 bis 5 verfügbar.", status_code=400) favorite_song = favorite_song.strip() notes = notes.strip() if len(favorite_song) > 255 or len(notes) > 5000: @@ -3479,31 +3530,47 @@ 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("SELECT COUNT(*) FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) + existing_photo_count = cursor.fetchone()[0] + uploads = [photo for photo in photos if photo and photo.filename] + if existing_photo_count + len(uploads) > 3: + return HTMLResponse("Pro Tagebucheintrag sind maximal drei Bilder möglich.", status_code=400) + saved_paths = [] + for photo in uploads: + saved_path, error = save_image(photo, DIARY_PHOTO_DIR, "/diary/photo/") + if error: + for path in saved_paths: + remove_uploaded_file(path, DIARY_PHOTO_DIR, "/diary/photo/") + return error + saved_paths.append(saved_path) cursor.execute( """ - INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes, photo_path) - VALUES (%s, %s, %s, %s, %s, %s) + INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes) + VALUES (%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, photo_path = EXCLUDED.photo_path, - updated_at = CURRENT_TIMESTAMP + notes = EXCLUDED.notes, updated_at = CURRENT_TIMESTAMP """, - (user["id"], concert_id, rating, favorite_song or None, notes or None, photo_path), + (user["id"], concert_id, rating_value, favorite_song or None, notes or None), ) + for path in saved_paths: + cursor.execute("INSERT INTO concert_diary_photos (user_id, concert_id, path) VALUES (%s, %s, %s)", (user["id"], concert_id, 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) + return RedirectResponse(f"/diary#concert-{concert_id}", status_code=303) + + +@app.post("/diary/photos/{photo_id}/delete") +def delete_diary_photo(request: Request, photo_id: int): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM concert_diary_photos WHERE id = %s AND user_id = %s RETURNING path, concert_id", (photo_id, user["id"])) + deleted = cursor.fetchone() + connection.commit() + if not deleted: + return HTMLResponse("Bild nicht gefunden.", status_code=404) + remove_uploaded_file(deleted[0], DIARY_PHOTO_DIR, "/diary/photo/") + return RedirectResponse(f"/diary#concert-{deleted[1]}", status_code=303) @app.post("/concerts/{concert_id}/diary/delete") @@ -3511,13 +3578,14 @@ 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("SELECT path FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) + photo_paths = [row[0] for row in cursor.fetchall()] + cursor.execute("DELETE FROM concert_diary_photos 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() - 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) + for path in photo_paths: + remove_uploaded_file(path, DIARY_PHOTO_DIR, "/diary/photo/") + return RedirectResponse("/diary", status_code=303) # ============================================================ @@ -4190,6 +4258,11 @@ def set_attendance( """, (concert_id, user["id"], status), ) + if status == "attending" and concert["is_past"]: + cursor.execute( + "INSERT INTO concert_diary (user_id, concert_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", + (user["id"], concert_id), + ) connection.commit() return RedirectResponse(f"/concerts/{concert_id}#attendance", status_code=303) diff --git a/app/templates/_user_menu.html b/app/templates/_user_menu.html index 2436cc6..662c237 100644 --- a/app/templates/_user_menu.html +++ b/app/templates/_user_menu.html @@ -2,9 +2,9 @@ ☠ {{ user.display_name }}{% if user.notification_count %} 🤘{{ user.notification_count }}{% endif %}
Mein Profil - Gefolgte Bands & Locations - Konzerttagebuch Nachrichten{% if user.notification_count %} 🤘{{ user.notification_count }}{% endif %} + Konzerttagebuch + Gefolgte Bands & Locations {% if user.is_admin %}⚙️ Verwaltung{% endif %} Datenschutz Impressum diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html index f0cce29..5d005f9 100644 --- a/app/templates/concert_detail.html +++ b/app/templates/concert_detail.html @@ -391,27 +391,9 @@

📓 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 %} +

Der private Eintrag wurde automatisch angelegt. Im Konzerttagebuch bearbeiten

{% else %} -

Das private Tagebuch ist verfügbar, wenn du dieses vergangene Konzert als „Zugesagt“ markiert hattest.

+

Ein Tagebucheintrag wird automatisch angelegt, sobald du das vergangene Konzert als „Zugesagt“ markierst.

{% endif %}
{% endif %} diff --git a/app/templates/diary.html b/app/templates/diary.html index 89e1ffa..46d7114 100644 --- a/app/templates/diary.html +++ b/app/templates/diary.html @@ -1,7 +1,91 @@ -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.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 %}
-
+ + + + + + Konzerttagebuch · MetalCircle + + + + +
{% include '_user_menu.html' %}
+
+

📓 Konzerttagebuch

Besuchte Konzerte werden automatisch eingetragen. Deine Erinnerungen bleiben privat.

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

{{ entry.artist }}

+

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

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

Noch nicht bewertet.

{% endif %} + {% if entry.favorite_song %}

🎵 Lieblingssong: {{ entry.favorite_song }}

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

{{ entry.notes }}

{% endif %} + {% if entry.photos %}{% 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 %} +
+ Eintrag bearbeiten +
+ + + + + + + + = 3 %}disabled{% endif %}> + Bis zu drei Bilder; große Bilder werden automatisch optimiert. + +
+
+
+ {% else %} +

Noch keine Einträge. Vergangene zugesagte Konzerte erscheinen hier automatisch.

+ {% endfor %} +
+
+ + + diff --git a/db/init/01_initial.sql b/db/init/01_initial.sql index 66e46b6..0b8dd1b 100644 --- a/db/init/01_initial.sql +++ b/db/init/01_initial.sql @@ -213,7 +213,7 @@ CREATE TABLE followed_venues ( CREATE TABLE concert_diary ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, - rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + rating SMALLINT CHECK (rating BETWEEN 1 AND 5), favorite_song VARCHAR(255), notes TEXT, photo_path TEXT, @@ -222,6 +222,17 @@ CREATE TABLE concert_diary ( PRIMARY KEY (user_id, concert_id) ); +CREATE TABLE concert_diary_photos ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + path TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_concert_diary_photos_entry + ON concert_diary_photos(user_id, concert_id, created_at); + CREATE TABLE concert_bands ( concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, band_key VARCHAR(255) NOT NULL, diff --git a/db/migrations/17_automatic_diary_entries.sql b/db/migrations/17_automatic_diary_entries.sql new file mode 100644 index 0000000..50a4dfe --- /dev/null +++ b/db/migrations/17_automatic_diary_entries.sql @@ -0,0 +1,10 @@ +ALTER TABLE concert_diary +ALTER COLUMN rating DROP NOT NULL; + +INSERT INTO concert_diary (user_id, concert_id) +SELECT ca.user_id, ca.concert_id +FROM concert_attendance ca +JOIN concerts c ON c.id = ca.concert_id +WHERE ca.status = 'attending' + AND COALESCE(c.end_datetime, c.start_datetime) < CURRENT_TIMESTAMP +ON CONFLICT (user_id, concert_id) DO NOTHING; diff --git a/db/migrations/18_diary_photo_gallery.sql b/db/migrations/18_diary_photo_gallery.sql new file mode 100644 index 0000000..7768183 --- /dev/null +++ b/db/migrations/18_diary_photo_gallery.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS concert_diary_photos ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + path TEXT NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_concert_diary_photos_entry +ON concert_diary_photos(user_id, concert_id, created_at); + +INSERT INTO concert_diary_photos (user_id, concert_id, path) +SELECT user_id, concert_id, photo_path +FROM concert_diary +WHERE photo_path IS NOT NULL +ON CONFLICT (path) DO NOTHING;