Files
Wedding-Website/backend/app.py
2026-07-25 13:56:02 +00:00

1353 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import sqlite3
import uuid
from datetime import datetime
from functools import wraps
from pathlib import Path
from urllib.parse import urlencode
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from flask import (
Flask,
flash,
g,
redirect,
render_template,
request,
send_from_directory,
session,
url_for,
)
from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
app = Flask(__name__)
base_dir = Path(__file__).resolve().parent
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "change-me-in-production")
app.config["DB_PATH"] = os.environ.get("DB_PATH", str(base_dir / "app.sqlite3"))
app.config["UPLOAD_FOLDER"] = os.environ.get("UPLOAD_FOLDER", str(base_dir / "uploads"))
app.config["MAX_CONTENT_LENGTH"] = int(os.environ.get("MAX_UPLOAD_BYTES", str(64 * 1024 * 1024)))
app.config["LOCATION_NAME"] = os.environ.get("LOCATION_NAME", "Hochheimer Terrasse")
app.config["LOCATION_ADDRESS"] = os.environ.get("LOCATION_ADDRESS", "Mainzer Str. 22, 65239 Hochheim am Main")
app.config["LOCATION_WEBSITE_URL"] = os.environ.get("LOCATION_WEBSITE_URL", "https://hochheimerterrasse.de/")
app.config["GOOGLE_MAPS_EMBED_URL"] = os.environ.get(
"GOOGLE_MAPS_EMBED_URL",
"https://www.google.com/maps?q=Hochheimer+Terrasse+Mainzer+Str.+22+65239+Hochheim+am+Main&output=embed",
)
app.config["WEDDING_DATE"] = os.environ.get("WEDDING_DATE", "")
app.config["WEDDING_COUNTDOWN_ISO"] = os.environ.get("WEDDING_COUNTDOWN_ISO", "")
app.config["WEDDING_COUNTDOWN_LOCAL"] = os.environ.get("WEDDING_COUNTDOWN_LOCAL", "2026-09-04 16:00")
app.config["WEDDING_TIMEZONE"] = os.environ.get("WEDDING_TIMEZONE", "Europe/Berlin")
app.config["HERO_IMAGE_FILENAME"] = os.environ.get("HERO_IMAGE_FILENAME")
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "heic", "heif"}
ALLOWED_MIME_TYPES = {
"image/jpeg",
"image/jpg",
"image/png",
"image/heic",
"image/heif",
"image/heic-sequence",
"image/heif-sequence",
}
LOCATION_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v"}
ONBOARDING_VERSION = 2
DEFAULT_INVITATION_GROUPS = [
{
"name": "Bubus",
"password": "Bubu!Herz24#",
"role": "admin",
"members": ["Svenja", "Dominic"],
},
{"name": "Remi", "password": "Remi#Ring24!", "role": "guest", "members": ["Remi"]},
{"name": "Chantal", "password": "Chan!Tanz24#", "role": "guest", "members": ["Chantal"]},
{"name": "Madeleine", "password": "Madi$Rose24!", "role": "guest", "members": ["Madeleine"]},
{
"name": "Julie & Daniel",
"password": "Juli&Dan24!#",
"role": "guest",
"members": ["Julie", "Daniel"],
},
{
"name": "Tim & Sophie",
"password": "Tim+Sofi24!#",
"role": "guest",
"members": ["Tim", "Sophie"],
},
{
"name": "Marcel & Kathrin",
"password": "Marc&Kath24#",
"role": "guest",
"members": ["Marcel", "Kathrin"],
},
{
"name": "Marie & Kai",
"password": "Mari&Kai24!#",
"role": "guest",
"members": ["Marie", "Kai"],
},
{
"name": "Familie Olsem",
"password": "Olse!Fam24#?",
"role": "guest",
"members": ["Laura", "Sven", "Lena", "Finn"],
},
{
"name": "Maxime",
"password": "Maxi#Love24!",
"role": "guest",
"members": ["Maxime"],
},
{
"name": "Familie Löster",
"password": "Loes@Ring24#",
"role": "guest",
"members": ["Claudia", "Mario", "Mélodie", "Julian"],
},
{
"name": "Familie Thiels",
"password": "Thie$Fest24!",
"role": "guest",
"members": ["Matthias", "Opa Bernd", "Oma Heidi"],
},
{
"name": "Familie Gollor",
"password": "Goll%Herz24!",
"role": "guest",
"members": ["Michael", "Christin", "Bruno"],
},
{"name": "Monika", "password": "Moni!Rose24#", "role": "guest", "members": ["Monika"]},
{
"name": "Familie Konrad",
"password": "Konr#Fest24!",
"role": "guest",
"members": ["Michael", "Sandra", "Christoph", "Alexander", "Merle"],
},
{"name": "Mark", "password": "Mark!Gold24#", "role": "guest", "members": ["Mark"]},
{"name": "Elias", "password": "Elia$Ring24!", "role": "guest", "members": ["Elias"]},
{"name": "Milan", "password": "Mila#Tanz24!", "role": "guest", "members": ["Milan", "Anna"]},
{
"name": "Familie Wolff",
"password": "Wolf!Herz24#",
"role": "guest",
"members": ["Anja", "Bodo"],
},
{
"name": "Anna & Leon",
"password": "Anna&Leo24!#",
"role": "guest",
"members": ["Anna", "Leon"],
},
{"name": "Aryan", "password": "Arya!Fest24#", "role": "guest", "members": ["Aryan"]},
{
"name": "Sebastian",
"password": "Seba$Ring24!",
"role": "guest",
"members": ["Sebastian", "Olivia"],
},
{"name": "Flo", "password": "Flo!Liebe24#", "role": "guest", "members": ["Flo"]},
{"name": "Kiki", "password": "Kiki!Rose24#", "role": "guest", "members": ["Kiki"]},
{
"name": "Lana & Eric",
"password": "Lan&Eric24!#",
"role": "guest",
"members": ["Lana", "Eric"],
},
{"name": "Britta", "password": "Brit!Tanz24#", "role": "guest", "members": ["Britta"]},
{"name": "Eirene", "password": "Eire$Fest24!", "role": "guest", "members": ["Eirene"]},
{
"name": "Family Hynes",
"password": "Hyne&Fam24!#",
"role": "guest",
"members": ["Michael", "Traci", "Bethany", "Josiah", "Nicholas"],
},
{
"name": "Steven & Martha",
"password": "Stev&Mart24!#",
"role": "guest",
"members": ["Steven", "Martha"],
},
{
"name": "Leander & Henrieke",
"password": "Lean&Hen26!#",
"role": "guest",
"members": ["Leander", "Henrieke"],
},
{"name": "Timbo", "password": "Timb!Rose24#", "role": "guest", "members": ["Timbo"]},
{
"name": "Karen & Jay",
"password": "Kare&Jay24!#",
"role": "guest",
"members": ["Karen", "Jay"],
},
{"name": "Alina", "password": "Alin!Gold24#", "role": "guest", "members": ["Alina"]},
{"name": "Max", "password": "Max!Liebe24#", "role": "guest", "members": ["Max"]},
{
"name": "Paul & Alix",
"password": "Paul&Alx24!#",
"role": "guest",
"members": ["Paul", "Alix"],
},
{
"name": "Alfred & Nadia",
"password": "Alfr&Nad24!#",
"role": "guest",
"members": ["Alfred", "Nadia"],
},
{
"name": "Anne-Marie & Erny",
"password": "Anne&Ern24!#",
"role": "guest",
"members": ["Anne-Marie", "Erny"],
},
{
"name": "Familie Kieffer",
"password": "Kief!Fest24#",
"role": "guest",
"members": ["Anny", "John"],
},
{"name": "Peter", "password": "Peter!Fest26#", "role": "guest", "members": ["Peter"]},
{"name": "Johanna", "password": "Joha!Fest26#", "role": "guest", "members": ["Johanna"]},
]
AGE_REQUIRED_NAMES = {"Lena", "Finn", "Fin", "Bruno"}
HOTEL_RECOMMENDATIONS = [
{
"id": "unico_boutique",
"name": "UNICO Boutique Hotel",
"address": "Frankfurter Straße 70, 65239 Hochheim am Main",
"website_url": "https://www.unicohotel.de/",
"drive_minutes": "5",
"walk_minutes": "20",
},
{
"id": "parkhotel_sonnenberg",
"name": "Parkhotel Sonnenberg",
"address": "Friedrichstraße 65, 65343 Eltville am Rhein",
"website_url": "https://parkhotel-sonnenberg.com/",
"drive_minutes": "25",
"walk_minutes": None,
},
{
"id": "hotel_frankenbach",
"name": "Hotel Frankenbach",
"address": "Wilhelmstraße 13, 65343 Eltville am Rhein",
"website_url": "https://www.hotel-frankenbach.de/",
"drive_minutes": "25",
"walk_minutes": None,
},
{
"id": "spritzenhaus",
"name": "Das Spritzenhaus",
"address": "Platz von Montrichard 1, 65343 Eltville am Rhein",
"website_url": "https://www.das-spritzenhaus.de/",
"drive_minutes": "25",
"walk_minutes": None,
},
{
"id": "kronenschloesschen",
"name": "Hotel Kronenschlösschen",
"address": "Rheinallee, 65347 Eltville-Hattenheim",
"website_url": "https://www.kronenschloesschen.de/",
"drive_minutes": "30",
"walk_minutes": None,
},
{
"id": "kloster_eberbach",
"name": "Kloster Eberbach",
"address": "Kloster-Eberbach-Straße 1, 65346 Eltville am Rhein",
"website_url": "https://www.kloster-eberbach.de/",
"drive_minutes": "30",
"walk_minutes": None,
},
{
"id": "weinhaus_engel",
"name": "Weinhaus & Hotel Engel",
"address": "Hauptstraße 10, 65345 Eltville-Rauenthal",
"website_url": "https://weinhaus-engel.com/rauenthal/",
"drive_minutes": "25",
"walk_minutes": None,
},
]
TEXTS = {
"de": {
"brand": "Svenja & Dominic",
"subtitle": "Willkommen in unserer Hochzeits-App",
"login_note": "Passwortgeschützter Zugriff für unsere Gäste.",
"login_privacy_title": "Datenschutzhinweis",
"login_privacy_text": "Beim Anzeigen eingebetteter Karten werden Daten an Google übertragen. Details findet ihr in der Datenschutzerklärung.",
"login_privacy_accept": "Verstanden",
"login": "Login",
"group_name": "Benutzername",
"group_password": "Passwort",
"show_password": "Passwort anzeigen",
"hide_password": "Passwort verbergen",
"login_submit": "Weiter zum Eingangsbereich",
"guest_area": "Gästebereich",
"hello_guest": "Hallo {name}.",
"logout": "Abmelden",
"rsvp": "Zu-/Absage",
"upload": "Upload",
"upload_intro": "Hier könnt ihr Fotos von der Hochzeit hochladen.",
"gallery": "Galerie",
"host_area": "Gastgeberbereich",
"info": "Infos",
"save": "Speichern",
"attending": "Ich komme",
"not_attending": "Ich komme nicht",
"member_status": "Rückmeldung",
"member_age": "Alter",
"member_age_hint": "Bitte Alter eintragen, wenn die Person kommt.",
"rsvp_members_intro": "Bitte gib die Zu- oder Absage pro Mitglied an.",
"file": "Bild auswählen",
"upload_picker_hint": "Tippe hier, um ein oder mehrere Bilder auszuwählen.",
"add_more_files": "Weitere Datei hinzufügen",
"upload_multi_hint": "Du kannst mehrere Bilder auf einmal auswählen oder weitere Felder hinzufügen.",
"upload_selected_count": "{count} Bilder ausgewählt",
"upload_ready": "Bereit zum Hochladen",
"upload_submit": "Foto hochladen",
"schedule": "Ablauf",
"hotels": "Hotels",
"taxi": "Genuss",
"location": "Location",
"gifts": "Geschenke",
"visit_location": "Zur Location-Webseite",
"location_story_title": "Rheingauer Flair auf der Hochheimer Terrasse",
"location_story_text": "Die Hochheimer Terrasse liegt in der historischen Altstadt von Hochheim und verbindet Rheingauer Atmosphäre mit einem eleganten Rahmen für unseren Hochzeitstag. Euch erwartet ein besonderer Ort zwischen Mainz, Wiesbaden und den Weinbergen, an dem wir gemeinsam feiern möchten.",
"location_map_intro": "Damit ihr problemlos zur Location findet, könnt ihr direkt von hier zu Google Maps wechseln oder eine Route von eurem aktuellen Standort aus starten.",
"route_from_current": "Route ab aktuellem Standort",
"route_location_denied": "Standortfreigabe abgelehnt. Es wurde keine Route mit deinem Standort erstellt.",
"route_location_unavailable": "Standort ist in diesem Browser derzeit nicht verfügbar.",
"maps_load_button": "Google Maps anzeigen",
"privacy": "Datenschutz",
"imprint": "Impressum",
"hero_headline": "Willkommen zu unserer Hochzeit",
"hero_text": "Wir freuen uns riesig, diesen besonderen Tag am 04.09.26 zu feiern.",
"hero_headline_with_group": "Willkommen zu unserer Hochzeit, {name}",
"hero_text_single": "Wir freuen uns riesig, diesen besonderen Tag am 04.09.26 mit dir zu feiern.",
"hero_text_group": "Wir freuen uns riesig, diesen besonderen Tag am 04.09.26 mit euch zu feiern.",
"hero_app_hint_single": "In dieser Webapp kannst du zu- oder absagen, Fotos hochladen, die Galerie ansehen und alle wichtigen Infos rund um den Tag finden. Im Header findest du außerdem unsere FAQ und eine Deutsch-Englisch-Übersetzungshilfe. Bitte gib deine Zu- oder Absage möglichst bald ab.",
"hero_app_hint": "In dieser Webapp könnt ihr zu- oder absagen, Fotos hochladen, die Galerie ansehen und alle wichtigen Infos rund um den Tag finden. Im Header findet ihr außerdem unsere FAQ und eine Deutsch-Englisch-Übersetzungshilfe. Bitte gebt eure Zu- oder Absage möglichst bald ab.",
"to_guest_area": "Zum Gästebereich",
"schedule_text": "16:00 Ankunft, 16:15 Trauzeremonie, anschließend Sektempfang.",
"schedule_intro": "Hier findet ihr den Ablauf für unseren Hochzeitstag.",
"schedule_arrival_title": "Ankunft",
"schedule_arrival_text": "Wir freuen uns auf eure Ankunft.",
"schedule_ceremony_title": "Trauzeremonie",
"schedule_ceremony_text": "Trauzeremonie im Hof und Fotos.",
"schedule_reception_title": "Sektempfang & Häppchen",
"schedule_reception_time": "Im Anschluss",
"schedule_reception_text": "Sektempfang im Hof der Hochheimer Terrasse.",
"schedule_buffet_title": "Buffet-Eröffnung",
"schedule_buffet_text": "Das Buffet wird eröffnet, danach folgen die Brautpaarspiele. Später gibt es zusätzlich Käseplatten.",
"schedule_cake_title": "Tortenanschnitt",
"schedule_cake_time": "Am Abend",
"schedule_cake_text": "Gemeinsamer Tortenmoment am Abend.",
"schedule_afterwards_time": "Anschließend",
"schedule_party_title": "Party",
"schedule_party_text": "Wir feiern gemeinsam bis 03:30 Uhr.",
"hotels_text": "Empfehlungen folgen. Bitte frühzeitig buchen.",
"hotels_intro": "Hier findet ihr Hotels in der Nähe. Bitte bucht frühzeitig und schaut euch die aktuellen Preise direkt auf den Hotel-Webseiten an.",
"hotel_visit_website": "Zur Hotel-Webseite",
"hotel_route_from_current": "Route ab aktuellem Standort",
"hotel_route_drive": "Route vom Hotel zur Location (Auto)",
"hotel_route_walk": "Route vom Hotel zur Location (zu Fuß)",
"hotel_time_note": "Genaue Fahr- und Gehzeit seht ihr live in Google Maps.",
"hotel_route_modal_title": "Route zur Location",
"hotel_route_modal_close": "Karte schließen",
"hotel_route_open_maps": "In Google Maps öffnen",
"hotel_drive_badge": "Auto ca. {minutes} Min",
"hotel_walk_badge": "Zu Fuß ca. {minutes} Min",
"hotel_walk_not_recommended": "Zu Fuß nicht empfohlen",
"hotel_parkhotel_sonnenberg_desc": "Ruhiges Hotel mit Blick über Eltville.",
"hotel_hotel_frankenbach_desc": "Zentral in Eltville, gute Anbindung zur Location.",
"hotel_spritzenhaus_desc": "Historisches Haus am Rhein mit modernen Zimmern.",
"hotel_kronenschloesschen_desc": "Elegantes Hotel in Hattenheim, nah an den Weinbergen.",
"hotel_kloster_eberbach_desc": "Historisches Klosterambiente, umgeben von Weinbergen und Natur.",
"hotel_weinhaus_engel_desc": "Gemütliches Weinhaus-Hotel in Rauenthal.",
"hotel_unico_boutique_desc": "Modernes Boutique-Hotel direkt in Hochheim mit stilvollen Zimmern und kurzer Entfernung zur Location.",
"taxi_text": "Coming soon",
"gifts_teaser": "Wir würden uns über diese Geschenke sehr freuen...",
"gifts_reveal_button": "Wunsch aufdecken",
"gifts_image_alt": "Ein großer Haufen Geldscheine",
"gifts_money_label": "Moneten",
"gifts_caption": "Trommelwirbel... in unserer 70-Quadratmeter-Wohnung ist leider kein Platz mehr für Materielles.",
"gifts_text": "Also: Money! Ein finanzieller Beitrag zu unserer Reise nach der Hochzeit ist herzlich willkommen.",
"gallery_uploaded_by": "von {name}",
"gallery_empty": "Noch keine Bilder vorhanden.",
"gallery_image_alt": "Upload von {name}",
"flash_enter_group_name": "Bitte Gruppenname eingeben.",
"flash_invalid_group_login": "Ungültiger Gruppenname oder Passwort.",
"flash_rsvp_select": "Bitte für alle Mitglieder eine RSVP-Auswahl treffen.",
"flash_rsvp_age_missing": "Bitte Alter für {name} angeben.",
"flash_rsvp_age_invalid": "Bitte ein gültiges Alter (0-17) für {name} angeben.",
"flash_rsvp_saved": "Antwort gespeichert.",
"flash_select_image": "Bitte eine Bilddatei auswählen.",
"flash_allowed_types": "Nur JPG/JPEG/PNG/HEIC/HEIF sind erlaubt.",
"flash_upload_success_count": "{count} Bilder erfolgreich hochgeladen.",
"flash_upload_too_large": "Upload zu groß. Bitte in kleineren Paketen hochladen (max. {max_mb} MB pro Anfrage).",
"flash_upload_failed": "Upload fehlgeschlagen. Bitte erneut versuchen.",
"host_stats_title": "Übersicht",
"total_guests": "Gäste gesamt",
"attending_yes": "Zusagen",
"attending_no": "Absagen",
"attending_open": "Noch offen",
"host_table_group": "Gruppe",
"host_table_member": "Mitglied",
"host_table_status": "RSVP",
"host_table_age": "Alter",
"status_yes": "Kommt",
"status_no": "Kommt nicht",
"status_open": "Offen",
"legal_de_authoritative": "Bei rechtlichen Unklarheiten gilt die deutsche Fassung.",
"download": "Download",
"delete": "Löschen",
"flash_delete_not_allowed": "Du darfst dieses Bild nicht löschen.",
"flash_image_deleted": "Bild gelöscht.",
"flash_admin_only": "Dieser Bereich ist nur für Admins verfügbar.",
"dashboard": "Dashboard",
"back": "Zurück",
"faq": "Häufig gestellte Fragen",
"faq_intro": "Hier findet ihr Antworten auf die wichtigsten Fragen rund um unsere Hochzeit.",
"faq_deadline_question": "Bis wann sollen wir zu- oder absagen?",
"faq_deadline_answer": "Bitte gebt uns bis spätestens 4. August 2026 über den Bereich „Zu-/Absage“ Bescheid.",
"faq_change_question": "Kann ich meine Antwort später noch ändern?",
"faq_change_answer": "Ja, ihr könnt eure Zu- oder Absage jederzeit bis zum Ende der Rückmeldefrist anpassen.",
"faq_when_where_question": "Wann und wo findet die Hochzeit statt?",
"faq_when_where_answer": "Wir erwarten euch ab 16:00 Uhr auf der Hochheimer Terrasse. Die Trauzeremonie beginnt um 16:15 Uhr. Weitere Informationen findet ihr auf der Location-Seite.",
"faq_dresscode_question": "Gibt es einen Dresscode?",
"faq_dresscode_answer": "Wir wünschen uns festliche Abendgarderobe. Wählt gerne ein elegantes Outfit, in dem ihr euch wohlfühlt ob Kleid, Anzug oder eine andere festliche Kombination.",
"faq_parking_question": "Gibt es Parkmöglichkeiten?",
"faq_parking_answer": "In der Hochheimer Altstadt und in der näheren Umgebung gibt es viele Parkmöglichkeiten. Plant bitte etwas Zeit für die Parkplatzsuche und den anschließenden Fußweg ein.",
"faq_hotel_question": "Wo können wir übernachten?",
"faq_hotel_answer": "Für eine möglichst kurze Anreise empfehlen wir euch eine Unterkunft direkt in Hochheim oder in der näheren Umgebung. Eine Auswahl findet ihr auf unserer Hotel-Seite.",
"faq_upload_question": "Wie funktioniert der Foto-Upload?",
"faq_upload_answer": "Über den Bereich „Upload“ könnt ihr Fotos direkt von eurem Smartphone oder Computer hochladen.",
"faq_visibility_question": "Wer kann die hochgeladenen Bilder sehen?",
"faq_visibility_answer": "Die Bilder sind ausschließlich für eingeloggte Hochzeitsgäste sichtbar. Eure eigenen Uploads könnt ihr selbst wieder löschen.",
"translator": "Übersetzungshilfe",
"translator_kicker": "Deutsch & Englisch",
"translator_close": "Übersetzungshilfe schließen",
"translator_direction": "Übersetzungsrichtung",
"translator_swap": "Übersetzungsrichtung wechseln",
"translator_input_label": "Was möchtest du übersetzen?",
"translator_placeholder": "Kurzen Text eingeben …",
"translator_quick_phrases": "Schnelle Sätze",
"translator_phrase_meet": "Kennenlernen",
"translator_phrase_drink": "Getränk anbieten",
"translator_phrase_restroom": "Toilette finden",
"translator_privacy": "Der Text wird erst nach dem Klick an DeepL übertragen. Bitte keine Namen, Kontaktdaten oder vertraulichen Inhalte eingeben.",
"translator_submit": "Mit DeepL übersetzen",
"translator_policy": "Datenschutz bei DeepL",
"language_german": "Deutsch",
"language_english": "Englisch",
"tour_title": "Willkommen in unserer Hochzeits-App",
"tour_progress": "Schritt {current} von {total}",
"tour_dashboard_title": "Alles auf einen Blick",
"tour_dashboard_text": "Im Dashboard findet ihr alle wichtigen Bereiche und Informationen für unseren Hochzeitstag.",
"tour_rsvp_title": "Zu- oder Absage",
"tour_rsvp_text": "Gebt die Rückmeldung für jedes Mitglied eurer Einladung bequem über den Bereich „Zu-/Absage“ ab.",
"tour_photos_title": "Fotos & Galerie",
"tour_photos_text": "Ladet eure Fotos direkt vom Smartphone hoch, entdeckt die gemeinsame Galerie und ladet Erinnerungen herunter.",
"tour_routes_title": "Hotels, Location & Routen",
"tour_routes_text": "Bei den Hotels und der Location könnt ihr Routen mit Google Maps öffnen. Karten werden erst nach eurem Klick geladen.",
"tour_translator_title": "Übersetzungshilfe",
"tour_translator_text": "Über das Buch im Header könnt ihr kurze deutsche oder englische Texte für die Übersetzung mit DeepL vorbereiten.",
"tour_faq_title": "Häufig gestellte Fragen",
"tour_faq_text": "Das Fragezeichen im Header führt euch jederzeit zu den wichtigsten Antworten rund um die Hochzeit.",
"tour_back": "Zurück",
"tour_next": "Weiter",
"tour_skip": "Überspringen",
"tour_finish": "Los gehts",
"tour_restart": "Einführung erneut ansehen",
"countdown_button_label": "Countdown bis zur Hochzeit",
"countdown_until": "Noch",
"countdown_started": "Die Feier hat begonnen",
"countdown_days": "Tage",
"countdown_hours": "Std",
"countdown_minutes": "Min",
"countdown_seconds": "Sek",
"countdown_subline": "bis zur Hochzeit",
},
"en": {
"brand": "Svenja & Dominic",
"subtitle": "Welcome to our wedding app",
"login_note": "Password-protected access for our guests.",
"login_privacy_title": "Privacy notice",
"login_privacy_text": "When embedded maps are displayed, data is transferred to Google. Details are available in the privacy policy.",
"login_privacy_accept": "Got it",
"login": "Login",
"group_name": "Username",
"group_password": "Password",
"show_password": "Show password",
"hide_password": "Hide password",
"login_submit": "Continue to entrance area",
"guest_area": "Guest Area",
"hello_guest": "Hello {name}.",
"logout": "Logout",
"rsvp": "RSVP",
"upload": "Upload",
"upload_intro": "Upload your wedding photos here.",
"gallery": "Gallery",
"host_area": "Host Area",
"info": "Info",
"save": "Save",
"attending": "I will attend",
"not_attending": "I cannot attend",
"member_status": "RSVP status",
"member_age": "Age",
"member_age_hint": "Please add age if this person attends.",
"rsvp_members_intro": "Please submit attendance for each group member.",
"file": "Select image",
"upload_picker_hint": "Tap here to select one or more images.",
"add_more_files": "Add more files",
"upload_multi_hint": "You can select multiple images at once or add more file fields.",
"upload_selected_count": "{count} images selected",
"upload_ready": "Ready to upload",
"upload_submit": "Upload photo",
"schedule": "Schedule",
"hotels": "Hotels",
"taxi": "Culinary Delights",
"location": "Location",
"gifts": "Gifts",
"visit_location": "Visit location website",
"location_story_title": "Rheingau flair at Hochheimer Terrasse",
"location_story_text": "Hochheimer Terrasse is located in Hochheim's historic old town and combines Rheingau atmosphere with an elegant setting for our wedding day. It is a special place between Mainz, Wiesbaden, and the vineyards where we cannot wait to celebrate with you.",
"location_map_intro": "To help you find the location easily, you can open Google Maps directly from here or start a route from your current location.",
"route_from_current": "Route from current location",
"route_location_denied": "Location access was denied. No route with your location was created.",
"route_location_unavailable": "Location is currently unavailable in this browser.",
"maps_load_button": "Show Google Maps",
"privacy": "Privacy",
"imprint": "Imprint",
"hero_headline": "Welcome to our wedding",
"hero_text": "We are incredibly excited to celebrate this special day on 09/04/26.",
"hero_headline_with_group": "Welcome to our wedding, {name}",
"hero_text_single": "We are incredibly excited to celebrate this special day on 09/04/26 with you.",
"hero_text_group": "We are incredibly excited to celebrate this special day on 09/04/26 with all of you.",
"hero_app_hint_single": "In this web app, you can send your RSVP, upload photos, view the gallery, and find all important details for the day. The header also includes our FAQ and a German-English translation helper. Please submit your RSVP soon.",
"hero_app_hint": "In this web app, you can send your RSVP, upload photos, view the gallery, and find all important details for the day. The header also includes our FAQ and a German-English translation helper. Please submit your RSVP soon.",
"to_guest_area": "Open guest area",
"schedule_text": "4:00 PM arrival, 4:15 PM wedding ceremony, followed by a sparkling reception.",
"schedule_intro": "Here is the schedule for our wedding day.",
"schedule_arrival_title": "Arrival",
"schedule_arrival_text": "We look forward to welcoming you.",
"schedule_ceremony_title": "Wedding Ceremony",
"schedule_ceremony_text": "Wedding ceremony in the courtyard and photos.",
"schedule_reception_title": "Sparkling Reception & Snacks",
"schedule_reception_time": "Afterwards",
"schedule_reception_text": "Sparkling reception in the courtyard of Hochheimer Terrasse.",
"schedule_buffet_title": "Buffet Opening",
"schedule_buffet_text": "The buffet opens, followed by wedding games. Cheese platters will be served later.",
"schedule_cake_title": "Cake Cutting",
"schedule_cake_time": "In the evening",
"schedule_cake_text": "Cake cutting together in the evening.",
"schedule_afterwards_time": "Afterwards",
"schedule_party_title": "Party",
"schedule_party_text": "We celebrate together until 3:30 AM.",
"hotels_text": "Recommendations will follow. Please book early.",
"hotels_intro": "Here are hotel options nearby. Please book early and check current rates directly on each hotel website.",
"hotel_visit_website": "Visit hotel website",
"hotel_route_from_current": "Route from current location",
"hotel_route_drive": "Route from hotel to location (car)",
"hotel_route_walk": "Route from hotel to location (walk)",
"hotel_time_note": "Exact driving and walking times are shown live in Google Maps.",
"hotel_route_modal_title": "Route to location",
"hotel_route_modal_close": "Close map",
"hotel_route_open_maps": "Open in Google Maps",
"hotel_drive_badge": "By car about {minutes} min",
"hotel_walk_badge": "On foot about {minutes} min",
"hotel_walk_not_recommended": "Walking not recommended",
"hotel_parkhotel_sonnenberg_desc": "Quiet hotel with a view over Eltville.",
"hotel_hotel_frankenbach_desc": "Central in Eltville with good access to the venue.",
"hotel_spritzenhaus_desc": "Historic house by the Rhine with modern rooms.",
"hotel_kronenschloesschen_desc": "Elegant hotel in Hattenheim near the vineyards.",
"hotel_kloster_eberbach_desc": "Historic monastery atmosphere surrounded by vineyards and nature.",
"hotel_weinhaus_engel_desc": "Cozy winehouse hotel in Rauenthal.",
"hotel_unico_boutique_desc": "Modern boutique hotel in Hochheim with stylish rooms and a short journey to the venue.",
"taxi_text": "Coming soon",
"gifts_teaser": "We would be very happy to receive these gifts...",
"gifts_reveal_button": "Reveal wish",
"gifts_image_alt": "A big pile of cash",
"gifts_money_label": "Moolah",
"gifts_caption": "Drum roll... in our 70-square-meter apartment, we sadly have no room left for material things.",
"gifts_text": "So: Money! A financial contribution to our trip after the wedding is very welcome.",
"gallery_uploaded_by": "by {name}",
"gallery_empty": "No photos available yet.",
"gallery_image_alt": "Uploaded by {name}",
"flash_enter_group_name": "Please enter group name.",
"flash_invalid_group_login": "Invalid group name or password.",
"flash_rsvp_select": "Please choose RSVP values for all members.",
"flash_rsvp_age_missing": "Please enter age for {name}.",
"flash_rsvp_age_invalid": "Please enter a valid age (0-17) for {name}.",
"flash_rsvp_saved": "RSVP saved.",
"flash_select_image": "Please select an image file.",
"flash_allowed_types": "Only JPG/JPEG/PNG/HEIC/HEIF are allowed.",
"flash_upload_success_count": "{count} images uploaded successfully.",
"flash_upload_too_large": "Upload too large. Please upload smaller batches (max {max_mb} MB per request).",
"flash_upload_failed": "Upload failed. Please try again.",
"host_stats_title": "Overview",
"total_guests": "Total guests",
"attending_yes": "Attending",
"attending_no": "Declined",
"attending_open": "Pending",
"host_table_group": "Group",
"host_table_member": "Member",
"host_table_status": "RSVP",
"host_table_age": "Age",
"status_yes": "Attending",
"status_no": "Not attending",
"status_open": "Pending",
"legal_de_authoritative": "In case of legal ambiguity, the German version shall prevail.",
"download": "Download",
"delete": "Delete",
"flash_delete_not_allowed": "You are not allowed to delete this image.",
"flash_image_deleted": "Image deleted.",
"flash_admin_only": "This area is available to admins only.",
"dashboard": "Dashboard",
"back": "Back",
"faq": "Frequently Asked Questions",
"faq_intro": "Here you will find answers to the most important questions about our wedding.",
"faq_deadline_question": "When do we need to RSVP by?",
"faq_deadline_answer": "Please let us know whether you will be attending by 4 August 2026 in the “RSVP” section.",
"faq_change_question": "Can I change my response later?",
"faq_change_answer": "Yes, you can update your RSVP at any time before the response deadline.",
"faq_when_where_question": "When and where is the wedding taking place?",
"faq_when_where_answer": "We look forward to welcoming you from 4:00 PM at Hochheimer Terrasse. The wedding ceremony begins at 4:15 PM. You can find more information on the location page.",
"faq_dresscode_question": "Is there a dress code?",
"faq_dresscode_answer": "We would love for you to wear formal evening attire. Please choose an elegant outfit you feel comfortable in—whether that is a dress, a suit, or another festive ensemble.",
"faq_parking_question": "Is parking available?",
"faq_parking_answer": "There are plenty of parking options in Hochheim's old town and the surrounding area. Please allow some extra time to find a space and walk to the venue.",
"faq_hotel_question": "Where can we stay overnight?",
"faq_hotel_answer": "For the shortest possible journey, we recommend staying in Hochheim or nearby. You can find a selection on our hotels page.",
"faq_upload_question": "How does the photo upload work?",
"faq_upload_answer": "You can upload photos directly from your smartphone or computer in the “Upload” section.",
"faq_visibility_question": "Who can see the uploaded photos?",
"faq_visibility_answer": "The photos are visible only to logged-in wedding guests. You can delete your own uploads at any time.",
"translator": "Translation helper",
"translator_kicker": "English & German",
"translator_close": "Close translation helper",
"translator_direction": "Translation direction",
"translator_swap": "Switch translation direction",
"translator_input_label": "What would you like to translate?",
"translator_placeholder": "Enter a short text …",
"translator_quick_phrases": "Quick phrases",
"translator_phrase_meet": "Meeting someone",
"translator_phrase_drink": "Offering a drink",
"translator_phrase_restroom": "Finding the restroom",
"translator_privacy": "Your text is sent to DeepL only after you click the button. Please do not enter names, contact details, or confidential information.",
"translator_submit": "Translate with DeepL",
"translator_policy": "DeepL privacy policy",
"language_german": "German",
"language_english": "English",
"tour_title": "Welcome to our wedding app",
"tour_progress": "Step {current} of {total}",
"tour_dashboard_title": "Everything at a glance",
"tour_dashboard_text": "The dashboard brings together all the important sections and information for our wedding day.",
"tour_rsvp_title": "RSVP",
"tour_rsvp_text": "Submit a response for every member of your invitation conveniently in the “RSVP” section.",
"tour_photos_title": "Photos & gallery",
"tour_photos_text": "Upload photos directly from your phone, explore the shared gallery, and download your favourite memories.",
"tour_routes_title": "Hotels, location & routes",
"tour_routes_text": "You can open Google Maps routes from the hotel and location pages. Maps load only after you click.",
"tour_translator_title": "Translation helper",
"tour_translator_text": "Use the book in the header to prepare short German or English texts for translation with DeepL.",
"tour_faq_title": "Frequently asked questions",
"tour_faq_text": "The question mark in the header takes you to the most important answers about the wedding at any time.",
"tour_back": "Back",
"tour_next": "Next",
"tour_skip": "Skip",
"tour_finish": "Lets go",
"tour_restart": "View introduction again",
"countdown_button_label": "Wedding countdown",
"countdown_until": "Countdown",
"countdown_started": "The celebration has started",
"countdown_days": "Days",
"countdown_hours": "Hrs",
"countdown_minutes": "Min",
"countdown_seconds": "Sec",
"countdown_subline": "until the wedding",
},
}
def get_lang() -> str:
lang = session.get("lang", "de")
return lang if lang in TEXTS else "de"
def t(key: str) -> str:
return TEXTS[get_lang()].get(key, key)
def get_hero_image_asset() -> str:
assets_dir = base_dir / "static" / "assets"
configured = app.config.get("HERO_IMAGE_FILENAME")
candidates = []
if configured:
candidates.append(configured)
candidates.extend(
[
"hero.avif",
"hero.webp",
"hero.jpg",
"hero.jpeg",
"hero.png",
"image.png",
"image-1.png",
]
)
for filename in candidates:
if (assets_dir / filename).is_file():
return f"assets/{filename}"
return "assets/hero.jpg"
def get_wedding_countdown_iso() -> str:
configured_iso = str(app.config.get("WEDDING_COUNTDOWN_ISO", "") or "").strip()
if configured_iso:
try:
datetime.fromisoformat(configured_iso.replace("Z", "+00:00"))
return configured_iso
except ValueError:
pass
local_value = str(app.config.get("WEDDING_COUNTDOWN_LOCAL", "") or "").strip()
timezone_name = str(app.config.get("WEDDING_TIMEZONE", "Europe/Berlin") or "Europe/Berlin").strip()
if not local_value:
return "2026-09-04T16:00:00+02:00"
parsed_local = None
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
parsed_local = datetime.strptime(local_value, fmt)
break
except ValueError:
continue
if parsed_local is None:
return "2026-09-04T16:00:00+02:00"
try:
tz = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
tz = ZoneInfo("Europe/Berlin")
return parsed_local.replace(tzinfo=tz).isoformat()
def build_google_maps_directions(origin: str, travel_mode: str) -> str:
query = urlencode(
{
"api": "1",
"origin": origin,
"destination": app.config["LOCATION_ADDRESS"],
"travelmode": travel_mode,
}
)
return f"https://www.google.com/maps/dir/?{query}"
def build_google_maps_destination(destination: str, travel_mode: str) -> str:
query = urlencode(
{
"api": "1",
"destination": destination,
"travelmode": travel_mode,
}
)
return f"https://www.google.com/maps/dir/?{query}"
def build_google_maps_route_embed(origin: str, travel_mode: str) -> str:
mode_flag = "d" if travel_mode == "driving" else "w"
query = urlencode(
{
"saddr": origin,
"daddr": app.config["LOCATION_ADDRESS"],
"dirflg": mode_flag,
"output": "embed",
}
)
return f"https://www.google.com/maps?{query}"
def get_hotels_for_page() -> list[dict]:
hotels = []
for entry in HOTEL_RECOMMENDATIONS:
hotels.append(
{
"name": entry["name"],
"address": entry["address"],
"website_url": entry["website_url"],
"description": t(f"hotel_{entry['id']}_desc"),
"drive_badge": t("hotel_drive_badge").format(minutes=entry["drive_minutes"]),
"walk_badge": (
t("hotel_walk_badge").format(minutes=entry["walk_minutes"])
if entry["walk_minutes"]
else t("hotel_walk_not_recommended")
),
"drive_route_url": build_google_maps_directions(entry["address"], "driving"),
"walk_route_url": build_google_maps_directions(entry["address"], "walking"),
"drive_route_embed_url": build_google_maps_route_embed(entry["address"], "driving"),
"walk_route_embed_url": build_google_maps_route_embed(entry["address"], "walking"),
"current_route_url": build_google_maps_destination(entry["address"], "driving"),
}
)
return hotels
def get_location_video_asset() -> str | None:
location_dir = base_dir / "static" / "assets" / "location"
if not location_dir.exists():
return None
for asset in sorted(location_dir.iterdir()):
if asset.is_file() and asset.suffix.lower() in LOCATION_VIDEO_EXTENSIONS:
return f"assets/location/{asset.name}"
return None
@app.context_processor
def inject_common() -> dict:
role = session.get("role", "guest")
group_id = session.get("group_id")
location_video_asset = get_location_video_asset()
return {
"t": t,
"lang": get_lang(),
"guest_name": session.get("group_name"),
"guest_id": group_id,
"group_name": session.get("group_name"),
"group_id": group_id,
"is_host": role == "admin",
"is_admin": role == "admin",
"location_name": app.config["LOCATION_NAME"],
"location_address": app.config["LOCATION_ADDRESS"],
"location_website_url": app.config["LOCATION_WEBSITE_URL"],
"location_route_url": build_google_maps_destination(app.config["LOCATION_ADDRESS"], "driving"),
"google_maps_embed_url": app.config["GOOGLE_MAPS_EMBED_URL"],
"location_video_url": url_for("static", filename=location_video_asset) if location_video_asset else None,
"wedding_date": app.config["WEDDING_DATE"],
"wedding_countdown_iso": get_wedding_countdown_iso(),
"hero_image_url": url_for("static", filename=get_hero_image_asset()),
}
def get_db() -> sqlite3.Connection:
db_path = app.config["DB_PATH"]
os.makedirs(os.path.dirname(db_path), exist_ok=True)
if "db" not in g:
g.db = sqlite3.connect(db_path)
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(_error) -> None:
db = g.pop("db", None)
if db is not None:
db.close()
def table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
return {str(row[1]) for row in rows}
def init_db() -> None:
db_path = app.config["DB_PATH"]
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
with sqlite3.connect(db_path) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'guest',
onboarding_completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
name TEXT NOT NULL,
attending INTEGER,
child_age INTEGER,
requires_age INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
FOREIGN KEY(group_id) REFERENCES groups(id),
UNIQUE(group_id, name)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
uploaded_by INTEGER NOT NULL,
uploaded_at TEXT NOT NULL,
FOREIGN KEY(uploaded_by) REFERENCES groups(id)
)
"""
)
member_cols = table_columns(conn, "group_members")
if "child_age" not in member_cols:
conn.execute("ALTER TABLE group_members ADD COLUMN child_age INTEGER")
if "requires_age" not in member_cols:
conn.execute("ALTER TABLE group_members ADD COLUMN requires_age INTEGER NOT NULL DEFAULT 0")
if "created_at" not in member_cols:
conn.execute("ALTER TABLE group_members ADD COLUMN created_at TEXT NOT NULL DEFAULT ''")
group_cols = table_columns(conn, "groups")
if "role" not in group_cols:
conn.execute("ALTER TABLE groups ADD COLUMN role TEXT NOT NULL DEFAULT 'guest'")
if "onboarding_completed" not in group_cols:
conn.execute("ALTER TABLE groups ADD COLUMN onboarding_completed INTEGER NOT NULL DEFAULT 0")
conn.commit()
def seed_default_groups() -> None:
db = get_db()
existing = db.execute("SELECT COUNT(*) AS c FROM groups").fetchone()
if int(existing["c"] or 0) > 0:
return
now = datetime.utcnow().isoformat()
for entry in DEFAULT_INVITATION_GROUPS:
password_hash = generate_password_hash(entry["password"])
cursor = db.execute(
"INSERT INTO groups (name, password_hash, role, created_at) VALUES (?, ?, ?, ?)",
(entry["name"], password_hash, entry["role"], now),
)
group_id = int(cursor.lastrowid)
member_rows = []
for member_name in entry["members"]:
member_rows.append(
(
group_id,
member_name,
1 if member_name in AGE_REQUIRED_NAMES else 0,
now,
)
)
db.executemany(
"""
INSERT INTO group_members (group_id, name, requires_age, created_at)
VALUES (?, ?, ?, ?)
""",
member_rows,
)
db.commit()
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if "group_id" not in session:
return redirect(url_for("landing"))
return view(*args, **kwargs)
return wrapped
def admin_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if session.get("role") != "admin":
flash(t("flash_admin_only"))
return redirect(url_for("guest_area"))
return view(*args, **kwargs)
return wrapped
@app.errorhandler(RequestEntityTooLarge)
def handle_request_too_large(_error):
max_mb = max(1, int(app.config.get("MAX_CONTENT_LENGTH", 0)) // (1024 * 1024))
flash(t("flash_upload_too_large").format(max_mb=max_mb))
if request.method == "POST":
return redirect(url_for("upload"))
return redirect(url_for("landing"))
def is_allowed_file(filename: str) -> bool:
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/")
def landing():
if "group_id" in session:
return redirect(url_for("welcome"))
return render_template("login.html")
@app.post("/login")
def login():
group_name = (request.form.get("group_name") or "").strip()
group_password = request.form.get("group_password") or ""
if not group_name:
flash(t("flash_enter_group_name"))
return redirect(url_for("landing"))
db = get_db()
group = db.execute(
"SELECT id, name, password_hash, role FROM groups WHERE LOWER(name) = LOWER(?)",
(group_name,),
).fetchone()
if group is None or not check_password_hash(str(group["password_hash"]), group_password):
flash(t("flash_invalid_group_login"))
return redirect(url_for("landing"))
session["group_id"] = int(group["id"])
session["group_name"] = str(group["name"])
session["role"] = str(group["role"] or "guest")
return redirect(url_for("welcome"))
@app.post("/logout")
def logout():
session.clear()
return redirect(url_for("landing"))
@app.post("/lang/<code>")
def set_lang(code: str):
if code in TEXTS:
session["lang"] = code
return redirect(request.referrer or url_for("landing"))
@app.get("/welcome")
@login_required
def welcome():
db = get_db()
member_count_row = db.execute(
"SELECT COUNT(*) AS c FROM group_members WHERE group_id = ?",
(int(session["group_id"]),),
).fetchone()
member_count = int((member_count_row["c"] if member_count_row else 0) or 0)
is_single_guest = member_count <= 1
welcome_headline = t("hero_headline_with_group").format(name=session.get("group_name", ""))
welcome_text = t("hero_text_single") if is_single_guest else t("hero_text_group")
onboarding_row = db.execute(
"SELECT onboarding_completed FROM groups WHERE id = ?",
(int(session["group_id"]),),
).fetchone()
completed_onboarding_version = (
int(onboarding_row["onboarding_completed"] or 0) if onboarding_row else 0
)
show_onboarding = (
request.args.get("tour") == "1"
or completed_onboarding_version < ONBOARDING_VERSION
)
return render_template(
"welcome.html",
welcome_headline=welcome_headline,
welcome_text=welcome_text,
welcome_hint=t("hero_app_hint_single") if is_single_guest else t("hero_app_hint"),
show_onboarding=show_onboarding,
)
@app.post("/onboarding/complete")
@login_required
def complete_onboarding():
db = get_db()
db.execute(
"UPDATE groups SET onboarding_completed = ? WHERE id = ?",
(ONBOARDING_VERSION, int(session["group_id"])),
)
db.commit()
return ("", 204)
@app.get("/gaestebereich")
@app.get("/gästebereich")
@login_required
def guest_area():
return render_template("guest_area.html")
@app.route("/gastgeberbereich", methods=["GET"])
@login_required
@admin_required
def host_area():
db = get_db()
stats_row = db.execute(
"""
SELECT
COUNT(*) AS total_guests,
SUM(CASE WHEN attending = 1 THEN 1 ELSE 0 END) AS attending_yes,
SUM(CASE WHEN attending = 0 THEN 1 ELSE 0 END) AS attending_no,
SUM(CASE WHEN attending IS NULL THEN 1 ELSE 0 END) AS attending_open
FROM group_members
"""
).fetchone()
members = db.execute(
"""
SELECT groups.name AS group_name, group_members.name, group_members.attending, group_members.child_age, group_members.requires_age
FROM group_members
JOIN groups ON groups.id = group_members.group_id
ORDER BY groups.name COLLATE NOCASE ASC, group_members.name COLLATE NOCASE ASC
"""
).fetchall()
stats = {
"total_guests": int(stats_row["total_guests"] or 0),
"attending_yes": int(stats_row["attending_yes"] or 0),
"attending_no": int(stats_row["attending_no"] or 0),
"attending_open": int(stats_row["attending_open"] or 0),
}
return render_template("host_area.html", stats=stats, members=members)
@app.get("/dashboard")
@login_required
def dashboard():
return redirect(url_for("guest_area"))
@app.get("/faq")
@login_required
def faq():
faq_items = [
("faq_deadline_question", "faq_deadline_answer"),
("faq_change_question", "faq_change_answer"),
("faq_when_where_question", "faq_when_where_answer"),
("faq_dresscode_question", "faq_dresscode_answer"),
("faq_parking_question", "faq_parking_answer"),
("faq_hotel_question", "faq_hotel_answer"),
("faq_upload_question", "faq_upload_answer"),
("faq_visibility_question", "faq_visibility_answer"),
]
return render_template("faq.html", faq_items=faq_items)
@app.route("/rsvp", methods=["GET", "POST"])
@login_required
def rsvp():
db = get_db()
current_group_id = int(session["group_id"])
members = db.execute(
"""
SELECT id, name, attending, child_age, requires_age
FROM group_members
WHERE group_id = ?
ORDER BY id ASC
""",
(current_group_id,),
).fetchall()
if request.method == "POST":
updates = []
for member in members:
member_id = int(member["id"])
attending_raw = request.form.get(f"attending_{member_id}")
if attending_raw not in {"yes", "no"}:
flash(t("flash_rsvp_select"))
return redirect(url_for("rsvp"))
attending = 1 if attending_raw == "yes" else 0
child_age = None
if attending == 1 and int(member["requires_age"] or 0) == 1:
age_raw = (request.form.get(f"age_{member_id}") or "").strip()
if not age_raw:
flash(t("flash_rsvp_age_missing").format(name=member["name"]))
return redirect(url_for("rsvp"))
if not age_raw.isdigit():
flash(t("flash_rsvp_age_invalid").format(name=member["name"]))
return redirect(url_for("rsvp"))
age_value = int(age_raw)
if age_value < 0 or age_value > 17:
flash(t("flash_rsvp_age_invalid").format(name=member["name"]))
return redirect(url_for("rsvp"))
child_age = age_value
updates.append((attending, child_age, member_id, current_group_id))
db.executemany(
"""
UPDATE group_members
SET attending = ?, child_age = ?
WHERE id = ? AND group_id = ?
""",
updates,
)
db.commit()
flash(t("flash_rsvp_saved"))
return redirect(url_for("guest_area"))
return render_template("rsvp.html", members=members)
@app.route("/upload", methods=["GET", "POST"])
@login_required
def upload():
if request.method == "POST":
try:
files = [f for f in request.files.getlist("photo") if f and f.filename]
if not files:
flash(t("flash_select_image"))
return redirect(url_for("upload"))
for file in files:
if not is_allowed_file(file.filename):
flash(t("flash_allowed_types"))
return redirect(url_for("upload"))
mime_type = (file.mimetype or "").lower()
if mime_type and mime_type not in ALLOWED_MIME_TYPES:
flash(t("flash_allowed_types"))
return redirect(url_for("upload"))
upload_dir = app.config["UPLOAD_FOLDER"]
os.makedirs(upload_dir, exist_ok=True)
db = get_db()
now = datetime.utcnow().isoformat()
upload_rows = []
for file in files:
safe_name = secure_filename(file.filename)
if "." not in safe_name:
flash(t("flash_allowed_types"))
return redirect(url_for("upload"))
ext = safe_name.rsplit(".", 1)[1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
file.save(os.path.join(upload_dir, stored_name))
upload_rows.append((stored_name, int(session["group_id"]), now))
db.executemany(
"INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)",
upload_rows,
)
db.commit()
flash(t("flash_upload_success_count").format(count=len(upload_rows)))
return redirect(url_for("gallery"))
except RequestEntityTooLarge:
raise
except Exception:
app.logger.exception("Upload failed")
flash(t("flash_upload_failed"))
return redirect(url_for("upload"))
return render_template("upload.html")
@app.get("/gallery")
@login_required
def gallery():
db = get_db()
images = db.execute(
"""
SELECT uploads.id, uploads.filename, uploads.uploaded_by, uploads.uploaded_at,
COALESCE(groups.name, 'Unbekannt') AS uploaded_by_name
FROM uploads
LEFT JOIN groups ON groups.id = uploads.uploaded_by
ORDER BY uploads.id DESC
"""
).fetchall()
return render_template("gallery.html", images=images)
@app.post("/gallery/delete/<int:image_id>")
@login_required
def delete_image(image_id: int):
db = get_db()
image = db.execute(
"SELECT id, filename, uploaded_by FROM uploads WHERE id = ?",
(image_id,),
).fetchone()
if image is None:
return redirect(url_for("gallery"))
current_group_id = int(session["group_id"])
is_admin = session.get("role") == "admin"
if not is_admin and int(image["uploaded_by"]) != current_group_id:
flash(t("flash_delete_not_allowed"))
return redirect(url_for("gallery"))
db.execute("DELETE FROM uploads WHERE id = ?", (image_id,))
db.commit()
file_path = os.path.join(app.config["UPLOAD_FOLDER"], image["filename"])
if os.path.isfile(file_path):
os.remove(file_path)
flash(t("flash_image_deleted"))
return redirect(url_for("gallery"))
@app.get("/uploads/<path:filename>")
@login_required
def serve_upload(filename: str):
as_attachment = request.args.get("download") == "1"
return send_from_directory(
app.config["UPLOAD_FOLDER"],
filename,
as_attachment=as_attachment,
download_name=filename if as_attachment else None,
)
@app.get("/info/<page>")
@login_required
def info(page: str):
allowed = {"schedule", "hotels", "taxi", "location", "gifts"}
if page not in allowed:
return redirect(url_for("guest_area"))
if page == "gifts" and session.get("role") == "admin":
return redirect(url_for("guest_area"))
hotels = get_hotels_for_page() if page == "hotels" else []
return render_template("info.html", page=page, hotels=hotels)
@app.get("/datenschutz")
def datenschutz():
return render_template("datenschutz.html")
@app.get("/impressum")
def impressum():
return render_template("impressum.html")
init_db()
with app.app_context():
seed_default_groups()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)