Fix after rebase
This commit is contained in:
parent
f16e457d14
commit
10a33add75
@ -280,7 +280,7 @@ admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin)
|
|||||||
|
|
||||||
|
|
||||||
class ShoppingListEntryAdmin(admin.ModelAdmin):
|
class ShoppingListEntryAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'food', 'unit', 'list_recipe', 'checked')
|
list_display = ('id', 'food', 'unit', 'list_recipe', 'created_by', 'created_at', 'checked')
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)
|
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)
|
||||||
|
@ -1,16 +1,14 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.forms import widgets, NumberInput
|
from django.forms import NumberInput, widgets
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
||||||
from hcaptcha.fields import hCaptchaField
|
from hcaptcha.fields import hCaptchaField
|
||||||
|
|
||||||
from .models import (Comment, InviteLink, Keyword, MealPlan, Recipe,
|
from .models import (Comment, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||||
RecipeBook, RecipeBookEntry, Storage, Sync, User,
|
RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference)
|
||||||
UserPreference, MealType, Space,
|
|
||||||
SearchPreference)
|
|
||||||
|
|
||||||
|
|
||||||
class SelectWidget(widgets.Select):
|
class SelectWidget(widgets.Select):
|
||||||
@ -19,6 +17,7 @@ class SelectWidget(widgets.Select):
|
|||||||
|
|
||||||
|
|
||||||
class MultiSelectWidget(widgets.SelectMultiple):
|
class MultiSelectWidget(widgets.SelectMultiple):
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
js = ('custom/js/form_multiselect.js',)
|
js = ('custom/js/form_multiselect.js',)
|
||||||
|
|
||||||
@ -46,8 +45,7 @@ class UserPreferenceForm(forms.ModelForm):
|
|||||||
fields = (
|
fields = (
|
||||||
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
|
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
|
||||||
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
|
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
|
||||||
'plan_share', 'ingredient_decimals', 'shopping_auto_sync',
|
'plan_share', 'ingredient_decimals', 'comments',
|
||||||
'comments'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
@ -75,20 +73,26 @@ class UserPreferenceForm(forms.ModelForm):
|
|||||||
# noqa: E501
|
# noqa: E501
|
||||||
'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501
|
'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501
|
||||||
'plan_share': _(
|
'plan_share': _(
|
||||||
'Users with whom newly created meal plan/shopping list entries should be shared by default.'),
|
'Users with whom newly created meal plans should be shared by default.'),
|
||||||
|
'shopping_share': _('Users with whom to share shopping lists.'),
|
||||||
# noqa: E501
|
# noqa: E501
|
||||||
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
|
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
|
||||||
|
|
||||||
'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
|
'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
|
||||||
'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501
|
'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501
|
||||||
'shopping_auto_sync': _(
|
'shopping_auto_sync': _(
|
||||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
|
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
|
||||||
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
|
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
|
||||||
),
|
),
|
||||||
'sticky_navbar': _('Makes the navbar stick to the top of the page.') # noqa: E501
|
'sticky_navbar': _('Makes the navbar stick to the top of the page.'), # noqa: E501
|
||||||
|
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
|
||||||
|
'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'),
|
||||||
}
|
}
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
'plan_share': MultiSelectWidget
|
'plan_share': MultiSelectWidget,
|
||||||
|
'shopping_share': MultiSelectWidget,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -262,6 +266,7 @@ class SyncForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
class BatchEditForm(forms.Form):
|
class BatchEditForm(forms.Form):
|
||||||
search = forms.CharField(label=_('Search String'))
|
search = forms.CharField(label=_('Search String'))
|
||||||
keywords = forms.ModelMultipleChoiceField(
|
keywords = forms.ModelMultipleChoiceField(
|
||||||
@ -298,6 +303,7 @@ class ImportRecipeForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
class MealPlanForm(forms.ModelForm):
|
class MealPlanForm(forms.ModelForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
space = kwargs.pop('space')
|
space = kwargs.pop('space')
|
||||||
@ -420,10 +426,8 @@ class UserCreateForm(forms.Form):
|
|||||||
|
|
||||||
class SearchPreferenceForm(forms.ModelForm):
|
class SearchPreferenceForm(forms.ModelForm):
|
||||||
prefix = 'search'
|
prefix = 'search'
|
||||||
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2,
|
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
|
||||||
widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
|
help_text=_('Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
|
||||||
help_text=_(
|
|
||||||
'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
|
|
||||||
preset = forms.CharField(widget=forms.HiddenInput(), required=False)
|
preset = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -465,3 +469,59 @@ class SearchPreferenceForm(forms.ModelForm):
|
|||||||
'trigram': MultiSelectWidget,
|
'trigram': MultiSelectWidget,
|
||||||
'fulltext': MultiSelectWidget,
|
'fulltext': MultiSelectWidget,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingPreferenceForm(forms.ModelForm):
|
||||||
|
prefix = 'shopping'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserPreference
|
||||||
|
|
||||||
|
fields = (
|
||||||
|
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand',
|
||||||
|
'mealplan_autoinclude_related', 'default_delay', 'filter_to_supermarket'
|
||||||
|
)
|
||||||
|
|
||||||
|
help_texts = {
|
||||||
|
'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'),
|
||||||
|
'shopping_auto_sync': _(
|
||||||
|
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
|
||||||
|
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
|
||||||
|
),
|
||||||
|
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
|
||||||
|
'mealplan_autoexclude_onhand': _('When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.'),
|
||||||
|
'mealplan_autoinclude_related': _('When automatically adding a meal plan to the shopping list, include all related recipes.'),
|
||||||
|
'default_delay': _('Default number of hours to delay a shopping list entry.'),
|
||||||
|
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
|
||||||
|
}
|
||||||
|
labels = {
|
||||||
|
'shopping_share': _('Share Shopping List'),
|
||||||
|
'shopping_auto_sync': _('Autosync'),
|
||||||
|
'mealplan_autoadd_shopping': _('Auto Add Meal Plan'),
|
||||||
|
'mealplan_autoexclude_onhand': _('Exclude On Hand'),
|
||||||
|
'mealplan_autoinclude_related': _('Include Related'),
|
||||||
|
'default_delay': _('Default Delay Hours'),
|
||||||
|
'filter_to_supermarket': _('Filter to Supermarket'),
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets = {
|
||||||
|
'shopping_share': MultiSelectWidget
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SpacePreferenceForm(forms.ModelForm):
|
||||||
|
prefix = 'space'
|
||||||
|
reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False,
|
||||||
|
help_text=_("Reset all food to inherit the fields configured."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Space
|
||||||
|
|
||||||
|
fields = ('food_inherit', 'reset_food_inherit',)
|
||||||
|
|
||||||
|
help_texts = {
|
||||||
|
'food_inherit': _('Fields on food that should be inherited by default.'), }
|
||||||
|
|
||||||
|
widgets = {
|
||||||
|
'food_inherit': MultiSelectWidget
|
||||||
|
}
|
||||||
|
13
cookbook/helper/HelperFunctions.py
Normal file
13
cookbook/helper/HelperFunctions.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from django.db.models import Func
|
||||||
|
|
||||||
|
|
||||||
|
class Round(Func):
|
||||||
|
function = 'ROUND'
|
||||||
|
template = '%(function)s(%(expressions)s, 0)'
|
||||||
|
|
||||||
|
|
||||||
|
def str2bool(v):
|
||||||
|
if type(v) == bool:
|
||||||
|
return v
|
||||||
|
else:
|
||||||
|
return v.lower() in ("yes", "true", "1")
|
@ -2,11 +2,9 @@
|
|||||||
Source: https://djangosnippets.org/snippets/1703/
|
Source: https://djangosnippets.org/snippets/1703/
|
||||||
"""
|
"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import caches
|
|
||||||
|
|
||||||
from cookbook.models import ShareLink
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import user_passes_test
|
from django.contrib.auth.decorators import user_passes_test
|
||||||
|
from django.core.cache import caches
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
@ -14,6 +12,8 @@ from django.utils.translation import gettext as _
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from rest_framework.permissions import SAFE_METHODS
|
from rest_framework.permissions import SAFE_METHODS
|
||||||
|
|
||||||
|
from cookbook.models import ShareLink
|
||||||
|
|
||||||
|
|
||||||
def get_allowed_groups(groups_required):
|
def get_allowed_groups(groups_required):
|
||||||
"""
|
"""
|
||||||
@ -79,7 +79,11 @@ def is_object_shared(user, obj):
|
|||||||
# share checks for relevant objects
|
# share checks for relevant objects
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
return user in obj.get_shared()
|
if obj.__class__.__name__ == 'ShoppingListEntry':
|
||||||
|
# shopping lists are shared all or none and stored in user preferences
|
||||||
|
return obj.created_by in user.get_shopping_share()
|
||||||
|
else:
|
||||||
|
return user in obj.get_shared()
|
||||||
|
|
||||||
|
|
||||||
def share_link_valid(recipe, share):
|
def share_link_valid(recipe, share):
|
||||||
|
@ -8,24 +8,13 @@ from django.db.models.functions import Coalesce
|
|||||||
from django.utils import timezone, translation
|
from django.utils import timezone, translation
|
||||||
|
|
||||||
from cookbook.filters import RecipeFilter
|
from cookbook.filters import RecipeFilter
|
||||||
|
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||||
from cookbook.helper.permission_helper import has_group_permission
|
from cookbook.helper.permission_helper import has_group_permission
|
||||||
from cookbook.managers import DICTIONARY
|
from cookbook.managers import DICTIONARY
|
||||||
from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog
|
from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog
|
||||||
from recipes import settings
|
from recipes import settings
|
||||||
|
|
||||||
|
|
||||||
class Round(Func):
|
|
||||||
function = 'ROUND'
|
|
||||||
template = '%(function)s(%(expressions)s, 0)'
|
|
||||||
|
|
||||||
|
|
||||||
def str2bool(v):
|
|
||||||
if type(v) == bool:
|
|
||||||
return v
|
|
||||||
else:
|
|
||||||
return v.lower() in ("yes", "true", "1")
|
|
||||||
|
|
||||||
|
|
||||||
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected
|
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected
|
||||||
# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
|
# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
|
||||||
def search_recipes(request, queryset, params):
|
def search_recipes(request, queryset, params):
|
||||||
@ -49,7 +38,7 @@ def search_recipes(request, queryset, params):
|
|||||||
search_internal = str2bool(params.get('internal', False))
|
search_internal = str2bool(params.get('internal', False))
|
||||||
search_random = str2bool(params.get('random', False))
|
search_random = str2bool(params.get('random', False))
|
||||||
search_new = str2bool(params.get('new', False))
|
search_new = str2bool(params.get('new', False))
|
||||||
search_last_viewed = int(params.get('last_viewed', 0))
|
search_last_viewed = int(params.get('last_viewed', 0)) # not included in schema currently?
|
||||||
orderby = []
|
orderby = []
|
||||||
|
|
||||||
# only sort by recent not otherwise filtering/sorting
|
# only sort by recent not otherwise filtering/sorting
|
||||||
@ -208,6 +197,7 @@ def search_recipes(request, queryset, params):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: This might be faster https://github.com/django-treebeard/django-treebeard/issues/115
|
||||||
def get_facet(qs=None, request=None, use_cache=True, hash_key=None):
|
def get_facet(qs=None, request=None, use_cache=True, hash_key=None):
|
||||||
"""
|
"""
|
||||||
Gets an annotated list from a queryset.
|
Gets an annotated list from a queryset.
|
||||||
|
40
cookbook/helper/shopping_helper.py
Normal file
40
cookbook/helper/shopping_helper.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
|
from django.db.models import F, OuterRef, Q, Subquery, Value
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||||
|
from cookbook.models import SupermarketCategoryRelation
|
||||||
|
from recipes import settings
|
||||||
|
|
||||||
|
|
||||||
|
def shopping_helper(qs, request):
|
||||||
|
supermarket = request.query_params.get('supermarket', None)
|
||||||
|
checked = request.query_params.get('checked', 'recent')
|
||||||
|
|
||||||
|
supermarket_order = ['food__supermarket_category__name', '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'
|
||||||
|
|
||||||
|
# qs = qs.annotate(supermarket_category=Coalesce(F('food__supermarket_category__name'), Value(_('Undefined'))))
|
||||||
|
# TODO add supermarket to API - order by category order
|
||||||
|
if supermarket:
|
||||||
|
supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category'))
|
||||||
|
qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999)))
|
||||||
|
supermarket_order = ['supermarket_order'] + supermarket_order
|
||||||
|
if checked in ['false', 0, '0']:
|
||||||
|
qs = qs.filter(checked=False)
|
||||||
|
elif checked in ['true', 1, '1']:
|
||||||
|
qs = qs.filter(checked=True)
|
||||||
|
elif checked in ['recent']:
|
||||||
|
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||||
|
# TODO make recent a user setting
|
||||||
|
week_ago = today_start - timedelta(days=7)
|
||||||
|
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
|
||||||
|
supermarket_order = ['checked'] + supermarket_order
|
||||||
|
|
||||||
|
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
|
@ -3,7 +3,7 @@ import json
|
|||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from io import BytesIO, StringIO
|
from io import BytesIO, StringIO
|
||||||
from zipfile import ZipFile, BadZipFile
|
from zipfile import BadZipFile, ZipFile
|
||||||
|
|
||||||
from bs4 import Tag
|
from bs4 import Tag
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
149
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
149
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2021-10-01 20:52
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.models import PermissionModelMixin, ShoppingListEntry
|
||||||
|
|
||||||
|
|
||||||
|
def copy_values_to_sle(apps, schema_editor):
|
||||||
|
with scopes_disabled():
|
||||||
|
entries = ShoppingListEntry.objects.all()
|
||||||
|
for entry in entries:
|
||||||
|
if entry.shoppinglist_set.first():
|
||||||
|
entry.created_by = entry.shoppinglist_set.first().created_by
|
||||||
|
entry.space = entry.shoppinglist_set.first().space
|
||||||
|
if entries:
|
||||||
|
ShoppingListEntry.objects.bulk_update(entries, ["created_by", "space", ])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('cookbook', '0158_userpreference_use_kj'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='on_hand',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='shoppinglistentry',
|
||||||
|
name='completed_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='shoppinglistentry',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='shoppinglistentry',
|
||||||
|
name='created_by',
|
||||||
|
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.user'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='shopping_share',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='shopping_share', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='shoppinglistentry',
|
||||||
|
name='space',
|
||||||
|
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='shoppinglistrecipe',
|
||||||
|
name='mealplan',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.mealplan'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='shoppinglistrecipe',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='shoppinglistentry',
|
||||||
|
name='ingredient',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.ingredient'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='shoppinglistentry',
|
||||||
|
name='unit',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.unit'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='mealplan_autoadd_shopping',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='mealplan_autoexclude_onhand',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='shoppinglistentry',
|
||||||
|
name='list_recipe',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='cookbook.shoppinglistrecipe'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FoodInheritField',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('field', models.CharField(max_length=32, unique=True)),
|
||||||
|
('name', models.CharField(max_length=64, unique=True)),
|
||||||
|
],
|
||||||
|
bases=(models.Model, PermissionModelMixin),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='inherit',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='mealplan_autoinclude_related',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='ignore_inherit',
|
||||||
|
field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='space',
|
||||||
|
name='food_inherit',
|
||||||
|
field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='shoppinglistentry',
|
||||||
|
name='delay_until',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='default_delay',
|
||||||
|
field=models.DecimalField(decimal_places=4, default=4, max_digits=8),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='filter_to_supermarket',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='shopping_recent_days',
|
||||||
|
field=models.PositiveIntegerField(default=7),
|
||||||
|
),
|
||||||
|
migrations.RunPython(copy_values_to_sle),
|
||||||
|
]
|
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal file
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 3.2.7 on 2021-10-01 22:34
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.timezone import utc
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.models import FoodInheritField, ShoppingListEntry
|
||||||
|
|
||||||
|
|
||||||
|
def delete_orphaned_sle(apps, schema_editor):
|
||||||
|
with scopes_disabled():
|
||||||
|
# shopping list entry is orphaned - delete it
|
||||||
|
ShoppingListEntry.objects.filter(shoppinglist=None).delete()
|
||||||
|
|
||||||
|
|
||||||
|
def create_inheritfields(apps, schema_editor):
|
||||||
|
FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category')
|
||||||
|
FoodInheritField.objects.create(name='Ignore Shopping', field='ignore_shopping')
|
||||||
|
FoodInheritField.objects.create(name='Diet', field='diet')
|
||||||
|
FoodInheritField.objects.create(name='Substitute', field='substitute')
|
||||||
|
FoodInheritField.objects.create(name='Substitute Children', field='substitute_children')
|
||||||
|
FoodInheritField.objects.create(name='Substitute Siblings', field='substitute_siblings')
|
||||||
|
|
||||||
|
|
||||||
|
def set_completed_at(apps, schema_editor):
|
||||||
|
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||||
|
# arbitrary - keeping all of the closed shopping list items out of the 'recent' view
|
||||||
|
month_ago = today_start - timedelta(days=30)
|
||||||
|
with scopes_disabled():
|
||||||
|
ShoppingListEntry.objects.filter(checked=True).update(completed_at=month_ago)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('cookbook', '0159_add_shoppinglistentry_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(delete_orphaned_sle),
|
||||||
|
migrations.RunPython(create_inheritfields),
|
||||||
|
migrations.RunPython(set_completed_at),
|
||||||
|
]
|
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal file
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.2.8 on 2021-11-03 23:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('cookbook', '0160_delete_shoppinglist_orphans'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='shoppinglistentry',
|
||||||
|
name='food',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_entries', to='cookbook.food'),
|
||||||
|
),
|
||||||
|
]
|
@ -35,7 +35,20 @@ def get_user_name(self):
|
|||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
|
def get_shopping_share(self):
|
||||||
|
# get list of users that shared shopping list with user. Django ORM forbids this type of query, so raw is required
|
||||||
|
return User.objects.raw(' '.join([
|
||||||
|
'SELECT auth_user.id FROM auth_user',
|
||||||
|
'INNER JOIN cookbook_userpreference',
|
||||||
|
'ON (auth_user.id = cookbook_userpreference.user_id)',
|
||||||
|
'INNER JOIN cookbook_userpreference_shopping_share',
|
||||||
|
'ON (cookbook_userpreference.user_id = cookbook_userpreference_shopping_share.userpreference_id)',
|
||||||
|
'WHERE cookbook_userpreference_shopping_share.user_id ={}'.format(self.id)
|
||||||
|
]))
|
||||||
|
|
||||||
|
|
||||||
auth.models.User.add_to_class('get_user_name', get_user_name)
|
auth.models.User.add_to_class('get_user_name', get_user_name)
|
||||||
|
auth.models.User.add_to_class('get_shopping_share', get_shopping_share)
|
||||||
|
|
||||||
|
|
||||||
def get_model_name(model):
|
def get_model_name(model):
|
||||||
@ -78,6 +91,13 @@ class TreeModel(MP_Node):
|
|||||||
else:
|
else:
|
||||||
return f"{self.name}"
|
return f"{self.name}"
|
||||||
|
|
||||||
|
# MP_Tree move uses raw SQL to execute move, override behavior to force a save triggering post_save signal
|
||||||
|
def move(self, *args, **kwargs):
|
||||||
|
super().move(*args, **kwargs)
|
||||||
|
# treebeard bypasses ORM, need to retrieve the object again to avoid writing previous state back to disk
|
||||||
|
obj = self.__class__.objects.get(id=self.id)
|
||||||
|
obj.save()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parent(self):
|
def parent(self):
|
||||||
parent = self.get_parent()
|
parent = self.get_parent()
|
||||||
@ -124,6 +144,47 @@ class TreeModel(MP_Node):
|
|||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
return super().add_root(**kwargs)
|
return super().add_root(**kwargs)
|
||||||
|
|
||||||
|
# i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
|
||||||
|
def include_descendants(queryset=None, filter=None):
|
||||||
|
"""
|
||||||
|
:param queryset: Model Queryset to add descendants
|
||||||
|
:param filter: Filter (exclude) the descendants nodes with the provided Q filter
|
||||||
|
"""
|
||||||
|
descendants = Q()
|
||||||
|
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
|
||||||
|
nodes = queryset.values('path', 'depth')
|
||||||
|
for node in nodes:
|
||||||
|
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
|
||||||
|
|
||||||
|
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | descendants)
|
||||||
|
|
||||||
|
def exclude_descendants(queryset=None, filter=None):
|
||||||
|
"""
|
||||||
|
:param queryset: Model Queryset to add descendants
|
||||||
|
:param filter: Filter (include) the descendants nodes with the provided Q filter
|
||||||
|
"""
|
||||||
|
descendants = Q()
|
||||||
|
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
|
||||||
|
nodes = queryset.values('path', 'depth')
|
||||||
|
for node in nodes:
|
||||||
|
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
|
||||||
|
|
||||||
|
return queryset.model.objects.filter(id__in=queryset.values_list('id')).exclude(descendants)
|
||||||
|
|
||||||
|
def include_ancestors(queryset=None):
|
||||||
|
"""
|
||||||
|
:param queryset: Model Queryset to add ancestors
|
||||||
|
:param filter: Filter (include) the ancestors nodes with the provided Q filter
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen))
|
||||||
|
nodes = list(set(queryset.values_list('root', 'depth')))
|
||||||
|
|
||||||
|
ancestors = Q()
|
||||||
|
for node in nodes:
|
||||||
|
ancestors |= Q(path__startswith=node[0], depth__lt=node[1])
|
||||||
|
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | ancestors)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
@ -157,6 +218,18 @@ class PermissionModelMixin:
|
|||||||
raise NotImplementedError('get space for method not implemented and standard fields not available')
|
raise NotImplementedError('get space for method not implemented and standard fields not available')
|
||||||
|
|
||||||
|
|
||||||
|
class FoodInheritField(models.Model, PermissionModelMixin):
|
||||||
|
field = models.CharField(max_length=32, unique=True)
|
||||||
|
name = models.CharField(max_length=64, unique=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return _(self.name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_name(self):
|
||||||
|
return _(self.name)
|
||||||
|
|
||||||
|
|
||||||
class Space(ExportModelOperationsMixin('space'), models.Model):
|
class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||||
name = models.CharField(max_length=128, default='Default')
|
name = models.CharField(max_length=128, default='Default')
|
||||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
|
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
|
||||||
@ -167,6 +240,7 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
|||||||
max_users = models.IntegerField(default=0)
|
max_users = models.IntegerField(default=0)
|
||||||
allow_sharing = models.BooleanField(default=True)
|
allow_sharing = models.BooleanField(default=True)
|
||||||
demo = models.BooleanField(default=False)
|
demo = models.BooleanField(default=False)
|
||||||
|
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@ -245,10 +319,18 @@ class UserPreference(models.Model, PermissionModelMixin):
|
|||||||
plan_share = models.ManyToManyField(
|
plan_share = models.ManyToManyField(
|
||||||
User, blank=True, related_name='plan_share_default'
|
User, blank=True, related_name='plan_share_default'
|
||||||
)
|
)
|
||||||
|
shopping_share = models.ManyToManyField(
|
||||||
|
User, blank=True, related_name='shopping_share'
|
||||||
|
)
|
||||||
ingredient_decimals = models.IntegerField(default=2)
|
ingredient_decimals = models.IntegerField(default=2)
|
||||||
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
|
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
|
||||||
shopping_auto_sync = models.IntegerField(default=5)
|
shopping_auto_sync = models.IntegerField(default=5)
|
||||||
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
||||||
|
mealplan_autoadd_shopping = models.BooleanField(default=False)
|
||||||
|
mealplan_autoexclude_onhand = models.BooleanField(default=True)
|
||||||
|
mealplan_autoinclude_related = models.BooleanField(default=True)
|
||||||
|
filter_to_supermarket = models.BooleanField(default=False)
|
||||||
|
default_delay = models.IntegerField(default=4)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
|
||||||
@ -363,8 +445,8 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
|
|||||||
name = models.CharField(max_length=64)
|
name = models.CharField(max_length=64)
|
||||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||||
description = models.TextField(default="", blank=True)
|
description = models.TextField(default="", blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True) # TODO deprecate
|
||||||
|
|
||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||||
@ -393,6 +475,10 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
|||||||
|
|
||||||
|
|
||||||
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||||
|
# exclude fields not implemented yet
|
||||||
|
inherit_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings'])
|
||||||
|
|
||||||
|
# WARNING: Food inheritance relies on post_save signals, avoid using UPDATE to update Food objects unless you intend to bypass those signals
|
||||||
if SORT_TREE_BY_NAME:
|
if SORT_TREE_BY_NAME:
|
||||||
node_order_by = ['name']
|
node_order_by = ['name']
|
||||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||||
@ -400,6 +486,9 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
|||||||
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
|
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
|
||||||
ignore_shopping = models.BooleanField(default=False)
|
ignore_shopping = models.BooleanField(default=False)
|
||||||
description = models.TextField(default='', blank=True)
|
description = models.TextField(default='', blank=True)
|
||||||
|
on_hand = models.BooleanField(default=False)
|
||||||
|
inherit = models.BooleanField(default=False)
|
||||||
|
ignore_inherit = models.ManyToManyField(FoodInheritField, blank=True) # is this better as inherit instead of ignore inherit? which is more intuitive?
|
||||||
|
|
||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||||
@ -413,6 +502,38 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
|||||||
else:
|
else:
|
||||||
return super().delete()
|
return super().delete()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reset_inheritance(space=None):
|
||||||
|
inherit = space.food_inherit.all()
|
||||||
|
ignore_inherit = Food.inherit_fields.difference(inherit)
|
||||||
|
|
||||||
|
# food is going to inherit attributes
|
||||||
|
if space.food_inherit.all().count() > 0:
|
||||||
|
# using update to avoid creating a N*depth! save signals
|
||||||
|
Food.objects.filter(space=space).update(inherit=True)
|
||||||
|
# ManyToMany cannot be updated through an UPDATE operation
|
||||||
|
Through = Food.objects.first().ignore_inherit.through
|
||||||
|
Through.objects.all().delete()
|
||||||
|
for i in ignore_inherit:
|
||||||
|
Through.objects.bulk_create([
|
||||||
|
Through(food_id=x, foodinheritfield_id=i.id)
|
||||||
|
for x in Food.objects.filter(space=space).values_list('id', flat=True)
|
||||||
|
])
|
||||||
|
|
||||||
|
inherit = inherit.values_list('field', flat=True)
|
||||||
|
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, 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
|
||||||
|
category_roots = Food.exclude_descendants(queryset=Food.objects.filter(supermarket_category__isnull=False, numchild__gt=0, space=space))
|
||||||
|
for root in category_roots:
|
||||||
|
root.get_descendants().update(supermarket_category=root.supermarket_category)
|
||||||
|
else: # food is not going to inherit any attributes
|
||||||
|
Food.objects.filter(space=space).update(inherit=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space')
|
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space')
|
||||||
@ -534,6 +655,21 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_related_recipes(self, levels=1):
|
||||||
|
# recipes for step recipe
|
||||||
|
step_recipes = Q(id__in=self.steps.exclude(step_recipe=None).values_list('step_recipe'))
|
||||||
|
# recipes for foods
|
||||||
|
food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe=self).exclude(recipe=None).values_list('recipe'))
|
||||||
|
related_recipes = Recipe.objects.filter(step_recipes | food_recipes)
|
||||||
|
if levels == 1:
|
||||||
|
return related_recipes
|
||||||
|
|
||||||
|
# this can loop over multiple levels if you update the value of related_recipes at each step (maybe an array?)
|
||||||
|
# for now keeping it at 2 levels max, should be sufficient in 99.9% of scenarios
|
||||||
|
sub_step_recipes = Q(id__in=Step.objects.filter(recipe__in=related_recipes.values_list('steps')).exclude(step_recipe=None).values_list('step_recipe'))
|
||||||
|
sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe'))
|
||||||
|
return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes)
|
||||||
|
|
||||||
class Meta():
|
class Meta():
|
||||||
indexes = (
|
indexes = (
|
||||||
GinIndex(fields=["name_search_vector"]),
|
GinIndex(fields=["name_search_vector"]),
|
||||||
@ -552,7 +688,7 @@ class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionMod
|
|||||||
|
|
||||||
objects = ScopedManager(space='recipe__space')
|
objects = ScopedManager(space='recipe__space')
|
||||||
|
|
||||||
@staticmethod
|
@ staticmethod
|
||||||
def get_space_key():
|
def get_space_key():
|
||||||
return 'recipe', 'space'
|
return 'recipe', 'space'
|
||||||
|
|
||||||
@ -600,7 +736,7 @@ class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, Pe
|
|||||||
|
|
||||||
objects = ScopedManager(space='book__space')
|
objects = ScopedManager(space='book__space')
|
||||||
|
|
||||||
@staticmethod
|
@ staticmethod
|
||||||
def get_space_key():
|
def get_space_key():
|
||||||
return 'book', 'space'
|
return 'book', 'space'
|
||||||
|
|
||||||
@ -647,6 +783,18 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
|||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
objects = ScopedManager(space='space')
|
objects = ScopedManager(space='space')
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
if self.get_owner().userpreference.mealplan_autoadd_shopping:
|
||||||
|
kwargs = {
|
||||||
|
'mealplan': self,
|
||||||
|
'space': self.space,
|
||||||
|
'created_by': self.get_owner()
|
||||||
|
}
|
||||||
|
if self.get_owner().userpreference.mealplan_autoexclude_onhand:
|
||||||
|
kwargs['ingredients'] = Ingredient.objects.filter(step__recipe=self.recipe, food__on_hand=False, space=self.space).values_list('id', flat=True)
|
||||||
|
ShoppingListEntry.list_from_recipe(**kwargs)
|
||||||
|
|
||||||
def get_label(self):
|
def get_label(self):
|
||||||
if self.title:
|
if self.title:
|
||||||
return self.title
|
return self.title
|
||||||
@ -660,12 +808,14 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
|||||||
|
|
||||||
|
|
||||||
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
|
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
|
||||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True)
|
name = models.CharField(max_length=32, blank=True, default='')
|
||||||
|
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) # TODO make required after old shoppinglist deprecated
|
||||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||||
|
mealplan = models.ForeignKey(MealPlan, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
|
||||||
objects = ScopedManager(space='recipe__space')
|
objects = ScopedManager(space='recipe__space')
|
||||||
|
|
||||||
@staticmethod
|
@ staticmethod
|
||||||
def get_space_key():
|
def get_space_key():
|
||||||
return 'recipe', 'space'
|
return 'recipe', 'space'
|
||||||
|
|
||||||
@ -677,22 +827,101 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
|
|||||||
|
|
||||||
def get_owner(self):
|
def get_owner(self):
|
||||||
try:
|
try:
|
||||||
return self.shoppinglist_set.first().created_by
|
return self.entries.first().created_by or self.shoppinglist_set.first().created_by
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
|
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
|
||||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True)
|
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
|
||||||
food = models.ForeignKey(Food, on_delete=models.CASCADE)
|
food = models.ForeignKey(Food, on_delete=models.CASCADE)
|
||||||
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True)
|
unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||||
order = models.IntegerField(default=0)
|
order = models.IntegerField(default=0)
|
||||||
checked = models.BooleanField(default=False)
|
checked = models.BooleanField(default=False)
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
delay_until = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
objects = ScopedManager(space='shoppinglist__space')
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
|
objects = ScopedManager(space='space')
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
|
@atomic
|
||||||
|
def list_from_recipe(self, list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None):
|
||||||
|
"""
|
||||||
|
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
||||||
|
:param list_recipe: Modify an existing ShoppingListRecipe
|
||||||
|
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
||||||
|
:param mealplan: alternatively use a mealplan recipe as source of ingredients
|
||||||
|
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
||||||
|
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
||||||
|
"""
|
||||||
|
# TODO cascade to related recipes
|
||||||
|
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
||||||
|
if not r:
|
||||||
|
raise ValueError(_("You must supply a recipe or mealplan"))
|
||||||
|
|
||||||
|
created_by = created_by or getattr(mealplan, 'created_by', None) or getattr(list_recipe, 'created_by', None)
|
||||||
|
if not created_by:
|
||||||
|
raise ValueError(_("You must supply a created_by"))
|
||||||
|
|
||||||
|
if type(servings) not in [int, float]:
|
||||||
|
servings = getattr(mealplan, 'servings', 1.0)
|
||||||
|
|
||||||
|
shared_users = list(created_by.get_shopping_share())
|
||||||
|
shared_users.append(created_by)
|
||||||
|
if list_recipe:
|
||||||
|
created = False
|
||||||
|
else:
|
||||||
|
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
||||||
|
created = True
|
||||||
|
|
||||||
|
if servings == 0 and not created:
|
||||||
|
list_recipe.delete()
|
||||||
|
return []
|
||||||
|
elif ingredients:
|
||||||
|
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
||||||
|
else:
|
||||||
|
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
|
||||||
|
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||||
|
# delete shopping list entries not included in ingredients
|
||||||
|
existing_list.exclude(ingredient__in=ingredients).delete()
|
||||||
|
# add shopping list entries that did not previously exist
|
||||||
|
add_ingredients = set(ingredients.values_list('id', flat=True)) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||||
|
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||||
|
|
||||||
|
# if servings have changed, update the ShoppingListRecipe and existing Entrys
|
||||||
|
if servings <= 0:
|
||||||
|
servings = 1
|
||||||
|
servings_factor = servings / r.servings
|
||||||
|
if not created and list_recipe.servings != servings:
|
||||||
|
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
||||||
|
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
||||||
|
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||||
|
sle.save()
|
||||||
|
|
||||||
|
# add any missing Entrys
|
||||||
|
shoppinglist = [
|
||||||
|
ShoppingListEntry(
|
||||||
|
list_recipe=list_recipe,
|
||||||
|
food=i.food,
|
||||||
|
unit=i.unit,
|
||||||
|
ingredient=i,
|
||||||
|
amount=i.amount * Decimal(servings_factor),
|
||||||
|
created_by=created_by,
|
||||||
|
space=space
|
||||||
|
)
|
||||||
|
for i in [x for x in add_ingredients if not x.food.ignore_shopping]
|
||||||
|
]
|
||||||
|
ShoppingListEntry.objects.bulk_create(shoppinglist)
|
||||||
|
# return all shopping list items
|
||||||
|
print('end of servings')
|
||||||
|
return ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||||
|
|
||||||
|
@ staticmethod
|
||||||
def get_space_key():
|
def get_space_key():
|
||||||
return 'shoppinglist', 'space'
|
return 'shoppinglist', 'space'
|
||||||
|
|
||||||
@ -702,12 +931,14 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'Shopping list entry {self.id}'
|
return f'Shopping list entry {self.id}'
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
def get_shared(self):
|
def get_shared(self):
|
||||||
return self.shoppinglist_set.first().shared.all()
|
return self.shoppinglist_set.first().shared.all()
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
def get_owner(self):
|
def get_owner(self):
|
||||||
try:
|
try:
|
||||||
return self.shoppinglist_set.first().created_by
|
return self.created_by or self.shoppinglist_set.first().created_by
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -863,7 +1094,7 @@ class SearchFields(models.Model, PermissionModelMixin):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return _(self.name)
|
return _(self.name)
|
||||||
|
|
||||||
@staticmethod
|
@ staticmethod
|
||||||
def get_name(self):
|
def get_name(self):
|
||||||
return _(self.name)
|
return _(self.name)
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ from decimal import Decimal
|
|||||||
from gettext import gettext as _
|
from gettext import gettext as _
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Avg, QuerySet, Sum
|
from django.db.models import Avg, QuerySet, Sum
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -11,12 +12,13 @@ from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import NotFound, ValidationError
|
from rest_framework.exceptions import NotFound, ValidationError
|
||||||
|
|
||||||
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, ImportLog,
|
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
|
||||||
Ingredient, Keyword, MealPlan, MealType, NutritionInformation, Recipe,
|
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
|
||||||
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink, ShoppingList,
|
NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
|
||||||
ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket,
|
RecipeImport, ShareLink, ShoppingList, ShoppingListEntry,
|
||||||
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit,
|
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
|
||||||
UserFile, UserPreference, ViewLog)
|
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
|
||||||
|
UserPreference, ViewLog)
|
||||||
from cookbook.templatetags.custom_tags import markdown
|
from cookbook.templatetags.custom_tags import markdown
|
||||||
|
|
||||||
|
|
||||||
@ -61,7 +63,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
|||||||
# probably not a tree
|
# probably not a tree
|
||||||
pass
|
pass
|
||||||
if recipes.count() != 0:
|
if recipes.count() != 0:
|
||||||
return random.choice(recipes).image.url
|
return recipes.order_by('?')[:1][0].image.url
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -78,7 +80,7 @@ class CustomDecimalField(serializers.Field):
|
|||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
if not isinstance(value, Decimal):
|
if not isinstance(value, Decimal):
|
||||||
value = Decimal(value)
|
value = Decimal(value)
|
||||||
return round(value, 2).normalize()
|
return round(value, 3).normalize()
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
if type(data) == int or type(data) == float:
|
if type(data) == int or type(data) == float:
|
||||||
@ -136,8 +138,27 @@ class UserNameSerializer(WritableNestedModelSerializer):
|
|||||||
fields = ('id', 'username')
|
fields = ('id', 'username')
|
||||||
|
|
||||||
|
|
||||||
|
class FoodInheritFieldSerializer(UniqueFieldsMixin):
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
# don't allow writing to FoodInheritField via API
|
||||||
|
return FoodInheritField.objects.get(**validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
# don't allow writing to FoodInheritField via API
|
||||||
|
return FoodInheritField.objects.get(**validated_data)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FoodInheritField
|
||||||
|
fields = ['id', 'name', 'field', ]
|
||||||
|
|
||||||
|
|
||||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||||
plan_share = UserNameSerializer(many=True, read_only=True)
|
# food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', read_only=True)
|
||||||
|
food_ignore_default = serializers.SerializerMethodField('get_ignore_default')
|
||||||
|
|
||||||
|
def get_ignore_default(self, obj):
|
||||||
|
return FoodInheritFieldSerializer(Food.inherit_fields.difference(obj.space.food_inherit.all()), many=True).data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if validated_data['user'] != self.context['request'].user:
|
if validated_data['user'] != self.context['request'].user:
|
||||||
@ -149,7 +170,8 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
|
|||||||
fields = (
|
fields = (
|
||||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page',
|
'user', 'theme', 'nav_color', 'default_unit', 'default_page',
|
||||||
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
|
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
|
||||||
'comments'
|
'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_ignore_default', 'default_delay',
|
||||||
|
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -255,25 +277,11 @@ class KeywordLabelSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||||
label = serializers.SerializerMethodField('get_label')
|
label = serializers.SerializerMethodField('get_label')
|
||||||
# image = serializers.SerializerMethodField('get_image')
|
|
||||||
# numrecipe = serializers.SerializerMethodField('count_recipes')
|
|
||||||
recipe_filter = 'keywords'
|
recipe_filter = 'keywords'
|
||||||
|
|
||||||
def get_label(self, obj):
|
def get_label(self, obj):
|
||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
# def get_image(self, obj):
|
|
||||||
# recipes = obj.recipe_set.all().filter(space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
|
||||||
# if recipes.count() == 0 and obj.has_children():
|
|
||||||
# recipes = Recipe.objects.filter(keywords__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
|
|
||||||
# if recipes.count() != 0:
|
|
||||||
# return random.choice(recipes).image.url
|
|
||||||
# else:
|
|
||||||
# return None
|
|
||||||
|
|
||||||
# def count_recipes(self, obj):
|
|
||||||
# return obj.recipe_set.filter(space=self.context['request'].space).all().count()
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
# since multi select tags dont have id's
|
# since multi select tags dont have id's
|
||||||
# duplicate names might be routed to create
|
# duplicate names might be routed to create
|
||||||
@ -285,27 +293,14 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Keyword
|
model = Keyword
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at',
|
'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe')
|
||||||
'updated_at')
|
read_only_fields = ('id', 'label', 'image', 'parent', 'numchild', 'numrecipe')
|
||||||
read_only_fields = ('id', 'numchild', 'parent', 'image')
|
|
||||||
|
|
||||||
|
|
||||||
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||||
# image = serializers.SerializerMethodField('get_image')
|
|
||||||
# numrecipe = serializers.SerializerMethodField('count_recipes')
|
|
||||||
recipe_filter = 'steps__ingredients__unit'
|
recipe_filter = 'steps__ingredients__unit'
|
||||||
|
|
||||||
# def get_image(self, obj):
|
|
||||||
# recipes = Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
|
||||||
|
|
||||||
# if recipes.count() != 0:
|
|
||||||
# return random.choice(recipes).image.url
|
|
||||||
# else:
|
|
||||||
# return None
|
|
||||||
|
|
||||||
# def count_recipes(self, obj):
|
|
||||||
# return Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).count()
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
validated_data['name'] = validated_data['name'].strip()
|
validated_data['name'] = validated_data['name'].strip()
|
||||||
validated_data['space'] = self.context['request'].space
|
validated_data['space'] = self.context['request'].space
|
||||||
@ -369,27 +364,13 @@ class RecipeSimpleSerializer(serializers.ModelSerializer):
|
|||||||
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
|
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||||
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
|
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
|
||||||
recipe = RecipeSimpleSerializer(allow_null=True, required=False)
|
recipe = RecipeSimpleSerializer(allow_null=True, required=False)
|
||||||
# image = serializers.SerializerMethodField('get_image')
|
shopping = serializers.SerializerMethodField('get_shopping_status')
|
||||||
# numrecipe = serializers.SerializerMethodField('count_recipes')
|
ignore_inherit = FoodInheritFieldSerializer(many=True)
|
||||||
|
|
||||||
recipe_filter = 'steps__ingredients__food'
|
recipe_filter = 'steps__ingredients__food'
|
||||||
|
|
||||||
# def get_image(self, obj):
|
def get_shopping_status(self, obj):
|
||||||
# if obj.recipe and obj.space == obj.recipe.space:
|
return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
|
||||||
# if obj.recipe.image and obj.recipe.image != '':
|
|
||||||
# return obj.recipe.image.url
|
|
||||||
# # if food is not also a recipe, look for recipe images that use the food
|
|
||||||
# recipes = Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
|
||||||
# # if no recipes found - check whole tree
|
|
||||||
# if recipes.count() == 0 and obj.has_children():
|
|
||||||
# recipes = Recipe.objects.filter(steps__ingredients__food__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
|
||||||
|
|
||||||
# if recipes.count() != 0:
|
|
||||||
# return random.choice(recipes).image.url
|
|
||||||
# else:
|
|
||||||
# return None
|
|
||||||
|
|
||||||
# def count_recipes(self, obj):
|
|
||||||
# return Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).count()
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
validated_data['name'] = validated_data['name'].strip()
|
validated_data['name'] = validated_data['name'].strip()
|
||||||
@ -403,16 +384,17 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
validated_data['name'] = validated_data['name'].strip()
|
if name := validated_data.get('name', None):
|
||||||
|
validated_data['name'] = name.strip()
|
||||||
return super(FoodSerializer, self).update(instance, validated_data)
|
return super(FoodSerializer, self).update(instance, validated_data)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Food
|
model = Food
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent',
|
'id', 'name', 'description', 'shopping', 'recipe', 'ignore_shopping', 'supermarket_category',
|
||||||
'numchild',
|
'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit',
|
||||||
'numrecipe')
|
)
|
||||||
read_only_fields = ('id', 'numchild', 'parent', 'image')
|
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
||||||
|
|
||||||
|
|
||||||
class IngredientSerializer(WritableNestedModelSerializer):
|
class IngredientSerializer(WritableNestedModelSerializer):
|
||||||
@ -559,6 +541,9 @@ class RecipeSerializer(RecipeBaseSerializer):
|
|||||||
validated_data['space'] = self.context['request'].space
|
validated_data['space'] = self.context['request'].space
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class RecipeImageSerializer(WritableNestedModelSerializer):
|
class RecipeImageSerializer(WritableNestedModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -628,7 +613,10 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
|||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
validated_data['created_by'] = self.context['request'].user
|
validated_data['created_by'] = self.context['request'].user
|
||||||
return super().create(validated_data)
|
mealplan = super().create(validated_data)
|
||||||
|
if self.context['request'].data.get('addshopping', False):
|
||||||
|
ShoppingListEntry.list_from_recipe(mealplan=mealplan, space=validated_data['space'], created_by=validated_data['created_by'])
|
||||||
|
return mealplan
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MealPlan
|
model = MealPlan
|
||||||
@ -640,34 +628,98 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
|||||||
read_only_fields = ('created_by',)
|
read_only_fields = ('created_by',)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||||
|
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
|
||||||
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
||||||
|
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
|
||||||
servings = CustomDecimalField()
|
servings = CustomDecimalField()
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
if not isinstance(value := obj.servings, Decimal):
|
||||||
|
value = Decimal(value)
|
||||||
|
value = value.quantize(Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero
|
||||||
|
return (
|
||||||
|
obj.name
|
||||||
|
or getattr(obj.mealplan, 'title', None)
|
||||||
|
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
|
||||||
|
or obj.recipe.name
|
||||||
|
) + f' ({value:.2g})'
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
if 'servings' in validated_data:
|
||||||
|
ShoppingListEntry.list_from_recipe(
|
||||||
|
list_recipe=instance,
|
||||||
|
servings=validated_data['servings'],
|
||||||
|
created_by=self.context['request'].user,
|
||||||
|
space=self.context['request'].space
|
||||||
|
)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ShoppingListRecipe
|
model = ShoppingListRecipe
|
||||||
fields = ('id', 'recipe', 'recipe_name', 'servings')
|
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
|
||||||
read_only_fields = ('id',)
|
read_only_fields = ('id',)
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||||
food = FoodSerializer(allow_null=True)
|
food = FoodSerializer(allow_null=True)
|
||||||
unit = UnitSerializer(allow_null=True, required=False)
|
unit = UnitSerializer(allow_null=True, required=False)
|
||||||
|
ingredient_note = serializers.ReadOnlyField(source='ingredient.note')
|
||||||
|
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
|
||||||
amount = CustomDecimalField()
|
amount = CustomDecimalField()
|
||||||
|
created_by = UserNameSerializer(read_only=True)
|
||||||
|
completed_at = serializers.DateTimeField(allow_null=True)
|
||||||
|
|
||||||
|
def get_fields(self, *args, **kwargs):
|
||||||
|
fields = super().get_fields(*args, **kwargs)
|
||||||
|
|
||||||
|
# autosync values are only needed for frequent 'checked' value updating
|
||||||
|
if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))):
|
||||||
|
for f in list(set(fields) - set(['id', 'checked'])):
|
||||||
|
del fields[f]
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def run_validation(self, data):
|
||||||
|
if (
|
||||||
|
data.get('checked', False)
|
||||||
|
and self.root.instance
|
||||||
|
and not self.root.instance.checked
|
||||||
|
):
|
||||||
|
# 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
|
||||||
|
else:
|
||||||
|
# otherwise don't write anything
|
||||||
|
if 'completed_at' in data:
|
||||||
|
del data['completed_at']
|
||||||
|
|
||||||
|
return super().run_validation(data)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['space'] = self.context['request'].space
|
||||||
|
validated_data['created_by'] = self.context['request'].user
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ShoppingListEntry
|
model = ShoppingListEntry
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked'
|
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan',
|
||||||
|
'created_by', 'created_at', 'completed_at', 'delay_until'
|
||||||
)
|
)
|
||||||
|
read_only_fields = ('id', 'created_by', 'created_at',)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
|
class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ShoppingListEntry
|
model = ShoppingListEntry
|
||||||
fields = ('id', 'checked')
|
fields = ('id', 'checked')
|
||||||
|
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
class ShoppingListSerializer(WritableNestedModelSerializer):
|
class ShoppingListSerializer(WritableNestedModelSerializer):
|
||||||
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
|
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
|
||||||
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
|
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
|
||||||
@ -688,6 +740,7 @@ class ShoppingListSerializer(WritableNestedModelSerializer):
|
|||||||
read_only_fields = ('id', 'created_by',)
|
read_only_fields = ('id', 'created_by',)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
|
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
|
||||||
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
|
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
|
||||||
|
|
||||||
@ -802,7 +855,7 @@ class FoodExportSerializer(FoodSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Food
|
model = Food
|
||||||
fields = ('name', 'ignore_shopping', 'supermarket_category')
|
fields = ('name', 'ignore_shopping', 'supermarket_category', 'on_hand')
|
||||||
|
|
||||||
|
|
||||||
class IngredientExportSerializer(WritableNestedModelSerializer):
|
class IngredientExportSerializer(WritableNestedModelSerializer):
|
||||||
@ -847,3 +900,24 @@ class RecipeExportSerializer(WritableNestedModelSerializer):
|
|||||||
validated_data['created_by'] = self.context['request'].user
|
validated_data['created_by'] = self.context['request'].user
|
||||||
validated_data['space'] = self.context['request'].space
|
validated_data['space'] = self.context['request'].space
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
|
||||||
|
list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Existing shopping list to update"))
|
||||||
|
ingredients = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_(
|
||||||
|
"List of ingredient IDs from the recipe to add, if not provided all ingredients will be added."))
|
||||||
|
servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_("Providing a list_recipe ID and servings of 0 will delete that shopping list."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Recipe
|
||||||
|
fields = ['id', 'list_recipe', 'ingredients', 'servings', ]
|
||||||
|
|
||||||
|
|
||||||
|
class FoodShoppingUpdateSerializer(serializers.ModelSerializer):
|
||||||
|
amount = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Amount of food to add to the shopping list"))
|
||||||
|
unit = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("ID of unit to use for the shopping list"))
|
||||||
|
delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True, help_text=_("When set to true will delete all food from active shopping lists."))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Recipe
|
||||||
|
fields = ['id', 'amount', 'unit', 'delete', ]
|
||||||
|
@ -1,47 +1,80 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
from django.contrib.postgres.search import SearchVector
|
from django.contrib.postgres.search import SearchVector
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
|
|
||||||
from cookbook.models import Recipe, Step
|
|
||||||
from cookbook.managers import DICTIONARY
|
from cookbook.managers import DICTIONARY
|
||||||
|
from cookbook.models import Food, FoodInheritField, Recipe, Step
|
||||||
|
|
||||||
|
|
||||||
|
# wraps a signal with the ability to set 'skip_signal' to avoid creating recursive signals
|
||||||
|
def skip_signal(signal_func):
|
||||||
|
@wraps(signal_func)
|
||||||
|
def _decorator(sender, instance, **kwargs):
|
||||||
|
if not instance:
|
||||||
|
return None
|
||||||
|
if hasattr(instance, 'skip_signal'):
|
||||||
|
return None
|
||||||
|
return signal_func(sender, instance, **kwargs)
|
||||||
|
return _decorator
|
||||||
|
|
||||||
|
|
||||||
# TODO there is probably a way to generalize this
|
# TODO there is probably a way to generalize this
|
||||||
@receiver(post_save, sender=Recipe)
|
@receiver(post_save, sender=Recipe)
|
||||||
|
@skip_signal
|
||||||
def update_recipe_search_vector(sender, instance=None, created=False, **kwargs):
|
def update_recipe_search_vector(sender, instance=None, created=False, **kwargs):
|
||||||
if not instance:
|
|
||||||
return
|
|
||||||
|
|
||||||
# needed to ensure search vector update doesn't trigger recursion
|
|
||||||
if hasattr(instance, '_dirty'):
|
|
||||||
return
|
|
||||||
|
|
||||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||||
instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language)
|
instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language)
|
||||||
instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language)
|
instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
instance._dirty = True
|
instance.skip_signal = True
|
||||||
instance.save()
|
instance.save()
|
||||||
finally:
|
finally:
|
||||||
del instance._dirty
|
del instance.skip_signal
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Step)
|
@receiver(post_save, sender=Step)
|
||||||
|
@skip_signal
|
||||||
def update_step_search_vector(sender, instance=None, created=False, **kwargs):
|
def update_step_search_vector(sender, instance=None, created=False, **kwargs):
|
||||||
|
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||||
|
instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language)
|
||||||
|
try:
|
||||||
|
instance.skip_signal = True
|
||||||
|
instance.save()
|
||||||
|
finally:
|
||||||
|
del instance.skip_signal
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Food)
|
||||||
|
@skip_signal
|
||||||
|
def update_food_inheritance(sender, instance=None, created=False, **kwargs):
|
||||||
if not instance:
|
if not instance:
|
||||||
return
|
return
|
||||||
|
|
||||||
# needed to ensure search vector update doesn't trigger recursion
|
inherit = Food.inherit_fields.difference(instance.ignore_inherit.all())
|
||||||
if hasattr(instance, '_dirty'):
|
# nothing to apply from parent and nothing to apply to children
|
||||||
|
if (not instance.inherit or not instance.parent or inherit.count() == 0) and instance.numchild == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
inherit = inherit.values_list('field', flat=True)
|
||||||
instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language)
|
# apply changes from parent to instance for each inheritted field
|
||||||
|
if instance.inherit and instance.parent and inherit.count() > 0:
|
||||||
|
parent = instance.get_parent()
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
instance.skip_signal = True
|
||||||
|
instance.save()
|
||||||
|
finally:
|
||||||
|
del instance.skip_signal
|
||||||
|
|
||||||
try:
|
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
|
||||||
instance._dirty = True
|
instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='ignore_shopping').update(ignore_shopping=instance.ignore_shopping)
|
||||||
instance.save()
|
# don't cascade empty supermarket category
|
||||||
finally:
|
if instance.supermarket_category:
|
||||||
del instance._dirty
|
instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='supermarket_category').update(supermarket_category=instance.supermarket_category)
|
||||||
|
@ -339,10 +339,10 @@
|
|||||||
{% user_prefs request as prefs%}
|
{% user_prefs request as prefs%}
|
||||||
{{ prefs|json_script:'user_preference' }}
|
{{ prefs|json_script:'user_preference' }}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
|
|
||||||
{% endblock script %}
|
{% endblock script %}
|
||||||
|
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load render_bundle from webpack_loader %}
|
|
||||||
{% load static %}
|
|
||||||
{% load i18n %}
|
|
||||||
{% comment %} {% load l10n %} {% endcomment %}
|
|
||||||
{% block title %}{{ title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content_fluid %}
|
|
||||||
|
|
||||||
<div id="app" >
|
|
||||||
<checklist-view></checklist-view>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
{{ config | json_script:"model_config" }}
|
|
||||||
|
|
||||||
{% if debug %}
|
|
||||||
<script src="{% url 'js_reverse' %}"></script>
|
|
||||||
{% else %}
|
|
||||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<script type="application/javascript">
|
|
||||||
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% render_bundle 'checklist_view' %}
|
|
||||||
{% endblock %}
|
|
@ -18,12 +18,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
<span class="col col-md-9">
|
||||||
{% if create_url %}
|
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
||||||
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
{% if create_url %}
|
||||||
|
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
</span>
|
||||||
|
{% if request.resolver_match.url_name in 'list_shopping_list' %}
|
||||||
|
<span class="col-md-3">
|
||||||
|
<a href="{% url 'view_shopping_new' %}" class="float-right">
|
||||||
|
<button class="btn btn-outline-secondary shadow-none">
|
||||||
|
<i class="fas fa-star"></i> {% trans 'Try the new shopping list' %}
|
||||||
|
</button>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
</span>
|
||||||
</h3>
|
{% endif %}
|
||||||
|
|
||||||
{% if filter %}
|
{% if filter %}
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -48,6 +48,13 @@
|
|||||||
aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}">
|
aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}">
|
||||||
{% trans 'Search-Settings' %}</a>
|
{% trans 'Search-Settings' %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<a class="nav-link {% if active_tab == 'shopping' %} active {% endif %}" id="shopping-tab" data-toggle="tab"
|
||||||
|
href="#shopping" role="tab"
|
||||||
|
aria-controls="search"
|
||||||
|
aria-selected="{% if active_tab == 'shopping' %} 'true' {% else %} 'false' {% endif %}">
|
||||||
|
{% trans 'Shopping-Settings' %}</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@ -195,6 +202,17 @@
|
|||||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tab-pane {% if active_tab == 'shopping' %} active {% endif %}" id="shopping" role="tabpanel"
|
||||||
|
aria-labelledby="shopping-tab">
|
||||||
|
<h4>{% trans 'Shopping Settings' %}</h4>
|
||||||
|
|
||||||
|
<form action="./#shopping" method="post" id="id_shopping_form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ shopping_form|crispy }}
|
||||||
|
<button class="btn btn-success" type="submit" name="shopping_form" id="shopping_form_button"><i
|
||||||
|
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -224,5 +242,26 @@
|
|||||||
$('.nav-tabs a').on('shown.bs.tab', function (e) {
|
$('.nav-tabs a').on('shown.bs.tab', function (e) {
|
||||||
window.location.hash = e.target.hash;
|
window.location.hash = e.target.hash;
|
||||||
})
|
})
|
||||||
|
// listen for events
|
||||||
|
$(document).ready(function(){
|
||||||
|
hideShow()
|
||||||
|
// call hideShow when the user clicks on the mealplan_autoadd checkbox
|
||||||
|
$("#id_shopping-mealplan_autoadd_shopping").click(function(event){
|
||||||
|
hideShow()
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
function hideShow(){
|
||||||
|
if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true)
|
||||||
|
{
|
||||||
|
$('#div_id_shopping-mealplan_autoexclude_onhand').show();
|
||||||
|
$('#div_id_shopping-mealplan_autoinclude_related').show();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$('#div_id_shopping-mealplan_autoexclude_onhand').hide();
|
||||||
|
$('#div_id_shopping-mealplan_autoinclude_related').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -655,6 +655,7 @@
|
|||||||
if (this.shopping_list.entries.length === 0) {
|
if (this.shopping_list.entries.length === 0) {
|
||||||
this.edit_mode = true
|
this.edit_mode = true
|
||||||
}
|
}
|
||||||
|
console.log(response.data)
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.log(err)
|
console.log(err)
|
||||||
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
|
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
|
||||||
|
17
cookbook/templates/shoppinglist_template.html
Normal file
17
cookbook/templates/shoppinglist_template.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %} {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} {% block title %} {{ title }} {% endblock %} {% block content_fluid %}
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<shopping-list-view></shopping-list-view>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block script %} {% if debug %}
|
||||||
|
<script src="{% url 'js_reverse' %}"></script>
|
||||||
|
{% else %}
|
||||||
|
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script type="application/javascript">
|
||||||
|
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% render_bundle 'shopping_list_view' %} {% endblock %}
|
@ -1,165 +1,188 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load django_tables2 %}
|
{% load django_tables2 %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
{% load crispy_forms_filters %}
|
{% load crispy_forms_filters %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}{% trans "Space Settings" %}{% endblock %}
|
{%block title %} {% trans "Space Settings" %} {% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{{ form.media }}
|
{{ form.media }}
|
||||||
|
{{ space_form.media }}
|
||||||
{% include 'include/vue_base.html' %}
|
{% include 'include/vue_base.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
|
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h3><span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }} <small>{% if HOSTED %}
|
<h3>
|
||||||
<a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small></h3>
|
<span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }}
|
||||||
|
<small>{% if HOSTED %} <a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<br/>
|
<br />
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">{% trans 'Number of objects' %}</div>
|
||||||
{% trans 'Number of objects' %}
|
<ul class="list-group list-group-flush">
|
||||||
</div>
|
<li class="list-group-item">
|
||||||
<ul class="list-group list-group-flush">
|
{% trans 'Recipes' %} :
|
||||||
<li class="list-group-item">{% trans 'Recipes' %} : <span
|
<span class="badge badge-pill badge-info"
|
||||||
class="badge badge-pill badge-info">{{ counts.recipes }} /
|
>{{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{%
|
||||||
{% if request.space.max_recipes > 0 %}
|
else %}∞{% endif %}</span
|
||||||
{{ request.space.max_recipes }}{% else %}∞{% endif %}</span></li>
|
>
|
||||||
<li class="list-group-item">{% trans 'Keywords' %} : <span
|
</li>
|
||||||
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li>
|
<li class="list-group-item">
|
||||||
<li class="list-group-item">{% trans 'Units' %} : <span
|
{% trans 'Keywords' %} : <span class="badge badge-pill badge-info">{{ counts.keywords }}</span>
|
||||||
class="badge badge-pill badge-info">{{ counts.units }}</span></li>
|
</li>
|
||||||
<li class="list-group-item">{% trans 'Ingredients' %} : <span
|
<li class="list-group-item">
|
||||||
class="badge badge-pill badge-info">{{ counts.ingredients }}</span></li>
|
{% trans 'Units' %} : <span class="badge badge-pill badge-info">{{ counts.units }}</span>
|
||||||
<li class="list-group-item">{% trans 'Recipe Imports' %} : <span
|
</li>
|
||||||
class="badge badge-pill badge-info">{{ counts.recipe_import }}</span></li>
|
<li class="list-group-item">
|
||||||
</ul>
|
{% trans 'Ingredients' %} :
|
||||||
</div>
|
<span class="badge badge-pill badge-info">{{ counts.ingredients }}</span>
|
||||||
</div>
|
</li>
|
||||||
<div class="col-md-6">
|
<li class="list-group-item">
|
||||||
<div class="card">
|
{% trans 'Recipe Imports' %} :
|
||||||
<div class="card-header">
|
<span class="badge badge-pill badge-info">{{ counts.recipe_import }}</span>
|
||||||
{% trans 'Objects stats' %}
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li class="list-group-item">{% trans 'Recipes without Keywords' %} : <span
|
|
||||||
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span></li>
|
|
||||||
<li class="list-group-item">{% trans 'External Recipes' %} : <span
|
|
||||||
class="badge badge-pill badge-info">{{ counts.recipes_external }}</span></li>
|
|
||||||
<li class="list-group-item">{% trans 'Internal Recipes' %} : <span
|
|
||||||
class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span></li>
|
|
||||||
<li class="list-group-item">{% trans 'Comments' %} : <span
|
|
||||||
class="badge badge-pill badge-info">{{ counts.comments }}</span></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
<div class="col-md-6">
|
||||||
<br/>
|
<div class="card">
|
||||||
<div class="row">
|
<div class="card-header">{% trans 'Objects stats' %}</div>
|
||||||
<div class="col col-md-12">
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item">
|
||||||
<h4>{% trans 'Members' %} <small class="text-muted">{{ space_users|length }}/
|
{% trans 'Recipes without Keywords' %} :
|
||||||
{% if request.space.max_users > 0 %}
|
<span class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span>
|
||||||
{{ request.space.max_users }}{% else %}∞{% endif %}</small>
|
</li>
|
||||||
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"><i
|
<li class="list-group-item">
|
||||||
class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a>
|
{% trans 'External Recipes' %} :
|
||||||
</h4>
|
<span class="badge badge-pill badge-info">{{ counts.recipes_external }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
{% trans 'Internal Recipes' %} :
|
||||||
|
<span class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
{% trans 'Comments' %} : <span class="badge badge-pill badge-info">{{ counts.comments }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
</div>
|
||||||
|
<br />
|
||||||
<div class="row">
|
<br />
|
||||||
<div class="col col-md-12">
|
<form action="." method="post">{% csrf_token %} {{ user_name_form|crispy }}</form>
|
||||||
{% if space_users %}
|
<div class="row">
|
||||||
<table class="table table-bordered">
|
<div class="col col-md-12">
|
||||||
<tr>
|
<h4>
|
||||||
<th>{% trans 'User' %}</th>
|
{% trans 'Members' %}
|
||||||
<th>{% trans 'Groups' %}</th>
|
<small class="text-muted"
|
||||||
<th>{% trans 'Edit' %}</th>
|
>{{ space_users|length }}/ {% if request.space.max_users > 0 %} {{ request.space.max_users }}{% else
|
||||||
</tr>
|
%}∞{% endif %}</small
|
||||||
{% for u in space_users %}
|
>
|
||||||
<tr>
|
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"
|
||||||
<td>
|
><i class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a
|
||||||
{{ u.user.username }}
|
>
|
||||||
</td>
|
</h4>
|
||||||
<td>
|
|
||||||
{{ u.user.groups.all |join:", " }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if u.user != request.user %}
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<select v-model="users['{{ u.pk }}']" class="custom-select form-control"
|
|
||||||
style="height: 44px">
|
|
||||||
<option value="admin">{% trans 'admin' %}</option>
|
|
||||||
<option value="user">{% trans 'user' %}</option>
|
|
||||||
<option value="guest">{% trans 'guest' %}</option>
|
|
||||||
<option value="remove">{% trans 'remove' %}</option>
|
|
||||||
</select>
|
|
||||||
<span class="input-group-append">
|
|
||||||
<a class="btn btn-warning"
|
|
||||||
:href="editUserUrl({{ u.pk }}, {{ u.space.pk }})">{% trans 'Update' %}</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{% trans 'You cannot edit yourself.' %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<p>{% trans 'There are no members in your space yet!' %}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<h4>{% trans 'Invite Links' %}</h4>
|
{% if space_users %}
|
||||||
{% render_table invite_links %}
|
<table class="table table-bordered">
|
||||||
</div>
|
<tr>
|
||||||
|
<th>{% trans 'User' %}</th>
|
||||||
|
<th>{% trans 'Groups' %}</th>
|
||||||
|
<th>{% trans 'Edit' %}</th>
|
||||||
|
</tr>
|
||||||
|
{% for u in space_users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.user.username }}</td>
|
||||||
|
<td>{{ u.user.groups.all |join:", " }}</td>
|
||||||
|
<td>
|
||||||
|
{% if u.user != request.user %}
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<select v-model="users['{{ u.pk }}']" class="custom-select form-control" style="height: 44px">
|
||||||
|
<option value="admin">{% trans 'admin' %}</option>
|
||||||
|
<option value="user">{% trans 'user' %}</option>
|
||||||
|
<option value="guest">{% trans 'guest' %}</option>
|
||||||
|
<option value="remove">{% trans 'remove' %}</option>
|
||||||
|
</select>
|
||||||
|
<span class="input-group-append">
|
||||||
|
<a class="btn btn-warning" :href="editUserUrl({{ u.pk }}, {{ u.space.pk }})"
|
||||||
|
>{% trans 'Update' %}</a
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% else %} {% trans 'You cannot edit yourself.' %} {% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>{% trans 'There are no members in your space yet!' %}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<br/>
|
<div class="row">
|
||||||
<br/>
|
<div class="col col-md-12">
|
||||||
<br/>
|
<h4>{% trans 'Invite Links' %}</h4>
|
||||||
|
{% render_table invite_links %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<div class="col col-md-12">
|
||||||
|
<h4>{% trans 'Space Settings' %}</h4>
|
||||||
|
<form action="." method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ space_form|crispy }}
|
||||||
|
<button class="btn btn-success" type="submit" name="space_form"><i
|
||||||
|
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
|
||||||
|
{% endblock %} {% block script %}
|
||||||
|
|
||||||
|
<script type="application/javascript">
|
||||||
|
let app = new Vue({
|
||||||
|
delimiters: ['[[', ']]'],
|
||||||
|
el: '#id_base_container',
|
||||||
|
data: {
|
||||||
|
users: {
|
||||||
|
{% for u in space_users %}
|
||||||
|
'{{ u.pk }}': 'none',
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted: function () {
|
||||||
|
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
editUserUrl: function (user_id, space_id) {
|
||||||
|
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
|
|
||||||
<script type="application/javascript">
|
|
||||||
let app = new Vue({
|
|
||||||
delimiters: ['[[', ']]'],
|
|
||||||
el: '#id_base_container',
|
|
||||||
data: {
|
|
||||||
users: {
|
|
||||||
{% for u in space_users %}
|
|
||||||
'{{ u.pk }}': 'none',
|
|
||||||
{% endfor %}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted: function () {
|
|
||||||
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
editUserUrl: function (user_id, space_id) {
|
|
||||||
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -1,10 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from django.contrib import auth
|
from django.contrib import auth
|
||||||
from django_scopes import scopes_disabled
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry
|
from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry
|
||||||
|
|
||||||
@ -74,7 +73,7 @@ def ing_1_1_s1(obj_1_1, space_1):
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sle_1_s1(obj_1, u1_s1, space_1):
|
def sle_1_s1(obj_1, u1_s1, space_1):
|
||||||
e = ShoppingListEntry.objects.create(food=obj_1)
|
e = ShoppingListEntry.objects.create(food=obj_1, created_by=auth.get_user(u1_s1), space=space_1,)
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
||||||
s.entries.add(e)
|
s.entries.add(e)
|
||||||
return e
|
return e
|
||||||
@ -82,12 +81,12 @@ def sle_1_s1(obj_1, u1_s1, space_1):
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sle_2_s1(obj_2, u1_s1, space_1):
|
def sle_2_s1(obj_2, u1_s1, space_1):
|
||||||
return ShoppingListEntry.objects.create(food=obj_2)
|
return ShoppingListEntry.objects.create(food=obj_2, created_by=auth.get_user(u1_s1), space=space_1,)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sle_3_s2(obj_3, u1_s2, space_2):
|
def sle_3_s2(obj_3, u1_s2, space_2):
|
||||||
e = ShoppingListEntry.objects.create(food=obj_3)
|
e = ShoppingListEntry.objects.create(food=obj_3, created_by=auth.get_user(u1_s2), space=space_2)
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s2), space=space_2, )
|
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s2), space=space_2, )
|
||||||
s.entries.add(e)
|
s.entries.add(e)
|
||||||
return e
|
return e
|
||||||
@ -95,7 +94,7 @@ def sle_3_s2(obj_3, u1_s2, space_2):
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sle_1_1_s1(obj_1_1, u1_s1, space_1):
|
def sle_1_1_s1(obj_1_1, u1_s1, space_1):
|
||||||
e = ShoppingListEntry.objects.create(food=obj_1_1)
|
e = ShoppingListEntry.objects.create(food=obj_1_1, created_by=auth.get_user(u1_s1), space=space_1,)
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
||||||
s.entries.add(e)
|
s.entries.add(e)
|
||||||
return e
|
return e
|
||||||
@ -449,3 +448,10 @@ def test_tree_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
|
|||||||
assert response['count'] == 4
|
assert response['count'] == 4
|
||||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}&query={obj_2.name[4:]}').content)
|
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}&query={obj_2.name[4:]}').content)
|
||||||
assert response['count'] == 4
|
assert response['count'] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# TODO test inherit creating, moving for each field type
|
||||||
|
# TODO test ignore inherit for each field type
|
||||||
|
# TODO test with grand-children
|
||||||
|
# - flow from parent through child and grand-child
|
||||||
|
# - flow from parent stop when child is ignore inherit
|
||||||
|
@ -111,3 +111,16 @@ def test_delete(u1_s1, u1_s2, recipe_1_s1):
|
|||||||
|
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
assert not Recipe.objects.filter(pk=recipe_1_s1.id).exists()
|
assert not Recipe.objects.filter(pk=recipe_1_s1.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
# TODO test related_recipes api
|
||||||
|
# -- step recipes
|
||||||
|
# -- ingredient recipes
|
||||||
|
# -- recipe wrong space
|
||||||
|
# -- steps wrong space
|
||||||
|
# -- ingredients wrong space
|
||||||
|
# -- step recipes included in step recipes
|
||||||
|
# -- step recipes included in food recipes
|
||||||
|
# -- food recipes included in step recipes
|
||||||
|
# -- food recipes included in food recipes
|
||||||
|
# -- included recipes in the wrong space
|
@ -14,7 +14,7 @@ DETAIL_URL = 'api:shoppinglistentry-detail'
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def obj_1(space_1, u1_s1):
|
def obj_1(space_1, u1_s1):
|
||||||
e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 1', space=space_1)[0])
|
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1)
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
||||||
s.entries.add(e)
|
s.entries.add(e)
|
||||||
return e
|
return e
|
||||||
@ -22,7 +22,7 @@ def obj_1(space_1, u1_s1):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def obj_2(space_1, u1_s1):
|
def obj_2(space_1, u1_s1):
|
||||||
e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 2', space=space_1)[0])
|
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1)
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
||||||
s.entries.add(e)
|
s.entries.add(e)
|
||||||
return e
|
return e
|
||||||
@ -45,8 +45,11 @@ def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
|||||||
|
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
s = ShoppingList.objects.first()
|
s = ShoppingList.objects.first()
|
||||||
|
e = ShoppingListEntry.objects.first()
|
||||||
s.space = space_2
|
s.space = space_2
|
||||||
|
e.space = space_2
|
||||||
s.save()
|
s.save()
|
||||||
|
e.save()
|
||||||
|
|
||||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||||
@ -114,3 +117,15 @@ def test_delete(u1_s1, u1_s2, obj_1):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
# TODO test sharing
|
||||||
|
# TODO test completed entries still visible if today, but not yesterday
|
||||||
|
# TODO test create shopping list from recipe
|
||||||
|
# TODO test delete shopping list from recipe - include created by, shared with and not shared with
|
||||||
|
# TODO test create shopping list from food
|
||||||
|
# TODO test delete shopping list from food - include created by, shared with and not shared with
|
||||||
|
# TODO test create shopping list from mealplan
|
||||||
|
# TODO test create shopping list from recipe, excluding ingredients
|
||||||
|
# TODO test auto creating shopping list from meal plan
|
||||||
|
# TODO test excluding on-hand when auto creating shopping list
|
||||||
|
@ -23,8 +23,8 @@ def test_list_permission(arg, request):
|
|||||||
|
|
||||||
|
|
||||||
def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
|
def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
|
||||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 2
|
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2
|
||||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0
|
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 0
|
||||||
|
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
recipe_1_s1.space = space_2
|
recipe_1_s1.space = space_2
|
||||||
@ -32,9 +32,9 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
|
|||||||
Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
|
Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
|
||||||
Ingredient.objects.update(space=Subquery(Ingredient.objects.filter(pk=OuterRef('pk')).values('step__recipe__space')[:1]))
|
Ingredient.objects.update(space=Subquery(Ingredient.objects.filter(pk=OuterRef('pk')).values('step__recipe__space')[:1]))
|
||||||
|
|
||||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0
|
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 0
|
||||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 2
|
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("arg", [
|
@pytest.mark.parametrize("arg", [
|
||||||
['a_u', 403],
|
['a_u', 403],
|
||||||
|
@ -49,7 +49,7 @@ def ing_3_s2(obj_3, space_2, u2_s2):
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sle_1_s1(obj_1, u1_s1, space_1):
|
def sle_1_s1(obj_1, u1_s1, space_1):
|
||||||
e = ShoppingListEntry.objects.create(unit=obj_1, food=random_food(space_1, u1_s1))
|
e = ShoppingListEntry.objects.create(unit=obj_1, food=random_food(space_1, u1_s1), created_by=auth.get_user(u1_s1), space=space_1,)
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
||||||
s.entries.add(e)
|
s.entries.add(e)
|
||||||
return e
|
return e
|
||||||
@ -57,12 +57,12 @@ def sle_1_s1(obj_1, u1_s1, space_1):
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sle_2_s1(obj_2, u1_s1, space_1):
|
def sle_2_s1(obj_2, u1_s1, space_1):
|
||||||
return ShoppingListEntry.objects.create(unit=obj_2, food=random_food(space_1, u1_s1))
|
return ShoppingListEntry.objects.create(unit=obj_2, food=random_food(space_1, u1_s1), created_by=auth.get_user(u1_s1), space=space_1,)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sle_3_s2(obj_3, u2_s2, space_2):
|
def sle_3_s2(obj_3, u2_s2, space_2):
|
||||||
e = ShoppingListEntry.objects.create(unit=obj_3, food=random_food(space_2, u2_s2))
|
e = ShoppingListEntry.objects.create(unit=obj_3, food=random_food(space_2, u2_s2), created_by=auth.get_user(u2_s2), space=space_2)
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u2_s2), space=space_2)
|
s = ShoppingList.objects.create(created_by=auth.get_user(u2_s2), space=space_2)
|
||||||
s.entries.add(e)
|
s.entries.add(e)
|
||||||
return e
|
return e
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
from cookbook.models import UserPreference
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -7,6 +5,8 @@ from django.contrib import auth
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.models import UserPreference
|
||||||
|
|
||||||
LIST_URL = 'api:userpreference-list'
|
LIST_URL = 'api:userpreference-list'
|
||||||
DETAIL_URL = 'api:userpreference-detail'
|
DETAIL_URL = 'api:userpreference-detail'
|
||||||
|
|
||||||
@ -109,3 +109,6 @@ def test_preference_delete(u1_s1, u2_s1):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
# TODO test existance of default food_inherit fields, test multiple users same space work and users in difference space do not
|
||||||
|
@ -15,33 +15,35 @@ from .models import (Automation, Comment, Food, InviteLink, Keyword, MealPlan, R
|
|||||||
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
|
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.register(r'user-name', api.UserNameViewSet, basename='username')
|
router.register(r'automation', api.AutomationViewSet)
|
||||||
router.register(r'user-preference', api.UserPreferenceViewSet)
|
router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
|
||||||
router.register(r'storage', api.StorageViewSet)
|
router.register(r'cook-log', api.CookLogViewSet)
|
||||||
router.register(r'sync', api.SyncViewSet)
|
|
||||||
router.register(r'sync-log', api.SyncLogViewSet)
|
|
||||||
router.register(r'keyword', api.KeywordViewSet)
|
|
||||||
router.register(r'unit', api.UnitViewSet)
|
|
||||||
router.register(r'food', api.FoodViewSet)
|
router.register(r'food', api.FoodViewSet)
|
||||||
router.register(r'step', api.StepViewSet)
|
router.register(r'food-inherit-field', api.FoodInheritFieldViewSet)
|
||||||
router.register(r'recipe', api.RecipeViewSet)
|
router.register(r'import-log', api.ImportLogViewSet)
|
||||||
router.register(r'ingredient', api.IngredientViewSet)
|
router.register(r'ingredient', api.IngredientViewSet)
|
||||||
|
router.register(r'keyword', api.KeywordViewSet)
|
||||||
router.register(r'meal-plan', api.MealPlanViewSet)
|
router.register(r'meal-plan', api.MealPlanViewSet)
|
||||||
router.register(r'meal-type', api.MealTypeViewSet)
|
router.register(r'meal-type', api.MealTypeViewSet)
|
||||||
|
router.register(r'recipe', api.RecipeViewSet)
|
||||||
|
router.register(r'recipe-book', api.RecipeBookViewSet)
|
||||||
|
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
|
||||||
router.register(r'shopping-list', api.ShoppingListViewSet)
|
router.register(r'shopping-list', api.ShoppingListViewSet)
|
||||||
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
|
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
|
||||||
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
|
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
|
||||||
router.register(r'view-log', api.ViewLogViewSet)
|
router.register(r'step', api.StepViewSet)
|
||||||
router.register(r'cook-log', api.CookLogViewSet)
|
router.register(r'storage', api.StorageViewSet)
|
||||||
router.register(r'recipe-book', api.RecipeBookViewSet)
|
|
||||||
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
|
|
||||||
router.register(r'supermarket', api.SupermarketViewSet)
|
router.register(r'supermarket', api.SupermarketViewSet)
|
||||||
router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
|
router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
|
||||||
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
|
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
|
||||||
router.register(r'import-log', api.ImportLogViewSet)
|
router.register(r'sync', api.SyncViewSet)
|
||||||
router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
|
router.register(r'sync-log', api.SyncLogViewSet)
|
||||||
|
router.register(r'unit', api.UnitViewSet)
|
||||||
router.register(r'user-file', api.UserFileViewSet)
|
router.register(r'user-file', api.UserFileViewSet)
|
||||||
router.register(r'automation', api.AutomationViewSet)
|
router.register(r'user-name', api.UserNameViewSet, basename='username')
|
||||||
|
router.register(r'user-preference', api.UserPreferenceViewSet)
|
||||||
|
router.register(r'view-log', api.ViewLogViewSet)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.index, name='index'),
|
path('', views.index, name='index'),
|
||||||
|
@ -38,21 +38,25 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
|
|||||||
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
||||||
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
|
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
|
||||||
from cookbook.helper.recipe_url_import import get_from_scraper
|
from cookbook.helper.recipe_url_import import get_from_scraper
|
||||||
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, ImportLog, Ingredient,
|
from cookbook.helper.shopping_helper import shopping_helper
|
||||||
Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry,
|
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
|
||||||
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Step,
|
ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||||
Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation,
|
RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry,
|
||||||
Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog)
|
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
|
||||||
|
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
|
||||||
|
UserPreference, ViewLog)
|
||||||
from cookbook.provider.dropbox import Dropbox
|
from cookbook.provider.dropbox import Dropbox
|
||||||
from cookbook.provider.local import Local
|
from cookbook.provider.local import Local
|
||||||
from cookbook.provider.nextcloud import Nextcloud
|
from cookbook.provider.nextcloud import Nextcloud
|
||||||
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
|
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
|
||||||
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
|
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
|
||||||
CookLogSerializer, FoodSerializer, ImportLogSerializer,
|
CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer,
|
||||||
|
FoodShoppingUpdateSerializer, ImportLogSerializer,
|
||||||
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
|
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
|
||||||
MealTypeSerializer, RecipeBookEntrySerializer,
|
MealTypeSerializer, RecipeBookEntrySerializer,
|
||||||
RecipeBookSerializer, RecipeImageSerializer,
|
RecipeBookSerializer, RecipeImageSerializer,
|
||||||
RecipeOverviewSerializer, RecipeSerializer,
|
RecipeOverviewSerializer, RecipeSerializer,
|
||||||
|
RecipeShoppingUpdateSerializer, RecipeSimpleSerializer,
|
||||||
ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
|
ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
|
||||||
ShoppingListRecipeSerializer, ShoppingListSerializer,
|
ShoppingListRecipeSerializer, ShoppingListSerializer,
|
||||||
StepSerializer, StorageSerializer,
|
StepSerializer, StorageSerializer,
|
||||||
@ -359,8 +363,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
|||||||
permission_classes = [CustomIsUser]
|
permission_classes = [CustomIsUser]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
self.queryset = self.queryset.filter(space=self.request.space)
|
return self.queryset.filter(space=self.request.space)
|
||||||
return super().get_queryset()
|
|
||||||
|
|
||||||
|
|
||||||
class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||||
@ -390,6 +393,16 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin):
|
|||||||
pagination_class = DefaultPagination
|
pagination_class = DefaultPagination
|
||||||
|
|
||||||
|
|
||||||
|
class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = FoodInheritField.objects
|
||||||
|
serializer_class = FoodInheritFieldSerializer
|
||||||
|
permission_classes = [CustomIsUser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# exclude fields not yet implemented
|
||||||
|
return Food.inherit_fields
|
||||||
|
|
||||||
|
|
||||||
class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||||
queryset = Food.objects
|
queryset = Food.objects
|
||||||
model = Food
|
model = Food
|
||||||
@ -397,6 +410,23 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
|||||||
permission_classes = [CustomIsUser]
|
permission_classes = [CustomIsUser]
|
||||||
pagination_class = DefaultPagination
|
pagination_class = DefaultPagination
|
||||||
|
|
||||||
|
@decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,)
|
||||||
|
def shopping(self, request, pk):
|
||||||
|
obj = self.get_object()
|
||||||
|
shared_users = list(self.request.user.get_shopping_share())
|
||||||
|
shared_users.append(request.user)
|
||||||
|
if request.data.get('_delete', False) == 'true':
|
||||||
|
ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space, created_by__in=shared_users).delete()
|
||||||
|
content = {'msg': _(f'{obj.name} was removed from the shopping list.')}
|
||||||
|
return Response(content, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
amount = request.data.get('amount', 1)
|
||||||
|
unit = request.data.get('unit', None)
|
||||||
|
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
|
||||||
|
|
||||||
|
ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space, created_by=request.user)
|
||||||
|
return Response(content, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
def destroy(self, *args, **kwargs):
|
def destroy(self, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
return (super().destroy(self, *args, **kwargs))
|
return (super().destroy(self, *args, **kwargs))
|
||||||
@ -547,27 +577,18 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
|||||||
pagination_class = RecipePagination
|
pagination_class = RecipePagination
|
||||||
# TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly
|
# TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly
|
||||||
query_params = [
|
query_params = [
|
||||||
QueryParam(name='query', description=_(
|
QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
|
||||||
'Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
|
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'), qtype='int'),
|
||||||
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'),
|
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
|
||||||
qtype='int'),
|
|
||||||
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'),
|
|
||||||
qtype='int'),
|
|
||||||
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
|
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
|
||||||
QueryParam(name='rating', description=_('Rating a recipe should have. [0 - 5]'), qtype='int'),
|
QueryParam(name='rating', description=_('Rating a recipe should have. [0 - 5]'), qtype='int'),
|
||||||
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
|
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
|
||||||
QueryParam(name='keywords_or', description=_(
|
QueryParam(name='keywords_or', description=_('If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided keywords.')),
|
||||||
'If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided keywords.')),
|
QueryParam(name='foods_or', description=_('If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided foods.')),
|
||||||
QueryParam(name='foods_or', description=_(
|
QueryParam(name='books_or', description=_('If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
|
||||||
'If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided foods.')),
|
QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
|
||||||
QueryParam(name='books_or', description=_(
|
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
|
||||||
'If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
|
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
|
||||||
QueryParam(name='internal',
|
|
||||||
description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
|
|
||||||
QueryParam(name='random',
|
|
||||||
description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
|
|
||||||
QueryParam(name='new',
|
|
||||||
description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
|
|
||||||
]
|
]
|
||||||
schema = QueryParamAutoSchema()
|
schema = QueryParamAutoSchema()
|
||||||
|
|
||||||
@ -625,16 +646,49 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
|||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
return Response(serializer.errors, 400)
|
return Response(serializer.errors, 400)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=True,
|
||||||
|
methods=['PUT'],
|
||||||
|
serializer_class=RecipeShoppingUpdateSerializer,
|
||||||
|
)
|
||||||
|
def shopping(self, request, pk):
|
||||||
|
obj = self.get_object()
|
||||||
|
ingredients = request.data.get('ingredients', None)
|
||||||
|
servings = request.data.get('servings', obj.servings)
|
||||||
|
list_recipe = request.data.get('list_recipe', None)
|
||||||
|
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
|
||||||
|
# TODO: Consider if this should be a Recipe method
|
||||||
|
ShoppingListEntry.list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=request.user)
|
||||||
|
|
||||||
|
return Response(content, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=True,
|
||||||
|
methods=['GET'],
|
||||||
|
serializer_class=RecipeSimpleSerializer
|
||||||
|
)
|
||||||
|
def related(self, request, pk):
|
||||||
|
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=2) # TODO: make levels a user setting, included in request data, keep solely in the backend?
|
||||||
|
return Response(self.serializer_class(qs, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
||||||
queryset = ShoppingListRecipe.objects
|
queryset = ShoppingListRecipe.objects
|
||||||
serializer_class = ShoppingListRecipeSerializer
|
serializer_class = ShoppingListRecipeSerializer
|
||||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
self.queryset = self.queryset.filter(Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space))
|
||||||
return self.queryset.filter(
|
return self.queryset.filter(
|
||||||
Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter(
|
Q(shoppinglist__created_by=self.request.user)
|
||||||
shoppinglist__space=self.request.space).distinct().all()
|
| Q(shoppinglist__shared=self.request.user)
|
||||||
|
| Q(entries__created_by=self.request.user)
|
||||||
|
| Q(entries__created_by__in=list(self.request.user.get_shopping_share()))
|
||||||
|
).distinct().all()
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListEntryViewSet(viewsets.ModelViewSet):
|
class ShoppingListEntryViewSet(viewsets.ModelViewSet):
|
||||||
@ -642,35 +696,46 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
|
|||||||
serializer_class = ShoppingListEntrySerializer
|
serializer_class = ShoppingListEntrySerializer
|
||||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||||
query_params = [
|
query_params = [
|
||||||
QueryParam(name='id',
|
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
|
||||||
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'),
|
|
||||||
qtype='int'),
|
|
||||||
QueryParam(
|
QueryParam(
|
||||||
name='checked',
|
name='checked',
|
||||||
description=_(
|
description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
|
||||||
'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
|
|
||||||
),
|
),
|
||||||
QueryParam(name='supermarket',
|
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
|
||||||
description=_('Returns the shopping list entries sorted by supermarket category order.'),
|
|
||||||
qtype='int'),
|
|
||||||
]
|
]
|
||||||
schema = QueryParamAutoSchema()
|
schema = QueryParamAutoSchema()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.queryset.filter(
|
self.queryset = self.queryset.filter(space=self.request.space)
|
||||||
Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter(
|
|
||||||
shoppinglist__space=self.request.space).distinct().all()
|
self.queryset = self.queryset.filter(
|
||||||
|
Q(created_by=self.request.user)
|
||||||
|
| Q(shoppinglist__shared=self.request.user)
|
||||||
|
| Q(created_by__in=list(self.request.user.get_shopping_share()))
|
||||||
|
).distinct().all()
|
||||||
|
|
||||||
|
if pk := self.request.query_params.getlist('id', []):
|
||||||
|
self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk])
|
||||||
|
|
||||||
|
if bool(int(self.request.query_params.get('recent', False))):
|
||||||
|
return shopping_helper(self.queryset, self.request)
|
||||||
|
|
||||||
|
# TODO once old shopping list is removed this needs updated to sharing users in preferences
|
||||||
|
return self.queryset
|
||||||
|
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
class ShoppingListViewSet(viewsets.ModelViewSet):
|
class ShoppingListViewSet(viewsets.ModelViewSet):
|
||||||
queryset = ShoppingList.objects
|
queryset = ShoppingList.objects
|
||||||
serializer_class = ShoppingListSerializer
|
serializer_class = ShoppingListSerializer
|
||||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||||
|
|
||||||
|
# TODO update to include settings shared user - make both work for a period of time
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
|
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
|
||||||
space=self.request.space).distinct()
|
space=self.request.space).distinct()
|
||||||
|
|
||||||
|
# TODO deprecate
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
try:
|
try:
|
||||||
autosync = self.request.query_params.get('autosync', False)
|
autosync = self.request.query_params.get('autosync', False)
|
||||||
|
@ -22,8 +22,8 @@ from cookbook.helper.image_processing import handle_image
|
|||||||
from cookbook.helper.ingredient_parser import IngredientParser
|
from cookbook.helper.ingredient_parser import IngredientParser
|
||||||
from cookbook.helper.permission_helper import group_required, has_group_permission
|
from cookbook.helper.permission_helper import group_required, has_group_permission
|
||||||
from cookbook.helper.recipe_url_import import parse_cooktime
|
from cookbook.helper.recipe_url_import import parse_cooktime
|
||||||
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe,
|
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, RecipeImport, Step, Sync,
|
||||||
RecipeImport, Step, Sync, Unit, UserPreference)
|
Unit, UserPreference)
|
||||||
from cookbook.tables import SyncTable
|
from cookbook.tables import SyncTable
|
||||||
from recipes import settings
|
from recipes import settings
|
||||||
|
|
||||||
@ -111,8 +111,8 @@ def batch_edit(request):
|
|||||||
'Batch edit done. %(count)d recipe was updated.',
|
'Batch edit done. %(count)d recipe was updated.',
|
||||||
'Batch edit done. %(count)d Recipes where updated.',
|
'Batch edit done. %(count)d Recipes where updated.',
|
||||||
count) % {
|
count) % {
|
||||||
'count': count,
|
'count': count,
|
||||||
}
|
}
|
||||||
messages.add_message(request, messages.SUCCESS, msg)
|
messages.add_message(request, messages.SUCCESS, msg)
|
||||||
|
|
||||||
return redirect('data_batch_edit')
|
return redirect('data_batch_edit')
|
||||||
|
@ -7,10 +7,9 @@ from django_tables2 import RequestConfig
|
|||||||
|
|
||||||
from cookbook.filters import ShoppingListFilter
|
from cookbook.filters import ShoppingListFilter
|
||||||
from cookbook.helper.permission_helper import group_required
|
from cookbook.helper.permission_helper import group_required
|
||||||
from cookbook.models import (InviteLink, RecipeImport,
|
from cookbook.models import InviteLink, RecipeImport, ShoppingList, Storage, SyncLog, UserFile
|
||||||
ShoppingList, Storage, SyncLog, UserFile)
|
from cookbook.tables import (ImportLogTable, InviteLinkTable, RecipeImportTable, ShoppingListTable,
|
||||||
from cookbook.tables import (ImportLogTable, InviteLinkTable,
|
StorageTable)
|
||||||
RecipeImportTable, ShoppingListTable, StorageTable)
|
|
||||||
|
|
||||||
|
|
||||||
@group_required('admin')
|
@group_required('admin')
|
||||||
@ -40,20 +39,6 @@ def recipe_import(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# @group_required('user')
|
|
||||||
# def food(request):
|
|
||||||
# f = FoodFilter(request.GET, queryset=Food.objects.filter(space=request.space).all().order_by('pk'))
|
|
||||||
|
|
||||||
# table = IngredientTable(f.qs)
|
|
||||||
# RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
|
||||||
|
|
||||||
# return render(
|
|
||||||
# request,
|
|
||||||
# 'generic/list_template.html',
|
|
||||||
# {'title': _("Ingredients"), 'table': table, 'filter': f}
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
@group_required('user')
|
@group_required('user')
|
||||||
def shopping_list(request):
|
def shopping_list(request):
|
||||||
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(space=request.space).filter(
|
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(space=request.space).filter(
|
||||||
@ -204,7 +189,7 @@ def automation(request):
|
|||||||
def user_file(request):
|
def user_file(request):
|
||||||
try:
|
try:
|
||||||
current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[
|
current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[
|
||||||
'file_size_kb__sum'] / 1000
|
'file_size_kb__sum'] / 1000
|
||||||
except TypeError:
|
except TypeError:
|
||||||
current_file_size_mb = 0
|
current_file_size_mb = 0
|
||||||
|
|
||||||
@ -244,11 +229,9 @@ def shopping_list_new(request):
|
|||||||
# model-name is the models.js name of the model, probably ALL-CAPS
|
# model-name is the models.js name of the model, probably ALL-CAPS
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
'generic/checklist_template.html',
|
'shoppinglist_template.html',
|
||||||
{
|
{
|
||||||
"title": _("New Shopping List"),
|
"title": _("New Shopping List"),
|
||||||
"config": {
|
|
||||||
'model': "SHOPPING_LIST", # *REQUIRED* name of the model in models.js
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -22,13 +22,13 @@ from django_tables2 import RequestConfig
|
|||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from cookbook.filters import RecipeFilter
|
from cookbook.filters import RecipeFilter
|
||||||
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm,
|
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm,
|
||||||
SpaceJoinForm, User, UserCreateForm, UserNameForm, UserPreference,
|
SpaceCreateForm, SpaceJoinForm, SpacePreferenceForm, User,
|
||||||
UserPreferenceForm)
|
UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm)
|
||||||
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid
|
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid
|
||||||
from cookbook.models import (Comment, CookLog, Food, InviteLink, Keyword, MealPlan, RecipeImport,
|
from cookbook.models import (Comment, CookLog, Food, FoodInheritField, InviteLink, Keyword,
|
||||||
SearchFields, SearchPreference, ShareLink, ShoppingList, Space, Unit,
|
MealPlan, RecipeImport, SearchFields, SearchPreference, ShareLink,
|
||||||
UserFile, ViewLog)
|
ShoppingList, Space, Unit, UserFile, ViewLog)
|
||||||
from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall,
|
from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall,
|
||||||
ViewLogTable)
|
ViewLogTable)
|
||||||
from cookbook.views.data import Object
|
from cookbook.views.data import Object
|
||||||
@ -304,10 +304,6 @@ def user_settings(request):
|
|||||||
up.use_kj = form.cleaned_data['use_kj']
|
up.use_kj = form.cleaned_data['use_kj']
|
||||||
up.sticky_navbar = form.cleaned_data['sticky_navbar']
|
up.sticky_navbar = form.cleaned_data['sticky_navbar']
|
||||||
|
|
||||||
up.shopping_auto_sync = form.cleaned_data['shopping_auto_sync']
|
|
||||||
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
|
|
||||||
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
|
|
||||||
|
|
||||||
up.save()
|
up.save()
|
||||||
|
|
||||||
elif 'user_name_form' in request.POST:
|
elif 'user_name_form' in request.POST:
|
||||||
@ -378,10 +374,28 @@ def user_settings(request):
|
|||||||
sp.trigram_threshold = 0.1
|
sp.trigram_threshold = 0.1
|
||||||
|
|
||||||
sp.save()
|
sp.save()
|
||||||
|
elif 'shopping_form' in request.POST:
|
||||||
|
shopping_form = ShoppingPreferenceForm(request.POST, prefix='shopping')
|
||||||
|
if shopping_form.is_valid():
|
||||||
|
if not up:
|
||||||
|
up = UserPreference(user=request.user)
|
||||||
|
|
||||||
|
up.shopping_share.set(shopping_form.cleaned_data['shopping_share'])
|
||||||
|
up.mealplan_autoadd_shopping = shopping_form.cleaned_data['mealplan_autoadd_shopping']
|
||||||
|
up.mealplan_autoexclude_onhand = shopping_form.cleaned_data['mealplan_autoexclude_onhand']
|
||||||
|
up.mealplan_autoinclude_related = shopping_form.cleaned_data['mealplan_autoinclude_related']
|
||||||
|
up.shopping_auto_sync = shopping_form.cleaned_data['shopping_auto_sync']
|
||||||
|
up.filter_to_supermarket = shopping_form.cleaned_data['filter_to_supermarket']
|
||||||
|
up.default_delay = shopping_form.cleaned_data['default_delay']
|
||||||
|
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
|
||||||
|
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
|
||||||
|
up.save()
|
||||||
if up:
|
if up:
|
||||||
preference_form = UserPreferenceForm(instance=up, space=request.space)
|
preference_form = UserPreferenceForm(instance=up)
|
||||||
|
shopping_form = ShoppingPreferenceForm(instance=up)
|
||||||
else:
|
else:
|
||||||
preference_form = UserPreferenceForm(space=request.space)
|
preference_form = UserPreferenceForm(space=request.space)
|
||||||
|
shopping_form = ShoppingPreferenceForm(space=request.space)
|
||||||
|
|
||||||
fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(
|
fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(
|
||||||
sp.fulltext.all())
|
sp.fulltext.all())
|
||||||
@ -406,6 +420,7 @@ def user_settings(request):
|
|||||||
'user_name_form': user_name_form,
|
'user_name_form': user_name_form,
|
||||||
'api_token': api_token,
|
'api_token': api_token,
|
||||||
'search_form': search_form,
|
'search_form': search_form,
|
||||||
|
'shopping_form': shopping_form,
|
||||||
'active_tab': active_tab
|
'active_tab': active_tab
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -541,7 +556,22 @@ def space(request):
|
|||||||
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=request.space).all())
|
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=request.space).all())
|
||||||
RequestConfig(request, paginate={'per_page': 25}).configure(invite_links)
|
RequestConfig(request, paginate={'per_page': 25}).configure(invite_links)
|
||||||
|
|
||||||
return render(request, 'space.html', {'space_users': space_users, 'counts': counts, 'invite_links': invite_links})
|
space_form = SpacePreferenceForm(instance=request.space)
|
||||||
|
|
||||||
|
space_form.base_fields['food_inherit'].queryset = Food.inherit_fields
|
||||||
|
if request.method == "POST" and 'space_form' in request.POST:
|
||||||
|
form = SpacePreferenceForm(request.POST, prefix='space')
|
||||||
|
if form.is_valid():
|
||||||
|
request.space.food_inherit.set(form.cleaned_data['food_inherit'])
|
||||||
|
if form.cleaned_data['reset_food_inherit']:
|
||||||
|
Food.reset_inheritance(space=request.space)
|
||||||
|
|
||||||
|
return render(request, 'space.html', {
|
||||||
|
'space_users': space_users,
|
||||||
|
'counts': counts,
|
||||||
|
'invite_links': invite_links,
|
||||||
|
'space_form': space_form
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# TODO super hacky and quick solution, safe but needs rework
|
# TODO super hacky and quick solution, safe but needs rework
|
||||||
|
@ -61,9 +61,9 @@ def SqlPrintingMiddleware(get_response):
|
|||||||
sql = "\033[1;31m[%s]\033[0m %s" % (query['time'], nice_sql)
|
sql = "\033[1;31m[%s]\033[0m %s" % (query['time'], nice_sql)
|
||||||
total_time = total_time + float(query['time'])
|
total_time = total_time + float(query['time'])
|
||||||
while len(sql) > width - indentation:
|
while len(sql) > width - indentation:
|
||||||
#print("%s%s" % (" " * indentation, sql[:width - indentation]))
|
# print("%s%s" % (" " * indentation, sql[:width - indentation]))
|
||||||
sql = sql[width - indentation:]
|
sql = sql[width - indentation:]
|
||||||
#print("%s%s\n" % (" " * indentation, sql))
|
# print("%s%s\n" % (" " * indentation, sql))
|
||||||
replace_tuple = (" " * indentation, str(total_time))
|
replace_tuple = (" " * indentation, str(total_time))
|
||||||
print("%s\033[1;32m[TOTAL TIME: %s seconds]\033[0m" % replace_tuple)
|
print("%s\033[1;32m[TOTAL TIME: %s seconds]\033[0m" % replace_tuple)
|
||||||
print("%s\033[1;32m[TOTAL QUERIES: %s]\033[0m" % (" " * indentation, len(connection.queries)))
|
print("%s\033[1;32m[TOTAL QUERIES: %s]\033[0m" % (" " * indentation, len(connection.queries)))
|
||||||
|
23
vue/.gitignore
vendored
23
vue/.gitignore
vendored
@ -1,23 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
node_modules
|
|
||||||
/dist
|
|
||||||
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Log files
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
8
vue/.openapi-generator/FILES
Normal file
8
vue/.openapi-generator/FILES
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.gitignore
|
||||||
|
.npmignore
|
||||||
|
api.ts
|
||||||
|
base.ts
|
||||||
|
common.ts
|
||||||
|
configuration.ts
|
||||||
|
git_push.sh
|
||||||
|
index.ts
|
1
vue/.openapi-generator/VERSION
Normal file
1
vue/.openapi-generator/VERSION
Normal file
@ -0,0 +1 @@
|
|||||||
|
5.2.1
|
@ -1,195 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div id="app" style="margin-bottom: 4vh" v-if="this_model">
|
|
||||||
<generic-modal-form v-if="this_model"
|
|
||||||
:model="this_model"
|
|
||||||
:action="this_action"
|
|
||||||
:item1="this_item"
|
|
||||||
:item2="this_target"
|
|
||||||
:show="show_modal"
|
|
||||||
@finish-action="finishAction"/>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-2 d-none d-md-block">
|
|
||||||
</div>
|
|
||||||
<div class="col-xl-8 col-12">
|
|
||||||
<div class="container-fluid d-flex flex-column flex-grow-1">
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6" style="margin-top: 1vh">
|
|
||||||
<h3>
|
|
||||||
<!-- <model-menu/> Replace with a List Menu or a Checklist Menu? -->
|
|
||||||
<span>{{ this.this_model.name }}</span>
|
|
||||||
<span><b-button variant="link" @click="startAction({'action':'new'})"><i
|
|
||||||
class="fas fa-plus-circle fa-2x"></i></b-button></span>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col col-md-12">
|
|
||||||
this is where shopping list items go
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
import Vue from 'vue'
|
|
||||||
import {BootstrapVue} from 'bootstrap-vue'
|
|
||||||
|
|
||||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
|
||||||
|
|
||||||
import {ApiMixin} from "@/utils/utils";
|
|
||||||
import {StandardToasts, ToastMixin} from "@/utils/utils";
|
|
||||||
|
|
||||||
import GenericModalForm from "@/components/Modals/GenericModalForm";
|
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
|
|
||||||
// or i'm capturing it incorrectly
|
|
||||||
name: 'ModelListView',
|
|
||||||
mixins: [ApiMixin, ToastMixin],
|
|
||||||
components: {GenericModalForm},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
// this.Models and this.Actions inherited from ApiMixin
|
|
||||||
items: [],
|
|
||||||
this_model: undefined,
|
|
||||||
model_menu: undefined,
|
|
||||||
this_action: undefined,
|
|
||||||
this_item: {},
|
|
||||||
show_modal: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
// value is passed from lists.py
|
|
||||||
let model_config = JSON.parse(document.getElementById('model_config').textContent)
|
|
||||||
this.this_model = this.Models[model_config?.model]
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
// this.genericAPI inherited from ApiMixin
|
|
||||||
startAction: function (e, param) {
|
|
||||||
let source = e?.source ?? {}
|
|
||||||
this.this_item = source
|
|
||||||
// remove recipe from shopping list
|
|
||||||
// mark on-hand
|
|
||||||
// mark puchased
|
|
||||||
// edit shopping category on food
|
|
||||||
// delete food from shopping list
|
|
||||||
// add food to shopping list
|
|
||||||
// add other to shopping list
|
|
||||||
// edit unit conversion
|
|
||||||
// edit purchaseable unit
|
|
||||||
switch (e.action) {
|
|
||||||
case 'delete':
|
|
||||||
this.this_action = this.Actions.DELETE
|
|
||||||
this.show_modal = true
|
|
||||||
break;
|
|
||||||
case 'new':
|
|
||||||
this.this_action = this.Actions.CREATE
|
|
||||||
this.show_modal = true
|
|
||||||
break;
|
|
||||||
case 'edit':
|
|
||||||
this.this_item = e.source
|
|
||||||
this.this_action = this.Actions.UPDATE
|
|
||||||
this.show_modal = true
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
finishAction: function (e) {
|
|
||||||
let update = undefined
|
|
||||||
switch (e?.action) {
|
|
||||||
case 'save':
|
|
||||||
this.saveThis(e.form_data)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (e !== 'cancel') {
|
|
||||||
switch (this.this_action) {
|
|
||||||
case this.Actions.DELETE:
|
|
||||||
this.deleteThis(this.this_item.id)
|
|
||||||
break;
|
|
||||||
case this.Actions.CREATE:
|
|
||||||
this.saveThis(e.form_data)
|
|
||||||
break;
|
|
||||||
case this.Actions.UPDATE:
|
|
||||||
update = e.form_data
|
|
||||||
update.id = this.this_item.id
|
|
||||||
this.saveThis(update)
|
|
||||||
break;
|
|
||||||
case this.Actions.MERGE:
|
|
||||||
this.mergeThis(this.this_item, e.form_data.target, false)
|
|
||||||
break;
|
|
||||||
case this.Actions.MOVE:
|
|
||||||
this.moveThis(this.this_item.id, e.form_data.target.id)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.clearState()
|
|
||||||
},
|
|
||||||
getItems: function (params) {
|
|
||||||
this.genericAPI(this.this_model, this.Actions.LIST, params).then((results) => {
|
|
||||||
if (results?.length) {
|
|
||||||
this.items = results.data
|
|
||||||
} else {
|
|
||||||
console.log('no data returned')
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
console.log(err)
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getThis: function (id) {
|
|
||||||
return this.genericAPI(this.this_model, this.Actions.FETCH, {'id': id})
|
|
||||||
},
|
|
||||||
saveThis: function (thisItem) {
|
|
||||||
if (!thisItem?.id) { // if there is no item id assume it's a new item
|
|
||||||
this.genericAPI(this.this_model, this.Actions.CREATE, thisItem).then((result) => {
|
|
||||||
// this.items = result.data - refresh the list here
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
|
||||||
}).catch((err) => {
|
|
||||||
console.log(err)
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.genericAPI(this.this_model, this.Actions.UPDATE, thisItem).then((result) => {
|
|
||||||
// this.refreshThis(thisItem.id) refresh the list here
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
|
||||||
}).catch((err) => {
|
|
||||||
console.log(err, err.response)
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getRecipe: function (item) {
|
|
||||||
// change to get pop up card? maybe same for unit and food?
|
|
||||||
},
|
|
||||||
deleteThis: function (id) {
|
|
||||||
this.genericAPI(this.this_model, this.Actions.DELETE, {'id': id}).then((result) => {
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
|
||||||
}).catch((err) => {
|
|
||||||
console.log(err)
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
clearState: function () {
|
|
||||||
this.show_modal = false
|
|
||||||
this.this_action = undefined
|
|
||||||
this.this_item = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
@ -1,18 +0,0 @@
|
|||||||
import Vue from 'vue'
|
|
||||||
import App from './ChecklistView'
|
|
||||||
import i18n from '@/i18n'
|
|
||||||
|
|
||||||
Vue.config.productionTip = false
|
|
||||||
|
|
||||||
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
|
|
||||||
let publicPath = localStorage.STATIC_URL + 'vue/'
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
publicPath = 'http://localhost:8080/'
|
|
||||||
}
|
|
||||||
export default __webpack_public_path__ = publicPath // eslint-disable-line
|
|
||||||
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
i18n,
|
|
||||||
render: h => h(App),
|
|
||||||
}).$mount('#app')
|
|
@ -61,10 +61,10 @@ import Vue from 'vue'
|
|||||||
import {BootstrapVue} from 'bootstrap-vue'
|
import {BootstrapVue} from 'bootstrap-vue'
|
||||||
|
|
||||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||||
import CookbookSlider from "@/components/CookbookSlider";
|
import CookbookSlider from "../../components/CookbookSlider";
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import LoadingSpinner from "../../components/LoadingSpinner";
|
||||||
import {StandardToasts} from "@/utils/utils";
|
import {StandardToasts} from "../../utils/utils";
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -25,14 +25,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3" style="position: relative; margin-top: 1vh">
|
<div class="col-md-3" style="position: relative; margin-top: 1vh">
|
||||||
<b-form-checkbox
|
<b-form-checkbox v-model="show_split" name="check-button" v-if="paginated" class="shadow-none" style="position: relative; top: 50%; transform: translateY(-50%)" switch>
|
||||||
v-model="show_split"
|
|
||||||
name="check-button"
|
|
||||||
v-if="paginated"
|
|
||||||
class="shadow-none"
|
|
||||||
style="position: relative; top: 50%; transform: translateY(-50%)"
|
|
||||||
switch
|
|
||||||
>
|
|
||||||
{{ $t("show_split_screen") }}
|
{{ $t("show_split_screen") }}
|
||||||
</b-form-checkbox>
|
</b-form-checkbox>
|
||||||
</div>
|
</div>
|
||||||
@ -42,46 +35,19 @@
|
|||||||
<div class="col" :class="{ 'col-md-6': show_split }">
|
<div class="col" :class="{ 'col-md-6': show_split }">
|
||||||
<!-- model isn't paginated and loads in one API call -->
|
<!-- model isn't paginated and loads in one API call -->
|
||||||
<div v-if="!paginated">
|
<div v-if="!paginated">
|
||||||
<generic-horizontal-card
|
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
|
||||||
v-for="i in items_left"
|
|
||||||
v-bind:key="i.id"
|
|
||||||
:item="i"
|
|
||||||
:model="this_model"
|
|
||||||
@item-action="startAction($event, 'left')"
|
|
||||||
@finish-action="finishAction"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- model is paginated and needs managed -->
|
<!-- model is paginated and needs managed -->
|
||||||
<generic-infinite-cards v-if="paginated" :card_counts="left_counts" :scroll="show_split" @search="getItems($event, 'left')" @reset="resetList('left')">
|
<generic-infinite-cards v-if="paginated" :card_counts="left_counts" :scroll="show_split" @search="getItems($event, 'left')" @reset="resetList('left')">
|
||||||
<template v-slot:cards>
|
<template v-slot:cards>
|
||||||
<generic-horizontal-card
|
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
|
||||||
v-for="i in items_left"
|
|
||||||
v-bind:key="i.id"
|
|
||||||
:item="i"
|
|
||||||
:model="this_model"
|
|
||||||
@item-action="startAction($event, 'left')"
|
|
||||||
@finish-action="finishAction"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</generic-infinite-cards>
|
</generic-infinite-cards>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6" v-if="show_split">
|
<div class="col col-md-6" v-if="show_split">
|
||||||
<generic-infinite-cards
|
<generic-infinite-cards v-if="this_model" :card_counts="right_counts" :scroll="show_split" @search="getItems($event, 'right')" @reset="resetList('right')">
|
||||||
v-if="this_model"
|
|
||||||
:card_counts="right_counts"
|
|
||||||
:scroll="show_split"
|
|
||||||
@search="getItems($event, 'right')"
|
|
||||||
@reset="resetList('right')"
|
|
||||||
>
|
|
||||||
<template v-slot:cards>
|
<template v-slot:cards>
|
||||||
<generic-horizontal-card
|
<generic-horizontal-card v-for="i in items_right" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'right')" @finish-action="finishAction" />
|
||||||
v-for="i in items_right"
|
|
||||||
v-bind:key="i.id"
|
|
||||||
:item="i"
|
|
||||||
:model="this_model"
|
|
||||||
@item-action="startAction($event, 'right')"
|
|
||||||
@finish-action="finishAction"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</generic-infinite-cards>
|
</generic-infinite-cards>
|
||||||
</div>
|
</div>
|
||||||
@ -98,13 +64,12 @@ import { BootstrapVue } from "bootstrap-vue"
|
|||||||
|
|
||||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
|
||||||
import { CardMixin, ApiMixin, getConfig } from "@/utils/utils"
|
import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference, makeToast } from "@/utils/utils"
|
||||||
import { StandardToasts, ToastMixin } from "@/utils/utils"
|
|
||||||
|
|
||||||
import GenericInfiniteCards from "@/components/GenericInfiniteCards"
|
import GenericInfiniteCards from "@/components/GenericInfiniteCards"
|
||||||
import GenericHorizontalCard from "@/components/GenericHorizontalCard"
|
import GenericHorizontalCard from "@/components/GenericHorizontalCard"
|
||||||
import GenericModalForm from "@/components/Modals/GenericModalForm"
|
import GenericModalForm from "@/components/Modals/GenericModalForm"
|
||||||
import ModelMenu from "@/components/ModelMenu"
|
import ModelMenu from "@/components/ContextMenu/ModelMenu"
|
||||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||||
//import StorageQuota from "@/components/StorageQuota";
|
//import StorageQuota from "@/components/StorageQuota";
|
||||||
|
|
||||||
@ -114,13 +79,8 @@ export default {
|
|||||||
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
|
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
|
||||||
// or i'm capturing it incorrectly
|
// or i'm capturing it incorrectly
|
||||||
name: "ModelListView",
|
name: "ModelListView",
|
||||||
mixins: [CardMixin, ApiMixin, ToastMixin],
|
mixins: [CardMixin, ApiMixin],
|
||||||
components: {
|
components: { GenericHorizontalCard, GenericModalForm, GenericInfiniteCards, ModelMenu },
|
||||||
GenericHorizontalCard,
|
|
||||||
GenericModalForm,
|
|
||||||
GenericInfiniteCards,
|
|
||||||
ModelMenu,
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
// this.Models and this.Actions inherited from ApiMixin
|
// this.Models and this.Actions inherited from ApiMixin
|
||||||
@ -236,6 +196,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
finishAction: function (e) {
|
finishAction: function (e) {
|
||||||
|
let update = undefined
|
||||||
switch (e?.action) {
|
switch (e?.action) {
|
||||||
case "save":
|
case "save":
|
||||||
this.saveThis(e.form_data)
|
this.saveThis(e.form_data)
|
||||||
@ -263,7 +224,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.clearState()
|
this.clearState()
|
||||||
},
|
},
|
||||||
getItems: function (params, col) {
|
getItems: function (params = {}, col) {
|
||||||
let column = col || "left"
|
let column = col || "left"
|
||||||
params.options = { query: { extended: 1 } } // returns extended values in API response
|
params.options = { query: { extended: 1 } } // returns extended values in API response
|
||||||
this.genericAPI(this.this_model, this.Actions.LIST, params)
|
this.genericAPI(this.this_model, this.Actions.LIST, params)
|
||||||
|
@ -629,7 +629,6 @@ export default {
|
|||||||
|
|
||||||
apiFactory.updateRecipe(this.recipe_id, this.recipe,
|
apiFactory.updateRecipe(this.recipe_id, this.recipe,
|
||||||
{}).then((response) => {
|
{}).then((response) => {
|
||||||
console.log(response)
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||||
this.recipe_changed = false
|
this.recipe_changed = false
|
||||||
if (view_after) {
|
if (view_after) {
|
||||||
|
@ -1,90 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
<loading-spinner></loading-spinner>
|
<loading-spinner></loading-spinner>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="!loading">
|
<div v-if="!loading">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12" style="text-align: center">
|
<div class="col-12" style="text-align: center">
|
||||||
<h3>{{ recipe.name }}</h3>
|
<h3>{{ recipe.name }}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row text-center">
|
|
||||||
<div class="col col-md-12">
|
|
||||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
|
||||||
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-auto">
|
|
||||||
<div class="col-12" style="text-align: center">
|
|
||||||
<i>{{ recipe.description }}</i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center">
|
|
||||||
<keywords-component :recipe="recipe"></keywords-component>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr/>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col col-md-3">
|
|
||||||
<div class="row d-flex" style="padding-left: 16px">
|
|
||||||
<div class="my-auto" style="padding-right: 4px">
|
|
||||||
<i class="fas fa-user-clock fa-2x text-primary"></i>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto" style="padding-right: 4px">
|
|
||||||
<span class="text-primary"><b>{{ $t('Preparation') }}</b></span><br/>
|
|
||||||
{{ recipe.working_time }} {{ $t('min') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col col-md-3">
|
<div class="row text-center">
|
||||||
<div class="row d-flex">
|
<div class="col col-md-12">
|
||||||
<div class="my-auto" style="padding-right: 4px">
|
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||||
<i class="far fa-clock fa-2x text-primary"></i>
|
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto" style="padding-right: 4px">
|
|
||||||
<span class="text-primary"><b>{{ $t('Waiting') }}</b></span><br/>
|
|
||||||
{{ recipe.waiting_time }} {{ $t('min') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col col-md-4 col-10 mt-2 mt-md-0 mt-lg-0 mt-xl-0">
|
<div class="my-auto">
|
||||||
<div class="row d-flex" style="padding-left: 16px">
|
<div class="col-12" style="text-align: center">
|
||||||
<div class="my-auto" style="padding-right: 4px">
|
<i>{{ recipe.description }}</i>
|
||||||
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto" style="padding-right: 4px">
|
|
||||||
<input
|
|
||||||
style="text-align: right; border-width:0px;border:none; padding:0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
|
|
||||||
value="1" maxlength="3" min="0"
|
|
||||||
type="number" class="form-control form-control-lg" v-model.number="servings"/>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
|
<div style="text-align: center">
|
||||||
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
|
<keywords :recipe="recipe"></keywords>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<hr/>
|
|
||||||
|
|
||||||
<div class="row">
|
<hr />
|
||||||
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
|
<div class="row">
|
||||||
<div class="card border-primary">
|
<div class="col col-md-3">
|
||||||
<div class="card-body">
|
<div class="row d-flex" style="padding-left: 16px">
|
||||||
<div class="row">
|
<div class="my-auto" style="padding-right: 4px">
|
||||||
<div class="col col-md-8">
|
<i class="fas fa-user-clock fa-2x text-primary"></i>
|
||||||
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h4>
|
</div>
|
||||||
|
<div class="my-auto" style="padding-right: 4px">
|
||||||
|
<span class="text-primary"
|
||||||
|
><b>{{ $t("Preparation") }}</b></span
|
||||||
|
><br />
|
||||||
|
{{ recipe.working_time }} {{ $t("min") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col col-md-3">
|
||||||
|
<div class="row d-flex">
|
||||||
|
<div class="my-auto" style="padding-right: 4px">
|
||||||
|
<i class="far fa-clock fa-2x text-primary"></i>
|
||||||
|
</div>
|
||||||
|
<div class="my-auto" style="padding-right: 4px">
|
||||||
|
<span class="text-primary"
|
||||||
|
><b>{{ $t("Waiting") }}</b></span
|
||||||
|
><br />
|
||||||
|
{{ recipe.waiting_time }} {{ $t("min") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br/>
|
<br/>
|
||||||
@ -94,184 +65,186 @@
|
|||||||
<template v-if="s.show_as_header && s.name !== '' && s.ingredients.length > 0">
|
<template v-if="s.show_as_header && s.name !== '' && s.ingredients.length > 0">
|
||||||
<b v-bind:key="s.id">{{s.name}}</b>
|
<b v-bind:key="s.id">{{s.name}}</b>
|
||||||
</template>
|
</template>
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<template v-for="i in s.ingredients" :key="i.id">
|
<template v-for="i in s.ingredients" :key="i.id">
|
||||||
<ingredient-component :ingredient="i" :ingredient_factor="ingredient_factor"
|
<ingredient-component :ingredient="i" :ingredient_factor="ingredient_factor"
|
||||||
@checked-state-changed="updateIngredientCheckedState"></ingredient-component>
|
@checked-state-changed="updateIngredientCheckedState"></ingredient-component>
|
||||||
</template>
|
</template>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
|
||||||
|
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<hr />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
|
<div class="row">
|
||||||
<div class="row">
|
<div class="col-md-8 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
|
||||||
<div class="col-12">
|
<ingredients-card
|
||||||
|
:steps="recipe.steps"
|
||||||
|
:recipe="recipe.id"
|
||||||
|
:ingredient_factor="ingredient_factor"
|
||||||
|
:servings="servings"
|
||||||
|
:header="true"
|
||||||
|
@checked-state-changed="updateIngredientCheckedState"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh;"
|
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-4 order-md-2">
|
||||||
:alt="$t( 'Recipe_Image')" v-if="recipe.image !== null">
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
|
||||||
|
<div class="col-12">
|
||||||
|
<Nutrition :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
|
<template v-if="!recipe.internal">
|
||||||
<div class="col-12">
|
<div v-if="recipe.file_path.includes('.pdf')">
|
||||||
<Nutrition-component :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition-component>
|
<PdfViewer :recipe="recipe"></PdfViewer>
|
||||||
|
</div>
|
||||||
|
<div v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
||||||
|
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||||
|
<Step
|
||||||
|
:recipe="recipe"
|
||||||
|
:step="s"
|
||||||
|
:ingredient_factor="ingredient_factor"
|
||||||
|
:index="index"
|
||||||
|
:start_time="start_time"
|
||||||
|
@update-start-time="updateStartTime"
|
||||||
|
@checked-state-changed="updateIngredientCheckedState"
|
||||||
|
></Step>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
|
||||||
|
|
||||||
</div>
|
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
|
||||||
|
<div class="col col-md-12">
|
||||||
<template v-if="!recipe.internal">
|
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t("Report Abuse") }}</a>
|
||||||
<div v-if="recipe.file_path.includes('.pdf')">
|
</div>
|
||||||
<PdfViewer :recipe="recipe"></PdfViewer>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
|
||||||
<ImageViewer :recipe="recipe"></ImageViewer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
|
||||||
<step-component :recipe="recipe" :step="s" :ingredient_factor="ingredient_factor" :index="index" :start_time="start_time"
|
|
||||||
@update-start-time="updateStartTime" @checked-state-changed="updateIngredientCheckedState"></step-component>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
|
|
||||||
|
|
||||||
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
|
|
||||||
<div class="col col-md-12">
|
|
||||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t('Report Abuse') }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Vue from 'vue'
|
import Vue from "vue"
|
||||||
import {BootstrapVue} from 'bootstrap-vue'
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
|
||||||
import {apiLoadRecipe} from "@/utils/api";
|
import { apiLoadRecipe } from "@/utils/api"
|
||||||
|
|
||||||
import Step from "@/components/StepComponent";
|
import Step from "@/components/Step"
|
||||||
import RecipeContextMenu from "@/components/RecipeContextMenu";
|
import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
|
||||||
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
|
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
|
||||||
import Ingredient from "@/components/IngredientComponent";
|
import IngredientsCard from "@/components/IngredientsCard"
|
||||||
|
|
||||||
import PdfViewer from "@/components/PdfViewer";
|
import PdfViewer from "@/components/PdfViewer"
|
||||||
import ImageViewer from "@/components/ImageViewer";
|
import ImageViewer from "@/components/ImageViewer"
|
||||||
import Nutrition from "@/components/NutritionComponent";
|
import Nutrition from "@/components/Nutrition"
|
||||||
|
|
||||||
import moment from 'moment'
|
import moment from "moment"
|
||||||
import Keywords from "@/components/KeywordsComponent";
|
import Keywords from "@/components/Keywords"
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
import AddRecipeToBook from "@/components/AddRecipeToBook";
|
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||||
import RecipeRating from "@/components/RecipeRating";
|
import RecipeRating from "@/components/RecipeRating"
|
||||||
import LastCooked from "@/components/LastCooked";
|
import LastCooked from "@/components/LastCooked"
|
||||||
import IngredientComponent from "@/components/IngredientComponent";
|
|
||||||
import StepComponent from "@/components/StepComponent";
|
|
||||||
import KeywordsComponent from "@/components/KeywordsComponent";
|
|
||||||
import NutritionComponent from "@/components/NutritionComponent";
|
|
||||||
|
|
||||||
Vue.prototype.moment = moment
|
Vue.prototype.moment = moment
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RecipeView',
|
name: "RecipeView",
|
||||||
mixins: [
|
mixins: [ResolveUrlMixin, ToastMixin],
|
||||||
ResolveUrlMixin,
|
components: {
|
||||||
ToastMixin,
|
LastCooked,
|
||||||
],
|
RecipeRating,
|
||||||
components: {
|
PdfViewer,
|
||||||
LastCooked,
|
ImageViewer,
|
||||||
RecipeRating,
|
IngredientsCard,
|
||||||
PdfViewer,
|
Step,
|
||||||
ImageViewer,
|
RecipeContextMenu,
|
||||||
IngredientComponent,
|
Nutrition,
|
||||||
StepComponent,
|
Keywords,
|
||||||
RecipeContextMenu,
|
LoadingSpinner,
|
||||||
NutritionComponent,
|
AddRecipeToBook,
|
||||||
KeywordsComponent,
|
|
||||||
LoadingSpinner,
|
|
||||||
AddRecipeToBook,
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
ingredient_factor: function () {
|
|
||||||
return this.servings / this.recipe.servings
|
|
||||||
},
|
},
|
||||||
},
|
computed: {
|
||||||
data() {
|
ingredient_factor: function () {
|
||||||
return {
|
return this.servings / this.recipe.servings
|
||||||
loading: true,
|
},
|
||||||
recipe: undefined,
|
|
||||||
ingredient_count: 0,
|
|
||||||
servings: 1,
|
|
||||||
start_time: "",
|
|
||||||
share_uid: window.SHARE_UID
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.loadRecipe(window.RECIPE_ID)
|
|
||||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
loadRecipe: function (recipe_id) {
|
|
||||||
apiLoadRecipe(recipe_id).then(recipe => {
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
step.time_offset = total_time
|
|
||||||
total_time += step.time
|
|
||||||
}
|
|
||||||
|
|
||||||
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
|
||||||
if (total_time > 0) {
|
|
||||||
this.start_time = moment().format('yyyy-MM-DDTHH:mm')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.recipe = recipe
|
|
||||||
this.loading = false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
updateStartTime: function (e) {
|
data() {
|
||||||
this.start_time = e
|
return {
|
||||||
},
|
loading: true,
|
||||||
updateIngredientCheckedState: function (e) {
|
recipe: undefined,
|
||||||
for (let step of this.recipe.steps) {
|
ingredient_count: 0,
|
||||||
for (let ingredient of step.ingredients) {
|
servings: 1,
|
||||||
if (ingredient.id === e.id) {
|
start_time: "",
|
||||||
this.$set(ingredient, 'checked', !ingredient.checked)
|
share_uid: window.SHARE_UID,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
mounted() {
|
||||||
|
this.loadRecipe(window.RECIPE_ID)
|
||||||
|
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadRecipe: function (recipe_id) {
|
||||||
|
apiLoadRecipe(recipe_id).then((recipe) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
step.time_offset = total_time
|
||||||
|
total_time += step.time
|
||||||
|
}
|
||||||
|
|
||||||
|
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
||||||
|
if (total_time > 0) {
|
||||||
|
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recipe = recipe
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateStartTime: function (e) {
|
||||||
|
this.start_time = e
|
||||||
|
},
|
||||||
|
|
||||||
|
updateIngredientCheckedState: function (e) {
|
||||||
|
for (let step of this.recipe.steps) {
|
||||||
|
for (let ingredient of step.ingredients) {
|
||||||
|
if (ingredient.id === e.id) {
|
||||||
|
this.$set(ingredient, "checked", !ingredient.checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style></style>
|
||||||
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
805
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
805
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
@ -0,0 +1,805 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app" style="margin-bottom: 4vh">
|
||||||
|
<b-alert :show="!online" dismissible class="small float-up" variant="warning">{{ $t("OfflineAlert") }}</b-alert>
|
||||||
|
<div class="row float-top">
|
||||||
|
<div class="offset-md-10 col-md-2 no-gutter text-right">
|
||||||
|
<b-button variant="link" class="px-0">
|
||||||
|
<i class="btn fas fa-plus-circle fa-lg px-0" @click="entrymode = !entrymode" :class="entrymode ? 'text-success' : 'text-muted'" />
|
||||||
|
</b-button>
|
||||||
|
<b-button variant="link" id="id_filters_button" class="px-0">
|
||||||
|
<i class="btn fas fa-filter text-decoration-none fa-lg px-0" :class="filterApplied ? 'text-danger' : 'text-muted'" />
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<b-tabs content-class="mt-3">
|
||||||
|
<!-- shopping list tab -->
|
||||||
|
<b-tab :title="$t('ShoppingList')" active>
|
||||||
|
<template #title> <b-spinner v-if="loading" type="border" small></b-spinner> {{ $t("ShoppingList") }} </template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
<div role="tablist" v-if="items && items.length > 0">
|
||||||
|
<div class="row justify-content-md-center w-75" v-if="entrymode">
|
||||||
|
<div class="col col-md-2 "><b-form-input min="1" type="number" :description="$t('Amount')" v-model="new_item.amount"></b-form-input></div>
|
||||||
|
<div class="col col-md-3">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="new_item.unit = $event.val"
|
||||||
|
:model="Models.UNIT"
|
||||||
|
:multiple="false"
|
||||||
|
:allow_create="false"
|
||||||
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
:placeholder="$t('Unit')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-4">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="new_item.food = $event.val"
|
||||||
|
:model="Models.FOOD"
|
||||||
|
:multiple="false"
|
||||||
|
:allow_create="false"
|
||||||
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
:placeholder="$t('Food')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-1 ">
|
||||||
|
<b-button variant="link" class="px-0">
|
||||||
|
<i class="btn fas fa-cart-plus fa-lg px-0 text-success" @click="addItem" />
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(done, x) in Sections" :key="x">
|
||||||
|
<div v-if="x == 'true'">
|
||||||
|
<hr />
|
||||||
|
<hr />
|
||||||
|
<h4>{{ $t("Completed") }}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(s, i) in done" :key="i">
|
||||||
|
<h5 v-if="Object.entries(s).length > 0">
|
||||||
|
<b-button
|
||||||
|
class="btn btn-lg text-decoration-none text-dark px-1 py-0 border-0"
|
||||||
|
variant="link"
|
||||||
|
data-toggle="collapse"
|
||||||
|
:href="'#section-' + sectionID(x, i)"
|
||||||
|
:aria-expanded="'true' ? x == 'false' : 'true'"
|
||||||
|
>
|
||||||
|
<i class="fa fa-chevron-right rotate" />
|
||||||
|
{{ i }}
|
||||||
|
</b-button>
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }">
|
||||||
|
<!-- passing an array of values to the table grouped by Food -->
|
||||||
|
<div v-for="(entries, x) in Object.entries(s)" :key="x">
|
||||||
|
<ShoppingLineItem :entries="entries[1]" :groupby="group_by" @open-context-menu="openContextMenu" @update-checkbox="updateChecked" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</b-tab>
|
||||||
|
<!-- recipe tab -->
|
||||||
|
<b-tab :title="$t('Recipes')">
|
||||||
|
<table class="table w-75">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{ $t("Meal_Plan") }}</th>
|
||||||
|
<th scope="col">{{ $t("Recipe") }}</th>
|
||||||
|
<th scope="col">{{ $t("Servings") }}</th>
|
||||||
|
<th scope="col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tr v-for="r in Recipes" :key="r.list_recipe">
|
||||||
|
<td>{{ r.recipe_mealplan.name }}</td>
|
||||||
|
<td>{{ r.recipe_mealplan.recipe_name }}</td>
|
||||||
|
<td class="block-inline">
|
||||||
|
<b-form-input min="1" type="number" :debounce="300" :value="r.recipe_mealplan.servings" @input="updateServings($event, r.list_recipe)"></b-form-input>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<i class="btn text-danger fas fa-trash fa-lg px-2 border-0" variant="link" :title="$t('Delete')" @click="deleteRecipe($event, r.list_recipe)" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</b-tab>
|
||||||
|
<!-- settings tab -->
|
||||||
|
<b-tab :title="$t('Settings')">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-4 ">
|
||||||
|
<b-card class="no-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div>
|
||||||
|
<div class="col col-md-6 text-right">
|
||||||
|
<input type="checkbox" size="sm" v-model="settings.mealplan_autoadd_shopping" @change="saveSettings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row sm mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="settings.mealplan_autoadd_shopping">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div>
|
||||||
|
<div class="col col-md-6 text-right">
|
||||||
|
<input type="checkbox" size="sm" v-model="settings.mealplan_autoexclude_onhand" @change="saveSettings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row sm mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="settings.mealplan_autoadd_shopping">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">{{ $t("mealplan_autoinclude_related") }}</div>
|
||||||
|
<div class="col col-md-6 text-right">
|
||||||
|
<input type="checkbox" size="sm" v-model="settings.mealplan_autoinclude_related" @change="saveSettings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row sm mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<em class="small text-muted">
|
||||||
|
{{ $t("mealplan_autoinclude_related_desc") }}
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">{{ $t("shopping_share") }}</div>
|
||||||
|
<div class="col col-md-6 text-right">
|
||||||
|
<generic-multiselect
|
||||||
|
size="sm"
|
||||||
|
@change="
|
||||||
|
settings.shopping_share = $event.val
|
||||||
|
saveSettings()
|
||||||
|
"
|
||||||
|
:model="Models.USER"
|
||||||
|
:initial_selection="settings.shopping_share"
|
||||||
|
label="username"
|
||||||
|
:multiple="true"
|
||||||
|
:allow_create="false"
|
||||||
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
:placeholder="$t('User')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row sm mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<em class="small text-muted">{{ $t("shopping_share_desc") }}</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">{{ $t("shopping_auto_sync") }}</div>
|
||||||
|
<div class="col col-md-6 text-right">
|
||||||
|
<input type="number" size="sm" v-model="settings.shopping_auto_sync" @change="saveSettings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row sm mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<em class="small text-muted">
|
||||||
|
{{ $t("shopping_auto_sync_desc") }}
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">{{ $t("filter_to_supermarket") }}</div>
|
||||||
|
<div class="col col-md-6 text-right">
|
||||||
|
<input type="checkbox" size="sm" v-model="settings.filter_to_supermarket" @change="saveSettings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row sm mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<em class="small text-muted">
|
||||||
|
{{ $t("filter_to_supermarket_desc") }}
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">{{ $t("default_delay") }}</div>
|
||||||
|
<div class="col col-md-6 text-right">
|
||||||
|
<input type="number" size="sm" min="1" v-model="settings.default_delay" @change="saveSettings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row sm mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<em class="small text-muted">
|
||||||
|
{{ $t("default_delay_desc") }}
|
||||||
|
</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</b-card>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-8">
|
||||||
|
<b-card class=" no-body">
|
||||||
|
put the supermarket stuff here<br />
|
||||||
|
-add supermarkets<br />
|
||||||
|
-add supermarket categories<br />
|
||||||
|
-sort supermarket categories<br />
|
||||||
|
</b-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</b-tab>
|
||||||
|
</b-tabs>
|
||||||
|
<b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')">
|
||||||
|
<div>
|
||||||
|
<b-form-group v-bind:label="$t('GroupBy')" label-for="popover-input-1" label-cols="6" class="mb-1">
|
||||||
|
<b-form-select v-model="group_by" :options="group_by_choices" size="sm"></b-form-select>
|
||||||
|
</b-form-group>
|
||||||
|
<b-form-group v-bind:label="$t('Supermarket')" label-for="popover-input-2" label-cols="6" class="mb-1">
|
||||||
|
<b-form-select v-model="selected_supermarket" :options="supermarkets" text-field="name" value-field="id" size="sm"></b-form-select>
|
||||||
|
</b-form-group>
|
||||||
|
<b-form-group v-bind:label="$t('ShowDelayed')" label-for="popover-input-3" content-cols="1" class="mb-1">
|
||||||
|
<b-form-checkbox v-model="show_delay"></b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
<b-form-group v-bind:label="$t('ShowUncategorizedFood')" label-for="popover-input-4" content-cols="1" class="mb-1" v-if="!selected_supermarket">
|
||||||
|
<b-form-checkbox v-model="show_undefined_categories"></b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
<b-form-group v-bind:label="$t('SupermarketCategoriesOnly')" label-for="popover-input-5" content-cols="1" class="mb-1" v-if="selected_supermarket">
|
||||||
|
<b-form-checkbox v-model="supermarket_categories_only"></b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
</div>
|
||||||
|
<div class="row " style="margin-top: 1vh;min-width:300px">
|
||||||
|
<div class="col-12 " style="text-align: right;">
|
||||||
|
<b-button size="sm" variant="primary" class="mx-1" @click="resetFilters">{{ $t("Reset") }} </b-button>
|
||||||
|
<b-button size="sm" variant="secondary" class="mr-3" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</b-popover>
|
||||||
|
<ContextMenu ref="menu">
|
||||||
|
<template #menu="{ contextData }">
|
||||||
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
moveEntry($event, contextData)
|
||||||
|
$refs.menu.close()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<b-form-group label-cols="6" content-cols="6" class="text-nowrap m-0 mr-2">
|
||||||
|
<template #label>
|
||||||
|
<a class="dropdown-item p-2" href="#"><i class="fas fa-cubes"></i> {{ $t("MoveCategory") }}</a>
|
||||||
|
</template>
|
||||||
|
<span @click.prevent.stop @mouseup.prevent.stop>
|
||||||
|
<!-- would like to hide the dropdown value and only display value in button - not sure how to do that -->
|
||||||
|
<b-form-select class="mt-2 border-0" :options="shopping_categories" text-field="name" value-field="id" v-model="shopcat"></b-form-select>
|
||||||
|
</span>
|
||||||
|
</b-form-group>
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
onHand(contextData)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<a class="dropdown-item p-2" href="#"><i class="fas fa-clipboard-check"></i> {{ $t("OnHand") }}</a>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
delayThis(contextData)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<b-form-group label-cols="10" content-cols="2" class="text-nowrap m-0 mr-2">
|
||||||
|
<template #label>
|
||||||
|
<a class="dropdown-item p-2" href="#"><i class="far fa-hourglass"></i> {{ $t("DelayFor", { hours: delay }) }}</a>
|
||||||
|
</template>
|
||||||
|
<div @click.prevent.stop>
|
||||||
|
<b-form-input class="mt-2" min="0" type="number" v-model="delay"></b-form-input>
|
||||||
|
</div>
|
||||||
|
</b-form-group>
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
ignoreThis(contextData)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<a class="dropdown-item p-2" href="#"><i class="fas fa-ban"></i> {{ $t("IgnoreThis", { food: foodName(contextData) }) }}</a>
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
deleteThis(contextData)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<a class="dropdown-item p-2 text-danger" href="#"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</template>
|
||||||
|
</ContextMenu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from "vue"
|
||||||
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
|
||||||
|
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||||
|
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||||
|
import ShoppingLineItem from "@/components/ShoppingLineItem"
|
||||||
|
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||||
|
|
||||||
|
import { ApiMixin, getUserPreference } from "@/utils/utils"
|
||||||
|
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||||
|
import { StandardToasts, makeToast } from "@/utils/utils"
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "ShoppingListView",
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
components: { ContextMenu, ContextMenuItem, ShoppingLineItem, GenericMultiselect },
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// this.Models and this.Actions inherited from ApiMixin
|
||||||
|
items: [],
|
||||||
|
group_by: "category",
|
||||||
|
group_by_choices: ["created_by", "category", "recipe"],
|
||||||
|
supermarkets: [],
|
||||||
|
shopping_categories: [],
|
||||||
|
selected_supermarket: undefined,
|
||||||
|
show_undefined_categories: true,
|
||||||
|
supermarket_categories_only: false,
|
||||||
|
shopcat: null,
|
||||||
|
delay: 0,
|
||||||
|
settings: {
|
||||||
|
shopping_auto_sync: 0,
|
||||||
|
default_delay: 4,
|
||||||
|
mealplan_autoadd_shopping: false,
|
||||||
|
mealplan_autoinclude_related: false,
|
||||||
|
mealplan_autoexclude_onhand: true,
|
||||||
|
filter_to_supermarket: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
autosync_id: undefined,
|
||||||
|
auto_sync_running: false,
|
||||||
|
show_delay: false,
|
||||||
|
show_modal: false,
|
||||||
|
fields: ["checked", "amount", "category", "unit", "food", "recipe", "details"],
|
||||||
|
loading: true,
|
||||||
|
entrymode: false,
|
||||||
|
new_item: { amount: 1, unit: undefined, food: undefined },
|
||||||
|
online: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
Sections() {
|
||||||
|
function getKey(item, group_by, x) {
|
||||||
|
switch (group_by) {
|
||||||
|
case "category":
|
||||||
|
return item?.food?.supermarket_category?.name ?? x
|
||||||
|
case "created_by":
|
||||||
|
return item?.created_by?.username ?? x
|
||||||
|
case "recipe":
|
||||||
|
return item?.recipe_mealplan?.recipe_name ?? x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shopping_list = this.items
|
||||||
|
|
||||||
|
// filter out list items that are delayed
|
||||||
|
if (!this.show_delay && shopping_list) {
|
||||||
|
shopping_list = shopping_list.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
.map((x) => x.category_to_supermarket)
|
||||||
|
.flat()
|
||||||
|
.map((x) => x.category.id)
|
||||||
|
shopping_list = shopping_list.filter((x) => shopping_categories.includes(x?.food?.supermarket_category?.id))
|
||||||
|
// if showing undefined is off, filter undefined
|
||||||
|
} else if (!this.show_undefined_categories) {
|
||||||
|
shopping_list = shopping_list.filter((x) => x?.food?.supermarket_category)
|
||||||
|
}
|
||||||
|
|
||||||
|
let 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)
|
||||||
|
.map((x) => x.category_to_supermarket)
|
||||||
|
.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] = {}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.shopping_categories.forEach((cat) => {
|
||||||
|
groups.false[cat.name] = {}
|
||||||
|
groups.true[cat.name] = {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
shopping_list.forEach((item) => {
|
||||||
|
let key = getKey(item, this.group_by, this.$t("Undefined"))
|
||||||
|
// first level of dict is done/not done
|
||||||
|
if (!groups[item.checked]) groups[item.checked] = {}
|
||||||
|
|
||||||
|
// second level of dict is this.group_by selection
|
||||||
|
if (!groups[item.checked][key]) groups[item.checked][key] = {}
|
||||||
|
|
||||||
|
// third level of dict is the food
|
||||||
|
if (groups[item.checked][key][item.food.name]) {
|
||||||
|
groups[item.checked][key][item.food.name].push(item)
|
||||||
|
} else {
|
||||||
|
groups[item.checked][key][item.food.name] = [item]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
},
|
||||||
|
defaultDelay() {
|
||||||
|
return getUserPreference("default_delay") || 2
|
||||||
|
},
|
||||||
|
itemsDelayed() {
|
||||||
|
return this.items.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now())).length < this.items.length
|
||||||
|
},
|
||||||
|
filterApplied() {
|
||||||
|
return (this.itemsDelayed && !this.show_delay) || !this.show_undefined_categories || (this.supermarket_categories_only && this.selected_supermarket)
|
||||||
|
},
|
||||||
|
Recipes() {
|
||||||
|
return [...new Map(this.items.filter((x) => x.list_recipe).map((item) => [item["list_recipe"], item])).values()]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selected_supermarket(newVal, oldVal) {
|
||||||
|
this.supermarket_categories_only = this.settings.filter_to_supermarket
|
||||||
|
},
|
||||||
|
"settings.filter_to_supermarket": function(newVal, oldVal) {
|
||||||
|
this.supermarket_categories_only = this.settings.filter_to_supermarket
|
||||||
|
},
|
||||||
|
"settings.shopping_auto_sync": function(newVal, oldVal) {
|
||||||
|
clearInterval(this.autosync_id)
|
||||||
|
this.autosync_id = undefined
|
||||||
|
if (!newVal) {
|
||||||
|
window.removeEventListener("online", this.updateOnlineStatus)
|
||||||
|
window.removeEventListener("offline", this.updateOnlineStatus)
|
||||||
|
return
|
||||||
|
} else if (oldVal === 0 && newVal > 0) {
|
||||||
|
window.addEventListener("online", this.updateOnlineStatus)
|
||||||
|
window.addEventListener("offline", this.updateOnlineStatus)
|
||||||
|
}
|
||||||
|
this.autosync_id = setInterval(() => {
|
||||||
|
if (this.online && !this.auto_sync_running) {
|
||||||
|
this.auto_sync_running = true
|
||||||
|
this.getShoppingList(true)
|
||||||
|
}
|
||||||
|
}, this.settings.shopping_auto_sync * 1000)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getShoppingList()
|
||||||
|
this.getSupermarkets()
|
||||||
|
this.getShoppingCategories()
|
||||||
|
|
||||||
|
this.settings = getUserPreference()
|
||||||
|
this.delay = this.settings.default_delay || 4
|
||||||
|
this.supermarket_categories_only = this.settings.filter_to_supermarket
|
||||||
|
if (this.settings.shopping_auto_sync) {
|
||||||
|
window.addEventListener("online", this.updateOnlineStatus)
|
||||||
|
window.addEventListener("offline", this.updateOnlineStatus)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// this.genericAPI inherited from ApiMixin
|
||||||
|
addItem() {
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
api.createShoppingListEntry(this.new_item)
|
||||||
|
.then((results) => {
|
||||||
|
if (results?.data) {
|
||||||
|
this.items.push(results.data)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
} else {
|
||||||
|
console.log("no data returned")
|
||||||
|
}
|
||||||
|
this.new_item = { amount: 1 }
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
categoryName: function(value) {
|
||||||
|
return this.shopping_categories.filter((x) => x.id == value)[0]?.name ?? ""
|
||||||
|
},
|
||||||
|
resetFilters: function() {
|
||||||
|
this.selected_supermarket = undefined
|
||||||
|
this.supermarket_categories_only = this.settings.filter_to_supermarket
|
||||||
|
this.show_undefined_categories = true
|
||||||
|
this.group_by = "category"
|
||||||
|
this.show_delay = false
|
||||||
|
},
|
||||||
|
delayThis: function(item) {
|
||||||
|
let entries = []
|
||||||
|
let promises = []
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
entries = item.map((x) => x.id)
|
||||||
|
} else {
|
||||||
|
entries = [item.id]
|
||||||
|
}
|
||||||
|
let delay_date = new Date(Date.now() + this.delay * (60 * 60 * 1000))
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
promises.push(this.saveThis({ id: entry, delay_until: delay_date }, false))
|
||||||
|
})
|
||||||
|
Promise.all(promises).then(() => {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||||
|
this.delay = this.defaultDelay
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteRecipe: function(e, recipe) {
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
api.destroyShoppingListRecipe(recipe)
|
||||||
|
.then((x) => {
|
||||||
|
this.items = this.items.filter((x) => x.list_recipe !== recipe)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteThis: function(item) {
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
let entries = []
|
||||||
|
let promises = []
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
entries = item.map((x) => x.id)
|
||||||
|
} else {
|
||||||
|
entries = [item.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.forEach((x) => {
|
||||||
|
promises.push(
|
||||||
|
api.destroyShoppingListEntry(x).catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Promise.all(promises).then((result) => {
|
||||||
|
this.items = this.items.filter((x) => !entries.includes(x.id))
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
foodName: function(value) {
|
||||||
|
return value?.food?.name ?? value?.[0]?.food?.name ?? ""
|
||||||
|
},
|
||||||
|
getShoppingCategories: function() {
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
api.listSupermarketCategorys().then((result) => {
|
||||||
|
this.shopping_categories = result.data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getShoppingList: function(autosync = false) {
|
||||||
|
let params = {}
|
||||||
|
params.supermarket = this.selected_supermarket
|
||||||
|
|
||||||
|
params.options = { query: { recent: 1 } }
|
||||||
|
if (autosync) {
|
||||||
|
params.options.query["autosync"] = 1
|
||||||
|
} else {
|
||||||
|
this.loading = true
|
||||||
|
}
|
||||||
|
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params)
|
||||||
|
.then((results) => {
|
||||||
|
if (!autosync) {
|
||||||
|
if (results.data?.length) {
|
||||||
|
this.items = results.data
|
||||||
|
} else {
|
||||||
|
console.log("no data returned")
|
||||||
|
}
|
||||||
|
this.loading = false
|
||||||
|
} else {
|
||||||
|
this.mergeShoppingList(results.data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
if (!autosync) {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getSupermarkets: function() {
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
api.listSupermarkets().then((result) => {
|
||||||
|
this.supermarkets = result.data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getThis: function(id) {
|
||||||
|
return this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.FETCH, { id: id })
|
||||||
|
},
|
||||||
|
ignoreThis: function(item) {
|
||||||
|
let food = {
|
||||||
|
id: item?.[0]?.food.id ?? item.food.id,
|
||||||
|
ignore_shopping: true,
|
||||||
|
}
|
||||||
|
this.updateFood(food, "ignore_shopping")
|
||||||
|
},
|
||||||
|
mergeShoppingList: function(data) {
|
||||||
|
this.items.map((x) =>
|
||||||
|
data.map((y) => {
|
||||||
|
if (y.id === x.id) {
|
||||||
|
x.checked = y.checked
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
this.auto_sync_running = false
|
||||||
|
},
|
||||||
|
moveEntry: function(e, item) {
|
||||||
|
if (!e) {
|
||||||
|
makeToast(this.$t("Warning"), this.$t("NoCategory"), "warning")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO make decision - should inheritance always be turned off when category 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")
|
||||||
|
this.shopcat = null
|
||||||
|
},
|
||||||
|
onHand: function(item) {
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
let food = {
|
||||||
|
id: item?.[0]?.food.id ?? item?.food?.id,
|
||||||
|
on_hand: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateFood(food)
|
||||||
|
.then((result) => {
|
||||||
|
let entries = this.items.filter((x) => x.food.id == food.id).map((x) => x.id)
|
||||||
|
this.items = this.items.filter((x) => x.food.id !== food.id)
|
||||||
|
return entries
|
||||||
|
})
|
||||||
|
.then((entries) => {
|
||||||
|
entries.forEach((x) => {
|
||||||
|
api.destroyShoppingListEntry(x).then((result) => {})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openContextMenu(e, value) {
|
||||||
|
this.shopcat = value?.food?.supermarket_category?.id ?? value?.[0]?.food?.supermarket_category?.id ?? undefined
|
||||||
|
this.$refs.menu.open(e, value)
|
||||||
|
},
|
||||||
|
saveSettings: function() {
|
||||||
|
let api = ApiApiFactory()
|
||||||
|
api.partialUpdateUserPreference(this.settings.user, this.settings)
|
||||||
|
.then((result) => {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
saveThis: function(thisItem, toast = true) {
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
if (!thisItem?.id) {
|
||||||
|
// if there is no item id assume it's a new item
|
||||||
|
return api
|
||||||
|
.createShoppingListEntry(thisItem)
|
||||||
|
.then((result) => {
|
||||||
|
if (toast) {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return api
|
||||||
|
.partialUpdateShoppingListEntry(thisItem.id, thisItem)
|
||||||
|
.then((result) => {
|
||||||
|
if (toast) {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err, err.response)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sectionID: function(a, b) {
|
||||||
|
return (a + b).replace(/\W/g, "")
|
||||||
|
},
|
||||||
|
updateChecked: function(update) {
|
||||||
|
// when checking a sub item don't refresh the screen until all entries complete but change class to cross out
|
||||||
|
let promises = []
|
||||||
|
update.entries.forEach((x) => {
|
||||||
|
promises.push(this.saveThis({ id: x, checked: update.checked }, false))
|
||||||
|
let item = this.items.filter((entry) => entry.id == x)[0]
|
||||||
|
|
||||||
|
Vue.set(item, "checked", update.checked)
|
||||||
|
if (update.checked) {
|
||||||
|
Vue.set(item, "completed_at", new Date().toISOString())
|
||||||
|
} else {
|
||||||
|
Vue.set(item, "completed_at", undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Promise.all(promises).catch((err) => {
|
||||||
|
console.log(err, err.response)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateFood: function(food, field) {
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
let ignore_category
|
||||||
|
if (field) {
|
||||||
|
ignore_category = food.ignore_inherit
|
||||||
|
.map((x) => food.ignore_inherit.fields)
|
||||||
|
.flat()
|
||||||
|
.includes(field)
|
||||||
|
} else {
|
||||||
|
ignore_category = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
.partialUpdateFood(food.id, food)
|
||||||
|
.then((result) => {
|
||||||
|
if (food.inherit && 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err, Object.keys(err))
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateServings(e, plan) {
|
||||||
|
// maybe this needs debounced?
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
api.partialUpdateShoppingListRecipe(plan, { id: plan, servings: e }).then(() => {
|
||||||
|
this.getShoppingList()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateOnlineStatus(e) {
|
||||||
|
const { type } = e
|
||||||
|
this.online = type === "online"
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener("online", this.updateOnlineStatus)
|
||||||
|
window.removeEventListener("offline", this.updateOnlineStatus)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rotate {
|
||||||
|
-moz-transition: all 0.25s linear;
|
||||||
|
-webkit-transition: all 0.25s linear;
|
||||||
|
transition: all 0.25s linear;
|
||||||
|
}
|
||||||
|
.btn[aria-expanded="true"] > .rotate {
|
||||||
|
-moz-transform: rotate(90deg);
|
||||||
|
-webkit-transform: rotate(90deg);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.float-top {
|
||||||
|
padding-bottom: -3em;
|
||||||
|
margin-bottom: -3em;
|
||||||
|
}
|
||||||
|
.float-up {
|
||||||
|
padding-top: -3em;
|
||||||
|
margin-top: -3em;
|
||||||
|
}
|
||||||
|
</style>
|
10
vue/src/apps/ShoppingListView/main.js
Normal file
10
vue/src/apps/ShoppingListView/main.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import i18n from "@/i18n"
|
||||||
|
import Vue from "vue"
|
||||||
|
import App from "./ShoppingListView"
|
||||||
|
|
||||||
|
Vue.config.productionTip = false
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
i18n,
|
||||||
|
render: (h) => h(App),
|
||||||
|
}).$mount("#app")
|
@ -4,16 +4,22 @@
|
|||||||
:item="item"/>
|
:item="item"/>
|
||||||
<icon-badge v-if="Icon"
|
<icon-badge v-if="Icon"
|
||||||
:item="item"/>
|
:item="item"/>
|
||||||
|
<on-hand-badge v-if="OnHand"
|
||||||
|
:item="item"/>
|
||||||
|
<shopping-badge v-if="Shopping"
|
||||||
|
:item="item"/>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import LinkedRecipe from "@/components/Badges/LinkedRecipe";
|
import LinkedRecipe from "@/components/Badges/LinkedRecipe";
|
||||||
import IconBadge from "@/components/Badges/Icon";
|
import IconBadge from "@/components/Badges/Icon";
|
||||||
|
import OnHandBadge from "@/components/Badges/OnHand";
|
||||||
|
import ShoppingBadge from "@/components/Badges/Shopping";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'CardBadges',
|
name: 'CardBadges',
|
||||||
components: {LinkedRecipe, IconBadge},
|
components: {LinkedRecipe, IconBadge, OnHandBadge, ShoppingBadge},
|
||||||
props: {
|
props: {
|
||||||
item: {type: Object},
|
item: {type: Object},
|
||||||
model: {type: Object}
|
model: {type: Object}
|
||||||
@ -30,6 +36,12 @@ export default {
|
|||||||
},
|
},
|
||||||
Icon: function () {
|
Icon: function () {
|
||||||
return this.model?.badges?.icon ?? false
|
return this.model?.badges?.icon ?? false
|
||||||
|
},
|
||||||
|
OnHand: function () {
|
||||||
|
return this.model?.badges?.on_hand ?? false
|
||||||
|
},
|
||||||
|
Shopping: function () {
|
||||||
|
return this.model?.badges?.shopping ?? false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
<b-button v-if="item.icon" class=" btn p-0 border-0" variant="link">
|
<b-button v-if="item.icon" class=" btn px-1 py-0 border-0 text-decoration-none" variant="link">
|
||||||
{{item.icon}}
|
{{item.icon}}
|
||||||
</b-button>
|
</b-button>
|
||||||
</span>
|
</span>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
<b-button v-if="item.recipe" v-b-tooltip.hover :title="item.recipe.name"
|
<b-button v-if="item.recipe" v-b-tooltip.hover :title="item.recipe.name"
|
||||||
class=" btn fas fa-book-open p-0 border-0" variant="link" :href="item.recipe.url"/>
|
class=" btn text-decoration-none fas fa-book-open px-1 py-0 border-0" variant="link" :href="item.recipe.url"/>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
40
vue/src/components/Badges/OnHand.vue
Normal file
40
vue/src/components/Badges/OnHand.vue
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<span>
|
||||||
|
<b-button class="btn text-decoration-none fas px-1 py-0 border-0" variant="link" v-b-popover.hover.html
|
||||||
|
:title="[onhand ? $t('FoodOnHand', {'food': item.name}) : $t('FoodNotOnHand', {'food': item.name})]"
|
||||||
|
:class="[onhand ? 'text-success fa-clipboard-check' : 'text-muted fa-clipboard' ]"
|
||||||
|
@click="toggleOnHand"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import {ApiMixin} from "@/utils/utils";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'OnHandBadge',
|
||||||
|
props: {
|
||||||
|
item: {type: Object}
|
||||||
|
},
|
||||||
|
mixins: [ ApiMixin ],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
onhand: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.onhand = this.item.on_hand
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleOnHand() {
|
||||||
|
let params = {'id': this.item.id, 'on_hand': !this.onhand}
|
||||||
|
this.genericAPI(this.Models.FOOD, this.Actions.UPDATE, params).then(() => {
|
||||||
|
this.onhand = !this.onhand
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
94
vue/src/components/Badges/Shopping.vue
Normal file
94
vue/src/components/Badges/Shopping.vue
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<span>
|
||||||
|
<b-button class="btn text-decoration-none px-1 border-0" variant="link"
|
||||||
|
v-if="ShowBadge"
|
||||||
|
:id="`shopping${item.id}`"
|
||||||
|
@click="addShopping()">
|
||||||
|
<i class="fas"
|
||||||
|
v-b-popover.hover.html
|
||||||
|
:title="[shopping ? $t('RemoveFoodFromShopping', {'food': item.name}) : $t('AddFoodToShopping', {'food': item.name})]"
|
||||||
|
:class="[shopping ? 'text-success fa-shopping-cart' : 'text-muted fa-cart-plus']"
|
||||||
|
/>
|
||||||
|
</b-button>
|
||||||
|
<b-popover :target="`${ShowConfirmation}`" :ref="'shopping'+item.id" triggers="focus" placement="top" >
|
||||||
|
<template #title>{{DeleteConfirmation}}</template>
|
||||||
|
<b-row align-h="end">
|
||||||
|
<b-col cols="auto"><b-button class="btn btn-sm btn-info shadow-none px-1 border-0" @click="cancelDelete()">{{$t("Cancel")}}</b-button>
|
||||||
|
<b-button class="btn btn-sm btn-danger shadow-none px-1" @click="confirmDelete()">{{$t("Confirm")}}</b-button></b-col>
|
||||||
|
</b-row >
|
||||||
|
</b-popover>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ShoppingBadge',
|
||||||
|
props: {
|
||||||
|
item: {type: Object},
|
||||||
|
override_ignore: {type: Boolean, default: false}
|
||||||
|
},
|
||||||
|
mixins: [ ApiMixin ],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
shopping: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// let random = [true, false,]
|
||||||
|
this.shopping = this.item?.shopping //?? random[Math.floor(Math.random() * random.length)]
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
ShowBadge() {
|
||||||
|
if (this.override_ignore) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return !this.item.ignore_shopping
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DeleteConfirmation() {
|
||||||
|
return this.$t('DeleteShoppingConfirm',{'food':this.item.name})
|
||||||
|
},
|
||||||
|
ShowConfirmation() {
|
||||||
|
if (this.shopping) {
|
||||||
|
return 'shopping' + this.item.id
|
||||||
|
} else {
|
||||||
|
return 'NoDialog'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addShopping() {
|
||||||
|
if (this.shopping) {return} // if item already in shopping list, excution handled after confirmation
|
||||||
|
let params = {
|
||||||
|
'id': this.item.id,
|
||||||
|
'amount': 1
|
||||||
|
}
|
||||||
|
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then((result) => {
|
||||||
|
this.shopping = true
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
cancelDelete() {
|
||||||
|
this.$refs['shopping' + this.item.id].$emit('close')
|
||||||
|
},
|
||||||
|
confirmDelete() {
|
||||||
|
let params = {
|
||||||
|
'id': this.item.id,
|
||||||
|
'_delete': 'true'
|
||||||
|
}
|
||||||
|
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then(() => {
|
||||||
|
this.shopping = false
|
||||||
|
this.$refs['shopping' + this.item.id].$emit('close')
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
@ -1,127 +1,118 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="context-menu" ref="popper" v-show="isVisible" tabindex="-1" v-click-outside="close" @contextmenu.capture.prevent>
|
||||||
class="context-menu"
|
<ul class="dropdown-menu" role="menu">
|
||||||
ref="popper"
|
<slot :contextData="contextData" name="menu" />
|
||||||
v-show="isVisible"
|
</ul>
|
||||||
tabindex="-1"
|
</div>
|
||||||
v-click-outside="close"
|
|
||||||
@contextmenu.capture.prevent>
|
|
||||||
<ul class="dropdown-menu" role="menu">
|
|
||||||
<slot :contextData="contextData" name="menu"/>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import Popper from 'popper.js';
|
import Popper from "popper.js"
|
||||||
|
|
||||||
Popper.Defaults.modifiers.computeStyle.gpuAcceleration = false
|
Popper.Defaults.modifiers.computeStyle.gpuAcceleration = false
|
||||||
import ClickOutside from 'vue-click-outside'
|
import ClickOutside from "vue-click-outside"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ContextMenu.vue",
|
name: "ContextMenu.vue",
|
||||||
props: {
|
props: {
|
||||||
boundariesElement: {
|
boundariesElement: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'body',
|
default: "body",
|
||||||
},
|
|
||||||
},
|
|
||||||
components: {},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
opened: false,
|
|
||||||
contextData: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
directives: {
|
|
||||||
ClickOutside,
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isVisible() {
|
|
||||||
return this.opened;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
open(evt, contextData) {
|
|
||||||
this.opened = true;
|
|
||||||
this.contextData = contextData;
|
|
||||||
|
|
||||||
if (this.popper) {
|
|
||||||
this.popper.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
|
|
||||||
placement: 'right-start',
|
|
||||||
modifiers: {
|
|
||||||
preventOverflow: {
|
|
||||||
boundariesElement: document.querySelector(this.boundariesElement),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.popper.scheduleUpdate();
|
|
||||||
});
|
|
||||||
|
|
||||||
},
|
},
|
||||||
close() {
|
components: {},
|
||||||
this.opened = false;
|
data() {
|
||||||
this.contextData = null;
|
|
||||||
},
|
|
||||||
referenceObject(evt) {
|
|
||||||
const left = evt.clientX;
|
|
||||||
const top = evt.clientY;
|
|
||||||
const right = left + 1;
|
|
||||||
const bottom = top + 1;
|
|
||||||
const clientWidth = 1;
|
|
||||||
const clientHeight = 1;
|
|
||||||
|
|
||||||
function getBoundingClientRect() {
|
|
||||||
return {
|
return {
|
||||||
left,
|
opened: false,
|
||||||
top,
|
contextData: {},
|
||||||
right,
|
}
|
||||||
bottom,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = {
|
|
||||||
getBoundingClientRect,
|
|
||||||
clientWidth,
|
|
||||||
clientHeight,
|
|
||||||
};
|
|
||||||
return obj;
|
|
||||||
},
|
},
|
||||||
},
|
directives: {
|
||||||
beforeUnmount() {
|
ClickOutside,
|
||||||
if (this.popper !== undefined) {
|
},
|
||||||
this.popper.destroy();
|
computed: {
|
||||||
}
|
isVisible() {
|
||||||
},
|
return this.opened
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
open(evt, contextData) {
|
||||||
|
this.opened = true
|
||||||
|
this.contextData = contextData
|
||||||
|
|
||||||
|
if (this.popper) {
|
||||||
|
this.popper.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
|
||||||
|
placement: "right-start",
|
||||||
|
modifiers: {
|
||||||
|
preventOverflow: {
|
||||||
|
boundariesElement: document.querySelector(this.boundariesElement),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.popper.scheduleUpdate()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.opened = false
|
||||||
|
this.contextData = null
|
||||||
|
},
|
||||||
|
referenceObject(evt) {
|
||||||
|
const left = evt.clientX
|
||||||
|
const top = evt.clientY
|
||||||
|
const right = left + 1
|
||||||
|
const bottom = top + 1
|
||||||
|
const clientWidth = 1
|
||||||
|
const clientHeight = 1
|
||||||
|
|
||||||
|
function getBoundingClientRect() {
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = {
|
||||||
|
getBoundingClientRect,
|
||||||
|
clientWidth,
|
||||||
|
clientHeight,
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
},
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.popper !== undefined) {
|
||||||
|
this.popper.destroy()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.context-menu {
|
.context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 1px 4px 0 #eee;
|
box-shadow: 0 1px 4px 0 #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu:focus {
|
.context-menu:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu ul {
|
.context-menu ul {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,16 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<li @click="$emit('click', $event)" role="presentation">
|
<li @click="$emit('click', $event)" role="presentation">
|
||||||
<slot/>
|
<slot />
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: "ContextMenuItem.vue",
|
name: "ContextMenuItem.vue",
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
34
vue/src/components/ContextMenu/ContextMenuSubmenu.vue
Normal file
34
vue/src/components/ContextMenu/ContextMenuSubmenu.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="position: static;" class=" btn-group">
|
||||||
|
<div class="dropdown b-dropdown position-static">
|
||||||
|
<li @click="$refs.submenu.open($event)" role="presentation" class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
|
||||||
|
<slot />
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ContextMenu ref="submenu">
|
||||||
|
<template #menu="{ contextData }">
|
||||||
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
moveEntry(contextData)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<a class="dropdown-item p-2" href="#"><i class="fas fa-cubes"></i> submenu item</a>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</template>
|
||||||
|
</ContextMenu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||||
|
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||||
|
export default {
|
||||||
|
name: "ContextSubmenu.vue",
|
||||||
|
components: { ContextMenu, ContextMenuItem },
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
@ -26,7 +26,11 @@
|
|||||||
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
|
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
|
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#">
|
||||||
|
<i class="fas fa-shopping-cart fa-fw"></i> New {{ $t('Add_to_Shopping') }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="dropdown-item" @click="createMealPlan" href="#"><i
|
||||||
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
|
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@ -76,6 +80,7 @@
|
|||||||
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]"
|
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]"
|
||||||
:entry-editing_initial_meal_type="[]" @save-entry="saveMealPlan"
|
:entry-editing_initial_meal_type="[]" @save-entry="saveMealPlan"
|
||||||
:modal_id="`modal-meal-plan_${modal_id}`" :allow_delete="false" :modal_title="$t('Create_Meal_Plan_Entry')"></meal-plan-edit-modal>
|
:modal_id="`modal-meal-plan_${modal_id}`" :allow_delete="false" :modal_title="$t('Create_Meal_Plan_Entry')"></meal-plan-edit-modal>
|
||||||
|
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -84,8 +89,9 @@
|
|||||||
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils";
|
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils";
|
||||||
import CookLog from "@/components/CookLog";
|
import CookLog from "@/components/CookLog";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import AddRecipeToBook from "./AddRecipeToBook";
|
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook";
|
||||||
import MealPlanEditModal from "@/components/MealPlanEditModal";
|
import MealPlanEditModal from "@/components/Modals/MealPlanEditModal";
|
||||||
|
import ShoppingModal from "@/components/Modals/ShoppingModal";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Vue from "vue";
|
import Vue from "vue";
|
||||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||||
@ -100,7 +106,8 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
AddRecipeToBook,
|
AddRecipeToBook,
|
||||||
CookLog,
|
CookLog,
|
||||||
MealPlanEditModal
|
MealPlanEditModal,
|
||||||
|
ShoppingModal
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -118,7 +125,7 @@ export default {
|
|||||||
servings: 1,
|
servings: 1,
|
||||||
shared: [],
|
shared: [],
|
||||||
title: '',
|
title: '',
|
||||||
title_placeholder: this.$t('Title')
|
title_placeholder: this.$t('Title'),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
entryEditing: {},
|
entryEditing: {},
|
||||||
@ -177,7 +184,10 @@ export default {
|
|||||||
url: this.recipe_share_link
|
url: this.recipe_share_link
|
||||||
}
|
}
|
||||||
navigator.share(shareData)
|
navigator.share(shareData)
|
||||||
}
|
},
|
||||||
|
addToShopping() {
|
||||||
|
this.$bvModal.show(`shopping_${this.modal_id}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
@ -92,13 +92,13 @@
|
|||||||
<i class="fas fa-times fa-fw"></i> <b>{{$t('Cancel')}}</b>
|
<i class="fas fa-times fa-fw"></i> <b>{{$t('Cancel')}}</b>
|
||||||
</b-list-group-item>
|
</b-list-group-item>
|
||||||
<!-- TODO add to shopping list -->
|
<!-- TODO add to shopping list -->
|
||||||
<!-- TODO add to and/or manage pantry -->
|
<!-- TODO toggle onhand -->
|
||||||
</b-list-group>
|
</b-list-group>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import GenericContextMenu from "@/components/GenericContextMenu";
|
import GenericContextMenu from "@/components/ContextMenu/GenericContextMenu";
|
||||||
import Badges from "@/components/Badges";
|
import Badges from "@/components/Badges";
|
||||||
import GenericPill from "@/components/GenericPill";
|
import GenericPill from "@/components/GenericPill";
|
||||||
import GenericOrderedPill from "@/components/GenericOrderedPill";
|
import GenericOrderedPill from "@/components/GenericOrderedPill";
|
||||||
|
@ -1,88 +1,217 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<tr>
|
||||||
<tr @click="$emit('checked-state-changed', ingredient)">
|
<template v-if="ingredient.is_header">
|
||||||
<template v-if="ingredient.is_header">
|
<td colspan="5" @click="done">
|
||||||
<td colspan="5">
|
<b>{{ ingredient.note }}</b>
|
||||||
<b>{{ ingredient.note }}</b>
|
</td>
|
||||||
</td>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<td class="d-print-non" v-if="detailed">
|
|
||||||
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
|
||||||
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<template v-if="ingredient.food !== null">
|
|
||||||
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null"
|
|
||||||
target="_blank" rel="noopener noreferrer">{{ ingredient.food.name }}</a>
|
|
||||||
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
</td>
|
|
||||||
<td v-if="detailed">
|
|
||||||
<div v-if="ingredient.note">
|
|
||||||
<span v-b-popover.hover="ingredient.note"
|
|
||||||
class="d-print-none touchable"> <i class="far fa-comment"></i>
|
|
||||||
</span>
|
|
||||||
<!-- v-if="ingredient.note.length > 15" -->
|
|
||||||
<!-- <span v-else>-->
|
|
||||||
<!-- {{ ingredient.note }}-->
|
|
||||||
<!-- </span>-->
|
|
||||||
|
|
||||||
<div class="d-none d-print-block">
|
<template v-else>
|
||||||
<i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}
|
<td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done">
|
||||||
</div>
|
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
||||||
</div>
|
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
||||||
</td>
|
</td>
|
||||||
</template>
|
<td class="text-nowrap" @click="done">
|
||||||
</tr>
|
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
|
||||||
|
</td>
|
||||||
|
<td @click="done">
|
||||||
|
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td @click="done">
|
||||||
|
<template v-if="ingredient.food !== null">
|
||||||
|
<!-- <i
|
||||||
|
v-if="show_shopping && !add_shopping_mode"
|
||||||
|
class="far fa-edit fa-sm px-1"
|
||||||
|
@click="editFood()"
|
||||||
|
></i> -->
|
||||||
|
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null" target="_blank" rel="noopener noreferrer">{{
|
||||||
|
ingredient.food.name
|
||||||
|
}}</a>
|
||||||
|
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="detailed && !show_shopping">
|
||||||
|
<div v-if="ingredient.note">
|
||||||
|
<span v-b-popover.hover="ingredient.note" class="d-print-none touchable">
|
||||||
|
<i class="far fa-comment"></i>
|
||||||
|
</span>
|
||||||
|
<!-- v-if="ingredient.note.length > 15" -->
|
||||||
|
<!-- <span v-else>-->
|
||||||
|
<!-- {{ ingredient.note }}-->
|
||||||
|
<!-- </span>-->
|
||||||
|
|
||||||
|
<div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-else-if="show_shopping" class="text-right text-nowrap">
|
||||||
|
<!-- in shopping mode and ingredient is not ignored -->
|
||||||
|
<div v-if="!ingredient.food.ignore_shopping">
|
||||||
|
<b-button
|
||||||
|
class="btn text-decoration-none fas fa-shopping-cart px-2 user-select-none"
|
||||||
|
variant="link"
|
||||||
|
v-b-popover.hover.click.blur.html.top="{ title: ShoppingPopover, variant: 'outline-dark' }"
|
||||||
|
:class="{
|
||||||
|
'text-success': shopping_status === true,
|
||||||
|
'text-muted': shopping_status === false,
|
||||||
|
'text-warning': shopping_status === null,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<span class="px-2">
|
||||||
|
<input type="checkbox" class="align-middle" v-model="shop" @change="changeShopping" />
|
||||||
|
</span>
|
||||||
|
<on-hand-badge :item="ingredient.food" />
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<!-- or in shopping mode and food is ignored: Shopping Badge bypasses linking ingredient to Recipe which would get ignored -->
|
||||||
|
<shopping-badge :item="ingredient.food" :override_ignore="true" class="px-1" />
|
||||||
|
<span class="px-2">
|
||||||
|
<input type="checkbox" class="align-middle" disabled v-b-popover.hover.click.blur :title="$t('IgnoredFood', { food: ingredient.food.name })" />
|
||||||
|
</span>
|
||||||
|
<on-hand-badge :item="ingredient.food" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils"
|
||||||
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils";
|
import OnHandBadge from "@/components/Badges/OnHand"
|
||||||
|
import ShoppingBadge from "@/components/Badges/Shopping"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'IngredientComponent',
|
name: "Ingredient",
|
||||||
props: {
|
components: { OnHandBadge, ShoppingBadge },
|
||||||
ingredient: Object,
|
props: {
|
||||||
ingredient_factor: {
|
ingredient: Object,
|
||||||
type: Number,
|
ingredient_factor: { type: Number, default: 1 },
|
||||||
default: 1,
|
detailed: { type: Boolean, default: true },
|
||||||
|
recipe_list: { type: Number }, // ShoppingListRecipe ID, to filter ShoppingStatus
|
||||||
|
show_shopping: { type: Boolean, default: false },
|
||||||
|
add_shopping_mode: { type: Boolean, default: false },
|
||||||
|
shopping_list: {
|
||||||
|
type: Array,
|
||||||
|
default() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
}, // list of unchecked ingredients in shopping list
|
||||||
|
},
|
||||||
|
mixins: [ResolveUrlMixin, ApiMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
checked: false,
|
||||||
|
shopping_status: null,
|
||||||
|
shopping_items: [],
|
||||||
|
shop: false,
|
||||||
|
dirty: undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
ShoppingListAndFilter: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal, oldVal) {
|
||||||
|
let filtered_list = this.shopping_list
|
||||||
|
// if a recipe list is provided, filter the shopping list
|
||||||
|
if (this.recipe_list) {
|
||||||
|
filtered_list = filtered_list.filter((x) => x.list_recipe == this.recipe_list)
|
||||||
|
}
|
||||||
|
// how many ShoppingListRecipes are there for this recipe?
|
||||||
|
let count_shopping_recipes = [...new Set(filtered_list.map((x) => x.list_recipe))].length
|
||||||
|
let count_shopping_ingredient = filtered_list.filter((x) => x.ingredient == this.ingredient.id).length
|
||||||
|
|
||||||
|
if (count_shopping_recipes > 1) {
|
||||||
|
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
|
||||||
|
} else if (this.ingredient.food.shopping) {
|
||||||
|
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
|
||||||
|
} else {
|
||||||
|
this.shopping_status = false // food is not in any shopping list
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// mark checked if the food is in the shopping list for this ingredient/recipe
|
||||||
|
if (count_shopping_ingredient >= 1) {
|
||||||
|
// ingredient is in this shopping list
|
||||||
|
this.shop = true
|
||||||
|
this.shopping_status = true
|
||||||
|
} else if (count_shopping_ingredient == 0 && this.ingredient.food.shopping) {
|
||||||
|
// food is in the shopping list, just not for this ingredient/recipe
|
||||||
|
this.shop = false
|
||||||
|
this.shopping_status = null
|
||||||
|
} else {
|
||||||
|
// the food is not in any shopping list
|
||||||
|
this.shop = false
|
||||||
|
this.shopping_status = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if we are in add shopping mode start with all checks marked
|
||||||
|
if (this.add_shopping_mode) {
|
||||||
|
this.shop = !this.ingredient.food.on_hand && !this.ingredient.food.ignore_shopping && !this.ingredient.food.recipe
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
computed: {
|
||||||
|
ShoppingListAndFilter() {
|
||||||
|
// hack to watch the shopping list and the recipe list at the same time
|
||||||
|
return this.shopping_list.map((x) => x.id).join(this.recipe_list)
|
||||||
|
},
|
||||||
|
ShoppingPopover() {
|
||||||
|
if (this.shopping_status == false) {
|
||||||
|
return this.$t("NotInShopping", { food: this.ingredient.food.name })
|
||||||
|
} else {
|
||||||
|
let list = this.shopping_list.filter((x) => x.food.id == this.ingredient.food.id)
|
||||||
|
let category = this.$t("Category") + ": " + this.ingredient?.food?.supermarket_category?.name ?? this.$t("Undefined")
|
||||||
|
let popover = []
|
||||||
|
|
||||||
|
list.forEach((x) => {
|
||||||
|
popover.push(
|
||||||
|
[
|
||||||
|
"<tr style='border-bottom: 1px solid #ccc'>",
|
||||||
|
"<td style='padding: 3px;'><em>",
|
||||||
|
x?.recipe_mealplan?.name ?? "",
|
||||||
|
"</em></td>",
|
||||||
|
"<td style='padding: 3px;'>",
|
||||||
|
x?.amount ?? "",
|
||||||
|
"</td>",
|
||||||
|
"<td style='padding: 3px;'>",
|
||||||
|
x?.unit?.name ?? "" + "</td>",
|
||||||
|
"<td style='padding: 3px;'>",
|
||||||
|
x?.food?.name ?? "",
|
||||||
|
"</td></tr>",
|
||||||
|
].join("")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return "<table class='table-small'><th colspan='4'>" + category + "</th>" + popover.join("") + "</table>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
calculateAmount: function(x) {
|
||||||
|
return calculateAmount(x, this.ingredient_factor)
|
||||||
|
},
|
||||||
|
// sends parent recipe ingredient to notify complete has been toggled
|
||||||
|
done: function() {
|
||||||
|
this.$emit("checked-state-changed", this.ingredient)
|
||||||
|
},
|
||||||
|
// sends true/false to parent to save all ingredient shopping updates as a batch
|
||||||
|
changeShopping: function() {
|
||||||
|
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
|
||||||
|
},
|
||||||
|
editFood: function() {
|
||||||
|
console.log("edit the food")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
detailed: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mixins: [
|
|
||||||
ResolveUrlMixin
|
|
||||||
],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
checked: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
calculateAmount: function (x) {
|
|
||||||
return calculateAmount(x, this.ingredient_factor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* increase size of hover/touchable space without changing spacing */
|
/* increase size of hover/touchable space without changing spacing */
|
||||||
.touchable {
|
.touchable {
|
||||||
padding-right: 2em;
|
padding-right: 2em;
|
||||||
padding-left: 2em;
|
padding-left: 2em;
|
||||||
margin-right: -2em;
|
margin-right: -2em;
|
||||||
margin-left: -2em;
|
margin-left: -2em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
187
vue/src/components/IngredientsCard.vue
Normal file
187
vue/src/components/IngredientsCard.vue
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="{ 'card border-primary no-border': header }">
|
||||||
|
<div :class="{ 'card-body': header }">
|
||||||
|
<div class="row" v-if="header">
|
||||||
|
<div class="col col-md-8">
|
||||||
|
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-4 text-right" v-if="header">
|
||||||
|
<h4>
|
||||||
|
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2" @click="saveShopping(true)"></i>
|
||||||
|
<i v-if="show_shopping" class="fas fa-save text-success px-2" @click="saveShopping()"></i>
|
||||||
|
<i class="fas fa-shopping-cart px-2" @click="getShopping()"></i>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
|
||||||
|
<div class="col col-md-6 offset-md-6 text-right">
|
||||||
|
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br v-if="header" />
|
||||||
|
<div class="row no-gutter">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||||
|
<template v-for="s in steps">
|
||||||
|
<template v-for="i in s.ingredients">
|
||||||
|
<ingredient
|
||||||
|
:ingredient="i"
|
||||||
|
:ingredient_factor="ingredient_factor"
|
||||||
|
:key="i.id"
|
||||||
|
:show_shopping="show_shopping"
|
||||||
|
:shopping_list="shopping_list"
|
||||||
|
:add_shopping_mode="add_shopping_mode"
|
||||||
|
:detailed="detailed"
|
||||||
|
:recipe_list="selected_shoppingrecipe"
|
||||||
|
@checked-state-changed="$emit('checked-state-changed', $event)"
|
||||||
|
@add-to-shopping="addShopping($event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from "vue"
|
||||||
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
|
||||||
|
import Ingredient from "@/components/Ingredient"
|
||||||
|
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "IngredientCard",
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
components: { Ingredient },
|
||||||
|
props: {
|
||||||
|
steps: {
|
||||||
|
type: Array,
|
||||||
|
default() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recipe: { type: Number },
|
||||||
|
ingredient_factor: { type: Number, default: 1 },
|
||||||
|
servings: { type: Number, default: 1 },
|
||||||
|
detailed: { type: Boolean, default: true },
|
||||||
|
header: { type: Boolean, default: false },
|
||||||
|
add_shopping_mode: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
show_shopping: false,
|
||||||
|
shopping_list: [],
|
||||||
|
update_shopping: [],
|
||||||
|
selected_shoppingrecipe: undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
ShoppingRecipes() {
|
||||||
|
// returns open shopping lists associated with this recipe
|
||||||
|
let recipe_in_list = this.shopping_list
|
||||||
|
.map((x) => {
|
||||||
|
return { value: x?.list_recipe, text: x?.recipe_mealplan?.name, recipe: x?.recipe_mealplan?.recipe ?? 0, servings: x?.recipe_mealplan?.servings }
|
||||||
|
})
|
||||||
|
.filter((x) => x?.recipe == this.recipe)
|
||||||
|
return [...new Map(recipe_in_list.map((x) => [x["value"], x])).values()] // filter to unique lists
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
ShoppingRecipes: function(newVal, oldVal) {
|
||||||
|
if (newVal.length === 0 || this.add_shopping_mode) {
|
||||||
|
this.selected_shoppingrecipe = undefined
|
||||||
|
} else if (newVal.length === 1) {
|
||||||
|
this.selected_shoppingrecipe = newVal[0].value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selected_shoppingrecipe: function(newVal, oldVal) {
|
||||||
|
this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.add_shopping_mode) {
|
||||||
|
this.show_shopping = true
|
||||||
|
this.getShopping(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getShopping: function(toggle_shopping = true) {
|
||||||
|
if (toggle_shopping) {
|
||||||
|
this.show_shopping = !this.show_shopping
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.show_shopping) {
|
||||||
|
let ingredient_list = this.steps
|
||||||
|
.map((x) => x.ingredients)
|
||||||
|
.flat()
|
||||||
|
.map((x) => x.food.id)
|
||||||
|
|
||||||
|
let params = {
|
||||||
|
id: ingredient_list,
|
||||||
|
checked: "false",
|
||||||
|
}
|
||||||
|
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => {
|
||||||
|
this.shopping_list = result.data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveShopping: function(del_shopping = false) {
|
||||||
|
let servings = this.servings
|
||||||
|
if (del_shopping) {
|
||||||
|
servings = 0
|
||||||
|
}
|
||||||
|
let params = {
|
||||||
|
id: this.recipe,
|
||||||
|
list_recipe: this.selected_shoppingrecipe,
|
||||||
|
ingredients: this.update_shopping,
|
||||||
|
servings: servings,
|
||||||
|
}
|
||||||
|
this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params)
|
||||||
|
.then(() => {
|
||||||
|
if (del_shopping) {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||||
|
} else if (this.selected_shoppingrecipe) {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||||
|
} else {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (!this.add_shopping_mode) {
|
||||||
|
return this.getShopping(false)
|
||||||
|
} else {
|
||||||
|
this.$emit("shopping-added")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (del_shopping) {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||||
|
} else if (this.selected_shoppingrecipe) {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
|
} else {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||||
|
}
|
||||||
|
this.$emit("shopping-failed")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addShopping: function(e) {
|
||||||
|
// ALERT: this will all break if ingredients are re-used between recipes
|
||||||
|
if (e.add) {
|
||||||
|
this.update_shopping.push(e.item.id)
|
||||||
|
} else {
|
||||||
|
this.update_shopping = this.update_shopping.filter((x) => x !== e.item.id)
|
||||||
|
}
|
||||||
|
if (this.add_shopping_mode) {
|
||||||
|
this.$emit("add-to-shopping", e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,216 +0,0 @@
|
|||||||
<template>
|
|
||||||
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col col-md-12">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6 col-lg-9">
|
|
||||||
<b-input-group>
|
|
||||||
<b-form-input id="TitleInput" v-model="entryEditing.title"
|
|
||||||
:placeholder="entryEditing.title_placeholder"
|
|
||||||
@change="missing_recipe = false"></b-form-input>
|
|
||||||
<b-input-group-append class="d-none d-lg-block">
|
|
||||||
<b-button variant="primary" @click="entryEditing.title = ''"><i class="fa fa-eraser"></i></b-button>
|
|
||||||
</b-input-group-append>
|
|
||||||
</b-input-group>
|
|
||||||
<span class="text-danger" v-if="missing_recipe">{{ $t('Title_or_Recipe_Required') }}</span>
|
|
||||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{ $t("Title") }}</small>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-lg-3">
|
|
||||||
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date">
|
|
||||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-3">
|
|
||||||
<div class="col-12 col-lg-6 col-xl-6">
|
|
||||||
<b-form-group>
|
|
||||||
<generic-multiselect
|
|
||||||
@change="selectRecipe"
|
|
||||||
:initial_selection="entryEditing_initial_recipe"
|
|
||||||
:label="'name'"
|
|
||||||
:model="Models.RECIPE"
|
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
|
||||||
v-bind:placeholder="$t('Recipe')" :limit="10"
|
|
||||||
:multiple="false"></generic-multiselect>
|
|
||||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
|
||||||
</b-form-group>
|
|
||||||
<b-form-group class="mt-3">
|
|
||||||
<generic-multiselect required
|
|
||||||
@change="selectMealType"
|
|
||||||
:label="'name'"
|
|
||||||
:model="Models.MEAL_TYPE"
|
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
|
||||||
v-bind:placeholder="$t('Meal_Type')" :limit="10"
|
|
||||||
:multiple="false"
|
|
||||||
:initial_selection="entryEditing_initial_meal_type"
|
|
||||||
:allow_create="true"
|
|
||||||
:create_placeholder="$t('Create_New_Meal_Type')"
|
|
||||||
@new="createMealType"
|
|
||||||
></generic-multiselect>
|
|
||||||
<span class="text-danger" v-if="missing_meal_type">{{ $t('Meal_Type_Required') }}</span>
|
|
||||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{ $t("Meal_Type") }}</small>
|
|
||||||
</b-form-group>
|
|
||||||
<b-form-group
|
|
||||||
label-for="NoteInput"
|
|
||||||
:description="$t('Note')" class="mt-3">
|
|
||||||
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note"
|
|
||||||
:placeholder="$t('Note')"></textarea>
|
|
||||||
</b-form-group>
|
|
||||||
<b-input-group>
|
|
||||||
<b-form-input id="ServingsInput" v-model="entryEditing.servings"
|
|
||||||
:placeholder="$t('Servings')"></b-form-input>
|
|
||||||
</b-input-group>
|
|
||||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
|
||||||
<b-form-group class="mt-3">
|
|
||||||
<generic-multiselect required
|
|
||||||
@change="entryEditing.shared = $event.val" parent_variable="entryEditing.shared"
|
|
||||||
:label="'username'"
|
|
||||||
:model="Models.USER_NAME"
|
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
|
||||||
v-bind:placeholder="$t('Share')" :limit="10"
|
|
||||||
:multiple="true"
|
|
||||||
:initial_selection="entryEditing.shared"
|
|
||||||
></generic-multiselect>
|
|
||||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
|
|
||||||
</b-form-group>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
|
||||||
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-3 mb-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t('Delete') }}
|
|
||||||
</b-button>
|
|
||||||
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t('Save') }}</b-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</b-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import Vue from "vue";
|
|
||||||
import {BootstrapVue} from "bootstrap-vue";
|
|
||||||
import GenericMultiselect from "./GenericMultiselect";
|
|
||||||
import {ApiMixin} from "../utils/utils";
|
|
||||||
|
|
||||||
const {ApiApiFactory} = require("@/utils/openapi/api");
|
|
||||||
|
|
||||||
const {StandardToasts} = require("@/utils/utils");
|
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "MealPlanEditModal",
|
|
||||||
props: {
|
|
||||||
entry: Object,
|
|
||||||
entryEditing_initial_recipe: Array,
|
|
||||||
entryEditing_initial_meal_type: Array,
|
|
||||||
modal_title: String,
|
|
||||||
modal_id: {
|
|
||||||
type: String,
|
|
||||||
default: "edit-modal"
|
|
||||||
},
|
|
||||||
allow_delete: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mixins: [ApiMixin],
|
|
||||||
components: {
|
|
||||||
GenericMultiselect,
|
|
||||||
RecipeCard: () => import('@/components/RecipeCard.vue')
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
entryEditing: {},
|
|
||||||
missing_recipe: false,
|
|
||||||
missing_meal_type: false,
|
|
||||||
default_plan_share: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
entry: {
|
|
||||||
handler() {
|
|
||||||
this.entryEditing = Object.assign({}, this.entry)
|
|
||||||
},
|
|
||||||
deep: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
showModal() {
|
|
||||||
let apiClient = new ApiApiFactory()
|
|
||||||
|
|
||||||
apiClient.listUserPreferences().then(result => {
|
|
||||||
if (this.entry.id === -1) {
|
|
||||||
this.entryEditing.shared = result.data[0].plan_share
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
editEntry() {
|
|
||||||
this.missing_meal_type = false
|
|
||||||
this.missing_recipe = false
|
|
||||||
let cancel = false
|
|
||||||
if (this.entryEditing.meal_type == null) {
|
|
||||||
this.missing_meal_type = true
|
|
||||||
cancel = true
|
|
||||||
}
|
|
||||||
if (this.entryEditing.recipe == null && this.entryEditing.title === '') {
|
|
||||||
this.missing_recipe = true
|
|
||||||
cancel = true
|
|
||||||
}
|
|
||||||
if (!cancel) {
|
|
||||||
this.$bvModal.hide(`edit-modal`);
|
|
||||||
this.$emit('save-entry', this.entryEditing)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteEntry() {
|
|
||||||
this.$bvModal.hide(`edit-modal`);
|
|
||||||
this.$emit('delete-entry', this.entryEditing)
|
|
||||||
},
|
|
||||||
selectMealType(event) {
|
|
||||||
this.missing_meal_type = false
|
|
||||||
if (event.val != null) {
|
|
||||||
this.entryEditing.meal_type = event.val;
|
|
||||||
} else {
|
|
||||||
this.entryEditing.meal_type = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectShared(event) {
|
|
||||||
if (event.val != null) {
|
|
||||||
this.entryEditing.shared = event.val;
|
|
||||||
} else {
|
|
||||||
this.entryEditing.meal_type = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
createMealType(event) {
|
|
||||||
if (event != "") {
|
|
||||||
let apiClient = new ApiApiFactory()
|
|
||||||
|
|
||||||
apiClient.createMealType({name: event}).then(e => {
|
|
||||||
this.$emit('reload-meal-types')
|
|
||||||
}).catch(error => {
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectRecipe(event) {
|
|
||||||
this.missing_recipe = false
|
|
||||||
if (event.val != null) {
|
|
||||||
this.entryEditing.recipe = event.val;
|
|
||||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
|
||||||
this.entryEditing.servings = this.entryEditing.recipe.servings
|
|
||||||
} else {
|
|
||||||
this.entryEditing.recipe = null;
|
|
||||||
this.entryEditing.title_placeholder = ""
|
|
||||||
this.entryEditing.servings = 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
@ -28,7 +28,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Vue from "vue"
|
import Vue from "vue"
|
||||||
import { BootstrapVue } from "bootstrap-vue"
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
import { getForm } from "@/utils/utils"
|
import { getForm, formFunctions } from "@/utils/utils"
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
@ -84,6 +84,10 @@ export default {
|
|||||||
show: function () {
|
show: function () {
|
||||||
if (this.show) {
|
if (this.show) {
|
||||||
this.form = getForm(this.model, this.action, this.item1, this.item2)
|
this.form = getForm(this.model, this.action, this.item1, this.item2)
|
||||||
|
// TODO: I don't know how to generalize this, but Food needs default values to drive inheritance
|
||||||
|
if (this.form?.form_function) {
|
||||||
|
this.form = formFunctions[this.form.form_function](this.form)
|
||||||
|
}
|
||||||
this.dirty = true
|
this.dirty = true
|
||||||
this.$bvModal.show("modal_" + this.id)
|
this.$bvModal.show("modal_" + this.id)
|
||||||
} else {
|
} else {
|
||||||
|
219
vue/src/components/Modals/MealPlanEditModal.vue
Normal file
219
vue/src/components/Modals/MealPlanEditModal.vue
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6 col-lg-9">
|
||||||
|
<b-input-group>
|
||||||
|
<b-form-input
|
||||||
|
id="TitleInput"
|
||||||
|
v-model="entryEditing.title"
|
||||||
|
:placeholder="entryEditing.title_placeholder"
|
||||||
|
@change="missing_recipe = false"
|
||||||
|
></b-form-input>
|
||||||
|
<b-input-group-append class="d-none d-lg-block">
|
||||||
|
<b-button variant="primary" @click="entryEditing.title = ''"
|
||||||
|
><i class="fa fa-eraser"></i
|
||||||
|
></b-button>
|
||||||
|
</b-input-group-append>
|
||||||
|
</b-input-group>
|
||||||
|
<span class="text-danger" v-if="missing_recipe">{{ $t("Title_or_Recipe_Required") }}</span>
|
||||||
|
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{
|
||||||
|
$t("Title")
|
||||||
|
}}</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date" />
|
||||||
|
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12 col-lg-6 col-xl-6">
|
||||||
|
<b-form-group>
|
||||||
|
<generic-multiselect
|
||||||
|
@change="selectRecipe"
|
||||||
|
:initial_selection="entryEditing_initial_recipe"
|
||||||
|
:label="'name'"
|
||||||
|
:model="Models.RECIPE"
|
||||||
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
v-bind:placeholder="$t('Recipe')"
|
||||||
|
:limit="10"
|
||||||
|
:multiple="false"
|
||||||
|
></generic-multiselect>
|
||||||
|
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
||||||
|
</b-form-group>
|
||||||
|
<b-form-group class="mt-3">
|
||||||
|
<generic-multiselect
|
||||||
|
required
|
||||||
|
@change="selectMealType"
|
||||||
|
:label="'name'"
|
||||||
|
:model="Models.MEAL_TYPE"
|
||||||
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
v-bind:placeholder="$t('Meal_Type')"
|
||||||
|
:limit="10"
|
||||||
|
:multiple="false"
|
||||||
|
:initial_selection="entryEditing_initial_meal_type"
|
||||||
|
:allow_create="true"
|
||||||
|
:create_placeholder="$t('Create_New_Meal_Type')"
|
||||||
|
@new="createMealType"
|
||||||
|
></generic-multiselect>
|
||||||
|
<span class="text-danger" v-if="missing_meal_type">{{ $t("Meal_Type_Required") }}</span>
|
||||||
|
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{
|
||||||
|
$t("Meal_Type")
|
||||||
|
}}</small>
|
||||||
|
</b-form-group>
|
||||||
|
<b-form-group label-for="NoteInput" :description="$t('Note')" class="mt-3">
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="NoteInput"
|
||||||
|
v-model="entryEditing.note"
|
||||||
|
:placeholder="$t('Note')"
|
||||||
|
></textarea>
|
||||||
|
</b-form-group>
|
||||||
|
<b-input-group>
|
||||||
|
<b-form-input
|
||||||
|
id="ServingsInput"
|
||||||
|
v-model="entryEditing.servings"
|
||||||
|
:placeholder="$t('Servings')"
|
||||||
|
></b-form-input>
|
||||||
|
</b-input-group>
|
||||||
|
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
||||||
|
<!-- TODO: hide this checkbox if autoadding menuplans, but allow editing on-hand -->
|
||||||
|
<b-input-group v-if="!autoMealPlan">
|
||||||
|
<b-form-checkbox id="AddToShopping" v-model="entryEditing.addshopping" />
|
||||||
|
<small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small>
|
||||||
|
</b-input-group>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
||||||
|
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3 mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete"
|
||||||
|
>{{ $t("Delete") }}
|
||||||
|
</b-button>
|
||||||
|
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t("Save") }}</b-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</b-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from "vue"
|
||||||
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
|
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||||
|
import { ApiMixin, getUserPreference } from "@/utils/utils"
|
||||||
|
|
||||||
|
const { ApiApiFactory } = require("@/utils/openapi/api")
|
||||||
|
|
||||||
|
const { StandardToasts } = require("@/utils/utils")
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "MealPlanEditModal",
|
||||||
|
props: {
|
||||||
|
entry: Object,
|
||||||
|
entryEditing_initial_recipe: Array,
|
||||||
|
entryEditing_initial_meal_type: Array,
|
||||||
|
modal_title: String,
|
||||||
|
modal_id: {
|
||||||
|
type: String,
|
||||||
|
default: "edit-modal",
|
||||||
|
},
|
||||||
|
allow_delete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
components: {
|
||||||
|
GenericMultiselect,
|
||||||
|
RecipeCard: () => import("@/components/RecipeCard.vue"),
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
entryEditing: {},
|
||||||
|
missing_recipe: false,
|
||||||
|
missing_meal_type: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
entry: {
|
||||||
|
handler() {
|
||||||
|
this.entryEditing = Object.assign({}, this.entry)
|
||||||
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted: function() {},
|
||||||
|
computed: {
|
||||||
|
autoMealPlan: function() {
|
||||||
|
return getUserPreference("mealplan_autoadd_shopping")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
editEntry() {
|
||||||
|
this.missing_meal_type = false
|
||||||
|
this.missing_recipe = false
|
||||||
|
let cancel = false
|
||||||
|
if (this.entryEditing.meal_type == null) {
|
||||||
|
this.missing_meal_type = true
|
||||||
|
cancel = true
|
||||||
|
}
|
||||||
|
if (this.entryEditing.recipe == null && this.entryEditing.title === "") {
|
||||||
|
this.missing_recipe = true
|
||||||
|
cancel = true
|
||||||
|
}
|
||||||
|
if (!cancel) {
|
||||||
|
this.$bvModal.hide(`edit-modal`)
|
||||||
|
this.$emit("save-entry", this.entryEditing)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteEntry() {
|
||||||
|
this.$bvModal.hide(`edit-modal`)
|
||||||
|
this.$emit("delete-entry", this.entryEditing)
|
||||||
|
},
|
||||||
|
selectMealType(event) {
|
||||||
|
this.missing_meal_type = false
|
||||||
|
if (event.val != null) {
|
||||||
|
this.entryEditing.meal_type = event.val
|
||||||
|
} else {
|
||||||
|
this.entryEditing.meal_type = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createMealType(event) {
|
||||||
|
if (event != "") {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
|
apiClient
|
||||||
|
.createMealType({ name: event })
|
||||||
|
.then((e) => {
|
||||||
|
this.$emit("reload-meal-types")
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectRecipe(event) {
|
||||||
|
console.log(event, this.entryEditing)
|
||||||
|
this.missing_recipe = false
|
||||||
|
if (event.val != null) {
|
||||||
|
this.entryEditing.recipe = event.val
|
||||||
|
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||||
|
this.entryEditing.servings = this.entryEditing.recipe.servings
|
||||||
|
} else {
|
||||||
|
this.entryEditing.recipe = null
|
||||||
|
this.entryEditing.title_placeholder = ""
|
||||||
|
this.entryEditing.servings = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
158
vue/src/components/Modals/ShoppingModal.vue
Normal file
158
vue/src/components/Modals/ShoppingModal.vue
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
|
||||||
|
<template v-slot:modal-title><h4>{{$t('Add_Servings_to_Shopping',{'servings': servings})}}</h4></template>
|
||||||
|
<loading-spinner v-if="loading"></loading-spinner>
|
||||||
|
<div class="accordion" role="tablist" v-if="!loading">
|
||||||
|
<b-card no-body class="mb-1">
|
||||||
|
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||||
|
<b-button block v-b-toggle.accordion-0 class="text-left" variant="outline-info">{{recipe.name}}</b-button>
|
||||||
|
</b-card-header>
|
||||||
|
<b-collapse id="accordion-0" visible accordion="my-accordion" role="tabpanel">
|
||||||
|
<ingredients-card
|
||||||
|
:steps="steps"
|
||||||
|
:recipe="recipe.id"
|
||||||
|
:ingredient_factor="ingredient_factor"
|
||||||
|
:servings="servings"
|
||||||
|
:show_shopping="true"
|
||||||
|
:add_shopping_mode="true"
|
||||||
|
:header="false"
|
||||||
|
@add-to-shopping="addShopping($event)"
|
||||||
|
/>
|
||||||
|
</b-collapse>
|
||||||
|
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||||
|
<template v-for="r in related_recipes">
|
||||||
|
<b-card no-body class="mb-1" :key="r.recipe.id">
|
||||||
|
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||||
|
<b-button btn-sm block v-b-toggle="'accordion-' +r.recipe.id" class="text-left" variant="outline-primary">{{r.recipe.name}}</b-button>
|
||||||
|
</b-card-header>
|
||||||
|
<b-collapse :id="'accordion-'+r.recipe.id" accordion="my-accordion" role="tabpanel">
|
||||||
|
<ingredients-card
|
||||||
|
:steps="r.steps"
|
||||||
|
:recipe="r.recipe.id"
|
||||||
|
:ingredient_factor="ingredient_factor"
|
||||||
|
:servings="servings"
|
||||||
|
:show_shopping="true"
|
||||||
|
:add_shopping_mode="true"
|
||||||
|
:header="false"
|
||||||
|
@add-to-shopping="addShopping($event)"
|
||||||
|
/>
|
||||||
|
</b-collapse>
|
||||||
|
</b-card>
|
||||||
|
</template>
|
||||||
|
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||||
|
</b-card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3 mb-3">
|
||||||
|
<div class="col-12 text-right">
|
||||||
|
<b-button class="mx-2" variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t('Cancel') }}
|
||||||
|
</b-button>
|
||||||
|
<b-button class="mx-2" variant="success" @click="saveShopping">{{ $t('Save') }}
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</b-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from 'vue'
|
||||||
|
import {BootstrapVue} from 'bootstrap-vue'
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
const {ApiApiFactory} = require("@/utils/openapi/api");
|
||||||
|
import {StandardToasts} from "@/utils/utils";
|
||||||
|
import IngredientsCard from "@/components/IngredientsCard";
|
||||||
|
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ShoppingModal',
|
||||||
|
components: {IngredientsCard, LoadingSpinner},
|
||||||
|
mixins: [],
|
||||||
|
props: {
|
||||||
|
recipe: {required: true, type: Object},
|
||||||
|
servings: {type: Number},
|
||||||
|
modal_id: {required: true, type: Number},
|
||||||
|
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
steps: [],
|
||||||
|
recipe_servings: 0,
|
||||||
|
add_shopping: [],
|
||||||
|
related_recipes: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
ingredient_factor: function () {
|
||||||
|
return this.servings / this.recipe.servings || this.recipe_servings
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadRecipe: function() {
|
||||||
|
this.add_shopping = []
|
||||||
|
this.related_recipes = []
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
apiClient.retrieveRecipe(this.recipe.id).then((result) => {
|
||||||
|
this.steps = result.data.steps
|
||||||
|
// ALERT: this will all break if ingredients are re-used between recipes
|
||||||
|
// ALERT: this also doesn't quite work right if the same recipe appears multiple time in the related recipes
|
||||||
|
this.add_shopping = [...this.add_shopping, ...this.steps.map(x => x.ingredients).flat().map(x => x.id)]
|
||||||
|
this.recipe_servings = result.data?.servings
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// get a list of related recipes
|
||||||
|
apiClient.relatedRecipe(this.recipe.id).then((result) => {
|
||||||
|
return result.data
|
||||||
|
}).then((related_recipes) => {
|
||||||
|
let promises = []
|
||||||
|
related_recipes.forEach(x => {
|
||||||
|
promises.push(apiClient.listSteps(x.id).then((recipe_steps) => {
|
||||||
|
this.related_recipes.push({
|
||||||
|
'recipe': x,
|
||||||
|
'steps': recipe_steps.data.results.filter(x => x.ingredients.length > 0)
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
return Promise.all(promises)
|
||||||
|
}).then(() => {
|
||||||
|
this.add_shopping = [
|
||||||
|
...this.add_shopping,
|
||||||
|
...this.related_recipes.map(x => x.steps).flat().map(x => x.ingredients).flat().map(x => x.id)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addShopping: function(e) {
|
||||||
|
if (e.add) {
|
||||||
|
this.add_shopping.push(e.item.id)
|
||||||
|
} else {
|
||||||
|
this.add_shopping = this.add_shopping.filter(x => x !== e.item.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveShopping: function() {
|
||||||
|
// another choice would be to create ShoppingListRecipes for each recipe - this bundles all related recipe under the parent recipe
|
||||||
|
let shopping_recipe = {
|
||||||
|
'id': this.recipe.id,
|
||||||
|
'ingredients': this.add_shopping,
|
||||||
|
'servings': this.servings
|
||||||
|
}
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
apiClient.shoppingRecipe(this.recipe.id, shopping_recipe).then((result) => {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
}).catch((err) => {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||||
|
})
|
||||||
|
this.$bvModal.hide(`shopping_${this.modal_id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,158 +1,137 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<b-card no-body v-hover v-if="recipe">
|
||||||
|
<a :href="clickUrl()">
|
||||||
<b-card no-body v-hover>
|
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src="recipe_image" v-bind:alt="$t('Recipe_Image')" top></b-card-img-lazy>
|
||||||
<a :href="clickUrl()">
|
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
|
||||||
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src=recipe_image
|
<a>
|
||||||
v-bind:alt="$t('Recipe_Image')"
|
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
|
||||||
top></b-card-img-lazy>
|
</a>
|
||||||
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
|
|
||||||
<a>
|
|
||||||
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2"
|
|
||||||
v-if="recipe.working_time !== 0 || recipe.waiting_time !== 0">
|
|
||||||
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0"><i class="fa fa-clock"></i>
|
|
||||||
{{ recipe.working_time }} {{ $t('min') }}
|
|
||||||
</b-badge>
|
|
||||||
<b-badge pill variant="secondary" class="mt-1 font-weight-normal" v-if="recipe.waiting_time !== 0"><i class="fa fa-pause"></i>
|
|
||||||
{{ recipe.waiting_time }} {{ $t('min') }}
|
|
||||||
</b-badge>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
|
|
||||||
<b-card-body class="p-4">
|
|
||||||
<h6><a :href="clickUrl()">
|
|
||||||
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
|
||||||
<template v-else>{{ meal_plan.title }}</template>
|
|
||||||
</a></h6>
|
|
||||||
|
|
||||||
<b-card-text style="text-overflow: ellipsis;">
|
|
||||||
<template v-if="recipe !== null">
|
|
||||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
|
||||||
<template v-if="recipe.description !== null">
|
|
||||||
<span v-if="recipe.description.length > text_length">
|
|
||||||
{{ recipe.description.substr(0, text_length) + "\u2026" }}
|
|
||||||
</span>
|
|
||||||
<span v-if="recipe.description.length <= text_length">
|
|
||||||
{{ recipe.description }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<p class="mt-1">
|
|
||||||
<last-cooked :recipe="recipe"></last-cooked>
|
|
||||||
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
|
|
||||||
</p>
|
|
||||||
<transition name="fade" mode="in-out">
|
|
||||||
<div class="row mt-3" v-if="detailed">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h6>
|
|
||||||
<table class="table table-sm text-wrap">
|
|
||||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
|
||||||
<template v-for="s in recipe.steps">
|
|
||||||
<template v-for="i in s.ingredients">
|
|
||||||
<Ingredient-component :detailed="false" :ingredient="i" :ingredient_factor="1" :key="i.id"></Ingredient-component>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2" v-if="recipe.waiting_time !== 0">
|
||||||
|
<b-badge pill variant="light" class="mt-1 font-weight-normal"><i class="fa fa-clock"></i> {{ recipe.working_time }} {{ $t("min") }} </b-badge>
|
||||||
|
<b-badge pill variant="secondary" class="mt-1 font-weight-normal"><i class="fa fa-pause"></i> {{ recipe.waiting_time }} {{ $t("min") }} </b-badge>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge>
|
<b-card-body class="p-4">
|
||||||
<!-- <b-badge pill variant="success"
|
<h6>
|
||||||
|
<a :href="clickUrl()">
|
||||||
|
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
||||||
|
<template v-else>{{ meal_plan.title }}</template>
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<b-card-text style="text-overflow: ellipsis;">
|
||||||
|
<template v-if="recipe !== null">
|
||||||
|
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||||
|
<template v-if="recipe.description !== null">
|
||||||
|
<span v-if="recipe.description.length > text_length">
|
||||||
|
{{ recipe.description.substr(0, text_length) + "\u2026" }}
|
||||||
|
</span>
|
||||||
|
<span v-if="recipe.description.length <= text_length">
|
||||||
|
{{ recipe.description }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<p class="mt-1">
|
||||||
|
<last-cooked :recipe="recipe"></last-cooked>
|
||||||
|
<keywords :recipe="recipe" style="margin-top: 4px"></keywords>
|
||||||
|
</p>
|
||||||
|
<transition name="fade" mode="in-out">
|
||||||
|
<div class="row mt-3" v-if="detailed">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h6>
|
||||||
|
|
||||||
|
<ingredients-card :steps="recipe.steps" :header="false" :detailed="false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t("External") }}</b-badge>
|
||||||
|
<!-- <b-badge pill variant="success"
|
||||||
v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))">
|
v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))">
|
||||||
{{ $t('New') }}
|
{{ $t('New') }}
|
||||||
</b-badge> -->
|
</b-badge> -->
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ meal_plan.note }}</template>
|
||||||
|
</b-card-text>
|
||||||
|
</b-card-body>
|
||||||
|
|
||||||
</template>
|
<b-card-footer v-if="footer_text !== undefined"> <i v-bind:class="footer_icon"></i> {{ footer_text }} </b-card-footer>
|
||||||
<template v-else>{{ meal_plan.note }}</template>
|
</b-card>
|
||||||
</b-card-text>
|
|
||||||
</b-card-body>
|
|
||||||
|
|
||||||
|
|
||||||
<b-card-footer v-if="footer_text !== undefined">
|
|
||||||
<i v-bind:class="footer_icon"></i> {{ footer_text }}
|
|
||||||
</b-card-footer>
|
|
||||||
</b-card>
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import RecipeContextMenu from "@/components/RecipeContextMenu";
|
import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
|
||||||
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils";
|
import Keywords from "@/components/Keywords"
|
||||||
import RecipeRating from "@/components/RecipeRating";
|
import { resolveDjangoUrl, ResolveUrlMixin } from "@/utils/utils"
|
||||||
import moment from "moment/moment";
|
import RecipeRating from "@/components/RecipeRating"
|
||||||
import Vue from "vue";
|
import moment from "moment/moment"
|
||||||
import LastCooked from "@/components/LastCooked";
|
import Vue from "vue"
|
||||||
import KeywordsComponent from "@/components/KeywordsComponent";
|
import LastCooked from "@/components/LastCooked"
|
||||||
import IngredientComponent from "@/components/IngredientComponent";
|
import IngredientsCard from "@/components/IngredientsCard"
|
||||||
|
|
||||||
Vue.prototype.moment = moment
|
Vue.prototype.moment = moment
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "RecipeCard",
|
name: "RecipeCard",
|
||||||
mixins: [
|
mixins: [ResolveUrlMixin],
|
||||||
ResolveUrlMixin,
|
components: { LastCooked, RecipeRating, Keywords, RecipeContextMenu, IngredientsCard },
|
||||||
],
|
props: {
|
||||||
components: {LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientComponent},
|
recipe: Object,
|
||||||
props: {
|
meal_plan: Object,
|
||||||
recipe: Object,
|
footer_text: String,
|
||||||
meal_plan: Object,
|
footer_icon: String,
|
||||||
footer_text: String,
|
|
||||||
footer_icon: String
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
detailed: function () {
|
|
||||||
return this.recipe.steps !== undefined;
|
|
||||||
},
|
},
|
||||||
text_length: function () {
|
computed: {
|
||||||
if (this.detailed) {
|
detailed: function() {
|
||||||
return 200
|
return this.recipe?.steps !== undefined
|
||||||
} else {
|
},
|
||||||
return 120
|
text_length: function() {
|
||||||
}
|
if (this.detailed) {
|
||||||
|
return 200
|
||||||
|
} else {
|
||||||
|
return 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
recipe_image: function() {
|
||||||
|
if (this.recipe == null || this.recipe.image === null) {
|
||||||
|
return window.IMAGE_PLACEHOLDER
|
||||||
|
} else {
|
||||||
|
return this.recipe.image
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// TODO: convert this to genericAPI
|
||||||
|
clickUrl: function() {
|
||||||
|
if (this.recipe !== null) {
|
||||||
|
return resolveDjangoUrl("view_recipe", this.recipe.id)
|
||||||
|
} else {
|
||||||
|
return resolveDjangoUrl("view_plan_entry", this.meal_plan.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
hover: {
|
||||||
|
inserted: function(el) {
|
||||||
|
el.addEventListener("mouseenter", () => {
|
||||||
|
el.classList.add("shadow")
|
||||||
|
})
|
||||||
|
el.addEventListener("mouseleave", () => {
|
||||||
|
el.classList.remove("shadow")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
recipe_image: function () {
|
|
||||||
if (this.recipe == null || this.recipe.image === null) {
|
|
||||||
return window.IMAGE_PLACEHOLDER
|
|
||||||
} else {
|
|
||||||
return this.recipe.image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
// TODO: convert this to genericAPI
|
|
||||||
clickUrl: function () {
|
|
||||||
if (this.recipe !== null) {
|
|
||||||
return resolveDjangoUrl('view_recipe', this.recipe.id)
|
|
||||||
} else {
|
|
||||||
return resolveDjangoUrl('view_plan_entry', this.meal_plan.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
directives: {
|
|
||||||
hover: {
|
|
||||||
inserted: function (el) {
|
|
||||||
el.addEventListener('mouseenter', () => {
|
|
||||||
el.classList.add("shadow")
|
|
||||||
});
|
|
||||||
el.addEventListener('mouseleave', () => {
|
|
||||||
el.classList.remove("shadow")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fade-enter-active, .fade-leave-active {
|
.fade-enter-active,
|
||||||
transition: opacity .5s;
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.5s;
|
||||||
}
|
}
|
||||||
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
|
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
269
vue/src/components/ShoppingLineItem.vue
Normal file
269
vue/src/components/ShoppingLineItem.vue
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<!-- add alert at top if offline -->
|
||||||
|
<!-- get autosync time from preferences and put fetching checked items on timer -->
|
||||||
|
<!-- allow reordering or items -->
|
||||||
|
<div id="shopping_line_item">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-1">
|
||||||
|
<div style="position: static;" class=" btn-group">
|
||||||
|
<div class="dropdown b-dropdown position-static inline-block">
|
||||||
|
<button
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
type="button"
|
||||||
|
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||||
|
@click.stop="$emit('open-context-menu', $event, entries)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" class="text-right mx-3 mt-2" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<div v-if="Object.entries(formatAmount).length == 1">{{ Object.entries(formatAmount)[0][1] }}   {{ Object.entries(formatAmount)[0][0] }}</div>
|
||||||
|
<div class="small" v-else v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }}   {{ x[0] }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{{ formatFood }} <span class="small text-muted">{{ formatHint }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-1">
|
||||||
|
<b-button size="sm" @click="showDetails = !showDetails" class="mr-2" variant="link">
|
||||||
|
<div class="text-nowrap">{{ showDetails ? "Hide" : "Show" }} Details</div>
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card no-body" v-if="showDetails">
|
||||||
|
<div v-for="(e, z) in entries" :key="z">
|
||||||
|
<div class="row ml-2 small">
|
||||||
|
<div class="col-md-4 overflow-hidden text-nowrap">
|
||||||
|
<button
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link btn-sm m-0 p-0"
|
||||||
|
style="text-overflow: ellipsis;"
|
||||||
|
@click.stop="openRecipeCard($event, e)"
|
||||||
|
@mouseover="openRecipeCard($event, e)"
|
||||||
|
>
|
||||||
|
{{ formatOneRecipe(e) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-muted">{{ formatOneMealPlan(e) }}</div>
|
||||||
|
<div class="col-md-4 text-muted text-right">{{ formatOneCreatedBy(e) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row ml-2 small">
|
||||||
|
<div class="col-md-4 offset-md-8 text-muted text-right">{{ formatOneCompletedAt(e) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row ml-2 light">
|
||||||
|
<div class="col-sm-1 text-nowrap">
|
||||||
|
<div style="position: static;" class=" btn-group ">
|
||||||
|
<div class="dropdown b-dropdown position-static inline-block">
|
||||||
|
<button
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
type="button"
|
||||||
|
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||||
|
@click.stop="$emit('open-context-menu', $event, e)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" class="text-right mx-3 mt-2" :checked="e.checked" @change="updateChecked($event, e)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-1">{{ formatOneAmount(e) }}</div>
|
||||||
|
<div class="col-sm-2">{{ formatOneUnit(e) }}</div>
|
||||||
|
|
||||||
|
<div class="col-sm-3">{{ formatOneFood(e) }}</div>
|
||||||
|
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="w-75" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="m-1" />
|
||||||
|
</div>
|
||||||
|
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width:300">
|
||||||
|
<template #menu="{ contextData }" v-if="recipe">
|
||||||
|
<ContextMenuItem><RecipeCard :recipe="contextData" :detail="false"></RecipeCard></ContextMenuItem>
|
||||||
|
<ContextMenuItem @click="$refs.menu.close()">
|
||||||
|
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
|
||||||
|
<template #label>
|
||||||
|
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a>
|
||||||
|
</template>
|
||||||
|
<div @click.prevent.stop>
|
||||||
|
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
|
||||||
|
</div>
|
||||||
|
</b-form-group>
|
||||||
|
</ContextMenuItem>
|
||||||
|
</template>
|
||||||
|
</ContextMenu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from "vue"
|
||||||
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||||
|
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||||
|
import { ApiMixin } from "@/utils/utils"
|
||||||
|
import RecipeCard from "./RecipeCard.vue"
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
|
||||||
|
// or i'm capturing it incorrectly
|
||||||
|
name: "ShoppingLineItem",
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
components: { RecipeCard, ContextMenu, ContextMenuItem },
|
||||||
|
props: {
|
||||||
|
entries: {
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
groupby: { type: String },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showDetails: false,
|
||||||
|
recipe: undefined,
|
||||||
|
servings: 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
formatAmount: function() {
|
||||||
|
let amount = {}
|
||||||
|
this.entries.forEach((entry) => {
|
||||||
|
let unit = entry?.unit?.name ?? "----"
|
||||||
|
if (entry.amount) {
|
||||||
|
if (amount[unit]) {
|
||||||
|
amount[unit] += entry.amount
|
||||||
|
} else {
|
||||||
|
amount[unit] = entry.amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return amount
|
||||||
|
},
|
||||||
|
formatCategory: function() {
|
||||||
|
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
|
||||||
|
},
|
||||||
|
formatChecked: function() {
|
||||||
|
return this.entries.map((x) => x.checked).every((x) => x === true)
|
||||||
|
},
|
||||||
|
formatHint: function() {
|
||||||
|
if (this.groupby == "recipe") {
|
||||||
|
return this.formatCategory
|
||||||
|
} else {
|
||||||
|
return this.formatRecipe
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatFood: function() {
|
||||||
|
return this.formatOneFood(this.entries[0])
|
||||||
|
},
|
||||||
|
formatUnit: function() {
|
||||||
|
return this.formatOneUnit(this.entries[0])
|
||||||
|
},
|
||||||
|
formatRecipe: function() {
|
||||||
|
if (this.entries?.length == 1) {
|
||||||
|
return this.formatOneMealPlan(this.entries[0]) || ""
|
||||||
|
} else {
|
||||||
|
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
|
||||||
|
return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatNotes: function() {
|
||||||
|
if (this.entries?.length == 1) {
|
||||||
|
return this.formatOneNote(this.entries[0]) || ""
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {},
|
||||||
|
mounted() {
|
||||||
|
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// this.genericAPI inherited from ApiMixin
|
||||||
|
|
||||||
|
formatDate: function(datetime) {
|
||||||
|
if (!datetime) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return Intl.DateTimeFormat(window.navigator.language, { dateStyle: "short", timeStyle: "short" }).format(Date.parse(datetime))
|
||||||
|
},
|
||||||
|
formatOneAmount: function(item) {
|
||||||
|
return item?.amount ?? 1
|
||||||
|
},
|
||||||
|
formatOneUnit: function(item) {
|
||||||
|
return item?.unit?.name ?? ""
|
||||||
|
},
|
||||||
|
formatOneCategory: function(item) {
|
||||||
|
return item?.food?.supermarket_category?.name
|
||||||
|
},
|
||||||
|
formatOneCompletedAt: function(item) {
|
||||||
|
if (!item.completed_at) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
|
||||||
|
},
|
||||||
|
formatOneFood: function(item) {
|
||||||
|
return item.food.name
|
||||||
|
},
|
||||||
|
formatOneChecked: function(item) {
|
||||||
|
return item.checked
|
||||||
|
},
|
||||||
|
formatOneMealPlan: function(item) {
|
||||||
|
return item?.recipe_mealplan?.name
|
||||||
|
},
|
||||||
|
formatOneRecipe: function(item) {
|
||||||
|
return item?.recipe_mealplan?.recipe_name
|
||||||
|
},
|
||||||
|
formatOneNote: function(item) {
|
||||||
|
if (!item) {
|
||||||
|
item = this.entries[0]
|
||||||
|
}
|
||||||
|
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
|
||||||
|
},
|
||||||
|
formatOneCreatedBy: function(item) {
|
||||||
|
return [item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ")
|
||||||
|
},
|
||||||
|
openRecipeCard: function(e, item) {
|
||||||
|
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, { id: item.recipe_mealplan.recipe }).then((result) => {
|
||||||
|
let recipe = result.data
|
||||||
|
recipe.steps = undefined
|
||||||
|
this.recipe = true
|
||||||
|
this.$refs.recipe_card.open(e, recipe)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateChecked: function(e, item) {
|
||||||
|
if (!item) {
|
||||||
|
let update = { entries: this.entries.map((x) => x.id), checked: !this.formatChecked }
|
||||||
|
this.$emit("update-checkbox", update)
|
||||||
|
} else {
|
||||||
|
this.$emit("update-checkbox", { id: item.id, checked: !item.checked })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* table { border-collapse:collapse } /* Ensure no space between cells */
|
||||||
|
/* tr.strikeout td { position:relative } /* Setup a new coordinate system */
|
||||||
|
/* tr.strikeout td:before { /* Create a new element that */
|
||||||
|
/* content: " "; /* …has no text content */
|
||||||
|
/* position: absolute; /* …is absolutely positioned */
|
||||||
|
/* left: 0; top: 50%; width: 100%; /* …with the top across the middle */
|
||||||
|
/* border-bottom: 1px solid #000; /* …and with a border on the top */
|
||||||
|
/* } */
|
||||||
|
</style>
|
@ -38,12 +38,11 @@
|
|||||||
<div class="col col-md-4"
|
<div class="col col-md-4"
|
||||||
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
|
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
<ingredients-card
|
||||||
<template v-for="i in step.ingredients">
|
:steps="[step]"
|
||||||
<Ingredient-component v-bind:ingredient="i" :ingredient_factor="ingredient_factor" :key="i.id"
|
:ingredient_factor="ingredient_factor"
|
||||||
@checked-state-changed="$emit('checked-state-changed', i)"></Ingredient-component>
|
@checked-state-changed="$emit('checked-state-changed', $event)"
|
||||||
</template>
|
/>
|
||||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1,}">
|
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1,}">
|
||||||
@ -161,6 +160,7 @@ import {calculateAmount} from "@/utils/utils";
|
|||||||
import {GettextMixin} from "@/utils/utils";
|
import {GettextMixin} from "@/utils/utils";
|
||||||
|
|
||||||
import CompileComponent from "@/components/CompileComponent";
|
import CompileComponent from "@/components/CompileComponent";
|
||||||
|
import IngredientsCard from "@/components/IngredientsCard";
|
||||||
import Vue from "vue";
|
import Vue from "vue";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {ResolveUrlMixin} from "@/utils/utils";
|
import {ResolveUrlMixin} from "@/utils/utils";
|
||||||
@ -174,10 +174,7 @@ export default {
|
|||||||
GettextMixin,
|
GettextMixin,
|
||||||
ResolveUrlMixin,
|
ResolveUrlMixin,
|
||||||
],
|
],
|
||||||
components: {
|
components: { CompileComponent, IngredientsCard},
|
||||||
IngredientComponent,
|
|
||||||
CompileComponent,
|
|
||||||
},
|
|
||||||
props: {
|
props: {
|
||||||
step: Object,
|
step: Object,
|
||||||
ingredient_factor: Number,
|
ingredient_factor: Number,
|
||||||
|
@ -173,6 +173,15 @@
|
|||||||
"Time": "Time",
|
"Time": "Time",
|
||||||
"Text": "Text",
|
"Text": "Text",
|
||||||
"Shopping_list": "Shopping List",
|
"Shopping_list": "Shopping List",
|
||||||
|
"Added_by": "Added By",
|
||||||
|
"Added_on": "Added On",
|
||||||
|
"AddToShopping": "Add to shopping list",
|
||||||
|
"IngredientInShopping": "This ingredient is in your shopping list.",
|
||||||
|
"NotInShopping": "{food} is not in your shopping list.",
|
||||||
|
"OnHand": "Have On Hand",
|
||||||
|
"FoodOnHand": "You have {food} on hand.",
|
||||||
|
"FoodNotOnHand": "You do not have {food} on hand.",
|
||||||
|
"Undefined": "Undefined",
|
||||||
"Create_Meal_Plan_Entry": "Create meal plan entry",
|
"Create_Meal_Plan_Entry": "Create meal plan entry",
|
||||||
"Edit_Meal_Plan_Entry": "Edit meal plan entry",
|
"Edit_Meal_Plan_Entry": "Edit meal plan entry",
|
||||||
"Title": "Title",
|
"Title": "Title",
|
||||||
@ -194,6 +203,41 @@
|
|||||||
"Title_or_Recipe_Required": "Title or recipe selection required",
|
"Title_or_Recipe_Required": "Title or recipe selection required",
|
||||||
"Color": "Color",
|
"Color": "Color",
|
||||||
"New_Meal_Type": "New Meal type",
|
"New_Meal_Type": "New Meal type",
|
||||||
|
"AddFoodToShopping": "Add {food} to your shopping list",
|
||||||
|
"RemoveFoodFromShopping": "Remove {food} from your shopping list",
|
||||||
|
"DeleteShoppingConfirm": "Are you sure that you want to remove all {food} from the shopping list?",
|
||||||
|
"IgnoredFood": "{food} is set to ignore shopping.",
|
||||||
|
"Add_Servings_to_Shopping": "Add {servings} Servings to Shopping",
|
||||||
|
"Inherit": "Inherit",
|
||||||
|
"IgnoreInherit": "Do Not Inherit Fields",
|
||||||
|
"FoodInherit": "Food Inheritable Fields",
|
||||||
|
"ShowUncategorizedFood": "Show Undefined",
|
||||||
|
"GroupBy": "Group By",
|
||||||
|
"SupermarketCategoriesOnly": "Supermarket Categories Only",
|
||||||
|
"MoveCategory": "Move To: ",
|
||||||
|
"CountMore": "...+{count} more",
|
||||||
|
"IgnoreThis": "Never auto-add {food} to shopping",
|
||||||
|
"DelayFor": "Delay for {hours} hours",
|
||||||
|
"Warning": "Warning",
|
||||||
|
"NoCategory": "No category selected.",
|
||||||
|
"InheritWarning": "{food} is set to inherit, changes may not persist.",
|
||||||
|
"ShowDelayed": "Show Delayed Items",
|
||||||
|
"Completed": "Completed",
|
||||||
|
"OfflineAlert": "You are offline, shopping list may not syncronize.",
|
||||||
|
"shopping_share": "Share Shopping List",
|
||||||
|
"shopping_auto_sync": "Autosync",
|
||||||
|
"mealplan_autoadd_shopping": "Auto Add Meal Plan",
|
||||||
|
"mealplan_autoexclude_onhand": "Exclude On Hand",
|
||||||
|
"mealplan_autoinclude_related": "Add Related Recipes",
|
||||||
|
"default_delay": "Default Delay Hours",
|
||||||
|
"shopping_share_desc": "Users will see all items you add to your shopping list. They must add you to see items on their list.",
|
||||||
|
"shopping_auto_sync_desc": "Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but will use mobile data.",
|
||||||
|
"mealplan_autoadd_shopping_desc": "Automatically add meal plan ingredients to shopping list.",
|
||||||
|
"mealplan_autoexclude_onhand_desc": "When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.",
|
||||||
|
"mealplan_autoinclude_related_desc": "When automatically adding a meal plan to the shopping list, include all related recipes.",
|
||||||
|
"default_delay_desc": "Default number of hours to delay a shopping list entry.",
|
||||||
|
"filter_to_supermarket": "Filter to Supermarket",
|
||||||
|
"filter_to_supermarket_desc": "Filter shopping list to only include supermarket categories.",
|
||||||
"Week_Numbers": "Week numbers",
|
"Week_Numbers": "Week numbers",
|
||||||
"Show_Week_Numbers": "Show week numbers ?",
|
"Show_Week_Numbers": "Show week numbers ?",
|
||||||
"Export_As_ICal": "Export current period to iCal format",
|
"Export_As_ICal": "Export current period to iCal format",
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {djangoGettext as _, makeToast} from "@/utils/utils";
|
import {djangoGettext as _, makeToast} from "@/utils/utils";
|
||||||
import {resolveDjangoUrl} from "@/utils/utils";
|
import {resolveDjangoUrl} from "@/utils/utils";
|
||||||
|
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||||
|
|
||||||
axios.defaults.xsrfCookieName = 'csrftoken'
|
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||||
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
|
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
|
||||||
@ -47,4 +48,8 @@ function handleError(error, message) {
|
|||||||
makeToast('Error', message, 'danger')
|
makeToast('Error', message, 'danger')
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generic class to use OpenAPIs with parameters and provide generic modals
|
||||||
|
* */
|
16
vue/src/utils/apiv2.js
Normal file
16
vue/src/utils/apiv2.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* Utility functions to use OpenAPIs generically
|
||||||
|
* */
|
||||||
|
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||||
|
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
|
||||||
|
|
||||||
|
export class GenericAPI {
|
||||||
|
constructor(model, action) {
|
||||||
|
this.model = model;
|
||||||
|
this.action = action;
|
||||||
|
this.function_name = action + model
|
||||||
|
}
|
||||||
|
}
|
@ -67,12 +67,15 @@ export class Models {
|
|||||||
merge: true,
|
merge: true,
|
||||||
badges: {
|
badges: {
|
||||||
linked_recipe: true,
|
linked_recipe: true,
|
||||||
|
on_hand: true,
|
||||||
|
shopping: true,
|
||||||
},
|
},
|
||||||
tags: [{ field: "supermarket_category", label: "name", color: "info" }],
|
tags: [{ field: "supermarket_category", label: "name", color: "info" }],
|
||||||
// REQUIRED: unordered array of fields that can be set during create
|
// REQUIRED: unordered array of fields that can be set during create
|
||||||
create: {
|
create: {
|
||||||
// if not defined partialUpdate will use the same parameters, prepending 'id'
|
// if not defined partialUpdate will use the same parameters, prepending 'id'
|
||||||
params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category"]],
|
params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category", "on_hand", "inherit", "ignore_inherit"]],
|
||||||
|
|
||||||
form: {
|
form: {
|
||||||
name: {
|
name: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
@ -101,6 +104,12 @@ export class Models {
|
|||||||
field: "ignore_shopping",
|
field: "ignore_shopping",
|
||||||
label: i18n.t("Ignore_Shopping"),
|
label: i18n.t("Ignore_Shopping"),
|
||||||
},
|
},
|
||||||
|
onhand: {
|
||||||
|
form_field: true,
|
||||||
|
type: "checkbox",
|
||||||
|
field: "on_hand",
|
||||||
|
label: i18n.t("OnHand"),
|
||||||
|
},
|
||||||
shopping_category: {
|
shopping_category: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
type: "lookup",
|
type: "lookup",
|
||||||
@ -109,8 +118,30 @@ export class Models {
|
|||||||
label: i18n.t("Shopping_Category"),
|
label: i18n.t("Shopping_Category"),
|
||||||
allow_create: true,
|
allow_create: true,
|
||||||
},
|
},
|
||||||
|
inherit: {
|
||||||
|
form_field: true,
|
||||||
|
type: "checkbox",
|
||||||
|
field: "inherit",
|
||||||
|
label: i18n.t("Inherit"),
|
||||||
|
},
|
||||||
|
ignore_inherit: {
|
||||||
|
form_field: true,
|
||||||
|
type: "lookup",
|
||||||
|
multiple: true,
|
||||||
|
field: "ignore_inherit",
|
||||||
|
list: "FOOD_INHERIT_FIELDS",
|
||||||
|
label: i18n.t("IgnoreInherit"),
|
||||||
|
},
|
||||||
|
form_function: "FoodCreateDefault",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
shopping: {
|
||||||
|
params: ["id", ["id", "amount", "unit", "_delete"]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
static FOOD_INHERIT_FIELDS = {
|
||||||
|
name: i18n.t("FoodInherit"),
|
||||||
|
apiName: "FoodInheritField",
|
||||||
}
|
}
|
||||||
|
|
||||||
static KEYWORD = {
|
static KEYWORD = {
|
||||||
@ -180,6 +211,12 @@ export class Models {
|
|||||||
static SHOPPING_LIST = {
|
static SHOPPING_LIST = {
|
||||||
name: i18n.t("Shopping_list"),
|
name: i18n.t("Shopping_list"),
|
||||||
apiName: "ShoppingListEntry",
|
apiName: "ShoppingListEntry",
|
||||||
|
list: {
|
||||||
|
params: ["id", "checked", "supermarket", "options"],
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
params: [["amount", "unit", "food", "checked"]],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
static RECIPE_BOOK = {
|
static RECIPE_BOOK = {
|
||||||
@ -370,41 +407,15 @@ export class Models {
|
|||||||
name: i18n.t("Recipe"),
|
name: i18n.t("Recipe"),
|
||||||
apiName: "Recipe",
|
apiName: "Recipe",
|
||||||
list: {
|
list: {
|
||||||
params: [
|
params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"],
|
||||||
"query",
|
// 'config': {
|
||||||
"keywords",
|
// 'foods': {'type': 'string'},
|
||||||
"foods",
|
// 'keywords': {'type': 'string'},
|
||||||
"units",
|
// 'books': {'type': 'string'},
|
||||||
"rating",
|
// }
|
||||||
"books",
|
|
||||||
"steps",
|
|
||||||
"keywordsOr",
|
|
||||||
"foodsOr",
|
|
||||||
"booksOr",
|
|
||||||
"internal",
|
|
||||||
"random",
|
|
||||||
"_new",
|
|
||||||
"page",
|
|
||||||
"pageSize",
|
|
||||||
"options",
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
foods: { type: "string" },
|
|
||||||
keywords: { type: "string" },
|
|
||||||
books: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
shopping: {
|
||||||
|
params: ["id", ["id", "list_recipe", "ingredients", "servings"]],
|
||||||
static STEP = {
|
|
||||||
name: i18n.t("Step"),
|
|
||||||
apiName: "Step",
|
|
||||||
paginated: true,
|
|
||||||
list: {
|
|
||||||
header_component: {
|
|
||||||
name: "BetaWarning",
|
|
||||||
},
|
|
||||||
params: ["query", "page", "pageSize", "options"],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -461,6 +472,11 @@ export class Models {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
static USER = {
|
||||||
|
name: i18n.t("User"),
|
||||||
|
apiName: "User",
|
||||||
|
paginated: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Actions {
|
export class Actions {
|
||||||
@ -639,4 +655,7 @@ export class Actions {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
static SHOPPING = {
|
||||||
|
function: "shopping",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@ import Vue from "vue"
|
|||||||
import { Actions, Models } from "./models"
|
import { Actions, Models } from "./models"
|
||||||
|
|
||||||
export const ToastMixin = {
|
export const ToastMixin = {
|
||||||
|
name: "ToastMixin",
|
||||||
methods: {
|
methods: {
|
||||||
makeToast: function (title, message, variant = null) {
|
makeToast: function (title, message, variant = null) {
|
||||||
return makeToast(title, message, variant)
|
return makeToast(title, message, variant)
|
||||||
@ -147,12 +148,17 @@ export function resolveDjangoUrl(url, params = null) {
|
|||||||
/*
|
/*
|
||||||
* other utilities
|
* other utilities
|
||||||
* */
|
* */
|
||||||
|
export function getUserPreference(pref = undefined) {
|
||||||
export function getUserPreference(pref) {
|
let user_preference
|
||||||
if (window.USER_PREF === undefined) {
|
if (document.getElementById("user_preference")) {
|
||||||
|
user_preference = JSON.parse(document.getElementById("user_preference").textContent)
|
||||||
|
} else {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return window.USER_PREF[pref]
|
if (pref) {
|
||||||
|
return user_preference[pref]
|
||||||
|
}
|
||||||
|
return user_preference
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateAmount(amount, factor) {
|
export function calculateAmount(amount, factor) {
|
||||||
@ -214,6 +220,11 @@ export const ApiMixin = {
|
|||||||
return {
|
return {
|
||||||
Models: Models,
|
Models: Models,
|
||||||
Actions: Actions,
|
Actions: Actions,
|
||||||
|
FoodCreateDefault: function (form) {
|
||||||
|
form.inherit_ignore = getUserPreference("food_ignore_default")
|
||||||
|
form.inherit = form.supermarket_category.length > 0
|
||||||
|
return form
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -525,3 +536,11 @@ const specialCases = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formFunctions = {
|
||||||
|
FoodCreateDefault: function (form) {
|
||||||
|
form.fields.filter((x) => x.field === "ignore_inherit")[0].value = getUserPreference("food_ignore_default")
|
||||||
|
form.fields.filter((x) => x.field === "inherit")[0].value = getUserPreference("food_ignore_default").length > 0
|
||||||
|
return form
|
||||||
|
},
|
||||||
|
}
|
||||||
|
@ -37,8 +37,8 @@ const pages = {
|
|||||||
entry: "./src/apps/MealPlanView/main.js",
|
entry: "./src/apps/MealPlanView/main.js",
|
||||||
chunks: ["chunk-vendors"],
|
chunks: ["chunk-vendors"],
|
||||||
},
|
},
|
||||||
checklist_view: {
|
shopping_list_view: {
|
||||||
entry: "./src/apps/ChecklistView/main.js",
|
entry: "./src/apps/ShoppingListView/main.js",
|
||||||
chunks: ["chunk-vendors"],
|
chunks: ["chunk-vendors"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -47,7 +47,7 @@ module.exports = {
|
|||||||
pages: pages,
|
pages: pages,
|
||||||
filenameHashing: false,
|
filenameHashing: false,
|
||||||
productionSourceMap: false,
|
productionSourceMap: false,
|
||||||
publicPath: process.env.NODE_ENV === "production" ? "" : "http://localhost:8080/",
|
publicPath: process.env.NODE_ENV === "production" ? "/static/vue" : "http://localhost:8080/",
|
||||||
outputDir: "../cookbook/static/vue/",
|
outputDir: "../cookbook/static/vue/",
|
||||||
runtimeCompiler: true,
|
runtimeCompiler: true,
|
||||||
pwa: {
|
pwa: {
|
||||||
@ -90,18 +90,9 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// TODO make this conditional on .env DEBUG = FALSE
|
// TODO make this conditional on .env DEBUG = FALSE
|
||||||
config.optimization.minimize(true)
|
config.optimization.minimize(false)
|
||||||
)
|
)
|
||||||
|
|
||||||
//TODO somehow remov them as they are also added to the manifest config of the service worker
|
|
||||||
/*
|
|
||||||
Object.keys(pages).forEach(page => {
|
|
||||||
config.plugins.delete(`html-${page}`);
|
|
||||||
config.plugins.delete(`preload-${page}`);
|
|
||||||
config.plugins.delete(`prefetch-${page}`);
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
||||||
config.plugin("BundleTracker").use(BundleTracker, [{ relativePath: true, path: "../vue/" }])
|
config.plugin("BundleTracker").use(BundleTracker, [{ relativePath: true, path: "../vue/" }])
|
||||||
|
|
||||||
config.resolve.alias.set("__STATIC__", "static")
|
config.resolve.alias.set("__STATIC__", "static")
|
||||||
|
Loading…
Reference in New Issue
Block a user