Bugfix + Feat Besseres Laden + Bildervorschau und Uploadbalken + Popup wirklich bild löschen?
This commit is contained in:
+122
-21
@@ -7,10 +7,12 @@ from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from flask import (
|
||||
Flask,
|
||||
flash,
|
||||
g,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
@@ -27,6 +29,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["THUMBNAIL_FOLDER"] = os.environ.get(
|
||||
"THUMBNAIL_FOLDER",
|
||||
str(Path(app.config["UPLOAD_FOLDER"]) / "thumbnails"),
|
||||
)
|
||||
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", "Hochheimer Terrasse")
|
||||
app.config["LOCATION_ADDRESS"] = os.environ.get("LOCATION_ADDRESS", "Mainzer Str. 22, 65239 Hochheim am Main")
|
||||
@@ -41,16 +47,19 @@ app.config["WEDDING_COUNTDOWN_LOCAL"] = os.environ.get("WEDDING_COUNTDOWN_LOCAL"
|
||||
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_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "heic", "heif"}
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/heic-sequence",
|
||||
"image/heif-sequence",
|
||||
}
|
||||
THUMBNAIL_EXTENSIONS = {"jpg", "jpeg", "png"}
|
||||
THUMBNAIL_MAX_SIZE = (900, 900)
|
||||
|
||||
LOCATION_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".m4v"}
|
||||
ONBOARDING_VERSION = 2
|
||||
@@ -322,7 +331,11 @@ TEXTS = {
|
||||
"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",
|
||||
"upload_submit": "Bilder hochladen",
|
||||
"upload_progress": "{uploaded} von {total} Bildern hochgeladen",
|
||||
"upload_in_progress": "Upload läuft – bitte diese Seite geöffnet lassen.",
|
||||
"upload_retry": "Upload fortsetzen",
|
||||
"upload_batch_failed": "Der Upload wurde unterbrochen. Bereits hochgeladene Bilder bleiben gespeichert.",
|
||||
"schedule": "Ablauf",
|
||||
"hotels": "Hotels",
|
||||
"taxi": "Genuss",
|
||||
@@ -476,6 +489,9 @@ TEXTS = {
|
||||
"gallery_uploader_selected": "{count} ausgewählt",
|
||||
"gallery_image_alt": "Upload von {name}",
|
||||
"gallery_save_hint": "Tipp: Halte das Bild länger gedrückt, um es in deiner Mediathek zu speichern.",
|
||||
"gallery_delete_title": "Bild löschen",
|
||||
"gallery_delete_confirm": "Möchtest du dieses Bild wirklich löschen?",
|
||||
"gallery_delete_cancel": "Abbrechen",
|
||||
"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.",
|
||||
@@ -483,7 +499,7 @@ TEXTS = {
|
||||
"flash_rsvp_age_invalid": "Bitte ein gültiges Alter (0-17) für {name} angeben.",
|
||||
"flash_rsvp_saved": "Antwort gespeichert.",
|
||||
"flash_select_image": "Bitte eine Bilddatei auswählen.",
|
||||
"flash_allowed_types": "Nur JPG/JPEG/PNG/HEIC/HEIF sind erlaubt.",
|
||||
"flash_allowed_types": "Nur JPG/JPEG/PNG/GIF/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.",
|
||||
@@ -604,7 +620,11 @@ TEXTS = {
|
||||
"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",
|
||||
"upload_submit": "Upload photos",
|
||||
"upload_progress": "{uploaded} of {total} images uploaded",
|
||||
"upload_in_progress": "Upload in progress – please keep this page open.",
|
||||
"upload_retry": "Continue upload",
|
||||
"upload_batch_failed": "The upload was interrupted. Images already uploaded remain saved.",
|
||||
"schedule": "Schedule",
|
||||
"hotels": "Hotels",
|
||||
"taxi": "Culinary Delights",
|
||||
@@ -758,6 +778,9 @@ TEXTS = {
|
||||
"gallery_uploader_selected": "{count} selected",
|
||||
"gallery_image_alt": "Uploaded by {name}",
|
||||
"gallery_save_hint": "Tip: Touch and hold the photo to save it to your photo library.",
|
||||
"gallery_delete_title": "Delete photo",
|
||||
"gallery_delete_confirm": "Are you sure you want to delete this photo?",
|
||||
"gallery_delete_cancel": "Cancel",
|
||||
"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.",
|
||||
@@ -765,7 +788,7 @@ TEXTS = {
|
||||
"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_allowed_types": "Only JPG/JPEG/PNG/GIF/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.",
|
||||
@@ -1171,7 +1194,10 @@ def admin_required(view):
|
||||
@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))
|
||||
message = t("flash_upload_too_large").format(max_mb=max_mb)
|
||||
if request.headers.get("X-Upload-Batch") == "1":
|
||||
return jsonify(ok=False, error=message), 413
|
||||
flash(message)
|
||||
if request.method == "POST":
|
||||
return redirect(url_for("upload"))
|
||||
return redirect(url_for("landing"))
|
||||
@@ -1181,6 +1207,56 @@ def is_allowed_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def upload_error_response(message: str, status: int = 400):
|
||||
if request.headers.get("X-Upload-Batch") == "1":
|
||||
return jsonify(ok=False, error=message), status
|
||||
flash(message)
|
||||
return redirect(url_for("upload"))
|
||||
|
||||
|
||||
def thumbnail_filename(filename: str) -> str | None:
|
||||
if Path(filename).name != filename or "." not in filename:
|
||||
return None
|
||||
extension = filename.rsplit(".", 1)[1].lower()
|
||||
if extension not in THUMBNAIL_EXTENSIONS:
|
||||
return None
|
||||
return f"{filename.rsplit('.', 1)[0]}.webp"
|
||||
|
||||
|
||||
def ensure_thumbnail(filename: str) -> str | None:
|
||||
thumbnail_name = thumbnail_filename(filename)
|
||||
if thumbnail_name is None:
|
||||
return None
|
||||
|
||||
source_path = Path(app.config["UPLOAD_FOLDER"]) / filename
|
||||
thumbnail_dir = Path(app.config["THUMBNAIL_FOLDER"])
|
||||
thumbnail_path = thumbnail_dir / thumbnail_name
|
||||
if thumbnail_path.is_file():
|
||||
return thumbnail_name
|
||||
if not source_path.is_file():
|
||||
return None
|
||||
|
||||
thumbnail_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary_path = thumbnail_dir / f".{thumbnail_name}.{uuid.uuid4().hex}.tmp"
|
||||
try:
|
||||
with Image.open(source_path) as source_image:
|
||||
preview = ImageOps.exif_transpose(source_image)
|
||||
if preview.mode not in {"RGB", "RGBA"}:
|
||||
preview = preview.convert("RGBA" if "transparency" in preview.info else "RGB")
|
||||
preview.thumbnail(THUMBNAIL_MAX_SIZE, Image.Resampling.LANCZOS)
|
||||
preview.save(temporary_path, format="WEBP", quality=84, method=4)
|
||||
os.replace(temporary_path, thumbnail_path)
|
||||
return thumbnail_name
|
||||
except (OSError, UnidentifiedImageError, ValueError):
|
||||
app.logger.warning("Could not create thumbnail for %s", filename, exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
try:
|
||||
temporary_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -1409,34 +1485,35 @@ def rsvp():
|
||||
@login_required
|
||||
def upload():
|
||||
if request.method == "POST":
|
||||
saved_paths = []
|
||||
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"))
|
||||
return upload_error_response(t("flash_select_image"))
|
||||
|
||||
validated_files = []
|
||||
for file in files:
|
||||
if not is_allowed_file(file.filename):
|
||||
flash(t("flash_allowed_types"))
|
||||
return redirect(url_for("upload"))
|
||||
return upload_error_response(t("flash_allowed_types"))
|
||||
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"))
|
||||
return upload_error_response(t("flash_allowed_types"))
|
||||
safe_name = secure_filename(file.filename)
|
||||
if "." not in safe_name:
|
||||
return upload_error_response(t("flash_allowed_types"))
|
||||
validated_files.append((file, safe_name.rsplit(".", 1)[1].lower()))
|
||||
|
||||
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()
|
||||
for file, ext in validated_files:
|
||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
file.save(os.path.join(upload_dir, stored_name))
|
||||
stored_path = os.path.join(upload_dir, stored_name)
|
||||
file.save(stored_path)
|
||||
saved_paths.append(stored_path)
|
||||
ensure_thumbnail(stored_name)
|
||||
upload_rows.append((stored_name, int(session["group_id"]), now))
|
||||
|
||||
db.executemany(
|
||||
@@ -1445,14 +1522,20 @@ def upload():
|
||||
)
|
||||
db.commit()
|
||||
|
||||
if request.headers.get("X-Upload-Batch") == "1":
|
||||
return jsonify(ok=True, count=len(upload_rows))
|
||||
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"))
|
||||
for saved_path in saved_paths:
|
||||
try:
|
||||
os.remove(saved_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return upload_error_response(t("flash_upload_failed"), 500)
|
||||
|
||||
return render_template("upload.html")
|
||||
|
||||
@@ -1500,6 +1583,11 @@ def delete_image(image_id: int):
|
||||
file_path = os.path.join(app.config["UPLOAD_FOLDER"], image["filename"])
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
thumbnail_name = thumbnail_filename(str(image["filename"]))
|
||||
if thumbnail_name:
|
||||
thumbnail_path = os.path.join(app.config["THUMBNAIL_FOLDER"], thumbnail_name)
|
||||
if os.path.isfile(thumbnail_path):
|
||||
os.remove(thumbnail_path)
|
||||
|
||||
flash(t("flash_image_deleted"))
|
||||
return redirect(url_for("gallery"))
|
||||
@@ -1517,6 +1605,19 @@ def serve_upload(filename: str):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/uploads/thumbnails/<path:filename>")
|
||||
@login_required
|
||||
def serve_thumbnail(filename: str):
|
||||
generated_name = ensure_thumbnail(filename)
|
||||
if generated_name:
|
||||
return send_from_directory(
|
||||
app.config["THUMBNAIL_FOLDER"],
|
||||
generated_name,
|
||||
max_age=31536000,
|
||||
)
|
||||
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
||||
|
||||
|
||||
@app.get("/info/<page>")
|
||||
@login_required
|
||||
def info(page: str):
|
||||
|
||||
Reference in New Issue
Block a user