diff --git a/cookbook/models.py b/cookbook/models.py index 83bf3b9b..57b5d878 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -824,7 +824,7 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin): list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries') - food = models.ForeignKey(Food, on_delete=models.CASCADE) + food = models.ForeignKey(Food, on_delete=models.CASCADE, related_name='shopping_entries') unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True) ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE, null=True, blank=True) amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) diff --git a/cookbook/signals.py b/cookbook/signals.py index 1e08bce6..c610e629 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -75,11 +75,20 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs): finally: del instance.skip_signal + # TODO figure out how to generalize this # apply changes to direct children - depend on save signals for those objects to cascade inheritance down - instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='ignore_shopping').update(ignore_shopping=instance.ignore_shopping) + _save = [] + for child in instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='ignore_shopping'): + child.ignore_shopping = instance.ignore_shopping + _save.append(child) # don't cascade empty supermarket category if instance.supermarket_category: - instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='supermarket_category').update(supermarket_category=instance.supermarket_category) + # apply changes to direct children - depend on save signals for those objects to cascade inheritance down + for child in instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='supermarket_category'): + child.supermarket_category = instance.supermarket_category + _save.append(child) + for child in set(_save): + child.save() @receiver(post_save, sender=MealPlan) diff --git a/cookbook/tests/api/test_api_food.py b/cookbook/tests/api/test_api_food.py index 666800c1..4e1772d7 100644 --- a/cookbook/tests/api/test_api_food.py +++ b/cookbook/tests/api/test_api_food.py @@ -6,9 +6,10 @@ from django.urls import reverse from django_scopes import scope, scopes_disabled from pytest_factoryboy import LazyFixture, register -from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry +from cookbook.models import Food, FoodInheritField, Ingredient, ShoppingList, ShoppingListEntry from cookbook.tests.conftest import get_random_json_recipe -from cookbook.tests.factories import FoodFactory, IngredientFactory, ShoppingListEntryFactory +from cookbook.tests.factories import (FoodFactory, IngredientFactory, ShoppingListEntryFactory, + SupermarketCategoryFactory) # ------------------ IMPORTANT ------------------- # @@ -32,18 +33,38 @@ else: register(FoodFactory, 'obj_1', space=LazyFixture('space_1')) register(FoodFactory, 'obj_2', space=LazyFixture('space_1')) register(FoodFactory, 'obj_3', space=LazyFixture('space_2')) +register(SupermarketCategoryFactory, 'cat_1', space=LazyFixture('space_1')) + + +# @pytest.fixture +# def true(): +# return True + + +@pytest.fixture +def false(): + return False + + +@pytest.fixture +def non_exist(): + return {} @pytest.fixture() -def obj_tree_1(space_1): +def obj_tree_1(request, space_1): + try: + params = request.param + except AttributeError: + params = {} objs = [] - objs.extend(FoodFactory.create_batch(3, space=space_1)) + objs.extend(FoodFactory.create_batch(3, space=space_1, **params)) # request.param is a magic variable objs[0].move(objs[1], node_location) objs[1].move(objs[2], node_location) return Food.objects.get(id=objs[1].id) # whenever you move/merge a tree it's safest to re-get the object -@ pytest.mark.parametrize("arg", [ +@pytest.mark.parametrize("arg", [ ['a_u', 403], ['g1_s1', 403], ['u1_s1', 200], @@ -87,11 +108,11 @@ def test_list_filter(obj_1, obj_2, u1_s1): response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query=chicken').content) assert response['count'] == 0 - response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query={obj_1.name[4:]}').content) + response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query={obj_1.name[:-4]}').content) assert response['count'] == 1 -@ pytest.mark.parametrize("arg", [ +@pytest.mark.parametrize("arg", [ ['a_u', 403], ['g1_s1', 403], ['u1_s1', 200], @@ -116,7 +137,7 @@ def test_update(arg, request, obj_1): assert response['name'] == 'new' -@ pytest.mark.parametrize("arg", [ +@pytest.mark.parametrize("arg", [ ['a_u', 403], ['g1_s1', 403], ['u1_s1', 201], @@ -447,11 +468,63 @@ def test_tree_filter(obj_tree_1, obj_2, obj_3, u1_s1): assert response['count'] == 4 -def test_inherit(obj_tree_1, u1_s1): - pass -# TODO test inherit creating, moving for each field type -# TODO test ignore inherit for each field type -# TODO test with grand-children -# - flow from parent through child and grand-child -# - flow from parent stop when child is ignore inherit +# This is more about the model than the API - should this be moved to a different test? +@pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [ + ({'add_categories': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'), + ({'add_categories': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'), + ({'ignore_shopping': True, 'inherit': True}, 'ignore_shopping', True, 'false'), + ({'ignore_shopping': True, 'inherit': False}, 'ignore_shopping', False, 'false'), +], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter +def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1): + with scope(space=obj_tree_1.space): + parent = obj_tree_1.get_parent() + child = obj_tree_1.get_descendants()[0] + + new_val = request.getfixturevalue(new_val) + # if this test passes it demonstrates that inheritance works + # when moving to a parent as each food is created with a different category + assert (getattr(parent, field) == getattr(obj_tree_1, field)) in [inherit, True] + assert (getattr(obj_tree_1, field) == getattr(child, field)) in [inherit, True] + # change parent to a new value + setattr(parent, field, new_val) + with scope(space=parent.space): + parent.save() # trigger post-save signal + # get the objects again because values are cached + obj_tree_1 = Food.objects.get(id=obj_tree_1.id) + child = Food.objects.get(id=child.id) + # when changing parent value the obj value should be same if inherited + assert (getattr(obj_tree_1, field) == new_val) == inherit + assert (getattr(child, field) == new_val) == inherit + + +# This is more about the model than the API - should this be moved to a different test? +@pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [ + ({'add_categories': True, 'inherit': True, }, 'supermarket_category', True, 'cat_1'), + ({'ignore_shopping': True, 'inherit': True, }, 'ignore_shopping', True, 'false'), +], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter +def test_ignoreinherit_field(request, obj_tree_1, field, inherit, new_val, u1_s1): + with scope(space=obj_tree_1.space): + parent = obj_tree_1.get_parent() + child = obj_tree_1.get_descendants()[0] + obj_tree_1.ignore_inherit.add(FoodInheritField.objects.get(field=field)) + new_val = request.getfixturevalue(new_val) + + # change parent to a new value + setattr(parent, field, new_val) + with scope(space=parent.space): + parent.save() # trigger post-save signal + # get the objects again because values are cached + obj_tree_1 = Food.objects.get(id=obj_tree_1.id) + # inheritance is blocked - should not get new value + assert getattr(obj_tree_1, field) != new_val + + setattr(obj_tree_1, field, new_val) + with scope(space=parent.space): + obj_tree_1.save() # trigger post-save signal + # get the objects again because values are cached + child = Food.objects.get(id=child.id) + # inherit with child should still work + assert getattr(child, field) == new_val + + # TODO test reset_inheritance diff --git a/cookbook/tests/factories/__init__.py b/cookbook/tests/factories/__init__.py index 84110f95..614ba73e 100644 --- a/cookbook/tests/factories/__init__.py +++ b/cookbook/tests/factories/__init__.py @@ -1,10 +1,27 @@ import factory +import pytest +from django.contrib import auth +from django.contrib.auth.models import User from django_scopes import scopes_disabled from faker import Factory as FakerFactory faker = FakerFactory.create() +@pytest.fixture(autouse=True) +def enable_db_access_for_all_tests(db): + pass + + +@pytest.hookimpl(hookwrapper=True) +def pytest_fixture_setup(fixturedef, request): + if inspect.isgeneratorfunction(fixturedef.func): + yield + else: + with scopes_disabled(): + yield + + class SpaceFactory(factory.django.DjangoModelFactory): """Space factory.""" name = factory.LazyAttribute(lambda x: faker.word()) @@ -18,11 +35,92 @@ class SpaceFactory(factory.django.DjangoModelFactory): model = 'cookbook.Space' -class FoodFactory(factory.django.DjangoModelFactory): - """Food factory.""" - name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3)) +class UserFactory(factory.django.DjangoModelFactory): + """User factory.""" + username = factory.LazyAttribute(lambda x: faker.word()) + first_name = factory.LazyAttribute(lambda x: faker.first_name()) + last_name = factory.LazyAttribute(lambda x: faker.last_name()) + email = factory.LazyAttribute(lambda x: faker.email()) + space = factory.SubFactory(SpaceFactory) + + class Meta: + model = User + + +class SupermarketCategoryFactory(factory.django.DjangoModelFactory): + """SupermarketCategory factory.""" + name = factory.LazyAttribute(lambda x: faker.word()) description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) space = factory.SubFactory(SpaceFactory) + class Meta: + model = 'cookbook.SupermarketCategory' + + +# @factory.django.mute_signals(post_save) +class FoodFactory(factory.django.DjangoModelFactory): + """Food factory.""" + name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3)) + description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) + supermarket_category = factory.Maybe( + factory.LazyAttribute( + lambda x: + x.add_categories), + yes_declaration=factory.SubFactory(SupermarketCategoryFactory), + no_declaration=None + ) + space = factory.SubFactory(SpaceFactory) + + # this code will run immediately prior to creating the model object useful when you want a reverse relationship + # log = factory.RelatedFactory( + # UserLogFactory, + # factory_related_name='user', + # action=models.UserLog.ACTION_CREATE, + # ) + + class Params: + add_categories = False + class Meta: model = 'cookbook.Food' + + +class UnitFactory(factory.django.DjangoModelFactory): + """Unit factory.""" + name = factory.LazyAttribute(lambda x: faker.word()) + description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) + space = factory.SubFactory(SpaceFactory) + + class Meta: + model = 'cookbook.Unit' + + +class IngredientFactory(factory.django.DjangoModelFactory): + """Ingredient factory.""" + food = factory.SubFactory(FoodFactory) + unit = factory.SubFactory(UnitFactory) + amount = 1 + note = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5)) + space = factory.SubFactory(SpaceFactory) + + class Meta: + model = 'cookbook.Ingredient' + + +class ShoppingListEntryFactory(factory.django.DjangoModelFactory): + """ShoppingListEntry factory.""" + # list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries') + food = factory.SubFactory(FoodFactory) + unit = factory.SubFactory(UnitFactory) + ingredient = factory.SubFactory(IngredientFactory) + amount = 1 + order = 0 + checked = False + created_by = factory.SubFactory(UserFactory) + # created_at = models.DateTimeField(auto_now_add=True) + # completed_at = models.DateTimeField(null=True, blank=True) + # delay_until = models.DateTimeField(null=True, blank=True) + space = factory.SubFactory(SpaceFactory) + + class Meta: + model = 'cookbook.ShoppingListEntry' diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 2677fee7..9463dca9 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -225,6 +225,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin): root = int(root) except ValueError: self.queryset = self.model.objects.none() + if root == 0: self.queryset = self.model.get_root_nodes() else: