- 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]>
292 lines
12 KiB
Python
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")
|