Files
Wedding-Website/backend/app.py

519 lines
16 KiB
Python

import os
import sqlite3
import uuid
from hmac import compare_digest
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.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["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(8 * 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"}
TEXTS = {
"de": {
"brand": "Svenja & Dominic",
"subtitle": "Willkommen zu unserer Hochzeits-App",
"login_note": "Passwortgeschuetzter Zugriff fuer unsere Gaeste.",
"login": "Login",
"name": "Dein Name",
"event_password": "Event-Passwort",
"login_submit": "Weiter zum Gaestebereich",
"guest_area": "Gaestebereich",
"hello_guest": "Hallo {name}.",
"logout": "Abmelden",
"rsvp": "RSVP",
"upload": "Upload",
"gallery": "Galerie",
"host_area": "Gastgeberbereich",
"info": "Infos",
"save": "Speichern",
"attending": "Ich komme",
"not_attending": "Ich komme nicht",
"plus_one": "Ich bringe eine Begleitperson mit",
"file": "Bild auswählen",
"upload_submit": "Foto hochladen",
"schedule": "Ablauf",
"hotels": "Hotels",
"taxi": "Taxi",
"location": "Location",
"visit_location": "Zur Location-Webseite",
"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 Gaestebereich",
"schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.",
"hotels_text": "Empfehlungen folgen. Bitte fruehzeitig 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_name": "Bitte Namen eingeben.",
"flash_invalid_password": "Ungueltiges Event-Passwort.",
"flash_rsvp_select": "Bitte eine RSVP-Auswahl treffen.",
"flash_rsvp_saved": "RSVP gespeichert.",
"flash_select_image": "Bitte eine Bilddatei auswaehlen.",
"flash_allowed_types": "Nur JPG/JPEG/PNG sind erlaubt.",
"flash_upload_success": "Upload erfolgreich.",
"flash_invalid_host_password": "Ungueltiges Gastgeber-Passwort.",
"host_access_title": "Gastgeberbereich",
"host_access_note": "Dieser Bereich ist nur fuer das Brautpaar vorgesehen.",
"host_password": "Gastgeber-Passwort",
"host_access_submit": "Adminbereich oeffnen",
"host_stats_title": "Uebersicht",
"total_guests": "Gaeste gesamt",
"attending_yes": "Zusagen",
"attending_no": "Absagen",
"attending_open": "Noch offen",
"plus_one_total": "Begleitpersonen",
"host_table_name": "Name",
"host_table_status": "RSVP",
"host_table_plus_one": "Begleitperson",
"status_yes": "Kommt",
"status_no": "Kommt nicht",
"status_open": "Offen",
"yes": "Ja",
"no": "Nein",
"legal_de_authoritative": "Bei rechtlichen Unklarheiten gilt die deutsche Fassung.",
},
"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",
"login_submit": "Open guest area",
"guest_area": "Guest Area",
"hello_guest": "Hello {name}.",
"logout": "Logout",
"rsvp": "RSVP",
"upload": "Upload",
"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",
"file": "Select image",
"upload_submit": "Upload photo",
"schedule": "Schedule",
"hotels": "Hotels",
"taxi": "Taxi",
"location": "Location",
"visit_location": "Visit location website",
"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_name": "Please enter your name.",
"flash_invalid_password": "Invalid event password.",
"flash_rsvp_select": "Please choose an RSVP option.",
"flash_rsvp_saved": "RSVP saved.",
"flash_select_image": "Please select an image file.",
"flash_allowed_types": "Only JPG/JPEG/PNG are allowed.",
"flash_upload_success": "Upload successful.",
"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_status": "RSVP",
"host_table_plus_one": "Plus-one",
"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.",
},
}
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:
return {
"t": t,
"lang": get_lang(),
"guest_name": session.get("guest_name"),
"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 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 guests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
attending INTEGER,
plus_one INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
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 guests(id)
)
"""
)
conn.commit()
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if "guest_id" not in session:
return redirect(url_for("landing"))
return view(*args, **kwargs)
return wrapped
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"}
@app.get("/")
def landing():
if "guest_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 ""
if not name:
flash(t("flash_enter_name"))
return redirect(url_for("landing"))
if event_password != app.config["EVENT_PASSWORD"]:
flash(t("flash_invalid_password"))
return redirect(url_for("landing"))
guest_id = upsert_guest(name)
session["guest_id"] = guest_id
session["guest_name"] = name
session.pop("is_host", None)
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")
@login_required
def guest_area():
return render_template("guest_area.html")
@app.route("/gastgeberbereich", methods=["GET", "POST"])
@login_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(
"""
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,
SUM(CASE WHEN attending = 1 AND plus_one = 1 THEN 1 ELSE 0 END) AS plus_one_total
FROM guests
"""
).fetchone()
guests = db.execute(
"""
SELECT name, attending, plus_one
FROM guests
ORDER BY 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),
"plus_one_total": int(stats_row["plus_one_total"] or 0),
}
return render_template("host_area.html", unlocked=True, stats=stats, guests=guests)
@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()
if request.method == "POST":
attending_raw = request.form.get("attending")
plus_one = 1 if request.form.get("plus_one") == "on" else 0
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
db.execute(
"UPDATE guests SET attending = ?, plus_one = ? WHERE id = ?",
(attending, plus_one, session["guest_id"]),
)
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)
@app.route("/upload", methods=["GET", "POST"])
@login_required
def upload():
if request.method == "POST":
file = request.files.get("photo")
if file is None or file.filename == "":
flash(t("flash_select_image"))
return redirect(url_for("upload"))
if not is_allowed_file(file.filename):
flash(t("flash_allowed_types"))
return redirect(url_for("upload"))
safe_name = secure_filename(file.filename)
ext = safe_name.rsplit(".", 1)[1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
upload_dir = app.config["UPLOAD_FOLDER"]
os.makedirs(upload_dir, exist_ok=True)
file.save(os.path.join(upload_dir, stored_name))
db = get_db()
db.execute(
"INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)",
(stored_name, session["guest_id"], datetime.utcnow().isoformat()),
)
db.commit()
flash(t("flash_upload_success"))
return redirect(url_for("gallery"))
return render_template("upload.html")
@app.get("/gallery")
@login_required
def gallery():
db = get_db()
images = db.execute(
"""
SELECT uploads.filename, uploads.uploaded_at, guests.name AS uploaded_by
FROM uploads
JOIN guests ON guests.id = uploads.uploaded_by
ORDER BY uploads.id DESC
"""
).fetchall()
return render_template("gallery.html", images=images)
@app.get("/uploads/<path:filename>")
@login_required
def serve_upload(filename: str):
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
@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()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)