From 5d79e4d3bebedd928048e0c3d0a11389472be126 Mon Sep 17 00:00:00 2001 From: smilerz Date: Sun, 12 Dec 2021 10:39:01 -0600 Subject: [PATCH] pytest shopping list from recipe methods --- cookbook/helper/shopping_helper.py | 42 ++++++- cookbook/signals.py | 30 ----- .../api/test_api_shopping_list_entryv2.py | 7 +- .../tests/api/test_api_shopping_recipe.py | 88 +++++++++++++ cookbook/tests/factories/__init__.py | 116 ++++++++++++++---- cookbook/views/api.py | 8 +- 6 files changed, 225 insertions(+), 66 deletions(-) create mode 100644 cookbook/tests/api/test_api_shopping_recipe.py diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index e9cfa8a63..8d76352c0 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -58,6 +58,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None if type(servings) not in [int, float]: servings = getattr(mealplan, 'servings', 1.0) + servings_factor = servings / r.servings shared_users = list(created_by.get_shopping_share()) shared_users.append(created_by) @@ -75,7 +76,44 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None else: ingredients = Ingredient.objects.filter(step__recipe=r, space=space) - add_ingredients = ingredients.values_list('id', flat=True) + if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand: + ingredients = ingredients.exclude(food__on_hand=True) + + related_step_ing = [] + if related := created_by.userpreference.mealplan_autoinclude_related: + # TODO: add levels of related recipes to use when auto-adding mealplans + related_recipes = r.get_related_recipes() + + for x in related_recipes: + # related recipe is a Step serving size is driven by recipe serving size + # TODO once Steps can have a serving size this needs to be refactored + if exclude_onhand: + # if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior + related_step_ing += Ingredient.objects.filter(step__recipe=x, food__on_hand=False, space=space).values_list('id', flat=True) + else: + related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True) + + x_ing = [] + if ingredients.filter(food__recipe=x).exists(): + for ing in ingredients.filter(food__recipe=x): + if exclude_onhand: + x_ing = Ingredient.objects.filter(step__recipe=x, food__on_hand=False, space=space) + else: + x_ing = Ingredient.objects.filter(step__recipe=x, space=space) + for i in [x for x in x_ing if not x.food.ignore_shopping]: + ShoppingListEntry.objects.create( + list_recipe=list_recipe, + food=i.food, + unit=i.unit, + ingredient=i, + amount=i.amount * Decimal(servings_factor), + created_by=created_by, + space=space, + ) + # dont' add food to the shopping list that are actually recipes that will be added as ingredients + ingredients = ingredients.exclude(food__recipe=x) + + add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing if not append: existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe) # delete shopping list entries not included in ingredients @@ -87,7 +125,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None # if servings have changed, update the ShoppingListRecipe and existing Entrys if servings <= 0: servings = 1 - servings_factor = servings / r.servings + if not created and list_recipe.servings != servings: update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True)) for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients): diff --git a/cookbook/signals.py b/cookbook/signals.py index c610e629d..c1e858ed0 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -106,35 +106,5 @@ def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs 'created_by': user, 'servings': instance.servings } - recipe_ingredients = Ingredient.objects.filter(step__recipe=instance.recipe, space=space) - if exclude_onhand := user.userpreference.mealplan_autoexclude_onhand: - recipe_ingredients = recipe_ingredients.exclude(food__on_hand=True) - if related := user.userpreference.mealplan_autoinclude_related: - # TODO: add levels of related recipes to use when auto-adding mealplans - related_recipes = instance.recipe.get_related_recipes() - # dont' add recipes that are going to have their recipes added to the shopping list - kwargs['ingredients'] = recipe_ingredients.exclude(food__recipe__in=related_recipes).values_list('id', flat=True) - else: - kwargs['ingredients'] = recipe_ingredients.values_list('id', flat=True) list_recipe = list_from_recipe(**kwargs) - if related: - servings_factor = Decimal(instance.servings / instance.recipe.servings) - kwargs['list_recipe'] = list_recipe - food_recipes = recipe_ingredients.filter(food__recipe__in=related_recipes).values('food__recipe', 'amount') - - for recipe in related_recipes: - kwargs['ingredients'] = [] - if exclude_onhand: - kwargs['ingredients'] = Ingredient.objects.filter(step__recipe=recipe, food__on_hand=False, space=space).values_list('id', flat=True) - kwargs['recipe'] = recipe - - # assume related recipes are intended to be 'full sized' to parent recipe - # Recipe1 (servings:4) includes StepRecipe2(servings:2) a Meal Plan serving size of 8 would assume 4 servings of StepRecipe2 - if recipe.id in [x['food__recipe'] for x in food_recipes if x['food__recipe'] == recipe.id]: - kwargs['servings'] = Decimal(recipe.servings) * sum([x['amount'] for x in food_recipes if x['food__recipe'] == recipe.id]) * servings_factor - else: - # TODO: When modifying step recipes to allow serving size - will need to update this - kwargs['servings'] = Decimal(recipe.servings) * servings_factor - - list_from_recipe(**kwargs, append=True) diff --git a/cookbook/tests/api/test_api_shopping_list_entryv2.py b/cookbook/tests/api/test_api_shopping_list_entryv2.py index 7efddac92..7ac0f3d30 100644 --- a/cookbook/tests/api/test_api_shopping_list_entryv2.py +++ b/cookbook/tests/api/test_api_shopping_list_entryv2.py @@ -11,7 +11,7 @@ from django_scopes import scopes_disabled from pytest_factoryboy import LazyFixture, register from cookbook.models import ShoppingListEntry -from cookbook.tests.factories import FoodFactory, ShoppingListEntryFactory +from cookbook.tests.factories import ShoppingListEntryFactory LIST_URL = 'api:shoppinglistentry-list' DETAIL_URL = 'api:shoppinglistentry-detail' @@ -219,11 +219,6 @@ def test_recent(sle, u1_s1): assert [x['checked'] for x in r].count(False) == 9 -# TODO test create shopping list from recipe -# TODO test delete shopping list from recipe - include created by, shared with and not shared with # TODO test create shopping list from food # TODO test delete shopping list from food - include created by, shared with and not shared with # TODO test create shopping list from mealplan -# TODO test create shopping list from recipe, excluding ingredients - -# test delay diff --git a/cookbook/tests/api/test_api_shopping_recipe.py b/cookbook/tests/api/test_api_shopping_recipe.py new file mode 100644 index 000000000..9c8447807 --- /dev/null +++ b/cookbook/tests/api/test_api_shopping_recipe.py @@ -0,0 +1,88 @@ +import json +from datetime import timedelta + +import factory +import pytest +from django.contrib import auth +from django.forms import model_to_dict +from django.urls import reverse +from django.utils import timezone +from django_scopes import scopes_disabled +from pytest_factoryboy import LazyFixture, register + +from cookbook.models import ShoppingListEntry +from cookbook.tests.factories import RecipeFactory + +SHOPPING_LIST_URL = 'api:shoppinglistentry-list' +SHOPPING_RECIPE_URL = 'api:recipe-shopping' + + +@pytest.fixture() +def recipe(request, space_1, u1_s1): + try: + params = request.param # request.param is a magic variable + except AttributeError: + params = {} + step_recipe = params.get('steps__count', 1) + steps__recipe_count = params.get('steps__recipe_count', 0) + steps__food_recipe_count = params.get('steps__food_recipe_count', {}) + created_by = params.get('created_by', auth.get_user(u1_s1)) + + return RecipeFactory.create( + steps__recipe_count=steps__recipe_count, + steps__food_recipe_count=steps__food_recipe_count, + created_by=created_by, + space=space_1, + ) + + +@pytest.mark.parametrize("arg", [ + ['g1_s1', 204], + ['u1_s1', 204], + ['u1_s2', 404], + ['a1_s1', 204], +]) +@pytest.mark.parametrize("recipe, sle_count", [ + ({}, 10), + ({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe + ({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe + ({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe +], indirect=['recipe']) +def test_shopping_recipe_method(request, arg, recipe, sle_count, u1_s1, u2_s1): + c = request.getfixturevalue(arg[0]) + user = auth.get_user(c) + user.userpreference.mealplan_autoadd_shopping = True + user.userpreference.save() + + assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 0 + + url = reverse(SHOPPING_RECIPE_URL, args={recipe.id}) + r = c.put(url) + assert r.status_code == arg[1] + # only PUT method should work + if r.status_code == 204: # skip anonymous user + + r = json.loads(c.get(reverse(SHOPPING_LIST_URL)).content) + assert len(r) == sle_count # recipe factory creates 10 ingredients by default + assert [x['created_by']['id'] for x in r].count(user.id) == sle_count + # user in space can't see shopping list + assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0 + user.userpreference.shopping_share.add(auth.get_user(u2_s1)) + # after share, user in space can see shopping list + assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count + # confirm that the author of the recipe doesn't have access to shopping list + if c != u1_s1: + assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0 + + r = c.get(url) + assert r.status_code == 405 + r = c.post(url) + assert r.status_code == 405 + r = c.delete(url) + assert r.status_code == 405 + + +# TODO test creating shopping list from recipe that includes recipes from multiple users +# TODO test create shopping list from recipe, excluding ingredients +# TODO meal plan recipe with all the user preferences tested +# TODO shopping list from recipe with different servings diff --git a/cookbook/tests/factories/__init__.py b/cookbook/tests/factories/__init__.py index 114bc64d5..f64f73396 100644 --- a/cookbook/tests/factories/__init__.py +++ b/cookbook/tests/factories/__init__.py @@ -9,6 +9,8 @@ from django_scopes import scopes_disabled from faker import Factory as FakerFactory from pytest_factoryboy import register +from cookbook.models import Step + # this code will run immediately prior to creating the model object useful when you want a reverse relationship # log = factory.RelatedFactory( # UserLogFactory, @@ -138,15 +140,21 @@ class KeywordFactory(factory.django.DjangoModelFactory): # icon = models.CharField(max_length=16, blank=True, null=True) description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) space = factory.SubFactory(SpaceFactory) + num = None # used on upstream factories to generate num keywords + + class Params: + num = None class Meta: model = 'cookbook.Keyword' django_get_or_create = ('name', 'space',) + exclude = ('num') @register class IngredientFactory(factory.django.DjangoModelFactory): """Ingredient factory.""" + # TODO add optional recipe food food = factory.SubFactory(FoodFactory, space=factory.SelfAttribute('..space')) unit = factory.SubFactory(UnitFactory, space=factory.SelfAttribute('..space')) amount = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10)) @@ -203,6 +211,7 @@ class ShoppingListRecipeFactory(factory.django.DjangoModelFactory): ) servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10)) mealplan = factory.SubFactory(MealPlanFactory, space=factory.SelfAttribute('..space')) + space = factory.SubFactory(SpaceFactory) class Params: has_recipe = False @@ -242,26 +251,43 @@ class ShoppingListEntryFactory(factory.django.DjangoModelFactory): @register class StepFactory(factory.django.DjangoModelFactory): name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5)) - # type = models.CharField( - # choices=((TEXT, _('Text')), (TIME, _('Time')), (FILE, _('File')), (RECIPE, _('Recipe')),), - # default=TEXT, - # max_length=16 - # ) instruction = factory.LazyAttribute(lambda x: ''.join(faker.paragraphs(nb=5))) - ingredients = factory.SubFactory(IngredientFactory, space=factory.SelfAttribute('..space')) + # TODO add optional recipe food, make dependent on recipe, make number of recipes a Params + ingredients__count = 10 # default number of ingredients to add time = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=1000)) - order = 0 + order = factory.Sequence(lambda x: x) # file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True) show_as_header = True - step_recipe = factory.Maybe( - factory.LazyAttribute(lambda x: x.has_recipe), - yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')), - no_declaration=None - ) + step_recipe__has_recipe = False + ingredients__food_recipe_count = 0 space = factory.SubFactory(SpaceFactory) - class Params: - has_recipe = False + @factory.post_generation + def step_recipe(self, create, extracted, **kwargs): + if not create: + return + if kwargs.get('has_recipe', False): + step_recipe = RecipeFactory(space=self.space) + self.type = Step.RECIPE + + @factory.post_generation + def ingredients(self, create, extracted, **kwargs): + if not create: + return + + num_ing = kwargs.get('count', 0) + num_food_recipe = kwargs.get('food_recipe_count', 0) + if num_ing > 0: + for i in range(num_ing): + if num_food_recipe > 0: + has_recipe = True + num_food_recipe = num_food_recipe-1 + else: + has_recipe = False + self.ingredients.add(IngredientFactory(space=self.space, food__has_recipe=has_recipe)) + elif extracted: + for ing in extracted: + self.ingredients.add(ing) class Meta: model = 'cookbook.Step' @@ -272,8 +298,54 @@ class RecipeFactory(factory.django.DjangoModelFactory): name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=7)) description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=20)) - servings_text = factory.LazyAttribute(lambda x: faker.sentence(nb_words=1)) - # image = models.ImageField(upload_to='recipes/', blank=True, null=True) #TODO test recipe image api + servings_text = factory.LazyAttribute(lambda x: faker.sentence(nb_words=1)) # TODO generate list of expected servings text that can be iterated through + keywords__count = 5 # default number of keywords to generate + steps__count = 1 # default number of steps to create + steps__recipe_count = 0 # default number of step recipes to create + steps__food_recipe_count = {} # by default, don't create food recipes, to override {'steps__food_recipe_count': {'step': 0, 'count': 1}} + working_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360)) + waiting_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360)) + internal = False + created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) + created_at = factory.LazyAttribute(lambda x: faker.date_this_decade()) + space = factory.SubFactory(SpaceFactory) + + @factory.post_generation + def keywords(self, create, extracted, **kwargs): + if not create: + # Simple build, do nothing. + return + + num_kw = kwargs.get('count', 0) + if num_kw > 0: + for i in range(num_kw): + self.keywords.add(KeywordFactory(space=self.space)) + elif extracted: + for kw in extracted: + self.keywords.add(kw) + + @factory.post_generation + def steps(self, create, extracted, **kwargs): + if not create: + return + + food_recipe_count = kwargs.get('food_recipe_count', {}) # TODO - pass this + num_steps = kwargs.get('count', 0) + num_recipe_steps = kwargs.get('recipe_count', 0) + if num_steps > 0: + for i in range(num_steps): + ing_recipe_count = 0 + if food_recipe_count.get('step', None) == i: + ing_recipe_count = food_recipe_count.get('count', 0) + self.steps.add(StepFactory(space=self.space, ingredients__food_recipe_count=ing_recipe_count)) + if num_recipe_steps > 0: + for j in range(num_recipe_steps): + self.steps.add(StepFactory(space=self.space, step_recipe__has_recipe=True)) + if extracted and (num_steps + num_recipe_steps == 0): + for step in extracted: + self.steps.add(step) + + # image = models.ImageField(upload_to='recipes/', blank=True, null=True) #TODO test recipe image api https://factoryboy.readthedocs.io/en/stable/orms.html#factory.django.ImageField # storage = models.ForeignKey( # Storage, on_delete=models.PROTECT, blank=True, null=True # ) @@ -281,18 +353,8 @@ class RecipeFactory(factory.django.DjangoModelFactory): # file_path = models.CharField(max_length=512, default="", blank=True) # link = models.CharField(max_length=512, null=True, blank=True) # cors_link = models.CharField(max_length=1024, null=True, blank=True) - keywords = factory.SubFactory(KeywordFactory, space=factory.SelfAttribute('..space')) - steps = factory.SubFactory(StepFactory, space=factory.SelfAttribute('..space')) - working_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360)) - waiting_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360)) - internal = False - # nutrition = models.ForeignKey( - # NutritionInformation, blank=True, null=True, on_delete=models.CASCADE - # ) - created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) - created_at = factory.LazyAttribute(lambda x: faker.date_this_decade()) + # nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE) # updated_at = models.DateTimeField(auto_now=True) - space = factory.SubFactory(SpaceFactory) class Meta: model = 'cookbook.Recipe' diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 20578453b..df762e41f 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -412,7 +412,8 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): permission_classes = [CustomIsUser] pagination_class = DefaultPagination - @decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,) + '' + @decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,) def shopping(self, request, pk): obj = self.get_object() shared_users = list(self.request.user.get_shopping_share()) @@ -594,6 +595,11 @@ class RecipeViewSet(viewsets.ModelViewSet): schema = QueryParamAutoSchema() def get_queryset(self): + + if self.detail: + self.queryset = self.queryset.filter(space=self.request.space) + return super().get_queryset() + share = self.request.query_params.get('share', None) if not (share and self.detail): self.queryset = self.queryset.filter(space=self.request.space)