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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
@@ -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}
|
||||
@@ -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