Merge branch 'develop' of https://github.com/vabene1111/recipes into develop

This commit is contained in:
vabene1111
2022-01-06 16:34:53 +01:00
26 changed files with 545 additions and 114 deletions

View File

@ -35,4 +35,3 @@ class CookbookConfig(AppConfig):
# if DEBUG:
# traceback.print_exc()
# pass # dont break startup just because fix could not run, need to investigate cases when this happens

View File

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

View File

@ -5,6 +5,7 @@ from rest_framework.authtoken.models import Token
from rest_framework.exceptions import AuthenticationFailed
from cookbook.views import views
from recipes import settings
class ScopeMiddleware:
@ -14,14 +15,15 @@ class ScopeMiddleware:
def __call__(self, request):
if request.user.is_authenticated:
if request.path.startswith('/admin/'):
prefix = settings.JS_REVERSE_SCRIPT_PREFIX or ''
if request.path.startswith(prefix + '/admin/'):
with scopes_disabled():
return self.get_response(request)
if request.path.startswith('/signup/') or request.path.startswith('/invite/'):
if request.path.startswith(prefix + '/signup/') or request.path.startswith(prefix + '/invite/'):
return self.get_response(request)
if request.path.startswith('/accounts/'):
if request.path.startswith(prefix + '/accounts/'):
return self.get_response(request)
with scopes_disabled():
@ -36,7 +38,7 @@ class ScopeMiddleware:
with scope(space=request.space):
return self.get_response(request)
else:
if request.path.startswith('/api/'):
if request.path.startswith(prefix + '/api/'):
try:
if auth := TokenAuthentication().authenticate(request):
request.space = auth[0].userpreference.space

View File

@ -17,8 +17,7 @@ def shopping_helper(qs, request):
supermarket = request.query_params.get('supermarket', None)
checked = request.query_params.get('checked', 'recent')
user = request.user
supermarket_order = ['food__supermarket_category__name', 'food__name']
supermarket_order = [F('food__supermarket_category__name').asc(nulls_first=True), 'food__name']
# TODO created either scheduled task or startup task to delete very old shopping list entries
# TODO create user preference to define 'very old'
@ -82,7 +81,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
ingredients = ingredients.exclude(food__food_onhand=True)
ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
if related := created_by.userpreference.mealplan_autoinclude_related:
# TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
@ -93,7 +92,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
# TODO once/if Steps can have a serving size this needs to be refactored
if exclude_onhand:
# if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
related_step_ing += Ingredient.objects.filter(step__recipe=x, food__food_onhand=False, space=space).values_list('id', flat=True)
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
else:
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
@ -101,7 +100,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
if ingredients.filter(food__recipe=x).exists():
for ing in ingredients.filter(food__recipe=x):
if exclude_onhand:
x_ing = Ingredient.objects.filter(step__recipe=x, food__food_onhand=False, space=space)
x_ing = Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
else:
x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
for i in [x for x in x_ing]:

View File

@ -0,0 +1,41 @@
# Generated by Django 3.2.10 on 2022-01-05 13:58
from django.conf import settings
from django.db import migrations, models
from cookbook.models import FoodInheritField
def rename_inherit_field(apps, schema_editor):
x = FoodInheritField.objects.filter(name='On Hand', field='food_onhand').first()
if x:
x.name = "Ignore Shopping"
x.field = "ignore_shopping"
x.save()
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0162_userpreference_csv_delim'),
]
operations = [
migrations.AddField(
model_name='food',
name='onhand_users',
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='userpreference',
name='shopping_add_onhand',
field=models.BooleanField(default=True),
),
migrations.RenameField(
model_name='food',
old_name='food_onhand',
new_name='ignore_shopping',
),
migrations.RunPython(rename_inherit_field),
]

View File

@ -332,6 +332,7 @@ class UserPreference(models.Model, PermissionModelMixin):
mealplan_autoadd_shopping = models.BooleanField(default=False)
mealplan_autoexclude_onhand = models.BooleanField(default=True)
mealplan_autoinclude_related = models.BooleanField(default=True)
shopping_add_onhand = models.BooleanField(default=True)
filter_to_supermarket = models.BooleanField(default=False)
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
shopping_recent_days = models.PositiveIntegerField(default=7)
@ -489,10 +490,11 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
node_order_by = ['name']
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
food_onhand = models.BooleanField(default=False) # inherited field
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) # inherited field
ignore_shopping = models.BooleanField(default=False) # inherited field
onhand_users = models.ManyToManyField(User, blank=True)
description = models.TextField(default='', blank=True)
inherit_fields = models.ManyToManyField(FoodInheritField, blank=True) # inherited field: is this name better as inherit instead of ignore inherit? which is more intuitive?
inherit_fields = models.ManyToManyField(FoodInheritField, blank=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space', _manager_class=TreeManager)
@ -524,10 +526,10 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
])
inherit = inherit.values_list('field', flat=True)
if 'food_onhand' in inherit:
if 'ignore_shopping' in inherit:
# get food at root that have children that need updated
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, food_onhand=True)).update(food_onhand=True)
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, food_onhand=False)).update(food_onhand=False)
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=True)).update(ignore_shopping=True)
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=False)).update(ignore_shopping=False)
if 'supermarket_category' in inherit:
# when supermarket_category is null or blank assuming it is not set and not intended to be blank for all descedants
# find top node that has category set

View File

@ -10,6 +10,7 @@ from django.utils import timezone
from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.fields import empty
from cookbook.helper.shopping_helper import list_from_recipe
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
@ -92,6 +93,18 @@ class CustomDecimalField(serializers.Field):
raise ValidationError('A valid number is required')
class CustomOnHandField(serializers.Field):
def get_attribute(self, instance):
return instance
def to_representation(self, obj):
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
return obj.onhand_users.filter(id__in=shared_users).exists()
def to_internal_value(self, data):
return data
class SpaceFilterSerializer(serializers.ListSerializer):
def to_representation(self, data):
@ -167,16 +180,13 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
raise NotFound()
return super().create(validated_data)
def update(self, instance, validated_data):
# don't allow writing to FoodInheritField via API
return super().update(instance, validated_data)
class Meta:
model = UserPreference
fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', 'filter_to_supermarket'
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand'
)
@ -371,6 +381,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
recipe = RecipeSimpleSerializer(allow_null=True, required=False)
shopping = serializers.SerializerMethodField('get_shopping_status')
inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False)
food_onhand = CustomOnHandField(required=False, allow_null=True)
recipe_filter = 'steps__ingredients__food'
@ -385,12 +396,29 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
validated_data['supermarket_category'], sc_created = SupermarketCategory.objects.get_or_create(
name=validated_data.pop('supermarket_category')['name'],
space=self.context['request'].space)
onhand = validated_data.get('food_onhand', None)
# assuming if on hand for user also onhand for shopping_share users
if not onhand is None:
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
if onhand:
validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users
else:
validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users))
obj, created = Food.objects.get_or_create(**validated_data)
return obj
def update(self, instance, validated_data):
if name := validated_data.get('name', None):
validated_data['name'] = name.strip()
# assuming if on hand for user also onhand for shopping_share users
onhand = validated_data.get('food_onhand', None)
if not onhand is None:
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
if onhand:
validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users
else:
validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users))
return super(FoodSerializer, self).update(instance, validated_data)
class Meta:
@ -687,6 +715,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
return fields
def run_validation(self, data):
if self.root.instance.__class__.__name__ == 'ShoppingListEntry':
if (
data.get('checked', False)
and self.root.instance
@ -694,6 +723,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
):
# if checked flips from false to true set completed datetime
data['completed_at'] = timezone.now()
elif not data.get('checked', False):
# if not checked set completed to None
data['completed_at'] = None
@ -709,6 +739,16 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
validated_data['created_by'] = self.context['request'].user
return super().create(validated_data)
def update(self, instance, validated_data):
user = self.context['request'].user
# update the onhand for food if shopping_add_onhand is True
if user.userpreference.shopping_add_onhand:
if checked := validated_data.get('checked', None):
instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
elif checked == False:
instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)
return super().update(instance, validated_data)
class Meta:
model = ShoppingListEntry
fields = (
@ -861,7 +901,7 @@ class FoodExportSerializer(FoodSerializer):
class Meta:
model = Food
fields = ('name', 'food_onhand', 'supermarket_category',)
fields = ('name', 'ignore_shopping', 'supermarket_category',)
class IngredientExportSerializer(WritableNestedModelSerializer):

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
if instance.parent and inherit.count() > 0:
parent = instance.get_parent()
if 'food_onhand' in inherit:
instance.food_onhand = parent.food_onhand
if 'ignore_shopping' in inherit:
instance.ignore_shopping = parent.ignore_shopping
# if supermarket_category is not set, do not cascade - if this becomes non-intuitive can change
if 'supermarket_category' in inherit and parent.supermarket_category:
instance.supermarket_category = parent.supermarket_category
@ -89,8 +89,8 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
# TODO figure out how to generalize this
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
_save = []
for child in instance.get_children().filter(inherit_fields__field='food_onhand'):
child.food_onhand = instance.food_onhand
for child in instance.get_children().filter(inherit_fields__field='ignore_shopping'):
child.ignore_shopping = instance.ignore_shopping
_save.append(child)
# don't cascade empty supermarket category
if instance.supermarket_category:
@ -121,3 +121,11 @@ def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs
'servings': instance.servings
}
list_recipe = list_from_recipe(**kwargs)
# user = self.context['request'].user
# if user.userpreference.shopping_add_onhand:
# if checked := validated_data.get('checked', None):
# instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
# elif checked == False:
# instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)

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", [
({'has_category': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'),
({'has_category': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'),
({'food_onhand': True, 'inherit': True}, 'food_onhand', True, 'false'),
({'food_onhand': True, 'inherit': False}, 'food_onhand', False, 'false'),
({'ignore_shopping': True, 'inherit': True}, 'ignore_shopping', True, 'false'),
({'ignore_shopping': True, 'inherit': False}, 'ignore_shopping', False, 'false'),
], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter
def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
with scope(space=obj_tree_1.space):
@ -509,16 +509,16 @@ def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
@pytest.mark.parametrize("obj_tree_1", [
({'has_category': True, 'inherit': False, 'food_onhand': True}),
({'has_category': True, 'inherit': False, 'ignore_shopping': True}),
], indirect=['obj_tree_1'])
def test_reset_inherit(obj_tree_1, space_1):
with scope(space=space_1):
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
obj_tree_1.food_onhand = False
assert parent.food_onhand == child.food_onhand
assert parent.food_onhand != obj_tree_1.food_onhand
obj_tree_1.ignore_shopping = False
assert parent.ignore_shopping == child.ignore_shopping
assert parent.ignore_shopping != obj_tree_1.ignore_shopping
assert parent.supermarket_category != child.supermarket_category
assert parent.supermarket_category != obj_tree_1.supermarket_category
@ -527,5 +527,26 @@ def test_reset_inherit(obj_tree_1, space_1):
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
assert parent.food_onhand == obj_tree_1.food_onhand == child.food_onhand
assert parent.ignore_shopping == obj_tree_1.ignore_shopping == child.ignore_shopping
assert parent.supermarket_category == obj_tree_1.supermarket_category == child.supermarket_category
def test_onhand(obj_1, u1_s1, u2_s1):
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False
u1_s1.patch(
reverse(
DETAIL_URL,
args={obj_1.id}
),
{'food_onhand': True},
content_type='application/json'
)
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == True
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False
user1 = auth.get_user(u1_s1)
user2 = auth.get_user(u2_s1)
user1.userpreference.shopping_share.add(user2)
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == True

View File

@ -65,7 +65,6 @@ def test_related_mixed_space(request, recipe, u1_s2):
reverse(RELATED_URL, args={recipe.id})).content)) == 0
# TODO add tests for mealplan related when thats added
# TODO if/when related recipes includes multiple levels (related recipes of related recipes) add the following tests
# -- step recipes included in step recipes
# -- step recipes included in food recipes

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)
assert len(r) == 10
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)
ingredients = Ingredient.objects.filter(step__recipe=recipe)
food = Food.objects.get(id=ingredients[2].food.id)
food.food_onhand = True
food.onhand_users.add(user)
food.save()
food = recipe.steps.filter(type=Step.RECIPE).first().step_recipe.steps.first().ingredients.first().food
food = Food.objects.get(id=food.id)
food.food_onhand = True
food.onhand_users.add(user)
food.save()
if use_mealplan:
@ -233,7 +233,6 @@ def test_shopping_recipe_mixed_authors(u1_s1, u2_s1):
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
# TODO test adding recipe with ingredients that are not food
@pytest.mark.parametrize("recipe", [{'steps__ingredients__header': 1}], indirect=['recipe'])
def test_shopping_with_header_ingredient(u1_s1, recipe):
# with scope(space=recipe.space):

View File

@ -689,8 +689,11 @@ class RecipeViewSet(viewsets.ModelViewSet):
obj = self.get_object()
if obj.get_space() != request.space:
raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403)
qs = obj.get_related_recipes(levels=1) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
# mealplans= TODO get todays mealplans
try:
levels = int(request.query_params.get('levels', 1))
except (ValueError, TypeError):
levels = 1
qs = obj.get_related_recipes(levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
return Response(self.serializer_class(qs, many=True).data)

View File

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

View File

@ -7,7 +7,7 @@
<div class="col-xl-8 col-12">
<div class="container-fluid d-flex flex-column flex-grow-1">
<!-- dynamically loaded header components -->
<div class="row" v-if="header_component_name !== ''">
<div class="row" v-if="header_component_name">
<div class="col-md-12">
<component :is="headerComponent"></component>
</div>

View File

@ -1,5 +1,6 @@
<template>
<div id="app" style="margin-bottom: 4vh">
<RecipeSwitcher mode="mealplan" />
<div class="row">
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
<div class="row">
@ -244,6 +245,7 @@ import RecipeCard from "@/components/RecipeCard"
import GenericMultiselect from "@/components/GenericMultiselect"
import Treeselect from "@riophae/vue-treeselect"
import "@riophae/vue-treeselect/dist/vue-treeselect.css"
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
Vue.use(BootstrapVue)
@ -252,7 +254,7 @@ let SETTINGS_COOKIE_NAME = "search_settings"
export default {
name: "RecipeSearchView",
mixins: [ResolveUrlMixin, ApiMixin],
components: { GenericMultiselect, RecipeCard, Treeselect },
components: { GenericMultiselect, RecipeCard, Treeselect, RecipeSwitcher },
data() {
return {
// this.Models and this.Actions inherited from ApiMixin

View File

@ -5,6 +5,7 @@
</template>
<div v-if="!loading">
<RecipeSwitcher :recipe="rootrecipe.id" :name="rootrecipe.name" mode="recipe" @switch="quickSwitch($event)" />
<div class="row">
<div class="col-12" style="text-align: center">
<h3>{{ recipe.name }}</h3>
@ -75,12 +76,12 @@
/>
</div>
<div class="my-auto">
<span class="text-primary"
><b
><template v-if="recipe.servings_text === ''">{{ $t("Servings") }}</template
><template v-else>{{ recipe.servings_text }}</template></b
></span
>
<span class="text-primary">
<b>
<template v-if="recipe.servings_text === ''">{{ $t("Servings") }}</template>
<template v-else>{{ recipe.servings_text }}</template>
</b>
</span>
</div>
</div>
</div>
@ -172,6 +173,7 @@ import IngredientsCard from "@/components/IngredientsCard"
import StepComponent from "@/components/StepComponent"
import KeywordsComponent from "@/components/KeywordsComponent"
import NutritionComponent from "@/components/NutritionComponent"
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
Vue.prototype.moment = moment
@ -192,22 +194,32 @@ export default {
KeywordsComponent,
LoadingSpinner,
AddRecipeToBook,
RecipeSwitcher,
},
computed: {
ingredient_factor: function () {
return this.servings / this.recipe.servings
},
ingredient_count() {
return this.recipe?.steps.map((x) => x.ingredients).flat().length
},
},
data() {
return {
loading: true,
recipe: undefined,
ingredient_count: 0,
rootrecipe: undefined,
servings: 1,
servings_cache: {},
start_time: "",
share_uid: window.SHARE_UID,
}
},
watch: {
servings(newVal, oldVal) {
this.servings_cache[this.recipe.id] = this.servings
},
},
mounted() {
this.loadRecipe(window.RECIPE_ID)
this.$i18n.locale = window.CUSTOM_LOCALE
@ -218,12 +230,9 @@ export default {
if (window.USER_SERVINGS !== 0) {
recipe.servings = window.USER_SERVINGS
}
this.servings = recipe.servings
let total_time = 0
for (let step of recipe.steps) {
this.ingredient_count += step.ingredients.length
for (let ingredient of step.ingredients) {
this.$set(ingredient, "checked", false)
}
@ -237,7 +246,8 @@ export default {
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
}
this.recipe = recipe
this.recipe = this.rootrecipe = recipe
this.servings = this.servings_cache[this.rootrecipe.id] = recipe.servings
this.loading = false
})
},
@ -253,6 +263,15 @@ export default {
}
}
},
quickSwitch: function (e) {
if (e === -1) {
this.recipe = this.rootrecipe
this.servings = this.servings_cache[this.rootrecipe?.id ?? 1]
} else {
this.recipe = e
this.servings = this.servings_cache?.[e.id] ?? e.servings
}
},
},
}
</script>

View File

@ -31,15 +31,19 @@
<div class="col col-md-12">
<div role="tablist">
<!-- add to shopping form -->
<b-row class="row justify-content-md-center" v-if="entrymode">
<b-col cols="12" sm="4" md="2">
<b-col cols="12" sm="4" md="2" v-if="!entry_mode_simple">
<b-form-input min="1" type="number" :description="$t('Amount')" v-model="new_item.amount"></b-form-input>
</b-col>
<b-col cols="12" sm="8" md="3">
<lookup-input :form="formUnit" :model="Models.UNIT" @change="new_item.unit = $event" :show_label="false" />
<b-col cols="12" sm="8" md="3" v-if="!entry_mode_simple">
<lookup-input :form="formUnit" :model="Models.UNIT" @change="new_item.unit = $event" :show_label="false" :clear="clear" />
</b-col>
<b-col cols="12" sm="8" md="4">
<lookup-input :form="formFood" :model="Models.FOOD" @change="new_item.food = $event" :show_label="false" />
<b-col cols="12" sm="8" md="4" v-if="!entry_mode_simple">
<lookup-input :form="formFood" :model="Models.FOOD" @change="new_item.food = $event" :show_label="false" :clear="clear" />
</b-col>
<b-col cols="12" sm="8" v-if="entry_mode_simple">
<b-form-input type="text" :placeholder="$t('QuickEntry')" v-model="new_item.ingredient" @keyup.enter="addItem"></b-form-input>
</b-col>
<b-col cols="12" sm="4" md="1">
<b-button variant="link" class="px-0">
@ -47,6 +51,12 @@
</b-button>
</b-col>
</b-row>
<b-row class="row justify-content-md-end" v-if="entrymode">
<b-form-checkbox switch v-model="entry_mode_simple">
{{ $t("QuickEntry") }}
</b-form-checkbox>
</b-row>
<!-- shopping list table -->
<div v-if="items && items.length > 0">
<div v-for="(done, x) in Sections" :key="x">
@ -58,6 +68,17 @@
<div v-for="(s, i) in done" :key="i">
<h5 v-if="Object.entries(s).length > 0">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-dark pr-2 dropdown-toggle-no-caret"
@click.stop="openContextMenu($event, s, true)"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
<b-button
class="btn btn-lg text-decoration-none text-dark px-1 py-0 border-0"
variant="link"
@ -68,6 +89,7 @@
<i class="fa fa-chevron-right rotate" />
{{ i }}
</b-button>
</div>
</h5>
<div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }">
@ -353,6 +375,19 @@
</em>
</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="col col-md-6">{{ $t("shopping_recent_days") }}</div>
<div class="col col-md-6 text-right">
@ -492,6 +527,14 @@
</div>
</b-form-group>
</ContextMenuItem>
<ContextMenuItem
@click="
$refs.menu.close()
updateChecked({ entries: contextData, checked: true })
"
>
<a class="dropdown-item p-2" href="#"><i class="fas fa-check-square"></i> {{ $t("mark_complete") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
@ -510,6 +553,7 @@
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import VueCookies from "vue-cookies"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
@ -522,11 +566,12 @@ import GenericPill from "@/components/GenericPill"
import LookupInput from "@/components/Modals/LookupInput"
import draggable from "vuedraggable"
import { ApiMixin, getUserPreference } from "@/utils/utils"
import { ApiMixin, getUserPreference, StandardToasts, makeToast } from "@/utils/utils"
import { ApiApiFactory } from "@/utils/openapi/api"
import { StandardToasts, makeToast } from "@/utils/utils"
Vue.use(BootstrapVue)
Vue.use(VueCookies)
let SETTINGS_COOKIE_NAME = "shopping_settings"
export default {
name: "ShoppingListView",
@ -546,6 +591,8 @@ export default {
supermarket_categories_only: false,
shopcat: null,
delay: 0,
clear: Math.random(),
entry_mode_simple: false,
settings: {
shopping_auto_sync: 0,
default_delay: 4,
@ -556,6 +603,7 @@ export default {
shopping_recent_days: 7,
csv_delim: ",",
csv_prefix: undefined,
shopping_add_onhand: true,
},
new_supermarket: { entrymode: false, value: undefined, editmode: undefined },
new_category: { entrymode: false, value: undefined },
@ -567,7 +615,7 @@ export default {
fields: ["checked", "amount", "category", "unit", "food", "recipe", "details"],
loading: true,
entrymode: false,
new_item: { amount: 1, unit: undefined, food: undefined },
new_item: { amount: 1, unit: undefined, food: undefined, ingredient: undefined },
online: true,
}
},
@ -594,6 +642,7 @@ export default {
// if a supermarket is selected and filtered to only supermarket categories filter out everything else
if (this.selected_supermarket && this.supermarket_categories_only) {
let shopping_categories = this.supermarkets // category IDs configured on supermarket
.filter((x) => x.id === this.selected_supermarket)
.map((x) => x.category_to_supermarket)
.flat()
.map((x) => x.category.id)
@ -603,7 +652,7 @@ export default {
shopping_list = shopping_list.filter((x) => x?.food?.supermarket_category)
}
let groups = { false: {}, true: {} } // force unchecked to always be first
var groups = { false: {}, true: {} } // force unchecked to always be first
if (this.selected_supermarket) {
let super_cats = this.supermarkets
.filter((x) => x.id === this.selected_supermarket)
@ -611,10 +660,13 @@ export default {
.flat()
.map((x) => x.category.name)
new Set([...super_cats, ...this.shopping_categories.map((x) => x.name)]).forEach((cat) => {
groups["false"][cat.name] = {}
groups["true"][cat.name] = {}
groups["false"][cat] = {}
groups["true"][cat] = {}
})
} else {
// TODO: make nulls_first a user setting
groups.false[this.$t("Undefined")] = {}
groups.true[this.$t("Undefined")] = {}
this.shopping_categories.forEach((cat) => {
groups.false[cat.name] = {}
groups.true[cat.name] = {}
@ -712,6 +764,9 @@ export default {
"settings.default_delay": function (newVal, oldVal) {
this.delay = Number(newVal)
},
entry_mode_simple(newVal) {
this.$cookies.set(SETTINGS_COOKIE_NAME, newVal)
},
},
mounted() {
this.getShoppingList()
@ -725,11 +780,29 @@ export default {
window.addEventListener("online", this.updateOnlineStatus)
window.addEventListener("offline", this.updateOnlineStatus)
}
this.$nextTick(function () {
if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) {
this.entry_mode_simple = this.$cookies.get(SETTINGS_COOKIE_NAME)
}
})
},
methods: {
// this.genericAPI inherited from ApiMixin
addItem() {
addItem: function () {
if (this.entry_mode_simple) {
this.genericPostAPI("api_ingredient_from_string", { text: this.new_item.ingredient }).then((result) => {
this.new_item = {
amount: result.data.amount,
unit: { name: result.data.unit },
food: { name: result.data.food },
}
this.addEntry()
})
} else {
this.addEntry()
}
},
addEntry: function (x) {
let api = new ApiApiFactory()
api.createShoppingListEntry(this.new_item)
.then((results) => {
@ -739,7 +812,8 @@ export default {
} else {
console.log("no data returned")
}
this.new_item = { amount: 1, unit: undefined, food: undefined }
this.new_item = { amount: 1, unit: undefined, food: undefined, ingredient: undefined }
this.clear += 1
})
.catch((err) => {
console.log(err)
@ -917,8 +991,17 @@ export default {
// TODO make decision - should inheritance always be set manually or give user a choice at front-end or make it a setting?
let food = this.items.filter((x) => x.food.id == item?.[0]?.food.id ?? item.food.id)[0].food
food.supermarket_category = this.shopping_categories.filter((x) => x?.id === this.shopcat)?.[0]
this.updateFood(food, "supermarket_category")
let supermarket_category = this.shopping_categories.filter((x) => x?.id === this.shopcat)?.[0]
food.supermarket_category = supermarket_category
this.updateFood(food, "supermarket_category").then((result) => {
this.items = this.items.map((x) => {
if (x.food.id === food.id) {
return { ...x, food: { ...x.food, supermarket_category: supermarket_category } }
} else {
return x
}
})
})
this.shopcat = null
},
onHand: function (item) {
@ -940,8 +1023,13 @@ export default {
})
})
},
openContextMenu(e, value) {
openContextMenu(e, value, section = false) {
if (section) {
value = Object.values(value).flat()
} else {
this.shopcat = value?.food?.supermarket_category?.id ?? value?.[0]?.food?.supermarket_category?.id ?? undefined
}
this.$refs.menu.open(e, value)
},
saveSettings: function () {
@ -1010,24 +1098,15 @@ export default {
},
updateFood: function (food, field) {
let api = new ApiApiFactory()
let ignore_category
if (field) {
ignore_category = food.inherit_fields
.map((x) => food.inherit_fields.fields)
.flat()
.includes(field)
} else {
ignore_category = true
// assume if field is changing it should no longer be inheritted
food.inherit_fields = food.inherit_fields.filter((x) => x.field !== field)
}
return api
.partialUpdateFood(food.id, food)
.then((result) => {
if (food.supermarket_category && !ignore_category && food.parent) {
makeToast(this.$t("Warning"), this.$t("InheritWarning", { food: food.name }), "warning")
} else {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}
if (food?.numchild > 0) {
this.getShoppingList() // if food has children, just get the whole list. probably could be more efficient
}

View File

@ -0,0 +1,185 @@
<template>
<div v-if="recipes.length > 0">
<div id="switcher">
<i class="btn btn-outline-dark fas fa-receipt fa-xl shadow-none btn-circle" v-b-toggle.related-recipes />
</div>
<b-sidebar id="related-recipes" :title="title" backdrop right shadow="sm" style="z-index: 10000">
<template #default="{ hide }">
<nav class="mb-3">
<b-nav vertical>
<b-nav-item
variant="link"
@click="
navRecipe(-1)
hide()
"
>{{ name }}</b-nav-item
>
<div v-for="r in recipes" :key="r.id">
<b-nav-item
variant="link"
@click="
navRecipe(r)
hide()
"
>{{ r.name }}</b-nav-item
>
</div>
</b-nav>
</nav>
</template>
</b-sidebar>
</div>
</template>
<script>
const { ApiApiFactory } = require("@/utils/openapi/api")
import { ResolveUrlMixin } from "@/utils/utils"
export default {
name: "RecipeSwitcher",
mixins: [ResolveUrlMixin],
props: {
recipe: { type: Number, default: undefined },
name: { type: String, default: undefined },
mode: { type: String, default: "recipe" },
},
data() {
return {
recipes: [],
recipe_list: [],
}
},
computed: {
title() {
let title = ""
switch (this.mode) {
case "recipe":
title = this.$t("related_recipes")
break
case "mealplan":
title = this.$t("today_recipes")
break
}
return title
},
},
mounted() {
this.recipes = []
switch (this.mode) {
case "recipe":
this.loadRecipes()
break
case "mealplan":
this.loadMealPlans()
break
}
},
methods: {
navRecipe: function (recipe) {
switch (this.mode) {
case "recipe":
this.$emit("switch", recipe)
break
case "mealplan":
window.location.href = this.resolveDjangoUrl("view_recipe", recipe.id)
break
default:
console.log(this.mode, " isn't defined.")
}
},
loadRecipes: function () {
let apiClient = new ApiApiFactory()
apiClient
.relatedRecipe(this.recipe, { query: { levels: 2 } })
// get related recipes and save them for later
.then((result) => {
this.recipe_list = result.data
})
// get all recipes for today
.then(() => {
this.loadMealPlans()
})
},
loadMealPlans: function () {
let apiClient = new ApiApiFactory()
// TODO move to utility function moment is in maintenance mode https://momentjs.com/docs/
var tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds
let today = new Date(Date.now() - tzoffset).toISOString().split("T")[0]
apiClient
.listMealPlans({
query: {
from_date: today,
to_date: today,
},
})
.then((result) => {
let promises = []
result.data.forEach((mealplan) => {
this.recipe_list.push({ ...mealplan?.recipe, servings: mealplan?.servings })
const serving_factor = (mealplan?.servings ?? mealplan?.recipe?.servings ?? 1) / (mealplan?.recipe?.servings ?? 1)
promises.push(
apiClient.relatedRecipe(mealplan?.recipe?.id, { query: { levels: 2 } }).then((r) => {
// scale all recipes to mealplan servings
r.data = r.data.map((x) => {
return { ...x, factor: serving_factor }
})
this.recipe_list = [...this.recipe_list, ...r.data]
})
)
})
return Promise.all(promises).then(() => {
let promises = []
let dedup = []
this.recipe_list.forEach((recipe) => {
if (!dedup.includes(recipe.id)) {
dedup.push(recipe.id)
promises.push(
apiClient.retrieveRecipe(recipe.id).then((result) => {
// scale all recipes to mealplan servings
result.data.servings = recipe?.servings ?? result.data.servings * (recipe?.factor ?? 1)
this.recipes.push(result.data)
})
)
}
})
return Promise.all(promises)
})
})
},
},
}
</script>
<style>
.btn-circle {
width: 50px;
height: 50px;
padding: 10px 16px;
text-align: center;
border-radius: 35px;
font-size: 24px;
line-height: 1.33;
z-index: 9000;
}
@media screen and (max-width: 600px) {
#switcher .btn-circle {
position: fixed;
top: 9px;
left: 80px;
color: white;
}
}
@media screen and (max-width: 2000px) {
#switcher .btn-circle {
position: fixed;
bottom: 10px;
right: 50px;
}
}
</style>

View File

@ -26,7 +26,7 @@
<div class="m-0 text-truncate">{{ item[subtitle] }}</div>
<div class="m-0 text-truncate small text-muted" v-if="getFullname">{{ getFullname }}</div>
<generic-pill v-for="x in itemTags" :key="x.field" :item_list="item[x.field]" :label="x.label" :color="x.color" />
<generic-pill v-for="x in itemTags" :key="x.field" :item_list="itemList(x)" :label="x.label" :color="x.color" />
<generic-ordered-pill
v-for="x in itemOrderedTags"
:key="x.field"
@ -259,6 +259,14 @@ export default {
finishAction: function (e) {
this.$emit("finish-action", e)
},
itemList: function (tag) {
let itemlist = this.item?.[tag?.field] ?? []
if (Array.isArray(itemlist)) {
return itemlist
} else {
return [itemlist]
}
},
},
}
</script>

View File

@ -62,12 +62,16 @@ export default {
multiple: { type: Boolean, default: true },
allow_create: { type: Boolean, default: false },
create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" },
clear: { type: Number },
},
watch: {
initial_selection: function (newVal, oldVal) {
// watch it
this.selected_objects = newVal
},
clear: function (newVal, oldVal) {
this.selected_objects = []
},
},
mounted() {
this.search("")

View File

@ -10,7 +10,7 @@
export default {
name: "GenericPill",
props: {
item_list: { type: Object },
item_list: { type: Array },
label: { type: String, default: "name" },
color: { type: String, default: "light" },
},

View File

@ -108,7 +108,7 @@ export default {
this.shop = false // don't check any boxes until user selects a shopping list to edit
if (count_shopping_ingredient >= 1) {
this.shopping_status = true // ingredient is in the shopping list - probably (but not definitely, this ingredient)
} else if (this.ingredient.food.shopping) {
} else if (this.ingredient?.food?.shopping) {
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
} else {
// food is not in any shopping list
@ -123,7 +123,7 @@ export default {
if (count_shopping_ingredient >= 1) {
// ingredient is in this shopping list (not entirely sure how this could happen?)
this.shopping_status = true
} else if (count_shopping_ingredient == 0 && this.ingredient.food.shopping) {
} else if (count_shopping_ingredient == 0 && this.ingredient?.food?.shopping) {
// food is in the shopping list, just not for this ingredient/recipe
this.shopping_status = null
} else {

View File

@ -13,6 +13,7 @@
:sticky_options="sticky_options"
:allow_create="form.allow_create"
:create_placeholder="createPlaceholder"
:clear="clear"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="modelName"
@new="addNew"
@ -44,6 +45,7 @@ export default {
},
},
show_label: { type: Boolean, default: true },
clear: { type: Number },
},
data() {
return {

View File

@ -276,5 +276,11 @@
"csv_prefix_label": "List Prefix",
"copy_markdown_table": "Copy as Markdown Table",
"in_shopping": "In Shopping List",
"DelayUntil": "Delay Until"
"DelayUntil": "Delay Until",
"mark_complete": "Mark Complete",
"QuickEntry": "Quick Entry",
"shopping_add_onhand_desc": "Mark food 'On Hand' when checked off shopping list.",
"shopping_add_onhand": "Auto On Hand",
"related_recipes": "Related Recipes",
"today_recipes": "Today's Recipes"
}

View File

@ -234,7 +234,14 @@ export const ApiMixin = {
return apiClient[func](...parameters)
},
genericGetAPI: function (url, options) {
return axios.get(this.resolveDjangoUrl(url), { params: options, emulateJSON: true })
return axios.get(resolveDjangoUrl(url), { params: options, emulateJSON: true })
},
genericPostAPI: function (url, form) {
let data = new FormData()
Object.keys(form).forEach((field) => {
data.append(field, form[field])
})
return axios.post(resolveDjangoUrl(url), data)
},
},
}