diff --git a/README.md b/README.md index c88a9aa..d58eed9 100644 --- a/README.md +++ b/README.md @@ -511,154 +511,116 @@ Adminnutzer: Bubus (Admingruppe) Mitglieder: Svenja, Dominic -Passwort: Bubu!Herz24# Standardnutzer: Remi Mitglieder: Remi -Passwort: Remi#Ring24! Chantal Mitglieder: Chantal -Passwort: Chan!Tanz24# Madeleine Mitglieder: Madeleine -Passwort: Madi$Rose24! Julie & Daniel Mitglieder: Julie, Daniel -Passwort: Juli&Dan24!# Tim & Sophie Mitglieder: Tim, Sophie -Passwort: Tim+Sofi24!# Marcel & Kathrin Mitglieder: Marcel, Kathrin -Passwort: Marc&Kath24# Familie Olsem Mitglieder: Laura, Sven, Lena, Finn -Passwort: Olse!Fam24#? Maxime Mitglieder: Maxime, Freund -Passwort: Maxi#Love24! Familie Löster Mitglieder: Claudia, Mario, Mélodie -Passwort: Loes@Ring24# Familie Thiels Mitglieder: Matthias, Opa Bernd, Oma Heidi -Passwort: Thie$Fest24! Familie Gollor Mitglieder: Michael, Christin, Bruno -Passwort: Goll%Herz24! Monika Mitglieder: Monika -Passwort: Moni!Rose24# Familie Konrad Mitglieder: Michael, Sandra, Christoph, Alexander -Passwort: Konr#Fest24! Mark Mitglieder: Mark -Passwort: Mark!Gold24# Elias Mitglieder: Elias -Passwort: Elia$Ring24! Milan Mitglieder: Milan -Passwort: Mila#Tanz24! Familie Wolff Mitglieder: Anja, Bodo -Passwort: Wolf!Herz24# Anna & Leon Mitglieder: Anna, Leon -Passwort: Anna&Leo24!# Aryan Mitglieder: Aryan -Passwort: Arya!Fest24# Sebastian Mitglieder: Sebastian, Olivia -Passwort: Seba$Ring24! Leander & Heni Mitglieder: Leander, Heni -Passwort: Lea&Heni24!# Flo Mitglieder: Flo -Passwort: Flo!Liebe24# Nico & Pia Mitglieder: Nico, Pia -Passwort: Nico&Pia24!# Kiki Mitglieder: Kiki -Passwort: Kiki!Rose24# Lana & Eric Mitglieder: Lana, Eric -Passwort: Lan&Eric24!# Britta Mitglieder: Britta -Passwort: Brit!Tanz24# Holzi Mitglieder: Holzi -Passwort: Holz!Ring24# Eirene Mitglieder: Eirene -Passwort: Eire$Fest24! Family Hynes Mitglieder: Steven, Martha, William, Tim, Steven Jr. -Passwort: Hyne#Love24! Timbo Mitglieder: Timbo -Passwort: Timb!Rose24# Karen & Jay Mitglieder: Karen, Jay -Passwort: Kare&Jay24!# Alina Mitglieder: Alina -Passwort: Alin!Gold24# Max Mitglieder: Max -Passwort: Max!Liebe24# Paul & Alix Mitglieder: Paul, Alix -Passwort: Paul&Alx24!# Alfred & Nadia Mitglieder: Alfred, Nadia -Passwort: Alfr&Nad24!# Anne-Marie & Erny Mitglieder: Anne-Marie, Erny -Passwort: Anne&Ern24!# Familie Kieffer Mitglieder: Anny, John, Jana -Passwort: Kief!Fest24# diff --git a/backend/__pycache__/app.cpython-312.pyc b/backend/__pycache__/app.cpython-312.pyc index ea7ac04..f4b4dc3 100644 Binary files a/backend/__pycache__/app.cpython-312.pyc and b/backend/__pycache__/app.cpython-312.pyc differ diff --git a/backend/app.py b/backend/app.py index 0ecb65a..11168a2 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,6 @@ import os import sqlite3 import uuid -from hmac import compare_digest from datetime import datetime from functools import wraps from pathlib import Path @@ -18,6 +17,7 @@ from flask import ( 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__) @@ -25,16 +25,10 @@ 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(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["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", @@ -53,37 +47,197 @@ ALLOWED_MIME_TYPES = { "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": "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"} + TEXTS = { "de": { "brand": "Svenja & Dominic", "subtitle": "Willkommen zu unserer Hochzeits-App", "login_note": "Passwortgeschützter Zugriff für unsere Gäste.", "login": "Login", - "name": "Dein Name", - "event_password": "Event-Passwort", + "group_name": "Gruppenname", + "group_password": "Gruppenpasswort", "login_submit": "Weiter zum Gästebereich", "guest_area": "Gästebereich", "hello_guest": "Hallo {name}.", "logout": "Abmelden", "rsvp": "RSVP", "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", - "plus_one": "Ich bringe eine Begleitperson mit", + "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", "visit_location": "Zur Location-Webseite", + "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", @@ -95,71 +249,76 @@ TEXTS = { "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": "Ungültiges Event-Passwort.", - "flash_rsvp_select": "Bitte eine RSVP-Auswahl treffen.", + "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": "Upload erfolgreich.", "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.", - "flash_invalid_host_password": "Ungültiges Gastgeber-Passwort.", - "host_access_title": "Gastgeberbereich", - "host_access_note": "Dieser Bereich ist nur für das Brautpaar vorgesehen.", - "host_password": "Gastgeber-Passwort", - "host_access_submit": "Adminbereich öffnen", "host_stats_title": "Übersicht", "total_guests": "Gäste gesamt", "attending_yes": "Zusagen", "attending_no": "Absagen", "attending_open": "Noch offen", - "plus_one_total": "Begleitpersonen", - "host_table_name": "Name", + "host_table_group": "Gruppe", + "host_table_member": "Mitglied", "host_table_status": "RSVP", - "host_table_plus_one": "Begleitperson", + "host_table_age": "Alter", "status_yes": "Kommt", "status_no": "Kommt nicht", "status_open": "Offen", - "yes": "Ja", - "no": "Nein", "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", }, "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", + "group_name": "Group name", + "group_password": "Group password", "login_submit": "Open guest 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", - "plus_one": "I will bring a plus-one", + "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", "visit_location": "Visit location website", + "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", @@ -171,40 +330,37 @@ TEXTS = { "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_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": "Upload successful.", "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.", - "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_group": "Group", + "host_table_member": "Member", "host_table_status": "RSVP", - "host_table_plus_one": "Plus-one", + "host_table_age": "Age", "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.", "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", }, } @@ -236,12 +392,17 @@ def get_hero_image_asset() -> str: @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("guest_name"), - "guest_id": session.get("guest_id"), - "is_host": bool(session.get("is_host")), + "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"], @@ -268,6 +429,11 @@ def close_db(_error) -> 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) @@ -277,15 +443,32 @@ def init_db() -> None: with sqlite3.connect(db_path) as conn: conn.execute( """ - CREATE TABLE IF NOT EXISTS guests ( + CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, - attending INTEGER, - plus_one INTEGER NOT NULL DEFAULT 0, + 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 ( @@ -293,23 +476,83 @@ def init_db() -> None: filename TEXT NOT NULL, uploaded_by INTEGER NOT NULL, uploaded_at TEXT NOT NULL, - FOREIGN KEY(uploaded_by) REFERENCES guests(id) + 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 "guest_id" not in session: + 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)) @@ -323,21 +566,6 @@ 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"} @@ -345,28 +573,33 @@ def health(): @app.get("/") def landing(): - if "guest_id" in session: + if "group_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 "" + group_name = (request.form.get("group_name") or "").strip() + group_password = request.form.get("group_password") or "" - if not name: - flash(t("flash_enter_name")) + if not group_name: + flash(t("flash_enter_group_name")) return redirect(url_for("landing")) - if event_password != app.config["EVENT_PASSWORD"]: - flash(t("flash_invalid_password")) + 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")) - guest_id = upsert_guest(name) - session["guest_id"] = guest_id - session["guest_name"] = name - session.pop("is_host", None) + session["group_id"] = int(group["id"]) + session["group_name"] = str(group["name"]) + session["role"] = str(group["role"] or "guest") return redirect(url_for("welcome")) @@ -396,21 +629,10 @@ def guest_area(): return render_template("guest_area.html") -@app.route("/gastgeberbereich", methods=["GET", "POST"]) +@app.route("/gastgeberbereich", methods=["GET"]) @login_required +@admin_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( """ @@ -418,16 +640,17 @@ def host_area(): 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 + SUM(CASE WHEN attending IS NULL THEN 1 ELSE 0 END) AS attending_open + FROM group_members """ ).fetchone() - guests = db.execute( + + members = db.execute( """ - SELECT name, attending, plus_one - FROM guests - ORDER BY name COLLATE NOCASE ASC + 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() @@ -436,9 +659,8 @@ def host_area(): "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) + return render_template("host_area.html", stats=stats, members=members) @app.get("/dashboard") @@ -451,33 +673,59 @@ def dashboard(): @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": - attending_raw = request.form.get("attending") - plus_one = 1 if request.form.get("plus_one") == "on" else 0 + 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")) + 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 + 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 - db.execute( - "UPDATE guests SET attending = ?, plus_one = ? WHERE id = ?", - (attending, plus_one, session["guest_id"]), + 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")) - guest = db.execute( - "SELECT attending, plus_one FROM guests WHERE id = ?", - (session["guest_id"],), - ).fetchone() - - return render_template("rsvp.html", guest=guest) + return render_template("rsvp.html", members=members) @app.route("/upload", methods=["GET", "POST"]) @@ -495,7 +743,6 @@ def upload(): flash(t("flash_allowed_types")) return redirect(url_for("upload")) mime_type = (file.mimetype or "").lower() - # Some mobile browsers may omit or vary MIME types for valid images. if mime_type and mime_type not in ALLOWED_MIME_TYPES: flash(t("flash_allowed_types")) return redirect(url_for("upload")) @@ -513,7 +760,7 @@ def 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, session["guest_id"], now)) + upload_rows.append((stored_name, int(session["group_id"]), now)) db.executemany( "INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)", @@ -539,9 +786,10 @@ def gallery(): db = get_db() images = db.execute( """ - SELECT uploads.id, uploads.filename, uploads.uploaded_by, uploads.uploaded_at, guests.name AS uploaded_by_name + SELECT uploads.id, uploads.filename, uploads.uploaded_by, uploads.uploaded_at, + COALESCE(groups.name, 'Unbekannt') AS uploaded_by_name FROM uploads - JOIN guests ON guests.id = uploads.uploaded_by + LEFT JOIN groups ON groups.id = uploads.uploaded_by ORDER BY uploads.id DESC """ ).fetchall() @@ -559,9 +807,9 @@ def delete_image(image_id: int): if image is None: return redirect(url_for("gallery")) - current_guest_id = int(session["guest_id"]) - is_host = bool(session.get("is_host")) - if not is_host and int(image["uploaded_by"]) != current_guest_id: + 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")) @@ -608,6 +856,8 @@ def impressum(): init_db() +with app.app_context(): + seed_default_groups() if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) diff --git a/backend/app.sqlite3 b/backend/app.sqlite3 index 711cf41..62f8ed5 100644 Binary files a/backend/app.sqlite3 and b/backend/app.sqlite3 differ diff --git a/backend/static/assets/location-map-preview.svg b/backend/static/assets/location-map-preview.svg new file mode 100644 index 0000000..8f2acb6 --- /dev/null +++ b/backend/static/assets/location-map-preview.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Klostermühle Kiedrich + An d. Klostermühle 3, 65399 Kiedrich + Kartenvorschau + + + + + + + + diff --git a/backend/static/styles.css b/backend/static/styles.css index 5bd1d33..62d8664 100644 --- a/backend/static/styles.css +++ b/backend/static/styles.css @@ -55,6 +55,8 @@ h3 { .toolbar { display: flex; gap: 0.4rem; + flex-wrap: wrap; + justify-content: flex-end; } .container { @@ -185,6 +187,89 @@ input[type="file"]:focus { gap: 0.45rem; } +.member-card { + border: 1px solid rgba(39, 66, 53, 0.14); + border-radius: 14px; + padding: 0.9rem; + background: #fff; +} + +.member-name { + margin: 0 0 0.45rem; + font-size: 1.15rem; +} + +.member-choice-row { + display: grid; + gap: 0.5rem; +} + +.member-age-wrap { + display: none; + margin-top: 0.5rem; +} + +.member-age-wrap.is-visible { + display: grid; +} + +.member-age-wrap small { + color: rgba(31, 31, 31, 0.68); +} + +.upload-card { + max-width: 760px; + margin-inline: auto; +} + +.upload-intro { + margin-top: -0.15rem; + margin-bottom: 0.3rem; + color: rgba(31, 31, 31, 0.82); +} + +.upload-picker { + display: grid; + gap: 0.25rem; + border: 1px dashed rgba(39, 66, 53, 0.35); + border-radius: 14px; + padding: 0.9rem 1rem; + background: rgba(255, 255, 255, 0.88); + cursor: pointer; + transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease; +} + +.upload-picker:hover { + border-color: rgba(39, 66, 53, 0.55); + background: #fff; +} + +.upload-picker:focus-within { + border-color: rgba(184, 145, 76, 0.88); + box-shadow: 0 0 0 3px rgba(184, 145, 76, 0.18); +} + +.upload-picker-title { + font-weight: 700; +} + +.upload-picker-subtitle { + color: rgba(31, 31, 31, 0.66); + font-size: 0.92rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .upload-hint { margin: -0.2rem 0 0.1rem; color: rgba(31, 31, 31, 0.72); @@ -193,6 +278,7 @@ input[type="file"]:focus { #extra-file-inputs { display: grid; + grid-template-columns: 1fr; gap: 0.65rem; } @@ -200,17 +286,61 @@ input[type="file"]:focus { display: grid; } +.upload-add-wrap { + display: grid; +} + +.upload-add-wrap.is-hidden { + display: none; +} + .upload-count { margin: 0.2rem 0 0; font-weight: 600; } +.upload-ready { + margin: -0.2rem 0 0; + color: rgba(39, 66, 53, 0.88); + font-weight: 600; + display: none; +} + +.upload-ready.is-visible { + display: block; +} + .upload-file-list { margin: 0; - padding-left: 1.1rem; + padding: 0; + list-style: none; color: rgba(31, 31, 31, 0.82); - max-height: 9rem; + max-height: 10rem; overflow: auto; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.upload-file-list li { + display: inline-flex; + align-items: center; + gap: 0.42rem; + border: 1px solid rgba(39, 66, 53, 0.2); + border-radius: 999px; + padding: 0.32rem 0.68rem; + background: rgba(255, 255, 255, 0.92); + font-size: 0.9rem; +} + +.upload-file-remove { + border: 0; + background: transparent; + color: rgba(39, 66, 53, 0.82); + font-size: 1rem; + line-height: 1; + cursor: pointer; + padding: 0; } .btn { @@ -233,6 +363,14 @@ input[type="file"]:focus { box-shadow: 0 10px 24px rgba(39, 66, 53, 0.2); } +.btn:disabled { + cursor: not-allowed; + background: rgba(39, 66, 53, 0.45); + box-shadow: none; + transform: none; + filter: none; +} + .btn-ghost { color: var(--forest); background: transparent; @@ -250,6 +388,16 @@ input[type="file"]:focus { font-weight: 600; } +.toolbar-nav-btn { + padding: 0.42rem 0.62rem; +} + +.toolbar-nav-btn svg { + width: 0.9rem; + height: 0.9rem; + fill: currentColor; +} + .flash { padding: 0.7rem 0.9rem; border-radius: 10px; @@ -501,6 +649,80 @@ input[type="file"]:focus { margin: 0.8rem 0; } +.map-consent { + display: grid; + gap: 0.7rem; + padding: 0.9rem; + border: 1px solid rgba(39, 66, 53, 0.14); + border-radius: 12px; + background: rgba(255, 255, 255, 0.7); +} + +.map-consent p { + margin: 0; +} + +.map-preview { + width: 100%; + aspect-ratio: 16 / 7; + min-height: 0; + border: 1px solid rgba(39, 66, 53, 0.16); + border-radius: 12px; + cursor: pointer; + background-color: rgba(221, 230, 225, 0.95); + background-image: + linear-gradient(180deg, rgba(28, 45, 37, 0.22), rgba(28, 45, 37, 0.22)), + var(--map-preview-image); + background-size: contain; + background-position: top left; + background-repeat: no-repeat; + display: grid; + place-items: center; + padding: 1rem; + transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease; +} + +.map-preview:hover { + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(39, 66, 53, 0.14); + filter: brightness(1.02); +} + +.map-preview-overlay { + font-weight: 700; + color: #fff; + background: rgba(39, 66, 53, 0.9); + border-radius: 999px; + padding: 0.48rem 0.82rem; +} + +.map-embed-target:not(:empty) { + margin-top: 0.2rem; +} + +.location-actions { + display: flex; + justify-content: flex-start; + margin-top: 0.55rem; + gap: 0.55rem; + flex-wrap: wrap; +} + +.location-actions .btn { + width: auto; + min-width: 0; + white-space: nowrap; + justify-content: center; + padding: 0.58rem 0.88rem; + font-size: 0.98rem; +} + +@media (max-width: 640px) { + .location-actions { + justify-content: flex-start; + } +} + .site-footer { border-top: 1px solid rgba(39, 66, 53, 0.12); background: rgba(255, 255, 255, 0.64); diff --git a/backend/templates/base.html b/backend/templates/base.html index 42b7337..bb2c5a9 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -22,6 +22,11 @@ {% if guest_name %} + + +
diff --git a/backend/templates/guest_area.html b/backend/templates/guest_area.html index c5bf85e..b26431f 100644 --- a/backend/templates/guest_area.html +++ b/backend/templates/guest_area.html @@ -13,6 +13,8 @@ {{ t('hotels') }} {{ t('taxi') }} {{ t('location') }} + {% if is_admin %} {{ t('host_area') }} + {% endif %} {% endblock %} diff --git a/backend/templates/host_area.html b/backend/templates/host_area.html index 865cf9f..f3bc664 100644 --- a/backend/templates/host_area.html +++ b/backend/templates/host_area.html @@ -1,21 +1,5 @@ {% extends 'base.html' %} {% block content %} -
-

{{ t('host_access_title') }}

-

{{ t('host_access_note') }}

-
- -{% if not unlocked %} -
-
- - -
-
-{% else %}

{{ t('total_guests') }}

@@ -33,10 +17,6 @@

{{ t('attending_open') }}

{{ stats.attending_open }}

-
-

{{ t('plus_one_total') }}

-

{{ stats.plus_one_total }}

-
@@ -45,29 +25,31 @@ - + + - + - {% for guest in guests %} + {% for member in members %} - + + @@ -76,5 +58,4 @@
{{ t('host_table_name') }}{{ t('host_table_group') }}{{ t('host_table_member') }} {{ t('host_table_status') }}{{ t('host_table_plus_one') }}{{ t('host_table_age') }}
{{ guest["name"] }}{{ member["group_name"] }}{{ member["name"] }} - {% if guest["attending"] == 1 %} + {% if member["attending"] == 1 %} {{ t('status_yes') }} - {% elif guest["attending"] == 0 %} + {% elif member["attending"] == 0 %} {{ t('status_no') }} {% else %} {{ t('status_open') }} {% endif %} - {% if guest["attending"] == 1 and guest["plus_one"] == 1 %} - {{ t('yes') }} + {% if member["child_age"] is not none %} + {{ member["child_age"] }} {% else %} - {{ t('no') }} + - {% endif %}
-{% endif %} {% endblock %} diff --git a/backend/templates/info.html b/backend/templates/info.html index a28a0c0..223f32b 100644 --- a/backend/templates/info.html +++ b/backend/templates/info.html @@ -17,15 +17,54 @@ {% elif page == 'location' %}

{{ location_name }}

{{ location_address }}

-
- + - {{ t('visit_location') }} + + {% endif %} {% endblock %} diff --git a/backend/templates/login.html b/backend/templates/login.html index dded186..a02c4a5 100644 --- a/backend/templates/login.html +++ b/backend/templates/login.html @@ -10,13 +10,13 @@

{{ t('login') }}

diff --git a/backend/templates/rsvp.html b/backend/templates/rsvp.html index efa8acd..9dda343 100644 --- a/backend/templates/rsvp.html +++ b/backend/templates/rsvp.html @@ -1,24 +1,82 @@ {% extends 'base.html' %} {% block content %} -
+

{{ t('rsvp') }}

+

{{ t('rsvp_members_intro') }}

- + {% for member in members %} +
+

{{ member['name'] }}

+
+ - + +
- + {% if member['requires_age'] == 1 %} + + {% endif %} +
+ {% endfor %}
+ + {% endblock %} diff --git a/backend/templates/upload.html b/backend/templates/upload.html index 003e3e0..d0c3684 100644 --- a/backend/templates/upload.html +++ b/backend/templates/upload.html @@ -1,77 +1,113 @@ {% extends 'base.html' %} {% block content %} -
+

{{ t('upload') }}

-
-
{% endblock %} diff --git a/data/db/app.sqlite3 b/data/db/app.sqlite3 index aa2d81d..edc7c16 100644 Binary files a/data/db/app.sqlite3 and b/data/db/app.sqlite3 differ diff --git a/docker-compose.yml b/docker-compose.yml index d46e700..99fab22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,3 +27,7 @@ services: - WEDDING_DATE=${WEDDING_DATE:-} - SECRET_KEY=${SECRET_KEY:-change-me-in-production} - MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-268435456} + - LOCATION_NAME=${LOCATION_NAME:-Klostermühle} + - LOCATION_ADDRESS=${LOCATION_ADDRESS:-An d. Klostermühle 3, 65399 Kiedrich} + - LOCATION_WEBSITE_URL=${LOCATION_WEBSITE_URL:-https://www.klostermuehle.de/} + - GOOGLE_MAPS_EMBED_URL=${GOOGLE_MAPS_EMBED_URL:-https://www.google.com/maps?q=Klostermuehle+Kiedrich+Eltville&output=embed}