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 @@
-