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
+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")]