Add phase 2: recipes with visibility, photos, search and favourites
- Recipe model with ingredients (amount/range, unit, group, note), steps, lowercase tags, author notes, source URL and private/public visibility - Central RecipeQuerySet.visible_to() used by every read path; edit only by owner, delete by owner or admin (visible recipes only) - Text-based editing: ingredient/step parser with roundtrip and scaling - Photo upload (JPEG/PNG/WebP/HEIC), re-encoded to WebP without EXIF, thumbnails, cover image, served only through permission-checked view - Search across title, description, ingredients, tags; ingredient and tag filters; "mine" and favourites views - Admin can delete all recipes of a deactivated user - Acceptance tests: private recipes unreachable for others and admins in list, search, tags, favourites, detail, edit, delete and image URLs Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ef9deaeff7
commit
6f7e4f92ca
@@ -0,0 +1,145 @@
|
||||
"""Acceptance: a private recipe is reachable by its owner only – in no view and under no URL,
|
||||
including image URLs. Other users and admins get 404 (indistinguishable from "does not exist")."""
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from recipes.models import Favorite, Recipe, RecipeImage
|
||||
|
||||
from .recipe_helpers import image_upload, make_recipe
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
SECRET_TITLE = "Geheimrezept Omas Gulasch"
|
||||
SECRET_INGREDIENT = "Zauberpaprika"
|
||||
SECRET_TAG = "geheimtag"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def private_recipe(user):
|
||||
recipe = make_recipe(user, title=SECRET_TITLE, visibility=Recipe.Visibility.PRIVATE,
|
||||
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
|
||||
return recipe
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image(private_recipe):
|
||||
return RecipeImage.objects.get(recipe=private_recipe)
|
||||
|
||||
|
||||
@pytest.fixture(params=["other", "admin"])
|
||||
def outsider(request, other_user, admin_user):
|
||||
c = Client()
|
||||
c.force_login(other_user if request.param == "other" else admin_user)
|
||||
return c
|
||||
|
||||
|
||||
def owner_client(user):
|
||||
c = Client()
|
||||
c.force_login(user)
|
||||
return c
|
||||
|
||||
|
||||
def get_urls(recipe, image):
|
||||
return [
|
||||
reverse("recipes:detail", args=[recipe.pk]),
|
||||
reverse("recipes:detail", args=[recipe.pk]) + "?portionen=8",
|
||||
reverse("recipes:edit", args=[recipe.pk]),
|
||||
reverse("recipes:delete", args=[recipe.pk]),
|
||||
reverse("recipes:photo", args=[image.uuid, "gross"]),
|
||||
reverse("recipes:photo", args=[image.uuid, "klein"]),
|
||||
*extra_get_urls(recipe, image),
|
||||
]
|
||||
|
||||
|
||||
def post_urls(recipe, image):
|
||||
return [
|
||||
reverse("recipes:edit", args=[recipe.pk]),
|
||||
reverse("recipes:delete", args=[recipe.pk]),
|
||||
reverse("recipes:favorite", args=[recipe.pk]),
|
||||
reverse("recipes:photo_upload", args=[recipe.pk]),
|
||||
reverse("recipes:photo_cover", args=[recipe.pk, image.pk]),
|
||||
reverse("recipes:photo_delete", args=[recipe.pk, image.pk]),
|
||||
*extra_post_urls(recipe, image),
|
||||
]
|
||||
|
||||
|
||||
def extra_get_urls(recipe, image):
|
||||
"""Hook for later features (comments, cooking mode, …)."""
|
||||
return []
|
||||
|
||||
|
||||
def extra_post_urls(recipe, image):
|
||||
return []
|
||||
|
||||
|
||||
def test_owner_sees_everything(user, private_recipe, image):
|
||||
c = owner_client(user)
|
||||
for url in get_urls(private_recipe, image):
|
||||
assert c.get(url).status_code == 200, url
|
||||
assert SECRET_TITLE in c.get(reverse("recipes:list")).content.decode()
|
||||
|
||||
|
||||
def test_outsider_gets_404_on_every_get_url(outsider, private_recipe, image):
|
||||
for url in get_urls(private_recipe, image):
|
||||
assert outsider.get(url).status_code == 404, url
|
||||
|
||||
|
||||
def test_outsider_gets_404_on_every_post_url(outsider, private_recipe, image):
|
||||
for url in post_urls(private_recipe, image):
|
||||
response = outsider.post(url, {"title": "gehackt", "servings": 1, "visibility": "public", "text": "x"})
|
||||
assert response.status_code == 404, url
|
||||
private_recipe.refresh_from_db()
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query",
|
||||
[
|
||||
{},
|
||||
{"q": "Geheimrezept"},
|
||||
{"q": SECRET_INGREDIENT},
|
||||
{"q": SECRET_TAG},
|
||||
{"zutat": SECRET_INGREDIENT},
|
||||
{"tag": SECRET_TAG},
|
||||
{"ansicht": "favoriten"},
|
||||
],
|
||||
)
|
||||
def test_private_recipe_absent_from_list_and_search(outsider, private_recipe, image, query):
|
||||
html = outsider.get(reverse("recipes:list"), query).content.decode()
|
||||
assert SECRET_TITLE not in html
|
||||
assert str(image.uuid) not in html
|
||||
assert reverse("recipes:detail", args=[private_recipe.pk]) not in html
|
||||
|
||||
|
||||
def test_private_tag_not_listed(outsider, private_recipe):
|
||||
assert f"#{SECRET_TAG}" not in outsider.get(reverse("recipes:list")).content.decode()
|
||||
|
||||
|
||||
def test_existing_favorite_disappears_when_recipe_turns_private(user, other_user):
|
||||
recipe = make_recipe(user, title="Bald privat")
|
||||
Favorite.objects.create(user=other_user, recipe=recipe)
|
||||
recipe.visibility = Recipe.Visibility.PRIVATE
|
||||
recipe.save()
|
||||
c = owner_client(other_user)
|
||||
assert "Bald privat" not in c.get(reverse("recipes:list"), {"ansicht": "favoriten"}).content.decode()
|
||||
assert c.get(reverse("recipes:detail", args=[recipe.pk])).status_code == 404
|
||||
|
||||
|
||||
def test_anonymous_gets_redirected_everywhere(private_recipe, image):
|
||||
c = Client()
|
||||
for url in get_urls(private_recipe, image):
|
||||
response = c.get(url)
|
||||
assert response.status_code == 302 and "/anmelden/" in response["Location"], url
|
||||
|
||||
|
||||
def test_visible_to_queryset_rule(user, other_user, admin_user, private_recipe):
|
||||
public = make_recipe(user, title="Öffentlich")
|
||||
assert set(Recipe.objects.visible_to(user)) == {private_recipe, public}
|
||||
assert set(Recipe.objects.visible_to(other_user)) == {public}
|
||||
assert set(Recipe.objects.visible_to(admin_user)) == {public}
|
||||
Reference in New Issue
Block a user