Files
Kochbuch/recipes/forms.py
T
Kyle DammannandClaude Opus 5.5 9256e94286 Add phase 3: comments and cooking mode
- 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]>
2026-10-30 21:39:15 +01:00

146 lines
6.2 KiB
Python

from django import forms
from django.conf import settings
from django.db import transaction
from . import images, parsing
from .models import Comment, 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()]
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ["text"]
widgets = {"text": forms.Textarea(attrs={"rows": 3, "placeholder": "Hast du es nachgekocht? Tipps?", "maxlength": 2000})}
labels = {"text": "Kommentar schreiben"}