Add phase 3: comments and cooking mode

- Comments on visible recipes; author and admin may delete; per-user
  hourly rate limit (COMMENT_RATE_LIMIT_PER_HOUR)
- Cooking mode with large text, tick-off checklists, serving scaling and
  Screen Wake Lock to keep the display on
- Serving calculator auto-submits on change
- Privacy tests cover comment and cooking-mode URLs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kyle Dammann
2026-10-30 21:39:15 +01:00
co-authored by Claude Opus 5.5
parent 6f7e4f92ca
commit 9256e94286
14 changed files with 342 additions and 16 deletions
+65 -7
View File
@@ -1,4 +1,5 @@
import logging
from datetime import timedelta
from django.conf import settings
from django.contrib import messages
@@ -7,12 +8,15 @@ 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 images
from .forms import PhotoUploadForm, RecipeForm, SearchForm
from .models import Favorite, Recipe, RecipeImage, Tag
from .forms import CommentForm, PhotoUploadForm, RecipeForm, SearchForm
from .models import Comment, Favorite, Recipe, RecipeImage, Tag
logger = logging.getLogger(__name__)
@@ -61,15 +65,28 @@ def recipe_list(request):
})
@require_GET
def recipe_detail(request, pk):
recipe = get_object_or_404(_visible(request).select_related("owner"), pk=pk)
def _requested_servings(request, recipe):
try:
servings = int(request.GET.get("portionen", recipe.servings))
except ValueError:
servings = recipe.servings
servings = max(1, min(servings, 200))
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),
@@ -79,7 +96,7 @@ def recipe_detail(request, pk):
"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 recipe_create(request):
@@ -188,3 +205,44 @@ def photo_serve(request, image_uuid, size):
# 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")