add food substitutions

This commit is contained in:
Chris Scoggins 2022-02-03 15:04:46 -06:00
parent 5e3f94fcf7
commit 6ef25b604b
No known key found for this signature in database
GPG Key ID: 41617A4206CCBAC6
19 changed files with 322 additions and 49 deletions

View File

@ -419,12 +419,47 @@ class RecipeSearch():
if not self._makenow: if not self._makenow:
return return
shopping_users = [*self._request.user.get_shopping_share(), self._request.user] 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( self._queryset = self._queryset.annotate(
count_food=Count('steps__ingredients__food'), 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)) 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) ).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 RecipeFacet():
class CacheEmpty(Exception): class CacheEmpty(Exception):
@ -605,7 +640,7 @@ class RecipeFacet():
def _recipe_count_queryset(self, field, depth=1, steplen=4): 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 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') ).annotate(count=Count('pk', distinct=True)).values('count')
def _keyword_queryset(self, queryset, keyword=None): def _keyword_queryset(self, queryset, keyword=None):

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -488,6 +488,7 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): 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 # exclude fields not implemented yet
inheritable_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings']) 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) onhand_users = models.ManyToManyField(User, blank=True)
description = models.TextField(default='', blank=True) description = models.TextField(default='', blank=True)
inherit_fields = models.ManyToManyField(FoodInheritField, 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) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space', _manager_class=TreeManager) objects = ScopedManager(space='space', _manager_class=TreeManager)
@ -560,9 +564,9 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, 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) 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) amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
note = models.CharField(max_length=256, null=True, blank=True) note = models.CharField(max_length=256, null=True, blank=True)
is_header = models.BooleanField(default=False) is_header = models.BooleanField(default=False)

View File

@ -4,7 +4,8 @@ from decimal import Decimal
from gettext import gettext as _ from gettext import gettext as _
from django.contrib.auth.models import User 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
@ -375,6 +376,13 @@ class RecipeSimpleSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'name', 'url'] 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): class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
recipe = RecipeSimpleSerializer(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') shopping = serializers.ReadOnlyField(source='shopping_status')
inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False) inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False)
food_onhand = CustomOnHandField(required=False, allow_null=True) 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' recipe_filter = 'steps__ingredients__food'
images = ['recipe__image'] 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): # def get_shopping_status(self, obj):
# return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0 # return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
@ -437,7 +460,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
model = Food model = Food
fields = ( fields = (
'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category', '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') read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')

View File

@ -90,7 +90,10 @@ def test_add(arg, request, obj_1):
c = request.getfixturevalue(arg[0]) c = request.getfixturevalue(arg[0])
r = c.post( r = c.post(
reverse(LIST_URL), 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' content_type='application/json'
) )
response = json.loads(r.content) response = json.loads(r.content)

View File

@ -103,7 +103,10 @@ def test_add(arg, request, sle):
c = request.getfixturevalue(arg[0]) c = request.getfixturevalue(arg[0])
r = c.post( r = c.post(
reverse(LIST_URL), 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' content_type='application/json'
) )
response = json.loads(r.content) response = json.loads(r.content)

View File

@ -655,7 +655,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')), QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'), 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='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''/''<b>false</b>'']'), qtype='int'), QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
] ]
schema = QueryParamAutoSchema() schema = QueryParamAutoSchema()

View File

@ -417,7 +417,6 @@ export default {
// TODO: make this generic // TODO: make this generic
let params = { pageSize: 50, random: true } let params = { pageSize: 50, random: true }
params[this.this_recipe_param] = item.id 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) this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params)
.then((result) => { .then((result) => {
parent = this.findCard(item.id, this["items_" + col]) parent = this.findCard(item.id, this["items_" + col])

View File

@ -915,7 +915,6 @@ export default {
}, },
}, },
mounted() { mounted() {
console.log(screen.height)
this.getShoppingList() this.getShoppingList()
this.getSupermarkets() this.getSupermarkets()
this.getShoppingCategories() this.getShoppingCategories()
@ -1406,7 +1405,6 @@ export default {
window.removeEventListener("offline", this.updateOnlineStatus) window.removeEventListener("offline", this.updateOnlineStatus)
}, },
addRecipeToShopping() { addRecipeToShopping() {
console.log(this.new_recipe)
this.$bvModal.show(`shopping_${this.new_recipe.id}`) this.$bvModal.show(`shopping_${this.new_recipe.id}`)
}, },
finishShopping() { finishShopping() {

View File

@ -100,7 +100,7 @@ export default {
this.loadInitial() this.loadInitial()
}, },
methods: { methods: {
loadInitial: function() { loadInitial: function () {
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
apiClient.listSupermarkets().then((results) => { apiClient.listSupermarkets().then((results) => {
this.supermarkets = results.data this.supermarkets = results.data
@ -110,7 +110,7 @@ export default {
this.selectable_categories = this.categories this.selectable_categories = this.categories
}) })
}, },
selectedCategoriesChanged: function(data) { selectedCategoriesChanged: function (data) {
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
if ("removed" in data) { if ("removed" in data) {
@ -133,23 +133,22 @@ export default {
if ("moved" in data || "added" in data) { if ("moved" in data || "added" in data) {
this.supermarket_categories.forEach((element, index) => { this.supermarket_categories.forEach((element, index) => {
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === element.id)[0] 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 }) apiClient.partialUpdateSupermarketCategoryRelation(relation.id, { order: index })
}) })
} }
}, },
selectedSupermarketChanged: function(supermarket, id) { selectedSupermarketChanged: function (supermarket, id) {
this.supermarket_categories = [] this.supermarket_categories = []
this.selectable_categories = this.categories this.selectable_categories = this.categories
for (let i of supermarket.category_to_supermarket) { for (let i of supermarket.category_to_supermarket) {
this.supermarket_categories.push(i.category) 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 return el.id !== i.category.id
}) })
} }
}, },
supermarketModalOk: function() { supermarketModalOk: function () {
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
if (this.selected_supermarket.new) { if (this.selected_supermarket.new) {
apiClient.createSupermarket({ name: this.selected_supermarket.name }).then((results) => { 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 }) apiClient.partialUpdateSupermarket(this.selected_supermarket.id, { name: this.selected_supermarket.name })
} }
}, },
categoryModalOk: function() { categoryModalOk: function () {
let apiClient = new ApiApiFactory() let apiClient = new ApiApiFactory()
if (this.selected_category.new) { if (this.selected_category.new) {
apiClient.createSupermarketCategory({ name: this.selected_category.name }).then((results) => { apiClient.createSupermarketCategory({ name: this.selected_category.name }).then((results) => {

View File

@ -1,13 +1,6 @@
<template> <template>
<span> <span>
<b-button <b-button v-if="!item.ignore_shopping" class="btn text-decoration-none fas px-1 py-0 border-0" variant="link" v-b-popover.hover.html :title="Title" :class="IconClass" @click="toggleOnHand" />
class="btn text-decoration-none fas px-1 py-0 border-0"
variant="link"
v-b-popover.hover.html
:title="[onhand ? $t('FoodOnHand', { food: item.name }) : $t('FoodNotOnHand', { food: item.name })]"
:class="[onhand ? 'text-success fa-clipboard-check' : 'text-muted fa-clipboard']"
@click="toggleOnHand"
/>
</span> </span>
</template> </template>
@ -25,6 +18,26 @@ export default {
onhand: false, 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() { mounted() {
this.onhand = this.item.food_onhand this.onhand = this.item.food_onhand
}, },

View File

@ -33,6 +33,7 @@
</div> </div>
</td> </td>
<td v-else-if="show_shopping" class="text-right text-nowrap"> <td v-else-if="show_shopping" class="text-right text-nowrap">
<shopping-badge v-if="ingredient.food.ignore_shopping" :item="shoppingBadgeFood" />
<b-button <b-button
v-if="!ingredient.food.ignore_shopping" v-if="!ingredient.food.ignore_shopping"
class="btn text-decoration-none fas fa-shopping-cart px-2 user-select-none" class="btn text-decoration-none fas fa-shopping-cart px-2 user-select-none"
@ -56,10 +57,11 @@
<script> <script>
import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils" import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils"
import OnHandBadge from "@/components/Badges/OnHand" import OnHandBadge from "@/components/Badges/OnHand"
import ShoppingBadge from "@/components/Badges/Shopping"
export default { export default {
name: "IngredientComponent", name: "IngredientComponent",
components: { OnHandBadge }, components: { OnHandBadge, ShoppingBadge },
props: { props: {
ingredient: Object, ingredient: Object,
ingredient_factor: { type: Number, default: 1 }, ingredient_factor: { type: Number, default: 1 },
@ -87,6 +89,11 @@ export default {
this.shop = this.ingredient?.shop this.shop = this.ingredient?.shop
}, },
computed: { 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() { ShoppingPopover() {
if (this.ingredient?.shopping_status == false) { if (this.ingredient?.shopping_status == false) {
return this.$t("NotInShopping", { food: this.ingredient.food.name }) return this.$t("NotInShopping", { food: this.ingredient.food.name })

View File

@ -56,7 +56,6 @@ import "bootstrap-vue/dist/bootstrap-vue.css"
import IngredientComponent from "@/components/IngredientComponent" import IngredientComponent from "@/components/IngredientComponent"
import { ApiMixin, StandardToasts } from "@/utils/utils" import { ApiMixin, StandardToasts } from "@/utils/utils"
import ShoppingListViewVue from "../apps/ShoppingListView/ShoppingListView.vue"
Vue.use(BootstrapVue) Vue.use(BootstrapVue)

View File

@ -16,8 +16,11 @@
<small-text v-if="visibleCondition(f, 'smalltext')" :value="f.value" /> <small-text v-if="visibleCondition(f, 'smalltext')" :value="f.value" />
</div> </div>
<template v-slot:modal-footer> <template v-slot:modal-footer>
<div class="row w-100 justify-content-end"> <div class="row w-100">
<div class="col-auto"> <div class="col-6 align-self-end">
<b-form-checkbox v-if="advancedForm" sm switch v-model="show_advanced">{{ $t("Advanced") }}</b-form-checkbox>
</div>
<div class="col-auto justify-content-end">
<b-button class="mx-1" variant="secondary" v-on:click="cancelAction">{{ $t("Cancel") }}</b-button> <b-button class="mx-1" variant="secondary" v-on:click="cancelAction">{{ $t("Cancel") }}</b-button>
<b-button class="mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button> <b-button class="mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button>
</div> </div>
@ -78,7 +81,8 @@ export default {
form: {}, form: {},
dirty: false, dirty: false,
special_handling: false, special_handling: false,
show_help: true, show_help: false,
show_advanced: false,
} }
}, },
mounted() { mounted() {
@ -86,6 +90,13 @@ export default {
this.$root.$on("change", this.storeValue) // bootstrap modal placed at document so have to listen at root of component this.$root.$on("change", this.storeValue) // bootstrap modal placed at document so have to listen at root of component
}, },
computed: { computed: {
advancedForm() {
return this.form.fields
.map((x) => {
return x?.advanced ?? false
})
.includes(true)
},
buttonLabel() { buttonLabel() {
return this.buttons[this.action].label return this.buttons[this.action].label
}, },
@ -268,6 +279,11 @@ export default {
visibleCondition(field, field_type) { visibleCondition(field, field_type) {
let type_match = field?.type == field_type let type_match = field?.type == field_type
let checks = true let checks = true
let show_advanced = true
if (field?.advanced) {
show_advanced = this.show_advanced
}
if (type_match && field?.condition) { if (type_match && field?.condition) {
const value = this.item1[field?.condition?.field] const value = this.item1[field?.condition?.field]
const preference = getUserPreference(field?.condition?.field) const preference = getUserPreference(field?.condition?.field)
@ -294,7 +310,7 @@ export default {
} }
} }
} }
return type_match && checks return type_match && checks && show_advanced
}, },
}, },
} }

View File

@ -55,7 +55,7 @@
</b-input-group-prepend> </b-input-group-prepend>
<b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton> <b-form-spinbutton min="1" v-model="recipe_servings" inline style="height: 3em"></b-form-spinbutton>
<CustomInputSpinButton v-model.number="recipe_servings" style="height: 3em" /> <!-- <CustomInputSpinButton v-model.number="recipe_servings" style="height: 3em" /> -->
<b-input-group-append> <b-input-group-append>
<b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button> <b-button variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
@ -76,11 +76,11 @@ const { ApiApiFactory } = require("@/utils/openapi/api")
import { StandardToasts } from "@/utils/utils" import { StandardToasts } from "@/utils/utils"
import IngredientsCard from "@/components/IngredientsCard" import IngredientsCard from "@/components/IngredientsCard"
import LoadingSpinner from "@/components/LoadingSpinner" import LoadingSpinner from "@/components/LoadingSpinner"
import CustomInputSpinButton from "@/components/CustomInputSpinButton" // import CustomInputSpinButton from "@/components/CustomInputSpinButton"
export default { export default {
name: "ShoppingModal", name: "ShoppingModal",
components: { IngredientsCard, LoadingSpinner, CustomInputSpinButton }, components: { IngredientsCard, LoadingSpinner },
mixins: [], mixins: [],
props: { props: {
recipe: { required: true, type: Object }, recipe: { required: true, type: Object },

View File

@ -289,13 +289,11 @@
"remember_search": "Remember Search", "remember_search": "Remember Search",
"remember_hours": "Hours to Remember", "remember_hours": "Hours to Remember",
"tree_select": "Use Tree Selection", "tree_select": "Use Tree Selection",
"OnHand_help": "Food is in inventory and will not be automatically added to a shopping list.", "OnHand_help": "Food is in inventory and will not be automatically added to a shopping list. Onhand status is shared with shopping users.",
"ignore_shopping_help": "Never add food to the shopping list (e.g. water)", "ignore_shopping_help": "Never add food to the shopping list (e.g. water)",
"shopping_category_help": "Supermarkets can be ordered and filtered by Shopping Category according to the layout of the aisles.", "shopping_category_help": "Supermarkets can be ordered and filtered by Shopping Category according to the layout of the aisles.",
"food_recipe_help": "Linking a recipe here will include the linked recipe in any other recipe that use this food", "food_recipe_help": "Linking a recipe here will include the linked recipe in any other recipe that use this food",
"Foods": "Foods", "Foods": "Foods",
"review_shopping": "Review shopping entries before saving",
"view_recipe": "View Recipe",
"enable_expert": "Enable Expert Mode", "enable_expert": "Enable Expert Mode",
"expert_mode": "Expert Mode", "expert_mode": "Expert Mode",
"simple_mode": "Simple Mode", "simple_mode": "Simple Mode",
@ -326,6 +324,15 @@
"make_now": "Make Now", "make_now": "Make Now",
"recipe_filter": "Recipe Filter", "recipe_filter": "Recipe Filter",
"book_filter_help": "Include recipes from recipe filter instead of assigning each recipe", "book_filter_help": "Include recipes from recipe filter instead of assigning each recipe",
"review_shopping": "Review shopping entries before saving",
"view_recipe": "View Recipe",
"filter": "Filter",
"reset_children": "Reset Child Inheritance", "reset_children": "Reset Child Inheritance",
"reset_children_help": "Overwrite all children with values from inherited fields." "reset_children_help": "Overwrite all children with values from inherited fields.",
"substitute_help": "Substitutes are considered when searching for recipes that can be made with onhand ingredients.",
"substitute_siblings_help": "All food that share a parent of this food are considered substitutes.",
"substitute_children_help": "All food that are children of this food are considered substitutes.",
"substitute_siblings": "Substitute Siblings",
"substitute_children": "Substitute Children",
"SubstituteOnHand": "You have a substitute on hand."
} }

View File

@ -76,7 +76,22 @@ export class Models {
// REQUIRED: unordered array of fields that can be set during create // REQUIRED: unordered array of fields that can be set during create
create: { create: {
// if not defined partialUpdate will use the same parameters, prepending 'id' // if not defined partialUpdate will use the same parameters, prepending 'id'
params: [["name", "description", "recipe", "food_onhand", "supermarket_category", "inherit", "inherit_fields", "ignore_shopping", "reset_inherit"]], params: [
[
"name",
"description",
"recipe",
"food_onhand",
"supermarket_category",
"inherit",
"inherit_fields",
"ignore_shopping",
"substitute",
"substitute_siblings",
"substitute_children",
"reset_inherit",
],
],
form: { form: {
show_help: true, show_help: true,
@ -126,8 +141,38 @@ export class Models {
allow_create: true, allow_create: true,
help_text: i18n.t("shopping_category_help"), help_text: i18n.t("shopping_category_help"),
}, },
substitute: {
form_field: true,
advanced: true,
type: "lookup",
multiple: true,
field: "substitute",
list: "FOOD",
label: i18n.t("Substitutes"),
allow_create: false,
help_text: i18n.t("substitute_help"),
},
substitute_siblings: {
form_field: true,
advanced: true,
type: "checkbox",
field: "substitute_siblings",
label: i18n.t("substitute_siblings"),
help_text: i18n.t("substitute_siblings_help"),
condition: { field: "parent", value: true, condition: "field_exists" },
},
substitute_children: {
form_field: true,
advanced: true,
type: "checkbox",
field: "substitute_children",
label: i18n.t("substitute_children"),
help_text: i18n.t("substitute_children_help"),
condition: { field: "numchild", value: 0, condition: "gt" },
},
inherit_fields: { inherit_fields: {
form_field: true, form_field: true,
advanced: true,
type: "lookup", type: "lookup",
multiple: true, multiple: true,
field: "inherit_fields", field: "inherit_fields",
@ -137,6 +182,7 @@ export class Models {
}, },
reset_inherit: { reset_inherit: {
form_field: true, form_field: true,
advanced: true,
type: "checkbox", type: "checkbox",
field: "reset_inherit", field: "reset_inherit",
label: i18n.t("reset_children"), label: i18n.t("reset_children"),

View File

@ -307,6 +307,30 @@ export interface Food {
* @memberof Food * @memberof Food
*/ */
ignore_shopping?: boolean; ignore_shopping?: boolean;
/**
*
* @type {Array<FoodSubstitute>}
* @memberof Food
*/
substitute?: Array<FoodSubstitute> | null;
/**
*
* @type {boolean}
* @memberof Food
*/
substitute_siblings?: boolean;
/**
*
* @type {boolean}
* @memberof Food
*/
substitute_children?: boolean;
/**
*
* @type {string}
* @memberof Food
*/
substitute_onhand?: string;
} }
/** /**
* *
@ -423,6 +447,25 @@ export enum FoodShoppingUpdateDeleteEnum {
True = 'true' True = 'true'
} }
/**
*
* @export
* @interface FoodSubstitute
*/
export interface FoodSubstitute {
/**
*
* @type {number}
* @memberof FoodSubstitute
*/
id?: number;
/**
*
* @type {string}
* @memberof FoodSubstitute
*/
name?: string;
}
/** /**
* *
* @export * @export
@ -709,6 +752,30 @@ export interface IngredientFood {
* @memberof IngredientFood * @memberof IngredientFood
*/ */
ignore_shopping?: boolean; ignore_shopping?: boolean;
/**
*
* @type {Array<FoodSubstitute>}
* @memberof IngredientFood
*/
substitute?: Array<FoodSubstitute> | null;
/**
*
* @type {boolean}
* @memberof IngredientFood
*/
substitute_siblings?: boolean;
/**
*
* @type {boolean}
* @memberof IngredientFood
*/
substitute_children?: boolean;
/**
*
* @type {string}
* @memberof IngredientFood
*/
substitute_onhand?: string;
} }
/** /**
* *
@ -5482,13 +5549,13 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
* @param {string} [_new] Returns new results first in search results. [true/&lt;b&gt;false&lt;/b&gt;] * @param {string} [_new] Returns new results first in search results. [true/&lt;b&gt;false&lt;/b&gt;]
* @param {number} [timescooked] Filter recipes cooked X times or more. Negative values returns cooked less than X times * @param {number} [timescooked] Filter recipes cooked X times or more. Negative values returns cooked less than X times
* @param {string} [lastcooked] Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date. * @param {string} [lastcooked] Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date.
* @param {number} [makenow] Filter recipes that can be made with OnHand food. [true/&lt;b&gt;false&lt;/b&gt;] * @param {string} [makenow] Filter recipes that can be made with OnHand food. [true/&lt;b&gt;false&lt;/b&gt;]
* @param {number} [page] A page number within the paginated result set. * @param {number} [page] A page number within the paginated result set.
* @param {number} [pageSize] Number of results to return per page. * @param {number} [pageSize] Number of results to return per page.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
listRecipes: async (query?: string, keywords?: number, keywordsOr?: number, keywordsAnd?: number, keywordsOrNot?: number, keywordsAndNot?: number, foods?: number, foodsOr?: number, foodsAnd?: number, foodsOrNot?: number, foodsAndNot?: number, units?: number, rating?: number, books?: string, booksOr?: number, booksAnd?: number, booksOrNot?: number, booksAndNot?: number, internal?: string, random?: string, _new?: string, timescooked?: number, lastcooked?: string, makenow?: number, page?: number, pageSize?: number, options: any = {}): Promise<RequestArgs> => { listRecipes: async (query?: string, keywords?: number, keywordsOr?: number, keywordsAnd?: number, keywordsOrNot?: number, keywordsAndNot?: number, foods?: number, foodsOr?: number, foodsAnd?: number, foodsOrNot?: number, foodsAndNot?: number, units?: number, rating?: number, books?: string, booksOr?: number, booksAnd?: number, booksOrNot?: number, booksAndNot?: number, internal?: string, random?: string, _new?: string, timescooked?: number, lastcooked?: string, makenow?: string, page?: number, pageSize?: number, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/recipe/`; const localVarPath = `/api/recipe/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs. // use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -10068,13 +10135,13 @@ export const ApiApiFp = function(configuration?: Configuration) {
* @param {string} [_new] Returns new results first in search results. [true/&lt;b&gt;false&lt;/b&gt;] * @param {string} [_new] Returns new results first in search results. [true/&lt;b&gt;false&lt;/b&gt;]
* @param {number} [timescooked] Filter recipes cooked X times or more. Negative values returns cooked less than X times * @param {number} [timescooked] Filter recipes cooked X times or more. Negative values returns cooked less than X times
* @param {string} [lastcooked] Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date. * @param {string} [lastcooked] Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date.
* @param {number} [makenow] Filter recipes that can be made with OnHand food. [true/&lt;b&gt;false&lt;/b&gt;] * @param {string} [makenow] Filter recipes that can be made with OnHand food. [true/&lt;b&gt;false&lt;/b&gt;]
* @param {number} [page] A page number within the paginated result set. * @param {number} [page] A page number within the paginated result set.
* @param {number} [pageSize] Number of results to return per page. * @param {number} [pageSize] Number of results to return per page.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async listRecipes(query?: string, keywords?: number, keywordsOr?: number, keywordsAnd?: number, keywordsOrNot?: number, keywordsAndNot?: number, foods?: number, foodsOr?: number, foodsAnd?: number, foodsOrNot?: number, foodsAndNot?: number, units?: number, rating?: number, books?: string, booksOr?: number, booksAnd?: number, booksOrNot?: number, booksAndNot?: number, internal?: string, random?: string, _new?: string, timescooked?: number, lastcooked?: string, makenow?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse2004>> { async listRecipes(query?: string, keywords?: number, keywordsOr?: number, keywordsAnd?: number, keywordsOrNot?: number, keywordsAndNot?: number, foods?: number, foodsOr?: number, foodsAnd?: number, foodsOrNot?: number, foodsAndNot?: number, units?: number, rating?: number, books?: string, booksOr?: number, booksAnd?: number, booksOrNot?: number, booksAndNot?: number, internal?: string, random?: string, _new?: string, timescooked?: number, lastcooked?: string, makenow?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<InlineResponse2004>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRecipes(query, keywords, keywordsOr, keywordsAnd, keywordsOrNot, keywordsAndNot, foods, foodsOr, foodsAnd, foodsOrNot, foodsAndNot, units, rating, books, booksOr, booksAnd, booksOrNot, booksAndNot, internal, random, _new, timescooked, lastcooked, makenow, page, pageSize, options); const localVarAxiosArgs = await localVarAxiosParamCreator.listRecipes(query, keywords, keywordsOr, keywordsAnd, keywordsOrNot, keywordsAndNot, foods, foodsOr, foodsAnd, foodsOrNot, foodsAndNot, units, rating, books, booksOr, booksAnd, booksOrNot, booksAndNot, internal, random, _new, timescooked, lastcooked, makenow, page, pageSize, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
@ -11823,13 +11890,13 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
* @param {string} [_new] Returns new results first in search results. [true/&lt;b&gt;false&lt;/b&gt;] * @param {string} [_new] Returns new results first in search results. [true/&lt;b&gt;false&lt;/b&gt;]
* @param {number} [timescooked] Filter recipes cooked X times or more. Negative values returns cooked less than X times * @param {number} [timescooked] Filter recipes cooked X times or more. Negative values returns cooked less than X times
* @param {string} [lastcooked] Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date. * @param {string} [lastcooked] Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date.
* @param {number} [makenow] Filter recipes that can be made with OnHand food. [true/&lt;b&gt;false&lt;/b&gt;] * @param {string} [makenow] Filter recipes that can be made with OnHand food. [true/&lt;b&gt;false&lt;/b&gt;]
* @param {number} [page] A page number within the paginated result set. * @param {number} [page] A page number within the paginated result set.
* @param {number} [pageSize] Number of results to return per page. * @param {number} [pageSize] Number of results to return per page.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
listRecipes(query?: string, keywords?: number, keywordsOr?: number, keywordsAnd?: number, keywordsOrNot?: number, keywordsAndNot?: number, foods?: number, foodsOr?: number, foodsAnd?: number, foodsOrNot?: number, foodsAndNot?: number, units?: number, rating?: number, books?: string, booksOr?: number, booksAnd?: number, booksOrNot?: number, booksAndNot?: number, internal?: string, random?: string, _new?: string, timescooked?: number, lastcooked?: string, makenow?: number, page?: number, pageSize?: number, options?: any): AxiosPromise<InlineResponse2004> { listRecipes(query?: string, keywords?: number, keywordsOr?: number, keywordsAnd?: number, keywordsOrNot?: number, keywordsAndNot?: number, foods?: number, foodsOr?: number, foodsAnd?: number, foodsOrNot?: number, foodsAndNot?: number, units?: number, rating?: number, books?: string, booksOr?: number, booksAnd?: number, booksOrNot?: number, booksAndNot?: number, internal?: string, random?: string, _new?: string, timescooked?: number, lastcooked?: string, makenow?: string, page?: number, pageSize?: number, options?: any): AxiosPromise<InlineResponse2004> {
return localVarFp.listRecipes(query, keywords, keywordsOr, keywordsAnd, keywordsOrNot, keywordsAndNot, foods, foodsOr, foodsAnd, foodsOrNot, foodsAndNot, units, rating, books, booksOr, booksAnd, booksOrNot, booksAndNot, internal, random, _new, timescooked, lastcooked, makenow, page, pageSize, options).then((request) => request(axios, basePath)); return localVarFp.listRecipes(query, keywords, keywordsOr, keywordsAnd, keywordsOrNot, keywordsAndNot, foods, foodsOr, foodsAnd, foodsOrNot, foodsAndNot, units, rating, books, booksOr, booksAnd, booksOrNot, booksAndNot, internal, random, _new, timescooked, lastcooked, makenow, page, pageSize, options).then((request) => request(axios, basePath));
}, },
/** /**
@ -13605,14 +13672,14 @@ export class ApiApi extends BaseAPI {
* @param {string} [_new] Returns new results first in search results. [true/&lt;b&gt;false&lt;/b&gt;] * @param {string} [_new] Returns new results first in search results. [true/&lt;b&gt;false&lt;/b&gt;]
* @param {number} [timescooked] Filter recipes cooked X times or more. Negative values returns cooked less than X times * @param {number} [timescooked] Filter recipes cooked X times or more. Negative values returns cooked less than X times
* @param {string} [lastcooked] Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date. * @param {string} [lastcooked] Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date.
* @param {number} [makenow] Filter recipes that can be made with OnHand food. [true/&lt;b&gt;false&lt;/b&gt;] * @param {string} [makenow] Filter recipes that can be made with OnHand food. [true/&lt;b&gt;false&lt;/b&gt;]
* @param {number} [page] A page number within the paginated result set. * @param {number} [page] A page number within the paginated result set.
* @param {number} [pageSize] Number of results to return per page. * @param {number} [pageSize] Number of results to return per page.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
* @memberof ApiApi * @memberof ApiApi
*/ */
public listRecipes(query?: string, keywords?: number, keywordsOr?: number, keywordsAnd?: number, keywordsOrNot?: number, keywordsAndNot?: number, foods?: number, foodsOr?: number, foodsAnd?: number, foodsOrNot?: number, foodsAndNot?: number, units?: number, rating?: number, books?: string, booksOr?: number, booksAnd?: number, booksOrNot?: number, booksAndNot?: number, internal?: string, random?: string, _new?: string, timescooked?: number, lastcooked?: string, makenow?: number, page?: number, pageSize?: number, options?: any) { public listRecipes(query?: string, keywords?: number, keywordsOr?: number, keywordsAnd?: number, keywordsOrNot?: number, keywordsAndNot?: number, foods?: number, foodsOr?: number, foodsAnd?: number, foodsOrNot?: number, foodsAndNot?: number, units?: number, rating?: number, books?: string, booksOr?: number, booksAnd?: number, booksOrNot?: number, booksAndNot?: number, internal?: string, random?: string, _new?: string, timescooked?: number, lastcooked?: string, makenow?: string, page?: number, pageSize?: number, options?: any) {
return ApiApiFp(this.configuration).listRecipes(query, keywords, keywordsOr, keywordsAnd, keywordsOrNot, keywordsAndNot, foods, foodsOr, foodsAnd, foodsOrNot, foodsAndNot, units, rating, books, booksOr, booksAnd, booksOrNot, booksAndNot, internal, random, _new, timescooked, lastcooked, makenow, page, pageSize, options).then((request) => request(this.axios, this.basePath)); return ApiApiFp(this.configuration).listRecipes(query, keywords, keywordsOr, keywordsAnd, keywordsOrNot, keywordsAndNot, foods, foodsOr, foodsAnd, foodsOrNot, foodsAndNot, units, rating, books, booksOr, booksAnd, booksOrNot, booksAndNot, internal, random, _new, timescooked, lastcooked, makenow, page, pageSize, options).then((request) => request(this.axios, this.basePath));
} }