Massiv +
This commit is contained in:
496
backend/app.py
496
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)
|
||||
|
||||
Reference in New Issue
Block a user