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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef9deaeff7
commit
6f7e4f92ca
@@ -0,0 +1,124 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user