Add phase 2: recipes with visibility, photos, search and favourites
- 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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef9deaeff7
commit
6f7e4f92ca
@@ -49,3 +49,7 @@ GUNICORN_TIMEOUT=120
|
|||||||
LOGIN_MAX_FAILURES_PER_EMAIL=5
|
LOGIN_MAX_FAILURES_PER_EMAIL=5
|
||||||
LOGIN_MAX_FAILURES_PER_IP=30
|
LOGIN_MAX_FAILURES_PER_IP=30
|
||||||
LOGIN_LOCKOUT_MINUTES=15
|
LOGIN_LOCKOUT_MINUTES=15
|
||||||
|
|
||||||
|
# --- Fotos ---------------------------------------------------------------------
|
||||||
|
IMAGE_MAX_UPLOAD_MB=15
|
||||||
|
IMAGE_MAX_PER_RECIPE=10
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ port; TLS, domain and proxy are out of scope.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
- [x] Phase 1: Docker setup, DB, auth with invitations, roles
|
- [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 3: Comments, serving calculator, cooking mode (wake lock)
|
||||||
- [ ] Phase 4: AI (OpenAI): clean up manual input + photo/screenshot import
|
- [ ] 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 5: Link import (schema.org JSON-LD, Instagram with paste fallback, Android PWA share target)
|
||||||
- [ ] Phase 6: Backup script, full README
|
- [ ] Phase 6: Backup script, full README
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
Django 5.2 LTS (Python 3.12), server-rendered templates, HTMX planned for Phase 2+, PostgreSQL 17,
|
Django 5.2 LTS (Python 3.12), server-rendered templates, PostgreSQL 17,
|
||||||
gunicorn (gthread), WhiteNoise for static files. No Redis, no JS build step.
|
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
|
## Layout
|
||||||
- `config/` – settings (all env-driven, see `.env.example`), urls, wsgi. `settings_test.py` for pytest.
|
- `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`,
|
- `accounts/` – custom `User` (e-mail login, `role` = user|admin), `Invitation`, `PasswordResetToken`,
|
||||||
login/registration/reset views, admin area (`admin_views.py`, `/verwaltung/…`),
|
login/registration/reset views, admin area (`admin_views.py`, `/verwaltung/…`),
|
||||||
`ensure_admin` management command (first admin from env).
|
`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/<uuid>/<gross|klein>.webp`) after a visibility check.
|
||||||
- `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`.
|
- `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`.
|
||||||
- `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn.
|
- `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn.
|
||||||
- `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker).
|
- `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker).
|
||||||
|
|||||||
@@ -103,6 +103,25 @@ def user_action(request, pk, action):
|
|||||||
return redirect("accounts:admin_users")
|
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
|
@admin_required
|
||||||
@never_cache
|
@never_cache
|
||||||
def invitation_list(request):
|
def invitation_list(request):
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@ urlpatterns = [
|
|||||||
path("konto/", views.profile, name="profile"),
|
path("konto/", views.profile, name="profile"),
|
||||||
path("konto/passwort/", views.password_change, name="password_change"),
|
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_list, name="admin_users"),
|
||||||
path("verwaltung/nutzer/<int:pk>/<str:action>/", admin_views.user_action, name="admin_user_action"),
|
path("verwaltung/nutzer/<int:pk>/rezepte-loeschen/", admin_views.user_delete_recipes, name="admin_user_delete_recipes"),
|
||||||
|
path("verwaltung/nutzer/<int:pk>/<str:action>/",admin_views.user_action, name="admin_user_action"),
|
||||||
path("verwaltung/einladungen/", admin_views.invitation_list, name="admin_invitations"),
|
path("verwaltung/einladungen/", admin_views.invitation_list, name="admin_invitations"),
|
||||||
path("verwaltung/einladungen/<int:pk>/widerrufen/", admin_views.invitation_revoke, name="admin_invitation_revoke"),
|
path("verwaltung/einladungen/<int:pk>/widerrufen/", admin_views.invitation_revoke, name="admin_invitation_revoke"),
|
||||||
]
|
]
|
||||||
|
|||||||
+4
-4
@@ -38,7 +38,7 @@ def _safe_next(request):
|
|||||||
@never_cache
|
@never_cache
|
||||||
def login_view(request):
|
def login_view(request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return redirect("core:home")
|
return redirect("recipes:list")
|
||||||
form = LoginForm(request.POST or None)
|
form = LoginForm(request.POST or None)
|
||||||
status = 200
|
status = 200
|
||||||
if request.method == "POST" and form.is_valid():
|
if request.method == "POST" and form.is_valid():
|
||||||
@@ -100,7 +100,7 @@ def register(request, token):
|
|||||||
invitation.save(update_fields=["used_at", "used_by"])
|
invitation.save(update_fields=["used_at", "used_by"])
|
||||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||||
messages.success(request, "Willkommen im Kochbuch!")
|
messages.success(request, "Willkommen im Kochbuch!")
|
||||||
return redirect("core:home")
|
return redirect("recipes:list")
|
||||||
return render(request, "accounts/register.html", {"form": form, "invitation": invitation})
|
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"])
|
reset.save(update_fields=["used_at"])
|
||||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||||
messages.success(request, "Dein Passwort wurde geändert.")
|
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})
|
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"])
|
user.save(update_fields=["password", "must_change_password"])
|
||||||
update_session_auth_hash(request, user)
|
update_session_auth_hash(request, user)
|
||||||
messages.success(request, "Dein Passwort wurde geändert.")
|
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})
|
return render(request, "accounts/password_change.html", {"form": form, "forced": user.must_change_password})
|
||||||
|
|||||||
+7
-1
@@ -59,6 +59,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"core",
|
"core",
|
||||||
"accounts",
|
"accounts",
|
||||||
|
"recipes",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -117,7 +118,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|||||||
|
|
||||||
AUTH_USER_MODEL = "accounts.User"
|
AUTH_USER_MODEL = "accounts.User"
|
||||||
LOGIN_URL = "accounts:login"
|
LOGIN_URL = "accounts:login"
|
||||||
LOGIN_REDIRECT_URL = "core:home"
|
LOGIN_REDIRECT_URL = "recipes:list"
|
||||||
LOGOUT_REDIRECT_URL = "accounts:login"
|
LOGOUT_REDIRECT_URL = "accounts:login"
|
||||||
|
|
||||||
PASSWORD_HASHERS = [
|
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_MAX_FAILURES_PER_IP = env_int("LOGIN_MAX_FAILURES_PER_IP", 30)
|
||||||
LOGIN_LOCKOUT_MINUTES = env_int("LOGIN_LOCKOUT_MINUTES", 15)
|
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
|
INVITATION_VALID_DAYS = 7
|
||||||
PASSWORD_RESET_VALID_HOURS = 24
|
PASSWORD_RESET_VALID_HOURS = 24
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from django.urls import include, path
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include("core.urls")),
|
path("", include("core.urls")),
|
||||||
path("", include("accounts.urls")),
|
path("", include("accounts.urls")),
|
||||||
|
path("", include("recipes.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
handler403 = "core.views.error_403"
|
handler403 = "core.views.error_403"
|
||||||
|
|||||||
@@ -5,6 +5,5 @@ from . import views
|
|||||||
app_name = "core"
|
app_name = "core"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.home, name="home"),
|
|
||||||
path("healthz", views.healthz, name="healthz"),
|
path("healthz", views.healthz, name="healthz"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ from django.views.decorators.cache import never_cache
|
|||||||
from django.views.decorators.http import require_GET
|
from django.views.decorators.http import require_GET
|
||||||
|
|
||||||
|
|
||||||
def home(request):
|
|
||||||
return render(request, "core/home.html")
|
|
||||||
|
|
||||||
|
|
||||||
@login_not_required
|
@login_not_required
|
||||||
@never_cache
|
@never_cache
|
||||||
@require_GET
|
@require_GET
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class RecipesConfig(AppConfig):
|
||||||
|
name = "recipes"
|
||||||
|
verbose_name = "Rezepte"
|
||||||
@@ -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()]
|
||||||
@@ -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
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")]
|
||||||
@@ -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<a>{_NUMBER})(?:\s*(?:-|–|bis)\s*(?P<b>{_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)
|
||||||
@@ -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/<int:pk>/", views.recipe_detail, name="detail"),
|
||||||
|
path("rezepte/<int:pk>/bearbeiten/", views.recipe_edit, name="edit"),
|
||||||
|
path("rezepte/<int:pk>/loeschen/", views.recipe_delete, name="delete"),
|
||||||
|
path("rezepte/<int:pk>/favorit/", views.favorite_toggle, name="favorite"),
|
||||||
|
path("rezepte/<int:pk>/fotos/", views.photo_upload, name="photo_upload"),
|
||||||
|
path("rezepte/<int:pk>/fotos/<int:image_id>/titelbild/", views.photo_cover, name="photo_cover"),
|
||||||
|
path("rezepte/<int:pk>/fotos/<int:image_id>/loeschen/", views.photo_delete, name="photo_delete"),
|
||||||
|
path("bilder/<uuid:image_uuid>/<str:size>.webp", views.photo_serve, name="photo"),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -3,3 +3,8 @@ psycopg[binary]==3.3.6
|
|||||||
argon2-cffi==25.1.0
|
argon2-cffi==25.1.0
|
||||||
gunicorn==26.2.0
|
gunicorn==26.2.0
|
||||||
whitenoise==6.12.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
|
||||||
|
|||||||
@@ -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 { 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; }
|
.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; }
|
||||||
|
|||||||
@@ -12,3 +12,9 @@ document.addEventListener("click", async (event) => {
|
|||||||
button.textContent = "Bitte manuell kopieren";
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Rezepte löschen{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="narrow">
|
||||||
|
<h1>Rezepte von {{ target }} löschen?</h1>
|
||||||
|
{% if count %}
|
||||||
|
<p>{{ count }} Rezept{{ count|pluralize:"e" }} (auch private) werden mit allen Fotos, Kommentaren und Favoriten endgültig gelöscht. Das Konto bleibt deaktiviert bestehen.</p>
|
||||||
|
<form method="post" class="form-buttons">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="button danger">Endgültig löschen</button>
|
||||||
|
<a class="button secondary" href="{% url 'accounts:admin_users' %}">Abbrechen</a>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ target }} hat keine Rezepte.</p>
|
||||||
|
<p><a href="{% url 'accounts:admin_users' %}">Zurück</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{% url 'accounts:admin_user_action' u.pk 'activate' %}">{% csrf_token %}<button class="button secondary">Aktivieren</button></form>
|
<form method="post" action="{% url 'accounts:admin_user_action' u.pk 'activate' %}">{% csrf_token %}<button class="button secondary">Aktivieren</button></form>
|
||||||
|
<a class="button danger" href="{% url 'accounts:admin_user_delete_recipes' u.pk %}">Rezepte löschen …</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+2
-2
@@ -11,12 +11,12 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="{% url 'core:home' %}">Kochbuch</a>
|
<a class="brand" href="{% url 'recipes:list' %}">Kochbuch</a>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<details class="menu">
|
<details class="menu">
|
||||||
<summary aria-label="Menü">☰</summary>
|
<summary aria-label="Menü">☰</summary>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="{% url 'core:home' %}">Rezepte</a>
|
<a href="{% url 'recipes:list' %}">Rezepte</a>
|
||||||
<a href="{% url 'accounts:profile' %}">Mein Konto</a>
|
<a href="{% url 'accounts:profile' %}">Mein Konto</a>
|
||||||
{% if user.is_admin %}
|
{% if user.is_admin %}
|
||||||
<a href="{% url 'accounts:admin_users' %}">Nutzer verwalten</a>
|
<a href="{% url 'accounts:admin_users' %}">Nutzer verwalten</a>
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Rezepte{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<h1>Hallo {{ user.display_name }}!</h1>
|
|
||||||
<div class="card">
|
|
||||||
<p>Hier entstehen bald die Rezepte. Konto, Einladungen und Rollen sind bereits eingerichtet.</p>
|
|
||||||
{% if user.is_admin %}
|
|
||||||
<p><a class="button" href="{% url 'accounts:admin_invitations' %}">Jemanden einladen</a></p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Kein Zugriff</h1>
|
<h1>Kein Zugriff</h1>
|
||||||
<p>Dafür fehlen dir die Berechtigungen.</p>
|
<p>Dafür fehlen dir die Berechtigungen.</p>
|
||||||
<p><a href="{% url 'core:home' %}">Zur Startseite</a></p>
|
<p><a href="{% url 'recipes:list' %}">Zur Startseite</a></p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Nicht gefunden</h1>
|
<h1>Nicht gefunden</h1>
|
||||||
<p>Diese Seite gibt es nicht oder du hast keinen Zugriff darauf.</p>
|
<p>Diese Seite gibt es nicht oder du hast keinen Zugriff darauf.</p>
|
||||||
<p><a href="{% url 'core:home' %}">Zur Startseite</a></p>
|
<p><a href="{% url 'recipes:list' %}">Zur Startseite</a></p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<form method="get" class="servings" aria-label="Portionen umrechnen">
|
||||||
|
{% if servings > 1 %}<a class="step-btn" href="?portionen={{ servings|add:'-1' }}" aria-label="Weniger">−</a>{% else %}<span class="step-btn disabled">−</span>{% endif %}
|
||||||
|
<label><input type="number" name="portionen" value="{{ servings }}" min="1" max="200" inputmode="numeric"> {{ recipe.servings_label|default:"Portionen" }}</label>
|
||||||
|
<a class="step-btn" href="?portionen={{ servings|add:'1' }}" aria-label="Mehr">+</a>
|
||||||
|
{% if servings != recipe.servings %}<a class="small-link" href="?">Original ({{ recipe.servings }})</a>{% endif %}
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Rezept löschen{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="narrow">
|
||||||
|
<h1>Rezept löschen?</h1>
|
||||||
|
<p>„{{ recipe.title }}“ wird mit allen Fotos{% if recipe.comments %}, Kommentaren{% endif %} und Favoriten endgültig gelöscht.</p>
|
||||||
|
<form method="post" class="form-buttons">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="button danger">Endgültig löschen</button>
|
||||||
|
<a class="button secondary" href="{% url 'recipes:detail' recipe.pk %}">Abbrechen</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ recipe.title }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<article class="recipe">
|
||||||
|
{% if images %}
|
||||||
|
<div class="gallery">
|
||||||
|
{% for img in images %}
|
||||||
|
<a href="{% url 'recipes:photo' img.uuid 'gross' %}"><img src="{% url 'recipes:photo' img.uuid 'gross' %}" alt="Foto {{ forloop.counter }}" width="{{ img.width }}" height="{{ img.height }}"{% if not forloop.first %} loading="lazy"{% endif %}></a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h1>{{ recipe.title }}</h1>
|
||||||
|
<p class="muted">
|
||||||
|
von {{ recipe.owner.display_name }} · {% if recipe.is_public %}öffentlich{% else %}🔒 privat{% endif %}
|
||||||
|
· geändert {{ recipe.updated_at|date:"d.m.Y" }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="recipe-actions">
|
||||||
|
<form method="post" action="{% url 'recipes:favorite' recipe.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="button secondary">{% if is_favorite %}★ Favorit{% else %}☆ Merken{% endif %}</button>
|
||||||
|
</form>
|
||||||
|
{% block extra_actions %}{% endblock %}
|
||||||
|
{% if is_owner %}<a class="button secondary" href="{% url 'recipes:edit' recipe.pk %}">Bearbeiten</a>{% endif %}
|
||||||
|
{% if can_delete %}<a class="button danger" href="{% url 'recipes:delete' recipe.pk %}">Löschen</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if recipe.description %}<p class="lead">{{ recipe.description|linebreaksbr }}</p>{% endif %}
|
||||||
|
|
||||||
|
{% if recipe.prep_minutes or recipe.cook_minutes %}
|
||||||
|
<ul class="facts">
|
||||||
|
{% if recipe.prep_minutes %}<li><span class="muted">Zubereitung</span> {{ recipe.prep_minutes }} Min.</li>{% endif %}
|
||||||
|
{% if recipe.cook_minutes %}<li><span class="muted">Koch-/Backzeit</span> {{ recipe.cook_minutes }} Min.</li>{% endif %}
|
||||||
|
{% if recipe.prep_minutes and recipe.cook_minutes %}<li><span class="muted">Gesamt</span> {{ recipe.total_minutes }} Min.</li>{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tags %}
|
||||||
|
<p class="chips">{% for tag in tags %}<a class="chip" href="{% url 'recipes:list' %}?tag={{ tag.name|urlencode }}">#{{ tag.name }}</a>{% endfor %}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Zutaten</h2>
|
||||||
|
{% include "recipes/_servings.html" %}
|
||||||
|
</div>
|
||||||
|
{% for group, items in ingredient_groups %}
|
||||||
|
{% if group %}<h3>{{ group }}</h3>{% endif %}
|
||||||
|
<ul class="ingredients">
|
||||||
|
{% for ing in items %}
|
||||||
|
<li><span class="amount">{{ ing.amount }} {{ ing.unit }}</span> <span>{{ ing.name }}{% if ing.note %}<span class="muted">, {{ ing.note }}</span>{% endif %}</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% empty %}
|
||||||
|
<p class="muted">Keine Zutaten angegeben.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Zubereitung</h2>
|
||||||
|
{% if steps %}
|
||||||
|
<ol class="steps">
|
||||||
|
{% for step in steps %}<li>{{ step.text|linebreaksbr }}</li>{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Keine Schritte angegeben.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if recipe.author_notes %}
|
||||||
|
<section class="card notes">
|
||||||
|
<h2>Notizen von {{ recipe.owner.display_name }}</h2>
|
||||||
|
<p>{{ recipe.author_notes|linebreaksbr }}</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if recipe.source_url %}
|
||||||
|
<p class="muted">Quelle: <a href="{{ recipe.source_url }}" rel="noopener noreferrer nofollow" target="_blank">{{ recipe.source_url|truncatechars:60 }}</a></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block after_recipe %}{% endblock %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{% if is_new %}Neues Rezept{% else %}{{ recipe.title }} bearbeiten{% endif %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if is_new %}Neues Rezept{% else %}Rezept bearbeiten{% endif %}</h1>
|
||||||
|
|
||||||
|
{% block before_form %}{% endblock %}
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data" class="card recipe-form" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include "form_fields.html" %}
|
||||||
|
{% block form_buttons %}
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit" class="button">Speichern</button>
|
||||||
|
<a class="button secondary" href="{% if recipe %}{% url 'recipes:detail' recipe.pk %}{% else %}{% url 'recipes:list' %}{% endif %}">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if recipe and images %}
|
||||||
|
<section class="card">
|
||||||
|
<h2>Fotos</h2>
|
||||||
|
<ul class="photo-admin">
|
||||||
|
{% for img in images %}
|
||||||
|
<li>
|
||||||
|
<img src="{% url 'recipes:photo' img.uuid 'klein' %}" alt="Foto {{ forloop.counter }}" loading="lazy">
|
||||||
|
<div class="action-buttons">
|
||||||
|
{% if img.is_cover %}
|
||||||
|
<span class="badge">Titelbild</span>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{% url 'recipes:photo_cover' recipe.pk img.pk %}">{% csrf_token %}<button class="button secondary small">Als Titelbild</button></form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{% url 'recipes:photo_delete' recipe.pk img.pk %}">{% csrf_token %}<button class="button danger small">Entfernen</button></form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Rezepte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Rezepte</h1>
|
||||||
|
<a class="button" href="{% url 'recipes:create' %}">+ Neu</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="search" role="search">
|
||||||
|
<div class="search-row">
|
||||||
|
{{ form.q }}
|
||||||
|
<button type="submit" class="button">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<details class="filters"{% if form.zutat.value or form.ansicht.value %} open{% endif %}>
|
||||||
|
<summary>Filter</summary>
|
||||||
|
<div class="field"><label for="{{ form.zutat.id_for_label }}">{{ form.zutat.label }}</label>{{ form.zutat }}</div>
|
||||||
|
<div class="field"><label for="{{ form.ansicht.id_for_label }}">{{ form.ansicht.label }}</label>{{ form.ansicht }}</div>
|
||||||
|
</details>
|
||||||
|
{% if active_tag %}<input type="hidden" name="tag" value="{{ active_tag }}">{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if tags %}
|
||||||
|
<nav class="chips" aria-label="Tags">
|
||||||
|
{% for tag in tags %}
|
||||||
|
{% if tag.name == active_tag %}
|
||||||
|
<a class="chip active" href="{% querystring tag=None seite=None %}">#{{ tag.name }} ✕</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="chip" href="{% querystring tag=tag.name seite=None %}">#{{ tag.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page.object_list %}
|
||||||
|
<ul class="recipe-grid">
|
||||||
|
{% for recipe in page.object_list %}
|
||||||
|
<li>
|
||||||
|
<a class="recipe-card" href="{% url 'recipes:detail' recipe.pk %}">
|
||||||
|
{% with cover=recipe.cover %}
|
||||||
|
{% if cover %}
|
||||||
|
<img src="{% url 'recipes:photo' cover.uuid 'klein' %}" alt="" loading="lazy" width="480" height="360">
|
||||||
|
{% else %}
|
||||||
|
<div class="placeholder" aria-hidden="true">🍲</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<div class="recipe-card-body">
|
||||||
|
<strong>{{ recipe.title }}</strong>
|
||||||
|
<span class="muted">
|
||||||
|
{% if recipe.total_minutes %}{{ recipe.total_minutes }} Min. · {% endif %}{{ recipe.owner.display_name }}
|
||||||
|
{% if not recipe.is_public %} · 🔒{% endif %}{% if recipe.is_favorite %} · ★{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if page.has_other_pages %}
|
||||||
|
<nav class="pager">
|
||||||
|
{% if page.has_previous %}<a class="button secondary" href="{% querystring seite=page.previous_page_number %}">← Zurück</a>{% endif %}
|
||||||
|
<span class="muted">Seite {{ page.number }} von {{ page.paginator.num_pages }}</span>
|
||||||
|
{% if page.has_next %}<a class="button secondary" href="{% querystring seite=page.next_page_number %}">Weiter →</a>{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% elif filtered %}
|
||||||
|
<p class="muted">Keine Rezepte gefunden. <a href="{% url 'recipes:list' %}">Filter zurücksetzen</a></p>
|
||||||
|
{% else %}
|
||||||
|
<div class="card empty">
|
||||||
|
<p>Noch keine Rezepte. Leg das erste an!</p>
|
||||||
|
<a class="button" href="{% url 'recipes:create' %}">Rezept anlegen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -35,3 +35,9 @@ def admin_client(admin_user):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def user_client(user):
|
def user_client(user):
|
||||||
return logged_in_client(user)
|
return logged_in_client(user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def media_root(tmp_path, settings):
|
||||||
|
settings.MEDIA_ROOT = tmp_path / "media"
|
||||||
|
return settings.MEDIA_ROOT
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from recipes import parsing
|
||||||
|
from recipes.models import Recipe
|
||||||
|
|
||||||
|
|
||||||
|
def make_recipe(owner, title="Testrezept", visibility=Recipe.Visibility.PUBLIC, ingredients="200 g Mehl\n2 Eier",
|
||||||
|
steps="Mischen.\n\nBacken.", tags=("kuchen",)):
|
||||||
|
recipe = Recipe.objects.create(owner=owner, title=title, visibility=visibility, servings=4)
|
||||||
|
recipe.set_ingredients(parsing.parse_ingredients(ingredients))
|
||||||
|
recipe.set_steps(parsing.parse_steps(steps))
|
||||||
|
recipe.set_tags(tags)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
def image_upload(name="foto.jpg", size=(1200, 900), fmt="JPEG", color=(200, 120, 40)):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", size, color).save(buffer, fmt)
|
||||||
|
content_type = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}[fmt]
|
||||||
|
return SimpleUploadedFile(name, buffer.getvalue(), content_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
|
def form_data(**overrides):
|
||||||
|
data = {
|
||||||
|
"title": "Pfannkuchen",
|
||||||
|
"description": "Einfach und schnell.",
|
||||||
|
"servings": 4,
|
||||||
|
"servings_label": "Portionen",
|
||||||
|
"prep_minutes": 10,
|
||||||
|
"cook_minutes": 15,
|
||||||
|
"ingredients_text": "250 g Mehl\n500 ml Milch\n3 Eier\n1 Prise Salz",
|
||||||
|
"steps_text": "Alles verrühren.\n\nIn der Pfanne ausbacken.",
|
||||||
|
"tags_text": "Frühstück, Süß",
|
||||||
|
"author_notes": "Mit Apfelmus servieren.",
|
||||||
|
"source_url": "",
|
||||||
|
"visibility": "private",
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
@@ -52,7 +52,7 @@ def test_deactivated_user_session_ends(admin_client, user):
|
|||||||
session = Client()
|
session = Client()
|
||||||
session.force_login(user)
|
session.force_login(user)
|
||||||
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"]))
|
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"]))
|
||||||
assert session.get(reverse("core:home")).status_code == 302
|
assert session.get(reverse("recipes:list")).status_code == 302
|
||||||
|
|
||||||
|
|
||||||
def test_admin_cannot_demote_or_deactivate_self(admin_client, admin_user):
|
def test_admin_cannot_demote_or_deactivate_self(admin_client, admin_user):
|
||||||
|
|||||||
+7
-7
@@ -30,7 +30,7 @@ def test_login_with_valid_credentials(user):
|
|||||||
client = Client()
|
client = Client()
|
||||||
response = login(client, "[email protected]", PASSWORD) # e-mail is case-insensitive
|
response = login(client, "[email protected]", PASSWORD) # e-mail is case-insensitive
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert client.get(reverse("core:home")).status_code == 200
|
assert client.get(reverse("recipes:list")).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_login_wrong_password_gives_generic_error(user):
|
def test_login_wrong_password_gives_generic_error(user):
|
||||||
@@ -89,13 +89,13 @@ def test_login_redirect_rejects_external_next(user):
|
|||||||
{"email": user.email, "password": PASSWORD, "next": "https://evil.example/"},
|
{"email": user.email, "password": PASSWORD, "next": "https://evil.example/"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert response["Location"] == reverse("core:home")
|
assert response["Location"] == reverse("recipes:list")
|
||||||
|
|
||||||
|
|
||||||
def test_logout_requires_post(user_client):
|
def test_logout_requires_post(user_client):
|
||||||
assert user_client.get(reverse("accounts:logout")).status_code == 405
|
assert user_client.get(reverse("accounts:logout")).status_code == 405
|
||||||
assert user_client.post(reverse("accounts:logout")).status_code == 302
|
assert user_client.post(reverse("accounts:logout")).status_code == 302
|
||||||
assert user_client.get(reverse("core:home")).status_code == 302
|
assert user_client.get(reverse("recipes:list")).status_code == 302
|
||||||
|
|
||||||
|
|
||||||
def test_login_requires_csrf_token(user):
|
def test_login_requires_csrf_token(user):
|
||||||
@@ -145,7 +145,7 @@ def test_register_with_valid_invitation(admin_user):
|
|||||||
assert new_user.role == User.Role.USER
|
assert new_user.role == User.Role.USER
|
||||||
invitation.refresh_from_db()
|
invitation.refresh_from_db()
|
||||||
assert invitation.used_by == new_user and invitation.status == "used"
|
assert invitation.used_by == new_user and invitation.status == "used"
|
||||||
assert client.get(reverse("core:home")).status_code == 200
|
assert client.get(reverse("recipes:list")).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_invitation_is_single_use(admin_user):
|
def test_invitation_is_single_use(admin_user):
|
||||||
@@ -243,7 +243,7 @@ def test_reset_link_ends_existing_sessions(admin_user, user):
|
|||||||
reverse("accounts:password_reset", args=[token_from(link)]),
|
reverse("accounts:password_reset", args=[token_from(link)]),
|
||||||
{"password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
|
{"password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
|
||||||
)
|
)
|
||||||
assert old_session.get(reverse("core:home")).status_code == 302
|
assert old_session.get(reverse("recipes:list")).status_code == 302
|
||||||
|
|
||||||
|
|
||||||
def test_temp_password_forces_change(admin_client, user):
|
def test_temp_password_forces_change(admin_client, user):
|
||||||
@@ -252,9 +252,9 @@ def test_temp_password_forces_change(admin_client, user):
|
|||||||
client = Client()
|
client = Client()
|
||||||
assert login(client, user.email, temp).status_code == 302
|
assert login(client, user.email, temp).status_code == 302
|
||||||
# Every page redirects to the password change until a new password is set.
|
# Every page redirects to the password change until a new password is set.
|
||||||
assert client.get(reverse("core:home"))["Location"] == reverse("accounts:password_change")
|
assert client.get(reverse("recipes:list"))["Location"] == reverse("accounts:password_change")
|
||||||
client.post(reverse("accounts:password_change"), {"password1": NEW_PASSWORD, "password2": NEW_PASSWORD})
|
client.post(reverse("accounts:password_change"), {"password1": NEW_PASSWORD, "password2": NEW_PASSWORD})
|
||||||
assert client.get(reverse("core:home")).status_code == 200
|
assert client.get(reverse("recipes:list")).status_code == 200
|
||||||
user.refresh_from_db()
|
user.refresh_from_db()
|
||||||
assert not user.must_change_password and user.check_password(NEW_PASSWORD)
|
assert not user.must_change_password and user.check_password(NEW_PASSWORD)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from recipes.parsing import (
|
||||||
|
format_amount,
|
||||||
|
ingredients_to_text,
|
||||||
|
parse_ingredient_line,
|
||||||
|
parse_ingredients,
|
||||||
|
parse_steps,
|
||||||
|
parse_tags,
|
||||||
|
scale_amount,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"line, amount, amount_max, unit, name, note",
|
||||||
|
[
|
||||||
|
("200 g Mehl, gesiebt", "200", None, "g", "Mehl", "gesiebt"),
|
||||||
|
("200g Mehl", "200", None, "g", "Mehl", ""),
|
||||||
|
("1,5l Milch", "1.5", None, "l", "Milch", ""),
|
||||||
|
("1 ½ TL Salz", "1.5", None, "TL", "Salz", ""),
|
||||||
|
("1/2 Bund Petersilie", "0.5", None, "Bund", "Petersilie", ""),
|
||||||
|
("2 1/2 Tassen Reis", "2.5", None, "Tassen", "Reis", ""),
|
||||||
|
("2-3 Zehen Knoblauch", "2", "3", "Zehen", "Knoblauch", ""),
|
||||||
|
("2 bis 3 EL Öl", "2", "3", "EL", "Öl", ""),
|
||||||
|
("2 Eier", "2", None, "", "Eier", ""),
|
||||||
|
("3 Essl. Zucker", "3", None, "EL", "Zucker", ""),
|
||||||
|
("- 1 Prise Salz", "1", None, "Prise", "Salz", ""),
|
||||||
|
("½ Zitrone, nur Saft", "0.5", None, "", "Zitrone", "nur Saft"),
|
||||||
|
("Salz und Pfeffer", None, None, "", "Salz und Pfeffer", ""),
|
||||||
|
("1 Dose Tomaten (400 g)", "1", None, "Dose", "Tomaten (400 g)", ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_ingredient_line(line, amount, amount_max, unit, name, note):
|
||||||
|
item = parse_ingredient_line(line)
|
||||||
|
assert item.amount == (Decimal(amount) if amount else None)
|
||||||
|
assert item.amount_max == (Decimal(amount_max) if amount_max else None)
|
||||||
|
assert (item.unit, item.name, item.note) == (unit, name, note)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ingredients_with_groups():
|
||||||
|
items = parse_ingredients("Für den Teig:\n200 g Mehl\n\nFür die Soße:\n1 Dose Tomaten\n")
|
||||||
|
assert [(i.group, i.name) for i in items] == [("Für den Teig", "Mehl"), ("Für die Soße", "Tomaten")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingredient_text_roundtrip():
|
||||||
|
text = "200 g Mehl, gesiebt\n2-3 Zehen Knoblauch\n\nFür die Soße:\n½ TL Salz\nPfeffer"
|
||||||
|
items = parse_ingredients(text)
|
||||||
|
assert parse_ingredients(ingredients_to_text(items)) == items
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_steps_by_blank_lines():
|
||||||
|
assert parse_steps("Ofen vorheizen.\nAuf 180 °C.\n\n2. Mischen.") == ["Ofen vorheizen. Auf 180 °C.", "Mischen."]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_steps_by_lines_and_strip_numbers():
|
||||||
|
assert parse_steps("1. Schneiden\n2) Braten\nSchritt 3: Servieren") == ["Schneiden", "Braten", "Servieren"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_tags_lowercase_unique():
|
||||||
|
assert parse_tags("Pasta, #Vegetarisch, Schnell Gemacht ,pasta,") == ["pasta", "vegetarisch", "schnell gemacht"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value, expected",
|
||||||
|
[("2", "2"), ("0.5", "½"), ("1.5", "1 ½"), ("0.333", "⅓"), ("0.25", "¼"), ("2.37", "2,37"), ("1.999", "2")],
|
||||||
|
)
|
||||||
|
def test_format_amount(value, expected):
|
||||||
|
assert format_amount(Decimal(value)) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_scale_amount_normalises_metric():
|
||||||
|
assert scale_amount(Decimal(600), None, "g", Decimal(2)) == (Decimal("1.2"), None, "kg")
|
||||||
|
assert scale_amount(Decimal(250), None, "ml", Decimal("0.5")) == (Decimal(125), None, "ml")
|
||||||
|
assert scale_amount(Decimal(2), Decimal(3), "EL", Decimal(2)) == (Decimal(4), Decimal(6), "EL")
|
||||||
|
assert scale_amount(None, None, "", Decimal(2)) == (None, None, "")
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Acceptance: a private recipe is reachable by its owner only – in no view and under no URL,
|
||||||
|
including image URLs. Other users and admins get 404 (indistinguishable from "does not exist")."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import Client
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from recipes.models import Favorite, Recipe, RecipeImage
|
||||||
|
|
||||||
|
from .recipe_helpers import image_upload, make_recipe
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
SECRET_TITLE = "Geheimrezept Omas Gulasch"
|
||||||
|
SECRET_INGREDIENT = "Zauberpaprika"
|
||||||
|
SECRET_TAG = "geheimtag"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def private_recipe(user):
|
||||||
|
recipe = make_recipe(user, title=SECRET_TITLE, visibility=Recipe.Visibility.PRIVATE,
|
||||||
|
ingredients=f"500 g {SECRET_INGREDIENT}", tags=(SECRET_TAG,))
|
||||||
|
owner_client(user).post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
|
||||||
|
assert recipe.images.count() == 1
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def image(private_recipe):
|
||||||
|
return RecipeImage.objects.get(recipe=private_recipe)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=["other", "admin"])
|
||||||
|
def outsider(request, other_user, admin_user):
|
||||||
|
c = Client()
|
||||||
|
c.force_login(other_user if request.param == "other" else admin_user)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def owner_client(user):
|
||||||
|
c = Client()
|
||||||
|
c.force_login(user)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def get_urls(recipe, image):
|
||||||
|
return [
|
||||||
|
reverse("recipes:detail", args=[recipe.pk]),
|
||||||
|
reverse("recipes:detail", args=[recipe.pk]) + "?portionen=8",
|
||||||
|
reverse("recipes:edit", args=[recipe.pk]),
|
||||||
|
reverse("recipes:delete", args=[recipe.pk]),
|
||||||
|
reverse("recipes:photo", args=[image.uuid, "gross"]),
|
||||||
|
reverse("recipes:photo", args=[image.uuid, "klein"]),
|
||||||
|
*extra_get_urls(recipe, image),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def post_urls(recipe, image):
|
||||||
|
return [
|
||||||
|
reverse("recipes:edit", args=[recipe.pk]),
|
||||||
|
reverse("recipes:delete", args=[recipe.pk]),
|
||||||
|
reverse("recipes:favorite", args=[recipe.pk]),
|
||||||
|
reverse("recipes:photo_upload", args=[recipe.pk]),
|
||||||
|
reverse("recipes:photo_cover", args=[recipe.pk, image.pk]),
|
||||||
|
reverse("recipes:photo_delete", args=[recipe.pk, image.pk]),
|
||||||
|
*extra_post_urls(recipe, image),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def extra_get_urls(recipe, image):
|
||||||
|
"""Hook for later features (comments, cooking mode, …)."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def extra_post_urls(recipe, image):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_sees_everything(user, private_recipe, image):
|
||||||
|
c = owner_client(user)
|
||||||
|
for url in get_urls(private_recipe, image):
|
||||||
|
assert c.get(url).status_code == 200, url
|
||||||
|
assert SECRET_TITLE in c.get(reverse("recipes:list")).content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_outsider_gets_404_on_every_get_url(outsider, private_recipe, image):
|
||||||
|
for url in get_urls(private_recipe, image):
|
||||||
|
assert outsider.get(url).status_code == 404, url
|
||||||
|
|
||||||
|
|
||||||
|
def test_outsider_gets_404_on_every_post_url(outsider, private_recipe, image):
|
||||||
|
for url in post_urls(private_recipe, image):
|
||||||
|
response = outsider.post(url, {"title": "gehackt", "servings": 1, "visibility": "public", "text": "x"})
|
||||||
|
assert response.status_code == 404, url
|
||||||
|
private_recipe.refresh_from_db()
|
||||||
|
assert private_recipe.title == SECRET_TITLE and not private_recipe.is_public
|
||||||
|
assert RecipeImage.objects.filter(pk=image.pk).exists()
|
||||||
|
assert not Favorite.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{"q": "Geheimrezept"},
|
||||||
|
{"q": SECRET_INGREDIENT},
|
||||||
|
{"q": SECRET_TAG},
|
||||||
|
{"zutat": SECRET_INGREDIENT},
|
||||||
|
{"tag": SECRET_TAG},
|
||||||
|
{"ansicht": "favoriten"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_private_recipe_absent_from_list_and_search(outsider, private_recipe, image, query):
|
||||||
|
html = outsider.get(reverse("recipes:list"), query).content.decode()
|
||||||
|
assert SECRET_TITLE not in html
|
||||||
|
assert str(image.uuid) not in html
|
||||||
|
assert reverse("recipes:detail", args=[private_recipe.pk]) not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_tag_not_listed(outsider, private_recipe):
|
||||||
|
assert f"#{SECRET_TAG}" not in outsider.get(reverse("recipes:list")).content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_favorite_disappears_when_recipe_turns_private(user, other_user):
|
||||||
|
recipe = make_recipe(user, title="Bald privat")
|
||||||
|
Favorite.objects.create(user=other_user, recipe=recipe)
|
||||||
|
recipe.visibility = Recipe.Visibility.PRIVATE
|
||||||
|
recipe.save()
|
||||||
|
c = owner_client(other_user)
|
||||||
|
assert "Bald privat" not in c.get(reverse("recipes:list"), {"ansicht": "favoriten"}).content.decode()
|
||||||
|
assert c.get(reverse("recipes:detail", args=[recipe.pk])).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_gets_redirected_everywhere(private_recipe, image):
|
||||||
|
c = Client()
|
||||||
|
for url in get_urls(private_recipe, image):
|
||||||
|
response = c.get(url)
|
||||||
|
assert response.status_code == 302 and "/anmelden/" in response["Location"], url
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_to_queryset_rule(user, other_user, admin_user, private_recipe):
|
||||||
|
public = make_recipe(user, title="Öffentlich")
|
||||||
|
assert set(Recipe.objects.visible_to(user)) == {private_recipe, public}
|
||||||
|
assert set(Recipe.objects.visible_to(other_user)) == {public}
|
||||||
|
assert set(Recipe.objects.visible_to(admin_user)) == {public}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.urls import reverse
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from recipes.models import Favorite, Recipe, RecipeImage
|
||||||
|
|
||||||
|
from .recipe_helpers import form_data, image_upload, make_recipe
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_recipe_with_photos(user_client, user, media_root):
|
||||||
|
data = form_data(photos=[image_upload("a.jpg"), image_upload("b.png", fmt="PNG")])
|
||||||
|
response = user_client.post(reverse("recipes:create"), data)
|
||||||
|
recipe = Recipe.objects.get()
|
||||||
|
assert response.status_code == 302 and response["Location"] == reverse("recipes:detail", args=[recipe.pk])
|
||||||
|
assert recipe.owner == user and recipe.visibility == "private"
|
||||||
|
assert [i.name for i in recipe.ingredients.all()] == ["Mehl", "Milch", "Eier", "Salz"]
|
||||||
|
assert [s.text for s in recipe.steps.all()] == ["Alles verrühren.", "In der Pfanne ausbacken."]
|
||||||
|
assert sorted(t.name for t in recipe.tags.all()) == ["frühstück", "süß"]
|
||||||
|
imgs = list(recipe.images.all())
|
||||||
|
assert len(imgs) == 2 and sum(i.is_cover for i in imgs) == 1
|
||||||
|
for img in imgs:
|
||||||
|
assert (Path(media_root) / img.file).exists() and (Path(media_root) / img.thumb).exists()
|
||||||
|
assert img.width <= 1600 and img.height <= 1600
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_shows_structured_recipe(user_client, user):
|
||||||
|
recipe = make_recipe(user)
|
||||||
|
html = user_client.get(reverse("recipes:detail", args=[recipe.pk])).content.decode()
|
||||||
|
assert "Mehl" in html and "Backen." in html and "#kuchen" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_form_roundtrip(user_client, user):
|
||||||
|
user_client.post(reverse("recipes:create"), form_data())
|
||||||
|
recipe = Recipe.objects.get()
|
||||||
|
form = user_client.get(reverse("recipes:edit", args=[recipe.pk])).context["form"]
|
||||||
|
data = form_data(ingredients_text=form.initial["ingredients_text"], steps_text=form.initial["steps_text"],
|
||||||
|
tags_text=form.initial["tags_text"], title="Pfannkuchen deluxe")
|
||||||
|
user_client.post(reverse("recipes:edit", args=[recipe.pk]), data)
|
||||||
|
recipe.refresh_from_db()
|
||||||
|
assert recipe.title == "Pfannkuchen deluxe"
|
||||||
|
assert [i.name for i in recipe.ingredients.all()] == ["Mehl", "Milch", "Eier", "Salz"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_owner_can_edit_public_recipe(client, user, other_user):
|
||||||
|
recipe = make_recipe(user)
|
||||||
|
client.force_login(other_user)
|
||||||
|
assert client.get(reverse("recipes:detail", args=[recipe.pk])).status_code == 200
|
||||||
|
assert client.get(reverse("recipes:edit", args=[recipe.pk])).status_code == 404
|
||||||
|
assert client.post(reverse("recipes:delete", args=[recipe.pk])).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_delete_public_recipe(admin_client, user, media_root):
|
||||||
|
recipe = make_recipe(user)
|
||||||
|
admin_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
|
||||||
|
assert not RecipeImage.objects.exists() # admin is not the owner → upload refused (404)
|
||||||
|
assert admin_client.post(reverse("recipes:delete", args=[recipe.pk])).status_code == 302
|
||||||
|
assert not Recipe.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_removes_image_files(user_client, user, media_root):
|
||||||
|
user_client.post(reverse("recipes:create"), form_data(photos=[image_upload()]))
|
||||||
|
recipe = Recipe.objects.get()
|
||||||
|
img = recipe.images.get()
|
||||||
|
user_client.post(reverse("recipes:delete", args=[recipe.pk]))
|
||||||
|
assert not (Path(media_root) / img.file).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_non_image(user_client):
|
||||||
|
fake = SimpleUploadedFile("evil.jpg", b"<?php echo 1; ?>", content_type="image/jpeg")
|
||||||
|
response = user_client.post(reverse("recipes:create"), form_data(photos=[fake]))
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "kein unterstütztes Bild" in response.content.decode()
|
||||||
|
assert not Recipe.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_too_large(user_client, settings):
|
||||||
|
settings.IMAGE_MAX_UPLOAD_MB = 0
|
||||||
|
response = user_client.post(reverse("recipes:create"), form_data(photos=[image_upload()]))
|
||||||
|
assert "zu groß" in response.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_gif(user_client):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (10, 10)).save(buffer, "GIF")
|
||||||
|
gif = SimpleUploadedFile("a.gif", buffer.getvalue(), content_type="image/gif")
|
||||||
|
response = user_client.post(reverse("recipes:create"), form_data(photos=[gif]))
|
||||||
|
assert "kein unterstütztes Bild" in response.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_image_has_no_exif(user_client, media_root):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img = Image.new("RGB", (800, 600))
|
||||||
|
exif = Image.Exif()
|
||||||
|
exif[0x8825] = {1: "N", 2: (52.0, 31.0, 0.0)} # GPS info
|
||||||
|
exif[0x010F] = "SpyCam"
|
||||||
|
img.save(buffer, "JPEG", exif=exif)
|
||||||
|
upload = SimpleUploadedFile("gps.jpg", buffer.getvalue(), content_type="image/jpeg")
|
||||||
|
user_client.post(reverse("recipes:create"), form_data(photos=[upload]))
|
||||||
|
stored = Image.open(Path(media_root) / RecipeImage.objects.get().file)
|
||||||
|
assert stored.format == "WEBP"
|
||||||
|
assert not stored.getexif()
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_limit_per_recipe(user_client, user, settings):
|
||||||
|
settings.IMAGE_MAX_PER_RECIPE = 2
|
||||||
|
recipe = make_recipe(user)
|
||||||
|
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload(), image_upload()]})
|
||||||
|
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
|
||||||
|
assert recipe.images.count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_switch_and_delete(user_client, user):
|
||||||
|
recipe = make_recipe(user)
|
||||||
|
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload(), image_upload()]})
|
||||||
|
first, second = recipe.images.order_by("position")
|
||||||
|
assert first.is_cover
|
||||||
|
user_client.post(reverse("recipes:photo_cover", args=[recipe.pk, second.pk]))
|
||||||
|
assert recipe.images.get(is_cover=True) == second
|
||||||
|
user_client.post(reverse("recipes:photo_delete", args=[recipe.pk, second.pk]))
|
||||||
|
assert recipe.images.get().is_cover
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_by_title_ingredient_and_tag(user_client, user):
|
||||||
|
make_recipe(user, title="Linsensuppe", ingredients="200 g rote Linsen", tags=("suppe",))
|
||||||
|
make_recipe(user, title="Tomatensalat", ingredients="4 Tomaten", tags=("salat",))
|
||||||
|
|
||||||
|
def titles(**q):
|
||||||
|
return [r.title for r in user_client.get(reverse("recipes:list"), q).context["page"].object_list]
|
||||||
|
|
||||||
|
assert titles(q="linsen") == ["Linsensuppe"]
|
||||||
|
assert titles(q="TOMATEN") == ["Tomatensalat"]
|
||||||
|
assert titles(q="suppe") == ["Linsensuppe"]
|
||||||
|
assert titles(zutat="linsen") == ["Linsensuppe"]
|
||||||
|
assert titles(tag="salat") == ["Tomatensalat"]
|
||||||
|
assert titles(q="linsen tomaten") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tags_stored_lowercase(user):
|
||||||
|
recipe = make_recipe(user, tags=("Vegan", "SCHNELL"))
|
||||||
|
assert sorted(t.name for t in recipe.tags.all()) == ["schnell", "vegan"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_favorites(user_client, user, other_user):
|
||||||
|
recipe = make_recipe(other_user, title="Fremdes Rezept")
|
||||||
|
make_recipe(other_user, title="Anderes")
|
||||||
|
user_client.post(reverse("recipes:favorite", args=[recipe.pk]))
|
||||||
|
assert Favorite.objects.filter(user=user, recipe=recipe).exists()
|
||||||
|
titles = [r.title for r in user_client.get(reverse("recipes:list"), {"ansicht": "favoriten"}).context["page"].object_list]
|
||||||
|
assert titles == ["Fremdes Rezept"]
|
||||||
|
user_client.post(reverse("recipes:favorite", args=[recipe.pk]))
|
||||||
|
assert not Favorite.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_favorite_redirect_is_local_only(user_client, user):
|
||||||
|
recipe = make_recipe(user)
|
||||||
|
response = user_client.post(reverse("recipes:favorite", args=[recipe.pk]), {"next": "https://evil.example/"})
|
||||||
|
assert response["Location"] == reverse("recipes:detail", args=[recipe.pk])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mine_filter(user_client, user, other_user):
|
||||||
|
make_recipe(user, title="Meins")
|
||||||
|
make_recipe(other_user, title="Deins")
|
||||||
|
titles = [r.title for r in user_client.get(reverse("recipes:list"), {"ansicht": "meine"}).context["page"].object_list]
|
||||||
|
assert titles == ["Meins"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_deletes_recipes_of_deactivated_user(admin_client, user):
|
||||||
|
make_recipe(user, visibility=Recipe.Visibility.PRIVATE)
|
||||||
|
make_recipe(user)
|
||||||
|
url = reverse("accounts:admin_user_delete_recipes", args=[user.pk])
|
||||||
|
assert admin_client.get(url).status_code == 404 # only for deactivated users
|
||||||
|
user.is_active = False
|
||||||
|
user.save()
|
||||||
|
assert admin_client.get(url).context["count"] == 2
|
||||||
|
admin_client.post(url)
|
||||||
|
assert not Recipe.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_cannot_bulk_delete(user_client, other_user):
|
||||||
|
other_user.is_active = False
|
||||||
|
other_user.save()
|
||||||
|
make_recipe(other_user)
|
||||||
|
assert user_client.post(reverse("accounts:admin_user_delete_recipes", args=[other_user.pk])).status_code == 403
|
||||||
|
assert Recipe.objects.exists()
|
||||||
Reference in New Issue
Block a user