928 lines
32 KiB
Python
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": "Zu-/Absage",
|
|
"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)
|