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:
Kyle Dammann
2026-10-30 21:37:13 +01:00
co-authored by Claude Opus 5.5
parent ef9deaeff7
commit 6f7e4f92ca
41 changed files with 1870 additions and 37 deletions
+4
View File
@@ -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
+9 -3
View File
@@ -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).
+19
View File
@@ -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):
+1
View File
@@ -12,6 +12,7 @@ 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>/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/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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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"
-1
View File
@@ -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"),
] ]
-4
View File
@@ -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
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class RecipesConfig(AppConfig):
name = "recipes"
verbose_name = "Rezepte"
+137
View File
@@ -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()]
+124
View File
@@ -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
+114
View File
@@ -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'),
),
]
View File
+199
View File
@@ -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")]
+248
View File
@@ -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)
View File
+18
View File
@@ -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"),
]
+190
View File
@@ -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
+5
View File
@@ -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
+70
View File
@@ -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; }
+6
View File
@@ -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 %}
+1
View File
@@ -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
View File
@@ -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>
-11
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+6
View File
@@ -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>
+13
View File
@@ -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 %}
+85
View File
@@ -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 %}
+39
View File
@@ -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 %}
+73
View File
@@ -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 %}
+6
View File
@@ -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
+42
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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)
+77
View File
@@ -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, "")
+145
View File
@@ -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}
+190
View File
@@ -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()