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,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()
|
||||
Reference in New Issue
Block a user