Files
Wedding-Website/backend/app.py
2026-03-03 18:54:41 +00:00

928 lines
32 KiB
Python

import os
import sqlite3
import uuid
from datetime import datetime
from functools import wraps
from pathlib import Path
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": "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",
"group_name": "Benutzername",
"group_password": "Passwort",
"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",
"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",
"hero_text": "Wir freuen uns riesig, diesen besonderen Tag mit euch zu feiern.",
"to_guest_area": "Zum Gästebereich",
"schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.",
"hotels_text": "Empfehlungen folgen. Bitte frühzeitig 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_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": "Login",
"group_name": "Username",
"group_password": "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",
"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",
"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_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": "Starts in",
"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()
@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"],
"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/<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():
return render_template("welcome.html")
@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/<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"}
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()
with app.app_context():
seed_default_groups()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)