Files
Kochbuch/recipes/models.py
T
Kyle DammannandClaude Opus 5.5 c76fd119cf Make new recipes public by default
New recipes (manual, AI draft and all imports) now start as public;
private can still be chosen per recipe. Existing recipes are unchanged.

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

223 lines
8.7 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.PUBLIC)
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")]
class CommentQuerySet(models.QuerySet):
def deletable_by(self, user):
if user is None or not user.is_authenticated:
return self.none()
visible = self.filter(recipe__in=Recipe.objects.visible_to(user))
return visible if user.is_admin else visible.filter(author=user)
class Comment(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="comments")
text = models.TextField("Kommentar", max_length=2000)
created_at = models.DateTimeField(auto_now_add=True)
objects = CommentQuerySet.as_manager()
class Meta:
ordering = ["created_at"]
def __str__(self):
return f"Kommentar von {self.author} zu {self.recipe}"