Viele neue Features

This commit is contained in:
2026-03-01 13:01:46 +00:00
parent 04a0d2b54d
commit 832199a44d
13 changed files with 903 additions and 210 deletions

View File

@@ -17,6 +17,7 @@ from flask import (
session,
url_for,
)
from werkzeug.exceptions import RequestEntityTooLarge
from werkzeug.utils import secure_filename
app = Flask(__name__)
@@ -26,7 +27,7 @@ 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["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"
@@ -41,18 +42,27 @@ app.config["GOOGLE_MAPS_EMBED_URL"] = os.environ.get(
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"}
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",
}
TEXTS = {
"de": {
"brand": "Svenja & Dominic",
"subtitle": "Willkommen zu unserer Hochzeits-App",
"login_note": "Passwortgeschuetzter Zugriff fuer unsere Gaeste.",
"login_note": "Passwortgeschützter Zugriff für unsere Gäste.",
"login": "Login",
"name": "Dein Name",
"event_password": "Event-Passwort",
"login_submit": "Weiter zum Gaestebereich",
"guest_area": "Gaestebereich",
"login_submit": "Weiter zum Gästebereich",
"guest_area": "Gästebereich",
"hello_guest": "Hallo {name}.",
"logout": "Abmelden",
"rsvp": "RSVP",
@@ -65,6 +75,9 @@ TEXTS = {
"not_attending": "Ich komme nicht",
"plus_one": "Ich bringe eine Begleitperson mit",
"file": "Bild auswä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_submit": "Foto hochladen",
"schedule": "Ablauf",
"hotels": "Hotels",
@@ -75,27 +88,30 @@ TEXTS = {
"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",
"to_guest_area": "Zum Gästebereich",
"schedule_text": "15:00 Trauung, 17:00 Empfang, 19:00 Dinner.",
"hotels_text": "Empfehlungen folgen. Bitte fruehzeitig buchen.",
"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_name": "Bitte Namen eingeben.",
"flash_invalid_password": "Ungueltiges Event-Passwort.",
"flash_invalid_password": "Ungültiges 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_select_image": "Bitte eine Bilddatei auswählen.",
"flash_allowed_types": "Nur JPG/JPEG/PNG/HEIC/HEIF sind erlaubt.",
"flash_upload_success": "Upload erfolgreich.",
"flash_invalid_host_password": "Ungueltiges Gastgeber-Passwort.",
"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 fuer das Brautpaar vorgesehen.",
"host_access_note": "Dieser Bereich ist nur für das Brautpaar vorgesehen.",
"host_password": "Gastgeber-Passwort",
"host_access_submit": "Adminbereich oeffnen",
"host_stats_title": "Uebersicht",
"total_guests": "Gaeste gesamt",
"host_access_submit": "Adminbereich öffnen",
"host_stats_title": "Übersicht",
"total_guests": "Gäste gesamt",
"attending_yes": "Zusagen",
"attending_no": "Absagen",
"attending_open": "Noch offen",
@@ -109,6 +125,10 @@ TEXTS = {
"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.",
},
"en": {
"brand": "Svenja & Dominic",
@@ -131,6 +151,9 @@ TEXTS = {
"not_attending": "I cannot attend",
"plus_one": "I will bring a plus-one",
"file": "Select image",
"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_submit": "Upload photo",
"schedule": "Schedule",
"hotels": "Hotels",
@@ -153,8 +176,11 @@ TEXTS = {
"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_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.",
@@ -175,6 +201,10 @@ TEXTS = {
"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.",
},
}
@@ -210,6 +240,8 @@ def inject_common() -> dict:
"t": t,
"lang": get_lang(),
"guest_name": session.get("guest_name"),
"guest_id": session.get("guest_id"),
"is_host": bool(session.get("is_host")),
"location_name": app.config["LOCATION_NAME"],
"location_address": app.config["LOCATION_ADDRESS"],
"location_website_url": app.config["LOCATION_WEBSITE_URL"],
@@ -278,6 +310,15 @@ def login_required(view):
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
@@ -349,6 +390,7 @@ def welcome():
@app.get("/gaestebereich")
@app.get("/gästebereich")
@login_required
def guest_area():
return render_template("guest_area.html")
@@ -442,33 +484,52 @@ def rsvp():
@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"))
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()
# 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"))
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, session["guest_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"))
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")
@@ -478,7 +539,7 @@ def gallery():
db = get_db()
images = db.execute(
"""
SELECT uploads.filename, uploads.uploaded_at, guests.name AS uploaded_by
SELECT uploads.id, uploads.filename, uploads.uploaded_by, uploads.uploaded_at, guests.name AS uploaded_by_name
FROM uploads
JOIN guests ON guests.id = uploads.uploaded_by
ORDER BY uploads.id DESC
@@ -487,10 +548,44 @@ def gallery():
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_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:
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):
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
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>")