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", "Schlossgarten Venue") app.config["LOCATION_ADDRESS"] = os.environ.get("LOCATION_ADDRESS", "Musterstraße 12, 12345 Musterstadt") app.config["LOCATION_WEBSITE_URL"] = os.environ.get("LOCATION_WEBSITE_URL", "https://example.com/location") app.config["GOOGLE_MAPS_EMBED_URL"] = os.environ.get( "GOOGLE_MAPS_EMBED_URL", "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d0", ) 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 15: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", } 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", "Freund"], }, { "name": "Familie Löster", "password": "Loes@Ring24#", "role": "guest", "members": ["Claudia", "Mario", "Mélodie"], }, { "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"], }, {"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"]}, { "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": "Leander & Heni", "password": "Lea&Heni24!#", "role": "guest", "members": ["Leander", "Heni"], }, {"name": "Flo", "password": "Flo!Liebe24#", "role": "guest", "members": ["Flo"]}, { "name": "Nico & Pia", "password": "Nico&Pia24!#", "role": "guest", "members": ["Nico", "Pia"], }, {"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": "Holzi", "password": "Holz!Ring24#", "role": "guest", "members": ["Holzi"]}, {"name": "Eirene", "password": "Eire$Fest24!", "role": "guest", "members": ["Eirene"]}, { "name": "Family Hynes", "password": "Hyne#Love24!", "role": "guest", "members": ["Steven", "Martha", "William", "Tim", "Steven Jr."], }, {"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", "Jana"], }, ] AGE_REQUIRED_NAMES = {"Lena", "Finn", "Fin", "Bruno"} HOTEL_RECOMMENDATIONS = [ { "id": "parkhotel_sonnenberg", "name": "Parkhotel Sonnenberg", "address": "Friedrichstraße 65, 65343 Eltville am Rhein", "website_url": "https://parkhotel-sonnenberg.com/", "drive_minutes": "5", "walk_minutes": "27", }, { "id": "hotel_frankenbach", "name": "Hotel Frankenbach", "address": "Wilhelmstraße 13, 65343 Eltville am Rhein", "website_url": "https://www.hotel-frankenbach.de/", "drive_minutes": "5", "walk_minutes": "19", }, { "id": "spritzenhaus", "name": "Das Spritzenhaus", "address": "Platz von Montrichard 1, 65343 Eltville am Rhein", "website_url": "https://www.das-spritzenhaus.de/", "drive_minutes": "5", "walk_minutes": "22", }, { "id": "kronenschloesschen", "name": "Hotel Kronenschlösschen", "address": "Rheinallee, 65347 Eltville-Hattenheim", "website_url": "https://www.kronenschloesschen.de/", "drive_minutes": "6", "walk_minutes": "63", }, { "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": "10", "walk_minutes": "69", }, { "id": "weinhaus_engel", "name": "Weinhaus & Hotel Engel", "address": "Hauptstraße 10, 65345 Eltville-Rauenthal", "website_url": "https://weinhaus-engel.com/rauenthal/", "drive_minutes": "10", "walk_minutes": "49", }, ] 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", "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": "Taxi", "location": "Location", "gifts": "Geschenke", "visit_location": "Zur Location-Webseite", "location_story_title": "Klostermühle in den Weinbergen", "location_story_text": "Die Klostermühle liegt mitten in den Weinbergen und verbindet ein besonderes Weingut-Ambiente mit saisonaler Küche. Euch erwartet eine entspannte Atmosphäre zwischen Natur, gutem Essen und einem wunderschönen Rahmen für unseren Hochzeitstag.", "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_privacy_notice": "Zur Anzeige der Karte werden Daten an Google übertragen.", "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. 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. Bitte gebt eure Zu- oder Absage möglichst bald ab.", "to_guest_area": "Zum Gästebereich", "schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.", "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 Rosengarten und Fotos.", "schedule_reception_title": "Sektempfang & Häppchen", "schedule_reception_text": "Sektempfang im Hof der Klostermühle.", "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_text": "Gemeinsamer Tortenmoment am Abend.", "schedule_afterwards_time": "Anschließend", "schedule_party_title": "Party", "schedule_party_text": "Wir feiern gemeinsam bis 03:00 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_drive": "Route zur Location (Auto)", "hotel_route_walk": "Route 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_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": "Besonderes Ambiente am Kloster mit kurzer Fahrzeit.", "hotel_weinhaus_engel_desc": "Gemütliches Weinhaus-Hotel in Rauenthal.", "taxi_text": "An einem Taxiservice arbeiten wir noch.", "taxi_sticker_alt": "Bauarbeiter-Sticker: Arbeit in Progress", "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_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": "RSVP 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", "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", "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": "Taxi", "location": "Location", "gifts": "Gifts", "visit_location": "Visit location website", "location_story_title": "Klostermuehle in the vineyards", "location_story_text": "Klostermuehle is surrounded by vineyards and combines a unique winery atmosphere with seasonal cuisine. Expect a relaxed setting with nature, great food, and a beautiful backdrop for our wedding day.", "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_privacy_notice": "To display the map, data will be transferred to Google.", "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. 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. Please submit your RSVP soon.", "to_guest_area": "Open guest area", "schedule_text": "3:00 PM ceremony, 5:00 PM reception, 7:00 PM dinner.", "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 rose garden and photos.", "schedule_reception_title": "Sparkling Reception & Snacks", "schedule_reception_text": "Sparkling reception in the courtyard of the Klostermuehle.", "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_text": "Cake cutting together in the evening.", "schedule_afterwards_time": "Afterwards", "schedule_party_title": "Party", "schedule_party_text": "We celebrate together until 3:00 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_drive": "Route to location (car)", "hotel_route_walk": "Route 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_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": "Unique monastery setting with a short drive to the venue.", "hotel_weinhaus_engel_desc": "Cozy winehouse hotel in Rauenthal.", "taxi_text": "We are still working on a taxi service.", "taxi_sticker_alt": "Construction worker sticker: Work in progress", "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_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", "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-04T15: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-04T15: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"]), "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"), } ) return hotels @app.context_processor def inject_common() -> dict: role = session.get("role", "guest") group_id = session.get("group_id") 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"], "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', 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'") 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/") 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") 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"), ) @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.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("rsvp")) 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/") @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/") @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/") @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)