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,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RecipesConfig(AppConfig):
|
||||
name = "recipes"
|
||||
verbose_name = "Rezepte"
|
||||
@@ -0,0 +1,137 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from . import images, parsing
|
||||
from .models import Recipe
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
allow_multiple_selected = True
|
||||
|
||||
|
||||
class MultipleImageField(forms.FileField):
|
||||
widget = MultipleFileInput
|
||||
|
||||
def clean(self, data, initial=None):
|
||||
files = data if isinstance(data, (list, tuple)) else ([data] if data else [])
|
||||
if len(files) > settings.IMAGE_MAX_PER_RECIPE:
|
||||
raise forms.ValidationError(f"Höchstens {settings.IMAGE_MAX_PER_RECIPE} Bilder auf einmal.")
|
||||
cleaned = []
|
||||
for f in files:
|
||||
f = super().clean(f, initial)
|
||||
try:
|
||||
images.validate(f)
|
||||
except images.ImageError as error:
|
||||
raise forms.ValidationError(f"{f.name}: {error}") from None
|
||||
cleaned.append(f)
|
||||
return cleaned
|
||||
|
||||
|
||||
def photo_field(label="Fotos"):
|
||||
return MultipleImageField(
|
||||
label=label,
|
||||
required=False,
|
||||
widget=MultipleFileInput(attrs={"accept": "image/*"}),
|
||||
help_text=f"JPEG, PNG, WebP oder HEIC, max. {settings.IMAGE_MAX_UPLOAD_MB} MB pro Bild. "
|
||||
"Am Handy kannst du direkt die Kamera wählen.",
|
||||
)
|
||||
|
||||
|
||||
class RecipeForm(forms.ModelForm):
|
||||
ingredients_text = forms.CharField(
|
||||
label="Zutaten",
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 10, "placeholder": "200 g Mehl\n2 Eier\n1 Prise Salz\n\nFür die Soße:\n1 Dose Tomaten"}),
|
||||
help_text="Eine Zutat pro Zeile. Gruppen mit Doppelpunkt am Ende, z. B. „Für die Soße:“.",
|
||||
)
|
||||
steps_text = forms.CharField(
|
||||
label="Zubereitung",
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 10, "placeholder": "Ofen auf 180 °C vorheizen.\n\nMehl und Eier verrühren."}),
|
||||
help_text="Schritte durch eine Leerzeile trennen (ohne Leerzeilen ist jede Zeile ein Schritt).",
|
||||
)
|
||||
tags_text = forms.CharField(
|
||||
label="Tags",
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={"placeholder": "pasta, vegetarisch, schnell", "autocapitalize": "none"}),
|
||||
help_text="Kommagetrennt, werden kleingeschrieben gespeichert.",
|
||||
)
|
||||
source_url = forms.URLField(label="Quelle (URL)", required=False, max_length=1000, assume_scheme="https")
|
||||
photos = photo_field()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ["title", "description", "servings", "servings_label", "prep_minutes", "cook_minutes",
|
||||
"visibility", "source_url", "author_notes"]
|
||||
widgets = {
|
||||
"description": forms.Textarea(attrs={"rows": 3}),
|
||||
"author_notes": forms.Textarea(attrs={"rows": 3, "placeholder": "Tipps, Abwandlungen, was beim nächsten Mal anders …"}),
|
||||
"servings": forms.NumberInput(attrs={"min": 1, "max": 100, "inputmode": "numeric"}),
|
||||
"prep_minutes": forms.NumberInput(attrs={"min": 0, "inputmode": "numeric"}),
|
||||
"cook_minutes": forms.NumberInput(attrs={"min": 0, "inputmode": "numeric"}),
|
||||
"visibility": forms.RadioSelect,
|
||||
}
|
||||
|
||||
field_order = ["title", "description", "servings", "servings_label", "prep_minutes", "cook_minutes",
|
||||
"ingredients_text", "steps_text", "tags_text", "photos", "author_notes", "source_url", "visibility"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["servings"].min_value = 1
|
||||
recipe = self.instance
|
||||
if recipe.pk and not self.is_bound:
|
||||
self.initial["ingredients_text"] = parsing.ingredients_to_text(recipe.ingredients.all())
|
||||
self.initial["steps_text"] = parsing.steps_to_text([s.text for s in recipe.steps.all()])
|
||||
self.initial["tags_text"] = ", ".join(t.name for t in recipe.tags.all())
|
||||
if recipe.pk:
|
||||
remaining = settings.IMAGE_MAX_PER_RECIPE - recipe.images.count()
|
||||
self.fields["photos"].label = "Weitere Fotos"
|
||||
if remaining <= 0:
|
||||
del self.fields["photos"]
|
||||
|
||||
def clean_servings(self):
|
||||
servings = self.cleaned_data["servings"]
|
||||
if not servings or servings < 1 or servings > 100:
|
||||
raise forms.ValidationError("Bitte eine Zahl zwischen 1 und 100 angeben.")
|
||||
return servings
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
cleaned["ingredients"] = parsing.parse_ingredients(cleaned.get("ingredients_text", ""))
|
||||
cleaned["steps"] = parsing.parse_steps(cleaned.get("steps_text", ""))
|
||||
cleaned["tags"] = parsing.parse_tags(cleaned.get("tags_text", ""))
|
||||
photos = cleaned.get("photos") or []
|
||||
if self.instance.pk and photos:
|
||||
if self.instance.images.count() + len(photos) > settings.IMAGE_MAX_PER_RECIPE:
|
||||
self.add_error("photos", f"Ein Rezept kann höchstens {settings.IMAGE_MAX_PER_RECIPE} Bilder haben.")
|
||||
return cleaned
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, owner=None):
|
||||
recipe = super().save(commit=False)
|
||||
if owner is not None and not recipe.pk:
|
||||
recipe.owner = owner
|
||||
recipe.save()
|
||||
recipe.set_ingredients(self.cleaned_data["ingredients"])
|
||||
recipe.set_steps(self.cleaned_data["steps"])
|
||||
recipe.set_tags(self.cleaned_data["tags"])
|
||||
return recipe
|
||||
|
||||
|
||||
class PhotoUploadForm(forms.Form):
|
||||
photos = photo_field("Fotos hinzufügen")
|
||||
|
||||
|
||||
class SearchForm(forms.Form):
|
||||
q = forms.CharField(label="Suche", required=False, widget=forms.SearchInput(attrs={"placeholder": "Titel, Zutat, Tag …"}))
|
||||
zutat = forms.CharField(label="Mit Zutaten", required=False, widget=forms.TextInput(attrs={"placeholder": "z. B. zucchini, feta"}))
|
||||
tag = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
ansicht = forms.ChoiceField(
|
||||
label="Anzeigen",
|
||||
required=False,
|
||||
choices=[("", "Alle Rezepte"), ("meine", "Meine Rezepte"), ("favoriten", "Favoriten")],
|
||||
)
|
||||
|
||||
def ingredient_terms(self):
|
||||
return [t.strip() for t in self.cleaned_data.get("zutat", "").split(",") if t.strip()]
|
||||
@@ -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
|
||||
@@ -0,0 +1,114 @@
|
||||
# Generated by Django 5.2.18 on 2026-10-30 20:33
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=40, unique=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Recipe',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='Titel')),
|
||||
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
|
||||
('servings', models.PositiveSmallIntegerField(default=4, verbose_name='Portionen')),
|
||||
('servings_label', models.CharField(blank=True, default='Portionen', max_length=30, verbose_name='Einheit der Portionen')),
|
||||
('prep_minutes', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Zubereitungszeit (Min.)')),
|
||||
('cook_minutes', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Koch-/Backzeit (Min.)')),
|
||||
('source_url', models.URLField(blank=True, max_length=1000, verbose_name='Quelle (URL)')),
|
||||
('author_notes', models.TextField(blank=True, verbose_name='Notizen')),
|
||||
('visibility', models.CharField(choices=[('private', 'Privat (nur für mich)'), ('public', 'Öffentlich (alle Nutzer)')], default='private', max_length=10, verbose_name='Sichtbarkeit')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Geändert')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipes', to=settings.AUTH_USER_MODEL, verbose_name='Autor')),
|
||||
('tags', models.ManyToManyField(blank=True, related_name='recipes', to='recipes.tag')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Ingredient',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('position', models.PositiveSmallIntegerField(default=0)),
|
||||
('group', models.CharField(blank=True, max_length=100)),
|
||||
('amount', models.DecimalField(blank=True, decimal_places=3, max_digits=10, null=True)),
|
||||
('amount_max', models.DecimalField(blank=True, decimal_places=3, max_digits=10, null=True)),
|
||||
('unit', models.CharField(blank=True, max_length=30)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('note', models.CharField(blank=True, max_length=200)),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='recipes.recipe')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['position'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RecipeImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('file', models.CharField(max_length=200)),
|
||||
('thumb', models.CharField(max_length=200)),
|
||||
('width', models.PositiveIntegerField(default=0)),
|
||||
('height', models.PositiveIntegerField(default=0)),
|
||||
('is_cover', models.BooleanField(default=False)),
|
||||
('position', models.PositiveSmallIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='recipes.recipe')),
|
||||
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-is_cover', 'position', 'created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Step',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('position', models.PositiveSmallIntegerField(default=0)),
|
||||
('text', models.TextField()),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='recipes.recipe')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['position'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Favorite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL)),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='recipes.recipe')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'recipe'), name='favorite_unique')],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='recipe',
|
||||
index=models.Index(fields=['visibility', 'owner'], name='recipes_rec_visibil_1325f0_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from . import parsing
|
||||
|
||||
|
||||
class RecipeQuerySet(models.QuerySet):
|
||||
def visible_to(self, user):
|
||||
"""THE access rule: public recipes for every logged-in user, private ones only for the owner.
|
||||
|
||||
Every read path (list, detail, search, tags, images, comments, favourites) must start here.
|
||||
Admins get no exception.
|
||||
"""
|
||||
if user is None or not user.is_authenticated:
|
||||
return self.none()
|
||||
return self.filter(Q(visibility=Recipe.Visibility.PUBLIC) | Q(owner=user))
|
||||
|
||||
def editable_by(self, user):
|
||||
if user is None or not user.is_authenticated:
|
||||
return self.none()
|
||||
return self.filter(owner=user)
|
||||
|
||||
def deletable_by(self, user):
|
||||
if user is None or not user.is_authenticated:
|
||||
return self.none()
|
||||
if user.is_admin:
|
||||
return self.visible_to(user)
|
||||
return self.editable_by(user)
|
||||
|
||||
def search(self, query="", ingredients=(), tags=()):
|
||||
"""Every word of the query must appear in title, description, an ingredient or a tag."""
|
||||
qs = self
|
||||
for word in query.split():
|
||||
qs = qs.filter(
|
||||
Q(title__icontains=word)
|
||||
| Q(description__icontains=word)
|
||||
| Q(ingredients__name__icontains=word)
|
||||
| Q(tags__name__icontains=word.lower())
|
||||
)
|
||||
for ingredient in ingredients:
|
||||
qs = qs.filter(ingredients__name__icontains=ingredient)
|
||||
for tag in tags:
|
||||
qs = qs.filter(tags__name=parsing.normalize_tag(tag))
|
||||
return qs.distinct()
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=parsing.MAX_TAG_LENGTH, unique=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.name = parsing.normalize_tag(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def visible_to(cls, user):
|
||||
"""Only tags that occur on at least one recipe the user may see (no leaks via tag names)."""
|
||||
visible = Recipe.objects.visible_to(user)
|
||||
return cls.objects.filter(recipes__in=visible).distinct()
|
||||
|
||||
|
||||
class Recipe(models.Model):
|
||||
class Visibility(models.TextChoices):
|
||||
PRIVATE = "private", "Privat (nur für mich)"
|
||||
PUBLIC = "public", "Öffentlich (alle Nutzer)"
|
||||
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="recipes", verbose_name="Autor")
|
||||
title = models.CharField("Titel", max_length=200)
|
||||
description = models.TextField("Beschreibung", blank=True)
|
||||
servings = models.PositiveSmallIntegerField("Portionen", default=4)
|
||||
servings_label = models.CharField("Einheit der Portionen", max_length=30, default="Portionen", blank=True)
|
||||
prep_minutes = models.PositiveSmallIntegerField("Zubereitungszeit (Min.)", null=True, blank=True)
|
||||
cook_minutes = models.PositiveSmallIntegerField("Koch-/Backzeit (Min.)", null=True, blank=True)
|
||||
source_url = models.URLField("Quelle (URL)", max_length=1000, blank=True)
|
||||
author_notes = models.TextField("Notizen", blank=True)
|
||||
visibility = models.CharField("Sichtbarkeit", max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name="recipes")
|
||||
created_at = models.DateTimeField("Erstellt", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Geändert", auto_now=True)
|
||||
|
||||
objects = RecipeQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
indexes = [models.Index(fields=["visibility", "owner"])]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def is_public(self):
|
||||
return self.visibility == self.Visibility.PUBLIC
|
||||
|
||||
@property
|
||||
def total_minutes(self):
|
||||
if self.prep_minutes is None and self.cook_minutes is None:
|
||||
return None
|
||||
return (self.prep_minutes or 0) + (self.cook_minutes or 0)
|
||||
|
||||
@property
|
||||
def cover(self):
|
||||
images = list(self.images.all())
|
||||
return next((img for img in images if img.is_cover), images[0] if images else None)
|
||||
|
||||
def set_ingredients(self, items):
|
||||
self.ingredients.all().delete()
|
||||
Ingredient.objects.bulk_create(
|
||||
Ingredient(recipe=self, position=i, group=item.group, amount=item.amount, amount_max=item.amount_max,
|
||||
unit=item.unit[:30], name=item.name[:200], note=item.note[:200])
|
||||
for i, item in enumerate(items)
|
||||
)
|
||||
|
||||
def set_steps(self, texts):
|
||||
self.steps.all().delete()
|
||||
Step.objects.bulk_create(Step(recipe=self, position=i, text=text) for i, text in enumerate(texts))
|
||||
|
||||
def set_tags(self, names):
|
||||
self.tags.set([Tag.objects.get_or_create(name=parsing.normalize_tag(n))[0] for n in names])
|
||||
|
||||
def scaled_ingredients(self, servings):
|
||||
"""Ingredients scaled to the requested servings, grouped: [(group, [display dicts])]."""
|
||||
factor = Decimal(servings) / Decimal(self.servings) if self.servings else Decimal(1)
|
||||
groups = []
|
||||
for ing in self.ingredients.all():
|
||||
amount, amount_max, unit = parsing.scale_amount(ing.amount, ing.amount_max, ing.unit, factor)
|
||||
entry = {
|
||||
"amount": parsing.format_amount(amount)
|
||||
+ (f"–{parsing.format_amount(amount_max)}" if amount_max is not None else ""),
|
||||
"unit": unit,
|
||||
"name": ing.name,
|
||||
"note": ing.note,
|
||||
}
|
||||
if not groups or groups[-1][0] != ing.group:
|
||||
groups.append((ing.group, []))
|
||||
groups[-1][1].append(entry)
|
||||
return groups
|
||||
|
||||
|
||||
class Ingredient(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="ingredients")
|
||||
position = models.PositiveSmallIntegerField(default=0)
|
||||
group = models.CharField(max_length=100, blank=True)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True)
|
||||
amount_max = models.DecimalField(max_digits=10, decimal_places=3, null=True, blank=True)
|
||||
unit = models.CharField(max_length=30, blank=True)
|
||||
name = models.CharField(max_length=200)
|
||||
note = models.CharField(max_length=200, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["position"]
|
||||
|
||||
def __str__(self):
|
||||
return parsing.format_ingredient(self.amount, self.amount_max, self.unit, self.name, self.note)
|
||||
|
||||
|
||||
class Step(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="steps")
|
||||
position = models.PositiveSmallIntegerField(default=0)
|
||||
text = models.TextField()
|
||||
|
||||
class Meta:
|
||||
ordering = ["position"]
|
||||
|
||||
|
||||
class RecipeImage(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="images")
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
file = models.CharField(max_length=200)
|
||||
thumb = models.CharField(max_length=200)
|
||||
width = models.PositiveIntegerField(default=0)
|
||||
height = models.PositiveIntegerField(default=0)
|
||||
is_cover = models.BooleanField(default=False)
|
||||
position = models.PositiveSmallIntegerField(default=0)
|
||||
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="+")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-is_cover", "position", "created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Bild {self.uuid} zu {self.recipe_id}"
|
||||
|
||||
|
||||
class Favorite(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="favorites")
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="favorited_by")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [models.UniqueConstraint(fields=["user", "recipe"], name="favorite_unique")]
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Parsing and formatting of ingredient lines, steps and tags.
|
||||
|
||||
Recipes are edited as plain text (one ingredient per line, group headers end with ":",
|
||||
steps separated by blank lines). This module converts between that text and the
|
||||
structured data stored in the database, and scales amounts for the serving calculator.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
MAX_TAGS = 15
|
||||
MAX_TAG_LENGTH = 40
|
||||
|
||||
# Abbreviations that get a canonical spelling.
|
||||
CANONICAL_UNITS = {
|
||||
"g": "g", "gr": "g", "gr.": "g", "gramm": "g",
|
||||
"kg": "kg", "kilo": "kg", "kilogramm": "kg",
|
||||
"mg": "mg",
|
||||
"ml": "ml", "milliliter": "ml",
|
||||
"cl": "cl", "dl": "dl",
|
||||
"l": "l", "liter": "l", "ltr": "l", "ltr.": "l",
|
||||
"el": "EL", "el.": "EL", "essl.": "EL", "esslöffel": "EL", "tbsp": "EL", "tbsp.": "EL",
|
||||
"tl": "TL", "tl.": "TL", "teel.": "TL", "teelöffel": "TL", "tsp": "TL", "tsp.": "TL",
|
||||
"msp": "Msp.", "msp.": "Msp.", "messerspitze": "Msp.", "messerspitzen": "Msp.",
|
||||
"pck": "Pck.", "pck.": "Pck.", "pkg": "Pck.", "pkg.": "Pck.", "päckchen": "Pck.", "packung": "Pck.",
|
||||
"stk": "Stück", "stk.": "Stück", "st.": "Stück", "stück": "Stück",
|
||||
"prise": "Prise", "prisen": "Prise",
|
||||
"cup": "Cup", "cups": "Cup", "oz": "oz", "lb": "lb", "lbs": "lb",
|
||||
}
|
||||
# Unit words kept as written (plural forms matter in German).
|
||||
UNIT_WORDS = {
|
||||
"bund", "dose", "dosen", "zehe", "zehen", "tasse", "tassen", "becher", "glas", "gläser",
|
||||
"scheibe", "scheiben", "handvoll", "zweig", "zweige", "blatt", "blätter", "schuss",
|
||||
"tropfen", "würfel", "stange", "stangen", "knolle", "knollen", "kopf", "köpfe",
|
||||
"flasche", "flaschen", "beutel", "tafel", "tafeln", "spritzer", "stiel", "stiele",
|
||||
}
|
||||
|
||||
FRACTION_CHARS = {"½": Decimal("0.5"), "¼": Decimal("0.25"), "¾": Decimal("0.75"), "⅓": Decimal(1) / 3,
|
||||
"⅔": Decimal(2) / 3, "⅛": Decimal("0.125")}
|
||||
NICE_FRACTIONS = [(Decimal("0.125"), "⅛"), (Decimal("0.25"), "¼"), (Decimal(1) / 3, "⅓"), (Decimal("0.5"), "½"),
|
||||
(Decimal(2) / 3, "⅔"), (Decimal("0.75"), "¾")]
|
||||
|
||||
_FRAC = "".join(FRACTION_CHARS)
|
||||
_NUMBER = rf"(?:\d+\s+\d+\s*/\s*\d+|\d+\s*/\s*\d+|\d+\s*[{_FRAC}]|[{_FRAC}]|\d+(?:[.,]\d+)?)"
|
||||
_AMOUNT_RE = re.compile(rf"^(?P<a>{_NUMBER})(?:\s*(?:-|–|bis)\s*(?P<b>{_NUMBER}))?(?=[\s\D]|$)", re.IGNORECASE)
|
||||
_BULLET_RE = re.compile(r"^\s*(?:[-*•·▢□◦]|\d+[.)](?=\s))\s*")
|
||||
_STEP_NUMBER_RE = re.compile(r"^\s*(?:schritt\s*)?\d+\s*[.):]\s*", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedIngredient:
|
||||
name: str
|
||||
amount: Decimal | None = None
|
||||
amount_max: Decimal | None = None
|
||||
unit: str = ""
|
||||
note: str = ""
|
||||
group: str = ""
|
||||
|
||||
|
||||
def parse_number(text):
|
||||
text = text.strip().replace(" ", " ")
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
if "/" in text:
|
||||
parts = text.split()
|
||||
whole = Decimal(parts[0]) if len(parts) == 2 else Decimal(0)
|
||||
num, den = (parts[-1] if len(parts) == 2 else text).replace(" ", "").split("/")
|
||||
if Decimal(den) == 0:
|
||||
return None
|
||||
return whole + Decimal(num) / Decimal(den)
|
||||
if text[-1] in FRACTION_CHARS:
|
||||
whole = text[:-1].strip()
|
||||
return (Decimal(whole) if whole else Decimal(0)) + FRACTION_CHARS[text[-1]]
|
||||
return Decimal(text.replace(",", "."))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _split_unit(rest):
|
||||
"""Split a leading unit off the remaining text. Returns (unit, remainder)."""
|
||||
match = re.match(r"^(\S+)(?:\s+(.*))?$", rest)
|
||||
if not match:
|
||||
return "", rest
|
||||
word, remainder = match.group(1), (match.group(2) or "").strip()
|
||||
if not remainder:
|
||||
return "", rest # "2 Eier": the only word is the ingredient, not a unit
|
||||
key = word.lower()
|
||||
if key in CANONICAL_UNITS:
|
||||
return CANONICAL_UNITS[key], remainder
|
||||
if key in UNIT_WORDS:
|
||||
return word, remainder
|
||||
return "", rest
|
||||
|
||||
|
||||
def parse_ingredient_line(line, group=""):
|
||||
line = _BULLET_RE.sub("", line.strip()).strip()
|
||||
if not line:
|
||||
return None
|
||||
amount = amount_max = None
|
||||
rest = line
|
||||
match = _AMOUNT_RE.match(line)
|
||||
if match:
|
||||
amount = parse_number(match.group("a"))
|
||||
amount_max = parse_number(match.group("b")) if match.group("b") else None
|
||||
if amount is not None:
|
||||
rest = line[match.end():].strip()
|
||||
# "200g Mehl": unit glued to the number
|
||||
glued = re.match(r"^([a-zA-ZäöüÄÖÜ.]+)\s+(.*)$", rest)
|
||||
if glued and glued.group(1).lower() in CANONICAL_UNITS and not line[match.end():match.end() + 1].isspace():
|
||||
unit, rest = CANONICAL_UNITS[glued.group(1).lower()], glued.group(2)
|
||||
else:
|
||||
unit, rest = _split_unit(rest)
|
||||
else:
|
||||
unit = ""
|
||||
else:
|
||||
unit = ""
|
||||
name, note = rest, ""
|
||||
if "," in rest:
|
||||
name, note = (part.strip() for part in rest.split(",", 1))
|
||||
if not name:
|
||||
name, note = note, ""
|
||||
return ParsedIngredient(name=name, amount=amount, amount_max=amount_max, unit=unit, note=note, group=group)
|
||||
|
||||
|
||||
def is_group_header(line):
|
||||
stripped = line.strip()
|
||||
return stripped.endswith(":") and len(stripped) > 1 and not _AMOUNT_RE.match(stripped)
|
||||
|
||||
|
||||
def parse_ingredients(text):
|
||||
"""Parse the ingredient textarea into a list of ParsedIngredient."""
|
||||
items, group = [], ""
|
||||
for raw in (text or "").splitlines():
|
||||
if not raw.strip():
|
||||
continue
|
||||
if is_group_header(raw):
|
||||
group = _BULLET_RE.sub("", raw.strip())[:-1].strip()[:100]
|
||||
continue
|
||||
item = parse_ingredient_line(raw, group)
|
||||
if item:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def parse_steps(text):
|
||||
"""Blank lines separate steps; without blank lines every line is a step."""
|
||||
text = (text or "").replace("\r\n", "\n").strip()
|
||||
if not text:
|
||||
return []
|
||||
chunks = re.split(r"\n\s*\n", text) if re.search(r"\n\s*\n", text) else text.split("\n")
|
||||
steps = []
|
||||
for chunk in chunks:
|
||||
chunk = " ".join(part.strip() for part in chunk.split("\n") if part.strip())
|
||||
chunk = _STEP_NUMBER_RE.sub("", chunk).strip()
|
||||
if chunk:
|
||||
steps.append(chunk)
|
||||
return steps
|
||||
|
||||
|
||||
def normalize_tag(tag):
|
||||
tag = re.sub(r"\s+", " ", (tag or "").strip().lstrip("#")).lower()
|
||||
return tag[:MAX_TAG_LENGTH]
|
||||
|
||||
|
||||
def parse_tags(text):
|
||||
seen, tags = set(), []
|
||||
for raw in re.split(r"[,;\n]", text or ""):
|
||||
tag = normalize_tag(raw)
|
||||
if tag and tag not in seen:
|
||||
seen.add(tag)
|
||||
tags.append(tag)
|
||||
return tags[:MAX_TAGS]
|
||||
|
||||
|
||||
# --- Formatting & scaling ------------------------------------------------------------
|
||||
|
||||
|
||||
def format_amount(value):
|
||||
if value is None:
|
||||
return ""
|
||||
value = Decimal(value)
|
||||
whole = int(value)
|
||||
frac = value - whole
|
||||
for target, glyph in NICE_FRACTIONS:
|
||||
if abs(frac - target) < Decimal("0.02"):
|
||||
return f"{whole} {glyph}" if whole else glyph
|
||||
if frac < Decimal("0.02"):
|
||||
return str(whole)
|
||||
if frac > Decimal("0.98"):
|
||||
return str(whole + 1)
|
||||
rounded = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP).normalize()
|
||||
return f"{rounded:f}".replace(".", ",")
|
||||
|
||||
|
||||
def _round_metric(value):
|
||||
"""Gram/millilitre amounts: no fractions; coarser rounding for big numbers."""
|
||||
if value >= 100:
|
||||
step = Decimal(5)
|
||||
elif value >= 10:
|
||||
step = Decimal(1)
|
||||
else:
|
||||
return value
|
||||
return (value / step).quantize(Decimal(1), rounding=ROUND_HALF_UP) * step
|
||||
|
||||
|
||||
def scale_amount(amount, amount_max, unit, factor):
|
||||
"""Scale an amount (and optional upper bound of a range) and normalise metric units
|
||||
(1250 g -> 1,25 kg). Returns (amount, amount_max, unit)."""
|
||||
if amount is None:
|
||||
return None, None, unit
|
||||
values = [Decimal(v) * Decimal(factor) if v is not None else None for v in (amount, amount_max)]
|
||||
if unit in ("g", "ml"):
|
||||
values = [_round_metric(v) if v is not None else None for v in values]
|
||||
if values[0] >= 1000:
|
||||
values = [v / 1000 if v is not None else None for v in values]
|
||||
unit = "kg" if unit == "g" else "l"
|
||||
return values[0], values[1], unit
|
||||
|
||||
|
||||
def format_ingredient(amount, amount_max, unit, name, note=""):
|
||||
parts = []
|
||||
if amount is not None:
|
||||
parts.append(format_amount(amount) + (f"-{format_amount(amount_max)}" if amount_max is not None else ""))
|
||||
if unit:
|
||||
parts.append(unit)
|
||||
parts.append(name)
|
||||
text = " ".join(parts)
|
||||
return f"{text}, {note}" if note else text
|
||||
|
||||
|
||||
def ingredients_to_text(ingredients):
|
||||
"""Inverse of parse_ingredients for the edit form. Accepts objects with the same attributes."""
|
||||
lines, group = [], ""
|
||||
for ing in ingredients:
|
||||
if ing.group != group:
|
||||
if lines:
|
||||
lines.append("")
|
||||
if ing.group:
|
||||
lines.append(f"{ing.group}:")
|
||||
group = ing.group
|
||||
lines.append(format_ingredient(ing.amount, ing.amount_max, ing.unit, ing.name, ing.note))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def steps_to_text(steps):
|
||||
return "\n\n".join(steps)
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "recipes"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.recipe_list, name="list"),
|
||||
path("rezepte/neu/", views.recipe_create, name="create"),
|
||||
path("rezepte/<int:pk>/", views.recipe_detail, name="detail"),
|
||||
path("rezepte/<int:pk>/bearbeiten/", views.recipe_edit, name="edit"),
|
||||
path("rezepte/<int:pk>/loeschen/", views.recipe_delete, name="delete"),
|
||||
path("rezepte/<int:pk>/favorit/", views.favorite_toggle, name="favorite"),
|
||||
path("rezepte/<int:pk>/fotos/", views.photo_upload, name="photo_upload"),
|
||||
path("rezepte/<int:pk>/fotos/<int:image_id>/titelbild/", views.photo_cover, name="photo_cover"),
|
||||
path("rezepte/<int:pk>/fotos/<int:image_id>/loeschen/", views.photo_delete, name="photo_delete"),
|
||||
path("bilder/<uuid:image_uuid>/<str:size>.webp", views.photo_serve, name="photo"),
|
||||
]
|
||||
@@ -0,0 +1,190 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, OuterRef, Prefetch
|
||||
from django.http import FileResponse, Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from . import images
|
||||
from .forms import PhotoUploadForm, RecipeForm, SearchForm
|
||||
from .models import Favorite, Recipe, RecipeImage, Tag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAGE_SIZE = 24
|
||||
|
||||
|
||||
def _visible(request):
|
||||
return Recipe.objects.visible_to(request.user)
|
||||
|
||||
|
||||
def attach_photos(request, recipe, files):
|
||||
"""Process uploaded files and attach them. Problems are reported as messages, not exceptions."""
|
||||
has_cover = recipe.images.filter(is_cover=True).exists()
|
||||
position = recipe.images.count()
|
||||
for f in files:
|
||||
try:
|
||||
stored = images.process_upload(f)
|
||||
except images.ImageError as error:
|
||||
messages.warning(request, f"{f.name}: {error}")
|
||||
continue
|
||||
RecipeImage.objects.create(recipe=recipe, uploaded_by=request.user, position=position,
|
||||
is_cover=not has_cover, **stored)
|
||||
has_cover = True
|
||||
position += 1
|
||||
|
||||
|
||||
@require_GET
|
||||
def recipe_list(request):
|
||||
form = SearchForm(request.GET or None)
|
||||
form.is_valid()
|
||||
data = getattr(form, "cleaned_data", {}) or {}
|
||||
qs = _visible(request).search(data.get("q", ""), form.ingredient_terms() if data else [], [data["tag"]] if data.get("tag") else [])
|
||||
view = data.get("ansicht", "")
|
||||
if view == "meine":
|
||||
qs = qs.filter(owner=request.user)
|
||||
elif view == "favoriten":
|
||||
qs = qs.filter(favorited_by__user=request.user)
|
||||
qs = qs.select_related("owner").prefetch_related(
|
||||
Prefetch("images", queryset=RecipeImage.objects.order_by("-is_cover", "position"))
|
||||
).annotate(is_favorite=Exists(Favorite.objects.filter(user=request.user, recipe=OuterRef("pk"))))
|
||||
page = Paginator(qs, PAGE_SIZE).get_page(request.GET.get("seite"))
|
||||
tags = Tag.visible_to(request.user)
|
||||
return render(request, "recipes/list.html", {
|
||||
"form": form, "page": page, "tags": tags, "active_tag": data.get("tag", ""),
|
||||
"filtered": bool(data.get("q") or data.get("zutat") or data.get("tag") or view),
|
||||
})
|
||||
|
||||
|
||||
@require_GET
|
||||
def recipe_detail(request, pk):
|
||||
recipe = get_object_or_404(_visible(request).select_related("owner"), pk=pk)
|
||||
try:
|
||||
servings = int(request.GET.get("portionen", recipe.servings))
|
||||
except ValueError:
|
||||
servings = recipe.servings
|
||||
servings = max(1, min(servings, 200))
|
||||
return render(request, "recipes/detail.html", {
|
||||
"recipe": recipe,
|
||||
"servings": servings,
|
||||
"ingredient_groups": recipe.scaled_ingredients(servings),
|
||||
"steps": recipe.steps.all(),
|
||||
"images": recipe.images.all(),
|
||||
"tags": recipe.tags.all(),
|
||||
"is_owner": recipe.owner_id == request.user.id,
|
||||
"can_delete": Recipe.objects.deletable_by(request.user).filter(pk=recipe.pk).exists(),
|
||||
"is_favorite": Favorite.objects.filter(user=request.user, recipe=recipe).exists(),
|
||||
})
|
||||
|
||||
|
||||
def recipe_create(request):
|
||||
form = RecipeForm(request.POST or None, request.FILES or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
recipe = form.save(owner=request.user)
|
||||
attach_photos(request, recipe, form.cleaned_data.get("photos") or [])
|
||||
messages.success(request, "Rezept gespeichert.")
|
||||
return redirect("recipes:detail", pk=recipe.pk)
|
||||
return render(request, "recipes/form.html", {"form": form, "is_new": True})
|
||||
|
||||
|
||||
def recipe_edit(request, pk):
|
||||
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
|
||||
form = RecipeForm(request.POST or None, request.FILES or None, instance=recipe)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
recipe = form.save()
|
||||
attach_photos(request, recipe, form.cleaned_data.get("photos") or [])
|
||||
messages.success(request, "Änderungen gespeichert.")
|
||||
return redirect("recipes:detail", pk=recipe.pk)
|
||||
return render(request, "recipes/form.html", {"form": form, "recipe": recipe, "images": recipe.images.all()})
|
||||
|
||||
|
||||
def recipe_delete(request, pk):
|
||||
recipe = get_object_or_404(Recipe.objects.deletable_by(request.user), pk=pk)
|
||||
if request.method == "POST":
|
||||
delete_recipe(recipe)
|
||||
messages.success(request, f"„{recipe.title}“ wurde gelöscht.")
|
||||
return redirect("recipes:list")
|
||||
return render(request, "recipes/delete.html", {"recipe": recipe})
|
||||
|
||||
|
||||
def delete_recipe(recipe):
|
||||
paths = [p for img in recipe.images.all() for p in (img.file, img.thumb)]
|
||||
with transaction.atomic():
|
||||
recipe.delete()
|
||||
images.delete_files(*paths)
|
||||
|
||||
|
||||
@require_POST
|
||||
def favorite_toggle(request, pk):
|
||||
recipe = get_object_or_404(_visible(request), pk=pk)
|
||||
favorite, created = Favorite.objects.get_or_create(user=request.user, recipe=recipe)
|
||||
if not created:
|
||||
favorite.delete()
|
||||
target = request.POST.get("next", "")
|
||||
if url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}) and target.startswith("/"):
|
||||
return redirect(target)
|
||||
return redirect("recipes:detail", pk=recipe.pk)
|
||||
|
||||
|
||||
@require_POST
|
||||
def photo_upload(request, pk):
|
||||
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
|
||||
form = PhotoUploadForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
files = form.cleaned_data["photos"]
|
||||
if recipe.images.count() + len(files) > settings.IMAGE_MAX_PER_RECIPE:
|
||||
messages.error(request, f"Ein Rezept kann höchstens {settings.IMAGE_MAX_PER_RECIPE} Bilder haben.")
|
||||
else:
|
||||
attach_photos(request, recipe, files)
|
||||
else:
|
||||
for error in form.errors.get("photos", []):
|
||||
messages.error(request, error)
|
||||
return redirect("recipes:edit", pk=recipe.pk)
|
||||
|
||||
|
||||
@require_POST
|
||||
def photo_cover(request, pk, image_id):
|
||||
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
|
||||
image = get_object_or_404(recipe.images, pk=image_id)
|
||||
with transaction.atomic():
|
||||
recipe.images.update(is_cover=False)
|
||||
image.is_cover = True
|
||||
image.save(update_fields=["is_cover"])
|
||||
return redirect("recipes:edit", pk=recipe.pk)
|
||||
|
||||
|
||||
@require_POST
|
||||
def photo_delete(request, pk, image_id):
|
||||
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
|
||||
image = get_object_or_404(recipe.images, pk=image_id)
|
||||
was_cover = image.is_cover
|
||||
image.delete()
|
||||
images.delete_files(image.file, image.thumb)
|
||||
if was_cover:
|
||||
first = recipe.images.order_by("position", "created_at").first()
|
||||
if first:
|
||||
first.is_cover = True
|
||||
first.save(update_fields=["is_cover"])
|
||||
return redirect("recipes:edit", pk=recipe.pk)
|
||||
|
||||
|
||||
@require_GET
|
||||
def photo_serve(request, image_uuid, size):
|
||||
"""Serve a photo only if the user may see its recipe. Unknown and forbidden look the same (404)."""
|
||||
image = get_object_or_404(RecipeImage.objects.filter(recipe__in=_visible(request)), uuid=image_uuid)
|
||||
relative = image.thumb if size == "klein" else image.file
|
||||
try:
|
||||
path = images.file_path(relative)
|
||||
handle = path.open("rb")
|
||||
except (images.ImageError, OSError):
|
||||
logger.warning("Missing image file %s", relative)
|
||||
raise Http404 from None
|
||||
response = FileResponse(handle, content_type="image/webp")
|
||||
# Short browser-only caching, so a recipe made private stops showing up quickly.
|
||||
response["Cache-Control"] = "private, max-age=300"
|
||||
return response
|
||||
Reference in New Issue
Block a user