Files
Kochbuch/recipes/views.py
T
Kyle DammannandClaude Opus 5.5 1bf8ef8b65 Add phase 4: AI cleanup and photo/text import via OpenAI
- recipes/ai.py: Chat Completions with strict JSON schema (Structured
  Outputs), Pydantic re-validation, plausibility clamping, prompt treats
  input as data only
- "Mit KI aufbereiten" on create/edit form returns an editable draft
- Import page: photo/screenshot (vision, downscaled JPEG, not stored) and
  pasted text; drafts pre-fill the recipe form with a preview
- Clear UI messages for timeout, rate limit, quota, auth, model and
  connection errors; without OPENAI_API_KEY AI features show a notice
- Optional per-user and global daily AI limits (default unlimited)
- Tests for response parsing, schema strictness, error mapping, views

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:43:30 +01:00

272 lines
11 KiB
Python

import logging
from datetime import timedelta
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.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, 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 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),
})
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)
attach_photos(request, recipe, form.cleaned_data.get("photos") or [])
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")