Add phase 2: recipes with visibility, photos, search and favourites
- Recipe model with ingredients (amount/range, unit, group, note), steps, lowercase tags, author notes, source URL and private/public visibility - Central RecipeQuerySet.visible_to() used by every read path; edit only by owner, delete by owner or admin (visible recipes only) - Text-based editing: ingredient/step parser with roundtrip and scaling - Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF, thumbnails, cover image, served only through permission-checked view - Search across title, description, ingredients, tags; ingredient and tag filters; "mine" and favourites views - Admin can delete all recipes of a deactivated user - Acceptance tests: private recipes unreachable for others and admins in list, search, tags, favourites, detail, edit, delete and image URLs Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef9deaeff7
commit
6f7e4f92ca
@@ -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
|
||||
Reference in New Issue
Block a user