Merge branch 'develop' of https://github.com/vabene1111/recipes into develop
This commit is contained in:
@ -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
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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]:
|
||||
|
41
cookbook/migrations/0163_auto_20220105_0758.py
Normal file
41
cookbook/migrations/0163_auto_20220105_0758.py
Normal 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),
|
||||
]
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
}
|
||||
|
185
vue/src/components/Buttons/RecipeSwitcher.vue
Normal file
185
vue/src/components/Buttons/RecipeSwitcher.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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("")
|
||||
|
@ -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" },
|
||||
},
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
Reference in New Issue
Block a user