Files
Kyle DammannandClaude Opus 5.5 23adb882bd Add tabs for all, own and favourite recipes
- Tabs "Alle", "Eigene Rezepte", "Favoriten" above the recipe list;
  search and tag filters stay within the active tab
- Menu link to own recipes; empty states per tab
- Fix recipe card images rendering portrait instead of 4:3

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:45:29 +01:00

292 lines
12 KiB
Python

import logging
from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.core.files.uploadedfile import SimpleUploadedFile
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.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_GET, require_POST
from core import ratelimit
from . import ai, fetch, images
from .forms import CommentForm, PhotoUploadForm, RecipeForm, SearchForm
from .models import Comment, Favorite, Recipe, RecipeImage, Tag
logger = logging.getLogger(__name__)
PAGE_SIZE = 24
def _visible(request):
return Recipe.objects.visible_to(request.user)
def download_image(request, url):
"""Fetch a suggested source photo (SSRF-safe). Returns an uploaded-file object or None."""
try:
result = fetch.fetch(url, accept="image/*", max_bytes=images.max_upload_bytes())
except fetch.FetchError as error:
messages.warning(request, f"Das Bild der Quelle konnte nicht geladen werden: {error.user_message}")
return None
if result.status != 200 or not result.content_type.lower().startswith("image/"):
messages.warning(request, "Das Bild der Quelle konnte nicht geladen werden.")
return None
return SimpleUploadedFile("quelle.jpg", result.body, content_type=result.content_type)
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", ""), "active_view": view,
"filtered": bool(data.get("q") or data.get("zutat") or data.get("tag") or view),
"only_view": bool(view and not (data.get("q") or data.get("zutat") or data.get("tag"))),
})
def _requested_servings(request, recipe):
try:
servings = int(request.GET.get("portionen", recipe.servings))
except ValueError:
servings = recipe.servings
return max(1, min(servings, 200))
@require_GET
def recipe_detail(request, pk):
recipe = get_object_or_404(_visible(request).select_related("owner"), pk=pk)
return _render_detail(request, recipe)
def _render_detail(request, recipe, comment_form=None, status=200):
servings = _requested_servings(request, recipe)
comments = recipe.comments.select_related("author")
deletable = set(Comment.objects.deletable_by(request.user).filter(recipe=recipe).values_list("pk", flat=True))
return render(request, "recipes/detail.html", {
"comments": comments,
"deletable_comments": deletable,
"comment_form": comment_form or CommentForm(),
"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(),
}, status=status)
def _render_form(request, form, recipe=None, **extra):
context = {"form": form, "is_new": recipe is None, "recipe": recipe,
"images": recipe.images.all() if recipe else [], **extra}
return render(request, "recipes/form.html", context)
def _ai_cleanup(request, recipe=None):
"""'Mit KI aufbereiten' on the manual form: replace the fields with an AI draft (not saved)."""
data = request.POST
try:
draft = ai.structure_text(request.user, ai.form_text_for_ai(data))
except ai.AIError as error:
messages.error(request, error.user_message)
return _render_form(request, RecipeForm(initial=data.dict(), instance=recipe), recipe)
keep = {key: data.get(key, "") for key in ("visibility", "source_url") if data.get(key)}
form = RecipeForm(initial={**draft.to_form_initial(), **keep}, instance=recipe)
return _render_form(request, form, recipe, ai_draft=draft)
def recipe_create(request):
if request.method == "POST" and "ai" in request.POST:
return _ai_cleanup(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)
photos = list(form.cleaned_data.get("photos") or [])
if form.cleaned_data.get("use_import_image") and form.cleaned_data.get("import_image_url"):
downloaded = download_image(request, form.cleaned_data["import_image_url"])
if downloaded:
photos.insert(0, downloaded)
attach_photos(request, recipe, photos[: settings.IMAGE_MAX_PER_RECIPE])
messages.success(request, "Rezept gespeichert.")
return redirect("recipes:detail", pk=recipe.pk)
return _render_form(request, form)
def recipe_edit(request, pk):
recipe = get_object_or_404(Recipe.objects.editable_by(request.user), pk=pk)
if request.method == "POST" and "ai" in request.POST:
return _ai_cleanup(request, recipe)
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_form(request, form, recipe)
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
@require_GET
def cook_mode(request, pk):
recipe = get_object_or_404(_visible(request), pk=pk)
servings = _requested_servings(request, recipe)
return render(request, "recipes/cook.html", {
"recipe": recipe,
"servings": servings,
"ingredient_groups": recipe.scaled_ingredients(servings),
"steps": recipe.steps.all(),
})
@require_POST
def comment_create(request, pk):
recipe = get_object_or_404(_visible(request), pk=pk)
form = CommentForm(request.POST)
if not form.is_valid():
return _render_detail(request, recipe, comment_form=form, status=400)
allowed = ratelimit.hit(f"comment:{request.user.pk}", settings.COMMENT_RATE_LIMIT_PER_HOUR, timedelta(hours=1))
if not allowed:
messages.error(request, "Du hast gerade sehr viele Kommentare geschrieben. Bitte versuche es später erneut.")
return redirect("recipes:detail", pk=recipe.pk)
comment = form.save(commit=False)
comment.recipe = recipe
comment.author = request.user
comment.save()
return redirect(f"{recipe_url(recipe)}#kommentar-{comment.pk}")
def recipe_url(recipe):
return reverse("recipes:detail", args=[recipe.pk])
@require_POST
def comment_delete(request, pk, comment_id):
comment = get_object_or_404(Comment.objects.deletable_by(request.user), pk=comment_id, recipe_id=pk)
comment.delete()
messages.success(request, "Kommentar gelöscht.")
return redirect(f"{recipe_url(comment.recipe)}#kommentare")