From 159515b0eb0ba3b421c06ce7701f8e81bc339fa7 Mon Sep 17 00:00:00 2001 From: kai Date: Sat, 29 Aug 2026 09:25:39 +0200 Subject: [PATCH] Add band and venue follows with concert diary --- app/main.py | 367 ++++++++++++++++++++++++- app/templates/_user_menu.html | 2 + app/templates/concert_detail.html | 44 +++ app/templates/datenschutz.html | 2 +- app/templates/diary.html | 7 + app/templates/edit_concert.html | 6 + app/templates/following.html | 8 + app/templates/new_concert.html | 12 +- db/init/01_initial.sql | 34 +++ db/migrations/13_follows_and_diary.sql | 25 ++ db/migrations/14_concert_bands.sql | 7 + 11 files changed, 506 insertions(+), 8 deletions(-) create mode 100644 app/templates/diary.html create mode 100644 app/templates/following.html create mode 100644 db/migrations/13_follows_and_diary.sql create mode 100644 db/migrations/14_concert_bands.sql diff --git a/app/main.py b/app/main.py index 78fcf07..6de2e6a 100644 --- a/app/main.py +++ b/app/main.py @@ -395,6 +395,44 @@ def ensure_schema(): CREATE INDEX IF NOT EXISTS idx_direct_messages_unread ON direct_messages (recipient_id, read_at) """, + """ + CREATE TABLE IF NOT EXISTS followed_bands ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, band_key) + ) + """, + """ + CREATE TABLE IF NOT EXISTS followed_venues ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, venue_id) + ) + """, + """ + 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), + favorite_song VARCHAR(255), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, concert_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS concert_bands ( + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + position SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (concert_id, band_key) + ) + """, ] with get_db_connection() as connection: @@ -1088,7 +1126,48 @@ def artist_names_similar(first: str, second: str) -> bool: return SequenceMatcher(None, left, right).ratio() >= 0.78 -def find_duplicate_concerts(user, artist: str, start_date: str): +def inferred_band_names(title: str, event_type: str = "concert") -> list[str]: + if event_type != "concert": + return [] + primary = re.split(r"\s+(?:-|–|—)\s+|:\s+", title.strip(), maxsplit=1)[0] + return [part.strip() for part in re.split(r"\s+(?:\+|/|&|and|und)\s+", primary) if part.strip()] + + +def parse_band_names(value: str, fallback_title: str = "", event_type: str = "concert") -> list[dict]: + names = [line.strip() for line in (value or "").splitlines() if line.strip()] + if not names: + names = inferred_band_names(fallback_title, event_type) + bands = [] + seen = set() + for name in names[:30]: + key = normalize_artist_name(name)[:255] + if key and key not in seen: + seen.add(key) + bands.append({"key": key, "name": name[:255]}) + return bands + + +def load_concert_bands(concert_id: int, title: str = "", event_type: str = "concert") -> list[dict]: + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "SELECT band_key, display_name FROM concert_bands WHERE concert_id = %s ORDER BY position, display_name", + (concert_id,), + ) + bands = [{"key": row[0], "name": row[1]} for row in cursor.fetchall()] + return bands or parse_band_names("", title, event_type) + + +def replace_concert_bands(cursor, concert_id: int, bands: list[dict]): + cursor.execute("DELETE FROM concert_bands WHERE concert_id = %s", (concert_id,)) + for position, band in enumerate(bands): + cursor.execute( + "INSERT INTO concert_bands (concert_id, band_key, display_name, position) VALUES (%s, %s, %s, %s)", + (concert_id, band["key"], band["name"], position), + ) + + +def find_duplicate_concerts(user, artist: str, start_date: str, band_names: str = ""): try: concert_date = datetime.strptime(start_date[:10], "%Y-%m-%d").date() except (TypeError, ValueError): @@ -1120,6 +1199,15 @@ def find_duplicate_concerts(user, artist: str, start_date: str): (concert_date, user["id"], user["is_admin"], user["id"], user["id"], user["id"]), ) rows = cursor.fetchall() + row_ids = [row[0] for row in rows] + cursor.execute( + "SELECT concert_id, band_key, display_name FROM concert_bands WHERE concert_id = ANY(%s) ORDER BY position", + (row_ids or [0],), + ) + stored_bands = {} + for concert_id, band_key, display_name in cursor.fetchall(): + stored_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name}) + submitted_bands = parse_band_names(band_names, artist, "concert") return [ { "id": row[0], @@ -1130,7 +1218,11 @@ def find_duplicate_concerts(user, artist: str, start_date: str): "venue": ", ".join(part for part in (row[4], row[5]) if part), } for row in rows - if artist_names_similar(artist, row[1]) + if any( + artist_names_similar(submitted["name"], existing["name"]) + for submitted in submitted_bands + for existing in (stored_bands.get(row[0]) or parse_band_names("", row[1], row[3])) + ) or artist_names_similar(artist, row[1]) ] @@ -2626,6 +2718,22 @@ def export_profile_data(request: Request): ) photos = cursor.fetchall() + cursor.execute( + "SELECT band_key, display_name, created_at FROM followed_bands WHERE user_id = %s ORDER BY created_at", + (user_id,), + ) + followed_bands = cursor.fetchall() + cursor.execute( + "SELECT venue_id, created_at FROM followed_venues WHERE user_id = %s ORDER BY created_at", + (user_id,), + ) + followed_venues = cursor.fetchall() + cursor.execute( + "SELECT concert_id, rating, favorite_song, notes, created_at, updated_at FROM concert_diary WHERE user_id = %s ORDER BY updated_at", + (user_id,), + ) + diary_entries = cursor.fetchall() + cursor.execute( """ SELECT badge_code, awarded_at FROM user_badges @@ -2659,6 +2767,9 @@ def export_profile_data(request: Request): "event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")), "comments": rows_to_dicts(comments, ("id", "concert_id", "body", "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_venues": rows_to_dicts(followed_venues, ("venue_id", "created_at")), + "concert_diary": rows_to_dicts(diary_entries, ("concert_id", "rating", "favorite_song", "notes", "created_at", "updated_at")), "badges": rows_to_dicts(badges, ("badge_code", "awarded_at")), } filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"]) @@ -3074,11 +3185,219 @@ def new_concert(request: Request): @app.get("/api/concerts/duplicates") -def duplicate_concerts(request: Request, artist: str = "", start_date: str = ""): +def duplicate_concerts(request: Request, artist: str = "", start_date: str = "", band_names: str = ""): user = get_current_user(request) if len(artist.strip()) < 2 or len(artist) > 300: return JSONResponse({"matches": []}) - return JSONResponse({"matches": find_duplicate_concerts(user, artist, start_date)}) + return JSONResponse({"matches": find_duplicate_concerts(user, artist, start_date, band_names)}) + + +@app.get("/following", response_class=HTMLResponse) +def following_page(request: Request): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "SELECT band_key, display_name FROM followed_bands WHERE user_id = %s ORDER BY display_name", + (user["id"],), + ) + followed_bands = [{"key": row[0], "name": row[1]} for row in cursor.fetchall()] + cursor.execute( + """ + SELECT v.id, v.name, COALESCE(v.city, '') FROM followed_venues fv + JOIN venues v ON v.id = fv.venue_id + WHERE fv.user_id = %s ORDER BY v.name, v.city + """, + (user["id"],), + ) + followed_venues = [{"id": row[0], "name": row[1], "city": row[2]} for row in cursor.fetchall()] + cursor.execute( + """ + SELECT c.id, c.artist, c.start_datetime, c.venue_id, + COALESCE(v.name, ''), COALESCE(v.city, ''), c.visibility, c.created_by, + c.event_type + FROM concerts c LEFT JOIN venues v ON v.id = c.venue_id + WHERE COALESCE(c.end_datetime, c.start_datetime) >= CURRENT_TIMESTAMP + AND (c.visibility = 'public' OR c.created_by = %s OR %s + OR EXISTS (SELECT 1 FROM event_invitations ei WHERE ei.concert_id = c.id AND ei.user_id = %s) + OR (c.visibility = 'friends' AND EXISTS ( + SELECT 1 FROM friendships f WHERE f.status = 'accepted' + AND ((f.requester_id = c.created_by AND f.addressee_id = %s) + OR (f.addressee_id = c.created_by AND f.requester_id = %s))))) + ORDER BY c.start_datetime + """, + (user["id"], user["is_admin"], user["id"], user["id"], user["id"]), + ) + candidates = cursor.fetchall() + candidate_ids = [row[0] for row in candidates] + cursor.execute( + "SELECT concert_id, band_key, display_name FROM concert_bands WHERE concert_id = ANY(%s) ORDER BY position", + (candidate_ids or [0],), + ) + candidate_bands = {} + for concert_id, band_key, display_name in cursor.fetchall(): + candidate_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name}) + band_names = [band["name"] for band in followed_bands] + venue_ids = {venue["id"] for venue in followed_venues} + events = [ + { + "id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"), + "time": row[2].strftime("%H:%M"), "venue": ", ".join(filter(None, (row[4], row[5]))), + "matched_band": any( + artist_names_similar(event_band["name"], followed_band) + for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8])) + for followed_band in band_names + ), + "matched_venue": row[3] in venue_ids, + } + for row in candidates + if row[3] in venue_ids or any( + artist_names_similar(event_band["name"], followed_band) + for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8])) + for followed_band in band_names + ) + ] + return templates.get_template("following.html").render( + user=user, followed_bands=followed_bands, followed_venues=followed_venues, events=events + ) + + +@app.post("/following/bands/remove") +def remove_followed_band(request: Request, band_key: str = Form(...)): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM followed_bands WHERE user_id = %s AND band_key = %s", (user["id"], band_key[:255])) + connection.commit() + return RedirectResponse("/following", status_code=303) + + +@app.post("/following/venues/remove") +def remove_followed_venue(request: Request, venue_id: int = Form(...)): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM followed_venues WHERE user_id = %s AND venue_id = %s", (user["id"], venue_id)) + connection.commit() + return RedirectResponse("/following", status_code=303) + + +@app.post("/concerts/{concert_id}/follow-band") +def follow_band(request: Request, concert_id: int, band_key: str = Form(...), action: str = Form("follow")): + 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) + bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"]) + selected_band = next((band for band in bands if band["key"] == band_key), None) + if not selected_band: + return HTMLResponse("Band nicht gefunden.", status_code=404) + with get_db_connection() as connection: + with connection.cursor() as cursor: + if action == "unfollow": + cursor.execute("DELETE FROM followed_bands WHERE user_id = %s AND band_key = %s", (user["id"], band_key)) + elif action == "follow": + cursor.execute( + "INSERT INTO followed_bands (user_id, band_key, display_name) VALUES (%s, %s, %s) ON CONFLICT (user_id, band_key) DO UPDATE SET display_name = EXCLUDED.display_name", + (user["id"], band_key, selected_band["name"]), + ) + else: + return HTMLResponse("Ungültige Aktion.", status_code=400) + connection.commit() + return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303) + + +@app.post("/concerts/{concert_id}/follow-venue") +def follow_venue(request: Request, concert_id: int, action: str = Form("follow")): + 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) + venue_id = concert["venue"]["id"] + if not venue_id: + return HTMLResponse("Diese Veranstaltung hat keine zugeordnete Location.", status_code=400) + with get_db_connection() as connection: + with connection.cursor() as cursor: + if action == "unfollow": + cursor.execute("DELETE FROM followed_venues WHERE user_id = %s AND venue_id = %s", (user["id"], venue_id)) + elif action == "follow": + cursor.execute("INSERT INTO followed_venues (user_id, venue_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", (user["id"], venue_id)) + else: + return HTMLResponse("Ungültige Aktion.", status_code=400) + connection.commit() + return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303) + + +@app.get("/diary", response_class=HTMLResponse) +def diary_page(request: Request): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + 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, '') + 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 + """, + (user["id"],), + ) + 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])))} + for row in cursor.fetchall() + ] + return templates.get_template("diary.html").render(user=user, entries=entries) + + +@app.post("/concerts/{concert_id}/diary") +def save_diary_entry( + request: Request, concert_id: int, rating: int = Form(...), + favorite_song: str = Form(""), notes: str = Form("") +): + 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) + favorite_song = favorite_song.strip() + notes = notes.strip() + if len(favorite_song) > 255 or len(notes) > 5000: + return HTMLResponse("Tagebucheintrag ist zu lang.", status_code=400) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "SELECT 1 FROM concert_attendance WHERE concert_id = %s AND user_id = %s AND status = 'attending'", + (concert_id, user["id"]), + ) + if not cursor.fetchone(): + return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403) + cursor.execute( + """ + 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, updated_at = CURRENT_TIMESTAMP + """, + (user["id"], concert_id, rating, favorite_song or None, notes or None), + ) + connection.commit() + return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303) + + +@app.post("/concerts/{concert_id}/diary/delete") +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("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) + connection.commit() + return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303) # ============================================================ @@ -3199,6 +3518,25 @@ def concert_detail(request: Request, concert_id: int): (concert_id, user["id"], user["id"]), ) attendance_rows = cursor.fetchall() + cursor.execute( + "SELECT band_key FROM followed_bands WHERE user_id = %s", + (user["id"],), + ) + followed_band_keys = {row[0] for row in cursor.fetchall()} + venue_id = concert["venue"]["id"] + if venue_id: + cursor.execute( + "SELECT EXISTS (SELECT 1 FROM followed_venues WHERE user_id = %s AND venue_id = %s)", + (user["id"], venue_id), + ) + follows_venue = cursor.fetchone()[0] + else: + follows_venue = False + cursor.execute( + "SELECT rating, COALESCE(favorite_song, ''), COALESCE(notes, '') FROM concert_diary WHERE user_id = %s AND concert_id = %s", + (user["id"], concert_id), + ) + diary_row = cursor.fetchone() comments = [ { @@ -3238,6 +3576,12 @@ def concert_detail(request: Request, concert_id: int): (item["status"] for item in attendance if item["user_id"] == user["id"]), None, ) + diary_entry = { + "rating": diary_row[0], "favorite_song": diary_row[1], "notes": diary_row[2] + } if diary_row else None + concert_bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"]) + for band in concert_bands: + band["is_following"] = band["key"] in followed_band_keys template = templates.get_template("concert_detail.html") return template.render( @@ -3256,6 +3600,10 @@ def concert_detail(request: Request, concert_id: int): ticket_offer_count=len(ticket_offers), maybe_users=maybe_users, maybe_count=len(maybe_users), + concert_bands=concert_bands, + follows_venue=follows_venue, + diary_entry=diary_entry, + can_write_diary=concert["is_past"] and current_attendance == "attending", ) @@ -3267,6 +3615,7 @@ def concert_detail(request: Request, concert_id: int): async def create_concert( request: Request, artist: str = Form(...), + band_names: str = Form(""), event_type: str = Form("concert"), parent_event_id: str = Form(""), visibility: str = Form("public"), @@ -3298,6 +3647,7 @@ async def create_concert( return HTMLResponse("

Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.

", status_code=400) if event_type not in EVENT_TYPES: return HTMLResponse("

Ungültige Veranstaltungskategorie.

", status_code=400) + parsed_bands = parse_band_names(band_names, artist, event_type) if event_type == "festival" and not end_datetime: return HTMLResponse("

Bei Festivals ist ein Enddatum erforderlich.

", status_code=400) if event_type != "festival": @@ -3306,7 +3656,7 @@ async def create_concert( return HTMLResponse("

Ungültige Sichtbarkeit.

", status_code=400) if event_type != "other": visibility = "public" - duplicate_matches = find_duplicate_concerts(user, artist, start_datetime) + duplicate_matches = find_duplicate_concerts(user, artist, start_datetime, band_names) if duplicate_matches and not duplicate_confirmed: match_items = "".join( f'
  • {escape(match["artist"])} · {match["date"]} {match["time"]}
  • ' @@ -3396,6 +3746,7 @@ async def create_concert( ) concert_id = cursor.fetchone()[0] + replace_concert_bands(cursor, concert_id, parsed_bands) if visibility == "private" and invited_user_ids: cursor.execute( """ @@ -3456,6 +3807,7 @@ def edit_concert_page(request: Request, concert_id: int): invitable_users=get_invitable_users(user["id"]), invited_user_ids=get_event_invitee_ids(concert_id), can_manage_access=can_manage_event_access(user, concert), + band_names="\n".join(band["name"] for band in load_concert_bands(concert_id, concert["artist"], concert["event_type"])), ) @@ -3464,6 +3816,7 @@ async def edit_concert( request: Request, concert_id: int, artist: str = Form(""), + band_names: str = Form(""), event_type: str = Form("concert"), parent_event_id: str = Form(""), visibility: str = Form("public"), @@ -3637,6 +3990,10 @@ async def edit_concert( ) elif can_manage_event_access(user, concert): cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (concert_id,)) + if can_edit_title(user, concert): + replace_concert_bands( + cursor, concert_id, parse_band_names(band_names, next_artist, next_event_type) + ) connection.commit() return RedirectResponse( diff --git a/app/templates/_user_menu.html b/app/templates/_user_menu.html index 3c758c2..2436cc6 100644 --- a/app/templates/_user_menu.html +++ b/app/templates/_user_menu.html @@ -2,6 +2,8 @@ ☠ {{ 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 %} {% if user.is_admin %}⚙️ Verwaltung{% endif %} Datenschutz diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html index 0d83620..6fed4e3 100644 --- a/app/templates/concert_detail.html +++ b/app/templates/concert_detail.html @@ -25,6 +25,14 @@ .flyer-source { display: block; width: 100%; flex: 0 0 100%; margin: 10px 0 0; color: #a8a29e; font-size: .85rem; text-align: center; } .flyer-source a { color: #f87171; text-decoration: underline; } .flyer-disclaimer { display: block; margin-top: 4px; font-size: .75rem; } + .follow-actions { display:flex; flex-wrap:wrap; justify-content:center; gap:8px; margin:0 0 20px; } + .follow-actions form { margin:0; } + .follow-button { padding:8px 11px; color:#fca5a5; background:#171212; border:1px solid var(--border); border-radius:9px; cursor:pointer; } + .follow-button.is-following { color:#fff; border-color:#dc2626; background:#7f1d1d; } + .diary-section { margin-top:26px; padding-top:22px; border-top:1px solid var(--border); } + .diary-form { max-width:none; } + .diary-form select, .diary-form input, .diary-form textarea { width:100%; } + .diary-form textarea { min-height:130px; resize:vertical; } @@ -70,6 +78,19 @@ {{ concert.artist }} + +
    @@ -366,6 +387,29 @@ {% endif %} + {% if concert.is_past %} +
    +

    📓 Mein Konzerttagebuch

    + {% if can_write_diary %} +

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

    +
    + + + + + + + +
    + {% if diary_entry %}
    {% endif %} + {% else %} +

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

    + {% endif %} +
    + {% endif %} +

    💬 Kommentare

    diff --git a/app/templates/datenschutz.html b/app/templates/datenschutz.html index 9a22d53..0c3f2b5 100644 --- a/app/templates/datenschutz.html +++ b/app/templates/datenschutz.html @@ -9,7 +9,7 @@

    Verantwortlicher

    Kai Piekny, Fleyerstr. 33, 58097 Hagen
    konzert@pinguholic.de

    Welche Daten werden gespeichert?

    -

    Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.

    +

    Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, gefolgte Bands und Locations, private Konzerttagebuch-Einträge, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.

    Wofür werden sie verwendet?

    Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.

    Rechtsgrundlage und Speicherdauer

    diff --git a/app/templates/diary.html b/app/templates/diary.html new file mode 100644 index 0000000..12404ee --- /dev/null +++ b/app/templates/diary.html @@ -0,0 +1,7 @@ +Konzerttagebuch · MetalCircle
    {% include '_user_menu.html' %}
    +

    📓 Konzerttagebuch

    Deine privaten Erinnerungen an besuchte Konzerte.

    +{% for entry in entries %}

    {{ entry.artist }}

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

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

    🎵 Lieblingssong: {{ entry.favorite_song }}

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

    {{ entry.notes }}

    {% endif %}
    +{% else %}

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

    {% endfor %}
    +
    diff --git a/app/templates/edit_concert.html b/app/templates/edit_concert.html index 0660aa5..16f203e 100644 --- a/app/templates/edit_concert.html +++ b/app/templates/edit_concert.html @@ -60,6 +60,12 @@

    +

    + + Diese Namen werden einzeln für die Folgen-Funktion verwendet. +

    {% if can_edit_details %}

    {% include '_user_menu.html' %}
    +

    ⭐ Gefolgt

    Bands, Locations und passende kommende Veranstaltungen.

    + + +
    diff --git a/app/templates/new_concert.html b/app/templates/new_concert.html index c8c7d93..8e254c8 100644 --- a/app/templates/new_concert.html +++ b/app/templates/new_concert.html @@ -170,6 +170,13 @@

    +

    + + Der Veranstaltungstitel bleibt frei. Diese Namen werden einzeln für „Band folgen“ verwendet. +

    + @@ -895,6 +902,7 @@ flyerInput.addEventListener( // Originaldateien verarbeiten. const concertForm = flyerInput.form; const artistInput = document.getElementById("artist"); +const bandNamesInput = document.getElementById("band-names"); const startDatetimeInput = document.getElementById("start-datetime"); const duplicateWarning = document.getElementById("duplicate-warning"); const duplicateConfirmed = document.getElementById("duplicate-confirmed"); @@ -934,7 +942,7 @@ async function checkForDuplicates(showWarning = true) { if (duplicateRequestController) duplicateRequestController.abort(); duplicateRequestController = new AbortController(); try { - const params = new URLSearchParams({artist, start_date: startDate}); + const params = new URLSearchParams({artist, start_date: startDate, band_names: bandNamesInput.value}); const response = await fetch(`/api/concerts/duplicates?${params}`, { signal: duplicateRequestController.signal }); @@ -948,7 +956,7 @@ async function checkForDuplicates(showWarning = true) { } } -[artistInput, startDatetimeInput].forEach(input => input.addEventListener("input", () => { +[artistInput, bandNamesInput, startDatetimeInput].forEach(input => input.addEventListener("input", () => { duplicateConfirmed.value = "false"; concertForm.dataset.readyToSubmit = "false"; clearTimeout(duplicateTimeout); diff --git a/db/init/01_initial.sql b/db/init/01_initial.sql index 933700d..d4ab920 100644 --- a/db/init/01_initial.sql +++ b/db/init/01_initial.sql @@ -193,3 +193,37 @@ CREATE INDEX idx_concert_photos_concert CREATE INDEX idx_concert_attendance_concert ON concert_attendance(concert_id, status); + +CREATE TABLE followed_bands ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, band_key) +); + +CREATE TABLE followed_venues ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, venue_id) +); + +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), + favorite_song VARCHAR(255), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, concert_id) +); + +CREATE TABLE concert_bands ( + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + position SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (concert_id, band_key) +); diff --git a/db/migrations/13_follows_and_diary.sql b/db/migrations/13_follows_and_diary.sql new file mode 100644 index 0000000..1d4c772 --- /dev/null +++ b/db/migrations/13_follows_and_diary.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS followed_bands ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, band_key) +); + +CREATE TABLE IF NOT EXISTS followed_venues ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, venue_id) +); + +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), + favorite_song VARCHAR(255), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, concert_id) +); diff --git a/db/migrations/14_concert_bands.sql b/db/migrations/14_concert_bands.sql new file mode 100644 index 0000000..38b602c --- /dev/null +++ b/db/migrations/14_concert_bands.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS concert_bands ( + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + position SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (concert_id, band_key) +);