From e2f8f29ec8e69e90c467f6db9543655a47a2e4eb Mon Sep 17 00:00:00 2001 From: Chris Scoggins Date: Sat, 29 Jan 2022 10:28:01 -0600 Subject: [PATCH 1/7] refactor list_from_recipe as class RecipeShoppingEditor --- cookbook/helper/shopping_helper.py | 348 +++++++++++++----- cookbook/serializer.py | 13 +- cookbook/signals.py | 40 +- .../tests/api/test_api_shopping_recipe.py | 2 +- cookbook/views/api.py | 30 +- vue/src/apps/MealPlanView/MealPlanView.vue | 149 +++----- vue/src/components/GenericMultiselect.vue | 42 ++- vue/src/components/IngredientsCard.vue | 2 +- vue/src/components/MealPlanEditModal.vue | 18 +- vue/src/components/Modals/ShoppingModal.vue | 1 - vue/src/components/RecipeContextMenu.vue | 2 +- 11 files changed, 403 insertions(+), 244 deletions(-) diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index 7e1fa6c7..761f1e7c 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -38,118 +38,268 @@ def shopping_helper(qs, request): return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe') -# TODO refactor as class -def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False): - """ - Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe - :param list_recipe: Modify an existing ShoppingListRecipe - :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required - :param mealplan: alternatively use a mealplan recipe as source of ingredients - :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted - :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used - :param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list - """ - r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None) - if not r: - raise ValueError(_("You must supply a recipe or mealplan")) +class RecipeShoppingEditor(): + def __init__(self, user, space, **kwargs): + self.created_by = user + self.space = space + self._kwargs = {**kwargs} - created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None) - if not created_by: - raise ValueError(_("You must supply a created_by")) + self.mealplan = self._kwargs.get('mealplan', None) + self.id = self._kwargs.get('id', None) - try: - servings = float(servings) - except (ValueError, TypeError): - servings = getattr(mealplan, 'servings', 1.0) + self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space) - servings_factor = servings / r.servings + if self._shopping_list_recipe: + # created_by needs to be sticky to original creator as it is 'their' shopping list + # changing shopping list created_by can shift some items to new owner which may not share in the other direction + self.created_by = getattr(self._shopping_list_recipe.entries.first(), 'created_by', self.created_by) - shared_users = list(created_by.get_shopping_share()) - shared_users.append(created_by) - if list_recipe: - created = False - else: - list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings) - created = True + self.recipe = getattr(self._shopping_list_recipe, 'recipe', None) or self._kwargs.get('recipe', None) or getattr(self.mealplan, 'recipe', None) - related_step_ing = [] - if servings == 0 and not created: - list_recipe.delete() - return [] - elif ingredients: - ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space) - else: - ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space) + try: + self.servings = float(self._kwargs.get('servings', None)) + except (ValueError, TypeError): + self.servings = getattr(self._shopping_list_recipe, 'servings', None) or getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', None) - if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand: - ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users]) + @property + def _servings_factor(self): + return self.servings / self.recipe.servings - if related := created_by.userpreference.mealplan_autoinclude_related: - # TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans - related_recipes = r.get_related_recipes() + @property + def _shared_users(self): + return [*list(self.created_by.get_shopping_share()), self.created_by] - for x in related_recipes: - # related recipe is a Step serving size is driven by recipe serving size - # TODO once/if 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, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True) - else: - related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True) + @staticmethod + def get_shopping_list_recipe(id, user, space): + return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter( + Q(shoppinglist__created_by=user) + | Q(shoppinglist__shared=user) + | Q(entries__created_by=user) + | Q(entries__created_by__in=list(user.get_shopping_share())) + ).prefetch_related('entries').first() - 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__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]) - else: - x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True) - for i in [x for x in x_ing]: - 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) + def get_recipe_ingredients(self, id, exclude_onhand=False): + if exclude_onhand: + return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(food__onhand_users__id__in=[x.id for x in self._shared_users]) + else: + return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space) - 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 - existing_list.exclude(ingredient__in=ingredients).delete() - # add shopping list entries that did not previously exist - add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True)) - add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space) + @property + def _include_related(self): + return self.created_by.userpreference.mealplan_autoinclude_related - # if servings have changed, update the ShoppingListRecipe and existing Entries - if servings <= 0: - servings = 1 + @property + def _exclude_onhand(self): + return self.created_by.userpreference.mealplan_autoexclude_onhand - 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)) - list_recipe.servings = servings - list_recipe.save() - for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients): - sle.amount = sle.ingredient.amount * Decimal(servings_factor) + def create(self, **kwargs): + ingredients = kwargs.get('ingredients', None) + exclude_onhand = not ingredients and self._exclude_onhand + if servings := kwargs.get('servings', None): + self.servings = float(servings) + + if mealplan := kwargs.get('mealplan', None): + self.mealplan = mealplan + self.recipe = mealplan.recipe + elif recipe := kwargs.get('recipe', None): + self.recipe = recipe + + if not self.servings: + self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0) + + self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings) + + if ingredients: + self._add_ingredients(ingredients=ingredients) + else: + if self._include_related: + related = self.recipe.get_related_recipes() + self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related)) + for r in related: + self._add_ingredients(self.get_recipe_ingredients(r.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related)) + else: + self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand)) + + return True + + def add(self, **kwargs): + return + + def edit(self, servings=None, ingredients=None, **kwargs): + if servings: + self.servings = servings + + self._delete_ingredients(ingredients=ingredients) + if self.servings != self._shopping_list_recipe.servings: + self.edit_servings() + self._add_ingredients(ingredients=ingredients) + return True + + def edit_servings(self, servings=None, **kwargs): + if servings: + self.servings = servings + if id := kwargs.get('id', None): + self._shopping_list_recipe = self.get_shopping_list_recipe(id, self.created_by, self.space) + if not self.servings: + raise ValueError(_("You must supply a servings size")) + + if self._shopping_list_recipe.servings == self.servings: + return True + + for sle in ShoppingListEntry.objects.filter(list_recipe=self._shopping_list_recipe): + sle.amount = sle.ingredient.amount * Decimal(self._servings_factor) sle.save() + self._shopping_list_recipe.servings = self.servings + self._shopping_list_recipe.save() + return True - # add any missing Entries - for i in [x for x in add_ingredients if x.food]: + def delete(self, **kwargs): + try: + self._shopping_list_recipe.delete() + return True + except: + return False - 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, - ) + def _add_ingredients(self, ingredients=None): + if not ingredients: + return + elif type(ingredients) == list: + ingredients = Ingredient.objects.filter(id__in=ingredients) + existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True) + add_ingredients = ingredients.exclude(id__in=existing) - # return all shopping list items - return list_recipe + for i in [x for x in add_ingredients if x.food]: + ShoppingListEntry.objects.create( + list_recipe=self._shopping_list_recipe, + food=i.food, + unit=i.unit, + ingredient=i, + amount=i.amount * Decimal(self._servings_factor), + created_by=self.created_by, + space=self.space, + ) + + # deletes shopping list entries not in ingredients list + def _delete_ingredients(self, ingredients=None): + if not ingredients: + return + to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients) + ShoppingListEntry.objects.filter(id__in=to_delete).delete() + self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space) + + +# # TODO refactor as class +# def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False): +# """ +# Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe +# :param list_recipe: Modify an existing ShoppingListRecipe +# :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required +# :param mealplan: alternatively use a mealplan recipe as source of ingredients +# :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted +# :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used +# :param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list +# """ +# r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None) +# if not r: +# raise ValueError(_("You must supply a recipe or mealplan")) + +# created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None) +# if not created_by: +# raise ValueError(_("You must supply a created_by")) + +# try: +# servings = float(servings) +# except (ValueError, TypeError): +# servings = getattr(mealplan, 'servings', 1.0) + +# servings_factor = servings / r.servings + +# shared_users = list(created_by.get_shopping_share()) +# shared_users.append(created_by) +# if list_recipe: +# created = False +# else: +# list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings) +# created = True + +# related_step_ing = [] +# if servings == 0 and not created: +# list_recipe.delete() +# return [] +# elif ingredients: +# ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space) +# else: +# ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space) + +# if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand: +# ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users]) + +# if related := created_by.userpreference.mealplan_autoinclude_related: +# # TODO: add levels of related recipes (related recipes 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/if 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, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).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__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]) +# else: +# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True) +# for i in [x for x in x_ing]: +# 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 +# existing_list.exclude(ingredient__in=ingredients).delete() +# # add shopping list entries that did not previously exist +# add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True)) +# add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space) + +# # if servings have changed, update the ShoppingListRecipe and existing Entries +# if servings <= 0: +# servings = 1 + +# 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)) +# list_recipe.servings = servings +# list_recipe.save() +# for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients): +# sle.amount = sle.ingredient.amount * Decimal(servings_factor) +# sle.save() + +# # add any missing Entries +# for i in [x for x in add_ingredients if x.food]: + +# 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, +# ) + +# # return all shopping list items +# return list_recipe diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 8a5212cd..baf77d05 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -13,7 +13,7 @@ from rest_framework.exceptions import NotFound, ValidationError from rest_framework.fields import empty from cookbook.helper.HelperFunctions import str2bool -from cookbook.helper.shopping_helper import list_from_recipe +from cookbook.helper.shopping_helper import RecipeShoppingEditor from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType, NutritionInformation, Recipe, RecipeBook, RecipeBookEntry, @@ -660,7 +660,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): validated_data['created_by'] = self.context['request'].user mealplan = super().create(validated_data) if self.context['request'].data.get('addshopping', False): - list_from_recipe(mealplan=mealplan, servings=validated_data['servings'], created_by=validated_data['created_by'], space=validated_data['space']) + SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space']) + SLR.create(mealplan=mealplan, servings=validated_data['servings']) return mealplan class Meta: @@ -694,12 +695,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): # TODO remove once old shopping list if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet': - list_from_recipe( - list_recipe=instance, - servings=validated_data['servings'], - created_by=self.context['request'].user, - space=self.context['request'].space - ) + SLR = RecipeShoppingEditor(user=self.context['request'].user, space=self.context['request'].space) + SLR.edit_servings(servings=validated_data['servings'], id=instance.id) return super().update(instance, validated_data) class Meta: diff --git a/cookbook/signals.py b/cookbook/signals.py index b4a52874..17a3da86 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -6,8 +6,9 @@ from django.contrib.postgres.search import SearchVector from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import translation +from django_scopes import scope -from cookbook.helper.shopping_helper import list_from_recipe +from cookbook.helper.shopping_helper import RecipeShoppingEditor from cookbook.managers import DICTIONARY from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe, ShoppingListEntry, Step) @@ -104,20 +105,31 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs): @receiver(post_save, sender=MealPlan) def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs): + if not instance: + return user = instance.get_owner() - if not user.userpreference.mealplan_autoadd_shopping: + with scope(space=instance.space): + slr_exists = instance.shoppinglistrecipe_set.exists() + + if not created and slr_exists: + for x in instance.shoppinglistrecipe_set.all(): + # assuming that permissions checks for the MealPlan have happened upstream + if instance.servings != x.servings: + SLR = RecipeShoppingEditor(id=x.id, user=user, space=instance.space) + SLR.edit_servings(servings=instance.servings) + # list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space) + elif not user.userpreference.mealplan_autoadd_shopping or not instance.recipe: return - if not created and instance.shoppinglistrecipe_set.exists(): - for x in instance.shoppinglistrecipe_set.all(): - if instance.servings != x.servings: - list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space) - elif created: + if created: # if creating a mealplan - perform shopping list activities - kwargs = { - 'mealplan': instance, - 'space': instance.space, - 'created_by': user, - 'servings': instance.servings - } - list_recipe = list_from_recipe(**kwargs) + # kwargs = { + # 'mealplan': instance, + # 'space': instance.space, + # 'created_by': user, + # 'servings': instance.servings + # } + SLR = RecipeShoppingEditor(user=user, space=instance.space) + SLR.create(mealplan=instance, servings=instance.servings) + + # list_recipe = list_from_recipe(**kwargs) diff --git a/cookbook/tests/api/test_api_shopping_recipe.py b/cookbook/tests/api/test_api_shopping_recipe.py index b32f2872..0fc85d86 100644 --- a/cookbook/tests/api/test_api_shopping_recipe.py +++ b/cookbook/tests/api/test_api_shopping_recipe.py @@ -164,7 +164,7 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u assert len(r) == sle_count assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count - # test removing 2 items from shopping list + # test removing 3 items from shopping list u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}), {'list_recipe': list_recipe, 'ingredients': keep_ing}, content_type='application/json' diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 9197f53e..8b6636ed 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -41,7 +41,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus from cookbook.helper.recipe_html_import import get_recipe_from_source from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search from cookbook.helper.recipe_url_import import get_from_scraper -from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper +from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry, @@ -717,16 +717,26 @@ class RecipeViewSet(viewsets.ModelViewSet): obj = self.get_object() ingredients = request.data.get('ingredients', None) servings = request.data.get('servings', None) - list_recipe = ShoppingListRecipe.objects.filter(id=request.data.get('list_recipe', None)).first() - if servings is None: - servings = getattr(list_recipe, 'servings', obj.servings) - # created_by needs to be sticky to original creator as it is 'their' shopping list - # changing shopping list created_by can shift some items to new owner which may not share in the other direction - created_by = getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', request.user) - content = {'msg': _(f'{obj.name} was added to the shopping list.')} - list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=created_by) + list_recipe = request.data.get('list_recipe', None) + SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj) - return Response(content, status=status.HTTP_204_NO_CONTENT) + content = {'msg': _(f'{obj.name} was added to the shopping list.')} + http_status = status.HTTP_204_NO_CONTENT + if servings and servings <= 0: + result = SLR.delete() + elif list_recipe: + result = SLR.edit(servings=servings, ingredients=ingredients) + else: + result = SLR.create(servings=servings, ingredients=ingredients) + + if not result: + content = {'msg': ('An error occurred')} + http_status = status.HTTP_500_INTERNAL_SERVER_ERROR + else: + content = {'msg': _(f'{obj.name} was added to the shopping list.')} + http_status = status.HTTP_204_NO_CONTENT + + return Response(content, status=http_status) @decorators.action( detail=True, diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index d45fb075..9b0d59b8 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -54,20 +54,14 @@
{{ $t("Planner_Settings") }}
- - + + - - + + - - + + @@ -80,23 +74,18 @@
{{ $t("Meal_Types") }}
- +
- +
- {{ meal_type.icon }} {{ - meal_type.name - }} + {{ meal_type.icon }} {{ meal_type.name + }}
@@ -104,26 +93,19 @@
- +
- +
- +
- + {{ $t("Default") }} - + @@ -147,15 +129,16 @@ openEntryEdit(contextData.originalItem.entry) " > - {{ - $t("Edit") - }} + {{ $t("Edit") }} - - {{ $t("Recipe") }} + v-if="contextData && contextData.originalItem && contextData.originalItem.entry.recipe != null" + @click=" + $refs.menu.close() + openRecipe(contextData.originalItem.entry.recipe) + " + > + {{ $t("Recipe") }} - - {{ $t("Move") }} + {{ $t("Move") }} - - {{ $t("Move") }} + {{ $t("Move") }} - - {{ $t("Add_to_Shopping") }} + {{ $t("Add_to_Shopping") }} - - {{ $t("Delete") }} + {{ $t("Delete") }} + > {{ $t("Open") }} - + {{ $t("Clear") }} @@ -243,46 +221,37 @@
-
+
- +
- +
-
- + - + - +
@@ -293,7 +262,7 @@ diff --git a/vue/src/components/IngredientsCard.vue b/vue/src/components/IngredientsCard.vue index dc7a67af..7da91e73 100644 --- a/vue/src/components/IngredientsCard.vue +++ b/vue/src/components/IngredientsCard.vue @@ -146,7 +146,7 @@ export default { saveShopping: function (del_shopping = false) { let servings = this.servings if (del_shopping) { - servings = 0 + servings = -1 } let params = { id: this.recipe, diff --git a/vue/src/components/MealPlanEditModal.vue b/vue/src/components/MealPlanEditModal.vue index 78e875ac..6c3eea6c 100644 --- a/vue/src/components/MealPlanEditModal.vue +++ b/vue/src/components/MealPlanEditModal.vue @@ -25,7 +25,7 @@
- - +
@@ -113,8 +112,6 @@ export default { name: "MealPlanEditModal", props: { entry: Object, - entryEditing_initial_recipe: Array, - entryEditing_initial_meal_type: Array, entryEditing_inital_servings: Number, modal_title: String, modal_id: { @@ -130,7 +127,6 @@ export default { components: { GenericMultiselect, RecipeCard: () => import("@/components/RecipeCard.vue"), - IngredientsCard: () => import("@/components/IngredientsCard.vue"), }, data() { return { @@ -144,12 +140,20 @@ export default { entry: { handler() { this.entryEditing = Object.assign({}, this.entry) + console.log("entryEditing", this.entryEditing) if (this.entryEditing_inital_servings) { this.entryEditing.servings = this.entryEditing_inital_servings } }, deep: true, }, + entryEditing: { + handler(newVal) {}, + deep: true, + }, + entryEditing_inital_servings: function (newVal) { + this.entryEditing.servings = newVal + }, }, mounted: function () {}, computed: { diff --git a/vue/src/components/Modals/ShoppingModal.vue b/vue/src/components/Modals/ShoppingModal.vue index e59eb21d..e0c17ec5 100644 --- a/vue/src/components/Modals/ShoppingModal.vue +++ b/vue/src/components/Modals/ShoppingModal.vue @@ -106,7 +106,6 @@ export default { deep: true, }, servings: function (newVal) { - console.log(newVal) this.recipe_servings = parseInt(newVal) }, }, diff --git a/vue/src/components/RecipeContextMenu.vue b/vue/src/components/RecipeContextMenu.vue index e0a77f7a..c7d34660 100644 --- a/vue/src/components/RecipeContextMenu.vue +++ b/vue/src/components/RecipeContextMenu.vue @@ -64,8 +64,8 @@ Date: Sat, 29 Jan 2022 11:59:06 -0600 Subject: [PATCH 2/7] fix shopping list sharing --- cookbook/helper/permission_helper.py | 6 ++--- cookbook/models.py | 15 ++++++++--- .../api/test_api_shopping_list_entryv2.py | 26 +++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/cookbook/helper/permission_helper.py b/cookbook/helper/permission_helper.py index e6a34537..a24483b2 100644 --- a/cookbook/helper/permission_helper.py +++ b/cookbook/helper/permission_helper.py @@ -205,9 +205,9 @@ class CustomIsShared(permissions.BasePermission): return request.user.is_authenticated def has_object_permission(self, request, view, obj): - # temporary hack to make old shopping list work with new shopping list - if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']: - return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share()) + # # temporary hack to make old shopping list work with new shopping list + # if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']: + # return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share()) return is_object_shared(request.user, obj) diff --git a/cookbook/models.py b/cookbook/models.py index fef3ab83..1a97d755 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -609,7 +609,7 @@ class NutritionInformation(models.Model, PermissionModelMixin): ) proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32) calories = models.DecimalField(default=0, decimal_places=16, max_digits=32) - source = models.CharField( max_length=512, default="", null=True, blank=True) + source = models.CharField(max_length=512, default="", null=True, blank=True) space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space') @@ -852,11 +852,12 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model def __str__(self): return f'Shopping list entry {self.id}' - # TODO deprecate def get_shared(self): - return self.shoppinglist_set.first().shared.all() + try: + return self.shoppinglist_set.first().shared.all() + except AttributeError: + return self.created_by.userpreference.shopping_share.all() - # TODO deprecate def get_owner(self): try: return self.created_by or self.shoppinglist_set.first().created_by @@ -881,6 +882,12 @@ class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, Pe def __str__(self): return f'Shopping list {self.id}' + def get_shared(self): + try: + return self.shared.all() or self.created_by.userpreference.shopping_share.all() + except AttributeError: + return [] + class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin): recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) diff --git a/cookbook/tests/api/test_api_shopping_list_entryv2.py b/cookbook/tests/api/test_api_shopping_list_entryv2.py index c5a62422..2ef04e89 100644 --- a/cookbook/tests/api/test_api_shopping_list_entryv2.py +++ b/cookbook/tests/api/test_api_shopping_list_entryv2.py @@ -156,6 +156,32 @@ def test_sharing(request, shared, count, sle_2, sle, u1_s1): # confirm shared user sees their list and the list that's shared with them assert len(json.loads(r.content)) == count + # test shared user can mark complete + x = shared_client.patch( + reverse(DETAIL_URL, args={sle[0].id}), + {'checked': True}, + content_type='application/json' + ) + r = json.loads(shared_client.get(reverse(LIST_URL)).content) + assert len(r) == count + # count unchecked entries + if not x.status_code == 404: + count = count-1 + assert [x['checked'] for x in r].count(False) == count + # test shared user can delete + x = shared_client.delete( + reverse( + DETAIL_URL, + args={sle[1].id} + ) + ) + r = json.loads(shared_client.get(reverse(LIST_URL)).content) + assert len(r) == count + # count unchecked entries + if not x.status_code == 404: + count = count-1 + assert [x['checked'] for x in r].count(False) == count + def test_completed(sle, u1_s1): # check 1 entry From e00794bbdf37c8d001abfcb08dc2452d02a980d6 Mon Sep 17 00:00:00 2001 From: Chris Scoggins Date: Sat, 29 Jan 2022 14:10:14 -0600 Subject: [PATCH 3/7] review shopping list in MealPlan modal --- cookbook/helper/shopping_helper.py | 6 ++++- cookbook/views/api.py | 4 +-- vue/src/components/GenericMultiselect.vue | 8 +++--- vue/src/components/MealPlanEditModal.vue | 28 ++++++++++++++++++--- vue/src/components/Modals/ShoppingModal.vue | 2 ++ vue/src/components/RecipeContextMenu.vue | 18 ++++++++++--- vue/src/locales/en.json | 3 ++- 7 files changed, 54 insertions(+), 15 deletions(-) diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index 761f1e7c..2bd641fe 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -8,7 +8,7 @@ from django.utils import timezone from django.utils.translation import gettext as _ from cookbook.helper.HelperFunctions import Round, str2bool -from cookbook.models import (Ingredient, ShoppingListEntry, ShoppingListRecipe, +from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe, SupermarketCategoryRelation) from recipes import settings @@ -45,6 +45,8 @@ class RecipeShoppingEditor(): self._kwargs = {**kwargs} self.mealplan = self._kwargs.get('mealplan', None) + if type(self.mealplan) in [int, float]: + self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space) self.id = self._kwargs.get('id', None) self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space) @@ -55,6 +57,8 @@ class RecipeShoppingEditor(): self.created_by = getattr(self._shopping_list_recipe.entries.first(), 'created_by', self.created_by) self.recipe = getattr(self._shopping_list_recipe, 'recipe', None) or self._kwargs.get('recipe', None) or getattr(self.mealplan, 'recipe', None) + if type(self.recipe) in [int, float]: + self.recipe = Recipe.objects.filter(id=self.recipe, space=self.space) try: self.servings = float(self._kwargs.get('servings', None)) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 8b6636ed..23d07088 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -644,7 +644,6 @@ 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() @@ -718,7 +717,8 @@ class RecipeViewSet(viewsets.ModelViewSet): ingredients = request.data.get('ingredients', None) servings = request.data.get('servings', None) list_recipe = request.data.get('list_recipe', None) - SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj) + mealplan = request.data.get('mealplan', None) + SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj, mealplan=mealplan) content = {'msg': _(f'{obj.name} was added to the shopping list.')} http_status = status.HTTP_204_NO_CONTENT diff --git a/vue/src/components/GenericMultiselect.vue b/vue/src/components/GenericMultiselect.vue index c6b4040d..be26f545 100644 --- a/vue/src/components/GenericMultiselect.vue +++ b/vue/src/components/GenericMultiselect.vue @@ -35,7 +35,7 @@ export default { // this.Models and this.Actions inherited from ApiMixin loading: false, objects: [], - selected_objects: [], + selected_objects: undefined, } }, props: { @@ -80,7 +80,7 @@ export default { this.selected_objects = newVal }, clear: function (newVal, oldVal) { - if (this.multiple) { + if (this.multiple || !this.initial_single_selection) { this.selected_objects = [] } else { this.selected_objects = undefined @@ -100,10 +100,10 @@ export default { return this.placeholder || this.model.name || this.$t("Search") }, nothingSelected() { - if (this.multiple) { + if (this.multiple || !this.initial_single_selection) { return this.selected_objects.length === 0 && this.initial_selection.length === 0 } else { - return !this.selected_objects && !this.initial_selection + return !this.selected_objects && !this.initial_single_selection } }, }, diff --git a/vue/src/components/MealPlanEditModal.vue b/vue/src/components/MealPlanEditModal.vue index 6c3eea6c..cb1d7243 100644 --- a/vue/src/components/MealPlanEditModal.vue +++ b/vue/src/components/MealPlanEditModal.vue @@ -76,9 +76,13 @@ {{ $t("Share") }} - + {{ $t("AddToShopping") }} + + + {{ $t("review_shopping") }} +
@@ -99,6 +103,7 @@ diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index aaf93c2f..3e68edd7 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -190,6 +190,9 @@ + + + @@ -432,7 +435,10 @@
- +
@@ -1399,11 +1405,23 @@ export default { window.removeEventListener("offline", this.updateOnlineStatus) }, addRecipeToShopping() { + console.log(this.new_recipe) this.$bvModal.show(`shopping_${this.new_recipe.id}`) }, finishShopping() { + this.add_recipe_servings = 1 + this.new_recipe = { id: undefined } + this.edit_recipe_list = undefined this.getShoppingList() }, + editRecipeList(e, r) { + this.new_recipe = { id: r.recipe_mealplan.recipe, name: r.recipe_mealplan.recipe_name, servings: r.recipe_mealplan.servings, list_recipe: r.list_recipe } + this.$nextTick(function () { + this.$bvModal.show(`shopping_${this.new_recipe.id}`) + }) + + // this.$bvModal.show(`shopping_${this.new_recipe.id}`) + }, }, directives: { hover: { diff --git a/vue/src/components/IngredientComponent.vue b/vue/src/components/IngredientComponent.vue index b3e9919f..fcb3ca54 100644 --- a/vue/src/components/IngredientComponent.vue +++ b/vue/src/components/IngredientComponent.vue @@ -7,7 +7,7 @@ @@ -914,6 +915,7 @@ export default { }, }, mounted() { + console.log(screen.height) this.getShoppingList() this.getSupermarkets() this.getShoppingCategories() @@ -1102,7 +1104,6 @@ export default { if (!autosync) { if (results.data?.length) { this.items = results.data - console.log(this.items) } else { console.log("no data returned") } @@ -1483,7 +1484,7 @@ export default { font-size: 20px; } -@media (max-width: 768px) { +@media screen and (max-width: 768px) { #shoppinglist { display: flex; flex-direction: column; @@ -1494,6 +1495,17 @@ export default { padding-right: 8px !important; } } +@media screen and (min-height: 700px) and (max-width: 768px) { + #shoppinglist { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-y: scroll; + overflow-x: hidden; + height: 72vh; + padding-right: 8px !important; + } +} .settings-checkbox { font-size: 0.3rem; diff --git a/vue/src/components/GenericMultiselect.vue b/vue/src/components/GenericMultiselect.vue index b886fc25..be26f545 100644 --- a/vue/src/components/GenericMultiselect.vue +++ b/vue/src/components/GenericMultiselect.vue @@ -117,7 +117,6 @@ export default { limit: this.limit, } this.genericAPI(this.model, this.Actions.LIST, options).then((result) => { - console.log(result) this.objects = this.sticky_options.concat(result.data?.results ?? result.data) if (this.nothingSelected && this.objects.length > 0) { this.objects.forEach((item) => { diff --git a/vue/src/components/IngredientsCard.vue b/vue/src/components/IngredientsCard.vue index b207a45b..d1a8307d 100644 --- a/vue/src/components/IngredientsCard.vue +++ b/vue/src/components/IngredientsCard.vue @@ -142,20 +142,12 @@ export default { this.shopping_list = result.data if (this.add_shopping_mode) { - console.log("add shopping mode", this.recipe_list, this.steps) if (this.recipe_list) { this.$emit( "starting-cart", this.shopping_list.filter((x) => x.list_recipe === this.recipe_list).map((x) => x.ingredient) ) } else { - console.log( - this.steps - .map((x) => x.ingredients) - .flat() - .filter((x) => x?.food?.food_onhand == false && x?.food?.ignore_shopping == false) - .map((x) => x.id) - ) this.$emit( "starting-cart", this.steps diff --git a/vue/src/components/MealPlanCard.vue b/vue/src/components/MealPlanCard.vue index d72eb6b1..2770f642 100644 --- a/vue/src/components/MealPlanCard.vue +++ b/vue/src/components/MealPlanCard.vue @@ -71,9 +71,7 @@ export default { image_placeholder: window.IMAGE_PLACEHOLDER, } }, - mounted() { - console.log(this.value) - }, + mounted() {}, computed: { entry: function () { return this.value.originalItem diff --git a/vue/src/components/RecipeContextMenu.vue b/vue/src/components/RecipeContextMenu.vue index eb53bed4..1bd7531e 100644 --- a/vue/src/components/RecipeContextMenu.vue +++ b/vue/src/components/RecipeContextMenu.vue @@ -154,7 +154,6 @@ export default { .createMealPlan(entry) .then((result) => { this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`) - console.log(entry) if (reviewshopping) { this.mealplan = result.data.id this.servings_value = result.data.servings diff --git a/vue/src/components/ShoppingLineItem.vue b/vue/src/components/ShoppingLineItem.vue index 057e9b49..e49bb68f 100644 --- a/vue/src/components/ShoppingLineItem.vue +++ b/vue/src/components/ShoppingLineItem.vue @@ -1,334 +1,311 @@ @@ -344,34 +321,34 @@ export default { /* border-bottom: 1px solid #000; /* …and with a border on the top */ /* } */ .checkbox-control { - font-size: 0.6rem + font-size: 0.6rem; } .checkbox-control-mobile { - font-size: 1rem + font-size: 1rem; } .rotate { - -moz-transition: all 0.25s linear; - -webkit-transition: all 0.25s linear; - transition: all 0.25s linear; + -moz-transition: all 0.25s linear; + -webkit-transition: all 0.25s linear; + transition: all 0.25s linear; } .rotated { - -moz-transform: rotate(90deg); - -webkit-transform: rotate(90deg); - transform: rotate(90deg); + -moz-transform: rotate(90deg); + -webkit-transform: rotate(90deg); + transform: rotate(90deg); } .unit-badge-lg { - font-size: 1rem !important; - font-weight: 500 !important; + font-size: 1rem !important; + font-weight: 500 !important; } @media (max-width: 768px) { - .dropdown-spacing { - padding-left: 0 !important; - padding-right: 0 !important; - } + .dropdown-spacing { + padding-left: 0 !important; + padding-right: 0 !important; + } } From 13d144345e31a6445e068c57fab8831e930d8496 Mon Sep 17 00:00:00 2001 From: Chris Scoggins Date: Mon, 31 Jan 2022 16:30:54 -0600 Subject: [PATCH 7/7] adjust height of viewport in mobile shopping view --- vue/src/apps/ShoppingListView/ShoppingListView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index f0ad74b2..dc2cbb2b 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -1491,7 +1491,7 @@ export default { flex-grow: 1; overflow-y: scroll; overflow-x: hidden; - height: 65vh; + height: 6vh; padding-right: 8px !important; } }