diff --git a/app/main.py b/app/main.py index f6097e0..86c69a4 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ from io import BytesIO from urllib.parse import urlparse from contextlib import asynccontextmanager from datetime import datetime, timedelta +from zoneinfo import ZoneInfo import bcrypt import httpx @@ -21,7 +22,7 @@ except ImportError: Image = ImageOps = UnidentifiedImageError = None from fastapi import FastAPI, File, Form, Request, UploadFile -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse from fastapi.encoders import jsonable_encoder from fastapi.staticfiles import StaticFiles @@ -825,7 +826,7 @@ def can_manage_event_access(user, concert) -> bool: def serialize_concert_card(row): - concert_id, artist, start_datetime, end_datetime, venue, city, event_type, parent_event_id, visibility, is_invited = row + concert_id, artist, start_datetime, end_datetime, venue, city, country, event_type, parent_event_id, visibility, is_invited = row venue_text = venue or "Veranstaltungsort unbekannt" if city: venue_text += f", {city}" @@ -837,6 +838,8 @@ def serialize_concert_card(row): "time": start_datetime.strftime("%H:%M"), "end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None, "venue": venue_text, + "city": city or "", + "country": country or "", "event_type": event_type, "event_type_label": EVENT_TYPES[event_type], "parent_event_id": parent_event_id, @@ -870,10 +873,10 @@ def build_event_overview(rows): return upcoming, past -def load_event_overview(user, search_query: str = ""): +def load_event_overview(user, search_query: str = "", date_from: str = "", date_to: str = "", venue_filter: str = "", country_filter: str = "", category_filter: str = ""): query = """ SELECT concerts.id, concerts.artist, concerts.start_datetime, - concerts.end_datetime, venues.name, venues.city, + concerts.end_datetime, venues.name, venues.city, venues.country, concerts.event_type, concerts.parent_event_id, concerts.visibility, EXISTS (SELECT 1 FROM event_invitations ei @@ -905,12 +908,23 @@ def load_event_overview(user, search_query: str = ""): upcoming, past = build_event_overview(cursor.fetchall()) term = search_query.strip().casefold() - if not term: + if not any((term, date_from.strip(), date_to.strip(), venue_filter.strip(), country_filter.strip(), category_filter.strip())): return upcoming, past def matches(card): - text = f"{card['artist']} {card['venue']} {card['event_type_label']}".casefold() - return term in text or any(matches(child) for child in card["children"]) + text = f"{card['artist']} {card['venue']} {card['event_type_label']} {card['country']}".casefold() + own = (not term or term in text) + own = own and (not venue_filter or venue_filter.casefold() in card['venue'].casefold()) + own = own and (not country_filter or country_filter.casefold() in card['country'].casefold()) + own = own and (not category_filter or card['event_type'] == category_filter) + event_date = card['_start_datetime'].date().isoformat() + if date_from and date_to: + own = own and date_from <= event_date <= date_to + elif date_from: + own = own and event_date == date_from + elif date_to: + own = own and event_date == date_to + return own or any(matches(child) for child in card["children"]) return ( [card for card in upcoming if matches(card)], @@ -2154,9 +2168,9 @@ def logout(request: Request): # ============================================================ @app.get("/", response_class=HTMLResponse) -def home(request: Request, q: str = ""): +def home(request: Request, q: str = "", date_from: str = "", date_to: str = "", venue: str = "", country: str = "", category: str = ""): user = get_current_user(request) - upcoming_concerts, _past_concerts = load_event_overview(user, q) + upcoming_concerts, _past_concerts = load_event_overview(user, q, date_from, date_to, venue, country, category) template = templates.get_template("index.html") return template.render( @@ -2166,13 +2180,14 @@ def home(request: Request, q: str = ""): archive=False, search_query=q.strip(), user_results=search_users(q), + filters={"date_from": date_from, "date_to": date_to, "venue": venue, "country": country, "category": category}, ) @app.get("/events/past", response_class=HTMLResponse) -def past_events(request: Request, q: str = ""): +def past_events(request: Request, q: str = "", date_from: str = "", date_to: str = "", venue: str = "", country: str = "", category: str = ""): user = get_current_user(request) - _upcoming_concerts, past_concerts = load_event_overview(user, q) + _upcoming_concerts, past_concerts = load_event_overview(user, q, date_from, date_to, venue, country, category) template = templates.get_template("index.html") return template.render( user=user, @@ -2181,6 +2196,7 @@ def past_events(request: Request, q: str = ""): archive=True, search_query=q.strip(), user_results=search_users(q), + filters={"date_from": date_from, "date_to": date_to, "venue": venue, "country": country, "category": category}, ) @@ -2451,6 +2467,23 @@ def export_profile_data(request: Request): ) +@app.post("/profile/delete") +def delete_own_account(request: Request): + """Permanently remove the authenticated user's account and personal data.""" + user = get_current_user(request) + if not user: + return login_redirect("/profile") + user_id = user["id"] + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("UPDATE registration_invites SET used_by = NULL WHERE used_by = %s", (user_id,)) + cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) + connection.commit() + response = RedirectResponse("/login", status_code=303) + response.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax") + return response + + @app.get("/users/{username}", response_class=HTMLResponse) def user_profile(request: Request, username: str): return render_profile(request, username) @@ -2769,6 +2802,39 @@ def new_concert(request: Request): # Concert detail # ============================================================ +def _ics_escape(value: str) -> str: + return str(value or "").replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n") + + +@app.get("/concerts/{concert_id}.ics") +def concert_ics(request: Request, concert_id: int): + concert = load_concert(concert_id) + if not concert: + return PlainTextResponse("Veranstaltung nicht gefunden", status_code=404) + # Kalenderdaten unterliegen denselben Sichtbarkeitsregeln wie die Detailseite. + # Ohne Sitzung bzw. ohne Berechtigung keine Informationen preisgeben. + user = get_current_user(request) + if not user or not can_view_event(user, concert): + return PlainTextResponse("Veranstaltung nicht gefunden", status_code=404) + local_zone = ZoneInfo("Europe/Berlin") + start = concert["start_datetime"].replace(tzinfo=local_zone) + end_value = concert.get("end_datetime") + end = end_value.replace(tzinfo=local_zone) if end_value else start + timedelta(hours=2) + if end <= start: + end = start + timedelta(hours=2) + venue = concert.get("venue", {}) or {} + location = ", ".join(filter(None, [venue.get("name"), venue.get("city"), venue.get("country")])) + stamp = datetime.now(ZoneInfo("UTC")).strftime("%Y%m%dT%H%M%SZ") + body = "\r\n".join([ + "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//MetalCircle//Concerts//DE", "CALSCALE:GREGORIAN", + "BEGIN:VEVENT", f"UID:metalcircle-{concert_id}@konzerte.pinguholic.de", f"DTSTAMP:{stamp}", + f"DTSTART;TZID=Europe/Berlin:{start.strftime('%Y%m%dT%H%M%S')}", + f"DTEND;TZID=Europe/Berlin:{end.strftime('%Y%m%dT%H%M%S')}", + f"SUMMARY:{_ics_escape(concert['artist'])}", f"LOCATION:{_ics_escape(location)}", + f"URL:https://konzerte.pinguholic.de/concerts/{concert_id}", "END:VEVENT", "END:VCALENDAR", "" + ]) + return PlainTextResponse(body, media_type="text/calendar; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="metalcircle-{concert_id}.ics"'}) + @app.get( "/concerts/{concert_id}", response_class=HTMLResponse diff --git a/app/static/css/style.css b/app/static/css/style.css index 5e4bee3..6352010 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -494,7 +494,11 @@ h1, .page-title h1 { .comment-meta { display:flex; justify-content:space-between; gap:12px; color:var(--muted); font-size:.9rem; } .comment-meta strong { color:var(--text); } .comment p { margin:10px 0 0; white-space:pre-wrap; overflow-wrap:anywhere; } +.search-form select { margin:0; padding:10px 12px; color:var(--text); background:#101010; border:1px solid var(--border); border-radius:9px; font:inherit; } +.clear-filters { grid-column:1 / -1; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; color:#fca5a5; border:1px solid var(--border); border-radius:9px; background:#171212; white-space:nowrap; } +.clear-filters:hover { border-color:#ef4444; color:#fff; } .ticket-button-container, .detail-actions { display:flex; flex-wrap:wrap; justify-content:center; gap:10px; margin-top:22px; } +.calendar-export { display:flex; justify-content:center; margin-top:12px; } .ticket-button, .concert-action { display:inline-flex; padding:11px 16px; border:1px solid #b91c1c; border-radius:9px; background:#7f1d1d; color:#fff; font-weight:700; } .concert-action-delete { background:#450a0a; cursor:pointer; } @media (max-width:600px) { .detail-topbar { align-items:flex-start; } .concert-detail { padding:16px; } .comment-meta { flex-direction:column; gap:4px; } } diff --git a/app/templates/concert_detail.html b/app/templates/concert_detail.html index dc09cda..0d83620 100644 --- a/app/templates/concert_detail.html +++ b/app/templates/concert_detail.html @@ -212,6 +212,8 @@ {% endif %} +
+ {% if can_edit %}Meine gespeicherten Daten exportieren (JSON)
+Diese Aktion löscht dein Konto dauerhaft und kann nicht rückgängig gemacht werden.
+ +