diff --git a/cookbook/apps.py b/cookbook/apps.py index c785e5536..e551319db 100644 --- a/cookbook/apps.py +++ b/cookbook/apps.py @@ -35,4 +35,3 @@ class CookbookConfig(AppConfig): # if DEBUG: # traceback.print_exc() # pass # dont break startup just because fix could not run, need to investigate cases when this happens - diff --git a/cookbook/forms.py b/cookbook/forms.py index ea86d1cb8..8cd5d68f8 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/scope_middleware.py b/cookbook/helper/scope_middleware.py index 7f0676c7e..714214ac2 100644 --- a/cookbook/helper/scope_middleware.py +++ b/cookbook/helper/scope_middleware.py @@ -5,6 +5,7 @@ from rest_framework.authtoken.models import Token from rest_framework.exceptions import AuthenticationFailed from cookbook.views import views +from recipes import settings class ScopeMiddleware: @@ -14,14 +15,15 @@ class ScopeMiddleware: def __call__(self, request): if request.user.is_authenticated: - if request.path.startswith('/admin/'): + prefix = settings.JS_REVERSE_SCRIPT_PREFIX or '' + if request.path.startswith(prefix + '/admin/'): with scopes_disabled(): return self.get_response(request) - if request.path.startswith('/signup/') or request.path.startswith('/invite/'): + if request.path.startswith(prefix + '/signup/') or request.path.startswith(prefix + '/invite/'): return self.get_response(request) - if request.path.startswith('/accounts/'): + if request.path.startswith(prefix + '/accounts/'): return self.get_response(request) with scopes_disabled(): @@ -36,7 +38,7 @@ class ScopeMiddleware: with scope(space=request.space): return self.get_response(request) else: - if request.path.startswith('/api/'): + if request.path.startswith(prefix + '/api/'): try: if auth := TokenAuthentication().authenticate(request): request.space = auth[0].userpreference.space diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index 75a70d36b..658f63caa 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -17,8 +17,7 @@ def shopping_helper(qs, request): supermarket = request.query_params.get('supermarket', None) checked = request.query_params.get('checked', 'recent') user = request.user - - supermarket_order = ['food__supermarket_category__name', 'food__name'] + supermarket_order = [F('food__supermarket_category__name').asc(nulls_first=True), 'food__name'] # TODO created either scheduled task or startup task to delete very old shopping list entries # TODO create user preference to define 'very old' @@ -82,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 @@ -93,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) @@ -101,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/migrations/0163_auto_20220105_0758.py b/cookbook/migrations/0163_auto_20220105_0758.py new file mode 100644 index 000000000..b7d67cd18 --- /dev/null +++ b/cookbook/migrations/0163_auto_20220105_0758.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.10 on 2022-01-05 13:58 + +from django.conf import settings +from django.db import migrations, models + +from cookbook.models import FoodInheritField + + +def rename_inherit_field(apps, schema_editor): + x = FoodInheritField.objects.filter(name='On Hand', field='food_onhand').first() + if x: + x.name = "Ignore Shopping" + x.field = "ignore_shopping" + x.save() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0162_userpreference_csv_delim'), + ] + + operations = [ + migrations.AddField( + model_name='food', + name='onhand_users', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='userpreference', + name='shopping_add_onhand', + field=models.BooleanField(default=True), + ), + migrations.RenameField( + model_name='food', + old_name='food_onhand', + new_name='ignore_shopping', + ), + migrations.RunPython(rename_inherit_field), + ] diff --git a/cookbook/models.py b/cookbook/models.py index bc167ba47..cfe4ab814 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -332,6 +332,7 @@ class UserPreference(models.Model, PermissionModelMixin): mealplan_autoadd_shopping = models.BooleanField(default=False) mealplan_autoexclude_onhand = models.BooleanField(default=True) mealplan_autoinclude_related = models.BooleanField(default=True) + shopping_add_onhand = models.BooleanField(default=True) filter_to_supermarket = models.BooleanField(default=False) default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4) shopping_recent_days = models.PositiveIntegerField(default=7) @@ -489,10 +490,11 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): node_order_by = ['name'] name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL) - supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) - food_onhand = models.BooleanField(default=False) # inherited field + supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) # inherited field + ignore_shopping = models.BooleanField(default=False) # inherited field + onhand_users = models.ManyToManyField(User, blank=True) description = models.TextField(default='', blank=True) - inherit_fields = models.ManyToManyField(FoodInheritField, blank=True) # inherited field: is this name better as inherit instead of ignore inherit? which is more intuitive? + inherit_fields = models.ManyToManyField(FoodInheritField, blank=True) space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space', _manager_class=TreeManager) @@ -524,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 c43b52a7c..ac6238681 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 empty from cookbook.helper.shopping_helper import list_from_recipe from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, @@ -92,6 +93,18 @@ class CustomDecimalField(serializers.Field): raise ValidationError('A valid number is required') +class CustomOnHandField(serializers.Field): + def get_attribute(self, instance): + return instance + + def to_representation(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 to_internal_value(self, data): + return data + + class SpaceFilterSerializer(serializers.ListSerializer): def to_representation(self, data): @@ -167,16 +180,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' ) @@ -371,6 +381,7 @@ 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(required=False, allow_null=True) recipe_filter = 'steps__ingredients__food' @@ -385,12 +396,29 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR validated_data['supermarket_category'], sc_created = SupermarketCategory.objects.get_or_create( name=validated_data.pop('supermarket_category')['name'], space=self.context['request'].space) + onhand = validated_data.get('food_onhand', None) + + # assuming if on hand for user also onhand for shopping_share users + if not onhand is None: + shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) + if onhand: + 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)) obj, created = Food.objects.get_or_create(**validated_data) return obj def update(self, instance, validated_data): if name := validated_data.get('name', None): validated_data['name'] = name.strip() + # assuming if on hand for user also onhand for shopping_share users + onhand = validated_data.get('food_onhand', None) + if not onhand is None: + shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) + if onhand: + 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 super(FoodSerializer, self).update(instance, validated_data) class Meta: @@ -687,20 +715,22 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): return fields def run_validation(self, data): - if ( - data.get('checked', False) - and self.root.instance - and not self.root.instance.checked - ): - # 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 - else: - # otherwise don't write anything - if 'completed_at' in data: - del data['completed_at'] + if self.root.instance.__class__.__name__ == 'ShoppingListEntry': + if ( + data.get('checked', False) + and self.root.instance + and not self.root.instance.checked + ): + # 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 + else: + # otherwise don't write anything + if 'completed_at' in data: + del data['completed_at'] return super().run_validation(data) @@ -709,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 + # update the onhand for food if shopping_add_onhand is True + 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 = ( @@ -861,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 47e5bf9ea..c21d66389 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 cde725484..699ededa9 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_related_recipe.py b/cookbook/tests/api/test_api_related_recipe.py index ce6221880..1a381ed5c 100644 --- a/cookbook/tests/api/test_api_related_recipe.py +++ b/cookbook/tests/api/test_api_related_recipe.py @@ -65,7 +65,6 @@ def test_related_mixed_space(request, recipe, u1_s2): reverse(RELATED_URL, args={recipe.id})).content)) == 0 -# TODO add tests for mealplan related when thats added # TODO if/when related recipes includes multiple levels (related recipes of related recipes) add the following tests # -- step recipes included in step recipes # -- step recipes included in food recipes diff --git a/cookbook/tests/api/test_api_shopping_list_entryv2.py b/cookbook/tests/api/test_api_shopping_list_entryv2.py index 40b64a726..c5a624222 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 438de046e..8f4185cb8 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: @@ -233,7 +233,6 @@ def test_shopping_recipe_mixed_authors(u1_s1, u2_s1): assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0 -# TODO test adding recipe with ingredients that are not food @pytest.mark.parametrize("recipe", [{'steps__ingredients__header': 1}], indirect=['recipe']) def test_shopping_with_header_ingredient(u1_s1, recipe): # with scope(space=recipe.space): diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 69f19255d..058216c0d 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -689,8 +689,11 @@ class RecipeViewSet(viewsets.ModelViewSet): obj = self.get_object() if obj.get_space() != request.space: raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403) - qs = obj.get_related_recipes(levels=1) # TODO: make levels a user setting, included in request data?, keep solely in the backend? - # mealplans= TODO get todays mealplans + try: + levels = int(request.query_params.get('levels', 1)) + except (ValueError, TypeError): + levels = 1 + qs = obj.get_related_recipes(levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend? return Response(self.serializer_class(qs, many=True).data) diff --git a/cookbook/views/views.py b/cookbook/views/views.py index b875bc389..afc49a7a4 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/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index d889267c7..1d90667de 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -7,7 +7,7 @@
-
+
diff --git a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue index c0c8a2c0d..a76c7f051 100644 --- a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue +++ b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue @@ -1,5 +1,6 @@
+

{{ recipe.name }}

@@ -75,12 +76,12 @@ />
- + + + + + +
@@ -172,6 +173,7 @@ import IngredientsCard from "@/components/IngredientsCard" import StepComponent from "@/components/StepComponent" import KeywordsComponent from "@/components/KeywordsComponent" import NutritionComponent from "@/components/NutritionComponent" +import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher" Vue.prototype.moment = moment @@ -192,22 +194,32 @@ export default { KeywordsComponent, LoadingSpinner, AddRecipeToBook, + RecipeSwitcher, }, computed: { ingredient_factor: function () { return this.servings / this.recipe.servings }, + ingredient_count() { + return this.recipe?.steps.map((x) => x.ingredients).flat().length + }, }, data() { return { loading: true, recipe: undefined, - ingredient_count: 0, + rootrecipe: undefined, servings: 1, + servings_cache: {}, start_time: "", share_uid: window.SHARE_UID, } }, + watch: { + servings(newVal, oldVal) { + this.servings_cache[this.recipe.id] = this.servings + }, + }, mounted() { this.loadRecipe(window.RECIPE_ID) this.$i18n.locale = window.CUSTOM_LOCALE @@ -218,12 +230,9 @@ export default { if (window.USER_SERVINGS !== 0) { recipe.servings = window.USER_SERVINGS } - this.servings = recipe.servings let total_time = 0 for (let step of recipe.steps) { - this.ingredient_count += step.ingredients.length - for (let ingredient of step.ingredients) { this.$set(ingredient, "checked", false) } @@ -237,7 +246,8 @@ export default { this.start_time = moment().format("yyyy-MM-DDTHH:mm") } - this.recipe = recipe + this.recipe = this.rootrecipe = recipe + this.servings = this.servings_cache[this.rootrecipe.id] = recipe.servings this.loading = false }) }, @@ -253,6 +263,15 @@ export default { } } }, + quickSwitch: function (e) { + if (e === -1) { + this.recipe = this.rootrecipe + this.servings = this.servings_cache[this.rootrecipe?.id ?? 1] + } else { + this.recipe = e + this.servings = this.servings_cache?.[e.id] ?? e.servings + } + }, }, } diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index 37aa5e2df..f26fb7d1d 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -31,15 +31,19 @@
+ - + - - + + - - + + + + + @@ -47,6 +51,12 @@ + + + + {{ $t("QuickEntry") }} + +
@@ -58,16 +68,28 @@
- - - {{ i }} - +
@@ -353,6 +375,19 @@
+
+
{{ $t("shopping_add_onhand") }}
+
+ +
+
+
+
+ + {{ $t("shopping_add_onhand_desc") }} + +
+
{{ $t("shopping_recent_days") }}
@@ -492,6 +527,14 @@
+ + {{ $t("mark_complete") }} + x.id === this.selected_supermarket) .map((x) => x.category_to_supermarket) .flat() .map((x) => x.category.id) @@ -603,7 +652,7 @@ export default { shopping_list = shopping_list.filter((x) => x?.food?.supermarket_category) } - let groups = { false: {}, true: {} } // force unchecked to always be first + var groups = { false: {}, true: {} } // force unchecked to always be first if (this.selected_supermarket) { let super_cats = this.supermarkets .filter((x) => x.id === this.selected_supermarket) @@ -611,10 +660,13 @@ export default { .flat() .map((x) => x.category.name) new Set([...super_cats, ...this.shopping_categories.map((x) => x.name)]).forEach((cat) => { - groups["false"][cat.name] = {} - groups["true"][cat.name] = {} + groups["false"][cat] = {} + groups["true"][cat] = {} }) } else { + // TODO: make nulls_first a user setting + groups.false[this.$t("Undefined")] = {} + groups.true[this.$t("Undefined")] = {} this.shopping_categories.forEach((cat) => { groups.false[cat.name] = {} groups.true[cat.name] = {} @@ -712,6 +764,9 @@ export default { "settings.default_delay": function (newVal, oldVal) { this.delay = Number(newVal) }, + entry_mode_simple(newVal) { + this.$cookies.set(SETTINGS_COOKIE_NAME, newVal) + }, }, mounted() { this.getShoppingList() @@ -725,11 +780,29 @@ export default { window.addEventListener("online", this.updateOnlineStatus) window.addEventListener("offline", this.updateOnlineStatus) } + this.$nextTick(function () { + if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) { + this.entry_mode_simple = this.$cookies.get(SETTINGS_COOKIE_NAME) + } + }) }, methods: { // this.genericAPI inherited from ApiMixin - - addItem() { + addItem: function () { + if (this.entry_mode_simple) { + this.genericPostAPI("api_ingredient_from_string", { text: this.new_item.ingredient }).then((result) => { + this.new_item = { + amount: result.data.amount, + unit: { name: result.data.unit }, + food: { name: result.data.food }, + } + this.addEntry() + }) + } else { + this.addEntry() + } + }, + addEntry: function (x) { let api = new ApiApiFactory() api.createShoppingListEntry(this.new_item) .then((results) => { @@ -739,7 +812,8 @@ export default { } else { console.log("no data returned") } - this.new_item = { amount: 1, unit: undefined, food: undefined } + this.new_item = { amount: 1, unit: undefined, food: undefined, ingredient: undefined } + this.clear += 1 }) .catch((err) => { console.log(err) @@ -917,8 +991,17 @@ export default { // TODO make decision - should inheritance always be set manually or give user a choice at front-end or make it a setting? let food = this.items.filter((x) => x.food.id == item?.[0]?.food.id ?? item.food.id)[0].food - food.supermarket_category = this.shopping_categories.filter((x) => x?.id === this.shopcat)?.[0] - this.updateFood(food, "supermarket_category") + let supermarket_category = this.shopping_categories.filter((x) => x?.id === this.shopcat)?.[0] + food.supermarket_category = supermarket_category + this.updateFood(food, "supermarket_category").then((result) => { + this.items = this.items.map((x) => { + if (x.food.id === food.id) { + return { ...x, food: { ...x.food, supermarket_category: supermarket_category } } + } else { + return x + } + }) + }) this.shopcat = null }, onHand: function (item) { @@ -940,8 +1023,13 @@ export default { }) }) }, - openContextMenu(e, value) { - this.shopcat = value?.food?.supermarket_category?.id ?? value?.[0]?.food?.supermarket_category?.id ?? undefined + openContextMenu(e, value, section = false) { + if (section) { + value = Object.values(value).flat() + } else { + this.shopcat = value?.food?.supermarket_category?.id ?? value?.[0]?.food?.supermarket_category?.id ?? undefined + } + this.$refs.menu.open(e, value) }, saveSettings: function () { @@ -1010,24 +1098,15 @@ export default { }, updateFood: function (food, field) { let api = new ApiApiFactory() - let ignore_category if (field) { - ignore_category = food.inherit_fields - .map((x) => food.inherit_fields.fields) - .flat() - .includes(field) - } else { - ignore_category = true + // assume if field is changing it should no longer be inheritted + food.inherit_fields = food.inherit_fields.filter((x) => x.field !== field) } return api .partialUpdateFood(food.id, food) .then((result) => { - if (food.supermarket_category && !ignore_category && food.parent) { - makeToast(this.$t("Warning"), this.$t("InheritWarning", { food: food.name }), "warning") - } else { - StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE) - } + StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE) if (food?.numchild > 0) { this.getShoppingList() // if food has children, just get the whole list. probably could be more efficient } diff --git a/vue/src/components/Buttons/RecipeSwitcher.vue b/vue/src/components/Buttons/RecipeSwitcher.vue new file mode 100644 index 000000000..607155655 --- /dev/null +++ b/vue/src/components/Buttons/RecipeSwitcher.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/vue/src/components/GenericHorizontalCard.vue b/vue/src/components/GenericHorizontalCard.vue index 0635702e2..39a31d907 100644 --- a/vue/src/components/GenericHorizontalCard.vue +++ b/vue/src/components/GenericHorizontalCard.vue @@ -26,7 +26,7 @@
{{ item[subtitle] }}
{{ getFullname }}
- + diff --git a/vue/src/components/GenericMultiselect.vue b/vue/src/components/GenericMultiselect.vue index 178730ede..9a2c6499c 100644 --- a/vue/src/components/GenericMultiselect.vue +++ b/vue/src/components/GenericMultiselect.vue @@ -62,12 +62,16 @@ export default { multiple: { type: Boolean, default: true }, allow_create: { type: Boolean, default: false }, create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" }, + clear: { type: Number }, }, watch: { initial_selection: function (newVal, oldVal) { // watch it this.selected_objects = newVal }, + clear: function (newVal, oldVal) { + this.selected_objects = [] + }, }, mounted() { this.search("") diff --git a/vue/src/components/GenericPill.vue b/vue/src/components/GenericPill.vue index 183d83ec8..276042523 100644 --- a/vue/src/components/GenericPill.vue +++ b/vue/src/components/GenericPill.vue @@ -10,7 +10,7 @@ export default { name: "GenericPill", props: { - item_list: { type: Object }, + item_list: { type: Array }, label: { type: String, default: "name" }, color: { type: String, default: "light" }, }, diff --git a/vue/src/components/IngredientComponent.vue b/vue/src/components/IngredientComponent.vue index 184765c97..595db720b 100644 --- a/vue/src/components/IngredientComponent.vue +++ b/vue/src/components/IngredientComponent.vue @@ -108,7 +108,7 @@ export default { this.shop = false // don't check any boxes until user selects a shopping list to edit if (count_shopping_ingredient >= 1) { this.shopping_status = true // ingredient is in the shopping list - probably (but not definitely, this ingredient) - } else if (this.ingredient.food.shopping) { + } else if (this.ingredient?.food?.shopping) { this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe } else { // food is not in any shopping list @@ -123,7 +123,7 @@ export default { if (count_shopping_ingredient >= 1) { // ingredient is in this shopping list (not entirely sure how this could happen?) this.shopping_status = true - } else if (count_shopping_ingredient == 0 && this.ingredient.food.shopping) { + } else if (count_shopping_ingredient == 0 && this.ingredient?.food?.shopping) { // food is in the shopping list, just not for this ingredient/recipe this.shopping_status = null } else { diff --git a/vue/src/components/Modals/LookupInput.vue b/vue/src/components/Modals/LookupInput.vue index b1e766fdd..24dde7540 100644 --- a/vue/src/components/Modals/LookupInput.vue +++ b/vue/src/components/Modals/LookupInput.vue @@ -13,6 +13,7 @@ :sticky_options="sticky_options" :allow_create="form.allow_create" :create_placeholder="createPlaceholder" + :clear="clear" style="flex-grow: 1; flex-shrink: 1; flex-basis: 0" :placeholder="modelName" @new="addNew" @@ -44,6 +45,7 @@ export default { }, }, show_label: { type: Boolean, default: true }, + clear: { type: Number }, }, data() { return { diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index d7face968..fcf49e540 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -276,5 +276,11 @@ "csv_prefix_label": "List Prefix", "copy_markdown_table": "Copy as Markdown Table", "in_shopping": "In Shopping List", - "DelayUntil": "Delay Until" + "DelayUntil": "Delay Until", + "mark_complete": "Mark Complete", + "QuickEntry": "Quick Entry", + "shopping_add_onhand_desc": "Mark food 'On Hand' when checked off shopping list.", + "shopping_add_onhand": "Auto On Hand", + "related_recipes": "Related Recipes", + "today_recipes": "Today's Recipes" } diff --git a/vue/src/utils/utils.js b/vue/src/utils/utils.js index 9c6f184d5..1d4e47bb4 100644 --- a/vue/src/utils/utils.js +++ b/vue/src/utils/utils.js @@ -234,7 +234,14 @@ export const ApiMixin = { return apiClient[func](...parameters) }, genericGetAPI: function (url, options) { - return axios.get(this.resolveDjangoUrl(url), { params: options, emulateJSON: true }) + return axios.get(resolveDjangoUrl(url), { params: options, emulateJSON: true }) + }, + genericPostAPI: function (url, form) { + let data = new FormData() + Object.keys(form).forEach((field) => { + data.append(field, form[field]) + }) + return axios.post(resolveDjangoUrl(url), data) }, }, }