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
+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()