diff --git a/cookbook/models.py b/cookbook/models.py index 3a8badbb..e03c7461 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -12,7 +12,7 @@ from django.contrib.postgres.search import SearchVectorField from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile from django.core.validators import MinLengthValidator from django.db import models -from django.db.models import Index +from django.db.models import Index, ProtectedError from django.utils import timezone from django.utils.translation import gettext as _ from treebeard.mp_tree import MP_Node, MP_NodeManager @@ -385,6 +385,12 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): def __str__(self): return self.name + def delete(self): + if len(self.ingredient_set.all().exclude(step=None)) > 0: + raise ProtectedError(self.name + _(" is part of a recipe step and cannot be deleted"), self.ingredient_set.all().exclude(step=None)) + else: + return super().delete() + class Meta: constraints = [ models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space') @@ -393,7 +399,8 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin): - food = models.ForeignKey(Food, on_delete=models.PROTECT, null=True, blank=True) + # a pre-delete signal on Food checks if the Ingredient is part of a Step, if it is raises a ProtectedError instead of cascading the delete + food = models.ForeignKey(Food, on_delete=models.CASCADE, null=True, blank=True) unit = models.ForeignKey(Unit, on_delete=models.PROTECT, null=True, blank=True) amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) note = models.CharField(max_length=256, null=True, blank=True) diff --git a/cookbook/tests/api/test_api_food.py b/cookbook/tests/api/test_api_food.py index 2fd57706..eaaffb49 100644 --- a/cookbook/tests/api/test_api_food.py +++ b/cookbook/tests/api/test_api_food.py @@ -243,6 +243,42 @@ def test_delete(u1_s1, u1_s2, obj_1, obj_1_1, obj_1_1_1): assert Food.find_problems() == ([], [], [], [], []) +def test_integrity(u1_s1, recipe_1_s1): + with scopes_disabled(): + assert Food.objects.count() == 10 + assert Ingredient.objects.count() == 10 + f_1 = Food.objects.first() + + # deleting food will fail because food is part of recipe + r = u1_s1.delete( + reverse( + DETAIL_URL, + args={f_1.id} + ) + ) + assert r.status_code == 403 + + with scopes_disabled(): + i_1 = f_1.ingredient_set.first() + # remove Ingredient that references Food from recipe step + i_1.step_set.first().ingredients.remove(i_1) + assert Food.objects.count() == 10 + assert Ingredient.objects.count() == 10 + + # deleting food will succeed because its not part of recipe and delete will cascade to Ingredient + r = u1_s1.delete( + reverse( + DETAIL_URL, + args={f_1.id} + ) + ) + assert r.status_code == 204 + + with scopes_disabled(): + assert Food.objects.count() == 9 + assert Ingredient.objects.count() == 9 + + def test_move(u1_s1, obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, space_1): url = reverse(MOVE_URL, args=[obj_1_1.id, obj_2.id]) with scopes_disabled(): diff --git a/cookbook/views/api.py b/cookbook/views/api.py index a0b113fe..59dd9daa 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -12,7 +12,7 @@ from django.contrib.auth.models import User from django.contrib.postgres.search import TrigramSimilarity from django.core.exceptions import FieldError, ValidationError from django.core.files import File -from django.db.models import Case, Q, Value, When +from django.db.models import Case, ProtectedError, Q, Value, When from django.db.models.fields.related import ForeignObjectRel from django.http import FileResponse, HttpResponse, JsonResponse from django_scopes import scopes_disabled @@ -380,6 +380,13 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): permission_classes = [CustomIsUser] pagination_class = DefaultPagination + def destroy(self, *args, **kwargs): + try: + return (super().destroy(self, *args, **kwargs)) + except ProtectedError as e: + content = {'error': True, 'msg': e.args[0]} + return Response(content, status=status.HTTP_403_FORBIDDEN) + class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin): queryset = RecipeBook.objects diff --git a/docs/index.md b/docs/index.md index 990a8795..595a96a2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,6 +16,7 @@ ## Coming Next - Heirarchical Ingredients - Faceted Search +- Search filter by rating - What Can I Make Now? - Better ingredient/unit matching on import - Custom word replacement on import (e.g. 'grams' automatically imported as 'g')