Add calendar export and advanced event filters

This commit is contained in:
kai
2026-08-27 20:40:40 +02:00
parent c1306f86f6
commit a6a8358eb6
5 changed files with 97 additions and 12 deletions
+77 -11
View File
@@ -10,6 +10,7 @@ from io import BytesIO
from urllib.parse import urlparse from urllib.parse import urlparse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import bcrypt import bcrypt
import httpx import httpx
@@ -21,7 +22,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, RedirectResponse from fastapi.responses import 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
@@ -825,7 +826,7 @@ def can_manage_event_access(user, concert) -> bool:
def serialize_concert_card(row): 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" venue_text = venue or "Veranstaltungsort unbekannt"
if city: if city:
venue_text += f", {city}" venue_text += f", {city}"
@@ -837,6 +838,8 @@ def serialize_concert_card(row):
"time": start_datetime.strftime("%H:%M"), "time": start_datetime.strftime("%H:%M"),
"end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None, "end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None,
"venue": venue_text, "venue": venue_text,
"city": city or "",
"country": country or "",
"event_type": event_type, "event_type": event_type,
"event_type_label": EVENT_TYPES[event_type], "event_type_label": EVENT_TYPES[event_type],
"parent_event_id": parent_event_id, "parent_event_id": parent_event_id,
@@ -870,10 +873,10 @@ def build_event_overview(rows):
return upcoming, past 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 = """ query = """
SELECT concerts.id, concerts.artist, concerts.start_datetime, 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.event_type, concerts.parent_event_id,
concerts.visibility, concerts.visibility,
EXISTS (SELECT 1 FROM event_invitations ei 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()) upcoming, past = build_event_overview(cursor.fetchall())
term = search_query.strip().casefold() 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 return upcoming, past
def matches(card): def matches(card):
text = f"{card['artist']} {card['venue']} {card['event_type_label']}".casefold() text = f"{card['artist']} {card['venue']} {card['event_type_label']} {card['country']}".casefold()
return term in text or any(matches(child) for child in card["children"]) 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 ( return (
[card for card in upcoming if matches(card)], [card for card in upcoming if matches(card)],
@@ -2154,9 +2168,9 @@ def logout(request: Request):
# ============================================================ # ============================================================
@app.get("/", response_class=HTMLResponse) @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) 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") template = templates.get_template("index.html")
return template.render( return template.render(
@@ -2166,13 +2180,14 @@ def home(request: Request, q: str = ""):
archive=False, archive=False,
search_query=q.strip(), search_query=q.strip(),
user_results=search_users(q), 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) @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) 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") template = templates.get_template("index.html")
return template.render( return template.render(
user=user, user=user,
@@ -2181,6 +2196,7 @@ def past_events(request: Request, q: str = ""):
archive=True, archive=True,
search_query=q.strip(), search_query=q.strip(),
user_results=search_users(q), 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) @app.get("/users/{username}", response_class=HTMLResponse)
def user_profile(request: Request, username: str): def user_profile(request: Request, username: str):
return render_profile(request, username) return render_profile(request, username)
@@ -2769,6 +2802,39 @@ def new_concert(request: Request):
# Concert detail # 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( @app.get(
"/concerts/{concert_id}", "/concerts/{concert_id}",
response_class=HTMLResponse response_class=HTMLResponse
+4
View File
@@ -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 { display:flex; justify-content:space-between; gap:12px; color:var(--muted); font-size:.9rem; }
.comment-meta strong { color:var(--text); } .comment-meta strong { color:var(--text); }
.comment p { margin:10px 0 0; white-space:pre-wrap; overflow-wrap:anywhere; } .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; } .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; } .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; } .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; } } @media (max-width:600px) { .detail-topbar { align-items:flex-start; } .concert-detail { padding:16px; } .comment-meta { flex-direction:column; gap:4px; } }
+2
View File
@@ -212,6 +212,8 @@
{% endif %} {% endif %}
<div class="calendar-export"><a href="/concerts/{{ concert.id }}.ics" class="concert-action">📅 In Kalender speichern (.ics)</a></div>
{% if can_edit %} {% if can_edit %}
<div class="detail-actions" aria-label="Veranstaltung verwalten"> <div class="detail-actions" aria-label="Veranstaltung verwalten">
+7 -1
View File
@@ -156,7 +156,7 @@
} }
.search-panel { margin-bottom: 26px; padding: 16px; background: rgba(16,16,16,.92); border: 1px solid #3a3030; border-radius: 14px; } .search-panel { margin-bottom: 26px; padding: 16px; background: rgba(16,16,16,.92); border: 1px solid #3a3030; border-radius: 14px; }
.search-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; } .search-form { display: grid; grid-template-columns: minmax(180px, 2fr) repeat(4, minmax(120px, 1fr)) auto; gap: 10px; }
.search-form input { margin: 0; } .search-form input { margin: 0; }
.search-results-title { margin: 24px 0 12px; font-size: 1.15rem; } .search-results-title { margin: 24px 0 12px; font-size: 1.15rem; }
.user-results { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; margin-bottom: 24px; } .user-results { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; margin-bottom: 24px; }
@@ -265,7 +265,13 @@
<section class="search-panel" aria-label="Suche"> <section class="search-panel" aria-label="Suche">
<form class="search-form" method="get" action="{% if archive %}/events/past{% else %}/{% endif %}"> <form class="search-form" method="get" action="{% if archive %}/events/past{% else %}/{% endif %}">
<input type="search" name="q" value="{{ search_query }}" placeholder="Veranstaltungen, Orte oder User suchen …" aria-label="Veranstaltungen oder User suchen"> <input type="search" name="q" value="{{ search_query }}" placeholder="Veranstaltungen, Orte oder User suchen …" aria-label="Veranstaltungen oder User suchen">
<input type="date" name="date_from" value="{{ filters.date_from }}" aria-label="Von Datum">
<input type="date" name="date_to" value="{{ filters.date_to }}" aria-label="Bis Datum">
<input type="search" name="venue" value="{{ filters.venue }}" placeholder="Ort / Location">
<input type="search" name="country" value="{{ filters.country }}" placeholder="Land">
<select name="category" aria-label="Kategorie"><option value="">Alle Kategorien</option><option value="concert" {% if filters.category == 'concert' %}selected{% endif %}>Konzert</option><option value="festival" {% if filters.category == 'festival' %}selected{% endif %}>Festival</option><option value="other" {% if filters.category == 'other' %}selected{% endif %}>Sonstiges</option></select>
<button class="new-concert-button" type="submit">Suchen</button> <button class="new-concert-button" type="submit">Suchen</button>
{% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %}<a class="clear-filters" href="{% if archive %}/events/past{% else %}/{% endif %}{% if search_query %}?q={{ search_query|urlencode }}{% endif %}">Filter löschen</a>{% endif %}
</form> </form>
</section> </section>
+7
View File
@@ -188,6 +188,13 @@
<button class="button" type="submit">Profil speichern</button> <button class="button" type="submit">Profil speichern</button>
</form> </form>
<p class="data-export"><a href="/profile/export">Meine gespeicherten Daten exportieren (JSON)</a></p> <p class="data-export"><a href="/profile/export">Meine gespeicherten Daten exportieren (JSON)</a></p>
<details class="account-delete">
<summary>Account löschen</summary>
<p>Diese Aktion löscht dein Konto dauerhaft und kann nicht rückgängig gemacht werden.</p>
<form method="post" action="/profile/delete" onsubmit="return confirm('Account wirklich dauerhaft löschen?');">
<button type="submit" class="admin-danger">Account dauerhaft löschen</button>
</form>
</details>
</section> </section>
{% endif %} {% endif %}
</main> </main>