Viele neue Features
This commit is contained in:
181
backend/app.py
181
backend/app.py
@@ -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>")
|
||||
|
||||
Reference in New Issue
Block a user