- Comments on visible recipes; author and admin may delete; per-user hourly rate limit (COMMENT_RATE_LIMIT_PER_HOUR) - Cooking mode with large text, tick-off checklists, serving scaling and Screen Wake Lock to keep the display on - Serving calculator auto-submits on change - Privacy tests cover comment and cooking-mode URLs Co-Authored-By: Claude Opus 5.5 <[email protected]>
223 lines
8.7 KiB
Python
223 lines
8.7 KiB
Python
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")]
|
||
|
||
|
||
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}"
|