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:
co-authored by
Claude Opus 5.5
parent
6f7e4f92ca
commit
9256e94286
+9
-1
@@ -3,7 +3,7 @@ from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from . import images, parsing
|
||||
from .models import Recipe
|
||||
from .models import Comment, Recipe
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
@@ -135,3 +135,11 @@ class SearchForm(forms.Form):
|
||||
|
||||
def ingredient_terms(self):
|
||||
return [t.strip() for t in self.cleaned_data.get("zutat", "").split(",") if t.strip()]
|
||||
|
||||
|
||||
class CommentForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ["text"]
|
||||
widgets = {"text": forms.Textarea(attrs={"rows": 3, "placeholder": "Hast du es nachgekocht? Tipps?", "maxlength": 2000})}
|
||||
labels = {"text": "Kommentar schreiben"}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.18 on 2026-10-30 20:37
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recipes', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Comment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.TextField(max_length=2000, verbose_name='Kommentar')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)),
|
||||
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='recipes.recipe')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -197,3 +197,26 @@ class Favorite(models.Model):
|
||||
|
||||
class Meta:
|
||||
constraints = [models.UniqueConstraint(fields=["user", "recipe"], name="favorite_unique")]
|
||||
|
||||
|
||||
class CommentQuerySet(models.QuerySet):
|
||||
def deletable_by(self, user):
|
||||
if user is None or not user.is_authenticated:
|
||||
return self.none()
|
||||
visible = self.filter(recipe__in=Recipe.objects.visible_to(user))
|
||||
return visible if user.is_admin else visible.filter(author=user)
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="comments")
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="comments")
|
||||
text = models.TextField("Kommentar", max_length=2000)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = CommentQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Kommentar von {self.author} zu {self.recipe}"
|
||||
|
||||
@@ -10,6 +10,9 @@ urlpatterns = [
|
||||
path("rezepte/<int:pk>/", views.recipe_detail, name="detail"),
|
||||
path("rezepte/<int:pk>/bearbeiten/", views.recipe_edit, name="edit"),
|
||||
path("rezepte/<int:pk>/loeschen/", views.recipe_delete, name="delete"),
|
||||
path("rezepte/<int:pk>/kochen/", views.cook_mode, name="cook"),
|
||||
path("rezepte/<int:pk>/kommentare/", views.comment_create, name="comment_create"),
|
||||
path("rezepte/<int:pk>/kommentare/<int:comment_id>/loeschen/", views.comment_delete, name="comment_delete"),
|
||||
path("rezepte/<int:pk>/favorit/", views.favorite_toggle, name="favorite"),
|
||||
path("rezepte/<int:pk>/fotos/", views.photo_upload, name="photo_upload"),
|
||||
path("rezepte/<int:pk>/fotos/<int:image_id>/titelbild/", views.photo_cover, name="photo_cover"),
|
||||
|
||||
+65
-7
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user