import os import sqlite3 import uuid from hmac import compare_digest from datetime import datetime from functools import wraps from pathlib import Path from flask import ( Flask, flash, g, redirect, render_template, request, send_from_directory, session, url_for, ) 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["EVENT_PASSWORD"] = os.environ.get("EVENT_PASSWORD", "wedding2026") app.config["HOST_PASSWORD"] = os.environ.get("HOST_PASSWORD", "gastgeber2026") app.config["MAX_CONTENT_LENGTH"] = int(os.environ.get("MAX_UPLOAD_BYTES", str(8 * 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["HERO_IMAGE_FILENAME"] = os.environ.get("HERO_IMAGE_FILENAME") ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png"} TEXTS = { "de": { "brand": "Svenja & Dominic", "subtitle": "Willkommen zu unserer Hochzeits-App", "login_note": "Passwortgeschuetzter Zugriff fuer unsere Gaeste.", "login": "Login", "name": "Dein Name", "event_password": "Event-Passwort", "login_submit": "Weiter zum Gaestebereich", "guest_area": "Gaestebereich", "hello_guest": "Hallo {name}.", "logout": "Abmelden", "rsvp": "RSVP", "upload": "Upload", "gallery": "Galerie", "host_area": "Gastgeberbereich", "info": "Infos", "save": "Speichern", "attending": "Ich komme", "not_attending": "Ich komme nicht", "plus_one": "Ich bringe eine Begleitperson mit", "file": "Bild auswählen", "upload_submit": "Foto hochladen", "schedule": "Ablauf", "hotels": "Hotels", "taxi": "Taxi", "location": "Location", "visit_location": "Zur Location-Webseite", "privacy": "Datenschutz", "imprint": "Impressum", "hero_headline": "Willkommen zu unserer Hochzeit", "hero_text": "Wir freuen uns riesig, diesen besonderen Tag mit euch zu feiern.", "to_guest_area": "Zum Gaestebereich", "schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.", "hotels_text": "Empfehlungen folgen. Bitte fruehzeitig buchen.", "taxi_text": "Taxi-Service: 01234 / 567890 (24/7).", "gallery_uploaded_by": "von {name}", "gallery_empty": "Noch keine Bilder vorhanden.", "gallery_image_alt": "Upload von {name}", "flash_enter_name": "Bitte Namen eingeben.", "flash_invalid_password": "Ungueltiges Event-Passwort.", "flash_rsvp_select": "Bitte eine RSVP-Auswahl treffen.", "flash_rsvp_saved": "RSVP gespeichert.", "flash_select_image": "Bitte eine Bilddatei auswaehlen.", "flash_allowed_types": "Nur JPG/JPEG/PNG sind erlaubt.", "flash_upload_success": "Upload erfolgreich.", "flash_invalid_host_password": "Ungueltiges Gastgeber-Passwort.", "host_access_title": "Gastgeberbereich", "host_access_note": "Dieser Bereich ist nur fuer das Brautpaar vorgesehen.", "host_password": "Gastgeber-Passwort", "host_access_submit": "Adminbereich oeffnen", "host_stats_title": "Uebersicht", "total_guests": "Gaeste gesamt", "attending_yes": "Zusagen", "attending_no": "Absagen", "attending_open": "Noch offen", "plus_one_total": "Begleitpersonen", "host_table_name": "Name", "host_table_status": "RSVP", "host_table_plus_one": "Begleitperson", "status_yes": "Kommt", "status_no": "Kommt nicht", "status_open": "Offen", "yes": "Ja", "no": "Nein", "legal_de_authoritative": "Bei rechtlichen Unklarheiten gilt die deutsche Fassung.", }, "en": { "brand": "Svenja & Dominic", "subtitle": "Welcome to our wedding app", "login_note": "Password-protected access for our guests.", "login": "Login", "name": "Your name", "event_password": "Event password", "login_submit": "Open guest area", "guest_area": "Guest Area", "hello_guest": "Hello {name}.", "logout": "Logout", "rsvp": "RSVP", "upload": "Upload", "gallery": "Gallery", "host_area": "Host Area", "info": "Info", "save": "Save", "attending": "I will attend", "not_attending": "I cannot attend", "plus_one": "I will bring a plus-one", "file": "Select image", "upload_submit": "Upload photo", "schedule": "Schedule", "hotels": "Hotels", "taxi": "Taxi", "location": "Location", "visit_location": "Visit location website", "privacy": "Privacy", "imprint": "Imprint", "hero_headline": "Welcome to our wedding", "hero_text": "We are so excited to celebrate this special day with you.", "to_guest_area": "Open guest area", "schedule_text": "3:00 PM ceremony, 5:00 PM reception, 7:00 PM dinner.", "hotels_text": "Recommendations will follow. Please book early.", "taxi_text": "Taxi service: 01234 / 567890 (24/7).", "gallery_uploaded_by": "by {name}", "gallery_empty": "No photos available yet.", "gallery_image_alt": "Uploaded by {name}", "flash_enter_name": "Please enter your name.", "flash_invalid_password": "Invalid event password.", "flash_rsvp_select": "Please choose an RSVP option.", "flash_rsvp_saved": "RSVP saved.", "flash_select_image": "Please select an image file.", "flash_allowed_types": "Only JPG/JPEG/PNG are allowed.", "flash_upload_success": "Upload successful.", "flash_invalid_host_password": "Invalid host password.", "host_access_title": "Host Area", "host_access_note": "This section is intended for the wedding hosts only.", "host_password": "Host password", "host_access_submit": "Open admin area", "host_stats_title": "Overview", "total_guests": "Total guests", "attending_yes": "Attending", "attending_no": "Declined", "attending_open": "Pending", "plus_one_total": "Plus-ones", "host_table_name": "Name", "host_table_status": "RSVP", "host_table_plus_one": "Plus-one", "status_yes": "Attending", "status_no": "Not attending", "status_open": "Pending", "yes": "Yes", "no": "No", "legal_de_authoritative": "In case of legal ambiguity, the German version shall prevail.", }, } 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.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" @app.context_processor def inject_common() -> dict: return { "t": t, "lang": get_lang(), "guest_name": session.get("guest_name"), "location_name": app.config["LOCATION_NAME"], "location_address": app.config["LOCATION_ADDRESS"], "location_website_url": app.config["LOCATION_WEBSITE_URL"], "google_maps_embed_url": app.config["GOOGLE_MAPS_EMBED_URL"], "wedding_date": app.config["WEDDING_DATE"], "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 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 guests ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, attending INTEGER, plus_one INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ) """ ) 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 guests(id) ) """ ) conn.commit() def login_required(view): @wraps(view) def wrapped(*args, **kwargs): if "guest_id" not in session: return redirect(url_for("landing")) return view(*args, **kwargs) return wrapped def is_allowed_file(filename: str) -> bool: return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS def upsert_guest(name: str) -> int: now = datetime.utcnow().isoformat() db = get_db() row = db.execute("SELECT id FROM guests WHERE name = ?", (name,)).fetchone() if row: return int(row["id"]) cursor = db.execute( "INSERT INTO guests (name, created_at) VALUES (?, ?)", (name, now), ) db.commit() return int(cursor.lastrowid) @app.get("/health") def health(): return {"status": "ok"} @app.get("/") def landing(): if "guest_id" in session: return redirect(url_for("welcome")) return render_template("login.html") @app.post("/login") def login(): name = (request.form.get("name") or "").strip() event_password = request.form.get("event_password") or "" if not name: flash(t("flash_enter_name")) return redirect(url_for("landing")) if event_password != app.config["EVENT_PASSWORD"]: flash(t("flash_invalid_password")) return redirect(url_for("landing")) guest_id = upsert_guest(name) session["guest_id"] = guest_id session["guest_name"] = name session.pop("is_host", None) 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(): return render_template("welcome.html") @app.get("/gaestebereich") @login_required def guest_area(): return render_template("guest_area.html") @app.route("/gastgeberbereich", methods=["GET", "POST"]) @login_required def host_area(): if request.method == "POST": host_password = request.form.get("host_password") or "" expected = app.config.get("HOST_PASSWORD", "") if not expected or not compare_digest(host_password, expected): flash(t("flash_invalid_host_password")) return redirect(url_for("host_area")) session["is_host"] = True return redirect(url_for("host_area")) if not session.get("is_host"): return render_template("host_area.html", unlocked=False) 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, SUM(CASE WHEN attending = 1 AND plus_one = 1 THEN 1 ELSE 0 END) AS plus_one_total FROM guests """ ).fetchone() guests = db.execute( """ SELECT name, attending, plus_one FROM guests ORDER BY 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), "plus_one_total": int(stats_row["plus_one_total"] or 0), } return render_template("host_area.html", unlocked=True, stats=stats, guests=guests) @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() if request.method == "POST": attending_raw = request.form.get("attending") plus_one = 1 if request.form.get("plus_one") == "on" else 0 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 if not attending: plus_one = 0 db.execute( "UPDATE guests SET attending = ?, plus_one = ? WHERE id = ?", (attending, plus_one, session["guest_id"]), ) db.commit() flash(t("flash_rsvp_saved")) return redirect(url_for("rsvp")) guest = db.execute( "SELECT attending, plus_one FROM guests WHERE id = ?", (session["guest_id"],), ).fetchone() return render_template("rsvp.html", guest=guest) @app.route("/upload", methods=["GET", "POST"]) @login_required def upload(): if request.method == "POST": file = request.files.get("photo") if file is None or file.filename == "": flash(t("flash_select_image")) return redirect(url_for("upload")) if not is_allowed_file(file.filename): flash(t("flash_allowed_types")) return redirect(url_for("upload")) safe_name = secure_filename(file.filename) ext = safe_name.rsplit(".", 1)[1].lower() stored_name = f"{uuid.uuid4().hex}.{ext}" upload_dir = app.config["UPLOAD_FOLDER"] os.makedirs(upload_dir, exist_ok=True) file.save(os.path.join(upload_dir, stored_name)) db = get_db() db.execute( "INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)", (stored_name, session["guest_id"], datetime.utcnow().isoformat()), ) db.commit() flash(t("flash_upload_success")) return redirect(url_for("gallery")) return render_template("upload.html") @app.get("/gallery") @login_required def gallery(): db = get_db() images = db.execute( """ SELECT uploads.filename, uploads.uploaded_at, guests.name AS uploaded_by FROM uploads JOIN guests ON guests.id = uploads.uploaded_by ORDER BY uploads.id DESC """ ).fetchall() return render_template("gallery.html", images=images) @app.get("/uploads/") @login_required def serve_upload(filename: str): return send_from_directory(app.config["UPLOAD_FOLDER"], filename) @app.get("/info/") @login_required def info(page: str): allowed = {"schedule", "hotels", "taxi", "location"} if page not in allowed: return redirect(url_for("guest_area")) return render_template("info.html", page=page) @app.get("/datenschutz") def datenschutz(): return render_template("datenschutz.html") @app.get("/impressum") def impressum(): return render_template("impressum.html") init_db() if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)