From 6ef25b604bc444bdd369968f45d987d52dab2368 Mon Sep 17 00:00:00 2001 From: Chris Scoggins Date: Thu, 3 Feb 2022 15:04:46 -0600 Subject: [PATCH] add food substitutions --- cookbook/helper/recipe_search.py | 39 ++++++++- .../migrations/0170_alter_ingredient_unit.py | 19 +++++ .../migrations/0171_auto_20220202_1340.py | 34 ++++++++ cookbook/models.py | 8 +- cookbook/serializer.py | 28 ++++++- .../tests/api/test_api_shopping_list_entry.py | 5 +- .../api/test_api_shopping_list_entryv2.py | 5 +- cookbook/views/api.py | 2 +- vue/src/apps/ModelListView/ModelListView.vue | 1 - .../ShoppingListView/ShoppingListView.vue | 2 - .../apps/SupermarketView/SupermarketView.vue | 13 ++- vue/src/components/Badges/OnHand.vue | 29 +++++-- vue/src/components/IngredientComponent.vue | 9 +- vue/src/components/IngredientsCard.vue | 1 - .../components/Modals/GenericModalForm.vue | 24 +++++- vue/src/components/Modals/ShoppingModal.vue | 6 +- vue/src/locales/en.json | 15 +++- vue/src/utils/models.js | 48 ++++++++++- vue/src/utils/openapi/api.ts | 83 +++++++++++++++++-- 19 files changed, 322 insertions(+), 49 deletions(-) create mode 100644 cookbook/migrations/0170_alter_ingredient_unit.py create mode 100644 cookbook/migrations/0171_auto_20220202_1340.py diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 45d5b238..556de422 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -419,12 +419,47 @@ class RecipeSearch(): if not self._makenow: return shopping_users = [*self._request.user.get_shopping_share(), self._request.user] + + onhand_filter = ( + Q(steps__ingredients__food__onhand_users__in=shopping_users, steps__ingredients__food__ignore_shopping=False) # food onhand + | Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users, steps__ingredients__food__substitute__ignore_shopping=False) # or substitute food onhand + | Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users)) + | Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users)) + ) self._queryset = self._queryset.annotate( count_food=Count('steps__ingredients__food'), - count_onhand=Count('pk', filter=Q(steps__ingredients__food__onhand_users__in=shopping_users, steps__ingredients__food__ignore_shopping=False)), + count_onhand=Count('pk', filter=Q(onhand_filter)), count_ignore=Count('pk', filter=Q(steps__ingredients__food__ignore_shopping=True)) ).annotate(missingfood=F('count_food')-F('count_onhand')-F('count_ignore')).filter(missingfood=0) + @staticmethod + def __children_substitute_filter(shopping_users=None): + children_onhand_subquery = Food.objects.filter( + path__startswith=Substr(OuterRef('path'), 1, Food.steplen*OuterRef('depth')), + depth__gt=OuterRef('depth'), + onhand_users__in=shopping_users + ).annotate(child_onhand=Count((Substr(OuterRef('path'), 1, Food.steplen*OuterRef('depth'))), distinct=True)).values('child_onhand') + return Food.objects.exclude( # list of foods that are onhand and children of: foods that are not onhand and are set to use children as substitutes + Q(onhand_users__in=shopping_users) + | Q(ignore_shopping=True) + | Q(substitute__onhand_users__in=shopping_users) + ).exclude(depth=1, numchild=0).filter(substitute_children=True + ).annotate(child_onhand=Coalesce(Subquery(children_onhand_subquery), 0)) + + @staticmethod + def __sibling_substitute_filter(shopping_users=None): + sibling_onhand_subquery = Food.objects.filter( + path__startswith=Substr(OuterRef('path'), 1, Food.steplen*(OuterRef('depth')-1)), + depth=OuterRef('depth'), + onhand_users__in=shopping_users + ).annotate(sibling_onhand=Count(Substr(OuterRef('path'), 1, Food.steplen*(OuterRef('depth')-1)), distinct=True)).values('sibling_onhand') + return Food.objects.exclude( # list of foods that are onhand and siblings of: foods that are not onhand and are set to use siblings as substitutes + Q(onhand_users__in=shopping_users) + | Q(ignore_shopping=True) + | Q(substitute__onhand_users__in=shopping_users) + ).exclude(depth=1, numchild=0).filter(substitute_siblings=True + ).annotate(sibling_onhand=Coalesce(Subquery(sibling_onhand_subquery), 0)) + class RecipeFacet(): class CacheEmpty(Exception): @@ -605,7 +640,7 @@ class RecipeFacet(): def _recipe_count_queryset(self, field, depth=1, steplen=4): return Recipe.objects.filter(**{f'{field}__path__startswith': OuterRef('path')}, id__in=self._recipe_list, space=self._request.space - ).values(child=Substr(f'{field}__path', 1, steplen) + ).values(child=Substr(f'{field}__path', 1, steplen*depth) ).annotate(count=Count('pk', distinct=True)).values('count') def _keyword_queryset(self, queryset, keyword=None): diff --git a/cookbook/migrations/0170_alter_ingredient_unit.py b/cookbook/migrations/0170_alter_ingredient_unit.py new file mode 100644 index 00000000..b426ad12 --- /dev/null +++ b/cookbook/migrations/0170_alter_ingredient_unit.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.11 on 2022-02-02 19:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0169_auto_20220121_1427'), + ] + + operations = [ + migrations.AlterField( + model_name='ingredient', + name='unit', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.unit'), + ), + ] diff --git a/cookbook/migrations/0171_auto_20220202_1340.py b/cookbook/migrations/0171_auto_20220202_1340.py new file mode 100644 index 00000000..ceb4ec80 --- /dev/null +++ b/cookbook/migrations/0171_auto_20220202_1340.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.11 on 2022-02-02 19:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0170_alter_ingredient_unit'), + ] + + operations = [ + migrations.AddField( + model_name='food', + name='substitute', + field=models.ManyToManyField(blank=True, related_name='_cookbook_food_substitute_+', to='cookbook.Food'), + ), + migrations.AddField( + model_name='food', + name='substitute_children', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='food', + name='substitute_siblings', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='ingredient', + name='unit', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.unit'), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index c523a232..49592027 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -488,6 +488,7 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): + # TODO when savings a food as substitute children - assume children and descednats are also substitutes for siblings # exclude fields not implemented yet inheritable_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings']) @@ -501,6 +502,9 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): onhand_users = models.ManyToManyField(User, blank=True) description = models.TextField(default='', blank=True) inherit_fields = models.ManyToManyField(FoodInheritField, blank=True) + substitute = models.ManyToManyField("self", blank=True) + substitute_siblings = models.BooleanField(default=False) + substitute_children = models.BooleanField(default=False) space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space', _manager_class=TreeManager) @@ -560,9 +564,9 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin): - # a pre-delete signal on Food checks if the Ingredient is part of a Step, if it is raises a ProtectedError instead of cascading the delete + # delete method on Food and Unit checks if they are part of a Recipe, if it is raises a ProtectedError instead of cascading the delete food = models.ForeignKey(Food, on_delete=models.CASCADE, null=True, blank=True) - unit = models.ForeignKey(Unit, on_delete=models.PROTECT, null=True, blank=True) + unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True) amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) note = models.CharField(max_length=256, null=True, blank=True) is_header = models.BooleanField(default=False) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index fd67d25a..ec656750 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -4,7 +4,8 @@ from decimal import Decimal from gettext import gettext as _ from django.contrib.auth.models import User -from django.db.models import Avg, QuerySet, Sum +from django.db.models import Avg, Q, QuerySet, Sum, Value +from django.db.models.functions import Substr from django.urls import reverse from django.utils import timezone from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer @@ -375,6 +376,13 @@ class RecipeSimpleSerializer(serializers.ModelSerializer): read_only_fields = ['id', 'name', 'url'] +class FoodSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = Food + fields = ('id', 'name') + read_only_fields = ['id', 'name'] + + class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin): supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False) @@ -382,10 +390,25 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR shopping = serializers.ReadOnlyField(source='shopping_status') inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False) food_onhand = CustomOnHandField(required=False, allow_null=True) + substitute_onhand = serializers.SerializerMethodField('get_substitute_onhand') + substitute = FoodSimpleSerializer(many=True, allow_null=True, required=False) recipe_filter = 'steps__ingredients__food' images = ['recipe__image'] + def get_substitute_onhand(self, obj): + shared_users = None + if request := self.context.get('request', None): + shared_users = getattr(request, '_shared_users', None) + if shared_users is None: + shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id] + filter = Q(id__in=obj.substitute.all()) + if obj.substitute_siblings: + filter |= Q(path__startswith=obj.path[:Food.steplen*(obj.depth-1)], depth=obj.depth) + if obj.substitute_children: + filter |= Q(path__startswith=obj.path, depth__gt=obj.depth) + return Food.objects.filter(filter).filter(onhand_users__id__in=shared_users).exists() + # def get_shopping_status(self, obj): # return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0 @@ -437,7 +460,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR model = Food fields = ( 'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category', - 'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping' + 'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping', + 'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand' ) read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') diff --git a/cookbook/tests/api/test_api_shopping_list_entry.py b/cookbook/tests/api/test_api_shopping_list_entry.py index e1b40553..112ea296 100644 --- a/cookbook/tests/api/test_api_shopping_list_entry.py +++ b/cookbook/tests/api/test_api_shopping_list_entry.py @@ -90,7 +90,10 @@ def test_add(arg, request, obj_1): c = request.getfixturevalue(arg[0]) r = c.post( reverse(LIST_URL), - {'food': model_to_dict(obj_1.food), 'amount': 1}, + {'food': { + 'id': obj_1.food.__dict__['id'], + 'name': obj_1.food.__dict__['name'], + }, 'amount': 1}, content_type='application/json' ) response = json.loads(r.content) diff --git a/cookbook/tests/api/test_api_shopping_list_entryv2.py b/cookbook/tests/api/test_api_shopping_list_entryv2.py index 2ef04e89..0a8e35de 100644 --- a/cookbook/tests/api/test_api_shopping_list_entryv2.py +++ b/cookbook/tests/api/test_api_shopping_list_entryv2.py @@ -103,7 +103,10 @@ def test_add(arg, request, sle): c = request.getfixturevalue(arg[0]) r = c.post( reverse(LIST_URL), - {'food': model_to_dict(sle[0].food), 'amount': 1}, + {'food': { + 'id': sle[0].food.__dict__['id'], + 'name': sle[0].food.__dict__['name'], + }, 'amount': 1}, content_type='application/json' ) response = json.loads(r.content) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 71cfaa37..6c5bea19 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -655,7 +655,7 @@ class RecipeViewSet(viewsets.ModelViewSet): QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''false'']')), QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'), QueryParam(name='lastcooked', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), - QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''false'']'), qtype='int'), + QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''false'']')), ] schema = QueryParamAutoSchema() diff --git a/vue/src/apps/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index 56232d9a..6c61d37e 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -417,7 +417,6 @@ export default { // TODO: make this generic let params = { pageSize: 50, random: true } params[this.this_recipe_param] = item.id - console.log("RECIPE PARAM", this.this_recipe_param, params, item.id) this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params) .then((result) => { parent = this.findCard(item.id, this["items_" + col]) diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index dc2cbb2b..db9d8394 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -915,7 +915,6 @@ export default { }, }, mounted() { - console.log(screen.height) this.getShoppingList() this.getSupermarkets() this.getShoppingCategories() @@ -1406,7 +1405,6 @@ export default { window.removeEventListener("offline", this.updateOnlineStatus) }, addRecipeToShopping() { - console.log(this.new_recipe) this.$bvModal.show(`shopping_${this.new_recipe.id}`) }, finishShopping() { diff --git a/vue/src/apps/SupermarketView/SupermarketView.vue b/vue/src/apps/SupermarketView/SupermarketView.vue index 821504c9..80bcaf66 100644 --- a/vue/src/apps/SupermarketView/SupermarketView.vue +++ b/vue/src/apps/SupermarketView/SupermarketView.vue @@ -100,7 +100,7 @@ export default { this.loadInitial() }, methods: { - loadInitial: function() { + loadInitial: function () { let apiClient = new ApiApiFactory() apiClient.listSupermarkets().then((results) => { this.supermarkets = results.data @@ -110,7 +110,7 @@ export default { this.selectable_categories = this.categories }) }, - selectedCategoriesChanged: function(data) { + selectedCategoriesChanged: function (data) { let apiClient = new ApiApiFactory() if ("removed" in data) { @@ -133,23 +133,22 @@ export default { if ("moved" in data || "added" in data) { this.supermarket_categories.forEach((element, index) => { let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === element.id)[0] - console.log(relation) apiClient.partialUpdateSupermarketCategoryRelation(relation.id, { order: index }) }) } }, - selectedSupermarketChanged: function(supermarket, id) { + selectedSupermarketChanged: function (supermarket, id) { this.supermarket_categories = [] this.selectable_categories = this.categories for (let i of supermarket.category_to_supermarket) { this.supermarket_categories.push(i.category) - this.selectable_categories = this.selectable_categories.filter(function(el) { + this.selectable_categories = this.selectable_categories.filter(function (el) { return el.id !== i.category.id }) } }, - supermarketModalOk: function() { + supermarketModalOk: function () { let apiClient = new ApiApiFactory() if (this.selected_supermarket.new) { apiClient.createSupermarket({ name: this.selected_supermarket.name }).then((results) => { @@ -160,7 +159,7 @@ export default { apiClient.partialUpdateSupermarket(this.selected_supermarket.id, { name: this.selected_supermarket.name }) } }, - categoryModalOk: function() { + categoryModalOk: function () { let apiClient = new ApiApiFactory() if (this.selected_category.new) { apiClient.createSupermarketCategory({ name: this.selected_category.name }).then((results) => { diff --git a/vue/src/components/Badges/OnHand.vue b/vue/src/components/Badges/OnHand.vue index 7e48294e..e27e50e8 100644 --- a/vue/src/components/Badges/OnHand.vue +++ b/vue/src/components/Badges/OnHand.vue @@ -1,13 +1,6 @@ @@ -25,6 +18,26 @@ export default { onhand: false, } }, + computed: { + Title: function () { + if (this.onhand) { + return this.$t("FoodOnHand", { food: this.item.name }) + } else if (this.item.substitute_onhand) { + return this.$t("SubstituteOnHand") + } else { + return this.$t("FoodNotOnHand", { food: this.item.name }) + } + }, + IconClass: function () { + if (this.onhand) { + return "text-success fa-clipboard-check" + } else if (this.item.substitute_onhand) { + return "text-warning fa-clipboard-check" + } else { + return "text-muted fa-clipboard" + } + }, + }, mounted() { this.onhand = this.item.food_onhand }, diff --git a/vue/src/components/IngredientComponent.vue b/vue/src/components/IngredientComponent.vue index fcb3ca54..79d8c443 100644 --- a/vue/src/components/IngredientComponent.vue +++ b/vue/src/components/IngredientComponent.vue @@ -33,6 +33,7 @@ + import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils" import OnHandBadge from "@/components/Badges/OnHand" +import ShoppingBadge from "@/components/Badges/Shopping" export default { name: "IngredientComponent", - components: { OnHandBadge }, + components: { OnHandBadge, ShoppingBadge }, props: { ingredient: Object, ingredient_factor: { type: Number, default: 1 }, @@ -87,6 +89,11 @@ export default { this.shop = this.ingredient?.shop }, computed: { + shoppingBadgeFood() { + // shopping badge is hidden when ignore_shopping=true. + // force true in this context to allow adding to shopping list from recipe view + return { ...this.ingredient.food, ignore_shopping: false } + }, ShoppingPopover() { if (this.ingredient?.shopping_status == false) { return this.$t("NotInShopping", { food: this.ingredient.food.name }) diff --git a/vue/src/components/IngredientsCard.vue b/vue/src/components/IngredientsCard.vue index d1a8307d..1decf3ee 100644 --- a/vue/src/components/IngredientsCard.vue +++ b/vue/src/components/IngredientsCard.vue @@ -56,7 +56,6 @@ import "bootstrap-vue/dist/bootstrap-vue.css" import IngredientComponent from "@/components/IngredientComponent" import { ApiMixin, StandardToasts } from "@/utils/utils" -import ShoppingListViewVue from "../apps/ShoppingListView/ShoppingListView.vue" Vue.use(BootstrapVue) diff --git a/vue/src/components/Modals/GenericModalForm.vue b/vue/src/components/Modals/GenericModalForm.vue index cd372f11..717421a9 100644 --- a/vue/src/components/Modals/GenericModalForm.vue +++ b/vue/src/components/Modals/GenericModalForm.vue @@ -16,8 +16,11 @@