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:
Kyle Dammann
2026-10-30 21:37:13 +01:00
co-authored by Claude Opus 5.5
parent ef9deaeff7
commit 6f7e4f92ca
41 changed files with 1870 additions and 37 deletions
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class RecipesConfig(AppConfig):
name = "recipes"
verbose_name = "Rezepte"
+137
View File
@@ -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()]
+124
View File
@@ -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
+114
View File
@@ -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'),
),
]
View File
+199
View File
@@ -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")]
+248
View File
@@ -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)
View File
+18
View File
@@ -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"),
]
+190
View File
@@ -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