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
+4
View File
@@ -53,3 +53,7 @@ LOGIN_LOCKOUT_MINUTES=15
# --- Fotos --------------------------------------------------------------------- # --- Fotos ---------------------------------------------------------------------
IMAGE_MAX_UPLOAD_MB=15 IMAGE_MAX_UPLOAD_MB=15
IMAGE_MAX_PER_RECIPE=10 IMAGE_MAX_PER_RECIPE=10
# --- Kommentare ------------------------------------------------------------------
# Maximal so viele Kommentare pro Nutzer und Stunde (0 = unbegrenzt)
COMMENT_RATE_LIMIT_PER_HOUR=30
+4 -2
View File
@@ -7,7 +7,7 @@ port; TLS, domain and proxy are out of scope.
## Status ## Status
- [x] Phase 1: Docker setup, DB, auth with invitations, roles - [x] Phase 1: Docker setup, DB, auth with invitations, roles
- [x] Phase 2: Recipes CRUD, visibility (private/public), photos, notes, search, favourites - [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 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 5: Link import (schema.org JSON-LD, Instagram with paste fallback, Android PWA share target)
- [ ] Phase 6: Backup script, full README - [ ] 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`, - `accounts/` – custom `User` (e-mail login, `role` = user|admin), `Invitation`, `PasswordResetToken`,
login/registration/reset views, admin area (`admin_views.py`, `/verwaltung/…`), login/registration/reset views, admin area (`admin_views.py`, `/verwaltung/…`),
`ensure_admin` management command (first admin from env). `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 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 (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). scales amounts. `images.py` validates/re-encodes uploads (WebP 1600 px + 480 px thumb, no EXIF).
Photos are served only via `recipes:photo` (`/bilder/<uuid>/<gross|klein>.webp`) after a visibility check. Photos are served only via `recipes:photo` (`/bilder/<uuid>/<gross|klein>.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`. - `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`.
- `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn. - `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn.
- `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker). - `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker).
+1
View File
@@ -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_UPLOAD_MB = env_int("IMAGE_MAX_UPLOAD_MB", 15)
IMAGE_MAX_PER_RECIPE = env_int("IMAGE_MAX_PER_RECIPE", 10) 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. # Request body limit for non-file fields; files are limited per image above.
DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024
+9 -1
View File
@@ -3,7 +3,7 @@ from django.conf import settings
from django.db import transaction from django.db import transaction
from . import images, parsing from . import images, parsing
from .models import Recipe from .models import Comment, Recipe
class MultipleFileInput(forms.ClearableFileInput): class MultipleFileInput(forms.ClearableFileInput):
@@ -135,3 +135,11 @@ class SearchForm(forms.Form):
def ingredient_terms(self): def ingredient_terms(self):
return [t.strip() for t in self.cleaned_data.get("zutat", "").split(",") if t.strip()] 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"}
+29
View File
@@ -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'],
},
),
]
+23
View File
@@ -197,3 +197,26 @@ class Favorite(models.Model):
class Meta: class Meta:
constraints = [models.UniqueConstraint(fields=["user", "recipe"], name="favorite_unique")] 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}"
+3
View File
@@ -10,6 +10,9 @@ urlpatterns = [
path("rezepte/<int:pk>/", views.recipe_detail, name="detail"), path("rezepte/<int:pk>/", views.recipe_detail, name="detail"),
path("rezepte/<int:pk>/bearbeiten/", views.recipe_edit, name="edit"), path("rezepte/<int:pk>/bearbeiten/", views.recipe_edit, name="edit"),
path("rezepte/<int:pk>/loeschen/", views.recipe_delete, name="delete"), 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>/favorit/", views.favorite_toggle, name="favorite"),
path("rezepte/<int:pk>/fotos/", views.photo_upload, name="photo_upload"), 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"), path("rezepte/<int:pk>/fotos/<int:image_id>/titelbild/", views.photo_cover, name="photo_cover"),
+65 -7
View File
@@ -1,4 +1,5 @@
import logging import logging
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -7,12 +8,15 @@ from django.db import transaction
from django.db.models import Exists, OuterRef, Prefetch from django.db.models import Exists, OuterRef, Prefetch
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404, redirect, render 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.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from core import ratelimit
from . import images from . import images
from .forms import PhotoUploadForm, RecipeForm, SearchForm from .forms import CommentForm, PhotoUploadForm, RecipeForm, SearchForm
from .models import Favorite, Recipe, RecipeImage, Tag from .models import Comment, Favorite, Recipe, RecipeImage, Tag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,15 +65,28 @@ def recipe_list(request):
}) })
@require_GET def _requested_servings(request, recipe):
def recipe_detail(request, pk):
recipe = get_object_or_404(_visible(request).select_related("owner"), pk=pk)
try: try:
servings = int(request.GET.get("portionen", recipe.servings)) servings = int(request.GET.get("portionen", recipe.servings))
except ValueError: except ValueError:
servings = recipe.servings 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", { return render(request, "recipes/detail.html", {
"comments": comments,
"deletable_comments": deletable,
"comment_form": comment_form or CommentForm(),
"recipe": recipe, "recipe": recipe,
"servings": servings, "servings": servings,
"ingredient_groups": recipe.scaled_ingredients(servings), "ingredient_groups": recipe.scaled_ingredients(servings),
@@ -79,7 +96,7 @@ def recipe_detail(request, pk):
"is_owner": recipe.owner_id == request.user.id, "is_owner": recipe.owner_id == request.user.id,
"can_delete": Recipe.objects.deletable_by(request.user).filter(pk=recipe.pk).exists(), "can_delete": Recipe.objects.deletable_by(request.user).filter(pk=recipe.pk).exists(),
"is_favorite": Favorite.objects.filter(user=request.user, recipe=recipe).exists(), "is_favorite": Favorite.objects.filter(user=request.user, recipe=recipe).exists(),
}) }, status=status)
def recipe_create(request): 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. # Short browser-only caching, so a recipe made private stops showing up quickly.
response["Cache-Control"] = "private, max-age=300" response["Cache-Control"] = "private, max-age=300"
return response 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")
+18
View File
@@ -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; } .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 { 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; } .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); }
+21
View File
@@ -18,3 +18,24 @@ document.addEventListener("change", (event) => {
const input = event.target.closest(".servings input[name=portionen]"); const input = event.target.closest(".servings input[name=portionen]");
if (input && input.value) input.form.requestSubmit(); 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();
})();
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Kochmodus: {{ recipe.title }}{% endblock %}
{% block content %}
<div class="cook" data-wakelock>
<div class="page-head">
<h1>{{ recipe.title }}</h1>
<a class="button secondary" href="{% url 'recipes:detail' recipe.pk %}">Fertig</a>
</div>
<p class="muted wakelock-status" hidden>Bildschirm bleibt an.</p>
<section class="card">
<div class="section-head">
<h2>Zutaten</h2>
{% include "recipes/_servings.html" %}
</div>
{% for group, items in ingredient_groups %}
{% if group %}<h3>{{ group }}</h3>{% endif %}
<ul class="checklist">
{% for ing in items %}
<li><label><input type="checkbox"> <span><strong>{{ ing.amount }} {{ ing.unit }}</strong> {{ ing.name }}{% if ing.note %}, {{ ing.note }}{% endif %}</span></label></li>
{% endfor %}
</ul>
{% endfor %}
</section>
<section class="card">
<h2>Zubereitung</h2>
<ol class="checklist steps-big">
{% for step in steps %}
<li><label><input type="checkbox"> <span><span class="step-no">{{ forloop.counter }}.</span> {{ step.text|linebreaksbr }}</span></label></li>
{% endfor %}
</ol>
</section>
</div>
{% endblock %}
+25 -2
View File
@@ -22,7 +22,7 @@
<input type="hidden" name="next" value="{{ request.get_full_path }}"> <input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="button secondary">{% if is_favorite %}★ Favorit{% else %}☆ Merken{% endif %}</button> <button type="submit" class="button secondary">{% if is_favorite %}★ Favorit{% else %}☆ Merken{% endif %}</button>
</form> </form>
{% block extra_actions %}{% endblock %} <a class="button secondary" href="{% url 'recipes:cook' recipe.pk %}{% if servings != recipe.servings %}?portionen={{ servings }}{% endif %}">Kochmodus</a>
{% if is_owner %}<a class="button secondary" href="{% url 'recipes:edit' recipe.pk %}">Bearbeiten</a>{% endif %} {% if is_owner %}<a class="button secondary" href="{% url 'recipes:edit' recipe.pk %}">Bearbeiten</a>{% endif %}
{% if can_delete %}<a class="button danger" href="{% url 'recipes:delete' recipe.pk %}">Löschen</a>{% endif %} {% if can_delete %}<a class="button danger" href="{% url 'recipes:delete' recipe.pk %}">Löschen</a>{% endif %}
</div> </div>
@@ -80,6 +80,29 @@
<p class="muted">Quelle: <a href="{{ recipe.source_url }}" rel="noopener noreferrer nofollow" target="_blank">{{ recipe.source_url|truncatechars:60 }}</a></p> <p class="muted">Quelle: <a href="{{ recipe.source_url }}" rel="noopener noreferrer nofollow" target="_blank">{{ recipe.source_url|truncatechars:60 }}</a></p>
{% endif %} {% endif %}
{% block after_recipe %}{% endblock %} <section class="card" id="kommentare">
<h2>Kommentare{% if comments %} ({{ comments|length }}){% endif %}</h2>
{% for comment in comments %}
<div class="comment" id="kommentar-{{ comment.pk }}">
<div class="comment-head">
<strong>{{ comment.author.display_name }}</strong>
<span class="muted">{{ comment.created_at|date:"d.m.Y H:i" }}</span>
</div>
<p>{{ comment.text|linebreaksbr }}</p>
{% if comment.pk in deletable_comments %}
<form method="post" action="{% url 'recipes:comment_delete' recipe.pk comment.pk %}">
{% csrf_token %}<button type="submit" class="linklike small-link">Löschen</button>
</form>
{% endif %}
</div>
{% empty %}
<p class="muted">Noch keine Kommentare.</p>
{% endfor %}
<form method="post" action="{% url 'recipes:comment_create' recipe.pk %}" class="comment-form" novalidate>
{% csrf_token %}
{% with form=comment_form %}{% include "form_fields.html" %}{% endwith %}
<button type="submit" class="button">Kommentieren</button>
</form>
</section>
</article> </article>
{% endblock %} {% endblock %}
+96
View File
@@ -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, "<script>alert(1)</script>")
html = user_client.get(reverse("recipes:detail", args=[recipe.pk])).content.decode()
assert "<script>alert(1)</script>" 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
+9 -4
View File
@@ -5,7 +5,7 @@ import pytest
from django.test import Client from django.test import Client
from django.urls import reverse 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 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,)) ingredients=f"500 g {SECRET_INGREDIENT}", tags=(SECRET_TAG,))
owner_client(user).post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]}) owner_client(user).post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
assert recipe.images.count() == 1 assert recipe.images.count() == 1
Comment.objects.create(recipe=recipe, author=user, text="Eigene Notiz im Kommentar")
return recipe return recipe
@@ -68,12 +69,15 @@ def post_urls(recipe, image):
def extra_get_urls(recipe, image): def extra_get_urls(recipe, image):
"""Hook for later features (comments, cooking mode, …).""" return [reverse("recipes:cook", args=[recipe.pk]), reverse("recipes:cook", args=[recipe.pk]) + "?portionen=2"]
return []
def extra_post_urls(recipe, image): 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): 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 private_recipe.title == SECRET_TITLE and not private_recipe.is_public
assert RecipeImage.objects.filter(pk=image.pk).exists() assert RecipeImage.objects.filter(pk=image.pk).exists()
assert not Favorite.objects.exists() assert not Favorite.objects.exists()
assert list(private_recipe.comments.values_list("text", flat=True)) == ["Eigene Notiz im Kommentar"]
@pytest.mark.parametrize( @pytest.mark.parametrize(