Files
Kochbuch/recipes/models.py
T
Kyle DammannandClaude Opus 5.5 6f7e4f92ca Add phase 2: recipes with visibility, photos, search and favourites
- Recipe model with ingredients (amount/range, unit, group, note), steps,
  lowercase tags, author notes, source URL and private/public visibility
- Central RecipeQuerySet.visible_to() used by every read path; edit only
  by owner, delete by owner or admin (visible recipes only)
- Text-based editing: ingredient/step parser with roundtrip and scaling
- Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF,
  thumbnails, cover image, served only through permission-checked view
- Search across title, description, ingredients, tags; ingredient and tag
  filters; "mine" and favourites views
- Admin can delete all recipes of a deactivated user
- Acceptance tests: private recipes unreachable for others and admins
  in list, search, tags, favourites, detail, edit, delete and image URLs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:37:13 +01:00

200 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")]