diff --git a/cookbook/helper/permission_helper.py b/cookbook/helper/permission_helper.py index e6a345376..a24483b24 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/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index 7e1fa6c76..2bd641fe8 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 @@ -38,118 +38,272 @@ 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) + 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) - 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) + if type(self.recipe) in [int, float]: + self.recipe = Recipe.objects.filter(id=self.recipe, space=self.space) - 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/migrations/0168_add_unit_searchfields.py b/cookbook/migrations/0168_add_unit_searchfields.py new file mode 100644 index 000000000..d6ba5ee6e --- /dev/null +++ b/cookbook/migrations/0168_add_unit_searchfields.py @@ -0,0 +1,20 @@ +from django.db import migrations + +from cookbook.models import SearchFields + + +def create_searchfields(apps, schema_editor): + SearchFields.objects.create(name='Units', field='steps__ingredients__unit__name') + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0167_userpreference_left_handed'), + ] + + operations = [ + migrations.RunPython( + create_searchfields + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index fef3ab838..1a97d755d 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/serializer.py b/cookbook/serializer.py index 8a5212cdc..baf77d051 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 b4a528742..17a3da863 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_list_entryv2.py b/cookbook/tests/api/test_api_shopping_list_entryv2.py index c5a624222..2ef04e893 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 diff --git a/cookbook/tests/api/test_api_shopping_recipe.py b/cookbook/tests/api/test_api_shopping_recipe.py index b32f2872f..0fc85d86a 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 9197f53e8..878c71188 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, @@ -153,11 +153,15 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin): ) else: # TODO have this check unaccent search settings or other search preferences? + filter = Q(name__icontains=query) + if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): + filter |= Q(name__unaccent__icontains=query) + self.queryset = ( self.queryset .annotate(starts=Case(When(name__istartswith=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set - .filter(name__icontains=query).order_by('-starts', 'name') + .filter(filter).order_by('-starts', 'name') ) updated_at = self.request.query_params.get('updated_at', None) @@ -644,7 +648,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() @@ -717,16 +720,27 @@ 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) + mealplan = request.data.get('mealplan', None) + SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj, mealplan=mealplan) - 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 d45fb0753..9b0d59b8b 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/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index aaf93c2f2..dc2cbb2b9 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -123,7 +123,7 @@
-
+
+ + + @@ -432,7 +435,10 @@
+ + +
+ +
+ + + + + + + +
+
+
@@ -637,26 +663,7 @@ - -
- -
- - - - - - - -
-
-
- +
@@ -908,6 +915,7 @@ export default { }, }, mounted() { + console.log(screen.height) this.getShoppingList() this.getSupermarkets() this.getShoppingCategories() @@ -1096,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") } @@ -1399,11 +1406,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: { @@ -1465,14 +1484,25 @@ export default { font-size: 20px; } -@media (max-width: 768px) { +@media screen and (max-width: 768px) { #shoppinglist { display: flex; flex-direction: column; flex-grow: 1; overflow-y: scroll; overflow-x: hidden; - height: 65vh; + height: 6vh; + 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; } } diff --git a/vue/src/components/GenericMultiselect.vue b/vue/src/components/GenericMultiselect.vue index 2fe6e68fb..be26f5454 100644 --- a/vue/src/components/GenericMultiselect.vue +++ b/vue/src/components/GenericMultiselect.vue @@ -3,7 +3,7 @@ v-model="selected_objects" :options="objects" :close-on-select="true" - :clear-on-select="true" + :clear-on-select="multiple" :hide-selected="multiple" :preserve-search="true" :internal-search="false" @@ -35,7 +35,7 @@ export default { // this.Models and this.Actions inherited from ApiMixin loading: false, objects: [], - selected_objects: [], + selected_objects: undefined, } }, props: { @@ -48,7 +48,7 @@ export default { }, label: { type: String, default: "name" }, parent_variable: { type: String, default: undefined }, - limit: { type: Number, default: 10 }, + limit: { type: Number, default: 25 }, sticky_options: { type: Array, default() { @@ -61,6 +61,10 @@ export default { return [] }, }, + initial_single_selection: { + type: Object, + default: undefined, + }, multiple: { type: Boolean, default: true }, allow_create: { type: Boolean, default: false }, create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" }, @@ -71,18 +75,37 @@ export default { // watch it this.selected_objects = newVal }, + initial_single_selection: function (newVal, oldVal) { + // watch it + this.selected_objects = newVal + }, clear: function (newVal, oldVal) { - this.selected_objects = [] + if (this.multiple || !this.initial_single_selection) { + this.selected_objects = [] + } else { + this.selected_objects = undefined + } }, }, mounted() { this.search("") - this.selected_objects = this.initial_selection + if (this.multiple || !this.initial_single_selection) { + this.selected_objects = this.initial_selection + } else { + this.selected_objects = this.initial_single_selection + } }, computed: { lookupPlaceholder() { return this.placeholder || this.model.name || this.$t("Search") }, + nothingSelected() { + 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_single_selection + } + }, }, methods: { // this.genericAPI inherited from ApiMixin @@ -95,8 +118,9 @@ export default { } this.genericAPI(this.model, this.Actions.LIST, options).then((result) => { this.objects = this.sticky_options.concat(result.data?.results ?? result.data) - if (this.selected_objects.length === 0 && this.initial_selection.length === 0 && this.objects.length > 0) { + if (this.nothingSelected && this.objects.length > 0) { this.objects.forEach((item) => { + // select default items when present in object if ("default" in item) { if (item.default) { if (this.multiple) { @@ -109,6 +133,7 @@ export default { } }) } + // this.removeMissingItems() # This removes items that are on another page of results }) }, selectionChanged: function () { @@ -121,6 +146,13 @@ export default { this.search("") }, 750) }, + // removeMissingItems: function () { + // if (this.multiple) { + // this.selected_objects = this.selected_objects.filter((x) => !this.objects.map((y) => y.id).includes(x)) + // } else { + // this.selected_objects = this.objects.filter((x) => x.id === this.selected_objects.id)[0] + // } + // }, }, } diff --git a/vue/src/components/IngredientComponent.vue b/vue/src/components/IngredientComponent.vue index b3e9919fe..fcb3ca548 100644 --- a/vue/src/components/IngredientComponent.vue +++ b/vue/src/components/IngredientComponent.vue @@ -7,7 +7,7 @@