- 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]>
138 lines
5.9 KiB
Python
138 lines
5.9 KiB
Python
from django import forms
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
|
|
from . import images, parsing
|
|
from .models import Recipe
|
|
|
|
|
|
class MultipleFileInput(forms.ClearableFileInput):
|
|
allow_multiple_selected = True
|
|
|
|
|
|
class MultipleImageField(forms.FileField):
|
|
widget = MultipleFileInput
|
|
|
|
def clean(self, data, initial=None):
|
|
files = data if isinstance(data, (list, tuple)) else ([data] if data else [])
|
|
if len(files) > settings.IMAGE_MAX_PER_RECIPE:
|
|
raise forms.ValidationError(f"Höchstens {settings.IMAGE_MAX_PER_RECIPE} Bilder auf einmal.")
|
|
cleaned = []
|
|
for f in files:
|
|
f = super().clean(f, initial)
|
|
try:
|
|
images.validate(f)
|
|
except images.ImageError as error:
|
|
raise forms.ValidationError(f"{f.name}: {error}") from None
|
|
cleaned.append(f)
|
|
return cleaned
|
|
|
|
|
|
def photo_field(label="Fotos"):
|
|
return MultipleImageField(
|
|
label=label,
|
|
required=False,
|
|
widget=MultipleFileInput(attrs={"accept": "image/*"}),
|
|
help_text=f"JPEG, PNG, WebP oder HEIC, max. {settings.IMAGE_MAX_UPLOAD_MB} MB pro Bild. "
|
|
"Am Handy kannst du direkt die Kamera wählen.",
|
|
)
|
|
|
|
|
|
class RecipeForm(forms.ModelForm):
|
|
ingredients_text = forms.CharField(
|
|
label="Zutaten",
|
|
required=False,
|
|
widget=forms.Textarea(attrs={"rows": 10, "placeholder": "200 g Mehl\n2 Eier\n1 Prise Salz\n\nFür die Soße:\n1 Dose Tomaten"}),
|
|
help_text="Eine Zutat pro Zeile. Gruppen mit Doppelpunkt am Ende, z. B. „Für die Soße:“.",
|
|
)
|
|
steps_text = forms.CharField(
|
|
label="Zubereitung",
|
|
required=False,
|
|
widget=forms.Textarea(attrs={"rows": 10, "placeholder": "Ofen auf 180 °C vorheizen.\n\nMehl und Eier verrühren."}),
|
|
help_text="Schritte durch eine Leerzeile trennen (ohne Leerzeilen ist jede Zeile ein Schritt).",
|
|
)
|
|
tags_text = forms.CharField(
|
|
label="Tags",
|
|
required=False,
|
|
widget=forms.TextInput(attrs={"placeholder": "pasta, vegetarisch, schnell", "autocapitalize": "none"}),
|
|
help_text="Kommagetrennt, werden kleingeschrieben gespeichert.",
|
|
)
|
|
source_url = forms.URLField(label="Quelle (URL)", required=False, max_length=1000, assume_scheme="https")
|
|
photos = photo_field()
|
|
|
|
class Meta:
|
|
model = Recipe
|
|
fields = ["title", "description", "servings", "servings_label", "prep_minutes", "cook_minutes",
|
|
"visibility", "source_url", "author_notes"]
|
|
widgets = {
|
|
"description": forms.Textarea(attrs={"rows": 3}),
|
|
"author_notes": forms.Textarea(attrs={"rows": 3, "placeholder": "Tipps, Abwandlungen, was beim nächsten Mal anders …"}),
|
|
"servings": forms.NumberInput(attrs={"min": 1, "max": 100, "inputmode": "numeric"}),
|
|
"prep_minutes": forms.NumberInput(attrs={"min": 0, "inputmode": "numeric"}),
|
|
"cook_minutes": forms.NumberInput(attrs={"min": 0, "inputmode": "numeric"}),
|
|
"visibility": forms.RadioSelect,
|
|
}
|
|
|
|
field_order = ["title", "description", "servings", "servings_label", "prep_minutes", "cook_minutes",
|
|
"ingredients_text", "steps_text", "tags_text", "photos", "author_notes", "source_url", "visibility"]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["servings"].min_value = 1
|
|
recipe = self.instance
|
|
if recipe.pk and not self.is_bound:
|
|
self.initial["ingredients_text"] = parsing.ingredients_to_text(recipe.ingredients.all())
|
|
self.initial["steps_text"] = parsing.steps_to_text([s.text for s in recipe.steps.all()])
|
|
self.initial["tags_text"] = ", ".join(t.name for t in recipe.tags.all())
|
|
if recipe.pk:
|
|
remaining = settings.IMAGE_MAX_PER_RECIPE - recipe.images.count()
|
|
self.fields["photos"].label = "Weitere Fotos"
|
|
if remaining <= 0:
|
|
del self.fields["photos"]
|
|
|
|
def clean_servings(self):
|
|
servings = self.cleaned_data["servings"]
|
|
if not servings or servings < 1 or servings > 100:
|
|
raise forms.ValidationError("Bitte eine Zahl zwischen 1 und 100 angeben.")
|
|
return servings
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
cleaned["ingredients"] = parsing.parse_ingredients(cleaned.get("ingredients_text", ""))
|
|
cleaned["steps"] = parsing.parse_steps(cleaned.get("steps_text", ""))
|
|
cleaned["tags"] = parsing.parse_tags(cleaned.get("tags_text", ""))
|
|
photos = cleaned.get("photos") or []
|
|
if self.instance.pk and photos:
|
|
if self.instance.images.count() + len(photos) > settings.IMAGE_MAX_PER_RECIPE:
|
|
self.add_error("photos", f"Ein Rezept kann höchstens {settings.IMAGE_MAX_PER_RECIPE} Bilder haben.")
|
|
return cleaned
|
|
|
|
@transaction.atomic
|
|
def save(self, owner=None):
|
|
recipe = super().save(commit=False)
|
|
if owner is not None and not recipe.pk:
|
|
recipe.owner = owner
|
|
recipe.save()
|
|
recipe.set_ingredients(self.cleaned_data["ingredients"])
|
|
recipe.set_steps(self.cleaned_data["steps"])
|
|
recipe.set_tags(self.cleaned_data["tags"])
|
|
return recipe
|
|
|
|
|
|
class PhotoUploadForm(forms.Form):
|
|
photos = photo_field("Fotos hinzufügen")
|
|
|
|
|
|
class SearchForm(forms.Form):
|
|
q = forms.CharField(label="Suche", required=False, widget=forms.SearchInput(attrs={"placeholder": "Titel, Zutat, Tag …"}))
|
|
zutat = forms.CharField(label="Mit Zutaten", required=False, widget=forms.TextInput(attrs={"placeholder": "z. B. zucchini, feta"}))
|
|
tag = forms.CharField(required=False, widget=forms.HiddenInput)
|
|
ansicht = forms.ChoiceField(
|
|
label="Anzeigen",
|
|
required=False,
|
|
choices=[("", "Alle Rezepte"), ("meine", "Meine Rezepte"), ("favoriten", "Favoriten")],
|
|
)
|
|
|
|
def ingredient_terms(self):
|
|
return [t.strip() for t in self.cleaned_data.get("zutat", "").split(",") if t.strip()]
|