diff --git a/cookbook/forms.py b/cookbook/forms.py index ea86d1cb..8cd5d68f 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -481,7 +481,7 @@ class ShoppingPreferenceForm(forms.ModelForm): fields = ( 'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', - 'mealplan_autoinclude_related', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix' + 'mealplan_autoinclude_related', 'shopping_add_onhand', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix' ) help_texts = { @@ -496,6 +496,7 @@ class ShoppingPreferenceForm(forms.ModelForm): 'default_delay': _('Default number of hours to delay a shopping list entry.'), 'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'), 'shopping_recent_days': _('Days of recent shopping list entries to display.'), + 'shopping_add_onhand': _("Mark food 'On Hand' when checked off shopping list."), 'csv_delim': _('Delimiter to use for CSV exports.'), 'csv_prefix': _('Prefix to add when copying list to the clipboard.'), @@ -510,7 +511,8 @@ class ShoppingPreferenceForm(forms.ModelForm): 'filter_to_supermarket': _('Filter to Supermarket'), 'shopping_recent_days': _('Recent Days'), 'csv_delim': _('CSV Delimiter'), - "csv_prefix_label": _("List Prefix") + "csv_prefix_label": _("List Prefix"), + 'shopping_add_onhand': _("Auto On Hand"), } widgets = { diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index 4f725ffa..658f63ca 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -81,7 +81,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None ingredients = Ingredient.objects.filter(step__recipe=r, space=space) if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand: - ingredients = ingredients.exclude(food__food_onhand=True) + 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 @@ -92,7 +92,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None # 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, food__food_onhand=False, space=space).values_list('id', flat=True) + 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) @@ -100,7 +100,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None 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__food_onhand=False, space=space) + x_ing = Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]) else: x_ing = Ingredient.objects.filter(step__recipe=x, space=space) for i in [x for x in x_ing]: diff --git a/cookbook/models.py b/cookbook/models.py index 1210776f..cfe4ab81 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -526,10 +526,10 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): ]) inherit = inherit.values_list('field', flat=True) - if 'food_onhand' in inherit: + if 'ignore_shopping' in inherit: # get food at root that have children that need updated - Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, food_onhand=True)).update(food_onhand=True) - Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, food_onhand=False)).update(food_onhand=False) + Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=True)).update(ignore_shopping=True) + Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=False)).update(ignore_shopping=False) if 'supermarket_category' in inherit: # when supermarket_category is null or blank assuming it is not set and not intended to be blank for all descedants # find top node that has category set diff --git a/cookbook/serializer.py b/cookbook/serializer.py index b89ebadb..c784c86a 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -10,6 +10,7 @@ from django.utils import timezone from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer from rest_framework import serializers from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.fields import SkipField from cookbook.helper.shopping_helper import list_from_recipe from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, @@ -176,16 +177,13 @@ class UserPreferenceSerializer(serializers.ModelSerializer): raise NotFound() return super().create(validated_data) - def update(self, instance, validated_data): - # don't allow writing to FoodInheritField via API - return super().update(instance, validated_data) - class Meta: model = UserPreference fields = ( 'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share', 'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay', - 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', 'filter_to_supermarket' + 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', + 'filter_to_supermarket', 'shopping_add_onhand' ) @@ -380,23 +378,23 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR recipe = RecipeSimpleSerializer(allow_null=True, required=False) shopping = serializers.SerializerMethodField('get_shopping_status') inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False) - food_onhand = CustomOnHandField() + food_onhand = CustomOnHandField(required=False) recipe_filter = 'steps__ingredients__food' def get_shopping_status(self, obj): return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0 - # def get_food_onhand(self, obj): - # shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id] - # return obj.onhand_users.filter(id__in=shared_users).exists() - def run_validation(self, data): - validated_data = super().run_validation(data) + try: + validated_data = super().run_validation(data) + except SkipField: + return super().run_validation(data) # convert boolean food_onhand to onhand_users + # TODO maybe this should be moved to pre_save signal to ensure shopping_share users are always included? if ( self.root.instance.__class__.__name__ == 'Food' - and not (onhand := data.pop('food_onhand', None)) is None + and not onhand is None ): # assuming if on hand for user also onhand for shopping_share users shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) @@ -404,6 +402,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users else: validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users)) + return validated_data def create(self, validated_data): @@ -724,6 +723,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): ): # if checked flips from false to true set completed datetime data['completed_at'] = timezone.now() + elif not data.get('checked', False): # if not checked set completed to None data['completed_at'] = None @@ -739,6 +739,16 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): validated_data['created_by'] = self.context['request'].user return super().create(validated_data) + def update(self, instance, validated_data): + user = self.context['request'].user + if user.userpreference.shopping_add_onhand: + if checked := validated_data.get('checked', None): + instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user) + elif checked == False: + instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user) + + return super().update(instance, validated_data) + class Meta: model = ShoppingListEntry fields = ( @@ -891,7 +901,7 @@ class FoodExportSerializer(FoodSerializer): class Meta: model = Food - fields = ('name', 'food_onhand', 'supermarket_category',) + fields = ('name', 'ignore_shopping', 'supermarket_category',) class IngredientExportSerializer(WritableNestedModelSerializer): diff --git a/cookbook/signals.py b/cookbook/signals.py index 47e5bf9e..c21d6638 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -75,8 +75,8 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs): # apply changes from parent to instance for each inheritted field if instance.parent and inherit.count() > 0: parent = instance.get_parent() - if 'food_onhand' in inherit: - instance.food_onhand = parent.food_onhand + if 'ignore_shopping' in inherit: + instance.ignore_shopping = parent.ignore_shopping # if supermarket_category is not set, do not cascade - if this becomes non-intuitive can change if 'supermarket_category' in inherit and parent.supermarket_category: instance.supermarket_category = parent.supermarket_category @@ -89,8 +89,8 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs): # TODO figure out how to generalize this # apply changes to direct children - depend on save signals for those objects to cascade inheritance down _save = [] - for child in instance.get_children().filter(inherit_fields__field='food_onhand'): - child.food_onhand = instance.food_onhand + for child in instance.get_children().filter(inherit_fields__field='ignore_shopping'): + child.ignore_shopping = instance.ignore_shopping _save.append(child) # don't cascade empty supermarket category if instance.supermarket_category: @@ -121,3 +121,11 @@ def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs 'servings': instance.servings } list_recipe = list_from_recipe(**kwargs) + + +# user = self.context['request'].user +# if user.userpreference.shopping_add_onhand: +# if checked := validated_data.get('checked', None): +# instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user) +# elif checked == False: +# instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user) diff --git a/cookbook/tests/api/test_api_food.py b/cookbook/tests/api/test_api_food.py index cde72548..699ededa 100644 --- a/cookbook/tests/api/test_api_food.py +++ b/cookbook/tests/api/test_api_food.py @@ -483,8 +483,8 @@ def test_tree_filter(obj_tree_1, obj_2, obj_3, u1_s1): @pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [ ({'has_category': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'), ({'has_category': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'), - ({'food_onhand': True, 'inherit': True}, 'food_onhand', True, 'false'), - ({'food_onhand': True, 'inherit': False}, 'food_onhand', False, 'false'), + ({'ignore_shopping': True, 'inherit': True}, 'ignore_shopping', True, 'false'), + ({'ignore_shopping': True, 'inherit': False}, 'ignore_shopping', False, 'false'), ], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1): with scope(space=obj_tree_1.space): @@ -509,16 +509,16 @@ def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1): @pytest.mark.parametrize("obj_tree_1", [ - ({'has_category': True, 'inherit': False, 'food_onhand': True}), + ({'has_category': True, 'inherit': False, 'ignore_shopping': True}), ], indirect=['obj_tree_1']) def test_reset_inherit(obj_tree_1, space_1): with scope(space=space_1): space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields parent = obj_tree_1.get_parent() child = obj_tree_1.get_descendants()[0] - obj_tree_1.food_onhand = False - assert parent.food_onhand == child.food_onhand - assert parent.food_onhand != obj_tree_1.food_onhand + obj_tree_1.ignore_shopping = False + assert parent.ignore_shopping == child.ignore_shopping + assert parent.ignore_shopping != obj_tree_1.ignore_shopping assert parent.supermarket_category != child.supermarket_category assert parent.supermarket_category != obj_tree_1.supermarket_category @@ -527,5 +527,26 @@ def test_reset_inherit(obj_tree_1, space_1): obj_tree_1 = Food.objects.get(id=obj_tree_1.id) parent = obj_tree_1.get_parent() child = obj_tree_1.get_descendants()[0] - assert parent.food_onhand == obj_tree_1.food_onhand == child.food_onhand + assert parent.ignore_shopping == obj_tree_1.ignore_shopping == child.ignore_shopping assert parent.supermarket_category == obj_tree_1.supermarket_category == child.supermarket_category + + +def test_onhand(obj_1, u1_s1, u2_s1): + assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False + assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False + + u1_s1.patch( + reverse( + DETAIL_URL, + args={obj_1.id} + ), + {'food_onhand': True}, + content_type='application/json' + ) + assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == True + assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False + + user1 = auth.get_user(u1_s1) + user2 = auth.get_user(u2_s1) + user1.userpreference.shopping_share.add(user2) + assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == True diff --git a/cookbook/tests/api/test_api_shopping_list_entryv2.py b/cookbook/tests/api/test_api_shopping_list_entryv2.py index 40b64a72..c5a62422 100644 --- a/cookbook/tests/api/test_api_shopping_list_entryv2.py +++ b/cookbook/tests/api/test_api_shopping_list_entryv2.py @@ -217,3 +217,6 @@ def test_recent(sle, u1_s1): r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content) assert len(r) == 10 assert [x['checked'] for x in r].count(False) == 9 + + +# TODO test auto onhand diff --git a/cookbook/tests/api/test_api_shopping_recipe.py b/cookbook/tests/api/test_api_shopping_recipe.py index d564bf2a..8f4185cb 100644 --- a/cookbook/tests/api/test_api_shopping_recipe.py +++ b/cookbook/tests/api/test_api_shopping_recipe.py @@ -198,11 +198,11 @@ def test_shopping_recipe_userpreference(recipe, sle_count, use_mealplan, user2): # setup recipe with 10 ingredients, 1 step recipe with 10 ingredients, 2 food onhand(from recipe and step_recipe) ingredients = Ingredient.objects.filter(step__recipe=recipe) food = Food.objects.get(id=ingredients[2].food.id) - food.food_onhand = True + food.onhand_users.add(user) food.save() food = recipe.steps.filter(type=Step.RECIPE).first().step_recipe.steps.first().ingredients.first().food food = Food.objects.get(id=food.id) - food.food_onhand = True + food.onhand_users.add(user) food.save() if use_mealplan: diff --git a/cookbook/views/views.py b/cookbook/views/views.py index b875bc38..afc49a7a 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -388,6 +388,7 @@ def user_settings(request): up.filter_to_supermarket = shopping_form.cleaned_data['filter_to_supermarket'] up.default_delay = shopping_form.cleaned_data['default_delay'] up.shopping_recent_days = shopping_form.cleaned_data['shopping_recent_days'] + up.shopping_add_onhand = shopping_form.cleaned_data['shopping_add_onhand'] up.csv_delim = shopping_form.cleaned_data['csv_delim'] up.csv_prefix = shopping_form.cleaned_data['csv_prefix'] if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index 02999eff..54274d78 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -375,6 +375,19 @@ +