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 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
+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 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; } }
+2
View File
@@ -212,6 +212,8 @@
{% endif %}
<div class="calendar-export"><a href="/concerts/{{ concert.id }}.ics" class="concert-action">📅 In Kalender speichern (.ics)</a></div>
{% if can_edit %}
<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-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-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; }
@@ -265,7 +265,13 @@
<section class="search-panel" aria-label="Suche">
<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="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>
{% 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>
</section>
+7
View File
@@ -188,6 +188,13 @@
<button class="button" type="submit">Profil speichern</button>
</form>
<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>
{% endif %}
</main>