Files
Wedding-Website/backend/app.py
2026-03-01 20:51:26 +00:00

864 lines
29 KiB
Python

import os
import sqlite3
import uuid
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.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["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": "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",
"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",
},
"en": {
"brand": "Svenja & Dominic",
"subtitle": "Welcome to our wedding app",
"login_note": "Password-protected access for our guests.",
"login": "Login",
"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",
"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",
},
}
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:
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"],
"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)