make food onhand when complete shopping entry

This commit is contained in:
smilerz 2022-01-05 12:29:40 -06:00
parent 7c89117e04
commit 2c76fb7b69
11 changed files with 96 additions and 35 deletions

View File

@ -481,7 +481,7 @@ class ShoppingPreferenceForm(forms.ModelForm):
fields = ( fields = (
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', '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 = { help_texts = {
@ -496,6 +496,7 @@ class ShoppingPreferenceForm(forms.ModelForm):
'default_delay': _('Default number of hours to delay a shopping list entry.'), 'default_delay': _('Default number of hours to delay a shopping list entry.'),
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'), 'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
'shopping_recent_days': _('Days of recent shopping list entries to display.'), '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_delim': _('Delimiter to use for CSV exports.'),
'csv_prefix': _('Prefix to add when copying list to the clipboard.'), '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'), 'filter_to_supermarket': _('Filter to Supermarket'),
'shopping_recent_days': _('Recent Days'), 'shopping_recent_days': _('Recent Days'),
'csv_delim': _('CSV Delimiter'), 'csv_delim': _('CSV Delimiter'),
"csv_prefix_label": _("List Prefix") "csv_prefix_label": _("List Prefix"),
'shopping_add_onhand': _("Auto On Hand"),
} }
widgets = { widgets = {

View File

@ -81,7 +81,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
ingredients = Ingredient.objects.filter(step__recipe=r, space=space) ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand: 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: 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 # TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
@ -92,7 +92,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
# TODO once/if Steps can have a serving size this needs to be refactored # TODO once/if Steps can have a serving size this needs to be refactored
if exclude_onhand: if exclude_onhand:
# if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior # 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: else:
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True) related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
@ -100,7 +100,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
if ingredients.filter(food__recipe=x).exists(): if ingredients.filter(food__recipe=x).exists():
for ing in ingredients.filter(food__recipe=x): for ing in ingredients.filter(food__recipe=x):
if exclude_onhand: 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: else:
x_ing = Ingredient.objects.filter(step__recipe=x, space=space) x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
for i in [x for x in x_ing]: for i in [x for x in x_ing]:

View File

@ -526,10 +526,10 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
]) ])
inherit = inherit.values_list('field', flat=True) 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 # 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, ignore_shopping=True)).update(ignore_shopping=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=False)).update(ignore_shopping=False)
if 'supermarket_category' in inherit: 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 # 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 # find top node that has category set

View File

@ -10,6 +10,7 @@ from django.utils import timezone
from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.fields import SkipField
from cookbook.helper.shopping_helper import list_from_recipe from cookbook.helper.shopping_helper import list_from_recipe
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
@ -176,16 +177,13 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
raise NotFound() raise NotFound()
return super().create(validated_data) 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: class Meta:
model = UserPreference model = UserPreference
fields = ( fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share', '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', 'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', 'filter_to_supermarket' 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand'
) )
@ -380,23 +378,23 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
recipe = RecipeSimpleSerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False)
shopping = serializers.SerializerMethodField('get_shopping_status') shopping = serializers.SerializerMethodField('get_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() food_onhand = CustomOnHandField(required=False)
recipe_filter = 'steps__ingredients__food' recipe_filter = 'steps__ingredients__food'
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
# def get_food_onhand(self, obj):
# shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
# return obj.onhand_users.filter(id__in=shared_users).exists()
def run_validation(self, data): def run_validation(self, data):
try:
validated_data = super().run_validation(data) validated_data = super().run_validation(data)
except SkipField:
return super().run_validation(data)
# convert boolean food_onhand to onhand_users # convert boolean food_onhand to onhand_users
# TODO maybe this should be moved to pre_save signal to ensure shopping_share users are always included?
if ( if (
self.root.instance.__class__.__name__ == 'Food' self.root.instance.__class__.__name__ == 'Food'
and not (onhand := data.pop('food_onhand', None)) is None and not onhand is None
): ):
# assuming if on hand for user also onhand for shopping_share users # assuming if on hand for user also onhand for shopping_share users
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
@ -404,6 +402,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users
else: else:
validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users)) validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users))
return validated_data return validated_data
def create(self, validated_data): def create(self, validated_data):
@ -724,6 +723,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
): ):
# if checked flips from false to true set completed datetime # if checked flips from false to true set completed datetime
data['completed_at'] = timezone.now() data['completed_at'] = timezone.now()
elif not data.get('checked', False): elif not data.get('checked', False):
# if not checked set completed to None # if not checked set completed to None
data['completed_at'] = None data['completed_at'] = None
@ -739,6 +739,16 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
validated_data['created_by'] = self.context['request'].user validated_data['created_by'] = self.context['request'].user
return super().create(validated_data) return super().create(validated_data)
def update(self, instance, validated_data):
user = self.context['request'].user
if user.userpreference.shopping_add_onhand:
if checked := validated_data.get('checked', None):
instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
elif checked == False:
instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)
return super().update(instance, validated_data)
class Meta: class Meta:
model = ShoppingListEntry model = ShoppingListEntry
fields = ( fields = (
@ -891,7 +901,7 @@ class FoodExportSerializer(FoodSerializer):
class Meta: class Meta:
model = Food model = Food
fields = ('name', 'food_onhand', 'supermarket_category',) fields = ('name', 'ignore_shopping', 'supermarket_category',)
class IngredientExportSerializer(WritableNestedModelSerializer): class IngredientExportSerializer(WritableNestedModelSerializer):

View File

@ -75,8 +75,8 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
# apply changes from parent to instance for each inheritted field # apply changes from parent to instance for each inheritted field
if instance.parent and inherit.count() > 0: if instance.parent and inherit.count() > 0:
parent = instance.get_parent() parent = instance.get_parent()
if 'food_onhand' in inherit: if 'ignore_shopping' in inherit:
instance.food_onhand = parent.food_onhand 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 is not set, do not cascade - if this becomes non-intuitive can change
if 'supermarket_category' in inherit and parent.supermarket_category: if 'supermarket_category' in inherit and parent.supermarket_category:
instance.supermarket_category = 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 # TODO figure out how to generalize this
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down # apply changes to direct children - depend on save signals for those objects to cascade inheritance down
_save = [] _save = []
for child in instance.get_children().filter(inherit_fields__field='food_onhand'): for child in instance.get_children().filter(inherit_fields__field='ignore_shopping'):
child.food_onhand = instance.food_onhand child.ignore_shopping = instance.ignore_shopping
_save.append(child) _save.append(child)
# don't cascade empty supermarket category # don't cascade empty supermarket category
if instance.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 'servings': instance.servings
} }
list_recipe = list_from_recipe(**kwargs) 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)

View File

@ -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", [ @pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [
({'has_category': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'), ({'has_category': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'),
({'has_category': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'), ({'has_category': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'),
({'food_onhand': True, 'inherit': True}, 'food_onhand', True, 'false'), ({'ignore_shopping': True, 'inherit': True}, 'ignore_shopping', True, 'false'),
({'food_onhand': True, 'inherit': False}, 'food_onhand', False, '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 ], 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): def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
with scope(space=obj_tree_1.space): 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", [ @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']) ], indirect=['obj_tree_1'])
def test_reset_inherit(obj_tree_1, space_1): def test_reset_inherit(obj_tree_1, space_1):
with scope(space=space_1): with scope(space=space_1):
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0] child = obj_tree_1.get_descendants()[0]
obj_tree_1.food_onhand = False obj_tree_1.ignore_shopping = False
assert parent.food_onhand == child.food_onhand assert parent.ignore_shopping == child.ignore_shopping
assert parent.food_onhand != obj_tree_1.food_onhand assert parent.ignore_shopping != obj_tree_1.ignore_shopping
assert parent.supermarket_category != child.supermarket_category assert parent.supermarket_category != child.supermarket_category
assert parent.supermarket_category != obj_tree_1.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) obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0] 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 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

View File

@ -217,3 +217,6 @@ def test_recent(sle, u1_s1):
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content) r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
assert len(r) == 10 assert len(r) == 10
assert [x['checked'] for x in r].count(False) == 9 assert [x['checked'] for x in r].count(False) == 9
# TODO test auto onhand

View File

@ -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) # 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) ingredients = Ingredient.objects.filter(step__recipe=recipe)
food = Food.objects.get(id=ingredients[2].food.id) food = Food.objects.get(id=ingredients[2].food.id)
food.food_onhand = True food.onhand_users.add(user)
food.save() food.save()
food = recipe.steps.filter(type=Step.RECIPE).first().step_recipe.steps.first().ingredients.first().food food = recipe.steps.filter(type=Step.RECIPE).first().step_recipe.steps.first().ingredients.first().food
food = Food.objects.get(id=food.id) food = Food.objects.get(id=food.id)
food.food_onhand = True food.onhand_users.add(user)
food.save() food.save()
if use_mealplan: if use_mealplan:

View File

@ -388,6 +388,7 @@ def user_settings(request):
up.filter_to_supermarket = shopping_form.cleaned_data['filter_to_supermarket'] up.filter_to_supermarket = shopping_form.cleaned_data['filter_to_supermarket']
up.default_delay = shopping_form.cleaned_data['default_delay'] up.default_delay = shopping_form.cleaned_data['default_delay']
up.shopping_recent_days = shopping_form.cleaned_data['shopping_recent_days'] 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_delim = shopping_form.cleaned_data['csv_delim']
up.csv_prefix = shopping_form.cleaned_data['csv_prefix'] up.csv_prefix = shopping_form.cleaned_data['csv_prefix']
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:

View File

@ -375,6 +375,19 @@
</em> </em>
</div> </div>
</div> </div>
<div class="row">
<div class="col col-md-6">{{ $t("shopping_add_onhand") }}</div>
<div class="col col-md-6 text-right">
<input type="checkbox" size="sm" v-model="settings.shopping_add_onhand" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("shopping_add_onhand_desc") }}
</em>
</div>
</div>
<div class="row"> <div class="row">
<div class="col col-md-6">{{ $t("shopping_recent_days") }}</div> <div class="col col-md-6">{{ $t("shopping_recent_days") }}</div>
<div class="col col-md-6 text-right"> <div class="col col-md-6 text-right">
@ -590,6 +603,7 @@ export default {
shopping_recent_days: 7, shopping_recent_days: 7,
csv_delim: ",", csv_delim: ",",
csv_prefix: undefined, csv_prefix: undefined,
shopping_add_onhand: true,
}, },
new_supermarket: { entrymode: false, value: undefined, editmode: undefined }, new_supermarket: { entrymode: false, value: undefined, editmode: undefined },
new_category: { entrymode: false, value: undefined }, new_category: { entrymode: false, value: undefined },

View File

@ -278,5 +278,7 @@
"in_shopping": "In Shopping List", "in_shopping": "In Shopping List",
"DelayUntil": "Delay Until", "DelayUntil": "Delay Until",
"mark_complete": "Mark Complete", "mark_complete": "Mark Complete",
"QuickEntry": "Quick Entry" "QuickEntry": "Quick Entry",
"shopping_add_onhand_desc": "Mark food 'On Hand' when checked off shopping list.",
"shopping_add_onhand": "Auto On Hand"
} }