- Recipe model with ingredients (amount/range, unit, group, note), steps, lowercase tags, author notes, source URL and private/public visibility - Central RecipeQuerySet.visible_to() used by every read path; edit only by owner, delete by owner or admin (visible recipes only) - Text-based editing: ingredient/step parser with roundtrip and scaling - Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF, thumbnails, cover image, served only through permission-checked view - Search across title, description, ingredients, tags; ingredient and tag filters; "mine" and favourites views - Admin can delete all recipes of a deactivated user - Acceptance tests: private recipes unreachable for others and admins in list, search, tags, favourites, detail, edit, delete and image URLs Co-Authored-By: Claude Opus 5.5 <[email protected]>
125 lines
4.0 KiB
Python
125 lines
4.0 KiB
Python
"""Validation and processing of uploaded photos.
|
|
|
|
Uploads are decoded with Pillow (HEIC via pillow-heif), auto-rotated, stripped of all
|
|
metadata (EXIF/GPS) by re-encoding, downscaled and stored as WebP under MEDIA_ROOT.
|
|
The original upload is never stored.
|
|
"""
|
|
|
|
import io
|
|
import threading
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import pillow_heif
|
|
from django.conf import settings
|
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
|
|
|
pillow_heif.register_heif_opener()
|
|
|
|
ALLOWED_FORMATS = {"JPEG", "MPO", "PNG", "WEBP", "HEIF"}
|
|
MAX_PIXELS = 50_000_000
|
|
LARGE_SIZE = 1600
|
|
THUMB_SIZE = 480
|
|
AI_SIZE = 2000
|
|
|
|
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
|
|
|
|
# Decoding a 12 MP photo needs ~100 MB; one at a time per process keeps memory predictable.
|
|
_processing = threading.BoundedSemaphore(1)
|
|
|
|
|
|
class ImageError(ValueError):
|
|
pass
|
|
|
|
|
|
def max_upload_bytes():
|
|
return settings.IMAGE_MAX_UPLOAD_MB * 1024 * 1024
|
|
|
|
|
|
def _open(fileobj):
|
|
size = getattr(fileobj, "size", None)
|
|
if size is not None and size > max_upload_bytes():
|
|
raise ImageError(f"Das Bild ist zu groß (max. {settings.IMAGE_MAX_UPLOAD_MB} MB).")
|
|
try:
|
|
fileobj.seek(0)
|
|
img = Image.open(fileobj)
|
|
except (UnidentifiedImageError, OSError, Image.DecompressionBombError):
|
|
raise ImageError("Die Datei ist kein unterstütztes Bild (JPEG, PNG, WebP oder HEIC).") from None
|
|
if img.format not in ALLOWED_FORMATS:
|
|
raise ImageError("Die Datei ist kein unterstütztes Bild (JPEG, PNG, WebP oder HEIC).")
|
|
if img.width * img.height > MAX_PIXELS:
|
|
raise ImageError("Das Bild hat zu viele Pixel.")
|
|
return img
|
|
|
|
|
|
def validate(fileobj):
|
|
"""Cheap check (header only) used during form validation."""
|
|
_open(fileobj)
|
|
fileobj.seek(0)
|
|
|
|
|
|
def _decode(fileobj, target):
|
|
img = _open(fileobj)
|
|
if img.format == "JPEG":
|
|
img.draft("RGB", (target * 2, target * 2)) # decode at reduced size: much less memory
|
|
try:
|
|
img.load()
|
|
except (OSError, Image.DecompressionBombError):
|
|
raise ImageError("Das Bild ist beschädigt oder unvollständig.") from None
|
|
img = ImageOps.exif_transpose(img)
|
|
if img.mode in ("RGBA", "LA", "P"):
|
|
img = img.convert("RGBA")
|
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
|
background.paste(img, mask=img.getchannel("A"))
|
|
img = background
|
|
elif img.mode != "RGB":
|
|
img = img.convert("RGB")
|
|
return img
|
|
|
|
|
|
def _media_path(relative):
|
|
path = Path(settings.MEDIA_ROOT) / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def process_upload(fileobj):
|
|
"""Store a large and a thumbnail WebP. Returns dict with file, thumb, width, height."""
|
|
with _processing:
|
|
img = _decode(fileobj, LARGE_SIZE)
|
|
name = uuid.uuid4().hex
|
|
large = img.copy()
|
|
large.thumbnail((LARGE_SIZE, LARGE_SIZE), Image.LANCZOS)
|
|
thumb = img.copy()
|
|
thumb.thumbnail((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS)
|
|
result = {"file": f"recipes/{name}.webp", "thumb": f"recipes/{name}_t.webp",
|
|
"width": large.width, "height": large.height}
|
|
large.save(_media_path(result["file"]), "WEBP", quality=82, method=4)
|
|
thumb.save(_media_path(result["thumb"]), "WEBP", quality=78, method=4)
|
|
return result
|
|
|
|
|
|
def prepare_for_ai(fileobj):
|
|
"""Downscaled JPEG bytes (no metadata) for sending to a vision model."""
|
|
with _processing:
|
|
img = _decode(fileobj, AI_SIZE)
|
|
img.thumbnail((AI_SIZE, AI_SIZE), Image.LANCZOS)
|
|
out = io.BytesIO()
|
|
img.save(out, "JPEG", quality=85)
|
|
return out.getvalue()
|
|
|
|
|
|
def delete_files(*relative_paths):
|
|
for relative in relative_paths:
|
|
if relative:
|
|
(Path(settings.MEDIA_ROOT) / relative).unlink(missing_ok=True)
|
|
|
|
|
|
def file_path(relative):
|
|
"""Absolute path of a stored file; refuses anything outside MEDIA_ROOT."""
|
|
root = Path(settings.MEDIA_ROOT).resolve()
|
|
path = (root / relative).resolve()
|
|
if root not in path.parents:
|
|
raise ImageError("Ungültiger Pfad.")
|
|
return path
|