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