diff --git a/.env.example b/.env.example index 45c35ae..74ae218 100644 --- a/.env.example +++ b/.env.example @@ -49,3 +49,7 @@ GUNICORN_TIMEOUT=120 LOGIN_MAX_FAILURES_PER_EMAIL=5 LOGIN_MAX_FAILURES_PER_IP=30 LOGIN_LOCKOUT_MINUTES=15 + +# --- Fotos --------------------------------------------------------------------- +IMAGE_MAX_UPLOAD_MB=15 +IMAGE_MAX_PER_RECIPE=10 diff --git a/CLAUDE.md b/CLAUDE.md index d408482..17de651 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,15 +6,16 @@ port; TLS, domain and proxy are out of scope. ## Status - [x] Phase 1: Docker setup, DB, auth with invitations, roles -- [ ] Phase 2: Recipes CRUD, visibility (private/public), photos, notes, search, favourites +- [x] Phase 2: Recipes CRUD, visibility (private/public), photos, notes, search, favourites - [ ] Phase 3: Comments, serving calculator, cooking mode (wake lock) - [ ] Phase 4: AI (OpenAI): clean up manual input + photo/screenshot import - [ ] Phase 5: Link import (schema.org JSON-LD, Instagram with paste fallback, Android PWA share target) - [ ] Phase 6: Backup script, full README ## Stack -Django 5.2 LTS (Python 3.12), server-rendered templates, HTMX planned for Phase 2+, PostgreSQL 17, -gunicorn (gthread), WhiteNoise for static files. No Redis, no JS build step. +Django 5.2 LTS (Python 3.12), server-rendered templates, PostgreSQL 17, +gunicorn (gthread), WhiteNoise for static files, Pillow + pillow-heif for photos. No Redis, no HTMX, +no JS build step: plain forms with small progressive enhancements in `static/js/app.js`. ## Layout - `config/` – settings (all env-driven, see `.env.example`), urls, wsgi. `settings_test.py` for pytest. @@ -24,6 +25,11 @@ gunicorn (gthread), WhiteNoise for static files. No Redis, no JS build step. - `accounts/` – custom `User` (e-mail login, `role` = user|admin), `Invitation`, `PasswordResetToken`, login/registration/reset views, admin area (`admin_views.py`, `/verwaltung/…`), `ensure_admin` management command (first admin from env). +- `recipes/` – `Recipe` (+ `Ingredient`, `Step`, `Tag`, `RecipeImage`, `Favorite`). `RecipeQuerySet.visible_to/ + editable_by/deletable_by/search` hold all access rules. `parsing.py` converts the text form fields + (one ingredient per line, `Gruppe:` headers, steps separated by blank lines) to structured data and + scales amounts. `images.py` validates/re-encodes uploads (WebP 1600 px + 480 px thumb, no EXIF). + Photos are served only via `recipes:photo` (`/bilder//.webp`) after a visibility check. - `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`. - `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn. - `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker). diff --git a/accounts/admin_views.py b/accounts/admin_views.py index fffc970..0a4e9a4 100644 --- a/accounts/admin_views.py +++ b/accounts/admin_views.py @@ -103,6 +103,25 @@ def user_action(request, pk, action): return redirect("accounts:admin_users") +@admin_required +@never_cache +def user_delete_recipes(request, pk): + """Delete all recipes of a deactivated user, including private ones (content is never shown).""" + from recipes.models import Recipe + from recipes.views import delete_recipe + + target = get_object_or_404(User, pk=pk, is_active=False) + recipes = Recipe.objects.filter(owner=target) + if request.method == "POST": + count = 0 + for recipe in recipes: + delete_recipe(recipe) + count += 1 + messages.success(request, f"{count} Rezepte von {target} gelöscht.") + return redirect("accounts:admin_users") + return render(request, "accounts/admin/delete_recipes.html", {"target": target, "count": recipes.count()}) + + @admin_required @never_cache def invitation_list(request): diff --git a/accounts/urls.py b/accounts/urls.py index b00616a..72e475b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -12,7 +12,8 @@ urlpatterns = [ path("konto/", views.profile, name="profile"), path("konto/passwort/", views.password_change, name="password_change"), path("verwaltung/nutzer/", admin_views.user_list, name="admin_users"), - path("verwaltung/nutzer///", admin_views.user_action, name="admin_user_action"), + path("verwaltung/nutzer//rezepte-loeschen/", admin_views.user_delete_recipes, name="admin_user_delete_recipes"), + path("verwaltung/nutzer///",admin_views.user_action, name="admin_user_action"), path("verwaltung/einladungen/", admin_views.invitation_list, name="admin_invitations"), path("verwaltung/einladungen//widerrufen/", admin_views.invitation_revoke, name="admin_invitation_revoke"), ] diff --git a/accounts/views.py b/accounts/views.py index cec3072..6c449f3 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -38,7 +38,7 @@ def _safe_next(request): @never_cache def login_view(request): if request.user.is_authenticated: - return redirect("core:home") + return redirect("recipes:list") form = LoginForm(request.POST or None) status = 200 if request.method == "POST" and form.is_valid(): @@ -100,7 +100,7 @@ def register(request, token): invitation.save(update_fields=["used_at", "used_by"]) login(request, user, backend="django.contrib.auth.backends.ModelBackend") messages.success(request, "Willkommen im Kochbuch!") - return redirect("core:home") + return redirect("recipes:list") return render(request, "accounts/register.html", {"form": form, "invitation": invitation}) @@ -126,7 +126,7 @@ def password_reset(request, token): reset.save(update_fields=["used_at"]) login(request, user, backend="django.contrib.auth.backends.ModelBackend") messages.success(request, "Dein Passwort wurde geändert.") - return redirect("core:home") + return redirect("recipes:list") return render(request, "accounts/password_reset.html", {"form": form, "reset_user": reset.user}) @@ -152,5 +152,5 @@ def password_change(request): user.save(update_fields=["password", "must_change_password"]) update_session_auth_hash(request, user) messages.success(request, "Dein Passwort wurde geändert.") - return redirect("core:home") + return redirect("recipes:list") return render(request, "accounts/password_change.html", {"form": form, "forced": user.must_change_password}) diff --git a/config/settings.py b/config/settings.py index a3bc931..99661db 100644 --- a/config/settings.py +++ b/config/settings.py @@ -59,6 +59,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "core", "accounts", + "recipes", ] MIDDLEWARE = [ @@ -117,7 +118,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "accounts.User" LOGIN_URL = "accounts:login" -LOGIN_REDIRECT_URL = "core:home" +LOGIN_REDIRECT_URL = "recipes:list" LOGOUT_REDIRECT_URL = "accounts:login" PASSWORD_HASHERS = [ @@ -135,6 +136,11 @@ LOGIN_MAX_FAILURES_PER_EMAIL = env_int("LOGIN_MAX_FAILURES_PER_EMAIL", 5) LOGIN_MAX_FAILURES_PER_IP = env_int("LOGIN_MAX_FAILURES_PER_IP", 30) LOGIN_LOCKOUT_MINUTES = env_int("LOGIN_LOCKOUT_MINUTES", 15) +IMAGE_MAX_UPLOAD_MB = env_int("IMAGE_MAX_UPLOAD_MB", 15) +IMAGE_MAX_PER_RECIPE = env_int("IMAGE_MAX_PER_RECIPE", 10) +# Request body limit for non-file fields; files are limited per image above. +DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 + INVITATION_VALID_DAYS = 7 PASSWORD_RESET_VALID_HOURS = 24 diff --git a/config/urls.py b/config/urls.py index 149fb24..93b8288 100644 --- a/config/urls.py +++ b/config/urls.py @@ -3,6 +3,7 @@ from django.urls import include, path urlpatterns = [ path("", include("core.urls")), path("", include("accounts.urls")), + path("", include("recipes.urls")), ] handler403 = "core.views.error_403" diff --git a/core/urls.py b/core/urls.py index 49efc8c..1809821 100644 --- a/core/urls.py +++ b/core/urls.py @@ -5,6 +5,5 @@ from . import views app_name = "core" urlpatterns = [ - path("", views.home, name="home"), path("healthz", views.healthz, name="healthz"), ] diff --git a/core/views.py b/core/views.py index c8abdd2..1224bf9 100644 --- a/core/views.py +++ b/core/views.py @@ -6,10 +6,6 @@ from django.views.decorators.cache import never_cache from django.views.decorators.http import require_GET -def home(request): - return render(request, "core/home.html") - - @login_not_required @never_cache @require_GET diff --git a/recipes/__init__.py b/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recipes/apps.py b/recipes/apps.py new file mode 100644 index 0000000..0f4ee11 --- /dev/null +++ b/recipes/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RecipesConfig(AppConfig): + name = "recipes" + verbose_name = "Rezepte" diff --git a/recipes/forms.py b/recipes/forms.py new file mode 100644 index 0000000..6801322 --- /dev/null +++ b/recipes/forms.py @@ -0,0 +1,137 @@ +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()] diff --git a/recipes/images.py b/recipes/images.py new file mode 100644 index 0000000..71daa67 --- /dev/null +++ b/recipes/images.py @@ -0,0 +1,124 @@ +"""Validation and processing of uploaded photos. + +Uploads are decoded with Pillow (HEIC via pillow-heif), auto-rotated, stripped of all +metadata (EXIF/GPS) by re-encoding, downscaled and stored as WebP under MEDIA_ROOT. +The original upload is never stored. +""" + +import io +import threading +import uuid +from pathlib import Path + +import pillow_heif +from django.conf import settings +from PIL import Image, ImageOps, UnidentifiedImageError + +pillow_heif.register_heif_opener() + +ALLOWED_FORMATS = {"JPEG", "MPO", "PNG", "WEBP", "HEIF"} +MAX_PIXELS = 50_000_000 +LARGE_SIZE = 1600 +THUMB_SIZE = 480 +AI_SIZE = 2000 + +Image.MAX_IMAGE_PIXELS = MAX_PIXELS + +# Decoding a 12 MP photo needs ~100 MB; one at a time per process keeps memory predictable. +_processing = threading.BoundedSemaphore(1) + + +class ImageError(ValueError): + pass + + +def max_upload_bytes(): + return settings.IMAGE_MAX_UPLOAD_MB * 1024 * 1024 + + +def _open(fileobj): + size = getattr(fileobj, "size", None) + if size is not None and size > max_upload_bytes(): + raise ImageError(f"Das Bild ist zu groß (max. {settings.IMAGE_MAX_UPLOAD_MB} MB).") + try: + fileobj.seek(0) + img = Image.open(fileobj) + except (UnidentifiedImageError, OSError, Image.DecompressionBombError): + raise ImageError("Die Datei ist kein unterstütztes Bild (JPEG, PNG, WebP oder HEIC).") from None + if img.format not in ALLOWED_FORMATS: + raise ImageError("Die Datei ist kein unterstütztes Bild (JPEG, PNG, WebP oder HEIC).") + if img.width * img.height > MAX_PIXELS: + raise ImageError("Das Bild hat zu viele Pixel.") + return img + + +def validate(fileobj): + """Cheap check (header only) used during form validation.""" + _open(fileobj) + fileobj.seek(0) + + +def _decode(fileobj, target): + img = _open(fileobj) + if img.format == "JPEG": + img.draft("RGB", (target * 2, target * 2)) # decode at reduced size: much less memory + try: + img.load() + except (OSError, Image.DecompressionBombError): + raise ImageError("Das Bild ist beschädigt oder unvollständig.") from None + img = ImageOps.exif_transpose(img) + if img.mode in ("RGBA", "LA", "P"): + img = img.convert("RGBA") + background = Image.new("RGB", img.size, (255, 255, 255)) + background.paste(img, mask=img.getchannel("A")) + img = background + elif img.mode != "RGB": + img = img.convert("RGB") + return img + + +def _media_path(relative): + path = Path(settings.MEDIA_ROOT) / relative + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def process_upload(fileobj): + """Store a large and a thumbnail WebP. Returns dict with file, thumb, width, height.""" + with _processing: + img = _decode(fileobj, LARGE_SIZE) + name = uuid.uuid4().hex + large = img.copy() + large.thumbnail((LARGE_SIZE, LARGE_SIZE), Image.LANCZOS) + thumb = img.copy() + thumb.thumbnail((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS) + result = {"file": f"recipes/{name}.webp", "thumb": f"recipes/{name}_t.webp", + "width": large.width, "height": large.height} + large.save(_media_path(result["file"]), "WEBP", quality=82, method=4) + thumb.save(_media_path(result["thumb"]), "WEBP", quality=78, method=4) + return result + + +def prepare_for_ai(fileobj): + """Downscaled JPEG bytes (no metadata) for sending to a vision model.""" + with _processing: + img = _decode(fileobj, AI_SIZE) + img.thumbnail((AI_SIZE, AI_SIZE), Image.LANCZOS) + out = io.BytesIO() + img.save(out, "JPEG", quality=85) + return out.getvalue() + + +def delete_files(*relative_paths): + for relative in relative_paths: + if relative: + (Path(settings.MEDIA_ROOT) / relative).unlink(missing_ok=True) + + +def file_path(relative): + """Absolute path of a stored file; refuses anything outside MEDIA_ROOT.""" + root = Path(settings.MEDIA_ROOT).resolve() + path = (root / relative).resolve() + if root not in path.parents: + raise ImageError("Ungültiger Pfad.") + return path diff --git a/recipes/migrations/0001_initial.py b/recipes/migrations/0001_initial.py new file mode 100644 index 0000000..97eb9eb --- /dev/null +++ b/recipes/migrations/0001_initial.py @@ -0,0 +1,114 @@ +# Generated by Django 5.2.18 on 2026-10-30 20:33 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=40, unique=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Recipe', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Titel')), + ('description', models.TextField(blank=True, verbose_name='Beschreibung')), + ('servings', models.PositiveSmallIntegerField(default=4, verbose_name='Portionen')), + ('servings_label', models.CharField(blank=True, default='Portionen', max_length=30, verbose_name='Einheit der Portionen')), + ('prep_minutes', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Zubereitungszeit (Min.)')), + ('cook_minutes', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Koch-/Backzeit (Min.)')), + ('source_url', models.URLField(blank=True, max_length=1000, verbose_name='Quelle (URL)')), + ('author_notes', models.TextField(blank=True, verbose_name='Notizen')), + ('visibility', models.CharField(choices=[('private', 'Privat (nur für mich)'), ('public', 'Öffentlich (alle Nutzer)')], default='private', max_length=10, verbose_name='Sichtbarkeit')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Geändert')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipes', to=settings.AUTH_USER_MODEL, verbose_name='Autor')), + ('tags', models.ManyToManyField(blank=True, related_name='recipes', to='recipes.tag')), + ], + options={ + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='Ingredient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position', models.PositiveSmallIntegerField(default=0)), + ('group', models.CharField(blank=True, max_length=100)), + ('amount', models.DecimalField(blank=True, decimal_places=3, max_digits=10, null=True)), + ('amount_max', models.DecimalField(blank=True, decimal_places=3, max_digits=10, null=True)), + ('unit', models.CharField(blank=True, max_length=30)), + ('name', models.CharField(max_length=200)), + ('note', models.CharField(blank=True, max_length=200)), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='recipes.recipe')), + ], + options={ + 'ordering': ['position'], + }, + ), + migrations.CreateModel( + name='RecipeImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('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)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='recipes.recipe')), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-is_cover', 'position', 'created_at'], + }, + ), + migrations.CreateModel( + name='Step', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position', models.PositiveSmallIntegerField(default=0)), + ('text', models.TextField()), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='recipes.recipe')), + ], + options={ + 'ordering': ['position'], + }, + ), + migrations.CreateModel( + name='Favorite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL)), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='recipes.recipe')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('user', 'recipe'), name='favorite_unique')], + }, + ), + migrations.AddIndex( + model_name='recipe', + index=models.Index(fields=['visibility', 'owner'], name='recipes_rec_visibil_1325f0_idx'), + ), + ] diff --git a/recipes/migrations/__init__.py b/recipes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recipes/models.py b/recipes/models.py new file mode 100644 index 0000000..20966a6 --- /dev/null +++ b/recipes/models.py @@ -0,0 +1,199 @@ +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")] diff --git a/recipes/parsing.py b/recipes/parsing.py new file mode 100644 index 0000000..03b1da3 --- /dev/null +++ b/recipes/parsing.py @@ -0,0 +1,248 @@ +"""Parsing and formatting of ingredient lines, steps and tags. + +Recipes are edited as plain text (one ingredient per line, group headers end with ":", +steps separated by blank lines). This module converts between that text and the +structured data stored in the database, and scales amounts for the serving calculator. +""" + +import re +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation + +MAX_TAGS = 15 +MAX_TAG_LENGTH = 40 + +# Abbreviations that get a canonical spelling. +CANONICAL_UNITS = { + "g": "g", "gr": "g", "gr.": "g", "gramm": "g", + "kg": "kg", "kilo": "kg", "kilogramm": "kg", + "mg": "mg", + "ml": "ml", "milliliter": "ml", + "cl": "cl", "dl": "dl", + "l": "l", "liter": "l", "ltr": "l", "ltr.": "l", + "el": "EL", "el.": "EL", "essl.": "EL", "esslöffel": "EL", "tbsp": "EL", "tbsp.": "EL", + "tl": "TL", "tl.": "TL", "teel.": "TL", "teelöffel": "TL", "tsp": "TL", "tsp.": "TL", + "msp": "Msp.", "msp.": "Msp.", "messerspitze": "Msp.", "messerspitzen": "Msp.", + "pck": "Pck.", "pck.": "Pck.", "pkg": "Pck.", "pkg.": "Pck.", "päckchen": "Pck.", "packung": "Pck.", + "stk": "Stück", "stk.": "Stück", "st.": "Stück", "stück": "Stück", + "prise": "Prise", "prisen": "Prise", + "cup": "Cup", "cups": "Cup", "oz": "oz", "lb": "lb", "lbs": "lb", +} +# Unit words kept as written (plural forms matter in German). +UNIT_WORDS = { + "bund", "dose", "dosen", "zehe", "zehen", "tasse", "tassen", "becher", "glas", "gläser", + "scheibe", "scheiben", "handvoll", "zweig", "zweige", "blatt", "blätter", "schuss", + "tropfen", "würfel", "stange", "stangen", "knolle", "knollen", "kopf", "köpfe", + "flasche", "flaschen", "beutel", "tafel", "tafeln", "spritzer", "stiel", "stiele", +} + +FRACTION_CHARS = {"½": Decimal("0.5"), "¼": Decimal("0.25"), "¾": Decimal("0.75"), "⅓": Decimal(1) / 3, + "⅔": Decimal(2) / 3, "⅛": Decimal("0.125")} +NICE_FRACTIONS = [(Decimal("0.125"), "⅛"), (Decimal("0.25"), "¼"), (Decimal(1) / 3, "⅓"), (Decimal("0.5"), "½"), + (Decimal(2) / 3, "⅔"), (Decimal("0.75"), "¾")] + +_FRAC = "".join(FRACTION_CHARS) +_NUMBER = rf"(?:\d+\s+\d+\s*/\s*\d+|\d+\s*/\s*\d+|\d+\s*[{_FRAC}]|[{_FRAC}]|\d+(?:[.,]\d+)?)" +_AMOUNT_RE = re.compile(rf"^(?P{_NUMBER})(?:\s*(?:-|–|bis)\s*(?P{_NUMBER}))?(?=[\s\D]|$)", re.IGNORECASE) +_BULLET_RE = re.compile(r"^\s*(?:[-*•·▢□◦]|\d+[.)](?=\s))\s*") +_STEP_NUMBER_RE = re.compile(r"^\s*(?:schritt\s*)?\d+\s*[.):]\s*", re.IGNORECASE) + + +@dataclass +class ParsedIngredient: + name: str + amount: Decimal | None = None + amount_max: Decimal | None = None + unit: str = "" + note: str = "" + group: str = "" + + +def parse_number(text): + text = text.strip().replace(" ", " ") + if not text: + return None + try: + if "/" in text: + parts = text.split() + whole = Decimal(parts[0]) if len(parts) == 2 else Decimal(0) + num, den = (parts[-1] if len(parts) == 2 else text).replace(" ", "").split("/") + if Decimal(den) == 0: + return None + return whole + Decimal(num) / Decimal(den) + if text[-1] in FRACTION_CHARS: + whole = text[:-1].strip() + return (Decimal(whole) if whole else Decimal(0)) + FRACTION_CHARS[text[-1]] + return Decimal(text.replace(",", ".")) + except (InvalidOperation, ValueError): + return None + + +def _split_unit(rest): + """Split a leading unit off the remaining text. Returns (unit, remainder).""" + match = re.match(r"^(\S+)(?:\s+(.*))?$", rest) + if not match: + return "", rest + word, remainder = match.group(1), (match.group(2) or "").strip() + if not remainder: + return "", rest # "2 Eier": the only word is the ingredient, not a unit + key = word.lower() + if key in CANONICAL_UNITS: + return CANONICAL_UNITS[key], remainder + if key in UNIT_WORDS: + return word, remainder + return "", rest + + +def parse_ingredient_line(line, group=""): + line = _BULLET_RE.sub("", line.strip()).strip() + if not line: + return None + amount = amount_max = None + rest = line + match = _AMOUNT_RE.match(line) + if match: + amount = parse_number(match.group("a")) + amount_max = parse_number(match.group("b")) if match.group("b") else None + if amount is not None: + rest = line[match.end():].strip() + # "200g Mehl": unit glued to the number + glued = re.match(r"^([a-zA-ZäöüÄÖÜ.]+)\s+(.*)$", rest) + if glued and glued.group(1).lower() in CANONICAL_UNITS and not line[match.end():match.end() + 1].isspace(): + unit, rest = CANONICAL_UNITS[glued.group(1).lower()], glued.group(2) + else: + unit, rest = _split_unit(rest) + else: + unit = "" + else: + unit = "" + name, note = rest, "" + if "," in rest: + name, note = (part.strip() for part in rest.split(",", 1)) + if not name: + name, note = note, "" + return ParsedIngredient(name=name, amount=amount, amount_max=amount_max, unit=unit, note=note, group=group) + + +def is_group_header(line): + stripped = line.strip() + return stripped.endswith(":") and len(stripped) > 1 and not _AMOUNT_RE.match(stripped) + + +def parse_ingredients(text): + """Parse the ingredient textarea into a list of ParsedIngredient.""" + items, group = [], "" + for raw in (text or "").splitlines(): + if not raw.strip(): + continue + if is_group_header(raw): + group = _BULLET_RE.sub("", raw.strip())[:-1].strip()[:100] + continue + item = parse_ingredient_line(raw, group) + if item: + items.append(item) + return items + + +def parse_steps(text): + """Blank lines separate steps; without blank lines every line is a step.""" + text = (text or "").replace("\r\n", "\n").strip() + if not text: + return [] + chunks = re.split(r"\n\s*\n", text) if re.search(r"\n\s*\n", text) else text.split("\n") + steps = [] + for chunk in chunks: + chunk = " ".join(part.strip() for part in chunk.split("\n") if part.strip()) + chunk = _STEP_NUMBER_RE.sub("", chunk).strip() + if chunk: + steps.append(chunk) + return steps + + +def normalize_tag(tag): + tag = re.sub(r"\s+", " ", (tag or "").strip().lstrip("#")).lower() + return tag[:MAX_TAG_LENGTH] + + +def parse_tags(text): + seen, tags = set(), [] + for raw in re.split(r"[,;\n]", text or ""): + tag = normalize_tag(raw) + if tag and tag not in seen: + seen.add(tag) + tags.append(tag) + return tags[:MAX_TAGS] + + +# --- Formatting & scaling ------------------------------------------------------------ + + +def format_amount(value): + if value is None: + return "" + value = Decimal(value) + whole = int(value) + frac = value - whole + for target, glyph in NICE_FRACTIONS: + if abs(frac - target) < Decimal("0.02"): + return f"{whole} {glyph}" if whole else glyph + if frac < Decimal("0.02"): + return str(whole) + if frac > Decimal("0.98"): + return str(whole + 1) + rounded = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP).normalize() + return f"{rounded:f}".replace(".", ",") + + +def _round_metric(value): + """Gram/millilitre amounts: no fractions; coarser rounding for big numbers.""" + if value >= 100: + step = Decimal(5) + elif value >= 10: + step = Decimal(1) + else: + return value + return (value / step).quantize(Decimal(1), rounding=ROUND_HALF_UP) * step + + +def scale_amount(amount, amount_max, unit, factor): + """Scale an amount (and optional upper bound of a range) and normalise metric units + (1250 g -> 1,25 kg). Returns (amount, amount_max, unit).""" + if amount is None: + return None, None, unit + values = [Decimal(v) * Decimal(factor) if v is not None else None for v in (amount, amount_max)] + if unit in ("g", "ml"): + values = [_round_metric(v) if v is not None else None for v in values] + if values[0] >= 1000: + values = [v / 1000 if v is not None else None for v in values] + unit = "kg" if unit == "g" else "l" + return values[0], values[1], unit + + +def format_ingredient(amount, amount_max, unit, name, note=""): + parts = [] + if amount is not None: + parts.append(format_amount(amount) + (f"-{format_amount(amount_max)}" if amount_max is not None else "")) + if unit: + parts.append(unit) + parts.append(name) + text = " ".join(parts) + return f"{text}, {note}" if note else text + + +def ingredients_to_text(ingredients): + """Inverse of parse_ingredients for the edit form. Accepts objects with the same attributes.""" + lines, group = [], "" + for ing in ingredients: + if ing.group != group: + if lines: + lines.append("") + if ing.group: + lines.append(f"{ing.group}:") + group = ing.group + lines.append(format_ingredient(ing.amount, ing.amount_max, ing.unit, ing.name, ing.note)) + return "\n".join(lines) + + +def steps_to_text(steps): + return "\n\n".join(steps) diff --git a/recipes/templatetags/__init__.py b/recipes/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recipes/urls.py b/recipes/urls.py new file mode 100644 index 0000000..da639c7 --- /dev/null +++ b/recipes/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from . import views + +app_name = "recipes" + +urlpatterns = [ + path("", views.recipe_list, name="list"), + path("rezepte/neu/", views.recipe_create, name="create"), + path("rezepte//", views.recipe_detail, name="detail"), + path("rezepte//bearbeiten/", views.recipe_edit, name="edit"), + path("rezepte//loeschen/", views.recipe_delete, name="delete"), + path("rezepte//favorit/", views.favorite_toggle, name="favorite"), + path("rezepte//fotos/", views.photo_upload, name="photo_upload"), + path("rezepte//fotos//titelbild/", views.photo_cover, name="photo_cover"), + path("rezepte//fotos//loeschen/", views.photo_delete, name="photo_delete"), + path("bilder//.webp", views.photo_serve, name="photo"), +] diff --git a/recipes/views.py b/recipes/views.py new file mode 100644 index 0000000..41a9317 --- /dev/null +++ b/recipes/views.py @@ -0,0 +1,190 @@ +import logging + +from django.conf import settings +from django.contrib import messages +from django.core.paginator import Paginator +from django.db import transaction +from django.db.models import Exists, OuterRef, Prefetch +from django.http import FileResponse, Http404 +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.http import url_has_allowed_host_and_scheme +from django.views.decorators.http import require_GET, require_POST + +from . import images +from .forms import PhotoUploadForm, RecipeForm, SearchForm +from .models import Favorite, Recipe, RecipeImage, Tag + +logger = logging.getLogger(__name__) + +PAGE_SIZE = 24 + + +def _visible(request): + return Recipe.objects.visible_to(request.user) + + +def attach_photos(request, recipe, files): + """Process uploaded files and attach them. Problems are reported as messages, not exceptions.""" + has_cover = recipe.images.filter(is_cover=True).exists() + position = recipe.images.count() + for f in files: + try: + stored = images.process_upload(f) + except images.ImageError as error: + messages.warning(request, f"{f.name}: {error}") + continue + RecipeImage.objects.create(recipe=recipe, uploaded_by=request.user, position=position, + is_cover=not has_cover, **stored) + has_cover = True + position += 1 + + +@require_GET +def recipe_list(request): + form = SearchForm(request.GET or None) + form.is_valid() + data = getattr(form, "cleaned_data", {}) or {} + qs = _visible(request).search(data.get("q", ""), form.ingredient_terms() if data else [], [data["tag"]] if data.get("tag") else []) + view = data.get("ansicht", "") + if view == "meine": + qs = qs.filter(owner=request.user) + elif view == "favoriten": + qs = qs.filter(favorited_by__user=request.user) + qs = qs.select_related("owner").prefetch_related( + Prefetch("images", queryset=RecipeImage.objects.order_by("-is_cover", "position")) + ).annotate(is_favorite=Exists(Favorite.objects.filter(user=request.user, recipe=OuterRef("pk")))) + page = Paginator(qs, PAGE_SIZE).get_page(request.GET.get("seite")) + tags = Tag.visible_to(request.user) + return render(request, "recipes/list.html", { + "form": form, "page": page, "tags": tags, "active_tag": data.get("tag", ""), + "filtered": bool(data.get("q") or data.get("zutat") or data.get("tag") or view), + }) + + +@require_GET +def recipe_detail(request, pk): + recipe = get_object_or_404(_visible(request).select_related("owner"), pk=pk) + try: + servings = int(request.GET.get("portionen", recipe.servings)) + except ValueError: + servings = recipe.servings + servings = max(1, min(servings, 200)) + return render(request, "recipes/detail.html", { + "recipe": recipe, + "servings": servings, + "ingredient_groups": recipe.scaled_ingredients(servings), + "steps": recipe.steps.all(), + "images": recipe.images.all(), + "tags": recipe.tags.all(), + "is_owner": recipe.owner_id == request.user.id, + "can_delete": Recipe.objects.deletable_by(request.user).filter(pk=recipe.pk).exists(), + "is_favorite": Favorite.objects.filter(user=request.user, recipe=recipe).exists(), + }) + + +def recipe_create(request): + form = RecipeForm(request.POST or None, request.FILES or None) + if request.method == "POST" and form.is_valid(): + recipe = form.save(owner=request.user) + attach_photos(request, recipe, form.cleaned_data.get("photos") or []) + messages.success(request, "Rezept gespeichert.") + return redirect("recipes:detail", pk=recipe.pk) + return render(request, "recipes/form.html", {"form": form, "is_new": True}) + + +def recipe_edit(request, pk): + recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk) + form = RecipeForm(request.POST or None, request.FILES or None, instance=recipe) + if request.method == "POST" and form.is_valid(): + recipe = form.save() + attach_photos(request, recipe, form.cleaned_data.get("photos") or []) + messages.success(request, "Änderungen gespeichert.") + return redirect("recipes:detail", pk=recipe.pk) + return render(request, "recipes/form.html", {"form": form, "recipe": recipe, "images": recipe.images.all()}) + + +def recipe_delete(request, pk): + recipe = get_object_or_404(Recipe.objects.deletable_by(request.user), pk=pk) + if request.method == "POST": + delete_recipe(recipe) + messages.success(request, f"„{recipe.title}“ wurde gelöscht.") + return redirect("recipes:list") + return render(request, "recipes/delete.html", {"recipe": recipe}) + + +def delete_recipe(recipe): + paths = [p for img in recipe.images.all() for p in (img.file, img.thumb)] + with transaction.atomic(): + recipe.delete() + images.delete_files(*paths) + + +@require_POST +def favorite_toggle(request, pk): + recipe = get_object_or_404(_visible(request), pk=pk) + favorite, created = Favorite.objects.get_or_create(user=request.user, recipe=recipe) + if not created: + favorite.delete() + target = request.POST.get("next", "") + if url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}) and target.startswith("/"): + return redirect(target) + return redirect("recipes:detail", pk=recipe.pk) + + +@require_POST +def photo_upload(request, pk): + recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk) + form = PhotoUploadForm(request.POST, request.FILES) + if form.is_valid(): + files = form.cleaned_data["photos"] + if recipe.images.count() + len(files) > settings.IMAGE_MAX_PER_RECIPE: + messages.error(request, f"Ein Rezept kann höchstens {settings.IMAGE_MAX_PER_RECIPE} Bilder haben.") + else: + attach_photos(request, recipe, files) + else: + for error in form.errors.get("photos", []): + messages.error(request, error) + return redirect("recipes:edit", pk=recipe.pk) + + +@require_POST +def photo_cover(request, pk, image_id): + recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk) + image = get_object_or_404(recipe.images, pk=image_id) + with transaction.atomic(): + recipe.images.update(is_cover=False) + image.is_cover = True + image.save(update_fields=["is_cover"]) + return redirect("recipes:edit", pk=recipe.pk) + + +@require_POST +def photo_delete(request, pk, image_id): + recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk) + image = get_object_or_404(recipe.images, pk=image_id) + was_cover = image.is_cover + image.delete() + images.delete_files(image.file, image.thumb) + if was_cover: + first = recipe.images.order_by("position", "created_at").first() + if first: + first.is_cover = True + first.save(update_fields=["is_cover"]) + return redirect("recipes:edit", pk=recipe.pk) + + +@require_GET +def photo_serve(request, image_uuid, size): + """Serve a photo only if the user may see its recipe. Unknown and forbidden look the same (404).""" + image = get_object_or_404(RecipeImage.objects.filter(recipe__in=_visible(request)), uuid=image_uuid) + relative = image.thumb if size == "klein" else image.file + try: + path = images.file_path(relative) + handle = path.open("rb") + except (images.ImageError, OSError): + logger.warning("Missing image file %s", relative) + raise Http404 from None + response = FileResponse(handle, content_type="image/webp") + # Short browser-only caching, so a recipe made private stops showing up quickly. + response["Cache-Control"] = "private, max-age=300" + return response diff --git a/requirements.txt b/requirements.txt index b9e9c56..5cdf85f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,8 @@ psycopg[binary]==3.3.6 argon2-cffi==25.1.0 gunicorn==26.2.0 whitenoise==6.12.0 +Pillow==13.0.0 +pillow-heif==1.8.1 +openai==3.41.0 +pydantic==2.14.2 +urllib3==2.8.0 diff --git a/static/css/app.css b/static/css/app.css index a509b39..8d9c9e1 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -183,3 +183,73 @@ input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); ou .copyrow { display: flex; gap: 0.5rem; margin: 0.5rem 0; } .copyrow input { flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 0.9rem; } + +/* --- Recipes ----------------------------------------------------------------- */ +.page-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.search { margin-bottom: 1rem; } +.search-row { display: flex; gap: 0.5rem; } +.search-row input { flex: 1; min-width: 0; } +input[type="search"] { + width: 100%; min-height: var(--tap); padding: 0.6rem 0.75rem; font: inherit; color: var(--text); + background: var(--surface); border: 1px solid var(--border); border-radius: 999px; +} +.filters { margin-top: 0.5rem; } +.filters summary { cursor: pointer; color: var(--accent); min-height: 36px; display: flex; align-items: center; } + +.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0 0 1rem; } +.chip { + display: inline-block; padding: 0.3rem 0.75rem; border-radius: 999px; font-size: 0.9rem; + background: var(--surface); border: 1px solid var(--border); color: var(--text); text-decoration: none; +} +.chip.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); } + +.recipe-grid { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); } +@media (min-width: 640px) { .recipe-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +.recipe-card { + display: block; height: 100%; overflow: hidden; text-decoration: none; color: var(--text); + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); +} +.recipe-card img, .recipe-card .placeholder { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; } +.recipe-card .placeholder { display: grid; place-items: center; font-size: 2.5rem; background: var(--border); } +.recipe-card-body { padding: 0.6rem 0.75rem 0.75rem; display: flex; flex-direction: column; gap: 0.2rem; line-height: 1.3; } +.recipe-card-body .muted { font-size: 0.82rem; } +.empty { text-align: center; } +.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; gap: 0.5rem; } + +.gallery { display: flex; gap: 0.5rem; overflow-x: auto; scroll-snap-type: x mandatory; margin: 0 -1rem 1rem; padding: 0 1rem; } +.gallery a { flex: 0 0 100%; scroll-snap-align: center; } +.gallery img { width: 100%; height: auto; max-height: 60vh; object-fit: cover; border-radius: var(--radius); display: block; } +.gallery:has(a:nth-child(2)) a { flex-basis: 88%; } + +.recipe-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; } +.recipe-actions form { margin: 0; } +.lead { font-size: 1.05rem; } +.facts { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0 0 1rem; } +.facts .muted { display: block; font-size: 0.8rem; } +.section-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 0.5rem; } +.section-head h2 { margin: 0; } +h3 { font-size: 1rem; margin: 1rem 0 0.25rem; color: var(--muted); } +.ingredients { list-style: none; padding: 0; margin: 0.5rem 0 0; } +.ingredients li { display: flex; gap: 0.75rem; padding: 0.45rem 0; border-bottom: 1px solid var(--border); } +.ingredients li:last-child { border-bottom: 0; } +.ingredients .amount { flex: 0 0 6.5rem; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; } +.steps { padding-left: 1.4rem; margin: 0.5rem 0 0; } +.steps li { margin-bottom: 0.9rem; padding-left: 0.25rem; } +.notes { border-left: 4px solid var(--accent); } + +.servings { display: flex; align-items: center; gap: 0.4rem; margin: 0; } +.servings label { display: flex; align-items: center; gap: 0.4rem; font-weight: 400; margin: 0; } +.servings input { width: 4rem; min-height: 40px; text-align: center; padding: 0.3rem; } +.step-btn { + display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; + border: 1px solid var(--accent); color: var(--accent); text-decoration: none; font-size: 1.3rem; font-weight: 600; +} +.step-btn.disabled { opacity: 0.35; } +.small-link { font-size: 0.85rem; } + +.recipe-form textarea { font-size: 16px; line-height: 1.45; } +.recipe-form ul { list-style: none; padding: 0; margin: 0; } +.recipe-form ul label { display: flex; align-items: center; gap: 0.5rem; font-weight: 400; min-height: var(--tap); } +.form-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; } +.photo-admin { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.photo-admin img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; display: block; margin-bottom: 0.4rem; } diff --git a/static/js/app.js b/static/js/app.js index 1788dc3..dbeefe8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -12,3 +12,9 @@ document.addEventListener("click", async (event) => { button.textContent = "Bitte manuell kopieren"; } }); + +// Serving calculator: recalculate as soon as the number changes. +document.addEventListener("change", (event) => { + const input = event.target.closest(".servings input[name=portionen]"); + if (input && input.value) input.form.requestSubmit(); +}); diff --git a/templates/accounts/admin/delete_recipes.html b/templates/accounts/admin/delete_recipes.html new file mode 100644 index 0000000..fd0d8f1 --- /dev/null +++ b/templates/accounts/admin/delete_recipes.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Rezepte löschen{% endblock %} +{% block content %} + +{% endblock %} diff --git a/templates/accounts/admin/users.html b/templates/accounts/admin/users.html index f996d3b..79d71a1 100644 --- a/templates/accounts/admin/users.html +++ b/templates/accounts/admin/users.html @@ -29,6 +29,7 @@ {% endif %} {% else %}
{% csrf_token %}
+ Rezepte löschen … {% endif %} diff --git a/templates/base.html b/templates/base.html index a5a9d29..c4062eb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,12 +11,12 @@
- Kochbuch + Kochbuch {% if user.is_authenticated %}