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