- 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]>
191 lines
7.6 KiB
Python
191 lines
7.6 KiB
Python
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
|