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")