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
+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