diff --git a/.env.example b/.env.example index 74ae218..38a2a38 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,7 @@ LOGIN_LOCKOUT_MINUTES=15 # --- Fotos --------------------------------------------------------------------- IMAGE_MAX_UPLOAD_MB=15 IMAGE_MAX_PER_RECIPE=10 + +# --- Kommentare ------------------------------------------------------------------ +# Maximal so viele Kommentare pro Nutzer und Stunde (0 = unbegrenzt) +COMMENT_RATE_LIMIT_PER_HOUR=30 diff --git a/CLAUDE.md b/CLAUDE.md index 17de651..e6e80b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ port; TLS, domain and proxy are out of scope. ## Status - [x] Phase 1: Docker setup, DB, auth with invitations, roles - [x] Phase 2: Recipes CRUD, visibility (private/public), photos, notes, search, favourites -- [ ] Phase 3: Comments, serving calculator, cooking mode (wake lock) +- [x] Phase 3: Comments, serving calculator (server-side `?portionen=`), cooking mode (wake lock) - [ ] Phase 4: AI (OpenAI): clean up manual input + photo/screenshot import - [ ] Phase 5: Link import (schema.org JSON-LD, Instagram with paste fallback, Android PWA share target) - [ ] Phase 6: Backup script, full README @@ -25,11 +25,13 @@ no JS build step: plain forms with small progressive enhancements in `static/js/ - `accounts/` – custom `User` (e-mail login, `role` = user|admin), `Invitation`, `PasswordResetToken`, login/registration/reset views, admin area (`admin_views.py`, `/verwaltung/…`), `ensure_admin` management command (first admin from env). -- `recipes/` – `Recipe` (+ `Ingredient`, `Step`, `Tag`, `RecipeImage`, `Favorite`). `RecipeQuerySet.visible_to/ +- `recipes/` – `Recipe` (+ `Ingredient`, `Step`, `Tag`, `RecipeImage`, `Favorite`, `Comment`). `RecipeQuerySet.visible_to/ editable_by/deletable_by/search` hold all access rules. `parsing.py` converts the text form fields (one ingredient per line, `Gruppe:` headers, steps separated by blank lines) to structured data and scales amounts. `images.py` validates/re-encodes uploads (WebP 1600 px + 480 px thumb, no EXIF). Photos are served only via `recipes:photo` (`/bilder//.webp`) after a visibility check. + Comments: deletable by their author and admins only (`CommentQuerySet.deletable_by`), rate-limited. + `tests/test_privacy.py` lists every recipe URL – add new recipe routes there. - `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`. - `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn. - `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker). diff --git a/config/settings.py b/config/settings.py index 99661db..2e7ed4a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -138,6 +138,7 @@ LOGIN_LOCKOUT_MINUTES = env_int("LOGIN_LOCKOUT_MINUTES", 15) IMAGE_MAX_UPLOAD_MB = env_int("IMAGE_MAX_UPLOAD_MB", 15) IMAGE_MAX_PER_RECIPE = env_int("IMAGE_MAX_PER_RECIPE", 10) +COMMENT_RATE_LIMIT_PER_HOUR = env_int("COMMENT_RATE_LIMIT_PER_HOUR", 30) # Request body limit for non-file fields; files are limited per image above. DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 diff --git a/recipes/forms.py b/recipes/forms.py index 6801322..56a37e2 100644 --- a/recipes/forms.py +++ b/recipes/forms.py @@ -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"} diff --git a/recipes/migrations/0002_comment.py b/recipes/migrations/0002_comment.py new file mode 100644 index 0000000..f1fe3a7 --- /dev/null +++ b/recipes/migrations/0002_comment.py @@ -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'], + }, + ), + ] diff --git a/recipes/models.py b/recipes/models.py index 20966a6..eea4ab6 100644 --- a/recipes/models.py +++ b/recipes/models.py @@ -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}" diff --git a/recipes/urls.py b/recipes/urls.py index da639c7..802e18a 100644 --- a/recipes/urls.py +++ b/recipes/urls.py @@ -10,6 +10,9 @@ urlpatterns = [ path("rezepte//", views.recipe_detail, name="detail"), path("rezepte//bearbeiten/", views.recipe_edit, name="edit"), path("rezepte//loeschen/", views.recipe_delete, name="delete"), + path("rezepte//kochen/", views.cook_mode, name="cook"), + path("rezepte//kommentare/", views.comment_create, name="comment_create"), + path("rezepte//kommentare//loeschen/", views.comment_delete, name="comment_delete"), path("rezepte//favorit/", views.favorite_toggle, name="favorite"), path("rezepte//fotos/", views.photo_upload, name="photo_upload"), path("rezepte//fotos//titelbild/", views.photo_cover, name="photo_cover"), diff --git a/recipes/views.py b/recipes/views.py index 41a9317..dbc2d27 100644 --- a/recipes/views.py +++ b/recipes/views.py @@ -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") diff --git a/static/css/app.css b/static/css/app.css index 8d9c9e1..f34bf09 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -253,3 +253,21 @@ h3 { font-size: 1rem; margin: 1rem 0 0.25rem; color: var(--muted); } .form-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; } .photo-admin { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); } .photo-admin img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; display: block; margin-bottom: 0.4rem; } + +/* --- Comments & cooking mode ------------------------------------------------------ */ +.comment { padding: 0.75rem 0; border-bottom: 1px solid var(--border); } +.comment p { margin: 0.25rem 0; } +.comment form { margin: 0; } +.comment-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; } +.comment-form { margin-top: 1rem; } + +.cook { font-size: 1.15rem; } +.cook h1 { font-size: 1.5rem; } +.checklist { list-style: none; padding: 0; margin: 0.5rem 0 0; } +.checklist li { border-bottom: 1px solid var(--border); } +.checklist li:last-child { border-bottom: 0; } +.checklist label { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.7rem 0; cursor: pointer; font-weight: 400; } +.checklist input { width: 1.4rem; height: 1.4rem; margin-top: 0.15rem; flex: 0 0 auto; accent-color: var(--accent); } +.checklist input:checked + span { opacity: 0.45; text-decoration: line-through; } +.steps-big { font-size: 1.25rem; line-height: 1.55; } +.step-no { font-weight: 700; color: var(--accent); } diff --git a/static/js/app.js b/static/js/app.js index dbeefe8..512bbf5 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -18,3 +18,24 @@ document.addEventListener("change", (event) => { const input = event.target.closest(".servings input[name=portionen]"); if (input && input.value) input.form.requestSubmit(); }); + +// Cooking mode: keep the screen on while the page is visible (Screen Wake Lock API). +(() => { + const page = document.querySelector("[data-wakelock]"); + if (!page || !("wakeLock" in navigator)) return; + const status = page.querySelector(".wakelock-status"); + let lock = null; + const acquire = async () => { + try { + lock = await navigator.wakeLock.request("screen"); + if (status) status.hidden = false; + lock.addEventListener("release", () => { if (status) status.hidden = true; }); + } catch { + // Not allowed right now (e.g. battery saver); the page still works. + } + }; + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") acquire(); + }); + acquire(); +})(); diff --git a/templates/recipes/cook.html b/templates/recipes/cook.html new file mode 100644 index 0000000..fe5144a --- /dev/null +++ b/templates/recipes/cook.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Kochmodus: {{ recipe.title }}{% endblock %} +{% block content %} +
+
+

{{ recipe.title }}

+ Fertig +
+ + +
+
+

Zutaten

+ {% include "recipes/_servings.html" %} +
+ {% for group, items in ingredient_groups %} + {% if group %}

{{ group }}

{% endif %} +
    + {% for ing in items %} +
  • + {% endfor %} +
+ {% endfor %} +
+ +
+

Zubereitung

+
    + {% for step in steps %} +
  1. + {% endfor %} +
+
+
+{% endblock %} diff --git a/templates/recipes/detail.html b/templates/recipes/detail.html index 2bfadf2..9c72e20 100644 --- a/templates/recipes/detail.html +++ b/templates/recipes/detail.html @@ -22,7 +22,7 @@ - {% block extra_actions %}{% endblock %} + Kochmodus {% if is_owner %}Bearbeiten{% endif %} {% if can_delete %}Löschen{% endif %} @@ -80,6 +80,29 @@

Quelle: {{ recipe.source_url|truncatechars:60 }}

{% endif %} - {% block after_recipe %}{% endblock %} +
+

Kommentare{% if comments %} ({{ comments|length }}){% endif %}

+ {% for comment in comments %} +
+
+ {{ comment.author.display_name }} + {{ comment.created_at|date:"d.m.Y H:i" }} +
+

{{ comment.text|linebreaksbr }}

+ {% if comment.pk in deletable_comments %} +
+ {% csrf_token %} +
+ {% endif %} +
+ {% empty %} +

Noch keine Kommentare.

+ {% endfor %} +
+ {% csrf_token %} + {% with form=comment_form %}{% include "form_fields.html" %}{% endwith %} + +
+
{% endblock %} diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 0000000..a807e7b --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,96 @@ +import pytest +from django.urls import reverse + +from recipes.models import Comment + +from .recipe_helpers import make_recipe + +pytestmark = pytest.mark.django_db + + +def post_comment(client, recipe, text="Sehr lecker!"): + return client.post(reverse("recipes:comment_create", args=[recipe.pk]), {"text": text}) + + +def test_user_comments_on_public_recipe(client, user, other_user): + recipe = make_recipe(user) + client.force_login(other_user) + response = post_comment(client, recipe) + comment = Comment.objects.get() + assert response["Location"].endswith(f"#kommentar-{comment.pk}") + assert comment.author == other_user + assert "Sehr lecker!" in client.get(reverse("recipes:detail", args=[recipe.pk])).content.decode() + + +def test_empty_comment_rejected(user_client, user): + recipe = make_recipe(user) + assert post_comment(user_client, recipe, "").status_code == 400 + assert not Comment.objects.exists() + + +def test_comment_too_long_rejected(user_client, user): + recipe = make_recipe(user) + assert post_comment(user_client, recipe, "x" * 2001).status_code == 400 + + +def test_author_and_admin_can_delete(client, user, other_user, admin_user): + recipe = make_recipe(user) + first = Comment.objects.create(recipe=recipe, author=other_user, text="eins") + second = Comment.objects.create(recipe=recipe, author=other_user, text="zwei") + client.force_login(other_user) + client.post(reverse("recipes:comment_delete", args=[recipe.pk, first.pk])) + client.force_login(admin_user) + client.post(reverse("recipes:comment_delete", args=[recipe.pk, second.pk])) + assert not Comment.objects.exists() + + +def test_others_cannot_delete_comment(client, user, other_user): + """Not even the recipe owner may delete someone else's comment (spec: author and admin only).""" + recipe = make_recipe(user) + comment = Comment.objects.create(recipe=recipe, author=other_user, text="bleibt") + client.force_login(user) + assert client.post(reverse("recipes:comment_delete", args=[recipe.pk, comment.pk])).status_code == 404 + assert Comment.objects.exists() + + +def test_delete_button_only_where_allowed(client, user, other_user): + recipe = make_recipe(user) + comment = Comment.objects.create(recipe=recipe, author=other_user, text="hallo") + delete_url = reverse("recipes:comment_delete", args=[recipe.pk, comment.pk]) + client.force_login(user) + assert delete_url not in client.get(reverse("recipes:detail", args=[recipe.pk])).content.decode() + client.force_login(other_user) + assert delete_url in client.get(reverse("recipes:detail", args=[recipe.pk])).content.decode() + + +def test_comment_rate_limit(user_client, user, settings): + settings.COMMENT_RATE_LIMIT_PER_HOUR = 2 + recipe = make_recipe(user) + for i in range(3): + post_comment(user_client, recipe, f"Kommentar {i}") + assert Comment.objects.count() == 2 + + +def test_comment_text_is_escaped(user_client, user): + recipe = make_recipe(user) + post_comment(user_client, recipe, "") + html = user_client.get(reverse("recipes:detail", args=[recipe.pk])).content.decode() + assert "" not in html + + +def test_cook_mode_scales(user_client, user): + recipe = make_recipe(user, ingredients="200 g Mehl") + html = user_client.get(reverse("recipes:cook", args=[recipe.pk]), {"portionen": 8}).content.decode() + assert "400 g" in html and "data-wakelock" in html + + +def test_detail_scaling(user_client, user): + recipe = make_recipe(user, ingredients="600 g Kartoffeln\n1 ½ TL Salz") + html = user_client.get(reverse("recipes:detail", args=[recipe.pk]), {"portionen": 8}).content.decode() + assert "1,2 kg" in html and "3 TL" in html + + +@pytest.mark.parametrize("value", ["abc", "-5", "0", "99999"]) +def test_scaling_with_bad_input(user_client, user, value): + recipe = make_recipe(user) + assert user_client.get(reverse("recipes:detail", args=[recipe.pk]), {"portionen": value}).status_code == 200 diff --git a/tests/test_privacy.py b/tests/test_privacy.py index 32d8cf5..f292ade 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -5,7 +5,7 @@ import pytest from django.test import Client from django.urls import reverse -from recipes.models import Favorite, Recipe, RecipeImage +from recipes.models import Comment, Favorite, Recipe, RecipeImage from .recipe_helpers import image_upload, make_recipe @@ -22,6 +22,7 @@ def private_recipe(user): ingredients=f"500 g {SECRET_INGREDIENT}", tags=(SECRET_TAG,)) owner_client(user).post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]}) assert recipe.images.count() == 1 + Comment.objects.create(recipe=recipe, author=user, text="Eigene Notiz im Kommentar") return recipe @@ -68,12 +69,15 @@ def post_urls(recipe, image): def extra_get_urls(recipe, image): - """Hook for later features (comments, cooking mode, …).""" - return [] + return [reverse("recipes:cook", args=[recipe.pk]), reverse("recipes:cook", args=[recipe.pk]) + "?portionen=2"] def extra_post_urls(recipe, image): - return [] + comment = recipe.comments.first() + return [ + reverse("recipes:comment_create", args=[recipe.pk]), + reverse("recipes:comment_delete", args=[recipe.pk, comment.pk]), + ] def test_owner_sees_everything(user, private_recipe, image): @@ -96,6 +100,7 @@ def test_outsider_gets_404_on_every_post_url(outsider, private_recipe, image): assert private_recipe.title == SECRET_TITLE and not private_recipe.is_public assert RecipeImage.objects.filter(pk=image.pk).exists() assert not Favorite.objects.exists() + assert list(private_recipe.comments.values_list("text", flat=True)) == ["Eigene Notiz im Kommentar"] @pytest.mark.parametrize(