Add calendar export and advanced event filters
This commit is contained in:
+77
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user