Files
Kyle DammannandClaude Opus 5.5 6f7e4f92ca Add phase 2: recipes with visibility, photos, search and favourites
- 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]>
2026-10-30 21:37:13 +01:00

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