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:
Kyle Dammann
2026-10-30 21:37:13 +01:00
co-authored by Claude Opus 5.5
parent ef9deaeff7
commit 6f7e4f92ca
41 changed files with 1870 additions and 37 deletions
+6
View File
@@ -35,3 +35,9 @@ def admin_client(admin_user):
@pytest.fixture
def user_client(user):
return logged_in_client(user)
@pytest.fixture(autouse=True)
def media_root(tmp_path, settings):
settings.MEDIA_ROOT = tmp_path / "media"
return settings.MEDIA_ROOT
+42
View File
@@ -0,0 +1,42 @@
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from PIL import Image
from recipes import parsing
from recipes.models import Recipe
def make_recipe(owner, title="Testrezept", visibility=Recipe.Visibility.PUBLIC, ingredients="200 g Mehl\n2 Eier",
steps="Mischen.\n\nBacken.", tags=("kuchen",)):
recipe = Recipe.objects.create(owner=owner, title=title, visibility=visibility, servings=4)
recipe.set_ingredients(parsing.parse_ingredients(ingredients))
recipe.set_steps(parsing.parse_steps(steps))
recipe.set_tags(tags)
return recipe
def image_upload(name="foto.jpg", size=(1200, 900), fmt="JPEG", color=(200, 120, 40)):
buffer = io.BytesIO()
Image.new("RGB", size, color).save(buffer, fmt)
content_type = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}[fmt]
return SimpleUploadedFile(name, buffer.getvalue(), content_type=content_type)
def form_data(**overrides):
data = {
"title": "Pfannkuchen",
"description": "Einfach und schnell.",
"servings": 4,
"servings_label": "Portionen",
"prep_minutes": 10,
"cook_minutes": 15,
"ingredients_text": "250 g Mehl\n500 ml Milch\n3 Eier\n1 Prise Salz",
"steps_text": "Alles verrühren.\n\nIn der Pfanne ausbacken.",
"tags_text": "Frühstück, Süß",
"author_notes": "Mit Apfelmus servieren.",
"source_url": "",
"visibility": "private",
}
data.update(overrides)
return data
+1 -1
View File
@@ -52,7 +52,7 @@ def test_deactivated_user_session_ends(admin_client, user):
session = Client()
session.force_login(user)
admin_client.post(reverse("accounts:admin_user_action", args=[user.pk, "deactivate"]))
assert session.get(reverse("core:home")).status_code == 302
assert session.get(reverse("recipes:list")).status_code == 302
def test_admin_cannot_demote_or_deactivate_self(admin_client, admin_user):
+7 -7
View File
@@ -30,7 +30,7 @@ def test_login_with_valid_credentials(user):
client = Client()
response = login(client, "[email protected]", PASSWORD) # e-mail is case-insensitive
assert response.status_code == 302
assert client.get(reverse("core:home")).status_code == 200
assert client.get(reverse("recipes:list")).status_code == 200
def test_login_wrong_password_gives_generic_error(user):
@@ -89,13 +89,13 @@ def test_login_redirect_rejects_external_next(user):
{"email": user.email, "password": PASSWORD, "next": "https://evil.example/"},
)
assert response.status_code == 302
assert response["Location"] == reverse("core:home")
assert response["Location"] == reverse("recipes:list")
def test_logout_requires_post(user_client):
assert user_client.get(reverse("accounts:logout")).status_code == 405
assert user_client.post(reverse("accounts:logout")).status_code == 302
assert user_client.get(reverse("core:home")).status_code == 302
assert user_client.get(reverse("recipes:list")).status_code == 302
def test_login_requires_csrf_token(user):
@@ -145,7 +145,7 @@ def test_register_with_valid_invitation(admin_user):
assert new_user.role == User.Role.USER
invitation.refresh_from_db()
assert invitation.used_by == new_user and invitation.status == "used"
assert client.get(reverse("core:home")).status_code == 200
assert client.get(reverse("recipes:list")).status_code == 200
def test_invitation_is_single_use(admin_user):
@@ -243,7 +243,7 @@ def test_reset_link_ends_existing_sessions(admin_user, user):
reverse("accounts:password_reset", args=[token_from(link)]),
{"password1": NEW_PASSWORD, "password2": NEW_PASSWORD},
)
assert old_session.get(reverse("core:home")).status_code == 302
assert old_session.get(reverse("recipes:list")).status_code == 302
def test_temp_password_forces_change(admin_client, user):
@@ -252,9 +252,9 @@ def test_temp_password_forces_change(admin_client, user):
client = Client()
assert login(client, user.email, temp).status_code == 302
# Every page redirects to the password change until a new password is set.
assert client.get(reverse("core:home"))["Location"] == reverse("accounts:password_change")
assert client.get(reverse("recipes:list"))["Location"] == reverse("accounts:password_change")
client.post(reverse("accounts:password_change"), {"password1": NEW_PASSWORD, "password2": NEW_PASSWORD})
assert client.get(reverse("core:home")).status_code == 200
assert client.get(reverse("recipes:list")).status_code == 200
user.refresh_from_db()
assert not user.must_change_password and user.check_password(NEW_PASSWORD)
+77
View File
@@ -0,0 +1,77 @@
from decimal import Decimal
import pytest
from recipes.parsing import (
format_amount,
ingredients_to_text,
parse_ingredient_line,
parse_ingredients,
parse_steps,
parse_tags,
scale_amount,
)
@pytest.mark.parametrize(
"line, amount, amount_max, unit, name, note",
[
("200 g Mehl, gesiebt", "200", None, "g", "Mehl", "gesiebt"),
("200g Mehl", "200", None, "g", "Mehl", ""),
("1,5l Milch", "1.5", None, "l", "Milch", ""),
("1 ½ TL Salz", "1.5", None, "TL", "Salz", ""),
("1/2 Bund Petersilie", "0.5", None, "Bund", "Petersilie", ""),
("2 1/2 Tassen Reis", "2.5", None, "Tassen", "Reis", ""),
("2-3 Zehen Knoblauch", "2", "3", "Zehen", "Knoblauch", ""),
("2 bis 3 EL Öl", "2", "3", "EL", "Öl", ""),
("2 Eier", "2", None, "", "Eier", ""),
("3 Essl. Zucker", "3", None, "EL", "Zucker", ""),
("- 1 Prise Salz", "1", None, "Prise", "Salz", ""),
("½ Zitrone, nur Saft", "0.5", None, "", "Zitrone", "nur Saft"),
("Salz und Pfeffer", None, None, "", "Salz und Pfeffer", ""),
("1 Dose Tomaten (400 g)", "1", None, "Dose", "Tomaten (400 g)", ""),
],
)
def test_parse_ingredient_line(line, amount, amount_max, unit, name, note):
item = parse_ingredient_line(line)
assert item.amount == (Decimal(amount) if amount else None)
assert item.amount_max == (Decimal(amount_max) if amount_max else None)
assert (item.unit, item.name, item.note) == (unit, name, note)
def test_parse_ingredients_with_groups():
items = parse_ingredients("Für den Teig:\n200 g Mehl\n\nFür die Soße:\n1 Dose Tomaten\n")
assert [(i.group, i.name) for i in items] == [("Für den Teig", "Mehl"), ("Für die Soße", "Tomaten")]
def test_ingredient_text_roundtrip():
text = "200 g Mehl, gesiebt\n2-3 Zehen Knoblauch\n\nFür die Soße:\n½ TL Salz\nPfeffer"
items = parse_ingredients(text)
assert parse_ingredients(ingredients_to_text(items)) == items
def test_parse_steps_by_blank_lines():
assert parse_steps("Ofen vorheizen.\nAuf 180 °C.\n\n2. Mischen.") == ["Ofen vorheizen. Auf 180 °C.", "Mischen."]
def test_parse_steps_by_lines_and_strip_numbers():
assert parse_steps("1. Schneiden\n2) Braten\nSchritt 3: Servieren") == ["Schneiden", "Braten", "Servieren"]
def test_parse_tags_lowercase_unique():
assert parse_tags("Pasta, #Vegetarisch, Schnell Gemacht ,pasta,") == ["pasta", "vegetarisch", "schnell gemacht"]
@pytest.mark.parametrize(
"value, expected",
[("2", "2"), ("0.5", "½"), ("1.5", "1 ½"), ("0.333", "⅓"), ("0.25", "¼"), ("2.37", "2,37"), ("1.999", "2")],
)
def test_format_amount(value, expected):
assert format_amount(Decimal(value)) == expected
def test_scale_amount_normalises_metric():
assert scale_amount(Decimal(600), None, "g", Decimal(2)) == (Decimal("1.2"), None, "kg")
assert scale_amount(Decimal(250), None, "ml", Decimal("0.5")) == (Decimal(125), None, "ml")
assert scale_amount(Decimal(2), Decimal(3), "EL", Decimal(2)) == (Decimal(4), Decimal(6), "EL")
assert scale_amount(None, None, "", Decimal(2)) == (None, None, "")
+145
View File
@@ -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}
+190
View File
@@ -0,0 +1,190 @@
import io
from pathlib import Path
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from PIL import Image
from recipes.models import Favorite, Recipe, RecipeImage
from .recipe_helpers import form_data, image_upload, make_recipe
pytestmark = pytest.mark.django_db
def test_create_recipe_with_photos(user_client, user, media_root):
data = form_data(photos=[image_upload("a.jpg"), image_upload("b.png", fmt="PNG")])
response = user_client.post(reverse("recipes:create"), data)
recipe = Recipe.objects.get()
assert response.status_code == 302 and response["Location"] == reverse("recipes:detail", args=[recipe.pk])
assert recipe.owner == user and recipe.visibility == "private"
assert [i.name for i in recipe.ingredients.all()] == ["Mehl", "Milch", "Eier", "Salz"]
assert [s.text for s in recipe.steps.all()] == ["Alles verrühren.", "In der Pfanne ausbacken."]
assert sorted(t.name for t in recipe.tags.all()) == ["frühstück", "süß"]
imgs = list(recipe.images.all())
assert len(imgs) == 2 and sum(i.is_cover for i in imgs) == 1
for img in imgs:
assert (Path(media_root) / img.file).exists() and (Path(media_root) / img.thumb).exists()
assert img.width <= 1600 and img.height <= 1600
def test_detail_shows_structured_recipe(user_client, user):
recipe = make_recipe(user)
html = user_client.get(reverse("recipes:detail", args=[recipe.pk])).content.decode()
assert "Mehl" in html and "Backen." in html and "#kuchen" in html
def test_edit_form_roundtrip(user_client, user):
user_client.post(reverse("recipes:create"), form_data())
recipe = Recipe.objects.get()
form = user_client.get(reverse("recipes:edit", args=[recipe.pk])).context["form"]
data = form_data(ingredients_text=form.initial["ingredients_text"], steps_text=form.initial["steps_text"],
tags_text=form.initial["tags_text"], title="Pfannkuchen deluxe")
user_client.post(reverse("recipes:edit", args=[recipe.pk]), data)
recipe.refresh_from_db()
assert recipe.title == "Pfannkuchen deluxe"
assert [i.name for i in recipe.ingredients.all()] == ["Mehl", "Milch", "Eier", "Salz"]
def test_only_owner_can_edit_public_recipe(client, user, other_user):
recipe = make_recipe(user)
client.force_login(other_user)
assert client.get(reverse("recipes:detail", args=[recipe.pk])).status_code == 200
assert client.get(reverse("recipes:edit", args=[recipe.pk])).status_code == 404
assert client.post(reverse("recipes:delete", args=[recipe.pk])).status_code == 404
def test_admin_can_delete_public_recipe(admin_client, user, media_root):
recipe = make_recipe(user)
admin_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
assert not RecipeImage.objects.exists() # admin is not the owner → upload refused (404)
assert admin_client.post(reverse("recipes:delete", args=[recipe.pk])).status_code == 302
assert not Recipe.objects.exists()
def test_delete_removes_image_files(user_client, user, media_root):
user_client.post(reverse("recipes:create"), form_data(photos=[image_upload()]))
recipe = Recipe.objects.get()
img = recipe.images.get()
user_client.post(reverse("recipes:delete", args=[recipe.pk]))
assert not (Path(media_root) / img.file).exists()
def test_upload_rejects_non_image(user_client):
fake = SimpleUploadedFile("evil.jpg", b"<?php echo 1; ?>", content_type="image/jpeg")
response = user_client.post(reverse("recipes:create"), form_data(photos=[fake]))
assert response.status_code == 200
assert "kein unterstütztes Bild" in response.content.decode()
assert not Recipe.objects.exists()
def test_upload_rejects_too_large(user_client, settings):
settings.IMAGE_MAX_UPLOAD_MB = 0
response = user_client.post(reverse("recipes:create"), form_data(photos=[image_upload()]))
assert "zu groß" in response.content.decode()
def test_upload_rejects_gif(user_client):
buffer = io.BytesIO()
Image.new("RGB", (10, 10)).save(buffer, "GIF")
gif = SimpleUploadedFile("a.gif", buffer.getvalue(), content_type="image/gif")
response = user_client.post(reverse("recipes:create"), form_data(photos=[gif]))
assert "kein unterstütztes Bild" in response.content.decode()
def test_uploaded_image_has_no_exif(user_client, media_root):
buffer = io.BytesIO()
img = Image.new("RGB", (800, 600))
exif = Image.Exif()
exif[0x8825] = {1: "N", 2: (52.0, 31.0, 0.0)} # GPS info
exif[0x010F] = "SpyCam"
img.save(buffer, "JPEG", exif=exif)
upload = SimpleUploadedFile("gps.jpg", buffer.getvalue(), content_type="image/jpeg")
user_client.post(reverse("recipes:create"), form_data(photos=[upload]))
stored = Image.open(Path(media_root) / RecipeImage.objects.get().file)
assert stored.format == "WEBP"
assert not stored.getexif()
def test_image_limit_per_recipe(user_client, user, settings):
settings.IMAGE_MAX_PER_RECIPE = 2
recipe = make_recipe(user)
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload(), image_upload()]})
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload()]})
assert recipe.images.count() == 2
def test_cover_switch_and_delete(user_client, user):
recipe = make_recipe(user)
user_client.post(reverse("recipes:photo_upload", args=[recipe.pk]), {"photos": [image_upload(), image_upload()]})
first, second = recipe.images.order_by("position")
assert first.is_cover
user_client.post(reverse("recipes:photo_cover", args=[recipe.pk, second.pk]))
assert recipe.images.get(is_cover=True) == second
user_client.post(reverse("recipes:photo_delete", args=[recipe.pk, second.pk]))
assert recipe.images.get().is_cover
def test_search_by_title_ingredient_and_tag(user_client, user):
make_recipe(user, title="Linsensuppe", ingredients="200 g rote Linsen", tags=("suppe",))
make_recipe(user, title="Tomatensalat", ingredients="4 Tomaten", tags=("salat",))
def titles(**q):
return [r.title for r in user_client.get(reverse("recipes:list"), q).context["page"].object_list]
assert titles(q="linsen") == ["Linsensuppe"]
assert titles(q="TOMATEN") == ["Tomatensalat"]
assert titles(q="suppe") == ["Linsensuppe"]
assert titles(zutat="linsen") == ["Linsensuppe"]
assert titles(tag="salat") == ["Tomatensalat"]
assert titles(q="linsen tomaten") == []
def test_tags_stored_lowercase(user):
recipe = make_recipe(user, tags=("Vegan", "SCHNELL"))
assert sorted(t.name for t in recipe.tags.all()) == ["schnell", "vegan"]
def test_favorites(user_client, user, other_user):
recipe = make_recipe(other_user, title="Fremdes Rezept")
make_recipe(other_user, title="Anderes")
user_client.post(reverse("recipes:favorite", args=[recipe.pk]))
assert Favorite.objects.filter(user=user, recipe=recipe).exists()
titles = [r.title for r in user_client.get(reverse("recipes:list"), {"ansicht": "favoriten"}).context["page"].object_list]
assert titles == ["Fremdes Rezept"]
user_client.post(reverse("recipes:favorite", args=[recipe.pk]))
assert not Favorite.objects.exists()
def test_favorite_redirect_is_local_only(user_client, user):
recipe = make_recipe(user)
response = user_client.post(reverse("recipes:favorite", args=[recipe.pk]), {"next": "https://evil.example/"})
assert response["Location"] == reverse("recipes:detail", args=[recipe.pk])
def test_mine_filter(user_client, user, other_user):
make_recipe(user, title="Meins")
make_recipe(other_user, title="Deins")
titles = [r.title for r in user_client.get(reverse("recipes:list"), {"ansicht": "meine"}).context["page"].object_list]
assert titles == ["Meins"]
def test_admin_deletes_recipes_of_deactivated_user(admin_client, user):
make_recipe(user, visibility=Recipe.Visibility.PRIVATE)
make_recipe(user)
url = reverse("accounts:admin_user_delete_recipes", args=[user.pk])
assert admin_client.get(url).status_code == 404 # only for deactivated users
user.is_active = False
user.save()
assert admin_client.get(url).context["count"] == 2
admin_client.post(url)
assert not Recipe.objects.exists()
def test_user_cannot_bulk_delete(user_client, other_user):
other_user.is_active = False
other_user.save()
make_recipe(other_user)
assert user_client.post(reverse("accounts:admin_user_delete_recipes", args=[other_user.pk])).status_code == 403
assert Recipe.objects.exists()