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
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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"}
|
||||||
|
|||||||
@@ -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:
|
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}"
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user