Add bilingual UI and improve event actions layout

Add persistent German/English switching, translated UI messages and English 12-hour times. Align desktop event actions while preserving mobile layouts and record bilingual project guidance.

Include pending diary deletion exclusions. Validate both languages with nine tests.
This commit is contained in:
kai
2026-09-14 15:31:56 +02:00
parent b2af6c6857
commit b2af866cf0
28 changed files with 1339 additions and 506 deletions
+199 -131
View File
@@ -30,6 +30,10 @@ from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
from i18n import (
LANGUAGE_COOKIE, current_language, current_page, gettext as _,
language_url, safe_return_path, format_time, format_datetime,
)
DATABASE_URL = os.environ["DATABASE_URL"]
@@ -436,6 +440,14 @@ def ensure_schema():
)
""",
"""
CREATE TABLE IF NOT EXISTS concert_diary_exclusions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, concert_id)
)
""",
"""
ALTER TABLE concert_diary ADD COLUMN IF NOT EXISTS photo_path TEXT
""",
"""
@@ -531,9 +543,9 @@ async def security_controls(request: Request, call_next):
referer = request.headers.get("referer")
referer_host = urlparse(referer).netloc if referer else None
if fetch_site == "cross-site" or (origin_host and origin_host != expected_host) or (referer_host and referer_host != expected_host):
return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", status_code=403)
return HTMLResponse(_("Anfrage aus fremder Quelle abgelehnt."), status_code=403)
if request.url.path not in {"/login", "/register"} and request.url.path.startswith("/password-reset") is False and not origin and not referer:
return HTMLResponse("CSRF-Prüfung fehlgeschlagen.", status_code=403)
return HTMLResponse(_("CSRF-Prüfung fehlgeschlagen."), status_code=403)
path = request.url.path
if path == "/login":
@@ -554,7 +566,7 @@ async def security_controls(request: Request, call_next):
while bucket and bucket[0] <= now - window:
bucket.popleft()
if len(bucket) >= limit:
return HTMLResponse("Zu viele Anfragen. Bitte später erneut versuchen.", status_code=429,
return HTMLResponse(_("Zu viele Anfragen. Bitte später erneut versuchen."), status_code=429,
headers={"Retry-After": str(window)})
bucket.append(now)
@@ -576,6 +588,7 @@ async def require_login(request: Request, call_next):
or request.url.path.startswith("/register/")
or request.url.path.startswith("/password-reset")
or request.url.path in {"/impressum", "/datenschutz"}
or request.url.path.startswith("/language/")
or request.url.path == "/profile/export"
or request.url.path.startswith("/static/")
):
@@ -587,6 +600,39 @@ async def require_login(request: Request, call_next):
return login_redirect("/")
@app.middleware("http")
async def localize_request(request: Request, call_next):
language = request.cookies.get(LANGUAGE_COOKIE, "de")
language = language if language in {"de", "en"} else "de"
language_token = current_language.set(language)
page_token = current_page.set(safe_return_path(
request.url.path + ("?" + request.url.query if request.url.query else "")
))
try:
response = await call_next(request)
response.headers["Content-Language"] = language
vary = response.headers.get("Vary", "")
if "cookie" not in {item.strip().lower() for item in vary.split(",")}:
response.headers["Vary"] = (vary + ", Cookie").lstrip(", ")
return response
finally:
current_page.reset(page_token)
current_language.reset(language_token)
@app.get("/language/{language}")
def change_language(language: str, next: str = "/"):
if language not in {"de", "en"}:
return HTMLResponse(_("Ungültige Sprache."), status_code=400)
response = RedirectResponse(safe_return_path(next), status_code=303)
response.set_cookie(
LANGUAGE_COOKIE, language, max_age=365 * 24 * 60 * 60,
httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/",
)
response.headers["Cache-Control"] = "no-store"
return response
# ============================================================
# Templates
# ============================================================
@@ -597,6 +643,8 @@ templates = Environment(
),
autoescape=select_autoescape(["html"])
)
templates.globals.update(_=_, language=current_language.get, language_url=language_url)
templates.filters["t"] = _
# ============================================================
@@ -786,16 +834,16 @@ def get_linkable_events(exclude_id: int | None = None):
def resolve_event_relationship(cursor, event_type: str, parent_event_id: str, event_id=None):
if event_type not in EVENT_TYPES:
raise ValueError("Ungültige Veranstaltungskategorie.")
raise ValueError(_("Ungültige Veranstaltungskategorie."))
if event_type != "other" or not parent_event_id:
return None
try:
parent_id = int(parent_event_id)
except ValueError as error:
raise ValueError("Ungültige Hauptveranstaltung.") from error
raise ValueError(_("Ungültige Hauptveranstaltung.")) from error
if event_id is not None and parent_id == event_id:
raise ValueError("Eine Veranstaltung kann nicht mit sich selbst verknüpft werden.")
raise ValueError(_("Eine Veranstaltung kann nicht mit sich selbst verknüpft werden."))
cursor.execute(
"""
@@ -815,9 +863,9 @@ def resolve_event_relationship(cursor, event_type: str, parent_event_id: str, ev
)
parent = cursor.fetchone()
if not parent or parent[0] not in {"concert", "festival"}:
raise ValueError("Die Hauptveranstaltung muss ein Konzert oder Festival sein.")
raise ValueError(_("Die Hauptveranstaltung muss ein Konzert oder Festival sein."))
if not parent[1]:
raise ValueError("Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden.")
raise ValueError(_("Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden."))
return parent_id
@@ -882,10 +930,10 @@ def load_concert(concert_id: int):
"created_by": row[8],
"is_past": is_past,
"date": start_datetime.strftime("%d.%m.%Y"),
"time": start_datetime.strftime("%H:%M"),
"time": format_time(start_datetime),
"start_local": start_datetime.strftime("%Y-%m-%dT%H:%M"),
"end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None,
"end_time": end_datetime.strftime("%H:%M") if end_datetime else None,
"end_time": format_time(end_datetime) if end_datetime else None,
"end_local": end_datetime.strftime("%Y-%m-%dT%H:%M") if end_datetime else "",
"event_type": row[17],
"event_type_label": EVENT_TYPES[row[17]],
@@ -950,7 +998,7 @@ def serialize_concert_card(row):
"id": concert_id,
"artist": artist,
"date": start_datetime.strftime("%d.%m.%Y"),
"time": start_datetime.strftime("%H:%M"),
"time": format_time(start_datetime),
"end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None,
"venue": venue_text,
"city": city or "",
@@ -1124,10 +1172,10 @@ def normalize_instagram_url(value: str):
candidate = value if "://" in value else f"https://{value}"
parsed = urlparse(candidate)
if (parsed.hostname or "").lower() not in {"instagram.com", "www.instagram.com"}:
raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.")
raise ValueError(_("Bitte einen gültigen Instagram-Profillink eingeben."))
username = parsed.path.strip("/").split("/", 1)[0]
if not re.fullmatch(r"[A-Za-z0-9._]{1,30}", username):
raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.")
raise ValueError(_("Bitte einen gültigen Instagram-Profillink eingeben."))
return f"https://www.instagram.com/{username}/"
@@ -1137,7 +1185,7 @@ def normalize_external_url(value: str, field_name: str):
return None
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
raise ValueError(f"Bitte für {field_name} eine vollständige HTTP- oder HTTPS-Adresse eingeben.")
raise ValueError(_("Bitte für {field} eine vollständige HTTP- oder HTTPS-Adresse eingeben.").format(field=_(field_name)))
return value
@@ -1248,7 +1296,7 @@ def find_duplicate_concerts(user, artist: str, start_date: str, band_names: str
"id": row[0],
"artist": row[1],
"date": row[2].strftime("%d.%m.%Y"),
"time": row[2].strftime("%H:%M"),
"time": format_time(row[2]),
"event_type": row[3],
"venue": ", ".join(part for part in (row[4], row[5]) if part),
}
@@ -1269,7 +1317,7 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
if extension not in ALLOWED_IMAGE_EXTENSIONS:
return None, HTMLResponse(
"Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP.",
_("Ungültiges Bildformat. Erlaubt sind JPG, JPEG, PNG und WEBP."),
status_code=400,
)
@@ -1277,13 +1325,13 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
if len(contents) > MAX_IMAGE_BYTES:
return None, HTMLResponse(
"Die Datei darf maximal 10 MB groß sein.",
_("Die Datei darf maximal 10 MB groß sein."),
status_code=400,
)
if Image is None:
return None, HTMLResponse(
"Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen.",
_("Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen."),
status_code=503,
)
@@ -1294,7 +1342,7 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
with Image.open(BytesIO(contents)) as candidate:
image = ImageOps.exif_transpose(candidate)
if image.width * image.height > MAX_IMAGE_PIXELS:
raise ValueError("Bildauflösung zu groß")
raise ValueError(_("Bildauflösung zu groß"))
image.thumbnail((2400, 2400))
has_alpha = image.mode in {"RGBA", "LA"} or (
image.mode == "P" and "transparency" in image.info
@@ -1313,7 +1361,7 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
safe_contents = output.getvalue()
except (ValueError, OSError, UnidentifiedImageError, Image.DecompressionBombError):
return None, HTMLResponse(
"Die Datei ist kein gültiges oder unterstütztes Bild.", status_code=400
_("Die Datei ist kein gültiges oder unterstütztes Bild."), status_code=400
)
filename = str(uuid.uuid4()) + (".png" if output_format == "PNG" else ".jpg")
@@ -1706,7 +1754,7 @@ def get_admin_venues():
@app.get("/admin")
def admin_page(request: Request):
if not require_admin(request):
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
return RedirectResponse("/admin/users", status_code=303)
@@ -1715,7 +1763,7 @@ def admin_users_page(request: Request):
user = require_admin(request)
if not user:
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -1759,7 +1807,7 @@ def admin_users_page(request: Request):
def admin_venues_page(request: Request):
user = require_admin(request)
if not user:
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
template = templates.get_template("admin_venues.html")
return template.render(user=user, venues=get_admin_venues())
@@ -1768,7 +1816,7 @@ def admin_venues_page(request: Request):
def admin_patches_page(request: Request):
user = require_admin(request)
if not user:
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
assets = load_badge_assets()
patches = [
{
@@ -1798,7 +1846,7 @@ def admin_patches_page(request: Request):
def admin_statistics_page(request: Request):
user = require_admin(request)
if not user:
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
with get_db_connection() as connection:
with connection.cursor() as cursor:
queries = {
@@ -1827,7 +1875,7 @@ def create_invite(request: Request):
user = require_admin(request)
if not user:
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
token = secrets.token_urlsafe(32)
@@ -1902,11 +1950,11 @@ def update_venue(
is_verified: str = Form(""),
):
if not require_admin(request):
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
name = name.strip()
if not name:
return HTMLResponse("<h1>Der Name darf nicht leer sein.</h1>", status_code=400)
return HTMLResponse(_("<h1>Der Name darf nicht leer sein.</h1>"), status_code=400)
normalized_aliases = sorted({
alias.strip()
@@ -1950,15 +1998,15 @@ def merge_venue(
target_venue_id: int = Form(...),
):
if not require_admin(request):
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
if venue_id == target_venue_id:
return HTMLResponse("<h1>Ein Ort kann nicht mit sich selbst zusammengeführt werden.</h1>", status_code=400)
return HTMLResponse(_("<h1>Ein Ort kann nicht mit sich selbst zusammengeführt werden.</h1>"), status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT id FROM venues WHERE id IN (%s, %s)", (venue_id, target_venue_id))
if len(cursor.fetchall()) != 2:
return HTMLResponse("<h1>Veranstaltungsort nicht gefunden.</h1>", status_code=404)
return HTMLResponse(_("<h1>Veranstaltungsort nicht gefunden.</h1>"), status_code=404)
cursor.execute(
"""
INSERT INTO venue_aliases (venue_id, alias)
@@ -1988,7 +2036,7 @@ def merge_venue(
@app.post("/admin/venues/{venue_id}/delete")
def delete_venue(request: Request, venue_id: int):
if not require_admin(request):
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -2011,15 +2059,15 @@ async def update_patch_image(
user = require_admin(request)
valid_codes = {definition[0] for definition in BADGE_DEFINITIONS}
if not user:
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
if badge_code not in valid_codes:
return HTMLResponse("<h1>Patch nicht gefunden</h1>", status_code=404)
return HTMLResponse(_("<h1>Patch nicht gefunden</h1>"), status_code=404)
image_path, error = save_image(image, PATCH_DIR, "/static/uploads/patches/")
if error:
return error
if not image_path:
return HTMLResponse("<h1>Bitte ein Bild auswählen.</h1>", status_code=400)
return HTMLResponse(_("<h1>Bitte ein Bild auswählen.</h1>"), status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -2045,9 +2093,9 @@ async def update_patch_image(
@app.post("/admin/patches/{badge_code}/delete")
def delete_patch_image(request: Request, badge_code: str):
if not require_admin(request):
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
if badge_code not in {definition[0] for definition in BADGE_DEFINITIONS}:
return HTMLResponse("<h1>Patch nicht gefunden</h1>", status_code=404)
return HTMLResponse(_("<h1>Patch nicht gefunden</h1>"), status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -2069,12 +2117,12 @@ def update_user_role(
user = require_admin(request)
if not user:
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
if role not in {"admin", "user"}:
return HTMLResponse("<h1>Ungültige Rolle</h1>", status_code=400)
return HTMLResponse(_("<h1>Ungültige Rolle</h1>"), status_code=400)
if user_id == user["id"] and role != "admin":
return HTMLResponse(
"<h1>Die eigenen Adminrechte können nicht entfernt werden.</h1>",
_("<h1>Die eigenen Adminrechte können nicht entfernt werden.</h1>"),
status_code=400,
)
@@ -2084,7 +2132,7 @@ def update_user_role(
target_user = cursor.fetchone()
if target_user and (target_user[0] or "").casefold() == "kai" and role != "admin":
return HTMLResponse(
"<h1>Der Gründer Kai muss Admin bleiben.</h1>",
_("<h1>Der Gründer Kai muss Admin bleiben.</h1>"),
status_code=400,
)
cursor.execute(
@@ -2101,10 +2149,10 @@ def delete_user(request: Request, user_id: int):
user = require_admin(request)
if not user:
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
if user_id == user["id"]:
return HTMLResponse(
"<h1>Der eigene Account kann nicht gelöscht werden.</h1>",
_("<h1>Der eigene Account kann nicht gelöscht werden.</h1>"),
status_code=400,
)
@@ -2114,7 +2162,7 @@ def delete_user(request: Request, user_id: int):
target_user = cursor.fetchone()
if target_user and (target_user[0] or "").casefold() == "kai":
return HTMLResponse(
"<h1>Der Gründer Kai kann nicht gelöscht werden.</h1>",
_("<h1>Der Gründer Kai kann nicht gelöscht werden.</h1>"),
status_code=400,
)
cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,))
@@ -2134,15 +2182,15 @@ def delete_user(request: Request, user_id: int):
def create_user_account_link(request: Request, user_id: int, purpose: str = Form(...)):
user = require_admin(request)
if not user:
return HTMLResponse("<h1>Nicht erlaubt</h1>", status_code=403)
return HTMLResponse(_("<h1>Nicht erlaubt</h1>"), status_code=403)
if purpose != "password_reset":
return HTMLResponse("<h1>Ungültiger Linktyp</h1>", status_code=400)
return HTMLResponse(_("<h1>Ungültiger Linktyp</h1>"), status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT username, email FROM users WHERE id = %s", (user_id,))
target = cursor.fetchone()
if not target:
return HTMLResponse("<h1>Benutzer nicht gefunden</h1>", status_code=404)
return HTMLResponse(_("<h1>Benutzer nicht gefunden</h1>"), status_code=404)
token = create_account_token(user_id, purpose, 2)
path = f"/password-reset/{token}"
template = templates.get_template("account_link.html")
@@ -2166,7 +2214,7 @@ def password_reset_page(token: str):
)
valid = cursor.fetchone()
if not valid:
return HTMLResponse("<h1>Reset-Link ungültig oder abgelaufen.</h1>", status_code=410)
return HTMLResponse(_("<h1>Reset-Link ungültig oder abgelaufen.</h1>"), status_code=410)
return templates.get_template("password_reset.html").render(token=token, error=None)
@@ -2174,7 +2222,7 @@ def password_reset_page(token: str):
def password_reset(token: str, password: str = Form(...), password_repeat: str = Form(...)):
if len(password) < 10 or password != password_repeat:
return templates.get_template("password_reset.html").render(
token=token, error="Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein."
token=token, error=_("Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein.")
)
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -2188,7 +2236,7 @@ def password_reset(token: str, password: str = Form(...), password_repeat: str =
)
account_token = cursor.fetchone()
if not account_token:
return HTMLResponse("<h1>Reset-Link ungültig oder abgelaufen.</h1>", status_code=410)
return HTMLResponse(_("<h1>Reset-Link ungültig oder abgelaufen.</h1>"), status_code=410)
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, account_token[1]))
cursor.execute("UPDATE account_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = %s", (account_token[0],))
@@ -2211,13 +2259,13 @@ def register_user(
if len(username) < 3:
return HTMLResponse(
"<h1>Fehler</h1><p>Der Benutzername muss mindestens 3 Zeichen lang sein.</p>",
_("<h1>Fehler</h1><p>Der Benutzername muss mindestens 3 Zeichen lang sein.</p>"),
status_code=400
)
if len(password) < 10:
return HTMLResponse(
"<h1>Fehler</h1><p>Das Passwort muss mindestens 10 Zeichen lang sein.</p>",
_("<h1>Fehler</h1><p>Das Passwort muss mindestens 10 Zeichen lang sein.</p>"),
status_code=400
)
@@ -2241,7 +2289,7 @@ def register_user(
if not invite:
return HTMLResponse(
"<h1>Ungültige Einladung</h1>",
_("<h1>Ungültige Einladung</h1>"),
status_code=404
)
@@ -2249,13 +2297,13 @@ def register_user(
if used_at:
return HTMLResponse(
"<h1>Diese Einladung wurde bereits verwendet.</h1>",
_("<h1>Diese Einladung wurde bereits verwendet.</h1>"),
status_code=410
)
if expires_at and datetime.now() > expires_at:
return HTMLResponse(
"<h1>Diese Einladung ist abgelaufen.</h1>",
_("<h1>Diese Einladung ist abgelaufen.</h1>"),
status_code=410
)
@@ -2270,7 +2318,7 @@ def register_user(
if cursor.fetchone():
return HTMLResponse(
"<h1>Fehler</h1><p>Dieser Benutzername ist bereits vergeben.</p>",
_("<h1>Fehler</h1><p>Dieser Benutzername ist bereits vergeben.</p>"),
status_code=400
)
@@ -2285,7 +2333,7 @@ def register_user(
if cursor.fetchone():
return HTMLResponse(
"<h1>Fehler</h1><p>Diese E-Mail-Adresse ist bereits registriert.</p>",
_("<h1>Fehler</h1><p>Diese E-Mail-Adresse ist bereits registriert.</p>"),
status_code=400
)
@@ -2370,7 +2418,7 @@ def register_page(token: str):
if not invite:
return HTMLResponse(
"<h1>Ungültige Einladung</h1>",
_("<h1>Ungültige Einladung</h1>"),
status_code=404
)
@@ -2378,7 +2426,7 @@ def register_page(token: str):
if used_at:
return HTMLResponse(
"<h1>Diese Einladung wurde bereits verwendet.</h1>",
_("<h1>Diese Einladung wurde bereits verwendet.</h1>"),
status_code=410
)
@@ -2387,7 +2435,7 @@ def register_page(token: str):
if datetime.now() > expires_at:
return HTMLResponse(
"<h1>Diese Einladung ist abgelaufen.</h1>",
_("<h1>Diese Einladung ist abgelaufen.</h1>"),
status_code=410
)
@@ -2455,7 +2503,7 @@ def login(
return HTMLResponse(
template.render(
next_path=next_path,
error="Benutzername oder Passwort ist falsch.",
error=_("Benutzername oder Passwort ist falsch."),
),
status_code=401,
)
@@ -2536,7 +2584,7 @@ def render_profile(
profile = load_profile(username)
if not profile:
return HTMLResponse("<h1>Benutzer nicht gefunden</h1>", status_code=404)
return HTMLResponse(_("<h1>Benutzer nicht gefunden</h1>"), status_code=404)
is_own_profile = bool(viewer and viewer["id"] == profile["id"])
friendship = None
@@ -2689,11 +2737,11 @@ def own_profile(request: Request, saved: str = ""):
saved_items = set(saved.split(","))
messages = []
if "profile" in saved_items:
messages.append("Profil gespeichert")
messages.append(_("Profil gespeichert"))
if "avatar" in saved_items:
messages.append("Profilbild aktualisiert")
messages.append(_("Profilbild aktualisiert"))
if "instagram" in saved_items:
messages.append("Instagram verknüpft")
messages.append(_("Instagram verknüpft"))
return render_profile(
request,
user["username"],
@@ -2920,7 +2968,7 @@ async def update_profile(
status_code=400,
)
if profile_visibility not in {"public", "friends", "nobody"}:
return HTMLResponse("Ungültige Profilsichtbarkeit.", status_code=400)
return HTMLResponse(_("Ungültige Profilsichtbarkeit."), status_code=400)
avatar_path, error = save_image(
avatar,
AVATAR_DIR,
@@ -2952,7 +3000,7 @@ async def update_profile(
if not saved_profile:
remove_uploaded_file(avatar_path, AVATAR_DIR, "/static/uploads/avatars/")
return HTMLResponse("<h1>Profil konnte nicht gespeichert werden.</h1>", status_code=500)
return HTMLResponse(_("<h1>Profil konnte nicht gespeichert werden.</h1>"), status_code=500)
if avatar_path and previous_profile and previous_profile[0] != avatar_path:
remove_uploaded_file(previous_profile[0], AVATAR_DIR, "/static/uploads/avatars/")
saved_parts = ["profile"]
@@ -2971,9 +3019,9 @@ def send_friend_request(request: Request, username: str):
user = get_current_user(request)
profile = load_profile(username)
if not profile:
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404)
if profile["id"] == user["id"]:
return HTMLResponse("Du kannst dir nicht selbst eine Anfrage schicken.", status_code=400)
return HTMLResponse(_("Du kannst dir nicht selbst eine Anfrage schicken."), status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
@@ -2985,7 +3033,7 @@ def send_friend_request(request: Request, username: str):
(user["id"], profile["id"], profile["id"], user["id"]),
)
if cursor.fetchone():
return HTMLResponse("Freundschaftsanfrage wegen einer Blockierung nicht möglich.", status_code=403)
return HTMLResponse(_("Freundschaftsanfrage wegen einer Blockierung nicht möglich."), status_code=403)
cursor.execute(
"""
INSERT INTO friendships (requester_id, addressee_id)
@@ -3003,9 +3051,9 @@ def block_user(request: Request, username: str):
user = get_current_user(request)
profile = load_profile(username)
if not profile:
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404)
if profile["id"] == user["id"]:
return HTMLResponse("Du kannst dich nicht selbst blockieren.", status_code=400)
return HTMLResponse(_("Du kannst dich nicht selbst blockieren."), status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
@@ -3032,7 +3080,7 @@ def unblock_user(request: Request, username: str):
user = get_current_user(request)
profile = load_profile(username)
if not profile:
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
return HTMLResponse(_("Benutzer nicht gefunden."), status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
@@ -3047,7 +3095,7 @@ def unblock_user(request: Request, username: str):
def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")):
user = get_current_user(request)
if action not in {"accept", "decline", "remove"}:
return HTMLResponse("Ungültige Aktion.", status_code=400)
return HTMLResponse(_("Ungültige Aktion."), status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
@@ -3056,17 +3104,17 @@ def manage_friendship(request: Request, friendship_id: int, action: str, return_
)
friendship = cursor.fetchone()
if not friendship or user["id"] not in friendship[:2]:
return HTMLResponse("Freundschaft nicht gefunden.", status_code=404)
return HTMLResponse(_("Freundschaft nicht gefunden."), status_code=404)
if action == "accept":
if friendship[1] != user["id"] or friendship[2] != "pending":
return HTMLResponse("Diese Anfrage kann nicht angenommen werden.", status_code=403)
return HTMLResponse(_("Diese Anfrage kann nicht angenommen werden."), status_code=403)
cursor.execute(
"UPDATE friendships SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(friendship_id,),
)
else:
if action == "decline" and (friendship[1] != user["id"] or friendship[2] != "pending"):
return HTMLResponse("Diese Anfrage kann nicht abgelehnt werden.", status_code=403)
return HTMLResponse(_("Diese Anfrage kann nicht abgelehnt werden."), status_code=403)
cursor.execute("DELETE FROM friendships WHERE id = %s", (friendship_id,))
connection.commit()
other_id = friendship[1] if friendship[0] == user["id"] else friendship[0]
@@ -3131,7 +3179,7 @@ def message_inbox(request: Request):
)
friend_requests = [
{"id": row[0], "username": row[1], "display_name": row[2],
"created_at": row[3].strftime("%d.%m.%Y %H:%M")}
"created_at": format_datetime(row[3])}
for row in cursor.fetchall()
]
cursor.execute(
@@ -3148,7 +3196,7 @@ def message_inbox(request: Request):
)
event_invitations = [
{"concert_id": row[0], "artist": row[1],
"date": row[2].strftime("%d.%m.%Y %H:%M"), "invited_by": row[3] or "Ein Mitglied"}
"date": format_datetime(row[2]), "invited_by": row[3] or _("Ein Mitglied")}
for row in cursor.fetchall()
]
cursor.execute(
@@ -3188,7 +3236,7 @@ def message_inbox(request: Request):
conversations.append({
"id": row[0], "username": row[1], "display_name": row[2],
"avatar_path": row[3], "last_message": latest[0] if latest else None,
"last_at": latest[1].strftime("%d.%m.%Y %H:%M") if latest else None,
"last_at": format_datetime(latest[1]) if latest else None,
"last_at_raw": latest[1] if latest else None,
"last_from_me": bool(latest and latest[2] == user["id"]),
"unread_count": latest[3] if latest else 0,
@@ -3206,7 +3254,7 @@ def message_conversation(request: Request, username: str):
with connection.cursor() as cursor:
partner = load_chat_partner(cursor, user, username)
if not partner:
return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403)
return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403)
cursor.execute(
"""
UPDATE direct_messages SET read_at = CURRENT_TIMESTAMP
@@ -3227,7 +3275,7 @@ def message_conversation(request: Request, username: str):
)
messages = [
{"from_me": row[0] == user["id"], "body": row[1],
"created_at": row[2].strftime("%d.%m.%Y %H:%M")}
"created_at": format_datetime(row[2])}
for row in cursor.fetchall()
]
connection.commit()
@@ -3240,12 +3288,12 @@ def send_message(request: Request, username: str, body: str = Form(...)):
user = get_current_user(request)
body = body.strip()
if not body or len(body) > 2000:
return HTMLResponse("Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein.", status_code=400)
return HTMLResponse(_("Eine Nachricht muss zwischen 1 und 2000 Zeichen lang sein."), status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
partner = load_chat_partner(cursor, user, username)
if not partner:
return HTMLResponse("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar.", status_code=403)
return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403)
cursor.execute(
"INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s)",
(user["id"], partner["id"], body),
@@ -3331,7 +3379,7 @@ def following_page(request: Request):
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]))),
"time": format_time(row[2]), "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]))
@@ -3376,11 +3424,11 @@ def follow_band(request: Request, concert_id: int, band_key: str = Form(...), ac
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)
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)
return HTMLResponse(_("Band nicht gefunden."), status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
if action == "unfollow":
@@ -3391,7 +3439,7 @@ def follow_band(request: Request, concert_id: int, band_key: str = Form(...), ac
(user["id"], band_key, selected_band["name"]),
)
else:
return HTMLResponse("Ungültige Aktion.", status_code=400)
return HTMLResponse(_("Ungültige Aktion."), status_code=400)
connection.commit()
return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303)
@@ -3401,10 +3449,10 @@ 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)
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)
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":
@@ -3412,7 +3460,7 @@ def follow_venue(request: Request, concert_id: int, action: str = Form("follow")
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)
return HTMLResponse(_("Ungültige Aktion."), status_code=400)
connection.commit()
return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303)
@@ -3429,6 +3477,10 @@ def diary_page(request: Request):
FROM concert_attendance ca JOIN concerts c ON c.id = ca.concert_id
WHERE ca.user_id = %s AND ca.status = 'attending'
AND COALESCE(c.end_datetime, c.start_datetime) < CURRENT_TIMESTAMP
AND NOT EXISTS (
SELECT 1 FROM concert_diary_exclusions de
WHERE de.user_id = ca.user_id AND de.concert_id = ca.concert_id
)
ON CONFLICT (user_id, concert_id) DO NOTHING
""",
(user["id"],),
@@ -3486,7 +3538,7 @@ def diary_page(request: Request):
def diary_photo(request: Request, filename: str):
user = get_current_user(request)
if not filename or os.path.basename(filename) != filename:
return HTMLResponse("Bild nicht gefunden.", status_code=404)
return HTMLResponse(_("Bild nicht gefunden."), status_code=404)
photo_path = f"/diary/photo/{filename}"
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -3495,10 +3547,10 @@ def diary_photo(request: Request, filename: str):
(user["id"], photo_path),
)
if not cursor.fetchone():
return HTMLResponse("Bild nicht gefunden.", status_code=404)
return HTMLResponse(_("Bild nicht gefunden."), status_code=404)
file_path = os.path.join(DIARY_PHOTO_DIR, filename)
if not os.path.isfile(file_path):
return HTMLResponse("Bild nicht gefunden.", status_code=404)
return HTMLResponse(_("Bild nicht gefunden."), status_code=404)
return FileResponse(file_path)
@@ -3511,17 +3563,17 @@ def save_diary_entry(
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)
return HTMLResponse(_("Veranstaltung nicht gefunden."), status_code=404)
try:
rating_value = int(rating) if rating else None
except ValueError:
rating_value = None
if not concert["is_past"] or (rating_value is not None and rating_value not in range(1, 6)):
return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen Bewertung von 1 bis 5 verfügbar.", status_code=400)
return HTMLResponse(_("Das Tagebuch ist nur für vergangene Konzerte mit einer optionalen 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)
return HTMLResponse(_("Tagebucheintrag ist zu lang."), status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
@@ -3529,12 +3581,12 @@ def save_diary_entry(
(concert_id, user["id"]),
)
if not cursor.fetchone():
return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403)
return HTMLResponse(_("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich."), status_code=403)
cursor.execute("SELECT COUNT(*) FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
existing_photo_count = cursor.fetchone()[0]
uploads = [photo for photo in photos if photo and photo.filename]
if existing_photo_count + len(uploads) > 3:
return HTMLResponse("Pro Tagebucheintrag sind maximal drei Bilder möglich.", status_code=400)
return HTMLResponse(_("Pro Tagebucheintrag sind maximal drei Bilder möglich."), status_code=400)
saved_paths = []
for photo in uploads:
saved_path, error = save_image(photo, DIARY_PHOTO_DIR, "/diary/photo/")
@@ -3568,7 +3620,7 @@ def delete_diary_photo(request: Request, photo_id: int):
deleted = cursor.fetchone()
connection.commit()
if not deleted:
return HTMLResponse("Bild nicht gefunden.", status_code=404)
return HTMLResponse(_("Bild nicht gefunden."), status_code=404)
remove_uploaded_file(deleted[0], DIARY_PHOTO_DIR, "/diary/photo/")
return RedirectResponse(f"/diary#concert-{deleted[1]}", status_code=303)
@@ -3580,6 +3632,15 @@ def delete_diary_entry(request: Request, concert_id: int):
with connection.cursor() as cursor:
cursor.execute("SELECT path FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
photo_paths = [row[0] for row in cursor.fetchall()]
cursor.execute(
"""
INSERT INTO concert_diary_exclusions (user_id, concert_id)
SELECT user_id, concert_id FROM concert_diary
WHERE user_id = %s AND concert_id = %s
ON CONFLICT DO NOTHING
""",
(user["id"], concert_id),
)
cursor.execute("DELETE FROM concert_diary_photos WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
cursor.execute("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id))
connection.commit()
@@ -3600,12 +3661,12 @@ def _ics_escape(value: str) -> str:
def concert_ics(request: Request, concert_id: int):
concert = load_concert(concert_id)
if not concert:
return PlainTextResponse("Veranstaltung nicht gefunden", status_code=404)
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)
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")
@@ -3635,7 +3696,7 @@ def concert_detail(request: Request, concert_id: int):
if not concert or not can_view_event(user, concert):
return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>",
_("<h1>Veranstaltung nicht gefunden</h1>"),
status_code=404
)
@@ -3730,7 +3791,7 @@ def concert_detail(request: Request, concert_id: int):
{
"id": row[0],
"body": row[1],
"created_at": row[2].strftime("%d.%m.%Y %H:%M"),
"created_at": format_datetime(row[2]),
"author": row[3] or row[4],
"username": row[4],
}
@@ -3741,7 +3802,7 @@ def concert_detail(request: Request, concert_id: int):
{
"id": row[0],
"path": row[1],
"created_at": row[2].strftime("%d.%m.%Y %H:%M"),
"created_at": format_datetime(row[2]),
"author": row[3] or row[4],
}
for row in photo_rows
@@ -3833,16 +3894,16 @@ async def create_concert(
artist = artist.strip()
if len(artist) < 2 or len(artist) > 255:
return HTMLResponse("<h1>Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.</h1>", status_code=400)
return HTMLResponse(_("<h1>Der Titel oder Künstlername muss zwischen 2 und 255 Zeichen lang sein.</h1>"), status_code=400)
if event_type not in EVENT_TYPES:
return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
return HTMLResponse(_("<h1>Ungültige Veranstaltungskategorie.</h1>"), status_code=400)
parsed_bands = parse_band_names(band_names, artist, event_type)
if event_type == "festival" and not end_datetime:
return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400)
return HTMLResponse(_("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>"), status_code=400)
if event_type != "festival":
end_datetime = ""
if visibility not in {"public", "friends", "private"}:
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
return HTMLResponse(_("<h1>Ungültige Sichtbarkeit.</h1>"), status_code=400)
if event_type != "other":
visibility = "public"
duplicate_matches = find_duplicate_concerts(user, artist, start_datetime, band_names)
@@ -3852,14 +3913,13 @@ async def create_concert(
for match in duplicate_matches
)
return HTMLResponse(
"<h1>Mögliche doppelte Veranstaltung</h1>"
"<p>Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.</p>"
f"<ul>{match_items}</ul>"
"<p>Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.</p>",
_("<h1>Mögliche doppelte Veranstaltung</h1><p>Am selben Tag existiert bereits eine Veranstaltung mit einem sehr ähnlichen Künstlernamen.</p><ul>")
+ match_items
+ _("</ul><p>Bitte gehe zurück, prüfe den Treffer und bestätige den Hinweis im Formular, wenn du trotzdem speichern möchtest.</p>"),
status_code=409,
)
if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(start_datetime):
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
return HTMLResponse(_("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>"), status_code=400)
try:
normalized_flyer_url = normalize_external_url(flyer_url, "den Flyer")
except ValueError as error:
@@ -3975,13 +4035,13 @@ def edit_concert_page(request: Request, concert_id: int):
if not concert or not can_view_event(user, concert):
return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>",
_("<h1>Veranstaltung nicht gefunden</h1>"),
status_code=404
)
if not can_edit_concert(user, concert):
return HTMLResponse(
"<h1>Vergangene Veranstaltungen dürfen nur Admins bearbeiten.</h1>",
_("<h1>Vergangene Veranstaltungen dürfen nur Admins bearbeiten.</h1>"),
status_code=403
)
@@ -4035,13 +4095,13 @@ async def edit_concert(
if not concert or not can_view_event(user, concert):
return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>",
_("<h1>Veranstaltung nicht gefunden</h1>"),
status_code=404
)
if not can_edit_concert(user, concert):
return HTMLResponse(
"<h1>Nicht erlaubt</h1>",
_("<h1>Nicht erlaubt</h1>"),
status_code=403
)
@@ -4065,21 +4125,21 @@ async def edit_concert(
if not can_manage_event_access(user, concert):
event_type = concert["event_type"]
if event_type not in EVENT_TYPES:
return HTMLResponse("<h1>Ungültige Veranstaltungskategorie.</h1>", status_code=400)
return HTMLResponse(_("<h1>Ungültige Veranstaltungskategorie.</h1>"), status_code=400)
if event_type == "festival" and not end_datetime:
return HTMLResponse("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>", status_code=400)
return HTMLResponse(_("<h1>Bei Festivals ist ein Enddatum erforderlich.</h1>"), status_code=400)
if event_type != "festival":
end_datetime = ""
if can_manage_event_access(user, concert):
if visibility not in {"public", "friends", "private"}:
return HTMLResponse("<h1>Ungültige Sichtbarkeit.</h1>", status_code=400)
return HTMLResponse(_("<h1>Ungültige Sichtbarkeit.</h1>"), status_code=400)
if event_type != "other":
visibility = "public"
else:
visibility = concert["visibility"]
effective_start = start_datetime or concert["start_local"]
if end_datetime and datetime.fromisoformat(end_datetime) < datetime.fromisoformat(effective_start):
return HTMLResponse("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>", status_code=400)
return HTMLResponse(_("<h1>Das Enddatum darf nicht vor dem Beginn liegen.</h1>"), status_code=400)
try:
next_flyer_url = normalize_external_url(flyer_url, "den Flyer")
except ValueError as error:
@@ -4202,13 +4262,13 @@ def delete_concert(request: Request, concert_id: int):
if not concert or not can_view_event(user, concert):
return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>",
_("<h1>Veranstaltung nicht gefunden</h1>"),
status_code=404
)
if not can_delete_concert(user, concert):
return HTMLResponse(
"<h1>Nicht erlaubt</h1>",
_("<h1>Nicht erlaubt</h1>"),
status_code=403
)
@@ -4240,10 +4300,10 @@ def set_attendance(
if not user:
return login_redirect(f"/concerts/{concert_id}")
if status not in {"attending", "maybe", "ticket_search", "ticket_offer"}:
return HTMLResponse("<h1>Ungültige Auswahl</h1>", status_code=400)
return HTMLResponse(_("<h1>Ungültige Auswahl</h1>"), status_code=400)
concert = load_concert(concert_id)
if not concert or not can_view_event(user, concert):
return HTMLResponse("<h1>Veranstaltung nicht gefunden</h1>", status_code=404)
return HTMLResponse(_("<h1>Veranstaltung nicht gefunden</h1>"), status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -4260,8 +4320,16 @@ def set_attendance(
)
if status == "attending" and concert["is_past"]:
cursor.execute(
"INSERT INTO concert_diary (user_id, concert_id) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user["id"], concert_id),
"""
INSERT INTO concert_diary (user_id, concert_id)
SELECT %s, %s
WHERE NOT EXISTS (
SELECT 1 FROM concert_diary_exclusions
WHERE user_id = %s AND concert_id = %s
)
ON CONFLICT DO NOTHING
""",
(user["id"], concert_id, user["id"], concert_id),
)
connection.commit()
@@ -4283,7 +4351,7 @@ def add_comment(
if not concert or not can_view_event(user, concert):
return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>",
_("<h1>Veranstaltung nicht gefunden</h1>"),
status_code=404
)
@@ -4297,7 +4365,7 @@ def add_comment(
if len(text) > 2000:
return HTMLResponse(
"<h1>Kommentar ist zu lang (maximal 2000 Zeichen).</h1>",
_("<h1>Kommentar ist zu lang (maximal 2000 Zeichen).</h1>"),
status_code=400
)
@@ -4337,7 +4405,7 @@ async def add_photo(
if not concert or not can_view_event(user, concert):
return HTMLResponse(
"<h1>Veranstaltung nicht gefunden</h1>",
_("<h1>Veranstaltung nicht gefunden</h1>"),
status_code=404
)