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: # setdefault: an AI draft passed as initial wins over the stored values self.initial.setdefault("ingredients_text", parsing.ingredients_to_text(recipe.ingredients.all())) self.initial.setdefault("steps_text", parsing.steps_to_text([s.text for s in recipe.steps.all()])) self.initial.setdefault("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"}