Merge shopping_list develop
This commit is contained in:
@ -1,23 +1,22 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.contrib.auth.models import Group, User
|
||||||
from django.contrib.postgres.search import SearchVector
|
from django.contrib.postgres.search import SearchVector
|
||||||
|
from django.utils import translation
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
from treebeard.admin import TreeAdmin
|
from treebeard.admin import TreeAdmin
|
||||||
from treebeard.forms import movenodeform_factory
|
from treebeard.forms import movenodeform_factory
|
||||||
from django.contrib.auth.admin import UserAdmin
|
|
||||||
from django.contrib.auth.models import User, Group
|
|
||||||
from django_scopes import scopes_disabled
|
|
||||||
from django.utils import translation
|
|
||||||
|
|
||||||
from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
|
|
||||||
MealPlan, MealType, NutritionInformation, Recipe,
|
|
||||||
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink,
|
|
||||||
ShoppingList, ShoppingListEntry, ShoppingListRecipe,
|
|
||||||
Space, Step, Storage, Sync, SyncLog, Unit, UserPreference,
|
|
||||||
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation,
|
|
||||||
ImportLog, TelegramBot, BookmarkletImport, UserFile, SearchPreference)
|
|
||||||
|
|
||||||
from cookbook.managers import DICTIONARY
|
from cookbook.managers import DICTIONARY
|
||||||
|
|
||||||
|
from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField, ImportLog,
|
||||||
|
Ingredient, InviteLink, Keyword, MealPlan, MealType, NutritionInformation,
|
||||||
|
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
|
||||||
|
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||||
|
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||||
|
TelegramBot, Unit, UserFile, UserPreference, ViewLog)
|
||||||
|
|
||||||
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
def has_add_permission(self, request, obj=None):
|
def has_add_permission(self, request, obj=None):
|
||||||
@ -129,6 +128,7 @@ def sort_tree(modeladmin, request, queryset):
|
|||||||
class KeywordAdmin(TreeAdmin):
|
class KeywordAdmin(TreeAdmin):
|
||||||
form = movenodeform_factory(Keyword)
|
form = movenodeform_factory(Keyword)
|
||||||
ordering = ('space', 'path',)
|
ordering = ('space', 'path',)
|
||||||
|
search_fields = ('name', )
|
||||||
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
|
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
|
||||||
|
|
||||||
|
|
||||||
@ -171,11 +171,13 @@ class RecipeAdmin(admin.ModelAdmin):
|
|||||||
admin.site.register(Recipe, RecipeAdmin)
|
admin.site.register(Recipe, RecipeAdmin)
|
||||||
|
|
||||||
admin.site.register(Unit)
|
admin.site.register(Unit)
|
||||||
|
# admin.site.register(FoodInheritField)
|
||||||
|
|
||||||
|
|
||||||
class FoodAdmin(TreeAdmin):
|
class FoodAdmin(TreeAdmin):
|
||||||
form = movenodeform_factory(Keyword)
|
form = movenodeform_factory(Keyword)
|
||||||
ordering = ('space', 'path',)
|
ordering = ('space', 'path',)
|
||||||
|
search_fields = ('name', )
|
||||||
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
|
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
|
||||||
|
|
||||||
|
|
||||||
@ -280,7 +282,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)
|
||||||
|
@ -12,29 +12,27 @@ class CookbookConfig(AppConfig):
|
|||||||
name = 'cookbook'
|
name = 'cookbook'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
# post_save signal is only necessary if using full-text search on postgres
|
|
||||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
|
||||||
'django.db.backends.postgresql']:
|
|
||||||
import cookbook.signals # noqa
|
import cookbook.signals # noqa
|
||||||
|
|
||||||
if not settings.DISABLE_TREE_FIX_STARTUP:
|
# if not settings.DISABLE_TREE_FIX_STARTUP:
|
||||||
# when starting up run fix_tree to:
|
# # when starting up run fix_tree to:
|
||||||
# a) make sure that nodes are sorted when switching between sort modes
|
# # a) make sure that nodes are sorted when switching between sort modes
|
||||||
# b) fix problems, if any, with tree consistency
|
# # b) fix problems, if any, with tree consistency
|
||||||
with scopes_disabled():
|
# with scopes_disabled():
|
||||||
try:
|
# try:
|
||||||
from cookbook.models import Keyword, Food
|
# from cookbook.models import Food, Keyword
|
||||||
#Keyword.fix_tree(fix_paths=True) # disabled for now, causes to many unknown issues
|
# Keyword.fix_tree(fix_paths=True)
|
||||||
# Food.fix_tree(fix_paths=True)
|
# Food.fix_tree(fix_paths=True)
|
||||||
except OperationalError:
|
# except OperationalError:
|
||||||
if DEBUG:
|
# if DEBUG:
|
||||||
traceback.print_exc()
|
# traceback.print_exc()
|
||||||
pass # if model does not exist there is no need to fix it
|
# pass # if model does not exist there is no need to fix it
|
||||||
except ProgrammingError:
|
# except ProgrammingError:
|
||||||
if DEBUG:
|
# if DEBUG:
|
||||||
traceback.print_exc()
|
# traceback.print_exc()
|
||||||
pass # if migration has not been run database cannot be fixed yet
|
# pass # if migration has not been run database cannot be fixed yet
|
||||||
except Exception:
|
# except Exception:
|
||||||
if DEBUG:
|
# if DEBUG:
|
||||||
traceback.print_exc()
|
# traceback.print_exc()
|
||||||
pass # dont break startup just because fix could not run, need to investigate cases when this happens
|
# pass # dont break startup just because fix could not run, need to investigate cases when this happens
|
||||||
|
|
||||||
|
@ -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, Food, 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):
|
||||||
@ -37,6 +35,9 @@ class UserPreferenceForm(forms.ModelForm):
|
|||||||
prefix = 'preference'
|
prefix = 'preference'
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
if x := kwargs.get('instance', None):
|
||||||
|
space = x.space
|
||||||
|
else:
|
||||||
space = kwargs.pop('space')
|
space = kwargs.pop('space')
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['plan_share'].queryset = User.objects.filter(userpreference__space=space).all()
|
self.fields['plan_share'].queryset = User.objects.filter(userpreference__space=space).all()
|
||||||
@ -46,8 +47,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 = {
|
||||||
@ -74,8 +74,8 @@ class UserPreferenceForm(forms.ModelForm):
|
|||||||
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
|
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
|
||||||
# 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 plans should be shared by default.'),
|
||||||
'Users with whom newly created meal plan/shopping list entries 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
|
||||||
@ -84,11 +84,14 @@ class UserPreferenceForm(forms.ModelForm):
|
|||||||
'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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -224,6 +227,7 @@ class StorageForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Deprecate
|
||||||
class RecipeBookEntryForm(forms.ModelForm):
|
class RecipeBookEntryForm(forms.ModelForm):
|
||||||
prefix = 'bookmark'
|
prefix = 'bookmark'
|
||||||
|
|
||||||
@ -263,6 +267,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(
|
||||||
@ -299,6 +304,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')
|
||||||
@ -466,3 +472,70 @@ 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', 'shopping_recent_days', 'csv_delim', 'csv_prefix'
|
||||||
|
)
|
||||||
|
|
||||||
|
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_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
|
||||||
|
'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'),
|
||||||
|
'default_delay': _('Default number of hours to delay a shopping list entry.'),
|
||||||
|
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
|
||||||
|
'shopping_recent_days': _('Days of recent shopping list entries to display.'),
|
||||||
|
'csv_delim': _('Delimiter to use for CSV exports.'),
|
||||||
|
'csv_prefix': _('Prefix to add when copying list to the clipboard.'),
|
||||||
|
|
||||||
|
}
|
||||||
|
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'),
|
||||||
|
'shopping_recent_days': _('Recent Days'),
|
||||||
|
'csv_delim': _('CSV Delimiter'),
|
||||||
|
"csv_prefix_label": _("List Prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
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."))
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs) # populates the post
|
||||||
|
self.fields['food_inherit'].queryset = Food.inheritable_fields
|
||||||
|
|
||||||
|
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):
|
||||||
"""
|
"""
|
||||||
@ -205,6 +205,9 @@ class CustomIsShared(permissions.BasePermission):
|
|||||||
return request.user.is_authenticated
|
return request.user.is_authenticated
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
|
# temporary hack to make old shopping list work with new shopping list
|
||||||
|
if obj.__class__.__name__ == 'ShoppingList':
|
||||||
|
return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
|
||||||
return is_object_shared(request.user, obj)
|
return is_object_shared(request.user, obj)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
@ -41,7 +30,6 @@ def search_recipes(request, queryset, params):
|
|||||||
search_steps = params.getlist('steps', [])
|
search_steps = params.getlist('steps', [])
|
||||||
search_units = params.get('units', None)
|
search_units = params.get('units', None)
|
||||||
|
|
||||||
# TODO I think default behavior should be 'AND' which is how most sites operate with facet/filters based on results
|
|
||||||
search_keywords_or = str2bool(params.get('keywords_or', True))
|
search_keywords_or = str2bool(params.get('keywords_or', True))
|
||||||
search_foods_or = str2bool(params.get('foods_or', True))
|
search_foods_or = str2bool(params.get('foods_or', True))
|
||||||
search_books_or = str2bool(params.get('books_or', True))
|
search_books_or = str2bool(params.get('books_or', True))
|
||||||
@ -49,7 +37,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,24 +196,18 @@ 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.
|
||||||
:param qs:
|
:param qs:
|
||||||
|
|
||||||
recipe queryset to build facets from
|
recipe queryset to build facets from
|
||||||
|
|
||||||
:param request:
|
:param request:
|
||||||
|
|
||||||
the web request that contains the necessary query parameters
|
the web request that contains the necessary query parameters
|
||||||
|
|
||||||
:param use_cache:
|
:param use_cache:
|
||||||
|
|
||||||
will find results in cache, if any, and return them or empty list.
|
will find results in cache, if any, and return them or empty list.
|
||||||
will save the list of recipes IDs in the cache for future processing
|
will save the list of recipes IDs in the cache for future processing
|
||||||
|
|
||||||
:param hash_key:
|
:param hash_key:
|
||||||
|
|
||||||
the cache key of the recipe list to process
|
the cache key of the recipe list to process
|
||||||
only evaluated if the use_cache parameter is false
|
only evaluated if the use_cache parameter is false
|
||||||
"""
|
"""
|
||||||
@ -300,7 +282,6 @@ def get_facet(qs=None, request=None, use_cache=True, hash_key=None):
|
|||||||
foods = Food.objects.filter(ingredient__step__recipe__in=recipe_list, space=request.space).annotate(recipe_count=Count('ingredient'))
|
foods = Food.objects.filter(ingredient__step__recipe__in=recipe_list, space=request.space).annotate(recipe_count=Count('ingredient'))
|
||||||
food_a = annotated_qs(foods, root=True, fill=True)
|
food_a = annotated_qs(foods, root=True, fill=True)
|
||||||
|
|
||||||
# TODO add rating facet
|
|
||||||
facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list)
|
facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list)
|
||||||
facets['Foods'] = fill_annotated_parents(food_a, food_list)
|
facets['Foods'] = fill_annotated_parents(food_a, food_list)
|
||||||
# TODO add book facet
|
# TODO add book facet
|
||||||
@ -373,8 +354,6 @@ def annotated_qs(qs, root=False, fill=False):
|
|||||||
dirty = False
|
dirty = False
|
||||||
current_node = node_queue[-1]
|
current_node = node_queue[-1]
|
||||||
depth = current_node.get_depth()
|
depth = current_node.get_depth()
|
||||||
# TODO if node is at the wrong depth for some reason this fails
|
|
||||||
# either create a 'fix node' page, or automatically move the node to the root
|
|
||||||
parent_id = current_node.parent
|
parent_id = current_node.parent
|
||||||
if root and depth > 1 and parent_id not in nodes_list:
|
if root and depth > 1 and parent_id not in nodes_list:
|
||||||
parent_id = current_node.parent
|
parent_id = current_node.parent
|
||||||
|
155
cookbook/helper/shopping_helper.py
Normal file
155
cookbook/helper/shopping_helper.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
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 (Ingredient, ShoppingListEntry, ShoppingListRecipe,
|
||||||
|
SupermarketCategoryRelation)
|
||||||
|
from recipes import settings
|
||||||
|
|
||||||
|
|
||||||
|
def shopping_helper(qs, request):
|
||||||
|
supermarket = request.query_params.get('supermarket', None)
|
||||||
|
checked = request.query_params.get('checked', 'recent')
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
supermarket_order = ['food__supermarket_category__name', 'food__name']
|
||||||
|
|
||||||
|
# TODO created either scheduled task or startup task to delete very old shopping list entries
|
||||||
|
# TODO create user preference to define 'very old'
|
||||||
|
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)
|
||||||
|
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
|
||||||
|
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')
|
||||||
|
|
||||||
|
|
||||||
|
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
:param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
|
||||||
|
"""
|
||||||
|
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(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
|
||||||
|
if not created_by:
|
||||||
|
raise ValueError(_("You must supply a created_by"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
servings = float(servings)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
servings = getattr(mealplan, 'servings', 1.0)
|
||||||
|
|
||||||
|
servings_factor = servings / r.servings
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
related_step_ing = []
|
||||||
|
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)
|
||||||
|
|
||||||
|
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
|
||||||
|
ingredients = ingredients.exclude(food__food_onhand=True)
|
||||||
|
|
||||||
|
if related := created_by.userpreference.mealplan_autoinclude_related:
|
||||||
|
# TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
|
||||||
|
related_recipes = r.get_related_recipes()
|
||||||
|
|
||||||
|
for x in related_recipes:
|
||||||
|
# related recipe is a Step serving size is driven by recipe serving size
|
||||||
|
# TODO once/if Steps can have a serving size this needs to be refactored
|
||||||
|
if exclude_onhand:
|
||||||
|
# if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
|
||||||
|
related_step_ing += Ingredient.objects.filter(step__recipe=x, food__food_onhand=False, space=space).values_list('id', flat=True)
|
||||||
|
else:
|
||||||
|
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
|
||||||
|
|
||||||
|
x_ing = []
|
||||||
|
if ingredients.filter(food__recipe=x).exists():
|
||||||
|
for ing in ingredients.filter(food__recipe=x):
|
||||||
|
if exclude_onhand:
|
||||||
|
x_ing = Ingredient.objects.filter(step__recipe=x, food__food_onhand=False, space=space)
|
||||||
|
else:
|
||||||
|
x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
|
||||||
|
for i in [x for x in x_ing]:
|
||||||
|
ShoppingListEntry.objects.create(
|
||||||
|
list_recipe=list_recipe,
|
||||||
|
food=i.food,
|
||||||
|
unit=i.unit,
|
||||||
|
ingredient=i,
|
||||||
|
amount=i.amount * Decimal(servings_factor),
|
||||||
|
created_by=created_by,
|
||||||
|
space=space,
|
||||||
|
)
|
||||||
|
# dont' add food to the shopping list that are actually recipes that will be added as ingredients
|
||||||
|
ingredients = ingredients.exclude(food__recipe=x)
|
||||||
|
|
||||||
|
add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
|
||||||
|
if not append:
|
||||||
|
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(add_ingredients) - 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
|
||||||
|
|
||||||
|
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))
|
||||||
|
list_recipe.servings = servings
|
||||||
|
list_recipe.save()
|
||||||
|
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
|
||||||
|
for i in [x for x in add_ingredients if x.food]:
|
||||||
|
|
||||||
|
ShoppingListEntry.objects.create(
|
||||||
|
list_recipe=list_recipe,
|
||||||
|
food=i.food,
|
||||||
|
unit=i.unit,
|
||||||
|
ingredient=i,
|
||||||
|
amount=i.amount * Decimal(servings_factor),
|
||||||
|
created_by=created_by,
|
||||||
|
space=space,
|
||||||
|
)
|
||||||
|
|
||||||
|
# return all shopping list items
|
||||||
|
return list_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
|
||||||
|
@ -2,13 +2,15 @@
|
|||||||
import annoying.fields
|
import annoying.fields
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.postgres.indexes import GinIndex
|
from django.contrib.postgres.indexes import GinIndex
|
||||||
from django.contrib.postgres.search import SearchVectorField, SearchVector
|
from django.contrib.postgres.search import SearchVector, SearchVectorField
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.db.models import deletion
|
from django.db.models import deletion
|
||||||
from django_scopes import scopes_disabled
|
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from cookbook.managers import DICTIONARY
|
from cookbook.managers import DICTIONARY
|
||||||
from cookbook.models import Recipe, Step, Index, PermissionModelMixin, nameSearchField, allSearchFields
|
from cookbook.models import (Index, PermissionModelMixin, Recipe, Step, allSearchFields,
|
||||||
|
nameSearchField)
|
||||||
|
|
||||||
|
|
||||||
def set_default_search_vector(apps, schema_editor):
|
def set_default_search_vector(apps, schema_editor):
|
||||||
@ -16,8 +18,6 @@ def set_default_search_vector(apps, schema_editor):
|
|||||||
return
|
return
|
||||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
# TODO this approach doesn't work terribly well if multiple languages are in use
|
|
||||||
# I'm also uncertain about forcing unaccent here
|
|
||||||
Recipe.objects.all().update(
|
Recipe.objects.all().update(
|
||||||
name_search_vector=SearchVector('name__unaccent', weight='A', config=language),
|
name_search_vector=SearchVector('name__unaccent', weight='A', config=language),
|
||||||
desc_search_vector=SearchVector('description__unaccent', weight='B', config=language)
|
desc_search_vector=SearchVector('description__unaccent', weight='B', config=language)
|
||||||
|
144
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
144
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
# 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='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='userpreference',
|
||||||
|
name='mealplan_autoinclude_related',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='inherit_fields',
|
||||||
|
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.RenameField(
|
||||||
|
model_name='food',
|
||||||
|
old_name='ignore_shopping',
|
||||||
|
new_name='food_onhand',
|
||||||
|
),
|
||||||
|
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='On Hand', field='food_onhand')
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
]
|
23
cookbook/migrations/0162_userpreference_csv_delim.py
Normal file
23
cookbook/migrations/0162_userpreference_csv_delim.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.9 on 2021-11-30 22:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('cookbook', '0161_alter_shoppinglistentry_food'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='csv_delim',
|
||||||
|
field=models.CharField(default=',', max_length=2),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userpreference',
|
||||||
|
name='csv_prefix',
|
||||||
|
field=models.CharField(blank=True, max_length=10),
|
||||||
|
),
|
||||||
|
]
|
@ -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):
|
||||||
@ -54,6 +67,9 @@ class TreeManager(MP_NodeManager):
|
|||||||
except self.model.DoesNotExist:
|
except self.model.DoesNotExist:
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
try:
|
try:
|
||||||
|
defaults = kwargs.pop('defaults', None)
|
||||||
|
if defaults:
|
||||||
|
kwargs = {**kwargs, **defaults}
|
||||||
# ManyToMany fields can't be set this way, so pop them out to save for later
|
# ManyToMany fields can't be set this way, so pop them out to save for later
|
||||||
fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
|
fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
|
||||||
many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
|
many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
|
||||||
@ -78,6 +94,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 +147,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 +221,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 +243,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 +322,21 @@ 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.DecimalField(default=4, max_digits=8, decimal_places=4)
|
||||||
|
shopping_recent_days = models.PositiveIntegerField(default=7)
|
||||||
|
csv_delim = models.CharField(max_length=2, default=",")
|
||||||
|
csv_prefix = models.CharField(max_length=10, blank=True,)
|
||||||
|
|
||||||
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 +451,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,13 +481,18 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
|||||||
|
|
||||||
|
|
||||||
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||||
|
# exclude fields not implemented yet
|
||||||
|
inheritable_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)])
|
||||||
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
|
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
|
||||||
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
|
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
|
||||||
ignore_shopping = models.BooleanField(default=False)
|
food_onhand = models.BooleanField(default=False) # inherited field
|
||||||
description = models.TextField(default='', blank=True)
|
description = models.TextField(default='', blank=True)
|
||||||
|
inherit_fields = models.ManyToManyField(FoodInheritField, blank=True) # inherited field: is this name better as inherit instead of ignore inherit? which is more intuitive?
|
||||||
|
|
||||||
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 +506,35 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
|||||||
else:
|
else:
|
||||||
return super().delete()
|
return super().delete()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reset_inheritance(space=None):
|
||||||
|
# resets inheritted fields to the space defaults and updates all inheritted fields to root object values
|
||||||
|
inherit = space.food_inherit.all()
|
||||||
|
|
||||||
|
# remove all inherited fields from food
|
||||||
|
Through = Food.objects.filter(space=space).first().inherit_fields.through
|
||||||
|
Through.objects.all().delete()
|
||||||
|
# food is going to inherit attributes
|
||||||
|
if space.food_inherit.all().count() > 0:
|
||||||
|
# ManyToMany cannot be updated through an UPDATE operation
|
||||||
|
for i in 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 'food_onhand' in inherit:
|
||||||
|
# get food at root that have children that need updated
|
||||||
|
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, food_onhand=True)).update(food_onhand=True)
|
||||||
|
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, food_onhand=False)).update(food_onhand=False)
|
||||||
|
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)
|
||||||
|
|
||||||
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 +656,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"]),
|
||||||
@ -660,8 +797,10 @@ 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')
|
||||||
|
|
||||||
@ -677,20 +816,26 @@ 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 getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None)
|
||||||
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, related_name='shopping_entries')
|
||||||
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
|
@staticmethod
|
||||||
def get_space_key():
|
def get_space_key():
|
||||||
@ -702,12 +847,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
|
||||||
|
|
||||||
|
@ -11,12 +11,14 @@ 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.helper.shopping_helper import list_from_recipe
|
||||||
Ingredient, Keyword, MealPlan, MealType, NutritionInformation, Recipe,
|
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
|
||||||
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink, ShoppingList,
|
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
|
||||||
ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket,
|
NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
|
||||||
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit,
|
RecipeImport, ShareLink, ShoppingList, ShoppingListEntry,
|
||||||
UserFile, UserPreference, ViewLog)
|
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
|
||||||
|
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
|
||||||
|
UserPreference, ViewLog)
|
||||||
from cookbook.templatetags.custom_tags import markdown
|
from cookbook.templatetags.custom_tags import markdown
|
||||||
|
|
||||||
|
|
||||||
@ -37,9 +39,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
|||||||
if bool(int(
|
if bool(int(
|
||||||
self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
|
self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
|
||||||
return fields
|
return fields
|
||||||
except AttributeError:
|
except (AttributeError, KeyError) as e:
|
||||||
pass
|
|
||||||
except KeyError:
|
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
del fields['image']
|
del fields['image']
|
||||||
@ -95,6 +95,8 @@ class CustomDecimalField(serializers.Field):
|
|||||||
class SpaceFilterSerializer(serializers.ListSerializer):
|
class SpaceFilterSerializer(serializers.ListSerializer):
|
||||||
|
|
||||||
def to_representation(self, data):
|
def to_representation(self, data):
|
||||||
|
if self.context.get('request', None) is None:
|
||||||
|
return
|
||||||
if (type(data) == QuerySet and data.query.is_sliced):
|
if (type(data) == QuerySet and data.query.is_sliced):
|
||||||
# if query is sliced it came from api request not nested serializer
|
# if query is sliced it came from api request not nested serializer
|
||||||
return super().to_representation(data)
|
return super().to_representation(data)
|
||||||
@ -136,20 +138,45 @@ class UserNameSerializer(WritableNestedModelSerializer):
|
|||||||
fields = ('id', 'username')
|
fields = ('id', 'username')
|
||||||
|
|
||||||
|
|
||||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
class FoodInheritFieldSerializer(WritableNestedModelSerializer):
|
||||||
plan_share = UserNameSerializer(many=True, read_only=True)
|
name = serializers.CharField(allow_null=True, allow_blank=True, required=False)
|
||||||
|
field = serializers.CharField(allow_null=True, allow_blank=True, required=False)
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if validated_data['user'] != self.context['request'].user:
|
# 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', )
|
||||||
|
read_only_fields = ['id']
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||||
|
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
|
||||||
|
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
if not validated_data.get('user', None):
|
||||||
|
raise ValidationError(_('A user is required'))
|
||||||
|
if (validated_data['user'] != self.context['request'].user):
|
||||||
raise NotFound()
|
raise NotFound()
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
# don't allow writing to FoodInheritField via API
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserPreference
|
model = UserPreference
|
||||||
fields = (
|
fields = (
|
||||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page',
|
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
|
||||||
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
|
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
|
||||||
'comments'
|
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', 'filter_to_supermarket'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -255,25 +282,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
|
||||||
@ -286,26 +299,13 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
|||||||
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', 'created_at',
|
||||||
'updated_at')
|
'updated_at', 'full_name')
|
||||||
read_only_fields = ('id', 'numchild', 'parent', 'image')
|
read_only_fields = ('id', 'label', '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 +369,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')
|
inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False)
|
||||||
|
|
||||||
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 +389,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', 'food_onhand', 'supermarket_category',
|
||||||
'numchild',
|
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name'
|
||||||
'numrecipe')
|
)
|
||||||
read_only_fields = ('id', 'numchild', 'parent', 'image')
|
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
||||||
|
|
||||||
|
|
||||||
class IngredientSerializer(WritableNestedModelSerializer):
|
class IngredientSerializer(WritableNestedModelSerializer):
|
||||||
@ -621,53 +608,124 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
|||||||
meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed
|
meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed
|
||||||
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
||||||
servings = CustomDecimalField()
|
servings = CustomDecimalField()
|
||||||
shared = UserNameSerializer(many=True)
|
shared = UserNameSerializer(many=True, required=False, allow_null=True)
|
||||||
|
shopping = serializers.SerializerMethodField('in_shopping')
|
||||||
|
|
||||||
def get_note_markdown(self, obj):
|
def get_note_markdown(self, obj):
|
||||||
return markdown(obj.note)
|
return markdown(obj.note)
|
||||||
|
|
||||||
|
def in_shopping(self, obj):
|
||||||
|
return ShoppingListRecipe.objects.filter(mealplan=obj.id).exists()
|
||||||
|
|
||||||
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):
|
||||||
|
list_from_recipe(mealplan=mealplan, servings=validated_data['servings'], created_by=validated_data['created_by'], space=validated_data['space'])
|
||||||
|
return mealplan
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MealPlan
|
model = MealPlan
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'title', 'recipe', 'servings', 'note', 'note_markdown',
|
'id', 'title', 'recipe', 'servings', 'note', 'note_markdown',
|
||||||
'date', 'meal_type', 'created_by', 'shared', 'recipe_name',
|
'date', 'meal_type', 'created_by', 'shared', 'recipe_name',
|
||||||
'meal_type_name'
|
'meal_type_name', 'shopping'
|
||||||
)
|
)
|
||||||
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:
|
||||||
|
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, required=False)
|
||||||
|
|
||||||
|
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 +746,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 +861,7 @@ class FoodExportSerializer(FoodSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Food
|
model = Food
|
||||||
fields = ('name', 'ignore_shopping', 'supermarket_category')
|
fields = ('name', 'food_onhand', 'supermarket_category',)
|
||||||
|
|
||||||
|
|
||||||
class IngredientExportSerializer(WritableNestedModelSerializer):
|
class IngredientExportSerializer(WritableNestedModelSerializer):
|
||||||
@ -847,3 +906,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,123 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
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.helper.shopping_helper import list_from_recipe
|
||||||
from cookbook.managers import DICTIONARY
|
from cookbook.managers import DICTIONARY
|
||||||
|
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
|
||||||
|
ShoppingListEntry, Step)
|
||||||
|
|
||||||
|
SQLITE = True
|
||||||
|
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||||
|
'django.db.backends.postgresql']:
|
||||||
|
SQLITE = False
|
||||||
|
|
||||||
|
# wraps a signal with the ability to set 'skip_signal' to avoid creating recursive signals
|
||||||
|
|
||||||
|
|
||||||
# TODO there is probably a way to generalize this
|
def skip_signal(signal_func):
|
||||||
@receiver(post_save, sender=Recipe)
|
@wraps(signal_func)
|
||||||
def update_recipe_search_vector(sender, instance=None, created=False, **kwargs):
|
def _decorator(sender, instance, **kwargs):
|
||||||
if not instance:
|
if not instance:
|
||||||
return
|
return None
|
||||||
|
if hasattr(instance, 'skip_signal'):
|
||||||
|
return None
|
||||||
|
return signal_func(sender, instance, **kwargs)
|
||||||
|
return _decorator
|
||||||
|
|
||||||
# needed to ensure search vector update doesn't trigger recursion
|
|
||||||
if hasattr(instance, '_dirty'):
|
|
||||||
return
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Recipe)
|
||||||
|
@skip_signal
|
||||||
|
def update_recipe_search_vector(sender, instance=None, created=False, **kwargs):
|
||||||
|
if SQLITE:
|
||||||
|
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):
|
||||||
|
if SQLITE:
|
||||||
|
return
|
||||||
|
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 = instance.inherit_fields.all()
|
||||||
if hasattr(instance, '_dirty'):
|
# nothing to apply from parent and nothing to apply to children
|
||||||
|
if (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.parent and inherit.count() > 0:
|
||||||
|
parent = instance.get_parent()
|
||||||
|
if 'food_onhand' in inherit:
|
||||||
|
instance.food_onhand = parent.food_onhand
|
||||||
|
# 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:
|
try:
|
||||||
instance._dirty = True
|
instance.skip_signal = True
|
||||||
instance.save()
|
instance.save()
|
||||||
finally:
|
finally:
|
||||||
del instance._dirty
|
del instance.skip_signal
|
||||||
|
|
||||||
|
# TODO figure out how to generalize this
|
||||||
|
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
|
||||||
|
_save = []
|
||||||
|
for child in instance.get_children().filter(inherit_fields__field='food_onhand'):
|
||||||
|
child.food_onhand = instance.food_onhand
|
||||||
|
_save.append(child)
|
||||||
|
# don't cascade empty supermarket category
|
||||||
|
if instance.supermarket_category:
|
||||||
|
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
|
||||||
|
for child in instance.get_children().filter(inherit_fields__field='supermarket_category'):
|
||||||
|
child.supermarket_category = instance.supermarket_category
|
||||||
|
_save.append(child)
|
||||||
|
for child in set(_save):
|
||||||
|
child.save()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=MealPlan)
|
||||||
|
def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs):
|
||||||
|
user = instance.get_owner()
|
||||||
|
if not user.userpreference.mealplan_autoadd_shopping:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not created and instance.shoppinglistrecipe_set.exists():
|
||||||
|
for x in instance.shoppinglistrecipe_set.all():
|
||||||
|
if instance.servings != x.servings:
|
||||||
|
list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
|
||||||
|
elif created:
|
||||||
|
# if creating a mealplan - perform shopping list activities
|
||||||
|
kwargs = {
|
||||||
|
'mealplan': instance,
|
||||||
|
'space': instance.space,
|
||||||
|
'created_by': user,
|
||||||
|
'servings': instance.servings
|
||||||
|
}
|
||||||
|
list_recipe = list_from_recipe(**kwargs)
|
||||||
|
File diff suppressed because one or more lines are too long
@ -339,7 +339,6 @@
|
|||||||
{% 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 %}
|
||||||
|
@ -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">
|
||||||
|
<span class="col col-md-9">
|
||||||
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
||||||
{% if create_url %}
|
{% if create_url %}
|
||||||
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</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>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if filter %}
|
{% if filter %}
|
||||||
<br/>
|
<br/>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% comment %} TODO: Deprecate {% endcomment %}
|
||||||
|
|
||||||
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
|
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
|
||||||
<div class="modal-dialog" role="document">
|
<div class="modal-dialog" role="document">
|
||||||
|
@ -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
|
||||||
|
{% comment %} $(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();
|
||||||
|
} {% endcomment %}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,15 +1,14 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% comment %} TODO: Deprecate {% endcomment %}
|
||||||
{% load django_tables2 %}
|
{% load django_tables2 %}
|
||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}{% trans "Shopping List" %}{% endblock %}
|
{% block title %}{% trans "Shopping List" %}{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{% include 'include/vue_base.html' %}
|
{% include 'include/vue_base.html' %}
|
||||||
|
|
||||||
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}">
|
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}" />
|
||||||
<script src="{% static 'js/vue-multiselect.min.js' %}"></script>
|
<script src="{% static 'js/vue-multiselect.min.js' %}"></script>
|
||||||
|
|
||||||
<script src="{% static 'js/Sortable.min.js' %}"></script>
|
<script src="{% static 'js/Sortable.min.js' %}"></script>
|
||||||
@ -19,7 +18,7 @@
|
|||||||
|
|
||||||
<script src="{% static 'js/js.cookie.min.js' %}"></script>
|
<script src="{% static 'js/js.cookie.min.js' %}"></script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="{% static 'css/pretty-checkbox.min.css' %}">
|
<link rel="stylesheet" href="{% static 'css/pretty-checkbox.min.css' %}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -28,14 +27,17 @@
|
|||||||
<span class="col col-md-9">
|
<span class="col col-md-9">
|
||||||
<h2>{% trans 'Shopping List' %}</h2>
|
<h2>{% trans 'Shopping List' %}</h2>
|
||||||
</span>
|
</span>
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
<div class="col col-mdd-3 text-right">
|
<div class="col col-mdd-3 text-right">
|
||||||
<b-form-checkbox switch size="lg" v-model="edit_mode"
|
<b-form-checkbox switch size="lg" v-model="edit_mode" @change="$forceUpdate()">{% trans 'Edit' %}</b-form-checkbox>
|
||||||
@change="$forceUpdate()">{% trans 'Edit' %}</b-form-checkbox>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="shopping_list !== undefined">
|
<template v-if="shopping_list !== undefined">
|
||||||
|
|
||||||
<div class="text-center" v-if="loading">
|
<div class="text-center" v-if="loading">
|
||||||
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
|
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
|
||||||
<img class="spinner-tandoor" />
|
<img class="spinner-tandoor" />
|
||||||
@ -46,25 +48,16 @@
|
|||||||
<div v-else-if="edit_mode">
|
<div v-else-if="edit_mode">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header"><i class="fa fa-search"></i> {% trans 'Search' %}</div>
|
||||||
<i class="fa fa-search"></i> {% trans 'Search' %}
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes"
|
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes" placeholder="{% trans 'Search Recipe' %}" />
|
||||||
placeholder="{% trans 'Search Recipe' %}">
|
|
||||||
<ul class="list-group" style="margin-top: 8px">
|
<ul class="list-group" style="margin-top: 8px">
|
||||||
<li class="list-group-item" v-for="x in recipes">
|
<li class="list-group-item" v-for="x in recipes">
|
||||||
<div class="row flex-row" style="padding-left: 0.5vw; padding-right: 0.5vw">
|
<div class="row flex-row" style="padding-left: 0.5vw; padding-right: 0.5vw">
|
||||||
<div class="flex-column flex-fill my-auto"><a v-bind:href="getRecipeUrl(x.id)"
|
<div class="flex-column flex-fill my-auto"><a v-bind:href="getRecipeUrl(x.id)" target="_blank" rel="nofollow norefferer">[[x.name]]</a></div>
|
||||||
target="_blank"
|
|
||||||
rel="nofollow norefferer">[[x.name]]</a>
|
|
||||||
</div>
|
|
||||||
<div class="flex-column align-self-end">
|
<div class="flex-column align-self-end">
|
||||||
<button class="btn btn-outline-primary shadow-none"
|
<button class="btn btn-outline-primary shadow-none" @click="addRecipeToList(x)"><i class="fa fa-plus"></i></button>
|
||||||
@click="addRecipeToList(x)"><i
|
|
||||||
class="fa fa-plus"></i></button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@ -75,41 +68,28 @@
|
|||||||
|
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header"><i class="fa fa-shopping-cart"></i> {% trans 'Shopping Recipes' %}</div>
|
||||||
<i class="fa fa-shopping-cart"></i> {% trans 'Shopping Recipes' %}
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<template v-if="shopping_list.recipes.length < 1">
|
<template v-if="shopping_list.recipes.length < 1"> {% trans 'No recipes selected' %} </template>
|
||||||
{% trans 'No recipes selected' %}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="row flex-row my-auto" v-for="x in shopping_list.recipes"
|
<div class="row flex-row my-auto" v-for="x in shopping_list.recipes" style="margin-top: 1vh !important">
|
||||||
style="margin-top: 1vh!important;">
|
|
||||||
<div class="flex-column align-self-start" style="margin-right: 0.4vw">
|
<div class="flex-column align-self-start" style="margin-right: 0.4vw">
|
||||||
<button class="btn btn-outline-danger" @click="removeRecipeFromList(x)"><i
|
<button class="btn btn-outline-danger" @click="removeRecipeFromList(x)"><i class="fa fa-trash"></i></button>
|
||||||
class="fa fa-trash"></i></button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow-1 flex-column my-auto"><a v-bind:href="getRecipeUrl(x.recipe)"
|
<div class="flex-grow-1 flex-column my-auto">
|
||||||
target="_blank"
|
<a v-bind:href="getRecipeUrl(x.recipe)" target="_blank" rel="nofollow norefferer">[[x.recipe_name]]</a>
|
||||||
rel="nofollow norefferer">[[x.recipe_name]]</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-column align-self-end">
|
<div class="flex-column align-self-end">
|
||||||
<div class="input-group input-group-sm my-auto">
|
<div class="input-group input-group-sm my-auto">
|
||||||
<div class="input-group-prepend">
|
<div class="input-group-prepend">
|
||||||
<button class="text-muted btn btn-outline-primary shadow-none"
|
<button class="text-muted btn btn-outline-primary shadow-none" @click="((x.servings - 1) > 0) ? x.servings -= 1 : 1">-</button>
|
||||||
@click="((x.servings - 1) > 0) ? x.servings -= 1 : 1">-
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input class="form-control" type="number" v-model="x.servings">
|
<input class="form-control" type="number" v-model="x.servings" />
|
||||||
<div class="input-group-append">
|
<div class="input-group-append">
|
||||||
<button class="text-muted btn btn-outline-primary shadow-none"
|
<button class="text-muted btn btn-outline-primary shadow-none" @click="x.servings += 1">+</button>
|
||||||
@click="x.servings += 1">
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -118,61 +98,57 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="table table-sm" style="margin-top: 1vh">
|
<table class="table table-sm" style="margin-top: 1vh">
|
||||||
|
|
||||||
<template v-for="c in display_categories">
|
<template v-for="c in display_categories">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="5">[[c.name]]</th>
|
<th colspan="5">[[c.name]]</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody is="draggable" :list="c.entries" tag="tbody" group="people" @sort="sortEntries"
|
<tbody is="draggable" :list="c.entries" tag="tbody" group="people" @sort="sortEntries" @change="dragChanged(c, $event)" handle=".handle">
|
||||||
@change="dragChanged(c, $event)" handle=".handle">
|
<tr v-for="(element, index) in c.entries" :key="element.id" v-bind:class="{ 'text-muted': element.checked }">
|
||||||
<tr v-for="(element, index) in c.entries" :key="element.id"
|
|
||||||
v-bind:class="{ 'text-muted': element.checked }">
|
|
||||||
<td class="handle"><i class="fas fa-sort"></i></td>
|
<td class="handle"><i class="fas fa-sort"></i></td>
|
||||||
<td>[[element.amount.toFixed(2)]]</td>
|
<td>[[element.amount.toFixed(2)]]</td>
|
||||||
<td>[[element.unit.name]]</td>
|
<td>[[element.unit.name]]</td>
|
||||||
<td>[[element.food.name]]</td>
|
<td>[[element.food.name]]</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-outline-danger" v-if="element.list_recipe === null"
|
<button
|
||||||
@click="shopping_list.entries = shopping_list.entries.filter(item => item.id !== element.id)">
|
class="btn btn-sm btn-outline-danger"
|
||||||
<i class="fa fa-trash"></i></button>
|
v-if="element.list_recipe === null"
|
||||||
|
@click="shopping_list.entries = shopping_list.entries.filter(item => item.id !== element.id)"
|
||||||
|
>
|
||||||
|
<i class="fa fa-trash"></i>
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="row" style="text-align: right">
|
<div class="row" style="text-align: right">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<b-form-checkbox switch v-model="entry_mode_simple"
|
<b-form-checkbox switch v-model="entry_mode_simple" @change="$cookies.set('shopping_entry_mode_simple',!entry_mode_simple, -1)"
|
||||||
@change="$cookies.set('shopping_entry_mode_simple',!entry_mode_simple, -1)">{% trans 'Entry Mode' %}</b-form-checkbox>
|
>{% trans 'Entry Mode' %}</b-form-checkbox
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" v-if="entry_mode_simple" style="margin-top: 2vh">
|
<div class="row" v-if="entry_mode_simple" style="margin-top: 2vh">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<form v-on:submit.prevent="addSimpleEntry()">
|
<form v-on:submit.prevent="addSimpleEntry()">
|
||||||
|
|
||||||
<label for="id_simple_entry">{% trans 'Add Entry' %}</label>
|
<label for="id_simple_entry">{% trans 'Add Entry' %}</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input id="id_simple_entry" class="form-control" v-model="simple_entry">
|
<input id="id_simple_entry" class="form-control" v-model="simple_entry" />
|
||||||
<div class="input-group-append">
|
<div class="input-group-append">
|
||||||
<button class="btn btn-outline-secondary" type="button" @click="addSimpleEntry()"><i
|
<button class="btn btn-outline-secondary" type="button" @click="addSimpleEntry()"><i class="fa fa-plus"></i></button>
|
||||||
class="fa fa-plus"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="row" v-if="!entry_mode_simple" style="margin-top: 2vh">
|
<div class="row" v-if="!entry_mode_simple" style="margin-top: 2vh">
|
||||||
<div class="col-12 col-lg-3">
|
<div class="col-12 col-lg-3">
|
||||||
<input id="id_advanced_entry" class="form-control" type="number" placeholder="{% trans 'Amount' %}"
|
<input id="id_advanced_entry" class="form-control" type="number" placeholder="{% trans 'Amount' %}" v-model="new_entry.amount" ref="new_entry_amount" />
|
||||||
v-model="new_entry.amount" ref="new_entry_amount">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-4">
|
<div class="col-12 col-lg-4">
|
||||||
<multiselect
|
<multiselect
|
||||||
@ -193,7 +169,8 @@
|
|||||||
track-by="name"
|
track-by="name"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:loading="units_loading"
|
:loading="units_loading"
|
||||||
@search-change="searchUnits">
|
@search-change="searchUnits"
|
||||||
|
>
|
||||||
</multiselect>
|
</multiselect>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-4">
|
<div class="col-12 col-lg-4">
|
||||||
@ -215,13 +192,13 @@
|
|||||||
track-by="name"
|
track-by="name"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:loading="foods_loading"
|
:loading="foods_loading"
|
||||||
@search-change="searchFoods">
|
@search-change="searchFoods"
|
||||||
|
>
|
||||||
</multiselect>
|
</multiselect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-lg-1 my-auto text-right">
|
<div class="col-12 col-lg-1 my-auto text-right">
|
||||||
<button class="btn btn-success btn-lg" @click="addEntry()"><i class="fa fa-plus"></i>
|
<button class="btn btn-success btn-lg" @click="addEntry()"><i class="fa fa-plus"></i></button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -243,9 +220,9 @@
|
|||||||
track-by="id"
|
track-by="id"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:loading="supermarkets_loading"
|
:loading="supermarkets_loading"
|
||||||
@search-change="searchSupermarket">
|
@search-change="searchSupermarket"
|
||||||
|
>
|
||||||
</multiselect>
|
</multiselect>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -267,34 +244,26 @@
|
|||||||
track-by="id"
|
track-by="id"
|
||||||
:multiple="true"
|
:multiple="true"
|
||||||
:loading="users_loading"
|
:loading="users_loading"
|
||||||
@search-change="searchUsers">
|
@search-change="searchUsers"
|
||||||
|
>
|
||||||
</multiselect>
|
</multiselect>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col" style="text-align: right; margin-top: 1vh">
|
<div class="col" style="text-align: right; margin-top: 1vh">
|
||||||
|
|
||||||
<div class="form-group form-check form-group-lg">
|
<div class="form-group form-check form-group-lg">
|
||||||
<input class="form-check-input" style="zoom:1.3;" type="checkbox"
|
<input class="form-check-input" style="zoom: 1.3" type="checkbox" v-model="shopping_list.finished" id="id_finished" />
|
||||||
v-model="shopping_list.finished" id="id_finished">
|
<label class="form-check-label" style="zoom: 1.3" for="id_finished"> {% trans 'Finished' %}</label>
|
||||||
<label class="form-check-label" style="zoom:1.3;"
|
</div>
|
||||||
for="id_finished"> {% trans 'Finished' %}</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
|
||||||
{% if request.user.userpreference.shopping_auto_sync > 0 %}
|
{% if request.user.userpreference.shopping_auto_sync > 0 %}
|
||||||
<div class="row" v-if="!onLine">
|
<div class="row" v-if="!onLine">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not syncronize.' %}</div>
|
||||||
{% trans 'You are offline, shopping list might not syncronize.' %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -309,13 +278,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-for="x in c.entries">
|
<tr v-for="x in c.entries">
|
||||||
<template v-if="!x.checked">
|
<template v-if="!x.checked">
|
||||||
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
|
<td><input type="checkbox" style="zoom: 1.4" v-model="x.checked" @change="entryChecked(x)" /></td>
|
||||||
@change="entryChecked(x)">
|
|
||||||
</td>
|
|
||||||
<td>[[x.amount.toFixed(2)]]</td>
|
<td>[[x.amount.toFixed(2)]]</td>
|
||||||
<td>[[x.unit.name]]</td>
|
<td>[[x.unit.name]]</td>
|
||||||
<td>[[x.food.name]] <span class="text-muted" v-if="x.recipes.length > 0">([[x.recipes.join(', ')]])</span>
|
<td>[[x.food.name]] <span class="text-muted" v-if="x.recipes.length > 0">([[x.recipes.join(', ')]])</span></td>
|
||||||
</td>
|
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@ -324,34 +290,24 @@
|
|||||||
<td colspan="4"></td>
|
<td colspan="4"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="c in display_categories">
|
<template v-for="c in display_categories">
|
||||||
|
|
||||||
<tr v-for="x in c.entries" class="text-muted">
|
<tr v-for="x in c.entries" class="text-muted">
|
||||||
<template v-if="x.checked">
|
<template v-if="x.checked">
|
||||||
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
|
<td><input type="checkbox" style="zoom: 1.4" v-model="x.checked" @change="entryChecked(x)" /></td>
|
||||||
@change="entryChecked(x)">
|
|
||||||
</td>
|
|
||||||
<td>[[x.amount]]</td>
|
<td>[[x.amount]]</td>
|
||||||
<td>[[x.unit.name]]</td>
|
<td>[[x.unit.name]]</td>
|
||||||
<td>[[x.food.name]]</td>
|
<td>[[x.food.name]]</td>
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" style="margin-top: 2vh">
|
<div class="row" style="margin-top: 2vh">
|
||||||
<div class="col" style="text-align: right">
|
<div class="col" style="text-align: right">
|
||||||
<b-button class="btn btn-info" v-b-modal.id_modal_export><i
|
<b-button class="btn btn-info" v-b-modal.id_modal_export><i class="fas fa-file-export"></i> {% trans 'Export' %}</b-button>
|
||||||
class="fas fa-file-export"></i> {% trans 'Export' %}</b-button>
|
<button class="btn btn-success" @click="updateShoppingList()" v-if="edit_mode"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||||
<button class="btn btn-success" @click="updateShoppingList()" v-if="edit_mode"><i
|
|
||||||
class="fas fa-save"></i> {% trans 'Save' %}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -363,28 +319,20 @@
|
|||||||
<div class="col col-12">
|
<div class="col col-12">
|
||||||
<label>
|
<label>
|
||||||
{% trans 'List Prefix' %}
|
{% trans 'List Prefix' %}
|
||||||
<input class="form-control" v-model="export_text_prefix">
|
<input class="form-control" v-model="export_text_prefix" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-12">
|
<div class="col col-12">
|
||||||
<b-form-textarea class="form-control" max-rows="8" v-model="export_text">
|
<b-form-textarea class="form-control" max-rows="8" v-model="export_text"> </b-form-textarea>
|
||||||
|
|
||||||
</b-form-textarea>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</b-modal>
|
</b-modal>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
{% endblock %} {% block script %}
|
||||||
{% endblock %}
|
|
||||||
{% block script %}
|
|
||||||
|
|
||||||
<script src="{% url 'javascript-catalog' %}"></script>
|
<script src="{% url 'javascript-catalog' %}"></script>
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
@ -407,7 +355,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
edit_mode: false,
|
edit_mode: false,
|
||||||
{% endif %}
|
{% endif %}
|
||||||
export_text_prefix: '', //TODO add userpreference
|
export_text_prefix: '',
|
||||||
recipe_query: '',
|
recipe_query: '',
|
||||||
recipes: [],
|
recipes: [],
|
||||||
shopping_list: undefined,
|
shopping_list: undefined,
|
||||||
@ -613,7 +561,6 @@
|
|||||||
this.onLine = type === 'online';
|
this.onLine = type === 'online';
|
||||||
},
|
},
|
||||||
makeToast: function (title, message, variant = null) {
|
makeToast: function (title, message, variant = null) {
|
||||||
//TODO remove duplicate function in favor of central one
|
|
||||||
this.$bvToast.toast(message, {
|
this.$bvToast.toast(message, {
|
||||||
title: title,
|
title: title,
|
||||||
variant: variant,
|
variant: variant,
|
||||||
@ -622,7 +569,7 @@
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
loadInitialRecipe: function (recipe, servings) {
|
loadInitialRecipe: function (recipe, servings) {
|
||||||
servings = 1 //TODO temporary until i can actually fix the servings for this #453
|
servings = 1
|
||||||
return this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe)).then((response) => {
|
return this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe)).then((response) => {
|
||||||
this.addRecipeToList(response.data, servings)
|
this.addRecipeToList(response.data, servings)
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@ -655,6 +602,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')
|
||||||
@ -734,7 +682,7 @@
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
sortEntries: function (a, b) {
|
sortEntries: function (a, b) {
|
||||||
//TODO implement me (might be difficult because of computed drag changed stuff)
|
|
||||||
},
|
},
|
||||||
dragChanged: function (category, evt) {
|
dragChanged: function (category, evt) {
|
||||||
if (evt.added !== undefined) {
|
if (evt.added !== undefined) {
|
||||||
@ -870,7 +818,7 @@
|
|||||||
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')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getRecipeUrl: function (id) { //TODO generic function that can be reused else were
|
getRecipeUrl: function (id) {
|
||||||
return '{% url 'view_recipe' 123456 %}'.replace('123456', id)
|
return '{% url 'view_recipe' 123456 %}'.replace('123456', id)
|
||||||
},
|
},
|
||||||
addRecipeToList: function (recipe, servings = 1) {
|
addRecipeToList: function (recipe, servings = 1) {
|
||||||
@ -886,7 +834,7 @@
|
|||||||
this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe.id)).then((response) => {
|
this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe.id)).then((response) => {
|
||||||
for (let s of response.data.steps) {
|
for (let s of response.data.steps) {
|
||||||
for (let i of s.ingredients) {
|
for (let i of s.ingredients) {
|
||||||
if (!i.is_header && i.food !== null && i.food.ignore_shopping === false) {
|
if (!i.is_header && i.food !== null && i.food.food_onhand === false) {
|
||||||
this.shopping_list.entries.push({
|
this.shopping_list.entries.push({
|
||||||
'list_recipe': slr.id,
|
'list_recipe': slr.id,
|
||||||
'food': i.food,
|
'food': i.food,
|
||||||
@ -916,7 +864,7 @@
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
searchUnits: function (query) { //TODO move to central component
|
searchUnits: function (query) {
|
||||||
this.units_loading = true
|
this.units_loading = true
|
||||||
this.$http.get("{% url 'api:unit-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
this.$http.get("{% url 'api:unit-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
||||||
this.units = response.data.results;
|
this.units = response.data.results;
|
||||||
@ -925,7 +873,7 @@
|
|||||||
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')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
searchFoods: function (query) { //TODO move to central component
|
searchFoods: function (query) {
|
||||||
this.foods_loading = true
|
this.foods_loading = true
|
||||||
this.$http.get("{% url 'api:food-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
this.$http.get("{% url 'api:food-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
||||||
this.foods = response.data.results
|
this.foods = response.data.results
|
||||||
@ -934,17 +882,17 @@
|
|||||||
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')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
addFoodType: function (tag, index) { //TODO move to central component
|
addFoodType: function (tag, index) {
|
||||||
let new_food = {'name': tag, supermarket_category: null}
|
let new_food = {'name': tag, supermarket_category: null}
|
||||||
this.foods.push(new_food)
|
this.foods.push(new_food)
|
||||||
this.new_entry.food = new_food
|
this.new_entry.food = new_food
|
||||||
},
|
},
|
||||||
addUnitType: function (tag, index) { //TODO move to central component
|
addUnitType: function (tag, index) {
|
||||||
let new_unit = {'name': tag}
|
let new_unit = {'name': tag}
|
||||||
this.units.push(new_unit)
|
this.units.push(new_unit)
|
||||||
this.new_entry.unit = new_unit
|
this.new_entry.unit = new_unit
|
||||||
},
|
},
|
||||||
searchUsers: function (query) { //TODO move to central component
|
searchUsers: function (query) {
|
||||||
this.users_loading = true
|
this.users_loading = true
|
||||||
this.$http.get("{% url 'api:username-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
this.$http.get("{% url 'api:username-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
||||||
this.users = response.data
|
this.users = response.data
|
||||||
@ -953,7 +901,7 @@
|
|||||||
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')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
searchSupermarket: function (query) { //TODO move to central component
|
searchSupermarket: function (query) {
|
||||||
this.supermarkets_loading = true
|
this.supermarkets_loading = true
|
||||||
this.$http.get("{% url 'api:supermarket-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
this.$http.get("{% url 'api:supermarket-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
||||||
this.supermarkets = response.data
|
this.supermarkets = response.data
|
||||||
|
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,5 +1,6 @@
|
|||||||
{% 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 %}
|
||||||
@ -8,7 +9,7 @@
|
|||||||
|
|
||||||
{% 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 %}
|
||||||
|
|
||||||
@ -20,65 +21,83 @@
|
|||||||
</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' %}
|
|
||||||
</div>
|
|
||||||
<ul class="list-group list-group-flush">
|
<ul class="list-group list-group-flush">
|
||||||
<li class="list-group-item">{% trans 'Recipes' %} : <span
|
<li class="list-group-item">
|
||||||
class="badge badge-pill badge-info">{{ counts.recipes }} /
|
{% trans 'Recipes' %} :
|
||||||
{% if request.space.max_recipes > 0 %}
|
<span class="badge badge-pill badge-info"
|
||||||
{{ request.space.max_recipes }}{% else %}∞{% endif %}</span></li>
|
>{{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{%
|
||||||
<li class="list-group-item">{% trans 'Keywords' %} : <span
|
else %}∞{% endif %}</span
|
||||||
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li>
|
>
|
||||||
<li class="list-group-item">{% trans 'Units' %} : <span
|
</li>
|
||||||
class="badge badge-pill badge-info">{{ counts.units }}</span></li>
|
<li class="list-group-item">
|
||||||
<li class="list-group-item">{% trans 'Ingredients' %} : <span
|
{% trans 'Keywords' %} : <span class="badge badge-pill badge-info">{{ counts.keywords }}</span>
|
||||||
class="badge badge-pill badge-info">{{ counts.ingredients }}</span></li>
|
</li>
|
||||||
<li class="list-group-item">{% trans 'Recipe Imports' %} : <span
|
<li class="list-group-item">
|
||||||
class="badge badge-pill badge-info">{{ counts.recipe_import }}</span></li>
|
{% trans 'Units' %} : <span class="badge badge-pill badge-info">{{ counts.units }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
{% trans 'Ingredients' %} :
|
||||||
|
<span class="badge badge-pill badge-info">{{ counts.ingredients }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
{% trans 'Recipe Imports' %} :
|
||||||
|
<span class="badge badge-pill badge-info">{{ counts.recipe_import }}</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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 'Objects stats' %}</div>
|
||||||
{% trans 'Objects stats' %}
|
|
||||||
</div>
|
|
||||||
<ul class="list-group list-group-flush">
|
<ul class="list-group list-group-flush">
|
||||||
<li class="list-group-item">{% trans 'Recipes without Keywords' %} : <span
|
<li class="list-group-item">
|
||||||
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span></li>
|
{% trans 'Recipes without Keywords' %} :
|
||||||
<li class="list-group-item">{% trans 'External Recipes' %} : <span
|
<span class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span>
|
||||||
class="badge badge-pill badge-info">{{ counts.recipes_external }}</span></li>
|
</li>
|
||||||
<li class="list-group-item">{% trans 'Internal Recipes' %} : <span
|
<li class="list-group-item">
|
||||||
class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span></li>
|
{% trans 'External Recipes' %} :
|
||||||
<li class="list-group-item">{% trans 'Comments' %} : <span
|
<span class="badge badge-pill badge-info">{{ counts.recipes_external }}</span>
|
||||||
class="badge badge-pill badge-info">{{ counts.comments }}</span></li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
|
<form action="." method="post">{% csrf_token %} {{ user_name_form|crispy }}</form>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
|
<h4>
|
||||||
<h4>{% trans 'Members' %} <small class="text-muted">{{ space_users|length }}/
|
{% trans 'Members' %}
|
||||||
{% if request.space.max_users > 0 %}
|
<small class="text-muted"
|
||||||
{{ request.space.max_users }}{% else %}∞{% endif %}</small>
|
>{{ space_users|length }}/ {% if request.space.max_users > 0 %} {{ request.space.max_users }}{% else
|
||||||
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"><i
|
%}∞{% endif %}</small
|
||||||
class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a>
|
>
|
||||||
|
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"
|
||||||
|
><i class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a
|
||||||
|
>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br />
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
@ -91,30 +110,24 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% for u in space_users %}
|
{% for u in space_users %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>{{ u.user.username }}</td>
|
||||||
{{ u.user.username }}
|
<td>{{ u.user.groups.all |join:", " }}</td>
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ u.user.groups.all |join:", " }}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if u.user != request.user %}
|
{% if u.user != request.user %}
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<select v-model="users['{{ u.pk }}']" class="custom-select form-control"
|
<select v-model="users['{{ u.pk }}']" class="custom-select form-control" style="height: 44px">
|
||||||
style="height: 44px">
|
|
||||||
<option value="admin">{% trans 'admin' %}</option>
|
<option value="admin">{% trans 'admin' %}</option>
|
||||||
<option value="user">{% trans 'user' %}</option>
|
<option value="user">{% trans 'user' %}</option>
|
||||||
<option value="guest">{% trans 'guest' %}</option>
|
<option value="guest">{% trans 'guest' %}</option>
|
||||||
<option value="remove">{% trans 'remove' %}</option>
|
<option value="remove">{% trans 'remove' %}</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="input-group-append">
|
<span class="input-group-append">
|
||||||
<a class="btn btn-warning"
|
<a class="btn btn-warning" :href="editUserUrl({{ u.pk }}, {{ u.space.pk }})"
|
||||||
:href="editUserUrl({{ u.pk }}, {{ u.space.pk }})">{% trans 'Update' %}</a>
|
>{% trans 'Update' %}</a
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %} {% trans 'You cannot edit yourself.' %} {% endif %}
|
||||||
{% trans 'You cannot edit yourself.' %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -131,14 +144,24 @@
|
|||||||
{% render_table invite_links %}
|
{% render_table invite_links %}
|
||||||
</div>
|
</div>
|
||||||
</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 />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %} {% block script %}
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
|
|
||||||
<script type="application/javascript">
|
<script type="application/javascript">
|
||||||
let app = new Vue({
|
let app = new Vue({
|
||||||
|
@ -715,7 +715,6 @@
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
makeToast: function (title, message, variant = null) {
|
makeToast: function (title, message, variant = null) {
|
||||||
//TODO remove duplicate function in favor of central one
|
|
||||||
this.$bvToast.toast(message, {
|
this.$bvToast.toast(message, {
|
||||||
title: title,
|
title: title,
|
||||||
variant: variant,
|
variant: variant,
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
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 scope, scopes_disabled
|
||||||
|
from pytest_factoryboy import LazyFixture, register
|
||||||
|
|
||||||
|
from cookbook.models import Food, FoodInheritField, Ingredient, ShoppingList, ShoppingListEntry
|
||||||
from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry
|
from cookbook.tests.factories import (FoodFactory, IngredientFactory, ShoppingListEntryFactory,
|
||||||
|
SupermarketCategoryFactory)
|
||||||
|
|
||||||
# ------------------ IMPORTANT -------------------
|
# ------------------ IMPORTANT -------------------
|
||||||
#
|
#
|
||||||
@ -27,78 +29,50 @@ else:
|
|||||||
node_location = 'last-child'
|
node_location = 'last-child'
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
register(FoodFactory, 'obj_1', space=LazyFixture('space_1'))
|
||||||
def obj_1(space_1):
|
register(FoodFactory, 'obj_2', space=LazyFixture('space_1'))
|
||||||
return Food.objects.get_or_create(name='test_1', space=space_1)[0]
|
register(FoodFactory, 'obj_3', space=LazyFixture('space_2'))
|
||||||
|
register(SupermarketCategoryFactory, 'cat_1', space=LazyFixture('space_1'))
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
# @pytest.fixture
|
||||||
def obj_1_1(obj_1, space_1):
|
# def true():
|
||||||
return obj_1.add_child(name='test_1_1', space=space_1)
|
# return True
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def obj_1_1_1(obj_1_1, space_1):
|
|
||||||
return obj_1_1.add_child(name='test_1_1_1', space=space_1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def obj_2(space_1):
|
def false():
|
||||||
return Food.objects.get_or_create(name='test_2', space=space_1)[0]
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def non_exist():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def obj_3(space_2):
|
def obj_tree_1(request, space_1):
|
||||||
return Food.objects.get_or_create(name='test_3', space=space_2)[0]
|
try:
|
||||||
|
params = request.param # request.param is a magic variable
|
||||||
|
except AttributeError:
|
||||||
|
params = {}
|
||||||
|
objs = []
|
||||||
|
inherit = params.pop('inherit', False)
|
||||||
|
objs.extend(FoodFactory.create_batch(3, space=space_1, **params))
|
||||||
|
|
||||||
|
# set all foods to inherit everything
|
||||||
|
if inherit:
|
||||||
|
inherit = Food.inheritable_fields
|
||||||
|
Through = Food.objects.filter(space=space_1).first().inherit_fields.through
|
||||||
|
for i in inherit:
|
||||||
|
Through.objects.bulk_create([
|
||||||
|
Through(food_id=x, foodinheritfield_id=i.id)
|
||||||
|
for x in Food.objects.filter(space=space_1).values_list('id', flat=True)
|
||||||
|
])
|
||||||
|
|
||||||
@pytest.fixture()
|
objs[0].move(objs[1], node_location)
|
||||||
def ing_1_s1(obj_1, space_1):
|
objs[1].move(objs[2], node_location)
|
||||||
return Ingredient.objects.create(food=obj_1, space=space_1)
|
return Food.objects.get(id=objs[1].id) # whenever you move/merge a tree it's safest to re-get the object
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def ing_2_s1(obj_2, space_1):
|
|
||||||
return Ingredient.objects.create(food=obj_2, space=space_1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def ing_3_s2(obj_3, space_2):
|
|
||||||
return Ingredient.objects.create(food=obj_3, space=space_2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def ing_1_1_s1(obj_1_1, space_1):
|
|
||||||
return Ingredient.objects.create(food=obj_1_1, space=space_1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def sle_1_s1(obj_1, u1_s1, space_1):
|
|
||||||
e = ShoppingListEntry.objects.create(food=obj_1)
|
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
|
||||||
s.entries.add(e)
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def sle_2_s1(obj_2, u1_s1, space_1):
|
|
||||||
return ShoppingListEntry.objects.create(food=obj_2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def sle_3_s2(obj_3, u1_s2, space_2):
|
|
||||||
e = ShoppingListEntry.objects.create(food=obj_3)
|
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s2), space=space_2, )
|
|
||||||
s.entries.add(e)
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def sle_1_1_s1(obj_1_1, u1_s1, space_1):
|
|
||||||
e = ShoppingListEntry.objects.create(food=obj_1_1)
|
|
||||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
|
||||||
s.entries.add(e)
|
|
||||||
return e
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("arg", [
|
@pytest.mark.parametrize("arg", [
|
||||||
@ -128,7 +102,10 @@ def test_list_filter(obj_1, obj_2, u1_s1):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
response = json.loads(r.content)
|
response = json.loads(r.content)
|
||||||
assert response['count'] == 2
|
assert response['count'] == 2
|
||||||
assert response['results'][0]['name'] == obj_1.name
|
|
||||||
|
assert obj_1.name in [x['name'] for x in response['results']]
|
||||||
|
assert obj_2.name in [x['name'] for x in response['results']]
|
||||||
|
assert response['results'][0]['name'] < response['results'][1]['name']
|
||||||
|
|
||||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?page_size=1').content)
|
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?page_size=1').content)
|
||||||
assert len(response['results']) == 1
|
assert len(response['results']) == 1
|
||||||
@ -142,7 +119,7 @@ def test_list_filter(obj_1, obj_2, u1_s1):
|
|||||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query=chicken').content)
|
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query=chicken').content)
|
||||||
assert response['count'] == 0
|
assert response['count'] == 0
|
||||||
|
|
||||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query={obj_1.name[4:]}').content)
|
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query={obj_1.name[:-4]}').content)
|
||||||
assert response['count'] == 1
|
assert response['count'] == 1
|
||||||
|
|
||||||
|
|
||||||
@ -194,7 +171,6 @@ def test_add(arg, request, u1_s2):
|
|||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db(transaction=True)
|
|
||||||
def test_add_duplicate(u1_s1, u1_s2, obj_1, obj_3):
|
def test_add_duplicate(u1_s1, u1_s2, obj_1, obj_3):
|
||||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 1
|
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 1
|
||||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 1
|
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 1
|
||||||
@ -220,9 +196,9 @@ def test_add_duplicate(u1_s1, u1_s2, obj_1, obj_3):
|
|||||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_delete(u1_s1, u1_s2, obj_1, obj_1_1, obj_1_1_1):
|
def test_delete(u1_s1, u1_s2, obj_1, obj_tree_1):
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert Food.objects.count() == 3
|
assert Food.objects.count() == 4
|
||||||
|
|
||||||
r = u1_s2.delete(
|
r = u1_s2.delete(
|
||||||
reverse(
|
reverse(
|
||||||
@ -232,18 +208,19 @@ def test_delete(u1_s1, u1_s2, obj_1, obj_1_1, obj_1_1_1):
|
|||||||
)
|
)
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert Food.objects.count() == 3
|
assert Food.objects.count() == 4
|
||||||
|
|
||||||
|
# should delete self and child, leaving parent
|
||||||
r = u1_s1.delete(
|
r = u1_s1.delete(
|
||||||
reverse(
|
reverse(
|
||||||
DETAIL_URL,
|
DETAIL_URL,
|
||||||
args={obj_1_1.id}
|
args={obj_tree_1.id}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert Food.objects.count() == 1
|
assert Food.objects.count() == 2
|
||||||
assert Food.find_problems() == ([], [], [], [], [])
|
assert Food.find_problems() == ([], [], [], [], [])
|
||||||
|
|
||||||
|
|
||||||
@ -283,13 +260,16 @@ def test_integrity(u1_s1, recipe_1_s1):
|
|||||||
assert Ingredient.objects.count() == 9
|
assert Ingredient.objects.count() == 9
|
||||||
|
|
||||||
|
|
||||||
def test_move(u1_s1, obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, space_1):
|
def test_move(u1_s1, obj_tree_1, obj_2, obj_3, space_1):
|
||||||
url = reverse(MOVE_URL, args=[obj_1_1.id, obj_2.id])
|
with scope(space=space_1):
|
||||||
with scopes_disabled():
|
parent = obj_tree_1.get_parent()
|
||||||
assert obj_1.get_num_children() == 1
|
child = obj_tree_1.get_descendants()[0]
|
||||||
assert obj_1.get_descendant_count() == 2
|
assert parent.get_num_children() == 1
|
||||||
|
assert parent.get_descendant_count() == 2
|
||||||
assert Food.get_root_nodes().filter(space=space_1).count() == 2
|
assert Food.get_root_nodes().filter(space=space_1).count() == 2
|
||||||
|
|
||||||
|
url = reverse(MOVE_URL, args=[obj_tree_1.id, obj_2.id])
|
||||||
|
|
||||||
# move child to new parent, only HTTP put method should work
|
# move child to new parent, only HTTP put method should work
|
||||||
r = u1_s1.get(url)
|
r = u1_s1.get(url)
|
||||||
assert r.status_code == 405
|
assert r.status_code == 405
|
||||||
@ -301,61 +281,107 @@ def test_move(u1_s1, obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, space_1):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
# django-treebeard bypasses django ORM so object needs retrieved again
|
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||||
obj_1 = Food.objects.get(pk=obj_1.id)
|
parent = Food.objects.get(pk=parent.id)
|
||||||
obj_2 = Food.objects.get(pk=obj_2.id)
|
obj_2 = Food.objects.get(pk=obj_2.id)
|
||||||
assert obj_1.get_num_children() == 0
|
assert parent.get_num_children() == 0
|
||||||
assert obj_1.get_descendant_count() == 0
|
assert parent.get_descendant_count() == 0
|
||||||
assert obj_2.get_num_children() == 1
|
assert obj_2.get_num_children() == 1
|
||||||
assert obj_2.get_descendant_count() == 2
|
assert obj_2.get_descendant_count() == 2
|
||||||
|
|
||||||
# move child to root
|
|
||||||
r = u1_s1.put(reverse(MOVE_URL, args=[obj_1_1.id, 0]))
|
|
||||||
assert r.status_code == 200
|
|
||||||
with scopes_disabled():
|
|
||||||
assert Food.get_root_nodes().filter(space=space_1).count() == 3
|
|
||||||
|
|
||||||
# attempt to move to non-existent parent
|
|
||||||
r = u1_s1.put(
|
|
||||||
reverse(MOVE_URL, args=[obj_1.id, 9999])
|
|
||||||
)
|
|
||||||
assert r.status_code == 404
|
|
||||||
|
|
||||||
# attempt to move to wrong space
|
|
||||||
r = u1_s1.put(
|
|
||||||
reverse(MOVE_URL, args=[obj_1_1.id, obj_3.id])
|
|
||||||
)
|
|
||||||
assert r.status_code == 404
|
|
||||||
|
|
||||||
# run diagnostic to find problems - none should be found
|
# run diagnostic to find problems - none should be found
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert Food.find_problems() == ([], [], [], [], [])
|
assert Food.find_problems() == ([], [], [], [], [])
|
||||||
|
|
||||||
|
|
||||||
def test_merge(
|
def test_move_errors(u1_s1, obj_tree_1, obj_3, space_1):
|
||||||
u1_s1,
|
with scope(space=space_1):
|
||||||
obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3,
|
parent = obj_tree_1.get_parent()
|
||||||
ing_1_s1, ing_2_s1, ing_3_s2, ing_1_1_s1,
|
child = obj_tree_1.get_descendants()[0]
|
||||||
sle_1_s1, sle_2_s1, sle_3_s2, sle_1_1_s1,
|
# move child to root
|
||||||
space_1
|
r = u1_s1.put(reverse(MOVE_URL, args=[obj_tree_1.id, 0]))
|
||||||
):
|
assert r.status_code == 200
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert obj_1.get_num_children() == 1
|
|
||||||
assert obj_1.get_descendant_count() == 2
|
|
||||||
assert Food.get_root_nodes().filter(space=space_1).count() == 2
|
assert Food.get_root_nodes().filter(space=space_1).count() == 2
|
||||||
assert Food.objects.filter(space=space_1).count() == 4
|
|
||||||
assert obj_1.ingredient_set.count() == 1
|
|
||||||
assert obj_2.ingredient_set.count() == 1
|
|
||||||
assert obj_3.ingredient_set.count() == 1
|
|
||||||
assert obj_1_1.ingredient_set.count() == 1
|
|
||||||
assert obj_1_1_1.ingredient_set.count() == 0
|
|
||||||
assert obj_1.shoppinglistentry_set.count() == 1
|
|
||||||
assert obj_2.shoppinglistentry_set.count() == 1
|
|
||||||
assert obj_3.shoppinglistentry_set.count() == 1
|
|
||||||
assert obj_1_1.shoppinglistentry_set.count() == 1
|
|
||||||
assert obj_1_1_1.shoppinglistentry_set.count() == 0
|
|
||||||
|
|
||||||
# merge food with no children and no ingredient/shopping list entry with another food, only HTTP put method should work
|
# attempt to move to non-existent parent
|
||||||
url = reverse(MERGE_URL, args=[obj_1_1_1.id, obj_2.id])
|
r = u1_s1.put(
|
||||||
|
reverse(MOVE_URL, args=[parent.id, 9999])
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
# attempt to move non-existent mode to parent
|
||||||
|
r = u1_s1.put(
|
||||||
|
reverse(MOVE_URL, args=[9999, parent.id])
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
# attempt to move to wrong space
|
||||||
|
r = u1_s1.put(
|
||||||
|
reverse(MOVE_URL, args=[obj_tree_1.id, obj_3.id])
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: figure out how to generalize this to be all related objects
|
||||||
|
def test_merge_ingredients(obj_tree_1, u1_s1, space_1):
|
||||||
|
with scope(space=space_1):
|
||||||
|
parent = obj_tree_1.get_parent()
|
||||||
|
child = obj_tree_1.get_descendants()[0]
|
||||||
|
IngredientFactory.create(food=parent, space=space_1)
|
||||||
|
IngredientFactory.create(food=child, space=space_1)
|
||||||
|
assert parent.get_num_children() == 1
|
||||||
|
assert parent.get_descendant_count() == 2
|
||||||
|
assert Ingredient.objects.count() == 2
|
||||||
|
assert parent.ingredient_set.count() == 1
|
||||||
|
assert obj_tree_1.ingredient_set.count() == 0
|
||||||
|
assert child.ingredient_set.count() == 1
|
||||||
|
|
||||||
|
# merge food (with connected ingredient) with children to another food
|
||||||
|
r = u1_s1.put(reverse(MERGE_URL, args=[child.id, obj_tree_1.id]))
|
||||||
|
assert r.status_code == 200
|
||||||
|
with scope(space=space_1):
|
||||||
|
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||||
|
with pytest.raises(Food.DoesNotExist):
|
||||||
|
Food.objects.get(pk=child.id)
|
||||||
|
obj_tree_1 = Food.objects.get(pk=obj_tree_1.id)
|
||||||
|
assert obj_tree_1.ingredient_set.count() == 1 # now has child's ingredient
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_shopping_entries(obj_tree_1, u1_s1, space_1):
|
||||||
|
with scope(space=space_1):
|
||||||
|
parent = obj_tree_1.get_parent()
|
||||||
|
child = obj_tree_1.get_descendants()[0]
|
||||||
|
ShoppingListEntryFactory.create(food=parent, space=space_1)
|
||||||
|
ShoppingListEntryFactory.create(food=child, space=space_1)
|
||||||
|
assert parent.get_num_children() == 1
|
||||||
|
assert parent.get_descendant_count() == 2
|
||||||
|
assert ShoppingListEntry.objects.count() == 2
|
||||||
|
assert parent.shopping_entries.count() == 1
|
||||||
|
assert obj_tree_1.shopping_entries.count() == 0
|
||||||
|
assert child.shopping_entries.count() == 1
|
||||||
|
|
||||||
|
# merge food (with connected shoppinglistentry) with children to another food
|
||||||
|
r = u1_s1.put(reverse(MERGE_URL, args=[child.id, obj_tree_1.id]))
|
||||||
|
assert r.status_code == 200
|
||||||
|
with scope(space=space_1):
|
||||||
|
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||||
|
with pytest.raises(Food.DoesNotExist):
|
||||||
|
Food.objects.get(pk=child.id)
|
||||||
|
obj_tree_1 = Food.objects.get(pk=obj_tree_1.id)
|
||||||
|
assert obj_tree_1.shopping_entries.count() == 1 # now has child's ingredient
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge(u1_s1, obj_tree_1, obj_1, obj_3, space_1):
|
||||||
|
with scope(space=space_1):
|
||||||
|
parent = obj_tree_1.get_parent()
|
||||||
|
child = obj_tree_1.get_descendants()[0]
|
||||||
|
assert parent.get_num_children() == 1
|
||||||
|
assert parent.get_descendant_count() == 2
|
||||||
|
assert Food.get_root_nodes().filter(space=space_1).count() == 2
|
||||||
|
assert Food.objects.count() == 4
|
||||||
|
|
||||||
|
# merge food with no children with another food, only HTTP put method should work
|
||||||
|
url = reverse(MERGE_URL, args=[child.id, obj_tree_1.id])
|
||||||
r = u1_s1.get(url)
|
r = u1_s1.get(url)
|
||||||
assert r.status_code == 405
|
assert r.status_code == 405
|
||||||
r = u1_s1.post(url)
|
r = u1_s1.post(url)
|
||||||
@ -364,88 +390,142 @@ def test_merge(
|
|||||||
assert r.status_code == 405
|
assert r.status_code == 405
|
||||||
r = u1_s1.put(url)
|
r = u1_s1.put(url)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
with scopes_disabled():
|
with scope(space=space_1):
|
||||||
# django-treebeard bypasses django ORM so object needs retrieved again
|
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||||
|
with pytest.raises(Food.DoesNotExist):
|
||||||
|
Food.objects.get(pk=child.id)
|
||||||
|
obj_tree_1 = Food.objects.get(pk=obj_tree_1.id)
|
||||||
|
assert parent.get_num_children() == 1
|
||||||
|
assert parent.get_descendant_count() == 1
|
||||||
|
|
||||||
|
# merge food with children with another food
|
||||||
|
r = u1_s1.put(reverse(MERGE_URL, args=[parent.id, obj_1.id]))
|
||||||
|
assert r.status_code == 200
|
||||||
|
with scope(space=space_1):
|
||||||
|
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||||
|
with pytest.raises(Food.DoesNotExist):
|
||||||
|
Food.objects.get(pk=parent.id)
|
||||||
obj_1 = Food.objects.get(pk=obj_1.id)
|
obj_1 = Food.objects.get(pk=obj_1.id)
|
||||||
obj_2 = Food.objects.get(pk=obj_2.id)
|
|
||||||
assert Food.objects.filter(pk=obj_1_1_1.id).count() == 0
|
|
||||||
assert obj_1.get_num_children() == 1
|
assert obj_1.get_num_children() == 1
|
||||||
assert obj_1.get_descendant_count() == 1
|
assert obj_1.get_descendant_count() == 1
|
||||||
assert obj_2.get_num_children() == 0
|
|
||||||
assert obj_2.get_descendant_count() == 0
|
|
||||||
assert obj_1.ingredient_set.count() == 1
|
|
||||||
assert obj_2.ingredient_set.count() == 1
|
|
||||||
assert obj_3.ingredient_set.count() == 1
|
|
||||||
assert obj_1_1.ingredient_set.count() == 1
|
|
||||||
assert obj_1.shoppinglistentry_set.count() == 1
|
|
||||||
assert obj_2.shoppinglistentry_set.count() == 1
|
|
||||||
assert obj_3.shoppinglistentry_set.count() == 1
|
|
||||||
assert obj_1_1.shoppinglistentry_set.count() == 1
|
|
||||||
|
|
||||||
# merge food (with connected ingredient/shoppinglistentry) with children to another food
|
|
||||||
r = u1_s1.put(reverse(MERGE_URL, args=[obj_1.id, obj_2.id]))
|
|
||||||
assert r.status_code == 200
|
|
||||||
with scopes_disabled():
|
|
||||||
# django-treebeard bypasses django ORM so object needs retrieved again
|
|
||||||
obj_2 = Food.objects.get(pk=obj_2.id)
|
|
||||||
assert Food.objects.filter(pk=obj_1.id).count() == 0
|
|
||||||
assert obj_2.get_num_children() == 1
|
|
||||||
assert obj_2.get_descendant_count() == 1
|
|
||||||
assert obj_2.ingredient_set.count() == 2
|
|
||||||
assert obj_3.ingredient_set.count() == 1
|
|
||||||
assert obj_1_1.ingredient_set.count() == 1
|
|
||||||
assert obj_2.shoppinglistentry_set.count() == 2
|
|
||||||
assert obj_3.shoppinglistentry_set.count() == 1
|
|
||||||
assert obj_1_1.shoppinglistentry_set.count() == 1
|
|
||||||
|
|
||||||
# attempt to merge with non-existent parent
|
|
||||||
r = u1_s1.put(
|
|
||||||
reverse(MERGE_URL, args=[obj_1_1.id, 9999])
|
|
||||||
)
|
|
||||||
assert r.status_code == 404
|
|
||||||
|
|
||||||
# attempt to move to wrong space
|
|
||||||
r = u1_s1.put(
|
|
||||||
reverse(MERGE_URL, args=[obj_2.id, obj_3.id])
|
|
||||||
)
|
|
||||||
assert r.status_code == 404
|
|
||||||
|
|
||||||
# attempt to merge with child
|
|
||||||
r = u1_s1.put(
|
|
||||||
reverse(MERGE_URL, args=[obj_2.id, obj_1_1.id])
|
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
# attempt to merge with self
|
|
||||||
r = u1_s1.put(
|
|
||||||
reverse(MERGE_URL, args=[obj_2.id, obj_2.id])
|
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
# run diagnostic to find problems - none should be found
|
# run diagnostic to find problems - none should be found
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert Food.find_problems() == ([], [], [], [], [])
|
assert Food.find_problems() == ([], [], [], [], [])
|
||||||
|
|
||||||
|
|
||||||
def test_root_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
|
def test_merge_errors(u1_s1, obj_tree_1, obj_3, space_1):
|
||||||
|
with scope(space=space_1):
|
||||||
|
parent = obj_tree_1.get_parent()
|
||||||
|
child = obj_tree_1.get_descendants()[0]
|
||||||
|
|
||||||
|
# attempt to merge with non-existent parent
|
||||||
|
r = u1_s1.put(
|
||||||
|
reverse(MERGE_URL, args=[obj_tree_1.id, 9999])
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
# attempt to merge non-existent node to parent
|
||||||
|
r = u1_s1.put(
|
||||||
|
reverse(MERGE_URL, args=[9999, obj_tree_1.id])
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
# attempt to move to wrong space
|
||||||
|
r = u1_s1.put(
|
||||||
|
reverse(MERGE_URL, args=[obj_tree_1.id, obj_3.id])
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
# attempt to merge with child
|
||||||
|
r = u1_s1.put(
|
||||||
|
reverse(MERGE_URL, args=[parent.id, obj_tree_1.id])
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
# attempt to merge with self
|
||||||
|
r = u1_s1.put(
|
||||||
|
reverse(MERGE_URL, args=[obj_tree_1.id, obj_tree_1.id])
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_filter(obj_tree_1, obj_2, obj_3, u1_s1):
|
||||||
|
with scope(space=obj_tree_1.space):
|
||||||
|
parent = obj_tree_1.get_parent()
|
||||||
|
child = obj_tree_1.get_descendants()[0]
|
||||||
|
|
||||||
# should return root objects in the space (obj_1, obj_2), ignoring query filters
|
# should return root objects in the space (obj_1, obj_2), ignoring query filters
|
||||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root=0').content)
|
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root=0').content)
|
||||||
assert len(response['results']) == 2
|
assert len(response['results']) == 2
|
||||||
|
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
obj_2.move(obj_1, node_location)
|
obj_2.move(parent, node_location)
|
||||||
# should return direct children of obj_1 (obj_1_1, obj_2), ignoring query filters
|
# should return direct children of parent (obj_tree_1, obj_2), ignoring query filters
|
||||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={obj_1.id}').content)
|
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={parent.id}').content)
|
||||||
assert response['count'] == 2
|
assert response['count'] == 2
|
||||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={obj_1.id}&query={obj_2.name[4:]}').content)
|
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={parent.id}&query={obj_2.name[4:]}').content)
|
||||||
assert response['count'] == 2
|
assert response['count'] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_tree_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
|
def test_tree_filter(obj_tree_1, obj_2, obj_3, u1_s1):
|
||||||
with scopes_disabled():
|
with scope(space=obj_tree_1.space):
|
||||||
obj_2.move(obj_1, node_location)
|
parent = obj_tree_1.get_parent()
|
||||||
# should return full tree starting at obj_1 (obj_1_1_1, obj_2), ignoring query filters
|
child = obj_tree_1.get_descendants()[0]
|
||||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}').content)
|
obj_2.move(parent, node_location)
|
||||||
|
# should return full tree starting at parent (obj_tree_1, obj_2), ignoring query filters
|
||||||
|
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={parent.id}').content)
|
||||||
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={parent.id}&query={obj_2.name[4:]}').content)
|
||||||
assert response['count'] == 4
|
assert response['count'] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# This is more about the model than the API - should this be moved to a different test?
|
||||||
|
@pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [
|
||||||
|
({'has_category': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'),
|
||||||
|
({'has_category': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'),
|
||||||
|
({'food_onhand': True, 'inherit': True}, 'food_onhand', True, 'false'),
|
||||||
|
({'food_onhand': True, 'inherit': False}, 'food_onhand', False, 'false'),
|
||||||
|
], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter
|
||||||
|
def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
|
||||||
|
with scope(space=obj_tree_1.space):
|
||||||
|
parent = obj_tree_1.get_parent()
|
||||||
|
child = obj_tree_1.get_descendants()[0]
|
||||||
|
|
||||||
|
new_val = request.getfixturevalue(new_val)
|
||||||
|
# if this test passes it demonstrates that inheritance works
|
||||||
|
# when moving to a parent as each food is created with a different category
|
||||||
|
assert (getattr(parent, field) == getattr(obj_tree_1, field)) in [inherit, True]
|
||||||
|
assert (getattr(obj_tree_1, field) == getattr(child, field)) in [inherit, True]
|
||||||
|
# change parent to a new value
|
||||||
|
setattr(parent, field, new_val)
|
||||||
|
with scope(space=parent.space):
|
||||||
|
parent.save() # trigger post-save signal
|
||||||
|
# get the objects again because values are cached
|
||||||
|
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
|
||||||
|
child = Food.objects.get(id=child.id)
|
||||||
|
# when changing parent value the obj value should be same if inherited
|
||||||
|
assert (getattr(obj_tree_1, field) == new_val) == inherit
|
||||||
|
assert (getattr(child, field) == new_val) == inherit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("obj_tree_1", [
|
||||||
|
({'has_category': True, 'inherit': False, 'food_onhand': True}),
|
||||||
|
], indirect=['obj_tree_1'])
|
||||||
|
def test_reset_inherit(obj_tree_1, space_1):
|
||||||
|
with scope(space=space_1):
|
||||||
|
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields
|
||||||
|
parent = obj_tree_1.get_parent()
|
||||||
|
child = obj_tree_1.get_descendants()[0]
|
||||||
|
obj_tree_1.food_onhand = False
|
||||||
|
assert parent.food_onhand == child.food_onhand
|
||||||
|
assert parent.food_onhand != obj_tree_1.food_onhand
|
||||||
|
assert parent.supermarket_category != child.supermarket_category
|
||||||
|
assert parent.supermarket_category != obj_tree_1.supermarket_category
|
||||||
|
|
||||||
|
parent.reset_inheritance(space=space_1)
|
||||||
|
# djangotree bypasses ORM and need to be retrieved again
|
||||||
|
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
|
||||||
|
parent = obj_tree_1.get_parent()
|
||||||
|
child = obj_tree_1.get_descendants()[0]
|
||||||
|
assert parent.food_onhand == obj_tree_1.food_onhand == child.food_onhand
|
||||||
|
assert parent.supermarket_category == obj_tree_1.supermarket_category == child.supermarket_category
|
||||||
|
96
cookbook/tests/api/test_api_food_shopping.py
Normal file
96
cookbook/tests/api/test_api_food_shopping.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# test create
|
||||||
|
# test create units
|
||||||
|
# test amounts
|
||||||
|
# test create wrong space
|
||||||
|
# test sharing
|
||||||
|
# test delete
|
||||||
|
# test delete checked (nothing should happen)
|
||||||
|
# test delete not shared (nothing happens)
|
||||||
|
# test delete shared
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib import auth
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_scopes import scope, scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.models import Food, ShoppingListEntry
|
||||||
|
from cookbook.tests.factories import FoodFactory
|
||||||
|
|
||||||
|
SHOPPING_LIST_URL = 'api:shoppinglistentry-list'
|
||||||
|
SHOPPING_FOOD_URL = 'api:food-shopping'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def food(request, space_1, u1_s1):
|
||||||
|
return FoodFactory(space=space_1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shopping_forbidden_methods(food, u1_s1):
|
||||||
|
r = u1_s1.post(
|
||||||
|
reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||||
|
assert r.status_code == 405
|
||||||
|
|
||||||
|
r = u1_s1.delete(
|
||||||
|
reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||||
|
assert r.status_code == 405
|
||||||
|
|
||||||
|
r = u1_s1.get(
|
||||||
|
reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||||
|
assert r.status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 204],
|
||||||
|
['u1_s2', 404],
|
||||||
|
['a1_s1', 204],
|
||||||
|
])
|
||||||
|
def test_shopping_food_create(request, arg, food):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
r = c.put(reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 204:
|
||||||
|
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 204],
|
||||||
|
['u1_s2', 404],
|
||||||
|
['a1_s1', 204],
|
||||||
|
])
|
||||||
|
def test_shopping_food_delete(request, arg, food):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
r = c.put(
|
||||||
|
reverse(SHOPPING_FOOD_URL, args={food.id}),
|
||||||
|
{'_delete': "true"},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 204:
|
||||||
|
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_shopping_food_share(u1_s1, u2_s1, food, space_1):
|
||||||
|
with scope(space=space_1):
|
||||||
|
user1 = auth.get_user(u1_s1)
|
||||||
|
user2 = auth.get_user(u2_s1)
|
||||||
|
food2 = FoodFactory(space=space_1)
|
||||||
|
r = u1_s1.put(reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||||
|
r = u2_s1.put(reverse(SHOPPING_FOOD_URL, args={food2.id}))
|
||||||
|
sl_1 = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||||
|
sl_2 = json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||||
|
assert len(sl_1) == 1
|
||||||
|
assert len(sl_2) == 1
|
||||||
|
sl_1[0]['created_by']['id'] == user1.id
|
||||||
|
sl_2[0]['created_by']['id'] == user2.id
|
||||||
|
|
||||||
|
with scopes_disabled():
|
||||||
|
user1.userpreference.shopping_share.add(user2)
|
||||||
|
user1.userpreference.save()
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 1
|
||||||
|
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 2
|
@ -4,13 +4,16 @@ from datetime import datetime, timedelta
|
|||||||
import pytest
|
import pytest
|
||||||
from django.contrib import auth
|
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 scope, scopes_disabled
|
||||||
|
|
||||||
from cookbook.models import Food, MealPlan, MealType
|
from cookbook.models import Food, MealPlan, MealType
|
||||||
|
from cookbook.tests.factories import RecipeFactory
|
||||||
|
|
||||||
LIST_URL = 'api:mealplan-list'
|
LIST_URL = 'api:mealplan-list'
|
||||||
DETAIL_URL = 'api:mealplan-detail'
|
DETAIL_URL = 'api:mealplan-detail'
|
||||||
|
|
||||||
|
# NOTE: auto adding shopping list from meal plan is tested in test_shopping_recipe as tests are identical
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def meal_type(space_1, u1_s1):
|
def meal_type(space_1, u1_s1):
|
||||||
@ -139,3 +142,17 @@ def test_delete(u1_s1, u1_s2, obj_1):
|
|||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert MealPlan.objects.count() == 0
|
assert MealPlan.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_with_shopping(u1_s1, meal_type):
|
||||||
|
space = meal_type.space
|
||||||
|
with scope(space=space):
|
||||||
|
recipe = RecipeFactory.create(space=space)
|
||||||
|
r = u1_s1.post(
|
||||||
|
reverse(LIST_URL),
|
||||||
|
{'recipe': {'id': recipe.id, 'name': recipe.name, 'keywords': []}, 'meal_type': {'id': meal_type.id, 'name': meal_type.name},
|
||||||
|
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test', 'shared': [], 'addshopping': True},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(json.loads(u1_s1.get(reverse('api:shoppinglistentry-list')).content)) == 10
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
|
74
cookbook/tests/api/test_api_related_recipe.py
Normal file
74
cookbook/tests/api/test_api_related_recipe.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import factory
|
||||||
|
import pytest
|
||||||
|
from django.contrib import auth
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
from pytest_factoryboy import LazyFixture, register
|
||||||
|
|
||||||
|
from cookbook.tests.factories import RecipeFactory
|
||||||
|
|
||||||
|
RELATED_URL = 'api:recipe-related'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def recipe(request, space_1, u1_s1):
|
||||||
|
try:
|
||||||
|
params = request.param # request.param is a magic variable
|
||||||
|
except AttributeError:
|
||||||
|
params = {}
|
||||||
|
step_recipe = params.get('steps__count', 1)
|
||||||
|
steps__recipe_count = params.get('steps__recipe_count', 0)
|
||||||
|
steps__food_recipe_count = params.get('steps__food_recipe_count', {})
|
||||||
|
created_by = params.get('created_by', auth.get_user(u1_s1))
|
||||||
|
|
||||||
|
return RecipeFactory.create(
|
||||||
|
steps__recipe_count=steps__recipe_count,
|
||||||
|
steps__food_recipe_count=steps__food_recipe_count,
|
||||||
|
created_by=created_by,
|
||||||
|
space=space_1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['g1_s1', 200],
|
||||||
|
['u1_s1', 200],
|
||||||
|
['u1_s2', 404],
|
||||||
|
['a1_s1', 200],
|
||||||
|
])
|
||||||
|
@pytest.mark.parametrize("recipe, related_count", [
|
||||||
|
({}, 0),
|
||||||
|
({'steps__recipe_count': 1}, 1), # shopping list from recipe with StepRecipe
|
||||||
|
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 1), # shopping list from recipe with food recipe
|
||||||
|
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 2), # shopping list from recipe with StepRecipe and food recipe
|
||||||
|
], indirect=['recipe'])
|
||||||
|
def test_get_related_recipes(request, arg, recipe, related_count, u1_s1, space_2):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
r = c.get(reverse(RELATED_URL, args={recipe.id}))
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 200:
|
||||||
|
assert len(json.loads(r.content)) == related_count
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("recipe", [
|
||||||
|
({'steps__recipe_count': 1}), # shopping list from recipe with StepRecipe
|
||||||
|
({'steps__food_recipe_count': {'step': 0, 'count': 1}}), # shopping list from recipe with food recipe
|
||||||
|
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}), # shopping list from recipe with StepRecipe and food recipe
|
||||||
|
], indirect=['recipe'])
|
||||||
|
def test_related_mixed_space(request, recipe, u1_s2):
|
||||||
|
with scopes_disabled():
|
||||||
|
recipe.space = auth.get_user(u1_s2).userpreference.space
|
||||||
|
recipe.save()
|
||||||
|
assert len(json.loads(
|
||||||
|
u1_s2.get(
|
||||||
|
reverse(RELATED_URL, args={recipe.id})).content)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# TODO add tests for mealplan related when thats added
|
||||||
|
# TODO if/when related recipes includes multiple levels (related recipes of related recipes) add the following tests
|
||||||
|
# -- step recipes included in step recipes
|
||||||
|
# -- step recipes included in food recipes
|
||||||
|
# -- food recipes included in step recipes
|
||||||
|
# -- food recipes included in food recipes
|
||||||
|
# -- -- included recipes in the wrong space
|
@ -5,7 +5,7 @@ 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 RecipeBook, Storage, Sync, SyncLog, ShoppingList
|
from cookbook.models import RecipeBook, ShoppingList, Storage, Sync, SyncLog
|
||||||
|
|
||||||
LIST_URL = 'api:shoppinglist-list'
|
LIST_URL = 'api:shoppinglist-list'
|
||||||
DETAIL_URL = 'api:shoppinglist-detail'
|
DETAIL_URL = 'api:shoppinglist-detail'
|
||||||
@ -56,6 +56,21 @@ def test_share(obj_1, u1_s1, u2_s1, u1_s2):
|
|||||||
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
|
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_share(request, obj_1, u1_s1, u2_s1, u1_s2):
|
||||||
|
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
|
||||||
|
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
|
||||||
|
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
|
||||||
|
|
||||||
|
with scopes_disabled():
|
||||||
|
user = auth.get_user(u1_s1)
|
||||||
|
user.userpreference.shopping_share.add(auth.get_user(u2_s1))
|
||||||
|
user.userpreference.save()
|
||||||
|
|
||||||
|
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
|
||||||
|
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
|
||||||
|
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("arg", [
|
@pytest.mark.parametrize("arg", [
|
||||||
['a_u', 403],
|
['a_u', 403],
|
||||||
['g1_s1', 404],
|
['g1_s1', 404],
|
||||||
|
@ -6,7 +6,7 @@ from django.forms import model_to_dict
|
|||||||
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 ShoppingList, ShoppingListEntry, Food
|
from cookbook.models import Food, ShoppingList, ShoppingListEntry
|
||||||
|
|
||||||
LIST_URL = 'api:shoppinglistentry-list'
|
LIST_URL = 'api:shoppinglistentry-list'
|
||||||
DETAIL_URL = 'api:shoppinglistentry-detail'
|
DETAIL_URL = 'api:shoppinglistentry-detail'
|
||||||
@ -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
|
||||||
|
219
cookbook/tests/api/test_api_shopping_list_entryv2.py
Normal file
219
cookbook/tests/api/test_api_shopping_list_entryv2.py
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import factory
|
||||||
|
import pytest
|
||||||
|
from django.contrib import auth
|
||||||
|
from django.forms import model_to_dict
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
from pytest_factoryboy import LazyFixture, register
|
||||||
|
|
||||||
|
from cookbook.models import ShoppingListEntry
|
||||||
|
from cookbook.tests.factories import ShoppingListEntryFactory
|
||||||
|
|
||||||
|
LIST_URL = 'api:shoppinglistentry-list'
|
||||||
|
DETAIL_URL = 'api:shoppinglistentry-detail'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sle(space_1, u1_s1):
|
||||||
|
user = auth.get_user(u1_s1)
|
||||||
|
return ShoppingListEntryFactory.create_batch(10, space=space_1, created_by=user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sle_2(request):
|
||||||
|
try:
|
||||||
|
params = request.param # request.param is a magic variable
|
||||||
|
except AttributeError:
|
||||||
|
params = {}
|
||||||
|
u = request.getfixturevalue(params.get('user', 'u1_s1'))
|
||||||
|
user = auth.get_user(u)
|
||||||
|
count = params.get('count', 10)
|
||||||
|
return ShoppingListEntryFactory.create_batch(count, space=user.userpreference.space, created_by=user)
|
||||||
|
|
||||||
|
|
||||||
|
@ pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 200],
|
||||||
|
['u1_s1', 200],
|
||||||
|
['a1_s1', 200],
|
||||||
|
])
|
||||||
|
def test_list_permission(arg, request):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_space(sle, u1_s1, u1_s2, space_2):
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
|
||||||
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||||
|
|
||||||
|
with scopes_disabled():
|
||||||
|
e = ShoppingListEntry.objects.first()
|
||||||
|
e.space = space_2
|
||||||
|
e.save()
|
||||||
|
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 9
|
||||||
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_detail(u1_s1, sle):
|
||||||
|
r = u1_s1.get(reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={sle[0].id}
|
||||||
|
))
|
||||||
|
assert json.loads(r.content)['id'] == sle[0].id
|
||||||
|
|
||||||
|
|
||||||
|
@ pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 404],
|
||||||
|
['u1_s1', 200],
|
||||||
|
['a1_s1', 404],
|
||||||
|
['g1_s2', 404],
|
||||||
|
['u1_s2', 404],
|
||||||
|
['a1_s2', 404],
|
||||||
|
])
|
||||||
|
def test_update(arg, request, sle):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
new_val = float(sle[0].amount + 1)
|
||||||
|
r = c.patch(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={sle[0].id}
|
||||||
|
),
|
||||||
|
{'amount': new_val},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 200:
|
||||||
|
response = json.loads(r.content)
|
||||||
|
assert response['amount'] == new_val
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 201],
|
||||||
|
['u1_s1', 201],
|
||||||
|
['a1_s1', 201],
|
||||||
|
])
|
||||||
|
def test_add(arg, request, sle):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
r = c.post(
|
||||||
|
reverse(LIST_URL),
|
||||||
|
{'food': model_to_dict(sle[0].food), 'amount': 1},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
response = json.loads(r.content)
|
||||||
|
print(r.content)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 201:
|
||||||
|
assert response['food']['id'] == sle[0].food.pk
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete(u1_s1, u1_s2, sle):
|
||||||
|
r = u1_s2.delete(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={sle[0].id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
r = u1_s1.delete(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={sle[0].id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("shared, count, sle_2", [
|
||||||
|
('g1_s1', 20, {'user': 'g1_s1'}),
|
||||||
|
('g1_s2', 10, {'user': 'g1_s2'}),
|
||||||
|
('u2_s1', 20, {'user': 'u2_s1'}),
|
||||||
|
('u1_s2', 10, {'user': 'u1_s2'}),
|
||||||
|
('a1_s1', 20, {'user': 'a1_s1'}),
|
||||||
|
('a1_s2', 10, {'user': 'a1_s2'}),
|
||||||
|
], indirect=['sle_2'])
|
||||||
|
def test_sharing(request, shared, count, sle_2, sle, u1_s1):
|
||||||
|
user = auth.get_user(u1_s1)
|
||||||
|
shared_client = request.getfixturevalue(shared)
|
||||||
|
shared_user = auth.get_user(shared_client)
|
||||||
|
|
||||||
|
# confirm shared user can't access shopping list items created by u1_s1
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
|
||||||
|
assert len(json.loads(shared_client.get(reverse(LIST_URL)).content)) == 10
|
||||||
|
|
||||||
|
user.userpreference.shopping_share.add(shared_user)
|
||||||
|
# confirm sharing user only sees their shopping list
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
|
||||||
|
r = shared_client.get(reverse(LIST_URL))
|
||||||
|
# confirm shared user sees their list and the list that's shared with them
|
||||||
|
assert len(json.loads(r.content)) == count
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed(sle, u1_s1):
|
||||||
|
# check 1 entry
|
||||||
|
#
|
||||||
|
u1_s1.patch(
|
||||||
|
reverse(DETAIL_URL, args={sle[0].id}),
|
||||||
|
{'checked': True},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
r = json.loads(u1_s1.get(reverse(LIST_URL)).content)
|
||||||
|
assert len(r) == 10
|
||||||
|
# count unchecked entries
|
||||||
|
assert [x['checked'] for x in r].count(False) == 9
|
||||||
|
# confirm completed_at is populated
|
||||||
|
assert [(x['completed_at'] != None) for x in r if x['checked']].count(True) == 1
|
||||||
|
|
||||||
|
assert len(json.loads(u1_s1.get(f'{reverse(LIST_URL)}?checked=0').content)) == 9
|
||||||
|
assert len(json.loads(u1_s1.get(f'{reverse(LIST_URL)}?checked=1').content)) == 1
|
||||||
|
|
||||||
|
# uncheck entry
|
||||||
|
u1_s1.patch(
|
||||||
|
reverse(DETAIL_URL, args={sle[0].id}),
|
||||||
|
{'checked': False},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
r = json.loads(u1_s1.get(reverse(LIST_URL)).content)
|
||||||
|
assert [x['checked'] for x in r].count(False) == 10
|
||||||
|
# confirm completed_at value cleared
|
||||||
|
assert [(x['completed_at'] != None) for x in r if x['checked']].count(True) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent(sle, u1_s1):
|
||||||
|
user = auth.get_user(u1_s1)
|
||||||
|
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||||
|
|
||||||
|
# past_date within recent_days threshold
|
||||||
|
past_date = today_start - timedelta(days=user.userpreference.shopping_recent_days - 1)
|
||||||
|
sle[0].checked = True
|
||||||
|
sle[0].completed_at = past_date
|
||||||
|
sle[0].save()
|
||||||
|
|
||||||
|
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
|
||||||
|
assert len(r) == 10
|
||||||
|
assert [x['checked'] for x in r].count(False) == 9
|
||||||
|
|
||||||
|
# past_date outside recent_days threshold
|
||||||
|
past_date = today_start - timedelta(days=user.userpreference.shopping_recent_days + 2)
|
||||||
|
sle[0].completed_at = past_date
|
||||||
|
sle[0].save()
|
||||||
|
|
||||||
|
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
|
||||||
|
assert len(r) == 9
|
||||||
|
assert [x['checked'] for x in r].count(False) == 9
|
||||||
|
|
||||||
|
# user preference moved to include entry again
|
||||||
|
user.userpreference.shopping_recent_days = user.userpreference.shopping_recent_days + 4
|
||||||
|
user.userpreference.save()
|
||||||
|
|
||||||
|
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
|
||||||
|
assert len(r) == 10
|
||||||
|
assert [x['checked'] for x in r].count(False) == 9
|
243
cookbook/tests/api/test_api_shopping_recipe.py
Normal file
243
cookbook/tests/api/test_api_shopping_recipe.py
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import factory
|
||||||
|
import pytest
|
||||||
|
# work around for bug described here https://stackoverflow.com/a/70312265/15762829
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import auth
|
||||||
|
from django.forms import model_to_dict
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django_scopes import scope, scopes_disabled
|
||||||
|
from pytest_factoryboy import LazyFixture, register
|
||||||
|
|
||||||
|
from cookbook.models import Food, Ingredient, ShoppingListEntry, Step
|
||||||
|
from cookbook.tests.factories import (IngredientFactory, MealPlanFactory, RecipeFactory,
|
||||||
|
StepFactory, UserFactory)
|
||||||
|
|
||||||
|
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||||
|
'django.db.backends.postgresql']:
|
||||||
|
from django.db.backends.postgresql.features import DatabaseFeatures
|
||||||
|
DatabaseFeatures.can_defer_constraint_checks = False
|
||||||
|
|
||||||
|
SHOPPING_LIST_URL = 'api:shoppinglistentry-list'
|
||||||
|
SHOPPING_RECIPE_URL = 'api:recipe-shopping'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def user2(request, u1_s1):
|
||||||
|
try:
|
||||||
|
params = request.param # request.param is a magic variable
|
||||||
|
except AttributeError:
|
||||||
|
params = {}
|
||||||
|
user = auth.get_user(u1_s1)
|
||||||
|
user.userpreference.mealplan_autoadd_shopping = params.get('mealplan_autoadd_shopping', True)
|
||||||
|
user.userpreference.mealplan_autoinclude_related = params.get('mealplan_autoinclude_related', True)
|
||||||
|
user.userpreference.mealplan_autoexclude_onhand = params.get('mealplan_autoexclude_onhand', True)
|
||||||
|
user.userpreference.save()
|
||||||
|
return u1_s1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def recipe(request, space_1, u1_s1):
|
||||||
|
try:
|
||||||
|
params = request.param # request.param is a magic variable
|
||||||
|
except AttributeError:
|
||||||
|
params = {}
|
||||||
|
# step_recipe = params.get('steps__count', 1)
|
||||||
|
# steps__recipe_count = params.get('steps__recipe_count', 0)
|
||||||
|
# steps__food_recipe_count = params.get('steps__food_recipe_count', {})
|
||||||
|
params['created_by'] = params.get('created_by', auth.get_user(u1_s1))
|
||||||
|
params['space'] = space_1
|
||||||
|
return RecipeFactory(**params)
|
||||||
|
|
||||||
|
# return RecipeFactory.create(
|
||||||
|
# steps__recipe_count=steps__recipe_count,
|
||||||
|
# steps__food_recipe_count=steps__food_recipe_count,
|
||||||
|
# created_by=created_by,
|
||||||
|
# space=space_1,
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['g1_s1', 204],
|
||||||
|
['u1_s1', 204],
|
||||||
|
['u1_s2', 404],
|
||||||
|
['a1_s1', 204],
|
||||||
|
])
|
||||||
|
@pytest.mark.parametrize("recipe, sle_count", [
|
||||||
|
({}, 10),
|
||||||
|
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe
|
||||||
|
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe
|
||||||
|
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe
|
||||||
|
], indirect=['recipe'])
|
||||||
|
def test_shopping_recipe_method(request, arg, recipe, sle_count, u1_s1, u2_s1):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
user = auth.get_user(c)
|
||||||
|
user.userpreference.mealplan_autoadd_shopping = True
|
||||||
|
user.userpreference.save()
|
||||||
|
|
||||||
|
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||||
|
|
||||||
|
url = reverse(SHOPPING_RECIPE_URL, args={recipe.id})
|
||||||
|
r = c.put(url)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
# only PUT method should work
|
||||||
|
if r.status_code == 204: # skip anonymous user
|
||||||
|
|
||||||
|
r = json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)
|
||||||
|
assert len(r) == sle_count # recipe factory creates 10 ingredients by default
|
||||||
|
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
|
||||||
|
# user in space can't see shopping list
|
||||||
|
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||||
|
user.userpreference.shopping_share.add(auth.get_user(u2_s1))
|
||||||
|
# after share, user in space can see shopping list
|
||||||
|
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||||
|
# confirm that the author of the recipe doesn't have access to shopping list
|
||||||
|
if c != u1_s1:
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||||
|
|
||||||
|
r = c.get(url)
|
||||||
|
assert r.status_code == 405
|
||||||
|
r = c.post(url)
|
||||||
|
assert r.status_code == 405
|
||||||
|
r = c.delete(url)
|
||||||
|
assert r.status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("recipe, sle_count", [
|
||||||
|
({}, 10),
|
||||||
|
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe
|
||||||
|
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe
|
||||||
|
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe
|
||||||
|
], indirect=['recipe'])
|
||||||
|
@pytest.mark.parametrize("use_mealplan", [(False), (True), ])
|
||||||
|
def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u2_s1):
|
||||||
|
# tests editing shopping list via recipe or mealplan
|
||||||
|
with scopes_disabled():
|
||||||
|
user = auth.get_user(u1_s1)
|
||||||
|
user2 = auth.get_user(u2_s1)
|
||||||
|
user.userpreference.mealplan_autoinclude_related = True
|
||||||
|
user.userpreference.mealplan_autoadd_shopping = True
|
||||||
|
user.userpreference.shopping_share.add(user2)
|
||||||
|
user.userpreference.save()
|
||||||
|
|
||||||
|
if use_mealplan:
|
||||||
|
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
|
||||||
|
else:
|
||||||
|
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
|
||||||
|
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||||
|
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
|
||||||
|
all_ing = [x['ingredient'] for x in r]
|
||||||
|
keep_ing = all_ing[1:-1] # remove first and last element
|
||||||
|
del keep_ing[int(len(keep_ing)/2)] # remove a middle element
|
||||||
|
list_recipe = r[0]['list_recipe']
|
||||||
|
amount_sum = sum([x['amount'] for x in r])
|
||||||
|
|
||||||
|
# test modifying shopping list as different user
|
||||||
|
# test increasing servings size of recipe shopping list
|
||||||
|
if use_mealplan:
|
||||||
|
mealplan.servings = 2*recipe.servings
|
||||||
|
mealplan.save()
|
||||||
|
else:
|
||||||
|
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||||
|
{'list_recipe': list_recipe, 'servings': 2*recipe.servings},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||||
|
assert sum([x['amount'] for x in r]) == amount_sum * 2
|
||||||
|
assert len(r) == sle_count
|
||||||
|
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||||
|
|
||||||
|
# testing decreasing servings size of recipe shopping list
|
||||||
|
if use_mealplan:
|
||||||
|
mealplan.servings = .5 * recipe.servings
|
||||||
|
mealplan.save()
|
||||||
|
else:
|
||||||
|
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||||
|
{'list_recipe': list_recipe, 'servings': .5 * recipe.servings},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||||
|
assert sum([x['amount'] for x in r]) == amount_sum * .5
|
||||||
|
assert len(r) == sle_count
|
||||||
|
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||||
|
|
||||||
|
# test removing 2 items from shopping list
|
||||||
|
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||||
|
{'list_recipe': list_recipe, 'ingredients': keep_ing},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||||
|
assert len(r) == sle_count - 3
|
||||||
|
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count - 3
|
||||||
|
|
||||||
|
# add all ingredients to existing shopping list - don't change serving size
|
||||||
|
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||||
|
{'list_recipe': list_recipe, 'ingredients': all_ing},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||||
|
assert sum([x['amount'] for x in r]) == amount_sum * .5
|
||||||
|
assert len(r) == sle_count
|
||||||
|
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("user2, sle_count", [
|
||||||
|
({'mealplan_autoadd_shopping': False}, (0, 18)),
|
||||||
|
({'mealplan_autoinclude_related': False}, (9, 9)),
|
||||||
|
({'mealplan_autoexclude_onhand': False}, (20, 20)),
|
||||||
|
({'mealplan_autoexclude_onhand': False, 'mealplan_autoinclude_related': False}, (10, 10)),
|
||||||
|
], indirect=['user2'])
|
||||||
|
@pytest.mark.parametrize("use_mealplan", [(False), (True), ])
|
||||||
|
@pytest.mark.parametrize("recipe", [({'steps__recipe_count': 1})], indirect=['recipe'])
|
||||||
|
def test_shopping_recipe_userpreference(recipe, sle_count, use_mealplan, user2):
|
||||||
|
with scopes_disabled():
|
||||||
|
user = auth.get_user(user2)
|
||||||
|
# setup recipe with 10 ingredients, 1 step recipe with 10 ingredients, 2 food onhand(from recipe and step_recipe)
|
||||||
|
ingredients = Ingredient.objects.filter(step__recipe=recipe)
|
||||||
|
food = Food.objects.get(id=ingredients[2].food.id)
|
||||||
|
food.food_onhand = True
|
||||||
|
food.save()
|
||||||
|
food = recipe.steps.filter(type=Step.RECIPE).first().step_recipe.steps.first().ingredients.first().food
|
||||||
|
food = Food.objects.get(id=food.id)
|
||||||
|
food.food_onhand = True
|
||||||
|
food.save()
|
||||||
|
|
||||||
|
if use_mealplan:
|
||||||
|
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
|
||||||
|
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[0]
|
||||||
|
else:
|
||||||
|
user2.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
|
||||||
|
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shopping_recipe_mixed_authors(u1_s1, u2_s1):
|
||||||
|
with scopes_disabled():
|
||||||
|
user1 = auth.get_user(u1_s1)
|
||||||
|
user2 = auth.get_user(u2_s1)
|
||||||
|
space = user1.userpreference.space
|
||||||
|
user3 = UserFactory(space=space)
|
||||||
|
recipe1 = RecipeFactory(created_by=user1, space=space)
|
||||||
|
recipe2 = RecipeFactory(created_by=user2, space=space)
|
||||||
|
recipe3 = RecipeFactory(created_by=user3, space=space)
|
||||||
|
food = Food.objects.get(id=recipe1.steps.first().ingredients.first().food.id)
|
||||||
|
food.recipe = recipe2
|
||||||
|
food.save()
|
||||||
|
recipe1.steps.add(StepFactory(step_recipe=recipe3, ingredients__count=0, space=space))
|
||||||
|
recipe1.save()
|
||||||
|
|
||||||
|
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe1.id}))
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 29
|
||||||
|
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# TODO test adding recipe with ingredients that are not food
|
||||||
|
@pytest.mark.parametrize("recipe", [{'steps__ingredients__header': 1}], indirect=['recipe'])
|
||||||
|
def test_shopping_with_header_ingredient(u1_s1, recipe):
|
||||||
|
# with scope(space=recipe.space):
|
||||||
|
# recipe.step_set.first().ingredient_set.add(IngredientFactory(ingredients__header=1))
|
||||||
|
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 10
|
||||||
|
assert len(json.loads(u1_s1.get(reverse('api:ingredient-list')).content)) == 11
|
@ -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,8 +32,8 @@ 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", [
|
||||||
|
@ -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,11 +1,11 @@
|
|||||||
from cookbook.models import UserPreference
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib import auth
|
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 scope, scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.models import Food, UserPreference
|
||||||
|
|
||||||
LIST_URL = 'api:userpreference-list'
|
LIST_URL = 'api:userpreference-list'
|
||||||
DETAIL_URL = 'api:userpreference-detail'
|
DETAIL_URL = 'api:userpreference-detail'
|
||||||
@ -109,3 +109,32 @@ def test_preference_delete(u1_s1, u2_s1):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_inherit_fields(u1_s1, u1_s2, space_1, space_2):
|
||||||
|
food_inherit_fields = Food.inheritable_fields
|
||||||
|
assert len([x.field for x in food_inherit_fields]) > 0
|
||||||
|
|
||||||
|
# by default space food will not inherit any fields, so all of them will be ignored
|
||||||
|
assert space_1.food_inherit.all().count() == 0
|
||||||
|
r = u1_s1.get(
|
||||||
|
reverse(DETAIL_URL, args={auth.get_user(u1_s1).id}),
|
||||||
|
)
|
||||||
|
assert len([x['field'] for x in json.loads(r.content)['food_inherit_default']]) == 0
|
||||||
|
|
||||||
|
# inherit all possible fields
|
||||||
|
with scope(space=space_1):
|
||||||
|
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True))
|
||||||
|
|
||||||
|
assert space_1.food_inherit.all().count() == Food.inheritable_fields.count() > 0
|
||||||
|
# now by default, food is inheriting all of the possible fields
|
||||||
|
r = u1_s1.get(
|
||||||
|
reverse(DETAIL_URL, args={auth.get_user(u1_s1).id}),
|
||||||
|
)
|
||||||
|
assert len([x['field'] for x in json.loads(r.content)['food_inherit_default']]) == space_1.food_inherit.all().count()
|
||||||
|
|
||||||
|
# other spaces and users in those spaces not effected
|
||||||
|
r = u1_s2.get(
|
||||||
|
reverse(DETAIL_URL, args={auth.get_user(u1_s2).id}),
|
||||||
|
)
|
||||||
|
assert space_2.food_inherit.all().count() == 0 == len([x['field'] for x in json.loads(r.content)['food_inherit_default']])
|
||||||
|
@ -5,14 +5,22 @@ import uuid
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib import auth
|
from django.contrib import auth
|
||||||
from django.contrib.auth.models import User, Group
|
from django.contrib.auth.models import Group, User
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
from pytest_factoryboy import LazyFixture, register
|
||||||
|
|
||||||
from cookbook.models import Space, Recipe, Step, Ingredient, Food, Unit
|
from cookbook.models import Food, Ingredient, Recipe, Space, Step, Unit
|
||||||
|
from cookbook.tests.factories import FoodFactory, SpaceFactory, UserFactory
|
||||||
|
|
||||||
|
register(SpaceFactory, 'space_1')
|
||||||
|
register(SpaceFactory, 'space_2')
|
||||||
|
# register(FoodFactory, space=LazyFixture('space_2'))
|
||||||
|
# TODO refactor clients to be factories
|
||||||
|
|
||||||
# hack from https://github.com/raphaelm/django-scopes to disable scopes for all fixtures
|
# hack from https://github.com/raphaelm/django-scopes to disable scopes for all fixtures
|
||||||
# does not work on yield fixtures as only one yield can be used per fixture (i think)
|
# does not work on yield fixtures as only one yield can be used per fixture (i think)
|
||||||
|
|
||||||
|
|
||||||
@pytest.hookimpl(hookwrapper=True)
|
@pytest.hookimpl(hookwrapper=True)
|
||||||
def pytest_fixture_setup(fixturedef, request):
|
def pytest_fixture_setup(fixturedef, request):
|
||||||
if inspect.isgeneratorfunction(fixturedef.func):
|
if inspect.isgeneratorfunction(fixturedef.func):
|
||||||
@ -27,23 +35,23 @@ def enable_db_access_for_all_tests(db):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
# @pytest.fixture()
|
||||||
def space_1():
|
# def space_1():
|
||||||
with scopes_disabled():
|
# with scopes_disabled():
|
||||||
return Space.objects.get_or_create(name='space_1')[0]
|
# return Space.objects.get_or_create(name='space_1')[0]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
# @pytest.fixture()
|
||||||
def space_2():
|
# def space_2():
|
||||||
with scopes_disabled():
|
# with scopes_disabled():
|
||||||
return Space.objects.get_or_create(name='space_2')[0]
|
# return Space.objects.get_or_create(name='space_2')[0]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------- OBJECT FIXTURES ---------------------
|
# ---------------------- OBJECT FIXTURES ---------------------
|
||||||
|
|
||||||
def get_random_recipe(space_1, u1_s1):
|
def get_random_recipe(space_1, u1_s1):
|
||||||
r = Recipe.objects.create(
|
r = Recipe.objects.create(
|
||||||
name=uuid.uuid4(),
|
name=str(uuid.uuid4()),
|
||||||
waiting_time=20,
|
waiting_time=20,
|
||||||
working_time=20,
|
working_time=20,
|
||||||
servings=4,
|
servings=4,
|
||||||
@ -52,8 +60,8 @@ def get_random_recipe(space_1, u1_s1):
|
|||||||
internal=True,
|
internal=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
s1 = Step.objects.create(name=uuid.uuid4(), instruction=uuid.uuid4(), space=space_1, )
|
s1 = Step.objects.create(name=str(uuid.uuid4()), instruction=str(uuid.uuid4()), space=space_1, )
|
||||||
s2 = Step.objects.create(name=uuid.uuid4(), instruction=uuid.uuid4(), space=space_1, )
|
s2 = Step.objects.create(name=str(uuid.uuid4()), instruction=str(uuid.uuid4()), space=space_1, )
|
||||||
|
|
||||||
r.steps.add(s1)
|
r.steps.add(s1)
|
||||||
r.steps.add(s2)
|
r.steps.add(s2)
|
||||||
@ -63,8 +71,8 @@ def get_random_recipe(space_1, u1_s1):
|
|||||||
Ingredient.objects.create(
|
Ingredient.objects.create(
|
||||||
amount=1,
|
amount=1,
|
||||||
food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0],
|
food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0],
|
||||||
unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ),
|
unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ),
|
||||||
note=uuid.uuid4(),
|
note=str(uuid.uuid4()),
|
||||||
space=space_1,
|
space=space_1,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -73,8 +81,8 @@ def get_random_recipe(space_1, u1_s1):
|
|||||||
Ingredient.objects.create(
|
Ingredient.objects.create(
|
||||||
amount=1,
|
amount=1,
|
||||||
food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0],
|
food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0],
|
||||||
unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ),
|
unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ),
|
||||||
note=uuid.uuid4(),
|
note=str(uuid.uuid4()),
|
||||||
space=space_1,
|
space=space_1,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -176,25 +184,17 @@ def create_user(client, space, **kwargs):
|
|||||||
c = copy.deepcopy(client)
|
c = copy.deepcopy(client)
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
group = kwargs.pop('group', None)
|
group = kwargs.pop('group', None)
|
||||||
username = kwargs.pop('username', uuid.uuid4())
|
user = UserFactory(space=space, groups=group)
|
||||||
|
|
||||||
user = User.objects.create(username=username, **kwargs)
|
|
||||||
if group:
|
|
||||||
user.groups.add(Group.objects.get(name=group))
|
|
||||||
|
|
||||||
user.userpreference.space = space
|
|
||||||
user.userpreference.save()
|
|
||||||
c.force_login(user)
|
c.force_login(user)
|
||||||
return c
|
return c
|
||||||
|
|
||||||
|
|
||||||
# anonymous user
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def a_u(client):
|
def a_u(client):
|
||||||
return copy.deepcopy(client)
|
return copy.deepcopy(client)
|
||||||
|
|
||||||
|
|
||||||
# users without any group
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def ng1_s1(client, space_1):
|
def ng1_s1(client, space_1):
|
||||||
return create_user(client, space_1)
|
return create_user(client, space_1)
|
||||||
|
372
cookbook/tests/factories/__init__.py
Normal file
372
cookbook/tests/factories/__init__.py
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import factory
|
||||||
|
import pytest
|
||||||
|
from django.contrib import auth
|
||||||
|
from django.contrib.auth.models import Group, User
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
from faker import Factory as FakerFactory
|
||||||
|
from pytest_factoryboy import register
|
||||||
|
|
||||||
|
from cookbook.models import Step
|
||||||
|
|
||||||
|
# this code will run immediately prior to creating the model object useful when you want a reverse relationship
|
||||||
|
# log = factory.RelatedFactory(
|
||||||
|
# UserLogFactory,
|
||||||
|
# factory_related_name='user',
|
||||||
|
# action=models.UserLog.ACTION_CREATE,
|
||||||
|
# )
|
||||||
|
faker = FakerFactory.create()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def enable_db_access_for_all_tests(db):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.hookimpl(hookwrapper=True)
|
||||||
|
def pytest_fixture_setup(fixturedef, request):
|
||||||
|
if inspect.isgeneratorfunction(fixturedef.func):
|
||||||
|
yield
|
||||||
|
else:
|
||||||
|
with scopes_disabled():
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class SpaceFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Space factory."""
|
||||||
|
name = factory.LazyAttribute(lambda x: faker.word())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create(cls, model_class, **kwargs):
|
||||||
|
with scopes_disabled():
|
||||||
|
return super()._create(model_class, **kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.Space'
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class UserFactory(factory.django.DjangoModelFactory):
|
||||||
|
|
||||||
|
"""User factory."""
|
||||||
|
username = factory.LazyAttribute(lambda x: faker.simple_profile()['username'])
|
||||||
|
first_name = factory.LazyAttribute(lambda x: faker.first_name())
|
||||||
|
last_name = factory.LazyAttribute(lambda x: faker.last_name())
|
||||||
|
email = factory.LazyAttribute(lambda x: faker.email())
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def groups(self, create, extracted, **kwargs):
|
||||||
|
if not create:
|
||||||
|
return
|
||||||
|
|
||||||
|
if extracted:
|
||||||
|
self.groups.add(Group.objects.get(name=extracted))
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def userpreference(self, create, extracted, **kwargs):
|
||||||
|
if not create:
|
||||||
|
return
|
||||||
|
|
||||||
|
if extracted:
|
||||||
|
for prefs in extracted:
|
||||||
|
self.userpreference[prefs] = extracted[prefs]/0 # intentionally break so it can be debugged later
|
||||||
|
self.userpreference.space = self.space
|
||||||
|
self.userpreference.save()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class SupermarketCategoryFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""SupermarketCategory factory."""
|
||||||
|
name = factory.LazyAttribute(lambda x: faker.word())
|
||||||
|
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.SupermarketCategory'
|
||||||
|
django_get_or_create = ('name', 'space',)
|
||||||
|
|
||||||
|
|
||||||
|
# @factory.django.mute_signals(post_save)
|
||||||
|
@register
|
||||||
|
class FoodFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Food factory."""
|
||||||
|
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3, variable_nb_words=False))
|
||||||
|
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||||
|
supermarket_category = factory.Maybe(
|
||||||
|
factory.LazyAttribute(lambda x: x.has_category),
|
||||||
|
yes_declaration=factory.SubFactory(SupermarketCategoryFactory, space=factory.SelfAttribute('..space')),
|
||||||
|
no_declaration=None
|
||||||
|
)
|
||||||
|
recipe = factory.Maybe(
|
||||||
|
factory.LazyAttribute(lambda x: x.has_recipe),
|
||||||
|
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
|
||||||
|
no_declaration=None
|
||||||
|
)
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
has_category = False
|
||||||
|
has_recipe = False
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.Food'
|
||||||
|
django_get_or_create = ('name', 'space',)
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class UnitFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Unit factory."""
|
||||||
|
name = factory.LazyAttribute(lambda x: faker.word())
|
||||||
|
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.Unit'
|
||||||
|
django_get_or_create = ('name', 'space',)
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class KeywordFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Keyword factory."""
|
||||||
|
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=2, variable_nb_words=False))
|
||||||
|
# icon = models.CharField(max_length=16, blank=True, null=True)
|
||||||
|
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
num = None # used on upstream factories to generate num keywords
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
num = None
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.Keyword'
|
||||||
|
django_get_or_create = ('name', 'space',)
|
||||||
|
exclude = ('num')
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class IngredientFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Ingredient factory."""
|
||||||
|
food = factory.SubFactory(FoodFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
unit = factory.SubFactory(UnitFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
amount = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10))
|
||||||
|
note = factory.LazyAttribute(lambda x: faker.sentence(nb_words=8))
|
||||||
|
is_header = False
|
||||||
|
no_amount = False
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.Ingredient'
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class MealTypeFactory(factory.django.DjangoModelFactory):
|
||||||
|
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
|
||||||
|
order = 0
|
||||||
|
# icon =
|
||||||
|
color = factory.LazyAttribute(lambda x: faker.safe_hex_color())
|
||||||
|
default = False
|
||||||
|
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.MealType'
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class MealPlanFactory(factory.django.DjangoModelFactory):
|
||||||
|
recipe = factory.Maybe(
|
||||||
|
factory.LazyAttribute(lambda x: x.has_recipe),
|
||||||
|
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
|
||||||
|
no_declaration=None
|
||||||
|
)
|
||||||
|
servings = factory.LazyAttribute(lambda x: Decimal(faker.random_int(min=1, max=1000)/100))
|
||||||
|
title = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
|
||||||
|
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
meal_type = factory.SubFactory(MealTypeFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
note = factory.LazyAttribute(lambda x: faker.paragraph())
|
||||||
|
date = factory.LazyAttribute(lambda x: faker.future_date())
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
has_recipe = True
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.MealPlan'
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class ShoppingListRecipeFactory(factory.django.DjangoModelFactory):
|
||||||
|
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
|
||||||
|
recipe = factory.Maybe(
|
||||||
|
factory.LazyAttribute(lambda x: x.has_recipe),
|
||||||
|
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
|
||||||
|
no_declaration=None
|
||||||
|
)
|
||||||
|
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10))
|
||||||
|
mealplan = factory.SubFactory(MealPlanFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
has_recipe = False
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.ShoppingListRecipe'
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class ShoppingListEntryFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""ShoppingListEntry factory."""
|
||||||
|
|
||||||
|
list_recipe = factory.Maybe(
|
||||||
|
factory.LazyAttribute(lambda x: x.has_mealplan),
|
||||||
|
yes_declaration=factory.SubFactory(ShoppingListRecipeFactory, space=factory.SelfAttribute('..space')),
|
||||||
|
no_declaration=None
|
||||||
|
)
|
||||||
|
food = factory.SubFactory(FoodFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
unit = factory.SubFactory(UnitFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
# # ingredient = factory.SubFactory(IngredientFactory)
|
||||||
|
amount = factory.LazyAttribute(lambda x: Decimal(faker.random_int(min=1, max=100))/10)
|
||||||
|
order = factory.Sequence(int)
|
||||||
|
checked = False
|
||||||
|
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
created_at = factory.LazyAttribute(lambda x: faker.past_date())
|
||||||
|
completed_at = None
|
||||||
|
delay_until = None
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
class Params:
|
||||||
|
has_mealplan = False
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.ShoppingListEntry'
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class StepFactory(factory.django.DjangoModelFactory):
|
||||||
|
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
|
||||||
|
instruction = factory.LazyAttribute(lambda x: ''.join(faker.paragraphs(nb=5)))
|
||||||
|
# TODO add optional recipe food, make dependent on recipe, make number of recipes a Params
|
||||||
|
ingredients__count = 10 # default number of ingredients to add
|
||||||
|
ingredients__header = 0
|
||||||
|
time = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=1000))
|
||||||
|
order = factory.Sequence(lambda x: x)
|
||||||
|
# file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True)
|
||||||
|
show_as_header = True
|
||||||
|
step_recipe__has_recipe = False
|
||||||
|
ingredients__food_recipe_count = 0
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def step_recipe(self, create, extracted, **kwargs):
|
||||||
|
if not create:
|
||||||
|
return
|
||||||
|
if kwargs.get('has_recipe', False):
|
||||||
|
self.step_recipe = RecipeFactory(space=self.space)
|
||||||
|
self.type = Step.RECIPE
|
||||||
|
elif extracted:
|
||||||
|
self.step_recipe = extracted
|
||||||
|
self.type = Step.RECIPE
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def ingredients(self, create, extracted, **kwargs):
|
||||||
|
if not create:
|
||||||
|
return
|
||||||
|
|
||||||
|
num_ing = kwargs.get('count', 0)
|
||||||
|
num_food_recipe = kwargs.get('food_recipe_count', 0)
|
||||||
|
if num_ing > 0:
|
||||||
|
for i in range(num_ing):
|
||||||
|
if num_food_recipe > 0:
|
||||||
|
has_recipe = True
|
||||||
|
num_food_recipe = num_food_recipe-1
|
||||||
|
else:
|
||||||
|
has_recipe = False
|
||||||
|
self.ingredients.add(IngredientFactory(space=self.space, food__has_recipe=has_recipe))
|
||||||
|
num_header = kwargs.get('header', 0)
|
||||||
|
#######################################################
|
||||||
|
#######################################################
|
||||||
|
if num_header > 0:
|
||||||
|
for i in range(num_header):
|
||||||
|
self.ingredients.add(IngredientFactory(food=None, unit=None, amount=0, is_header=True, space=self.space))
|
||||||
|
elif extracted:
|
||||||
|
for ing in extracted:
|
||||||
|
self.ingredients.add(ing)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.Step'
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class RecipeFactory(factory.django.DjangoModelFactory):
|
||||||
|
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=7))
|
||||||
|
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||||
|
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=20))
|
||||||
|
servings_text = factory.LazyAttribute(lambda x: faker.sentence(nb_words=1)) # TODO generate list of expected servings text that can be iterated through
|
||||||
|
keywords__count = 5 # default number of keywords to generate
|
||||||
|
steps__count = 1 # default number of steps to create
|
||||||
|
steps__recipe_count = 0 # default number of step recipes to create
|
||||||
|
steps__food_recipe_count = {} # by default, don't create food recipes, to override {'steps__food_recipe_count': {'step': 0, 'count': 1}}
|
||||||
|
working_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360))
|
||||||
|
waiting_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360))
|
||||||
|
internal = False
|
||||||
|
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
created_at = factory.LazyAttribute(lambda x: faker.date_this_decade())
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def keywords(self, create, extracted, **kwargs):
|
||||||
|
if not create:
|
||||||
|
# Simple build, do nothing.
|
||||||
|
return
|
||||||
|
|
||||||
|
num_kw = kwargs.get('count', 0)
|
||||||
|
if num_kw > 0:
|
||||||
|
for i in range(num_kw):
|
||||||
|
self.keywords.add(KeywordFactory(space=self.space))
|
||||||
|
elif extracted:
|
||||||
|
for kw in extracted:
|
||||||
|
self.keywords.add(kw)
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def steps(self, create, extracted, **kwargs):
|
||||||
|
if not create:
|
||||||
|
return
|
||||||
|
|
||||||
|
food_recipe_count = kwargs.get('food_recipe_count', {})
|
||||||
|
num_steps = kwargs.get('count', 0)
|
||||||
|
num_recipe_steps = kwargs.get('recipe_count', 0)
|
||||||
|
num_ing_headers = kwargs.get('ingredients__header', 0)
|
||||||
|
if num_steps > 0:
|
||||||
|
for i in range(num_steps):
|
||||||
|
ing_recipe_count = 0
|
||||||
|
if food_recipe_count.get('step', None) == i:
|
||||||
|
ing_recipe_count = food_recipe_count.get('count', 0)
|
||||||
|
self.steps.add(StepFactory(space=self.space, ingredients__food_recipe_count=ing_recipe_count, ingredients__header=num_ing_headers))
|
||||||
|
num_ing_headers+-1
|
||||||
|
if num_recipe_steps > 0:
|
||||||
|
for j in range(num_recipe_steps):
|
||||||
|
self.steps.add(StepFactory(space=self.space, step_recipe__has_recipe=True, ingredients__count=0))
|
||||||
|
if extracted and (num_steps + num_recipe_steps == 0):
|
||||||
|
for step in extracted:
|
||||||
|
self.steps.add(step)
|
||||||
|
|
||||||
|
# image = models.ImageField(upload_to='recipes/', blank=True, null=True) #TODO test recipe image api https://factoryboy.readthedocs.io/en/stable/orms.html#factory.django.ImageField
|
||||||
|
# storage = models.ForeignKey(
|
||||||
|
# Storage, on_delete=models.PROTECT, blank=True, null=True
|
||||||
|
# )
|
||||||
|
# file_uid = models.CharField(max_length=256, default="", blank=True)
|
||||||
|
# file_path = models.CharField(max_length=512, default="", blank=True)
|
||||||
|
# link = models.CharField(max_length=512, null=True, blank=True)
|
||||||
|
# cors_link = models.CharField(max_length=1024, null=True, blank=True)
|
||||||
|
# nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
|
||||||
|
# updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.Recipe'
|
@ -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 list_from_recipe, 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,
|
||||||
@ -221,6 +225,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
|||||||
root = int(root)
|
root = int(root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.queryset = self.model.objects.none()
|
self.queryset = self.model.objects.none()
|
||||||
|
|
||||||
if root == 0:
|
if root == 0:
|
||||||
self.queryset = self.model.get_root_nodes()
|
self.queryset = self.model.get_root_nodes()
|
||||||
else:
|
else:
|
||||||
@ -390,6 +395,17 @@ 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
|
||||||
|
self.queryset = Food.inheritable_fields
|
||||||
|
return super().get_queryset()
|
||||||
|
|
||||||
|
|
||||||
class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||||
queryset = Food.objects
|
queryset = Food.objects
|
||||||
model = Food
|
model = Food
|
||||||
@ -397,6 +413,26 @@ 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,)
|
||||||
|
# TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably
|
||||||
|
def shopping(self, request, pk):
|
||||||
|
if self.request.space.demo:
|
||||||
|
raise PermissionDenied(detail='Not available in demo', code=None)
|
||||||
|
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))
|
||||||
@ -504,8 +540,7 @@ class StepViewSet(viewsets.ModelViewSet):
|
|||||||
permission_classes = [CustomIsUser]
|
permission_classes = [CustomIsUser]
|
||||||
pagination_class = DefaultPagination
|
pagination_class = DefaultPagination
|
||||||
query_params = [
|
query_params = [
|
||||||
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'),
|
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
|
||||||
qtype='int'),
|
|
||||||
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
|
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
|
||||||
]
|
]
|
||||||
schema = QueryParamAutoSchema()
|
schema = QueryParamAutoSchema()
|
||||||
@ -547,31 +582,27 @@ 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()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|
||||||
|
if self.detail:
|
||||||
|
self.queryset = self.queryset.filter(space=self.request.space)
|
||||||
|
return super().get_queryset()
|
||||||
|
|
||||||
share = self.request.query_params.get('share', None)
|
share = self.request.query_params.get('share', None)
|
||||||
if not (share and self.detail):
|
if not (share and self.detail):
|
||||||
self.queryset = self.queryset.filter(space=self.request.space)
|
self.queryset = self.queryset.filter(space=self.request.space)
|
||||||
@ -625,6 +656,43 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
|||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
return Response(serializer.errors, 400)
|
return Response(serializer.errors, 400)
|
||||||
|
|
||||||
|
# TODO: refactor API to use post/put/delete or leave as put and change VUE to use list_recipe after creating
|
||||||
|
# DRF only allows one action in a decorator action without overriding get_operation_id_base()
|
||||||
|
@decorators.action(
|
||||||
|
detail=True,
|
||||||
|
methods=['PUT'],
|
||||||
|
serializer_class=RecipeShoppingUpdateSerializer,
|
||||||
|
)
|
||||||
|
def shopping(self, request, pk):
|
||||||
|
if self.request.space.demo:
|
||||||
|
raise PermissionDenied(detail='Not available in demo', code=None)
|
||||||
|
obj = self.get_object()
|
||||||
|
ingredients = request.data.get('ingredients', None)
|
||||||
|
servings = request.data.get('servings', None)
|
||||||
|
list_recipe = ShoppingListRecipe.objects.filter(id=request.data.get('list_recipe', None)).first()
|
||||||
|
if servings is None:
|
||||||
|
servings = getattr(list_recipe, 'servings', obj.servings)
|
||||||
|
# created_by needs to be sticky to original creator as it is 'their' shopping list
|
||||||
|
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
|
||||||
|
created_by = getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', request.user)
|
||||||
|
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
|
||||||
|
list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=created_by)
|
||||||
|
|
||||||
|
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=1) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
|
||||||
|
# mealplans= TODO get todays mealplans
|
||||||
|
return Response(self.serializer_class(qs, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
||||||
queryset = ShoppingListRecipe.objects
|
queryset = ShoppingListRecipe.objects
|
||||||
@ -632,9 +700,13 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
|||||||
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,34 +714,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 'checked' in self.request.query_params or 'recent' in self.request.query_params:
|
||||||
|
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]
|
||||||
|
|
||||||
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(
|
||||||
space=self.request.space).distinct()
|
Q(created_by=self.request.user)
|
||||||
|
| Q(shared=self.request.user)
|
||||||
|
| Q(created_by__in=list(self.request.user.get_shopping_share()))
|
||||||
|
).filter(space=self.request.space).distinct()
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
try:
|
try:
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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(
|
||||||
@ -240,15 +225,11 @@ def step(request):
|
|||||||
|
|
||||||
@group_required('user')
|
@group_required('user')
|
||||||
def shopping_list_new(request):
|
def shopping_list_new(request):
|
||||||
# recipe-param is the name of the parameters used when filtering recipes by this attribute
|
|
||||||
# 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
|
||||||
@ -254,13 +254,13 @@ def latest_shopping_list(request):
|
|||||||
|
|
||||||
|
|
||||||
@group_required('user')
|
@group_required('user')
|
||||||
def shopping_list(request, pk=None):
|
def shopping_list(request, pk=None): # TODO deprecate
|
||||||
html_list = request.GET.getlist('r')
|
html_list = request.GET.getlist('r')
|
||||||
|
|
||||||
recipes = []
|
recipes = []
|
||||||
for r in html_list:
|
for r in html_list:
|
||||||
r = r.replace('[', '').replace(']', '')
|
r = r.replace('[', '').replace(']', '')
|
||||||
if re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r):
|
if re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r): # vulnerable to DoS
|
||||||
rid, multiplier = r.split(',')
|
rid, multiplier = r.split(',')
|
||||||
if recipe := Recipe.objects.filter(pk=int(rid), space=request.space).first():
|
if recipe := Recipe.objects.filter(pk=int(rid), space=request.space).first():
|
||||||
recipes.append({'recipe': recipe.id, 'multiplier': multiplier})
|
recipes.append({'recipe': recipe.id, 'multiplier': multiplier})
|
||||||
@ -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,31 @@ 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']
|
||||||
|
up.shopping_recent_days = shopping_form.cleaned_data['shopping_recent_days']
|
||||||
|
up.csv_delim = shopping_form.cleaned_data['csv_delim']
|
||||||
|
up.csv_prefix = shopping_form.cleaned_data['csv_prefix']
|
||||||
|
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
|
||||||
|
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 +423,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 +559,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.inheritable_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
|
||||||
|
@ -65,15 +65,16 @@ This configuration exposes the application through an nginx web server on port 8
|
|||||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/plain/docker-compose.yml
|
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/plain/docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
~~~yaml
|
```yaml
|
||||||
{ % include "./docker/plain/docker-compose.yml" % }
|
{ % include "./docker/plain/docker-compose.yml" % }
|
||||||
~~~
|
```
|
||||||
|
|
||||||
### Reverse Proxy
|
### Reverse Proxy
|
||||||
|
|
||||||
Most deployments will likely use a reverse proxy.
|
Most deployments will likely use a reverse proxy.
|
||||||
|
|
||||||
#### Traefik
|
#### Traefik
|
||||||
|
|
||||||
If you use traefik, this configuration is the one for you.
|
If you use traefik, this configuration is the one for you.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
@ -85,9 +86,9 @@ If you use traefik, this configuration is the one for you.
|
|||||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/traefik-nginx/docker-compose.yml
|
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/traefik-nginx/docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
~~~yaml
|
```yaml
|
||||||
{ % include "./docker/traefik-nginx/docker-compose.yml" % }
|
{ % include "./docker/traefik-nginx/docker-compose.yml" % }
|
||||||
~~~
|
```
|
||||||
|
|
||||||
#### nginx-proxy
|
#### nginx-proxy
|
||||||
|
|
||||||
@ -97,6 +98,7 @@ in combination with [jrcs's letsencrypt companion](https://hub.docker.com/r/jrcs
|
|||||||
Please refer to the appropriate documentation on how to setup the reverse proxy and networks.
|
Please refer to the appropriate documentation on how to setup the reverse proxy and networks.
|
||||||
|
|
||||||
Remember to add the appropriate environment variables to `.env` file:
|
Remember to add the appropriate environment variables to `.env` file:
|
||||||
|
|
||||||
```
|
```
|
||||||
VIRTUAL_HOST=
|
VIRTUAL_HOST=
|
||||||
LETSENCRYPT_HOST=
|
LETSENCRYPT_HOST=
|
||||||
@ -107,11 +109,12 @@ LETSENCRYPT_EMAIL=
|
|||||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/nginx-proxy/docker-compose.yml
|
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/nginx-proxy/docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
~~~yaml
|
```yaml
|
||||||
{ % include "./docker/nginx-proxy/docker-compose.yml" % }
|
{ % include "./docker/nginx-proxy/docker-compose.yml" % }
|
||||||
~~~
|
```
|
||||||
|
|
||||||
#### Nginx Swag by LinuxServer
|
#### Nginx Swag by LinuxServer
|
||||||
|
|
||||||
[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io.
|
[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io.
|
||||||
|
|
||||||
It contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance.
|
It contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance.
|
||||||
@ -128,6 +131,27 @@ If your running Swag on a custom port, some headers must be changed:
|
|||||||
|
|
||||||
More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627).
|
More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627).
|
||||||
|
|
||||||
|
In both cases, also make sure to mount `/media/` in your swag container to point to your Tandoor Recipes Media directory.
|
||||||
|
|
||||||
|
Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup.
|
||||||
|
|
||||||
|
#### Nginx Swag by LinuxServer
|
||||||
|
|
||||||
|
[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io
|
||||||
|
|
||||||
|
It also contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance
|
||||||
|
|
||||||
|
If you're running Swag on the default port, you'll just need to change the container name to yours.
|
||||||
|
|
||||||
|
If your running Swag on a custom port, some headers must be changed. To do this,
|
||||||
|
|
||||||
|
- Create a copy of `proxy.conf`
|
||||||
|
- Replace `proxy_set_header X-Forwarded-Host $host;` and `proxy_set_header Host $host;` to
|
||||||
|
- `proxy_set_header X-Forwarded-Host $http_host;` and `proxy_set_header Host $http_host;`
|
||||||
|
- Update `recipes.subdomain.conf` to use the new file
|
||||||
|
- Restart the linuxserver/swag container and Recipes will work
|
||||||
|
|
||||||
|
More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627).
|
||||||
|
|
||||||
In both cases, also make sure to mount `/media/` in your swag container to point to your Tandoor Recipes Media directory.
|
In both cases, also make sure to mount `/media/` in your swag container to point to your Tandoor Recipes Media directory.
|
||||||
|
|
||||||
@ -136,6 +160,7 @@ Please refer to the [appropriate documentation](https://github.com/linuxserver/d
|
|||||||
## Additional Information
|
## Additional Information
|
||||||
|
|
||||||
### Nginx vs Gunicorn
|
### Nginx vs Gunicorn
|
||||||
|
|
||||||
All examples use an additional `nginx` container to serve mediafiles and act as the forward facing webserver.
|
All examples use an additional `nginx` container to serve mediafiles and act as the forward facing webserver.
|
||||||
This is **technically not required** but **very much recommended**.
|
This is **technically not required** but **very much recommended**.
|
||||||
|
|
||||||
|
@ -1,22 +1,22 @@
|
|||||||
Django==3.2.10
|
Django==3.2.11
|
||||||
cryptography==36.0.0
|
cryptography==36.0.1
|
||||||
django-annoying==0.10.6
|
django-annoying==0.10.6
|
||||||
django-autocomplete-light==3.8.2
|
django-autocomplete-light==3.8.2
|
||||||
django-cleanup==5.2.0
|
django-cleanup==5.2.0
|
||||||
django-crispy-forms==1.13.0
|
django-crispy-forms==1.13.0
|
||||||
django-filter==21.1
|
django-filter==21.1
|
||||||
django-tables2==2.4.1
|
django-tables2==2.4.1
|
||||||
djangorestframework==3.12.4
|
djangorestframework==3.13.1
|
||||||
drf-writable-nested==0.6.3
|
drf-writable-nested==0.6.3
|
||||||
bleach==4.1.0
|
bleach==4.1.0
|
||||||
bleach-allowlist==1.0.3
|
bleach-allowlist==1.0.3
|
||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
lxml==4.6.5
|
lxml==4.7.1
|
||||||
Markdown==3.3.6
|
Markdown==3.3.6
|
||||||
Pillow==8.4.0
|
Pillow==9.0.0
|
||||||
psycopg2-binary==2.9.2
|
psycopg2-binary==2.9.3
|
||||||
python-dotenv==0.19.2
|
python-dotenv==0.19.2
|
||||||
requests==2.26.0
|
requests==2.27.0
|
||||||
simplejson==3.17.6
|
simplejson==3.17.6
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
webdavclient3==3.14.6
|
webdavclient3==3.14.6
|
||||||
@ -29,17 +29,18 @@ microdata==0.7.2
|
|||||||
Jinja2==3.0.3
|
Jinja2==3.0.3
|
||||||
django-webpack-loader==1.4.1
|
django-webpack-loader==1.4.1
|
||||||
django-js-reverse==0.9.1
|
django-js-reverse==0.9.1
|
||||||
django-allauth==0.46.0
|
django-allauth==0.47.0
|
||||||
recipe-scrapers==13.7.0
|
recipe-scrapers==13.10.1
|
||||||
django-scopes==1.2.0
|
django-scopes==1.2.0
|
||||||
pytest==6.2.5
|
pytest==6.2.5
|
||||||
pytest-django==4.5.1
|
pytest-django==4.5.2
|
||||||
django-treebeard==4.5.1
|
django-treebeard==4.5.1
|
||||||
django-cors-headers==3.10.0
|
django-cors-headers==3.10.1
|
||||||
django-storages==1.12.3
|
django-storages==1.12.3
|
||||||
boto3==1.20.19
|
boto3==1.20.27
|
||||||
django-prometheus==2.1.0
|
django-prometheus==2.2.0
|
||||||
django-hCaptcha==0.1.0
|
django-hCaptcha==0.1.0
|
||||||
python-ldap==3.4.0
|
python-ldap==3.4.0
|
||||||
django-auth-ldap==3.0.0
|
django-auth-ldap==4.0.0
|
||||||
|
pytest-factoryboy==2.1.0
|
||||||
pyppeteer==0.2.6
|
pyppeteer==0.2.6
|
||||||
|
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
|
@ -15,7 +15,8 @@
|
|||||||
"@riophae/vue-treeselect": "^0.4.0",
|
"@riophae/vue-treeselect": "^0.4.0",
|
||||||
"axios": "^0.24.0",
|
"axios": "^0.24.0",
|
||||||
"bootstrap-vue": "^2.21.2",
|
"bootstrap-vue": "^2.21.2",
|
||||||
"core-js": "^3.19.0",
|
"core-js": "^3.20.2",
|
||||||
|
"html2pdf.js": "^0.10.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.1",
|
"moment": "^2.29.1",
|
||||||
"prismjs": "^1.25.0",
|
"prismjs": "^1.25.0",
|
||||||
@ -24,7 +25,7 @@
|
|||||||
"vue-click-outside": "^1.1.0",
|
"vue-click-outside": "^1.1.0",
|
||||||
"vue-clickaway": "^2.2.2",
|
"vue-clickaway": "^2.2.2",
|
||||||
"vue-cookies": "^1.7.4",
|
"vue-cookies": "^1.7.4",
|
||||||
"vue-i18n": "^8.26.5",
|
"vue-i18n": "^8.26.8",
|
||||||
"vue-infinite-loading": "^2.4.5",
|
"vue-infinite-loading": "^2.4.5",
|
||||||
"vue-multiselect": "^2.1.6",
|
"vue-multiselect": "^2.1.6",
|
||||||
"vue-property-decorator": "^9.1.2",
|
"vue-property-decorator": "^9.1.2",
|
||||||
@ -45,7 +46,7 @@
|
|||||||
"@vue/cli-plugin-typescript": "^4.5.15",
|
"@vue/cli-plugin-typescript": "^4.5.15",
|
||||||
"@vue/cli-service": "~4.5.13",
|
"@vue/cli-service": "~4.5.13",
|
||||||
"@vue/compiler-sfc": "^3.2.20",
|
"@vue/compiler-sfc": "^3.2.20",
|
||||||
"@vue/eslint-config-typescript": "^9.1.0",
|
"@vue/eslint-config-typescript": "^10.0.0",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"eslint": "^7.28.0",
|
"eslint": "^7.28.0",
|
||||||
"eslint-plugin-vue": "^8.0.3",
|
"eslint-plugin-vue": "^8.0.3",
|
||||||
|
@ -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')
|
|
@ -6,13 +6,13 @@
|
|||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-12 col-lg-10 mt-3 mb-3">
|
<div class="col-12 col-lg-10 mt-3 mb-3">
|
||||||
<b-input-group>
|
<b-input-group>
|
||||||
<b-input class="form-control form-control-lg form-control-borderless form-control-search"
|
<b-input
|
||||||
|
class="form-control form-control-lg form-control-borderless form-control-search"
|
||||||
v-model="search"
|
v-model="search"
|
||||||
v-bind:placeholder="$t('Search')"></b-input>
|
v-bind:placeholder="$t('Search')"
|
||||||
|
></b-input>
|
||||||
<b-input-group-append>
|
<b-input-group-append>
|
||||||
<b-button variant="primary"
|
<b-button variant="primary" v-b-tooltip.hover :title="$t('Create')" @click="createNew">
|
||||||
v-b-tooltip.hover :title="$t('Create')"
|
|
||||||
@click="createNew">
|
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
</b-button>
|
</b-button>
|
||||||
</b-input-group-append>
|
</b-input-group-append>
|
||||||
@ -25,20 +25,19 @@
|
|||||||
<div class="mb-3" v-for="book in filteredBooks" :key="book.id">
|
<div class="mb-3" v-for="book in filteredBooks" :key="book.id">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<b-card class="d-flex flex-column" v-hover
|
<b-card class="d-flex flex-column" v-hover v-on:click="openBook(book.id)">
|
||||||
v-on:click="openBook(book.id)">
|
<b-row no-gutters style="height: inherit">
|
||||||
<b-row no-gutters style="height:inherit;">
|
<b-col no-gutters md="2" style="height: inherit">
|
||||||
<b-col no-gutters md="2" style="height:inherit;">
|
|
||||||
<h3>{{ book.icon }}</h3>
|
<h3>{{ book.icon }}</h3>
|
||||||
</b-col>
|
</b-col>
|
||||||
<b-col no-gutters md="10" style="height:inherit;">
|
<b-col no-gutters md="10" style="height: inherit">
|
||||||
<b-card-body class="m-0 py-0" style="height:inherit;">
|
<b-card-body class="m-0 py-0" style="height: inherit">
|
||||||
<b-card-text class="h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
|
<b-card-text class="h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
|
||||||
<h5 class="m-0 mt-1 text-truncate">{{ book.name }} <span class="float-right"><i
|
<h5 class="m-0 mt-1 text-truncate">
|
||||||
class="fa fa-book"></i></span></h5>
|
{{ book.name }} <span class="float-right"><i class="fa fa-book"></i></span>
|
||||||
|
</h5>
|
||||||
<div class="m-0 text-truncate">{{ book.description }}</div>
|
<div class="m-0 text-truncate">{{ book.description }}</div>
|
||||||
<div class="mt-auto mb-1 d-flex flex-row justify-content-end">
|
<div class="mt-auto mb-1 d-flex flex-row justify-content-end"></div>
|
||||||
</div>
|
|
||||||
</b-card-text>
|
</b-card-text>
|
||||||
</b-card-body>
|
</b-card-body>
|
||||||
</b-col>
|
</b-col>
|
||||||
@ -49,27 +48,32 @@
|
|||||||
|
|
||||||
<loading-spinner v-if="current_book === book.id && loading"></loading-spinner>
|
<loading-spinner v-if="current_book === book.id && loading"></loading-spinner>
|
||||||
<transition name="slide-fade">
|
<transition name="slide-fade">
|
||||||
<cookbook-slider :recipes="recipes" :book="book" :key="`slider_${book.id}`"
|
<cookbook-slider
|
||||||
v-if="current_book === book.id && !loading" v-on:refresh="refreshData"></cookbook-slider>
|
:recipes="recipes"
|
||||||
|
:book="book"
|
||||||
|
:key="`slider_${book.id}`"
|
||||||
|
v-if="current_book === book.id && !loading"
|
||||||
|
v-on:refresh="refreshData"
|
||||||
|
></cookbook-slider>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</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 {ApiApiFactory} from "@/utils/openapi/api";
|
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||||
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)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'CookbookView',
|
name: "CookbookView",
|
||||||
mixins: [],
|
mixins: [],
|
||||||
components: { LoadingSpinner, CookbookSlider },
|
components: { LoadingSpinner, CookbookSlider },
|
||||||
data() {
|
data() {
|
||||||
@ -79,15 +83,15 @@ export default {
|
|||||||
recipes: [],
|
recipes: [],
|
||||||
current_book: undefined,
|
current_book: undefined,
|
||||||
loading: false,
|
loading: false,
|
||||||
search: ''
|
search: "",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
filteredBooks: function () {
|
filteredBooks: function () {
|
||||||
return this.cookbooks.filter(book => {
|
return this.cookbooks.filter((book) => {
|
||||||
return book.name.toLowerCase().includes(this.search.toLowerCase())
|
return book.name.toLowerCase().includes(this.search.toLowerCase())
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshData()
|
this.refreshData()
|
||||||
@ -97,7 +101,7 @@ export default {
|
|||||||
refreshData: function () {
|
refreshData: function () {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.listRecipeBooks().then(result => {
|
apiClient.listRecipeBooks().then((result) => {
|
||||||
this.cookbooks = result.data
|
this.cookbooks = result.data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -111,7 +115,7 @@ export default {
|
|||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
this.current_book = book
|
this.current_book = book
|
||||||
apiClient.listRecipeBookEntrys({query: {book: book}}).then(result => {
|
apiClient.listRecipeBookEntrys({ query: { book: book } }).then((result) => {
|
||||||
this.recipes = result.data
|
this.recipes = result.data
|
||||||
this.loading = false
|
this.loading = false
|
||||||
})
|
})
|
||||||
@ -119,39 +123,40 @@ export default {
|
|||||||
createNew: function () {
|
createNew: function () {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.createRecipeBook({name: this.$t('New_Cookbook'), description: '', icon: '', shared: []}).then(result => {
|
apiClient
|
||||||
|
.createRecipeBook({ name: this.$t("New_Cookbook"), description: "", icon: "", shared: [] })
|
||||||
|
.then((result) => {
|
||||||
let new_book = result.data
|
let new_book = result.data
|
||||||
this.refreshData()
|
this.refreshData()
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
hover: {
|
hover: {
|
||||||
inserted: function (el) {
|
inserted: function (el) {
|
||||||
el.addEventListener('mouseenter', () => {
|
el.addEventListener("mouseenter", () => {
|
||||||
el.classList.add("shadow")
|
el.classList.add("shadow")
|
||||||
});
|
})
|
||||||
el.addEventListener('mouseleave', () => {
|
el.addEventListener("mouseleave", () => {
|
||||||
el.classList.remove("shadow")
|
el.classList.remove("shadow")
|
||||||
});
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.slide-fade-enter-active {
|
.slide-fade-enter-active {
|
||||||
transition: all .6s ease;
|
transition: all 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-fade-enter, .slide-fade-leave-to
|
.slide-fade-enter, .slide-fade-leave-to
|
||||||
/* .slide-fade-leave-active below version 2.1.8 */
|
/* .slide-fade-leave-active below version 2.1.8 */ {
|
||||||
{
|
|
||||||
transform: translateX(10px);
|
transform: translateX(10px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
@ -5,29 +5,45 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 calender-parent">
|
<div class="col-12 calender-parent">
|
||||||
<calendar-view
|
<calendar-view
|
||||||
:show-date="showDate" :enable-date-selection="true" class="theme-default"
|
:show-date="showDate"
|
||||||
|
:enable-date-selection="true"
|
||||||
|
class="theme-default"
|
||||||
:items="plan_items"
|
:items="plan_items"
|
||||||
:display-period-uom="settings.displayPeriodUom"
|
:display-period-uom="settings.displayPeriodUom"
|
||||||
:period-changed-callback="periodChangedCallback" :enable-drag-drop="true"
|
:period-changed-callback="periodChangedCallback"
|
||||||
|
:enable-drag-drop="true"
|
||||||
:item-content-height="item_height"
|
:item-content-height="item_height"
|
||||||
@click-date="createEntryClick" @drop-on-date="moveEntry"
|
@click-date="createEntryClick"
|
||||||
|
@drop-on-date="moveEntry"
|
||||||
:display-period-count="settings.displayPeriodCount"
|
:display-period-count="settings.displayPeriodCount"
|
||||||
:starting-day-of-week="settings.startingDayOfWeek"
|
:starting-day-of-week="settings.startingDayOfWeek"
|
||||||
:display-week-numbers="settings.displayWeekNumbers">
|
:display-week-numbers="settings.displayWeekNumbers"
|
||||||
|
>
|
||||||
<template #item="{ value, weekStartDate, top }">
|
<template #item="{ value, weekStartDate, top }">
|
||||||
<meal-plan-card :value="value" :week-start-date="weekStartDate" :top="top" :detailed="detailed_items"
|
<meal-plan-card
|
||||||
:item_height="item_height" @dragstart="dragged_item = value" @click-item="entryClick"
|
:value="value"
|
||||||
@open-context-menu="openContextMenu"/>
|
:week-start-date="weekStartDate"
|
||||||
|
:top="top"
|
||||||
|
:detailed="detailed_items"
|
||||||
|
:item_height="item_height"
|
||||||
|
@dragstart="dragged_item = value"
|
||||||
|
@click-item="entryClick"
|
||||||
|
@open-context-menu="openContextMenu"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #header="{ headerProps }">
|
<template #header="{ headerProps }">
|
||||||
<meal-plan-calender-header ref="header"
|
<meal-plan-calender-header
|
||||||
|
ref="header"
|
||||||
:header-props="headerProps"
|
:header-props="headerProps"
|
||||||
@input="setShowDate" @delete-dragged="deleteEntry(dragged_item)"
|
@input="setShowDate"
|
||||||
|
@delete-dragged="deleteEntry(dragged_item)"
|
||||||
@create-new="createEntryClick(new Date())"
|
@create-new="createEntryClick(new Date())"
|
||||||
@set-starting-day-back="setStartingDay(-1)"
|
@set-starting-day-back="setStartingDay(-1)"
|
||||||
@set-starting-day-forward="setStartingDay(1)" :i-cal-url="iCalUrl"
|
@set-starting-day-forward="setStartingDay(1)"
|
||||||
|
:i-cal-url="iCalUrl"
|
||||||
:options="options"
|
:options="options"
|
||||||
:settings_prop="settings"/>
|
:settings_prop="settings"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</calendar-view>
|
</calendar-view>
|
||||||
</div>
|
</div>
|
||||||
@ -36,97 +52,67 @@
|
|||||||
<b-tab :title="$t('Settings')">
|
<b-tab :title="$t('Settings')">
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col-12 col-md-3 calender-options">
|
<div class="col-12 col-md-3 calender-options">
|
||||||
<h5>{{ $t('Planner_Settings') }}</h5>
|
<h5>{{ $t("Planner_Settings") }}</h5>
|
||||||
<b-form>
|
<b-form>
|
||||||
<b-form-group id="UomInput"
|
<b-form-group id="UomInput" :label="$t('Period')" :description="$t('Plan_Period_To_Show')" label-for="UomInput">
|
||||||
:label="$t('Period')"
|
<b-form-select id="UomInput" v-model="settings.displayPeriodUom" :options="options.displayPeriodUom"></b-form-select>
|
||||||
:description="$t('Plan_Period_To_Show')"
|
|
||||||
label-for="UomInput">
|
|
||||||
<b-form-select
|
|
||||||
id="UomInput"
|
|
||||||
v-model="settings.displayPeriodUom"
|
|
||||||
:options="options.displayPeriodUom"
|
|
||||||
></b-form-select>
|
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-form-group id="PeriodInput"
|
<b-form-group id="PeriodInput" :label="$t('Periods')" :description="$t('Plan_Show_How_Many_Periods')" label-for="PeriodInput">
|
||||||
:label="$t('Periods')"
|
<b-form-select id="PeriodInput" v-model="settings.displayPeriodCount" :options="options.displayPeriodCount"></b-form-select>
|
||||||
:description="$t('Plan_Show_How_Many_Periods')"
|
|
||||||
label-for="PeriodInput">
|
|
||||||
<b-form-select
|
|
||||||
id="PeriodInput"
|
|
||||||
v-model="settings.displayPeriodCount"
|
|
||||||
:options="options.displayPeriodCount"
|
|
||||||
></b-form-select>
|
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-form-group id="DaysInput"
|
<b-form-group id="DaysInput" :label="$t('Starting_Day')" :description="$t('Starting_Day')" label-for="DaysInput">
|
||||||
:label="$t('Starting_Day')"
|
<b-form-select id="DaysInput" v-model="settings.startingDayOfWeek" :options="dayNames"></b-form-select>
|
||||||
:description="$t('Starting_Day')"
|
|
||||||
label-for="DaysInput">
|
|
||||||
<b-form-select
|
|
||||||
id="DaysInput"
|
|
||||||
v-model="settings.startingDayOfWeek"
|
|
||||||
:options="dayNames"
|
|
||||||
></b-form-select>
|
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-form-group id="WeekNumInput"
|
<b-form-group id="WeekNumInput" :label="$t('Week_Numbers')">
|
||||||
:label="$t('Week_Numbers')">
|
|
||||||
<b-form-checkbox v-model="settings.displayWeekNumbers" name="week_num">
|
<b-form-checkbox v-model="settings.displayWeekNumbers" name="week_num">
|
||||||
{{ $t('Show_Week_Numbers') }}
|
{{ $t("Show_Week_Numbers") }}
|
||||||
</b-form-checkbox>
|
</b-form-checkbox>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
</b-form>
|
</b-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-9 col-lg-6">
|
<div class="col-12 col-md-9 col-lg-6">
|
||||||
<h5>{{ $t('Meal_Types') }}</h5>
|
<h5>{{ $t("Meal_Types") }}</h5>
|
||||||
<div>
|
<div>
|
||||||
<draggable :list="meal_types" group="meal_types"
|
<draggable :list="meal_types" group="meal_types" :empty-insert-threshold="10" handle=".handle" @sort="sortMealTypes()">
|
||||||
:empty-insert-threshold="10" handle=".handle" @sort="sortMealTypes()">
|
|
||||||
<b-card no-body class="mt-1" v-for="(meal_type, index) in meal_types" v-hover :key="meal_type.id">
|
<b-card no-body class="mt-1" v-for="(meal_type, index) in meal_types" v-hover :key="meal_type.id">
|
||||||
<b-card-header class="p-4">
|
<b-card-header class="p-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-2 handle">
|
<div class="col-2 handle">
|
||||||
<button type="button" class="btn btn-lg shadow-none"><i class="fas fa-arrows-alt-v "></i>
|
<button type="button" class="btn btn-lg shadow-none"><i class="fas fa-arrows-alt-v"></i></button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-10">
|
<div class="col-10">
|
||||||
<h5>{{ meal_type.icon }} {{ meal_type.name }}<span class="float-right text-primary"><i
|
<h5>
|
||||||
class="fa" v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }"
|
{{ meal_type.icon }} {{ meal_type.name
|
||||||
@click="editOrSaveMealType(index)"
|
}}<span class="float-right text-primary"
|
||||||
aria-hidden="true"></i></span></h5>
|
><i class="fa" v-bind:class="{ 'fa-pen': !meal_type.editing, 'fa-save': meal_type.editing }" @click="editOrSaveMealType(index)" aria-hidden="true"></i
|
||||||
|
></span>
|
||||||
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</b-card-header>
|
</b-card-header>
|
||||||
<b-card-body class="p-4" v-if="meal_type.editing">
|
<b-card-body class="p-4" v-if="meal_type.editing">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ $t('Name') }}</label>
|
<label>{{ $t("Name") }}</label>
|
||||||
<input class="form-control"
|
<input class="form-control" placeholder="Name" v-model="meal_type.name" />
|
||||||
placeholder="Name" v-model="meal_type.name">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<emoji-input :field="'icon'" :label="$t('Icon')" :value="meal_type.icon"></emoji-input>
|
<emoji-input :field="'icon'" :label="$t('Icon')" :value="meal_type.icon"></emoji-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ $t('Color') }}</label>
|
<label>{{ $t("Color") }}</label>
|
||||||
<input class="form-control" type="color"
|
<input class="form-control" type="color" name="Name" :value="meal_type.color" @change="meal_type.color = $event.target.value" />
|
||||||
name="Name" :value="meal_type.color" @change="meal_type.color = $event.target.value">
|
|
||||||
</div>
|
</div>
|
||||||
<b-form-checkbox
|
<b-form-checkbox id="checkbox-1" v-model="meal_type.default" name="default_checkbox" class="mb-2">
|
||||||
id="checkbox-1"
|
{{ $t("Default") }}
|
||||||
v-model="meal_type.default"
|
|
||||||
name="default_checkbox"
|
|
||||||
class="mb-2">
|
|
||||||
{{ $t('Default') }}
|
|
||||||
</b-form-checkbox>
|
</b-form-checkbox>
|
||||||
<button class="btn btn-danger" @click="deleteMealType(index)">{{ $t('Delete') }}</button>
|
<button class="btn btn-danger" @click="deleteMealType(index)">{{ $t("Delete") }}</button>
|
||||||
<button class="btn btn-primary float-right" @click="editOrSaveMealType(index)">{{
|
<button class="btn btn-primary float-right" @click="editOrSaveMealType(index)">{{ $t("Save") }}</button>
|
||||||
$t('Save')
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</b-card-body>
|
</b-card-body>
|
||||||
</b-card>
|
</b-card>
|
||||||
</draggable>
|
</draggable>
|
||||||
<button class="btn btn-success float-right mt-1" @click="newMealType"><i class="fas fa-plus"></i>
|
<button class="btn btn-success float-right mt-1" @click="newMealType">
|
||||||
{{ $t('New_Meal_Type') }}
|
<i class="fas fa-plus"></i>
|
||||||
|
{{ $t("New_Meal_Type") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -135,30 +121,66 @@
|
|||||||
</b-tabs>
|
</b-tabs>
|
||||||
<ContextMenu ref="menu">
|
<ContextMenu ref="menu">
|
||||||
<template #menu="{ contextData }">
|
<template #menu="{ contextData }">
|
||||||
<ContextMenuItem @click="$refs.menu.close();openEntryEdit(contextData.originalItem.entry)">
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
openEntryEdit(contextData.originalItem.entry)
|
||||||
|
"
|
||||||
|
>
|
||||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();moveEntryLeft(contextData)">
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
moveEntryLeft(contextData)
|
||||||
|
"
|
||||||
|
>
|
||||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();moveEntryRight(contextData)">
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
moveEntryRight(contextData)
|
||||||
|
"
|
||||||
|
>
|
||||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();createEntry(contextData.originalItem.entry)">
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
createEntry(contextData.originalItem.entry)
|
||||||
|
"
|
||||||
|
>
|
||||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-copy"></i> {{ $t("Clone") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-copy"></i> {{ $t("Clone") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();addToShopping(contextData)">
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
addToShopping(contextData)
|
||||||
|
"
|
||||||
|
>
|
||||||
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();deleteEntry(contextData)">
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
$refs.menu.close()
|
||||||
|
deleteEntry(contextData)
|
||||||
|
"
|
||||||
|
>
|
||||||
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
|
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</template>
|
</template>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="entryEditing_initial_recipe"
|
<meal-plan-edit-modal
|
||||||
:entry-editing_initial_meal_type="entryEditing_initial_meal_type" :modal_title="modal_title"
|
:entry="entryEditing"
|
||||||
:edit_modal_show="edit_modal_show" @save-entry="editEntry"
|
:entryEditing_initial_recipe="entryEditing_initial_recipe"
|
||||||
@delete-entry="deleteEntry" @reload-meal-types="refreshMealTypes"></meal-plan-edit-modal>
|
:entry-editing_initial_meal_type="entryEditing_initial_meal_type"
|
||||||
|
:modal_title="modal_title"
|
||||||
|
:edit_modal_show="edit_modal_show"
|
||||||
|
@save-entry="editEntry"
|
||||||
|
@delete-entry="deleteEntry"
|
||||||
|
@reload-meal-types="refreshMealTypes"
|
||||||
|
></meal-plan-edit-modal>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<b-sidebar id="sidebar-shopping" :title="$t('Shopping_list')" backdrop right shadow="sm">
|
<b-sidebar id="sidebar-shopping" :title="$t('Shopping_list')" backdrop right shadow="sm">
|
||||||
@ -175,11 +197,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12 mt-1" v-if="shopping_list.length > 0">
|
<div class="col-12 mt-1" v-if="shopping_list.length > 0">
|
||||||
<b-button-group>
|
<b-button-group>
|
||||||
<b-button variant="success" @click="saveShoppingList"><i class="fas fa-external-link-alt"></i>
|
<b-button variant="success" @click="saveShoppingList"
|
||||||
|
><i class="fas fa-external-link-alt"></i>
|
||||||
{{ $t("Open") }}
|
{{ $t("Open") }}
|
||||||
</b-button>
|
</b-button>
|
||||||
<b-button variant="danger" @click="shopping_list = []"><i class="fa fa-trash"></i> {{ $t("Clear") }}
|
<b-button variant="danger" @click="shopping_list = []"><i class="fa fa-trash"></i> {{ $t("Clear") }} </b-button>
|
||||||
</b-button>
|
|
||||||
</b-button-group>
|
</b-button-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -187,27 +209,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<transition name="slide-fade">
|
<transition name="slide-fade">
|
||||||
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background:rgba(255,255,255,0.6);"
|
<div class="row fixed-bottom p-2 b-1 border-top text-center" style="background: rgba(255, 255, 255, 0.6)" v-if="current_tab === 0">
|
||||||
v-if="current_tab === 0">
|
|
||||||
<div class="col-md-3 col-6">
|
<div class="col-md-3 col-6">
|
||||||
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i
|
<button class="btn btn-block btn-success shadow-none" @click="createEntryClick(new Date())"><i class="fas fa-calendar-plus"></i> {{ $t("Create") }}</button>
|
||||||
class="fas fa-calendar-plus"></i> {{ $t('Create') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 col-6">
|
<div class="col-md-3 col-6">
|
||||||
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i
|
<button class="btn btn-block btn-primary shadow-none" v-b-toggle.sidebar-shopping><i class="fas fa-shopping-cart"></i> {{ $t("Shopping_list") }}</button>
|
||||||
class="fas fa-shopping-cart"></i> {{ $t('Shopping_list') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 col-6">
|
<div class="col-md-3 col-6">
|
||||||
<a class="btn btn-block btn-primary shadow-none" :href="iCalUrl"><i class="fas fa-download"></i>
|
<a class="btn btn-block btn-primary shadow-none" :href="iCalUrl"
|
||||||
{{ $t('Export_To_ICal') }}
|
><i class="fas fa-download"></i>
|
||||||
|
{{ $t("Export_To_ICal") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 col-6">
|
<div class="col-md-3 col-6">
|
||||||
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top
|
<button class="btn btn-block btn-primary shadow-none disabled" v-b-tooltip.focus.top :title="$t('Coming_Soon')">
|
||||||
:title="$t('Coming_Soon')">
|
{{ $t("Auto_Planner") }}
|
||||||
{{ $t('Auto_Planner') }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 d-flex justify-content-center mt-2 d-block d-md-none">
|
<div class="col-12 d-flex justify-content-center mt-2 d-block d-md-none">
|
||||||
@ -217,12 +234,8 @@
|
|||||||
<b-button v-html="'<'" @click="setStartingDay(-1)"></b-button>
|
<b-button v-html="'<'" @click="setStartingDay(-1)"></b-button>
|
||||||
</b-button-group>
|
</b-button-group>
|
||||||
<b-button-group class="mx-1">
|
<b-button-group class="mx-1">
|
||||||
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i class="fas fa-home"></i>
|
<b-button @click="setShowDate($refs.header.headerProps.currentPeriod)"><i class="fas fa-home"></i> </b-button>
|
||||||
</b-button>
|
<b-form-datepicker button-only button-variant="secondary"></b-form-datepicker>
|
||||||
<b-form-datepicker
|
|
||||||
button-only
|
|
||||||
button-variant="secondary"
|
|
||||||
></b-form-datepicker>
|
|
||||||
</b-button-group>
|
</b-button-group>
|
||||||
<b-button-group class="mx-1">
|
<b-button-group class="mx-1">
|
||||||
<b-button v-html="'>'" @click="setStartingDay(1)"></b-button>
|
<b-button v-html="'>'" @click="setStartingDay(1)"></b-button>
|
||||||
@ -236,33 +249,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import Vue from "vue"
|
||||||
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
|
||||||
import Vue from "vue";
|
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||||
import {BootstrapVue} from 'bootstrap-vue'
|
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
import MealPlanCard from "@/components/MealPlanCard"
|
||||||
|
import MealPlanEditModal from "@/components/MealPlanEditModal"
|
||||||
import ContextMenu from "@/components/ContextMenu/ContextMenu";
|
import MealPlanCalenderHeader from "@/components/MealPlanCalenderHeader"
|
||||||
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem";
|
import EmojiInput from "@/components/Modals/EmojiInput"
|
||||||
import MealPlanCard from "@/components/MealPlanCard";
|
|
||||||
import MealPlanEditModal from "@/components/MealPlanEditModal";
|
|
||||||
import MealPlanCalenderHeader from "@/components/MealPlanCalenderHeader";
|
|
||||||
import EmojiInput from "@/components/Modals/EmojiInput";
|
|
||||||
|
|
||||||
import moment from "moment"
|
import moment from "moment"
|
||||||
import draggable from "vuedraggable"
|
import draggable from "vuedraggable"
|
||||||
import VueCookies from "vue-cookies";
|
import VueCookies from "vue-cookies"
|
||||||
|
|
||||||
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||||
import {CalendarView, CalendarMathMixin} from "vue-simple-calendar/src/components/bundle";
|
import { CalendarView, CalendarMathMixin } from "vue-simple-calendar/src/components/bundle"
|
||||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||||
|
|
||||||
const {makeToast} = require("@/utils/utils");
|
const { makeToast } = require("@/utils/utils")
|
||||||
|
|
||||||
Vue.prototype.moment = moment
|
Vue.prototype.moment = moment
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
Vue.use(VueCookies)
|
Vue.use(VueCookies)
|
||||||
|
|
||||||
let SETTINGS_COOKIE_NAME = 'mealplan_settings'
|
let SETTINGS_COOKIE_NAME = "mealplan_settings"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "MealPlanView",
|
name: "MealPlanView",
|
||||||
@ -274,7 +286,7 @@ export default {
|
|||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
MealPlanCalenderHeader,
|
MealPlanCalenderHeader,
|
||||||
EmojiInput,
|
EmojiInput,
|
||||||
draggable
|
draggable,
|
||||||
},
|
},
|
||||||
mixins: [CalendarMathMixin, ApiMixin],
|
mixins: [CalendarMathMixin, ApiMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
@ -283,20 +295,24 @@ export default {
|
|||||||
plan_entries: [],
|
plan_entries: [],
|
||||||
recipe_viewed: {},
|
recipe_viewed: {},
|
||||||
settings: {
|
settings: {
|
||||||
displayPeriodUom: 'week',
|
displayPeriodUom: "week",
|
||||||
displayPeriodCount: 2,
|
displayPeriodCount: 2,
|
||||||
startingDayOfWeek: 1,
|
startingDayOfWeek: 1,
|
||||||
displayWeekNumbers: true
|
displayWeekNumbers: true,
|
||||||
},
|
},
|
||||||
dragged_item: null,
|
dragged_item: null,
|
||||||
current_tab: 0,
|
current_tab: 0,
|
||||||
meal_types: [],
|
meal_types: [],
|
||||||
current_context_menu_item: null,
|
current_context_menu_item: null,
|
||||||
options: {
|
options: {
|
||||||
displayPeriodUom: [{text: this.$t('Week'), value: 'week'}, {
|
displayPeriodUom: [
|
||||||
text: this.$t('Month'),
|
{ text: this.$t("Week"), value: "week" },
|
||||||
value: 'month'
|
{
|
||||||
}, {text: this.$t('Year'), value: 'year'}],
|
text: this.$t("Month"),
|
||||||
|
value: "month",
|
||||||
|
},
|
||||||
|
{ text: this.$t("Year"), value: "year" },
|
||||||
|
],
|
||||||
displayPeriodCount: [1, 2, 3],
|
displayPeriodCount: [1, 2, 3],
|
||||||
entryEditing: {
|
entryEditing: {
|
||||||
date: null,
|
date: null,
|
||||||
@ -307,23 +323,23 @@ export default {
|
|||||||
recipe: null,
|
recipe: null,
|
||||||
servings: 1,
|
servings: 1,
|
||||||
shared: [],
|
shared: [],
|
||||||
title: '',
|
title: "",
|
||||||
title_placeholder: this.$t('Title')
|
title_placeholder: this.$t("Title"),
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
shopping_list: [],
|
shopping_list: [],
|
||||||
current_period: null,
|
current_period: null,
|
||||||
entryEditing: {},
|
entryEditing: {},
|
||||||
edit_modal_show: false,
|
edit_modal_show: false,
|
||||||
ical_url: window.ICAL_URL
|
ical_url: window.ICAL_URL,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
modal_title: function () {
|
modal_title: function () {
|
||||||
if (this.entryEditing.id === -1) {
|
if (this.entryEditing.id === -1) {
|
||||||
return this.$t('Create_Meal_Plan_Entry')
|
return this.$t("Create_Meal_Plan_Entry")
|
||||||
} else {
|
} else {
|
||||||
return this.$t('Edit_Meal_Plan_Entry')
|
return this.$t("Edit_Meal_Plan_Entry")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
entryEditing_initial_recipe: function () {
|
entryEditing_initial_recipe: function () {
|
||||||
@ -348,7 +364,7 @@ export default {
|
|||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
detailed_items: function () {
|
detailed_items: function () {
|
||||||
return this.settings.displayPeriodUom === 'week';
|
return this.settings.displayPeriodUom === "week"
|
||||||
},
|
},
|
||||||
dayNames: function () {
|
dayNames: function () {
|
||||||
let options = []
|
let options = []
|
||||||
@ -361,7 +377,7 @@ export default {
|
|||||||
return this.getDefaultBrowserLocale
|
return this.getDefaultBrowserLocale
|
||||||
},
|
},
|
||||||
item_height: function () {
|
item_height: function () {
|
||||||
if (this.settings.displayPeriodUom === 'week') {
|
if (this.settings.displayPeriodUom === "week") {
|
||||||
return "10rem"
|
return "10rem"
|
||||||
} else {
|
} else {
|
||||||
return "1.6rem"
|
return "1.6rem"
|
||||||
@ -369,13 +385,13 @@ export default {
|
|||||||
},
|
},
|
||||||
iCalUrl() {
|
iCalUrl() {
|
||||||
if (this.current_period !== null) {
|
if (this.current_period !== null) {
|
||||||
let start = moment(this.current_period.periodStart).format('YYYY-MM-DD')
|
let start = moment(this.current_period.periodStart).format("YYYY-MM-DD")
|
||||||
let end = moment(this.current_period.periodEnd).format('YYYY-MM-DD')
|
let end = moment(this.current_period.periodEnd).format("YYYY-MM-DD")
|
||||||
return this.ical_url.replace(/12345/, start).replace(/6789/, end)
|
return this.ical_url.replace(/12345/, start).replace(/6789/, end)
|
||||||
} else {
|
} else {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$nextTick(function () {
|
this.$nextTick(function () {
|
||||||
@ -383,24 +399,24 @@ export default {
|
|||||||
this.settings = Object.assign({}, this.settings, this.$cookies.get(SETTINGS_COOKIE_NAME))
|
this.settings = Object.assign({}, this.settings, this.$cookies.get(SETTINGS_COOKIE_NAME))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.$root.$on('change', this.updateEmoji);
|
this.$root.$on("change", this.updateEmoji)
|
||||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
settings: {
|
settings: {
|
||||||
handler() {
|
handler() {
|
||||||
this.$cookies.set(SETTINGS_COOKIE_NAME, this.settings, '360d')
|
this.$cookies.set(SETTINGS_COOKIE_NAME, this.settings, "360d")
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
addToShopping(entry) {
|
addToShopping(entry) {
|
||||||
if (entry.originalItem.entry.recipe !== null) {
|
if (entry.originalItem.entry.recipe !== null) {
|
||||||
this.shopping_list.push(entry.originalItem.entry)
|
this.shopping_list.push(entry.originalItem.entry)
|
||||||
makeToast(this.$t("Success"), this.$t("Added_To_Shopping_List"), 'success')
|
makeToast(this.$t("Success"), this.$t("Added_To_Shopping_List"), "success")
|
||||||
} else {
|
} else {
|
||||||
makeToast(this.$t("Failure"), this.$t("Cannot_Add_Notes_To_Shopping"), 'danger')
|
makeToast(this.$t("Failure"), this.$t("Cannot_Add_Notes_To_Shopping"), "danger")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
saveShoppingList() {
|
saveShoppingList() {
|
||||||
@ -428,9 +444,12 @@ export default {
|
|||||||
newMealType() {
|
newMealType() {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.createMealType({name: this.$t('Meal_Type')}).then(e => {
|
apiClient
|
||||||
|
.createMealType({ name: this.$t("Meal_Type") })
|
||||||
|
.then((e) => {
|
||||||
this.periodChangedCallback(this.current_period)
|
this.periodChangedCallback(this.current_period)
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -439,18 +458,21 @@ export default {
|
|||||||
sortMealTypes() {
|
sortMealTypes() {
|
||||||
this.meal_types.forEach(function (element, index) {
|
this.meal_types.forEach(function (element, index) {
|
||||||
element.order = index
|
element.order = index
|
||||||
});
|
})
|
||||||
let updated = 0
|
let updated = 0
|
||||||
this.meal_types.forEach((meal_type) => {
|
this.meal_types.forEach((meal_type) => {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.updateMealType(meal_type.id, meal_type).then(e => {
|
apiClient
|
||||||
if (updated === (this.meal_types.length - 1)) {
|
.updateMealType(meal_type.id, meal_type)
|
||||||
|
.then((e) => {
|
||||||
|
if (updated === this.meal_types.length - 1) {
|
||||||
this.periodChangedCallback(this.current_period)
|
this.periodChangedCallback(this.current_period)
|
||||||
} else {
|
} else {
|
||||||
updated++
|
updated++
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -458,26 +480,32 @@ export default {
|
|||||||
editOrSaveMealType(index) {
|
editOrSaveMealType(index) {
|
||||||
let meal_type = this.meal_types[index]
|
let meal_type = this.meal_types[index]
|
||||||
if (meal_type.editing) {
|
if (meal_type.editing) {
|
||||||
this.$set(this.meal_types[index], 'editing', false)
|
this.$set(this.meal_types[index], "editing", false)
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.updateMealType(this.meal_types[index].id, this.meal_types[index]).then(e => {
|
apiClient
|
||||||
|
.updateMealType(this.meal_types[index].id, this.meal_types[index])
|
||||||
|
.then((e) => {
|
||||||
this.periodChangedCallback(this.current_period)
|
this.periodChangedCallback(this.current_period)
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.$set(this.meal_types[index], 'editing', true)
|
this.$set(this.meal_types[index], "editing", true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteMealType(index) {
|
deleteMealType(index) {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.destroyMealType(this.meal_types[index].id).then(e => {
|
apiClient
|
||||||
|
.destroyMealType(this.meal_types[index].id)
|
||||||
|
.then((e) => {
|
||||||
this.periodChangedCallback(this.current_period)
|
this.periodChangedCallback(this.current_period)
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -501,15 +529,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
setShowDate(d) {
|
setShowDate(d) {
|
||||||
this.showDate = d;
|
this.showDate = d
|
||||||
},
|
},
|
||||||
createEntryClick(data) {
|
createEntryClick(data) {
|
||||||
this.entryEditing = this.options.entryEditing
|
this.entryEditing = this.options.entryEditing
|
||||||
this.entryEditing.date = moment(data).format('YYYY-MM-DD')
|
this.entryEditing.date = moment(data).format("YYYY-MM-DD")
|
||||||
this.$bvModal.show(`edit-modal`)
|
this.$bvModal.show(`edit-modal`)
|
||||||
},
|
},
|
||||||
findEntry(id) {
|
findEntry(id) {
|
||||||
return this.plan_entries.filter(entry => {
|
return this.plan_entries.filter((entry) => {
|
||||||
return entry.id === id
|
return entry.id === id
|
||||||
})[0]
|
})[0]
|
||||||
},
|
},
|
||||||
@ -530,7 +558,7 @@ export default {
|
|||||||
moveEntryLeft(data) {
|
moveEntryLeft(data) {
|
||||||
this.plan_entries.forEach((entry) => {
|
this.plan_entries.forEach((entry) => {
|
||||||
if (entry.id === data.id) {
|
if (entry.id === data.id) {
|
||||||
entry.date = moment(entry.date).subtract(1, 'd')
|
entry.date = moment(entry.date).subtract(1, "d")
|
||||||
this.saveEntry(entry)
|
this.saveEntry(entry)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -538,7 +566,7 @@ export default {
|
|||||||
moveEntryRight(data) {
|
moveEntryRight(data) {
|
||||||
this.plan_entries.forEach((entry) => {
|
this.plan_entries.forEach((entry) => {
|
||||||
if (entry.id === data.id) {
|
if (entry.id === data.id) {
|
||||||
entry.date = moment(entry.date).add(1, 'd')
|
entry.date = moment(entry.date).add(1, "d")
|
||||||
this.saveEntry(entry)
|
this.saveEntry(entry)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -548,9 +576,12 @@ export default {
|
|||||||
if (entry.id === data.id) {
|
if (entry.id === data.id) {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.destroyMealPlan(entry.id).then(e => {
|
apiClient
|
||||||
|
.destroyMealPlan(entry.id)
|
||||||
|
.then((e) => {
|
||||||
list.splice(index, 1)
|
list.splice(index, 1)
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -566,7 +597,7 @@ export default {
|
|||||||
openEntryEdit(entry) {
|
openEntryEdit(entry) {
|
||||||
this.$bvModal.show(`edit-modal`)
|
this.$bvModal.show(`edit-modal`)
|
||||||
this.entryEditing = entry
|
this.entryEditing = entry
|
||||||
this.entryEditing.date = moment(entry.date).format('YYYY-MM-DD')
|
this.entryEditing.date = moment(entry.date).format("YYYY-MM-DD")
|
||||||
if (this.entryEditing.recipe != null) {
|
if (this.entryEditing.recipe != null) {
|
||||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||||
}
|
}
|
||||||
@ -575,12 +606,14 @@ export default {
|
|||||||
this.current_period = date
|
this.current_period = date
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.listMealPlans({
|
apiClient
|
||||||
|
.listMealPlans({
|
||||||
query: {
|
query: {
|
||||||
from_date: moment(date.periodStart).format('YYYY-MM-DD'),
|
from_date: moment(date.periodStart).format("YYYY-MM-DD"),
|
||||||
to_date: moment(date.periodEnd).format('YYYY-MM-DD')
|
to_date: moment(date.periodEnd).format("YYYY-MM-DD"),
|
||||||
}
|
},
|
||||||
}).then(result => {
|
})
|
||||||
|
.then((result) => {
|
||||||
this.plan_entries = result.data
|
this.plan_entries = result.data
|
||||||
})
|
})
|
||||||
this.refreshMealTypes()
|
this.refreshMealTypes()
|
||||||
@ -588,7 +621,7 @@ export default {
|
|||||||
refreshMealTypes() {
|
refreshMealTypes() {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.listMealTypes().then(result => {
|
apiClient.listMealTypes().then((result) => {
|
||||||
result.data.forEach((meal_type) => {
|
result.data.forEach((meal_type) => {
|
||||||
meal_type.editing = false
|
meal_type.editing = false
|
||||||
})
|
})
|
||||||
@ -600,7 +633,7 @@ export default {
|
|||||||
|
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.updateMealPlan(entry.id, entry).catch(error => {
|
apiClient.updateMealPlan(entry.id, entry).catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -609,48 +642,52 @@ export default {
|
|||||||
|
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.createMealPlan(entry).catch(error => {
|
apiClient
|
||||||
|
.createMealPlan(entry)
|
||||||
|
.catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
}).then((entry_result) => {
|
})
|
||||||
|
.then((entry_result) => {
|
||||||
this.plan_entries.push(entry_result.data)
|
this.plan_entries.push(entry_result.data)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
buildItem(plan_entry) {
|
buildItem(plan_entry) {
|
||||||
//dirty hack to order items within a day
|
//dirty hack to order items within a day
|
||||||
let date = moment(plan_entry.date).add(plan_entry.meal_type.order, 'm')
|
let date = moment(plan_entry.date).add(plan_entry.meal_type.order, "m")
|
||||||
return {
|
return {
|
||||||
id: plan_entry.id,
|
id: plan_entry.id,
|
||||||
startDate: date,
|
startDate: date,
|
||||||
endDate: date,
|
endDate: date,
|
||||||
entry: plan_entry
|
entry: plan_entry,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
directives: {
|
directives: {
|
||||||
hover: {
|
hover: {
|
||||||
inserted: function (el) {
|
inserted: function (el) {
|
||||||
el.addEventListener('mouseenter', () => {
|
el.addEventListener("mouseenter", () => {
|
||||||
el.classList.add("shadow")
|
el.classList.add("shadow")
|
||||||
});
|
})
|
||||||
el.addEventListener('mouseleave', () => {
|
el.addEventListener("mouseleave", () => {
|
||||||
el.classList.remove("shadow")
|
el.classList.remove("shadow")
|
||||||
});
|
})
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.slide-fade-enter-active {
|
.slide-fade-enter-active {
|
||||||
transition: all .3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-fade-leave-active {
|
.slide-fade-leave-active {
|
||||||
transition: all .1s cubic-bezier(1.0, 0.5, 0.8, 1.0);
|
transition: all 0.1s cubic-bezier(1, 0.5, 0.8, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-fade-enter, .slide-fade-leave-to {
|
.slide-fade-enter,
|
||||||
|
.slide-fade-leave-to {
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
@ -19,20 +19,15 @@
|
|||||||
<!-- <span><b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button></span> -->
|
<!-- <span><b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button></span> -->
|
||||||
<model-menu />
|
<model-menu />
|
||||||
<span>{{ this.this_model.name }}</span>
|
<span>{{ this.this_model.name }}</span>
|
||||||
<span v-if="this_model.name !== 'Step'"
|
<span v-if="apiName !== 'Step'">
|
||||||
><b-button variant="link" @click="startAction({ action: 'new' })"><i class="fas fa-plus-circle fa-2x"></i></b-button></span
|
<b-button variant="link" @click="startAction({ action: 'new' })">
|
||||||
|
<i class="fas fa-plus-circle fa-2x"></i>
|
||||||
|
</b-button> </span
|
||||||
><!-- TODO add proper field to model config to determine if create should be available or not -->
|
><!-- TODO add proper field to model config to determine if create should be available or not -->
|
||||||
</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 +37,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>
|
||||||
@ -104,7 +72,7 @@ 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";
|
||||||
|
|
||||||
@ -146,6 +114,9 @@ export default {
|
|||||||
// TODO this is not necessarily bad but maybe there are better options to do this
|
// TODO this is not necessarily bad but maybe there are better options to do this
|
||||||
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.header_component_name}`)
|
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.header_component_name}`)
|
||||||
},
|
},
|
||||||
|
apiName() {
|
||||||
|
return this.this_model?.apiName
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
// value is passed from lists.py
|
// value is passed from lists.py
|
||||||
@ -236,6 +207,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)
|
||||||
@ -244,7 +216,6 @@ export default {
|
|||||||
if (e !== "cancel") {
|
if (e !== "cancel") {
|
||||||
switch (this.this_action) {
|
switch (this.this_action) {
|
||||||
case this.Actions.DELETE:
|
case this.Actions.DELETE:
|
||||||
console.log("delete")
|
|
||||||
this.deleteThis(this.this_item.id)
|
this.deleteThis(this.this_item.id)
|
||||||
break
|
break
|
||||||
case this.Actions.CREATE:
|
case this.Actions.CREATE:
|
||||||
@ -263,7 +234,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)
|
||||||
@ -315,6 +286,16 @@ export default {
|
|||||||
// this creates a deep copy to make sure that columns stay independent
|
// this creates a deep copy to make sure that columns stay independent
|
||||||
this.items_right = [{ ...item }].concat(this.destroyCard(item?.id, this.items_right))
|
this.items_right = [{ ...item }].concat(this.destroyCard(item?.id, this.items_right))
|
||||||
},
|
},
|
||||||
|
// this currently assumes shopping is only applicable on FOOD model
|
||||||
|
addShopping: function (food) {
|
||||||
|
let api = new ApiApiFactory()
|
||||||
|
food.shopping = true
|
||||||
|
api.createShoppingListEntry({ food: food, amount: 1 }).then(() => {
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
this.refreshCard(food, this.items_left)
|
||||||
|
this.refreshCard({ ...food }, this.items_right)
|
||||||
|
})
|
||||||
|
},
|
||||||
updateThis: function (item) {
|
updateThis: function (item) {
|
||||||
this.refreshThis(item.id)
|
this.refreshThis(item.id)
|
||||||
},
|
},
|
||||||
@ -334,8 +315,7 @@ export default {
|
|||||||
this.genericAPI(this.this_model, this.Actions.MOVE, { source: source_id, target: target_id })
|
this.genericAPI(this.this_model, this.Actions.MOVE, { source: source_id, target: target_id })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.moveUpdateItem(source_id, target_id)
|
this.moveUpdateItem(source_id, target_id)
|
||||||
// TODO make standard toast
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_MOVE)
|
||||||
this.makeToast(this.$t("Success"), "Succesfully moved resource", "success")
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log(err)
|
console.log(err)
|
||||||
@ -374,8 +354,7 @@ export default {
|
|||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.mergeUpdateItem(source_id, target_id)
|
this.mergeUpdateItem(source_id, target_id)
|
||||||
// TODO make standard toast
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_MERGE)
|
||||||
this.makeToast(this.$t("Success"), "Succesfully merged resource", "success")
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
//TODO error checking not working with OpenAPI methods
|
//TODO error checking not working with OpenAPI methods
|
||||||
@ -429,7 +408,7 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log(err)
|
console.log(err)
|
||||||
this.makeToast(this.$t("Error"), err.bodyText, "danger")
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getRecipes: function (col, item) {
|
getRecipes: function (col, item) {
|
||||||
|
@ -630,7 +630,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) {
|
||||||
|
@ -238,7 +238,7 @@ Vue.use(VueCookies)
|
|||||||
|
|
||||||
import { ApiMixin, ResolveUrlMixin } from "@/utils/utils"
|
import { ApiMixin, ResolveUrlMixin } from "@/utils/utils"
|
||||||
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner" // is this deprecated?
|
import LoadingSpinner from "@/components/LoadingSpinner" // TODO: is this deprecated?
|
||||||
|
|
||||||
import RecipeCard from "@/components/RecipeCard"
|
import RecipeCard from "@/components/RecipeCard"
|
||||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||||
|
@ -36,8 +36,10 @@
|
|||||||
<i class="fas fa-user-clock fa-2x text-primary"></i>
|
<i class="fas fa-user-clock fa-2x text-primary"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto" style="padding-right: 4px">
|
<div class="my-auto" style="padding-right: 4px">
|
||||||
<span class="text-primary"><b>{{ $t('Preparation') }}</b></span><br/>
|
<span class="text-primary"
|
||||||
{{ recipe.working_time }} {{ $t('min') }}
|
><b>{{ $t("Preparation") }}</b></span
|
||||||
|
><br />
|
||||||
|
{{ recipe.working_time }} {{ $t("min") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -48,8 +50,10 @@
|
|||||||
<i class="far fa-clock fa-2x text-primary"></i>
|
<i class="far fa-clock fa-2x text-primary"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto" style="padding-right: 4px">
|
<div class="my-auto" style="padding-right: 4px">
|
||||||
<span class="text-primary"><b>{{ $t('Waiting') }}</b></span><br/>
|
<span class="text-primary"
|
||||||
{{ recipe.waiting_time }} {{ $t('min') }}
|
><b>{{ $t("Waiting") }}</b></span
|
||||||
|
><br />
|
||||||
|
{{ recipe.waiting_time }} {{ $t("min") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -62,12 +66,21 @@
|
|||||||
<div class="my-auto" style="padding-right: 4px">
|
<div class="my-auto" style="padding-right: 4px">
|
||||||
<input
|
<input
|
||||||
style="text-align: right; border-width: 0px; border: none; padding: 0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
|
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"
|
value="1"
|
||||||
type="number" class="form-control form-control-lg" v-model.number="servings"/>
|
maxlength="3"
|
||||||
|
min="0"
|
||||||
|
type="number"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
v-model.number="servings"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-auto">
|
<div class="my-auto">
|
||||||
<span class="text-primary"><b><template v-if="recipe.servings_text === ''">{{ $t('Servings') }}</template><template
|
<span class="text-primary"
|
||||||
v-else>{{ recipe.servings_text }}</template></b></span>
|
><b
|
||||||
|
><template v-if="recipe.servings_text === ''">{{ $t("Servings") }}</template
|
||||||
|
><template v-else>{{ recipe.servings_text }}</template></b
|
||||||
|
></span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -80,39 +93,20 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<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="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
|
||||||
<div class="card border-primary">
|
<ingredients-card
|
||||||
<div class="card-body">
|
:recipe="recipe.id"
|
||||||
<div class="row">
|
:steps="recipe.steps"
|
||||||
<div class="col col-md-8">
|
:ingredient_factor="ingredient_factor"
|
||||||
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h4>
|
:servings="servings"
|
||||||
</div>
|
:header="true"
|
||||||
</div>
|
@checked-state-changed="updateIngredientCheckedState"
|
||||||
<br/>
|
/>
|
||||||
<template v-for="s in recipe.steps" v-bind:key="s.id">
|
|
||||||
<div class="row" >
|
|
||||||
<div class="col-md-12">
|
|
||||||
<template v-if="s.show_as_header && s.name !== '' && s.ingredients.length > 0">
|
|
||||||
<b v-bind:key="s.id">{{s.name}}</b>
|
|
||||||
</template>
|
|
||||||
<table class="table table-sm">
|
|
||||||
<template v-for="i in s.ingredients" :key="i.id">
|
|
||||||
<ingredient-component :ingredient="i" :ingredient_factor="ingredient_factor"
|
|
||||||
@checked-state-changed="updateIngredientCheckedState"></ingredient-component>
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
|
<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-12">
|
<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" />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
@ -122,24 +116,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!recipe.internal">
|
<template v-if="!recipe.internal">
|
||||||
<div v-if="recipe.file_path.includes('.pdf')">
|
<div v-if="recipe.file_path.includes('.pdf')">
|
||||||
<PdfViewer :recipe="recipe"></PdfViewer>
|
<PdfViewer :recipe="recipe"></PdfViewer>
|
||||||
</div>
|
</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')">
|
||||||
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>
|
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
<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"
|
<step-component
|
||||||
@update-start-time="updateStartTime" @checked-state-changed="updateIngredientCheckedState"></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>
|
</div>
|
||||||
|
|
||||||
@ -147,57 +144,48 @@
|
|||||||
|
|
||||||
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
|
<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">
|
<div class="col col-md-12">
|
||||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t('Report Abuse') }}</a>
|
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t("Report Abuse") }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 RecipeContextMenu from "@/components/RecipeContextMenu"
|
||||||
import RecipeContextMenu from "@/components/RecipeContextMenu";
|
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
|
||||||
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
|
|
||||||
import Ingredient from "@/components/IngredientComponent";
|
|
||||||
|
|
||||||
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 moment from 'moment'
|
import moment from "moment"
|
||||||
import Keywords from "@/components/KeywordsComponent";
|
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||||
import AddRecipeToBook from "@/components/AddRecipeToBook";
|
import RecipeRating from "@/components/RecipeRating"
|
||||||
import RecipeRating from "@/components/RecipeRating";
|
import LastCooked from "@/components/LastCooked"
|
||||||
import LastCooked from "@/components/LastCooked";
|
import IngredientsCard from "@/components/IngredientsCard"
|
||||||
import IngredientComponent from "@/components/IngredientComponent";
|
import StepComponent from "@/components/StepComponent"
|
||||||
import StepComponent from "@/components/StepComponent";
|
import KeywordsComponent from "@/components/KeywordsComponent"
|
||||||
import KeywordsComponent from "@/components/KeywordsComponent";
|
import NutritionComponent from "@/components/NutritionComponent"
|
||||||
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,
|
|
||||||
ToastMixin,
|
|
||||||
],
|
|
||||||
components: {
|
components: {
|
||||||
LastCooked,
|
LastCooked,
|
||||||
RecipeRating,
|
RecipeRating,
|
||||||
PdfViewer,
|
PdfViewer,
|
||||||
ImageViewer,
|
ImageViewer,
|
||||||
IngredientComponent,
|
IngredientsCard,
|
||||||
StepComponent,
|
StepComponent,
|
||||||
RecipeContextMenu,
|
RecipeContextMenu,
|
||||||
NutritionComponent,
|
NutritionComponent,
|
||||||
@ -217,7 +205,7 @@ export default {
|
|||||||
ingredient_count: 0,
|
ingredient_count: 0,
|
||||||
servings: 1,
|
servings: 1,
|
||||||
start_time: "",
|
start_time: "",
|
||||||
share_uid: window.SHARE_UID
|
share_uid: window.SHARE_UID,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@ -226,8 +214,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
loadRecipe: function (recipe_id) {
|
loadRecipe: function (recipe_id) {
|
||||||
apiLoadRecipe(recipe_id).then(recipe => {
|
apiLoadRecipe(recipe_id).then((recipe) => {
|
||||||
|
|
||||||
if (window.USER_SERVINGS !== 0) {
|
if (window.USER_SERVINGS !== 0) {
|
||||||
recipe.servings = window.USER_SERVINGS
|
recipe.servings = window.USER_SERVINGS
|
||||||
}
|
}
|
||||||
@ -238,7 +225,7 @@ export default {
|
|||||||
this.ingredient_count += step.ingredients.length
|
this.ingredient_count += step.ingredients.length
|
||||||
|
|
||||||
for (let ingredient of step.ingredients) {
|
for (let ingredient of step.ingredients) {
|
||||||
this.$set(ingredient, 'checked', false)
|
this.$set(ingredient, "checked", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
step.time_offset = total_time
|
step.time_offset = total_time
|
||||||
@ -247,7 +234,7 @@ export default {
|
|||||||
|
|
||||||
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
||||||
if (total_time > 0) {
|
if (total_time > 0) {
|
||||||
this.start_time = moment().format('yyyy-MM-DDTHH:mm')
|
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.recipe = recipe
|
this.recipe = recipe
|
||||||
@ -261,14 +248,13 @@ export default {
|
|||||||
for (let step of this.recipe.steps) {
|
for (let step of this.recipe.steps) {
|
||||||
for (let ingredient of step.ingredients) {
|
for (let ingredient of step.ingredients) {
|
||||||
if (ingredient.id === e.id) {
|
if (ingredient.id === e.id) {
|
||||||
this.$set(ingredient, 'checked', !ingredient.checked)
|
this.$set(ingredient, "checked", !ingredient.checked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
1187
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
1187
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
File diff suppressed because it is too large
Load Diff
17
vue/src/apps/ShoppingListView/main.js
Normal file
17
vue/src/apps/ShoppingListView/main.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import i18n from "@/i18n"
|
||||||
|
import Vue from "vue"
|
||||||
|
import App from "./ShoppingListView"
|
||||||
|
|
||||||
|
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")
|
@ -1,53 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- TODO: Deprecate -->
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<h2>{{ $t('Supermarket') }}</h2>
|
<h2>{{ $t("Supermarket") }}</h2>
|
||||||
|
|
||||||
<multiselect v-model="selected_supermarket" track-by="id" label="name"
|
<multiselect v-model="selected_supermarket" track-by="id" label="name" :options="supermarkets" @input="selectedSupermarketChanged"> </multiselect>
|
||||||
:options="supermarkets" @input="selectedSupermarketChanged">
|
|
||||||
</multiselect>
|
|
||||||
|
|
||||||
<b-button class="btn btn-primary btn-block" style="margin-top: 1vh" v-b-modal.modal-supermarket>
|
<b-button class="btn btn-primary btn-block" style="margin-top: 1vh" v-b-modal.modal-supermarket>
|
||||||
{{ $t('Edit') }}
|
{{ $t("Edit") }}
|
||||||
</b-button>
|
|
||||||
<b-button class="btn btn-success btn-block" @click="selected_supermarket = {new:true, name:''}"
|
|
||||||
v-b-modal.modal-supermarket>{{ $t('New') }}
|
|
||||||
</b-button>
|
</b-button>
|
||||||
|
<b-button class="btn btn-success btn-block" @click="selected_supermarket = { new: true, name: '' }" v-b-modal.modal-supermarket>{{ $t("New") }} </b-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr />
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<h4>{{ $t('Categories') }}
|
<h4>
|
||||||
<button class="btn btn-success btn-sm" @click="selected_category = {new:true, name:''}"
|
{{ $t("Categories") }}
|
||||||
v-b-modal.modal-category>{{ $t('New') }}
|
<button class="btn btn-success btn-sm" @click="selected_category = { new: true, name: '' }" v-b-modal.modal-category>{{ $t("New") }}</button>
|
||||||
</button>
|
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<draggable :list="selectable_categories" group="supermarket_categories"
|
<draggable :list="selectable_categories" group="supermarket_categories" :empty-insert-threshold="10">
|
||||||
:empty-insert-threshold="10">
|
|
||||||
<div v-for="c in selectable_categories" :key="c.id">
|
<div v-for="c in selectable_categories" :key="c.id">
|
||||||
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
|
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<h4>{{ $t('Selected') }} {{ $t('Categories') }}</h4>
|
<h4>{{ $t("Selected") }} {{ $t("Categories") }}</h4>
|
||||||
|
|
||||||
<draggable :list="supermarket_categories" group="supermarket_categories"
|
<draggable :list="supermarket_categories" group="supermarket_categories" :empty-insert-threshold="10" @change="selectedCategoriesChanged">
|
||||||
:empty-insert-threshold="10" @change="selectedCategoriesChanged">
|
|
||||||
<div v-for="c in supermarket_categories" :key="c.id">
|
<div v-for="c in supermarket_categories" :key="c.id">
|
||||||
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
|
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</draggable>
|
</draggable>
|
||||||
</div>
|
</div>
|
||||||
@ -56,52 +43,45 @@
|
|||||||
<!-- EDIT MODALS -->
|
<!-- EDIT MODALS -->
|
||||||
<b-modal id="modal-supermarket" v-bind:title="$t('Supermarket')" @ok="supermarketModalOk()">
|
<b-modal id="modal-supermarket" v-bind:title="$t('Supermarket')" @ok="supermarketModalOk()">
|
||||||
<label v-if="selected_supermarket !== undefined">
|
<label v-if="selected_supermarket !== undefined">
|
||||||
{{ $t('Name') }}
|
{{ $t("Name") }}
|
||||||
<b-input v-model="selected_supermarket.name"></b-input>
|
<b-input v-model="selected_supermarket.name"></b-input>
|
||||||
|
|
||||||
</label>
|
</label>
|
||||||
</b-modal>
|
</b-modal>
|
||||||
|
|
||||||
<b-modal id="modal-category" v-bind:title="$t('Category')" @ok="categoryModalOk()">
|
<b-modal id="modal-category" v-bind:title="$t('Category')" @ok="categoryModalOk()">
|
||||||
<label v-if="selected_category !== undefined">
|
<label v-if="selected_category !== undefined">
|
||||||
{{ $t('Name') }}
|
{{ $t("Name") }}
|
||||||
<b-input v-model="selected_category.name"></b-input>
|
<b-input v-model="selected_category.name"></b-input>
|
||||||
|
|
||||||
</label>
|
</label>
|
||||||
</b-modal>
|
</b-modal>
|
||||||
|
|
||||||
</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 {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
|
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
|
||||||
|
|
||||||
|
import { ApiApiFactory } from "@/utils/openapi/api.ts"
|
||||||
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
import draggable from 'vuedraggable'
|
import draggable from "vuedraggable"
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from "axios"
|
||||||
import Multiselect from "vue-multiselect";
|
import Multiselect from "vue-multiselect"
|
||||||
|
|
||||||
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
|
axios.defaults.xsrfHeaderName = "X-CSRFToken"
|
||||||
axios.defaults.xsrfCookieName = 'csrftoken'
|
axios.defaults.xsrfCookieName = "csrftoken"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SupermarketView',
|
name: "SupermarketView",
|
||||||
mixins: [
|
mixins: [ResolveUrlMixin, ToastMixin],
|
||||||
ResolveUrlMixin,
|
|
||||||
ToastMixin,
|
|
||||||
],
|
|
||||||
components: {
|
components: {
|
||||||
Multiselect,
|
Multiselect,
|
||||||
draggable
|
draggable,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -111,7 +91,6 @@ export default {
|
|||||||
selected_supermarket: {},
|
selected_supermarket: {},
|
||||||
selected_category: {},
|
selected_category: {},
|
||||||
|
|
||||||
|
|
||||||
selectable_categories: [],
|
selectable_categories: [],
|
||||||
supermarket_categories: [],
|
supermarket_categories: [],
|
||||||
}
|
}
|
||||||
@ -123,10 +102,10 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
loadInitial: function() {
|
loadInitial: function() {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
apiClient.listSupermarkets().then(results => {
|
apiClient.listSupermarkets().then((results) => {
|
||||||
this.supermarkets = results.data
|
this.supermarkets = results.data
|
||||||
})
|
})
|
||||||
apiClient.listSupermarketCategorys().then(results => {
|
apiClient.listSupermarketCategorys().then((results) => {
|
||||||
this.categories = results.data
|
this.categories = results.data
|
||||||
this.selectable_categories = this.categories
|
this.selectable_categories = this.categories
|
||||||
})
|
})
|
||||||
@ -134,21 +113,24 @@ export default {
|
|||||||
selectedCategoriesChanged: function(data) {
|
selectedCategoriesChanged: function(data) {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
if ('removed' in data) {
|
if ("removed" in data) {
|
||||||
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === data.removed.element.id)[0]
|
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === data.removed.element.id)[0]
|
||||||
apiClient.destroySupermarketCategoryRelation(relation.id)
|
apiClient.destroySupermarketCategoryRelation(relation.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('added' in data) {
|
if ("added" in data) {
|
||||||
apiClient.createSupermarketCategoryRelation({
|
apiClient
|
||||||
|
.createSupermarketCategoryRelation({
|
||||||
category: data.added.element,
|
category: data.added.element,
|
||||||
supermarket: this.selected_supermarket.id, order: 0
|
supermarket: this.selected_supermarket.id,
|
||||||
}).then(results => {
|
order: 0,
|
||||||
|
})
|
||||||
|
.then((results) => {
|
||||||
this.selected_supermarket.category_to_supermarket.push(results.data)
|
this.selected_supermarket.category_to_supermarket.push(results.data)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('moved' in data || 'added' in data) {
|
if ("moved" in data || "added" in data) {
|
||||||
this.supermarket_categories.forEach((element, index) => {
|
this.supermarket_categories.forEach((element, index) => {
|
||||||
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === element.id)[0]
|
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === element.id)[0]
|
||||||
console.log(relation)
|
console.log(relation)
|
||||||
@ -164,38 +146,33 @@ export default {
|
|||||||
this.supermarket_categories.push(i.category)
|
this.supermarket_categories.push(i.category)
|
||||||
this.selectable_categories = this.selectable_categories.filter(function(el) {
|
this.selectable_categories = this.selectable_categories.filter(function(el) {
|
||||||
return el.id !== i.category.id
|
return el.id !== i.category.id
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
supermarketModalOk: function() {
|
supermarketModalOk: function() {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
if (this.selected_supermarket.new) {
|
if (this.selected_supermarket.new) {
|
||||||
apiClient.createSupermarket({name: this.selected_supermarket.name}).then(results => {
|
apiClient.createSupermarket({ name: this.selected_supermarket.name }).then((results) => {
|
||||||
this.selected_supermarket = undefined
|
this.selected_supermarket = undefined
|
||||||
this.loadInitial()
|
this.loadInitial()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
apiClient.partialUpdateSupermarket(this.selected_supermarket.id, { name: this.selected_supermarket.name })
|
apiClient.partialUpdateSupermarket(this.selected_supermarket.id, { name: this.selected_supermarket.name })
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
categoryModalOk: function() {
|
categoryModalOk: function() {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
if (this.selected_category.new) {
|
if (this.selected_category.new) {
|
||||||
apiClient.createSupermarketCategory({name: this.selected_category.name}).then(results => {
|
apiClient.createSupermarketCategory({ name: this.selected_category.name }).then((results) => {
|
||||||
this.selected_category = {}
|
this.selected_category = {}
|
||||||
this.loadInitial()
|
this.loadInitial()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
apiClient.partialUpdateSupermarketCategory(this.selected_category.id, { name: this.selected_category.name })
|
apiClient.partialUpdateSupermarketCategory(this.selected_category.id, { name: this.selected_category.name })
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style></style>
|
||||||
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
@ -1,40 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
<linked-recipe v-if="linkedRecipe"
|
<linked-recipe v-if="linkedRecipe" :item="item" />
|
||||||
:item="item"/>
|
<icon-badge v-if="Icon" :item="item" />
|
||||||
<icon-badge v-if="Icon"
|
<on-hand-badge v-if="OnHand" :item="item" />
|
||||||
: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 },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {}
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
},
|
},
|
||||||
|
mounted() {},
|
||||||
computed: {
|
computed: {
|
||||||
linkedRecipe: function () {
|
linkedRecipe: function () {
|
||||||
return this.model?.badges?.linked_recipe ?? false
|
return this.model?.badges?.linked_recipe ?? false
|
||||||
},
|
},
|
||||||
Icon: function () {
|
Icon: function () {
|
||||||
return this.model?.badges?.icon ?? false
|
return this.model?.badges?.icon ?? false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
watch: {
|
OnHand: function () {
|
||||||
|
return this.model?.badges?.food_onhand ?? false
|
||||||
},
|
},
|
||||||
methods: {
|
Shopping: function () {
|
||||||
}
|
return this.model?.badges?.shopping ?? false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {},
|
||||||
|
methods: {},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
@ -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>
|
||||||
|
|
||||||
|
45
vue/src/components/Badges/OnHand.vue
Normal file
45
vue/src/components/Badges/OnHand.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<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.food_onhand
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
"item.food_onhand": function (newVal, oldVal) {
|
||||||
|
this.onhand = newVal
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleOnHand() {
|
||||||
|
let params = { id: this.item.id, food_onhand: !this.onhand }
|
||||||
|
this.genericAPI(this.Models.FOOD, this.Actions.UPDATE, params).then(() => {
|
||||||
|
this.onhand = !this.onhand
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
88
vue/src/components/Badges/Shopping.vue
Normal file
88
vue/src/components/Badges/Shopping.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<span>
|
||||||
|
<b-button class="btn text-decoration-none px-1 border-0" variant="link" :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 v-if="shopping" :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 },
|
||||||
|
},
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
shopping: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// let random = [true, false,]
|
||||||
|
this.shopping = this.item?.shopping //?? random[Math.floor(Math.random() * random.length)]
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
DeleteConfirmation() {
|
||||||
|
return this.$t("DeleteShoppingConfirm", { food: this.item.name })
|
||||||
|
},
|
||||||
|
ShowConfirmation() {
|
||||||
|
if (this.shopping) {
|
||||||
|
return "shopping" + this.item.id
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
"item.shopping": function (newVal, oldVal) {
|
||||||
|
this.shopping = newVal
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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>
|
62
vue/src/components/Buttons/CopyToClipboard.vue
Normal file
62
vue/src/components/Buttons/CopyToClipboard.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<a v-if="!button" class="dropdown-item" @click="clipboard"><i :class="icon"></i> {{ label }}</a>
|
||||||
|
<b-button v-if="button" @click="clipboard">{{ label }}</b-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { makeToast } from "@/utils/utils"
|
||||||
|
export default {
|
||||||
|
name: "CopyToClipboard",
|
||||||
|
|
||||||
|
props: {
|
||||||
|
items: { type: Array },
|
||||||
|
icon: { type: String },
|
||||||
|
label: { type: String },
|
||||||
|
button: { type: Boolean, default: false },
|
||||||
|
settings: { type: Object },
|
||||||
|
format: { type: String, default: "delim" },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clipboard: function () {
|
||||||
|
let text = ""
|
||||||
|
switch (this.format) {
|
||||||
|
case "delim":
|
||||||
|
text = this.delimited()
|
||||||
|
break
|
||||||
|
case "table":
|
||||||
|
text = this.table()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(makeToast(this.$t("Success"), this.$t("SuccessClipboard"), "success"))
|
||||||
|
},
|
||||||
|
delimited: function () {
|
||||||
|
let csvContent = ""
|
||||||
|
let delim = this.settings.csv_delim || ","
|
||||||
|
let prefix = this.settings.csv_prefix || ""
|
||||||
|
csvContent += [prefix + Object.keys(this.items[0]).join(delim), ...this.items.map((x) => prefix + Object.values(x).join(delim))].join("\n").replace(/(^\[)|(\]$)/gm, "")
|
||||||
|
return csvContent
|
||||||
|
},
|
||||||
|
table: function () {
|
||||||
|
let table = ""
|
||||||
|
let delim = "|"
|
||||||
|
table += [
|
||||||
|
delim + Object.keys(this.items[0]).join(delim) + delim,
|
||||||
|
delim +
|
||||||
|
Object.keys(this.items[0])
|
||||||
|
.map((x) => {
|
||||||
|
return ":---"
|
||||||
|
})
|
||||||
|
.join(delim) +
|
||||||
|
delim,
|
||||||
|
...this.items.map((x) => delim + Object.values(x).join(delim) + delim),
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
.replace(/(^\[)|(\]$)/gm, "")
|
||||||
|
return table
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
33
vue/src/components/Buttons/DownloadCSV.vue
Normal file
33
vue/src/components/Buttons/DownloadCSV.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
|
||||||
|
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "DownloadCSV",
|
||||||
|
|
||||||
|
props: {
|
||||||
|
items: { type: Array },
|
||||||
|
name: { type: String },
|
||||||
|
icon: { type: String },
|
||||||
|
label: { type: String },
|
||||||
|
button: { type: Boolean, default: false },
|
||||||
|
delim: { type: String, default: "," },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
downloadFile() {
|
||||||
|
let csvContent = "data:text/csv;charset=utf-8,"
|
||||||
|
csvContent += [Object.keys(this.items[0]).join(this.delim), ...this.items.map((x) => Object.values(x).join(this.delim))].join("\n").replace(/(^\[)|(\]$)/gm, "")
|
||||||
|
|
||||||
|
const data = encodeURI(csvContent)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.setAttribute("href", data)
|
||||||
|
link.setAttribute("download", "export.csv")
|
||||||
|
link.click()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
32
vue/src/components/Buttons/DownloadPDF.vue
Normal file
32
vue/src/components/Buttons/DownloadPDF.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
|
||||||
|
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import html2pdf from "html2pdf.js"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "DownloadPDF",
|
||||||
|
|
||||||
|
props: {
|
||||||
|
dom: { type: String },
|
||||||
|
name: { type: String },
|
||||||
|
icon: { type: String },
|
||||||
|
label: { type: String },
|
||||||
|
button: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
downloadFile() {
|
||||||
|
const doc = document.querySelector(this.dom)
|
||||||
|
var options = {
|
||||||
|
margin: 1,
|
||||||
|
filename: this.name,
|
||||||
|
}
|
||||||
|
html2pdf().from(doc).set(options).save()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,28 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="context-menu" ref="popper" v-show="isVisible" tabindex="-1" v-click-outside="close" @contextmenu.capture.prevent>
|
||||||
class="context-menu"
|
|
||||||
ref="popper"
|
|
||||||
v-show="isVisible"
|
|
||||||
tabindex="-1"
|
|
||||||
v-click-outside="close"
|
|
||||||
@contextmenu.capture.prevent>
|
|
||||||
<ul class="dropdown-menu" role="menu">
|
<ul class="dropdown-menu" role="menu">
|
||||||
<slot :contextData="contextData" name="menu" />
|
<slot :contextData="contextData" name="menu" />
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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: {},
|
components: {},
|
||||||
@ -30,49 +24,48 @@ export default {
|
|||||||
return {
|
return {
|
||||||
opened: false,
|
opened: false,
|
||||||
contextData: {},
|
contextData: {},
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
ClickOutside,
|
ClickOutside,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isVisible() {
|
isVisible() {
|
||||||
return this.opened;
|
return this.opened
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
open(evt, contextData) {
|
open(evt, contextData) {
|
||||||
this.opened = true;
|
this.opened = true
|
||||||
this.contextData = contextData;
|
this.contextData = contextData
|
||||||
|
|
||||||
if (this.popper) {
|
if (this.popper) {
|
||||||
this.popper.destroy();
|
this.popper.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
|
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
|
||||||
placement: 'right-start',
|
placement: "right-start",
|
||||||
modifiers: {
|
modifiers: {
|
||||||
preventOverflow: {
|
preventOverflow: {
|
||||||
boundariesElement: document.querySelector(this.boundariesElement),
|
boundariesElement: document.querySelector(this.boundariesElement),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.popper.scheduleUpdate();
|
this.popper.scheduleUpdate()
|
||||||
});
|
})
|
||||||
|
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
this.opened = false;
|
this.opened = false
|
||||||
this.contextData = null;
|
this.contextData = null
|
||||||
},
|
},
|
||||||
referenceObject(evt) {
|
referenceObject(evt) {
|
||||||
const left = evt.clientX;
|
const left = evt.clientX
|
||||||
const top = evt.clientY;
|
const top = evt.clientY
|
||||||
const right = left + 1;
|
const right = left + 1
|
||||||
const bottom = top + 1;
|
const bottom = top + 1
|
||||||
const clientWidth = 1;
|
const clientWidth = 1
|
||||||
const clientHeight = 1;
|
const clientHeight = 1
|
||||||
|
|
||||||
function getBoundingClientRect() {
|
function getBoundingClientRect() {
|
||||||
return {
|
return {
|
||||||
@ -80,24 +73,23 @@ export default {
|
|||||||
top,
|
top,
|
||||||
right,
|
right,
|
||||||
bottom,
|
bottom,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const obj = {
|
const obj = {
|
||||||
getBoundingClientRect,
|
getBoundingClientRect,
|
||||||
clientWidth,
|
clientWidth,
|
||||||
clientHeight,
|
clientHeight,
|
||||||
};
|
}
|
||||||
return obj;
|
return obj
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
if (this.popper !== undefined) {
|
if (this.popper !== undefined) {
|
||||||
this.popper.destroy();
|
this.popper.destroy()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -123,5 +115,4 @@ export default {
|
|||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -10,7 +10,4 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
38
vue/src/components/ContextMenu/GenericContextMenu.vue
Normal file
38
vue/src/components/ContextMenu/GenericContextMenu.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<span>
|
||||||
|
<b-dropdown variant="link" toggle-class="text-decoration-none" right no-caret style="boundary:window">
|
||||||
|
<template #button-content>
|
||||||
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
</template>
|
||||||
|
<b-dropdown-item v-on:click="$emit('item-action', 'edit')" v-if="show_edit"> <i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item v-on:click="$emit('item-action', 'delete')" v-if="show_delete"> <i class="fas fa-trash-alt fa-fw"></i> {{ $t("Delete") }} </b-dropdown-item>
|
||||||
|
<b-dropdown-item v-on:click="$emit('item-action', 'add-shopping')" v-if="show_shopping">
|
||||||
|
<i class="fas fa-cart-plus fa-fw"></i> {{ $t("Add_to_Shopping") }}
|
||||||
|
</b-dropdown-item>
|
||||||
|
<b-dropdown-item v-on:click="$emit('item-action', 'add-onhand')" v-if="show_onhand"> <i class="fas fa-clipboard-check fa-fw"></i> {{ $t("OnHand") }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item v-on:click="$emit('item-action', 'move')" v-if="show_move"> <i class="fas fa-expand-arrows-alt fa-fw"></i> {{ $t("Move") }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge')"> <i class="fas fa-compress-arrows-alt fa-fw"></i> {{ $t("Merge") }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge-automate')">
|
||||||
|
<i class="fas fa-robot fa-fw"></i> {{ $t("Merge") }} & {{ $t("Automate") }} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
|
||||||
|
</b-dropdown-item>
|
||||||
|
</b-dropdown>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "GenericContextMenu",
|
||||||
|
props: {
|
||||||
|
show_edit: { type: Boolean, default: true },
|
||||||
|
show_delete: { type: Boolean, default: true },
|
||||||
|
show_move: { type: Boolean, default: false },
|
||||||
|
show_merge: { type: Boolean, default: false },
|
||||||
|
show_shopping: { type: Boolean, default: false },
|
||||||
|
show_onhand: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
52
vue/src/components/ContextMenu/ModelMenu.vue
Normal file
52
vue/src/components/ContextMenu/ModelMenu.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<!-- <b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button> -->
|
||||||
|
<span>
|
||||||
|
<b-dropdown variant="link" toggle-class="text-decoration-none text-dark shadow-none" no-caret style="boundary: window">
|
||||||
|
<template #button-content>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</template>
|
||||||
|
<b-dropdown-item :href="resolveDjangoUrl('list_food')"> <i class="fas fa-leaf fa-fw"></i> {{ Models["FOOD"].name }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')"> <i class="fas fa-tags fa-fw"></i> {{ Models["KEYWORD"].name }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item :href="resolveDjangoUrl('list_unit')"> <i class="fas fa-balance-scale fa-fw"></i> {{ Models["UNIT"].name }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')"> <i class="fas fa-store-alt fa-fw"></i> {{ Models["SUPERMARKET"].name }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')"> <i class="fas fa-cubes fa-fw"></i> {{ Models["SHOPPING_CATEGORY"].name }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item :href="resolveDjangoUrl('list_automation')"> <i class="fas fa-robot fa-fw"></i> {{ Models["AUTOMATION"].name }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')"> <i class="fas fa-file fa-fw"></i> {{ Models["USERFILE"].name }} </b-dropdown-item>
|
||||||
|
|
||||||
|
<b-dropdown-item :href="resolveDjangoUrl('list_step')"> <i class="fas fa-puzzle-piece fa-fw"></i>{{ Models["STEP"].name }} </b-dropdown-item>
|
||||||
|
</b-dropdown>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from "vue"
|
||||||
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
|
||||||
|
import { Models } from "@/utils/models"
|
||||||
|
import { ResolveUrlMixin } from "@/utils/utils"
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "ModelMenu",
|
||||||
|
mixins: [ResolveUrlMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
Models: Models,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
methods: {
|
||||||
|
gotoURL: function (model) {
|
||||||
|
return
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,42 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span>
|
|
||||||
<b-dropdown variant="link" toggle-class="text-decoration-none" right no-caret style="boundary:window">
|
|
||||||
<template #button-content>
|
|
||||||
<i class="fas fa-ellipsis-v" ></i>
|
|
||||||
</template>
|
|
||||||
<b-dropdown-item v-on:click="$emit('item-action', 'edit')" v-if="show_edit">
|
|
||||||
<i class="fas fa-pencil-alt fa-fw"></i> {{ $t('Edit') }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item v-on:click="$emit('item-action', 'delete')" v-if="show_delete">
|
|
||||||
<i class="fas fa-trash-alt fa-fw"></i> {{ $t('Delete') }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item v-on:click="$emit('item-action', 'move')" v-if="show_move">
|
|
||||||
<i class="fas fa-expand-arrows-alt fa-fw"></i> {{ $t('Move') }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge')">
|
|
||||||
<i class="fas fa-compress-arrows-alt fa-fw"></i> {{ $t('Merge') }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge-automate')">
|
|
||||||
<i class="fas fa-robot fa-fw"></i> {{$t('Merge')}} & {{$t('Automate')}} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
</b-dropdown>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'GenericContextMenu',
|
|
||||||
props: {
|
|
||||||
show_edit: {type: Boolean, default: true},
|
|
||||||
show_delete: {type: Boolean, default: true},
|
|
||||||
show_move: {type: Boolean, default: false},
|
|
||||||
show_merge: {type: Boolean, default: false},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div row style="margin: 4px">
|
<div row style="margin: 4px">
|
||||||
<!-- @[useDrag&&`dragover`] <== this syntax completely shuts off draggable -->
|
<!-- @[useDrag&&`dragover`] <== this syntax completely shuts off draggable -->
|
||||||
<b-card no-body d-flex flex-column :class="{'border border-primary' : over, 'shake': isError}"
|
<b-card
|
||||||
|
no-body
|
||||||
|
d-flex
|
||||||
|
flex-column
|
||||||
|
:class="{ 'border border-primary': over, shake: isError }"
|
||||||
:style="{ 'cursor:grab': useDrag }"
|
:style="{ 'cursor:grab': useDrag }"
|
||||||
:draggable="useDrag"
|
:draggable="useDrag"
|
||||||
@[useDrag&&`dragover`].prevent
|
@[useDrag&&`dragover`].prevent
|
||||||
@ -9,38 +13,38 @@
|
|||||||
@[useDrag&&`dragstart`]="handleDragStart($event)"
|
@[useDrag&&`dragstart`]="handleDragStart($event)"
|
||||||
@[useDrag&&`dragenter`]="handleDragEnter($event)"
|
@[useDrag&&`dragenter`]="handleDragEnter($event)"
|
||||||
@[useDrag&&`dragleave`]="handleDragLeave($event)"
|
@[useDrag&&`dragleave`]="handleDragLeave($event)"
|
||||||
@[useDrag&&`drop`]="handleDragDrop($event)">
|
@[useDrag&&`drop`]="handleDragDrop($event)"
|
||||||
|
>
|
||||||
<b-row no-gutters>
|
<b-row no-gutters>
|
||||||
<b-col no-gutters class="col-sm-3">
|
<b-col no-gutters class="col-sm-3">
|
||||||
<b-card-img-lazy style="object-fit: cover; height: 6em;" :src="item_image" v-bind:alt="$t('Recipe_Image')"></b-card-img-lazy>
|
<b-card-img-lazy style="object-fit: cover; height: 6em" :src="item_image" v-bind:alt="$t('Recipe_Image')"></b-card-img-lazy>
|
||||||
</b-col>
|
</b-col>
|
||||||
<b-col no-gutters class="col-sm-9">
|
<b-col no-gutters class="col-sm-9">
|
||||||
<b-card-body class="m-0 py-0">
|
<b-card-body class="m-0 py-0">
|
||||||
<b-card-text class="h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
|
<b-card-text class="h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
|
||||||
<h5 class="m-0 mt-1 text-truncate">{{ item[title] }}</h5>
|
<h5 class="m-0 mt-1 text-truncate">{{ item[title] }}</h5>
|
||||||
<div class="m-0 text-truncate">{{ item[subtitle] }}</div>
|
<div class="m-0 text-truncate">{{ item[subtitle] }}</div>
|
||||||
<!-- <span>{{this_item[itemTags.field]}}</span> -->
|
<div class="m-0 text-truncate small text-muted" v-if="getFullname">{{ getFullname }}</div>
|
||||||
<generic-pill v-for="x in itemTags" :key="x.field"
|
|
||||||
:item_list="item[x.field]"
|
<generic-pill v-for="x in itemTags" :key="x.field" :item_list="item[x.field]" :label="x.label" :color="x.color" />
|
||||||
:label="x.label"
|
<generic-ordered-pill
|
||||||
:color="x.color"/>
|
v-for="x in itemOrderedTags"
|
||||||
<generic-ordered-pill v-for="x in itemOrderedTags" :key="x.field"
|
:key="x.field"
|
||||||
:item_list="item[x.field]"
|
:item_list="item[x.field]"
|
||||||
:label="x.label"
|
:label="x.label"
|
||||||
:color="x.color"
|
:color="x.color"
|
||||||
:field="x.field"
|
:field="x.field"
|
||||||
:item="item"
|
:item="item"
|
||||||
@finish-action="finishAction"/>
|
@finish-action="finishAction"
|
||||||
|
/>
|
||||||
<div class="mt-auto mb-1" align="right">
|
<div class="mt-auto mb-1" align="right">
|
||||||
<span v-if="item[child_count]" class="mx-2 btn btn-link btn-sm"
|
<span v-if="item[child_count]" class="mx-2 btn btn-link btn-sm" style="z-index: 800" v-on:click="$emit('item-action', { action: 'get-children', source: item })">
|
||||||
style="z-index: 800;" v-on:click="$emit('item-action',{'action':'get-children','source':item})">
|
|
||||||
<div v-if="!item.show_children">{{ item[child_count] }} {{ itemName }}</div>
|
<div v-if="!item.show_children">{{ item[child_count] }} {{ itemName }}</div>
|
||||||
<div v-else>{{ text.hide_children }}</div>
|
<div v-else>{{ text.hide_children }}</div>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="item[recipe_count]" class="mx-2 btn btn-link btn-sm" style="z-index: 800;"
|
<span v-if="item[recipe_count]" class="mx-2 btn btn-link btn-sm" style="z-index: 800" v-on:click="$emit('item-action', { action: 'get-recipes', source: item })">
|
||||||
v-on:click="$emit('item-action',{'action':'get-recipes','source':item})">
|
<div v-if="!item.show_recipes">{{ item[recipe_count] }} {{ $t("Recipes") }}</div>
|
||||||
<div v-if="!item.show_recipes">{{ item[recipe_count] }} {{$t('Recipes')}}</div>
|
<div v-else>{{ $t("Hide_Recipes") }}</div>
|
||||||
<div v-else>{{$t('Hide_Recipes')}}</div>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</b-card-text>
|
</b-card-text>
|
||||||
@ -48,10 +52,15 @@
|
|||||||
</b-col>
|
</b-col>
|
||||||
<div class="card-img-overlay justify-content-right h-25 m-0 p-0 text-right">
|
<div class="card-img-overlay justify-content-right h-25 m-0 p-0 text-right">
|
||||||
<badges :item="item" :model="model" />
|
<badges :item="item" :model="model" />
|
||||||
<generic-context-menu class="p-0"
|
<generic-context-menu
|
||||||
|
v-if="show_context_menu"
|
||||||
|
class="p-0"
|
||||||
:show_merge="useMerge"
|
:show_merge="useMerge"
|
||||||
:show_move="useMove"
|
:show_move="useMove"
|
||||||
@item-action="$emit('item-action', {'action': $event, 'source': item})">
|
:show_shopping="useShopping"
|
||||||
|
:show_onhand="useOnhand"
|
||||||
|
@item-action="$emit('item-action', { action: $event, source: item })"
|
||||||
|
>
|
||||||
</generic-context-menu>
|
</generic-context-menu>
|
||||||
</div>
|
</div>
|
||||||
</b-row>
|
</b-row>
|
||||||
@ -59,52 +68,68 @@
|
|||||||
<!-- recursively add child cards -->
|
<!-- recursively add child cards -->
|
||||||
<div class="row" v-if="item.show_children">
|
<div class="row" v-if="item.show_children">
|
||||||
<div class="col-md-10 offset-md-2">
|
<div class="col-md-10 offset-md-2">
|
||||||
<generic-horizontal-card v-for="child in item[children]" v-bind:key="child.id"
|
<generic-horizontal-card v-for="child in item[children]" v-bind:key="child.id" :item="child" :model="model" @item-action="$emit('item-action', $event)"> </generic-horizontal-card>
|
||||||
:item="child"
|
|
||||||
:model="model"
|
|
||||||
@item-action="$emit('item-action', $event)">
|
|
||||||
</generic-horizontal-card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- conditionally view recipes -->
|
<!-- conditionally view recipes -->
|
||||||
<div class="row" v-if="item.show_recipes">
|
<div class="row" v-if="item.show_recipes">
|
||||||
<div class="col-md-10 offset-md-2">
|
<div class="col-md-10 offset-md-2">
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));grid-gap: 1rem;">
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 1rem">
|
||||||
<recipe-card v-for="r in item[recipes]"
|
<recipe-card v-for="r in item[recipes]" v-bind:key="r.id" :recipe="r"> </recipe-card>
|
||||||
v-bind:key="r.id"
|
|
||||||
:recipe="r">
|
|
||||||
</recipe-card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- this should be made a generic component, would also require mixin for functions that generate the popup and put in parent container-->
|
<!-- this should be made a generic component, would also require mixin for functions that generate the popup and put in parent container-->
|
||||||
<b-list-group ref="tooltip" variant="light" v-show="show_menu" v-on-clickaway="closeMenu" style="z-index: 9999; cursor: pointer">
|
<b-list-group ref="tooltip" variant="light" v-show="show_menu" v-on-clickaway="closeMenu" style="z-index: 9999; cursor: pointer">
|
||||||
<b-list-group-item v-if="useMove" action v-on:click="$emit('item-action',{'action': 'move', 'target': item, 'source': source}); closeMenu()">
|
<b-list-group-item
|
||||||
<i class="fas fa-expand-arrows-alt fa-fw"></i> <b>{{$t('Move')}}</b>: <span v-html="$t('move_confirmation', {'child': source.name,'parent':item.name})"></span>
|
v-if="useMove"
|
||||||
|
action
|
||||||
|
v-on:click="
|
||||||
|
$emit('item-action', { action: 'move', target: item, source: source })
|
||||||
|
closeMenu()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="fas fa-expand-arrows-alt fa-fw"></i> <b>{{ $t("Move") }}</b
|
||||||
|
>: <span v-html="$t('move_confirmation', { child: source.name, parent: item.name })"></span>
|
||||||
</b-list-group-item>
|
</b-list-group-item>
|
||||||
<b-list-group-item v-if="useMerge" action v-on:click="$emit('item-action',{'action': 'merge', 'target': item, 'source': source}); closeMenu()">
|
<b-list-group-item
|
||||||
<i class="fas fa-compress-arrows-alt fa-fw"></i> <b>{{$t('Merge')}}</b>: <span v-html="$t('merge_confirmation', {'source': source.name,'target':item.name})"></span>
|
v-if="useMerge"
|
||||||
|
action
|
||||||
|
v-on:click="
|
||||||
|
$emit('item-action', { action: 'merge', target: item, source: source })
|
||||||
|
closeMenu()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="fas fa-compress-arrows-alt fa-fw"></i> <b>{{ $t("Merge") }}</b
|
||||||
|
>: <span v-html="$t('merge_confirmation', { source: source.name, target: item.name })"></span>
|
||||||
</b-list-group-item>
|
</b-list-group-item>
|
||||||
<b-list-group-item v-if="useMerge" action v-on:click="$emit('item-action',{'action': 'merge-automate', 'target': item, 'source': source}); closeMenu()">
|
<b-list-group-item
|
||||||
<i class="fas fa-robot fa-fw"></i> <b>{{$t('Merge')}} & {{$t('Automate')}}</b>: <span v-html="$t('merge_confirmation', {'source': source.name,'target':item.name})"></span> {{$t('create_rule')}} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')" >BETA</b-badge>
|
v-if="useMerge"
|
||||||
|
action
|
||||||
|
v-on:click="
|
||||||
|
$emit('item-action', { action: 'merge-automate', target: item, source: source })
|
||||||
|
closeMenu()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot fa-fw"></i> <b>{{ $t("Merge") }} & {{ $t("Automate") }}</b
|
||||||
|
>: <span v-html="$t('merge_confirmation', { source: source.name, target: item.name })"></span> {{ $t("create_rule") }}
|
||||||
|
<b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
|
||||||
</b-list-group-item>
|
</b-list-group-item>
|
||||||
<b-list-group-item action v-on:click="closeMenu()">
|
<b-list-group-item action v-on:click="closeMenu()">
|
||||||
<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 and/or manage pantry -->
|
|
||||||
</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"
|
||||||
import RecipeCard from "@/components/RecipeCard";
|
import RecipeCard from "@/components/RecipeCard"
|
||||||
import { mixin as clickaway } from 'vue-clickaway';
|
import { mixin as clickaway } from "vue-clickaway"
|
||||||
import { createPopper } from '@popperjs/core';
|
import { createPopper } from "@popperjs/core"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "GenericHorizontalCard",
|
name: "GenericHorizontalCard",
|
||||||
@ -113,41 +138,48 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
item: { type: Object },
|
item: { type: Object },
|
||||||
model: { type: Object },
|
model: { type: Object },
|
||||||
title: {type: String, default: 'name'}, // this and the following props need to be moved to model.js and made computed values
|
title: { type: String, default: "name" }, // this and the following props need to be moved to model.js and made computed values
|
||||||
subtitle: {type: String, default: 'description'},
|
subtitle: { type: String, default: "description" },
|
||||||
child_count: {type: String, default: 'numchild'},
|
child_count: { type: String, default: "numchild" },
|
||||||
children: {type: String, default: 'children'},
|
children: { type: String, default: "children" },
|
||||||
recipe_count: {type: String, default: 'numrecipe'},
|
recipe_count: { type: String, default: "numrecipe" },
|
||||||
recipes: {type: String, default: 'recipes'}
|
recipes: { type: String, default: "recipes" },
|
||||||
|
show_context_menu: { type: Boolean, default: true },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
item_image: '',
|
item_image: "",
|
||||||
over: false,
|
over: false,
|
||||||
show_menu: false,
|
show_menu: false,
|
||||||
dragMenu: undefined,
|
dragMenu: undefined,
|
||||||
isError: false,
|
isError: false,
|
||||||
source: {'id': undefined, 'name': undefined},
|
source: { id: undefined, name: undefined },
|
||||||
target: {'id': undefined, 'name': undefined},
|
target: { id: undefined, name: undefined },
|
||||||
text: {
|
text: {
|
||||||
'hide_children': '',
|
hide_children: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.item_image = this.item?.image ?? window.IMAGE_PLACEHOLDER
|
this.item_image = this.item?.image ?? window.IMAGE_PLACEHOLDER
|
||||||
this.dragMenu = this.$refs.tooltip
|
this.dragMenu = this.$refs.tooltip
|
||||||
this.text.hide_children = this.$t('Hide_' + this.itemName)
|
this.text.hide_children = this.$t("Hide_" + this.itemName)
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
itemName: function () {
|
itemName: function () {
|
||||||
return this.model?.name ?? "You Forgot To Set Model Name in model.js"
|
return this.model?.name ?? "You Forgot To Set Model Name in model.js"
|
||||||
},
|
},
|
||||||
useMove: function () {
|
useMove: function () {
|
||||||
return (this.model?.['move'] ?? false) ? true : false
|
return this.model?.["move"] ?? false ? true : false
|
||||||
},
|
},
|
||||||
useMerge: function () {
|
useMerge: function () {
|
||||||
return (this.model?.['merge'] ?? false) ? true : false
|
return this.model?.["merge"] ?? false ? true : false
|
||||||
|
},
|
||||||
|
useShopping: function () {
|
||||||
|
return this.model?.["shop"] ?? false ? true : false
|
||||||
|
},
|
||||||
|
useOnhand: function () {
|
||||||
|
return this.model?.["onhand"] ?? false ? true : false
|
||||||
},
|
},
|
||||||
useDrag: function () {
|
useDrag: function () {
|
||||||
return this.useMove || this.useMerge
|
return this.useMove || this.useMerge
|
||||||
@ -157,12 +189,18 @@ export default {
|
|||||||
},
|
},
|
||||||
itemOrderedTags: function () {
|
itemOrderedTags: function () {
|
||||||
return this.model?.ordered_tags ?? []
|
return this.model?.ordered_tags ?? []
|
||||||
|
},
|
||||||
|
getFullname: function () {
|
||||||
|
if (!this.item?.full_name?.includes(">")) {
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
return this.item?.full_name
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleDragStart: function (e) {
|
handleDragStart: function (e) {
|
||||||
this.isError = false
|
this.isError = false
|
||||||
e.dataTransfer.setData('source', JSON.stringify(this.item))
|
e.dataTransfer.setData("source", JSON.stringify(this.item))
|
||||||
},
|
},
|
||||||
handleDragEnter: function (e) {
|
handleDragEnter: function (e) {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget) && e.relatedTarget != null) {
|
if (!e.currentTarget.contains(e.relatedTarget) && e.relatedTarget != null) {
|
||||||
@ -175,35 +213,32 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleDragDrop: function (e) {
|
handleDragDrop: function (e) {
|
||||||
let source = JSON.parse(e.dataTransfer.getData('source'))
|
let source = JSON.parse(e.dataTransfer.getData("source"))
|
||||||
if (source.id != this.item.id) {
|
if (source.id != this.item.id) {
|
||||||
this.source = source
|
this.source = source
|
||||||
let menuLocation = {getBoundingClientRect: this.generateLocation(e.clientX, e.clientY),}
|
let menuLocation = { getBoundingClientRect: this.generateLocation(e.clientX, e.clientY) }
|
||||||
this.show_menu = true
|
this.show_menu = true
|
||||||
let popper = createPopper(
|
let popper = createPopper(menuLocation, this.dragMenu, {
|
||||||
menuLocation,
|
placement: "bottom-start",
|
||||||
this.dragMenu,
|
|
||||||
{
|
|
||||||
placement: 'bottom-start',
|
|
||||||
modifiers: [
|
modifiers: [
|
||||||
{
|
{
|
||||||
name: 'preventOverflow',
|
name: "preventOverflow",
|
||||||
options: {
|
options: {
|
||||||
rootBoundary: 'document',
|
rootBoundary: "document",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'flip',
|
name: "flip",
|
||||||
options: {
|
options: {
|
||||||
fallbackPlacements: ['bottom-end', 'top-start', 'top-end', 'left-start', 'right-start'],
|
fallbackPlacements: ["bottom-end", "top-start", "top-end", "left-start", "right-start"],
|
||||||
rootBoundary: 'document',
|
rootBoundary: "document",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
popper.update()
|
popper.update()
|
||||||
this.over = false
|
this.over = false
|
||||||
this.$emit({'action': 'drop', 'target': this.item, 'source': this.source})
|
this.$emit({ action: "drop", target: this.item, source: this.source })
|
||||||
} else {
|
} else {
|
||||||
this.isError = true
|
this.isError = true
|
||||||
}
|
}
|
||||||
@ -216,16 +251,15 @@ export default {
|
|||||||
right: x,
|
right: x,
|
||||||
bottom: y,
|
bottom: y,
|
||||||
left: x,
|
left: x,
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
closeMenu: function () {
|
closeMenu: function () {
|
||||||
this.show_menu = false
|
this.show_menu = false
|
||||||
},
|
},
|
||||||
finishAction: function (e) {
|
finishAction: function (e) {
|
||||||
this.$emit('finish-action', e)
|
this.$emit("finish-action", e)
|
||||||
}
|
},
|
||||||
|
},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -15,14 +15,14 @@
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
@search-change="search"
|
@search-change="search"
|
||||||
@input="selectionChanged"
|
@input="selectionChanged"
|
||||||
@tag="addNew">
|
@tag="addNew"
|
||||||
|
>
|
||||||
</multiselect>
|
</multiselect>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import Multiselect from "vue-multiselect"
|
||||||
import Multiselect from 'vue-multiselect'
|
import { ApiMixin } from "@/utils/utils"
|
||||||
import {ApiMixin} from "@/utils/utils";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "GenericMultiselect",
|
name: "GenericMultiselect",
|
||||||
@ -39,48 +39,52 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
placeholder: { type: String, default: undefined },
|
placeholder: { type: String, default: undefined },
|
||||||
model: {
|
model: {
|
||||||
type: Object, default() {
|
type: Object,
|
||||||
|
default() {
|
||||||
return {}
|
return {}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
label: {type: String, default: 'name'},
|
},
|
||||||
|
label: { type: String, default: "name" },
|
||||||
parent_variable: { type: String, default: undefined },
|
parent_variable: { type: String, default: undefined },
|
||||||
limit: {type: Number, default: 10,},
|
limit: { type: Number, default: 10 },
|
||||||
sticky_options: {
|
sticky_options: {
|
||||||
type: Array, default() {
|
type: Array,
|
||||||
|
default() {
|
||||||
return []
|
return []
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
initial_selection: {
|
initial_selection: {
|
||||||
type: Array, default() {
|
type: Array,
|
||||||
|
default() {
|
||||||
return []
|
return []
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
multiple: { type: Boolean, default: true },
|
multiple: { type: Boolean, default: true },
|
||||||
allow_create: {type: Boolean, default: false}, // TODO: this will create option to add new drop-downs
|
allow_create: { type: Boolean, default: false },
|
||||||
create_placeholder: {type: String, default: 'You Forgot to Add a Tag Placeholder'},
|
create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" },
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
initial_selection: function (newVal, oldVal) { // watch it
|
initial_selection: function (newVal, oldVal) {
|
||||||
|
// watch it
|
||||||
this.selected_objects = newVal
|
this.selected_objects = newVal
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.search('')
|
this.search("")
|
||||||
this.selected_objects = this.initial_selection
|
this.selected_objects = this.initial_selection
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
lookupPlaceholder() {
|
lookupPlaceholder() {
|
||||||
return this.placeholder || this.model.name || this.$t('Search')
|
return this.placeholder || this.model.name || this.$t("Search")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// this.genericAPI inherited from ApiMixin
|
// this.genericAPI inherited from ApiMixin
|
||||||
search: function (query) {
|
search: function (query) {
|
||||||
let options = {
|
let options = {
|
||||||
'page': 1,
|
page: 1,
|
||||||
'pageSize': 10,
|
pageSize: 10,
|
||||||
'query': query
|
query: query,
|
||||||
}
|
}
|
||||||
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
|
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
|
||||||
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
|
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
|
||||||
@ -101,23 +105,19 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
selectionChanged: function () {
|
selectionChanged: function () {
|
||||||
this.$emit('change', {var: this.parent_variable, val: this.selected_objects})
|
this.$emit("change", { var: this.parent_variable, val: this.selected_objects })
|
||||||
|
|
||||||
},
|
},
|
||||||
addNew(e) {
|
addNew(e) {
|
||||||
this.$emit('new', e)
|
this.$emit("new", e)
|
||||||
// could refactor as Promise - seems unecessary
|
// could refactor as Promise - seems unecessary
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.search('');
|
this.search("")
|
||||||
}, 750);
|
}, 750)
|
||||||
|
},
|
||||||
}
|
},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
@ -1,25 +1,29 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<draggable v-if="itemList" v-model="this_list" tag="span" group="ordered_items" z-index="500"
|
<draggable v-if="itemList" v-model="this_list" tag="span" group="ordered_items" z-index="500" @change="orderChanged">
|
||||||
@change="orderChanged">
|
|
||||||
<span :key="k.id" v-for="k in itemList" class="pl-1">
|
<span :key="k.id" v-for="k in itemList" class="pl-1">
|
||||||
<b-badge squared :variant="color"><i class="fas fa-grip-lines-vertical text-muted"></i><span class="ml-1">{{thisLabel(k)}}</span></b-badge>
|
<b-badge squared :variant="color"
|
||||||
|
><i class="fas fa-grip-lines-vertical text-muted"></i><span class="ml-1">{{ thisLabel(k) }}</span></b-badge
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</draggable>
|
</draggable>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// you can't use this component with a horizontal card that is also draggable
|
// you can't use this component with a horizontal card that is also draggable
|
||||||
import draggable from 'vuedraggable'
|
import draggable from "vuedraggable"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GenericOrderedPill',
|
name: "GenericOrderedPill",
|
||||||
components: { draggable },
|
components: { draggable },
|
||||||
props: {
|
props: {
|
||||||
item_list: {required: true, type: Array},
|
item_list: {
|
||||||
label: {type: String, default: 'name'},
|
type: Array,
|
||||||
color: {type: String, default: 'light'},
|
default() {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: { type: String, default: "name" },
|
||||||
|
color: { type: String, default: "light" },
|
||||||
field: { type: String, required: true },
|
field: { type: String, required: true },
|
||||||
item: { type: Object },
|
item: { type: Object },
|
||||||
},
|
},
|
||||||
@ -38,35 +42,34 @@ export default {
|
|||||||
return [this.this_list]
|
return [this.this_list]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.this_list = this.item_list
|
this.this_list = this.item_list
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'item_list': function (newVal) {
|
item_list: function(newVal) {
|
||||||
this.this_list = newVal
|
this.this_list = newVal
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
thisLabel: function(item) {
|
thisLabel: function(item) {
|
||||||
let fields = this.label.split('::')
|
let fields = this.label.split("::")
|
||||||
let value = item
|
let value = item
|
||||||
fields.forEach(x => {
|
fields.forEach((x) => {
|
||||||
value = value[x]
|
value = value[x]
|
||||||
});
|
})
|
||||||
return value
|
return value
|
||||||
},
|
},
|
||||||
orderChanged: function(e) {
|
orderChanged: function(e) {
|
||||||
let order = 0
|
let order = 0
|
||||||
this.this_list.forEach(x => {
|
this.this_list.forEach((x) => {
|
||||||
x['order'] = order
|
x["order"] = order
|
||||||
order++
|
order++
|
||||||
})
|
})
|
||||||
let new_order = { ...this.item }
|
let new_order = { ...this.item }
|
||||||
new_order[this.field] = this.this_list
|
new_order[this.field] = this.this_list
|
||||||
this.$emit('finish-action', {'action':'save','form_data': new_order })
|
this.$emit("finish-action", { action: "save", form_data: new_order })
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
@ -10,12 +10,7 @@
|
|||||||
export default {
|
export default {
|
||||||
name: "GenericPill",
|
name: "GenericPill",
|
||||||
props: {
|
props: {
|
||||||
item_list: {
|
item_list: { type: Object },
|
||||||
type: Array,
|
|
||||||
default() {
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
},
|
|
||||||
label: { type: String, default: "name" },
|
label: { type: String, default: "name" },
|
||||||
color: { type: String, default: "light" },
|
color: { type: String, default: "light" },
|
||||||
},
|
},
|
||||||
|
@ -1,79 +1,194 @@
|
|||||||
<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">
|
<td colspan="5" @click="done">
|
||||||
<b>{{ ingredient.note }}</b>
|
<b>{{ ingredient.note }}</b>
|
||||||
</td>
|
</td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<td class="d-print-non" v-if="detailed">
|
<td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done">
|
||||||
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
<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>
|
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="text-nowrap" @click="done">
|
||||||
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
|
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td @click="done">
|
||||||
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
|
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td @click="done">
|
||||||
<template v-if="ingredient.food !== null">
|
<template v-if="ingredient.food !== null">
|
||||||
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== 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>
|
||||||
target="_blank" rel="noopener noreferrer">{{ ingredient.food.name }}</a>
|
|
||||||
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
|
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="detailed">
|
<td v-if="detailed && !show_shopping">
|
||||||
<div v-if="ingredient.note">
|
<div v-if="ingredient.note">
|
||||||
<span v-b-popover.hover="ingredient.note"
|
<span v-b-popover.hover="ingredient.note" class="d-print-none touchable">
|
||||||
class="d-print-none touchable"> <i class="far fa-comment"></i>
|
<i class="far fa-comment"></i>
|
||||||
</span>
|
</span>
|
||||||
<!-- v-if="ingredient.note.length > 15" -->
|
|
||||||
<!-- <span v-else>-->
|
|
||||||
<!-- {{ ingredient.note }}-->
|
|
||||||
<!-- </span>-->
|
|
||||||
|
|
||||||
<div class="d-none d-print-block">
|
<div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
|
||||||
<i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td v-else-if="show_shopping" class="text-right text-nowrap">
|
||||||
|
<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" />
|
||||||
|
</td>
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils"
|
||||||
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils";
|
import OnHandBadge from "@/components/Badges/OnHand"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'IngredientComponent',
|
name: "IngredientComponent",
|
||||||
|
components: { OnHandBadge },
|
||||||
props: {
|
props: {
|
||||||
ingredient: Object,
|
ingredient: Object,
|
||||||
ingredient_factor: {
|
ingredient_factor: { type: Number, default: 1 },
|
||||||
type: Number,
|
detailed: { type: Boolean, default: true },
|
||||||
default: 1,
|
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 []
|
||||||
},
|
},
|
||||||
detailed: {
|
}, // list of unchecked ingredients in shopping list
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mixins: [
|
mixins: [ResolveUrlMixin, ApiMixin],
|
||||||
ResolveUrlMixin
|
|
||||||
],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
checked: false
|
checked: false,
|
||||||
|
shopping_status: null, // in any shopping list: boolean + null=in shopping list, but not for this recipe
|
||||||
|
shopping_items: [],
|
||||||
|
shop: false, // in shopping list for this recipe: boolean
|
||||||
|
dirty: undefined,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
ShoppingListAndFilter: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal, oldVal) {
|
||||||
|
// this whole sections is overly complicated
|
||||||
|
// trying to infer status of shopping for THIS recipe and THIS ingredient
|
||||||
|
// without know which recipe it is.
|
||||||
|
// If refactored:
|
||||||
|
// ## Needs to handle same recipe (multiple mealplans) being in shopping list multiple times
|
||||||
|
// ## Needs to handle same recipe being added as ShoppingListRecipe AND ingredients added from recipe as one-off
|
||||||
|
|
||||||
|
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 recipe is in the shopping list
|
||||||
|
this.shop = false // don't check any boxes until user selects a shopping list to edit
|
||||||
|
if (count_shopping_ingredient >= 1) {
|
||||||
|
this.shopping_status = true // ingredient is in the shopping list - probably (but not definitely, this ingredient)
|
||||||
|
} else if (this.ingredient.food.shopping) {
|
||||||
|
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
|
||||||
|
} else {
|
||||||
|
// food is not in any shopping list
|
||||||
|
this.shopping_status = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// there are not recipes in the shopping list
|
||||||
|
// set default value
|
||||||
|
this.shop = !this.ingredient?.food?.food_onhand && !this.ingredient?.food?.recipe
|
||||||
|
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
|
||||||
|
// 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 (not entirely sure how this could happen?)
|
||||||
|
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.shopping_status = null
|
||||||
|
} else {
|
||||||
|
// the food is not in any shopping list
|
||||||
|
this.shopping_status = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.add_shopping_mode) {
|
||||||
|
// if we are in add shopping mode (e.g. recipe_shopping_modal) start with all checks marked
|
||||||
|
// except if on_hand (could be if recipe too?)
|
||||||
|
this.shop = !this.ingredient?.food?.food_onhand && !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: {
|
methods: {
|
||||||
calculateAmount: function (x) {
|
calculateAmount: function (x) {
|
||||||
return calculateAmount(x, this.ingredient_factor)
|
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 })
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
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-6">
|
||||||
|
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-6 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-component
|
||||||
|
: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 IngredientComponent from "@/components/IngredientComponent"
|
||||||
|
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "IngredientCard",
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
components: { IngredientComponent },
|
||||||
|
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,26 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-hover class="card cv-item meal-plan-card p-0" :key="value.id" :draggable="true"
|
<div
|
||||||
|
v-hover
|
||||||
|
class="card cv-item meal-plan-card p-0"
|
||||||
|
:key="value.id"
|
||||||
|
:draggable="true"
|
||||||
:style="`top:${top};max-height:${item_height}`"
|
:style="`top:${top};max-height:${item_height}`"
|
||||||
@dragstart="onDragItemStart(value, $event)"
|
@dragstart="onDragItemStart(value, $event)"
|
||||||
@click="onClickItem(value, $event)"
|
@click="onClickItem(value, $event)"
|
||||||
:aria-grabbed="value == currentDragItem"
|
:aria-grabbed="value == currentDragItem"
|
||||||
:class="value.classes"
|
:class="value.classes"
|
||||||
@contextmenu.prevent="$emit('open-context-menu', $event, value)">
|
@contextmenu.prevent="$emit('open-context-menu', $event, value)"
|
||||||
<div class="card-header p-1 text-center text-primary border-bottom-0" v-if="detailed"
|
>
|
||||||
:style="`background-color: ${background_color}`">
|
<div class="card-header p-1 text-center text-primary border-bottom-0" v-if="detailed" :style="`background-color: ${background_color}`">
|
||||||
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null">{{
|
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null">{{ entry.entry.meal_type.icon }}</span>
|
||||||
entry.entry.meal_type.icon
|
|
||||||
}}</span>
|
|
||||||
<span class="font-light d-none d-md-inline">{{ entry.entry.meal_type.name }}</span>
|
<span class="font-light d-none d-md-inline">{{ entry.entry.meal_type.name }}</span>
|
||||||
|
<span v-if="entry.entry.shopping" class="font-light"><i class="fas fa-shopping-cart fa-xs float-left" v-b-tooltip.hover.top :title="$t('in_shopping')" /></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right p-0"
|
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right p-0" v-if="detailed">
|
||||||
v-if="detailed">
|
|
||||||
<a>
|
<a>
|
||||||
<div style="position: static;">
|
<div style="position: static">
|
||||||
<div class="dropdown b-dropdown position-static btn-group">
|
<div class="dropdown b-dropdown position-static btn-group">
|
||||||
<button aria-haspopup="true" aria-expanded="false" type="button"
|
<button
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
type="button"
|
||||||
class="btn btn-link text-decoration-none text-body pr-2 dropdown-toggle-no-caret"
|
class="btn btn-link text-decoration-none text-body pr-2 dropdown-toggle-no-caret"
|
||||||
@click.stop="$emit('open-context-menu', $event, value)"><i class="fas fa-ellipsis-v fa-lg"></i>
|
@click.stop="$emit('open-context-menu', $event, value)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -30,20 +37,14 @@
|
|||||||
<span class="font-light">{{ title }}</span>
|
<span class="font-light">{{ title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<b-img fluid class="card-img-bottom" :src="entry.entry.recipe.image" v-if="hasRecipe && detailed"></b-img>
|
<b-img fluid class="card-img-bottom" :src="entry.entry.recipe.image" v-if="hasRecipe && detailed"></b-img>
|
||||||
<b-img fluid class="card-img-bottom" :src="image_placeholder"
|
<b-img fluid class="card-img-bottom" :src="image_placeholder" v-if="detailed && ((!hasRecipe && entry.entry.note === '') || (hasRecipe && entry.entry.recipe.image === null))"></b-img>
|
||||||
v-if="detailed && ((!hasRecipe && entry.entry.note === '') || (hasRecipe && entry.entry.recipe.image === null))"></b-img>
|
<div class="card-body p-1" v-if="detailed && entry.entry.recipe == null" :style="`background-color: ${background_color}`">
|
||||||
<div class="card-body p-1" v-if="detailed && entry.entry.recipe == null"
|
|
||||||
:style="`background-color: ${background_color}`">
|
|
||||||
<p>{{ entry.entry.note }}</p>
|
<p>{{ entry.entry.note }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row p-1 flex-nowrap" v-if="!detailed" :style="`background-color: ${background_color}`">
|
<div class="row p-1 flex-nowrap" v-if="!detailed" :style="`background-color: ${background_color}`">
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null" v-b-tooltip.hover.left
|
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null" v-b-tooltip.hover.left :title="entry.entry.meal_type.name">{{ entry.entry.meal_type.icon }}</span>
|
||||||
:title=" entry.entry.meal_type.name">{{
|
<span class="font-light text-center" v-if="entry.entry.meal_type.icon == null" v-b-tooltip.hover.left :title="entry.entry.meal_type.name">❓</span>
|
||||||
entry.entry.meal_type.icon
|
|
||||||
}}</span>
|
|
||||||
<span class="font-light text-center" v-if="entry.entry.meal_type.icon == null" v-b-tooltip.hover.left
|
|
||||||
:title=" entry.entry.meal_type.name">❓</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-10 d-inline-block text-truncate" :style="`max-height:${item_height}`">
|
<div class="col-10 d-inline-block text-truncate" :style="`max-height:${item_height}`">
|
||||||
<span class="font-light">{{ title }}</span>
|
<span class="font-light">{{ title }}</span>
|
||||||
@ -61,31 +62,34 @@ export default {
|
|||||||
weekStartDate: Date,
|
weekStartDate: Date,
|
||||||
top: String,
|
top: String,
|
||||||
detailed: Boolean,
|
detailed: Boolean,
|
||||||
item_height: String
|
item_height: String,
|
||||||
},
|
},
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
dateSelectionOrigin: null,
|
dateSelectionOrigin: null,
|
||||||
currentDragItem: null,
|
currentDragItem: null,
|
||||||
image_placeholder: window.IMAGE_PLACEHOLDER
|
image_placeholder: window.IMAGE_PLACEHOLDER,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
console.log(this.value)
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
entry: function () {
|
entry: function () {
|
||||||
return this.value.originalItem
|
return this.value.originalItem
|
||||||
},
|
},
|
||||||
title: function () {
|
title: function () {
|
||||||
if (this.entry.entry.title != null && this.entry.entry.title !== '') {
|
if (this.entry.entry.title != null && this.entry.entry.title !== "") {
|
||||||
return this.entry.entry.title
|
return this.entry.entry.title
|
||||||
} else {
|
} else {
|
||||||
return this.entry.entry.recipe_name
|
return this.entry.entry.recipe_name
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hasRecipe: function () {
|
hasRecipe: function () {
|
||||||
return this.entry.entry.recipe != null;
|
return this.entry.entry.recipe != null
|
||||||
},
|
},
|
||||||
background_color: function () {
|
background_color: function () {
|
||||||
if (this.entry.entry.meal_type.color != null && this.entry.entry.meal_type.color !== '') {
|
if (this.entry.entry.meal_type.color != null && this.entry.entry.meal_type.color !== "") {
|
||||||
return this.entry.entry.meal_type.color
|
return this.entry.entry.meal_type.color
|
||||||
} else {
|
} else {
|
||||||
return "#fff"
|
return "#fff"
|
||||||
@ -109,15 +113,15 @@ export default {
|
|||||||
directives: {
|
directives: {
|
||||||
hover: {
|
hover: {
|
||||||
inserted: (el) => {
|
inserted: (el) => {
|
||||||
el.addEventListener('mouseenter', () => {
|
el.addEventListener("mouseenter", () => {
|
||||||
el.classList.add("shadow")
|
el.classList.add("shadow")
|
||||||
});
|
})
|
||||||
el.addEventListener('mouseleave', () => {
|
el.addEventListener("mouseleave", () => {
|
||||||
el.classList.remove("shadow")
|
el.classList.remove("shadow")
|
||||||
});
|
})
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -1,22 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
|
<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="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 col-lg-9">
|
<div class="col-6 col-lg-9">
|
||||||
<b-input-group>
|
<b-input-group>
|
||||||
<b-form-input id="TitleInput" v-model="entryEditing.title"
|
<b-form-input id="TitleInput" v-model="entryEditing.title" :placeholder="entryEditing.title_placeholder" @change="missing_recipe = false"></b-form-input>
|
||||||
:placeholder="entryEditing.title_placeholder"
|
|
||||||
@change="missing_recipe = false"></b-form-input>
|
|
||||||
<b-input-group-append class="d-none d-lg-block">
|
<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-button variant="primary" @click="entryEditing.title = ''"><i class="fa fa-eraser"></i></b-button>
|
||||||
</b-input-group-append>
|
</b-input-group-append>
|
||||||
</b-input-group>
|
</b-input-group>
|
||||||
<span class="text-danger" v-if="missing_recipe">{{ $t('Title_or_Recipe_Required') }}</span>
|
<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>
|
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{ $t("Title") }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-lg-3">
|
<div class="col-6 col-lg-3">
|
||||||
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date">
|
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date" />
|
||||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -29,59 +29,67 @@
|
|||||||
:label="'name'"
|
:label="'name'"
|
||||||
:model="Models.RECIPE"
|
:model="Models.RECIPE"
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
v-bind:placeholder="$t('Recipe')" :limit="10"
|
v-bind:placeholder="$t('Recipe')"
|
||||||
:multiple="false"></generic-multiselect>
|
:limit="10"
|
||||||
|
:multiple="false"
|
||||||
|
></generic-multiselect>
|
||||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-form-group class="mt-3">
|
<b-form-group class="mt-3">
|
||||||
<generic-multiselect required
|
<generic-multiselect
|
||||||
|
required
|
||||||
@change="selectMealType"
|
@change="selectMealType"
|
||||||
:label="'name'"
|
:label="'name'"
|
||||||
:model="Models.MEAL_TYPE"
|
:model="Models.MEAL_TYPE"
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
v-bind:placeholder="$t('Meal_Type')" :limit="10"
|
v-bind:placeholder="$t('Meal_Type')"
|
||||||
|
:limit="10"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:initial_selection="entryEditing_initial_meal_type"
|
:initial_selection="entryEditing_initial_meal_type"
|
||||||
:allow_create="true"
|
:allow_create="true"
|
||||||
:create_placeholder="$t('Create_New_Meal_Type')"
|
:create_placeholder="$t('Create_New_Meal_Type')"
|
||||||
@new="createMealType"
|
@new="createMealType"
|
||||||
></generic-multiselect>
|
></generic-multiselect>
|
||||||
<span class="text-danger" v-if="missing_meal_type">{{ $t('Meal_Type_Required') }}</span>
|
<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>
|
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{ $t("Meal_Type") }}</small>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-form-group
|
<b-form-group label-for="NoteInput" :description="$t('Note')" class="mt-3">
|
||||||
label-for="NoteInput"
|
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note" :placeholder="$t('Note')"></textarea>
|
||||||
:description="$t('Note')" class="mt-3">
|
|
||||||
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note"
|
|
||||||
:placeholder="$t('Note')"></textarea>
|
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-input-group>
|
<b-input-group>
|
||||||
<b-form-input id="ServingsInput" v-model="entryEditing.servings"
|
<b-form-input id="ServingsInput" v-model="entryEditing.servings" :placeholder="$t('Servings')"></b-form-input>
|
||||||
:placeholder="$t('Servings')"></b-form-input>
|
|
||||||
</b-input-group>
|
</b-input-group>
|
||||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
||||||
<b-form-group class="mt-3">
|
<b-form-group class="mt-3">
|
||||||
<generic-multiselect required
|
<generic-multiselect
|
||||||
@change="entryEditing.shared = $event.val" parent_variable="entryEditing.shared"
|
required
|
||||||
|
@change="entryEditing.shared = $event.val"
|
||||||
|
parent_variable="entryEditing.shared"
|
||||||
:label="'username'"
|
:label="'username'"
|
||||||
:model="Models.USER_NAME"
|
:model="Models.USER_NAME"
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
v-bind:placeholder="$t('Share')" :limit="10"
|
v-bind:placeholder="$t('Share')"
|
||||||
|
:limit="10"
|
||||||
:multiple="true"
|
:multiple="true"
|
||||||
:initial_selection="entryEditing.shared"
|
:initial_selection="entryEditing.shared"
|
||||||
></generic-multiselect>
|
></generic-multiselect>
|
||||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
|
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
|
<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>
|
||||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
<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>
|
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null" :detailed="false"></recipe-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mt-3 mb-3">
|
<div class="row mt-3 mb-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t('Delete') }}
|
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t("Delete") }} </b-button>
|
||||||
</b-button>
|
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t("Save") }}</b-button>
|
||||||
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t('Save') }}</b-button>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -90,14 +98,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Vue from "vue";
|
import Vue from "vue"
|
||||||
import {BootstrapVue} from "bootstrap-vue";
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
import GenericMultiselect from "./GenericMultiselect";
|
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||||
import {ApiMixin} from "../utils/utils";
|
import { ApiMixin, getUserPreference } from "@/utils/utils"
|
||||||
|
|
||||||
const {ApiApiFactory} = require("@/utils/openapi/api");
|
const { ApiApiFactory } = require("@/utils/openapi/api")
|
||||||
|
|
||||||
const {StandardToasts} = require("@/utils/utils");
|
const { StandardToasts } = require("@/utils/utils")
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
@ -107,47 +115,58 @@ export default {
|
|||||||
entry: Object,
|
entry: Object,
|
||||||
entryEditing_initial_recipe: Array,
|
entryEditing_initial_recipe: Array,
|
||||||
entryEditing_initial_meal_type: Array,
|
entryEditing_initial_meal_type: Array,
|
||||||
|
entryEditing_inital_servings: Number,
|
||||||
modal_title: String,
|
modal_title: String,
|
||||||
modal_id: {
|
modal_id: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "edit-modal"
|
default: "edit-modal",
|
||||||
},
|
},
|
||||||
allow_delete: {
|
allow_delete: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
mixins: [ApiMixin],
|
mixins: [ApiMixin],
|
||||||
components: {
|
components: {
|
||||||
GenericMultiselect,
|
GenericMultiselect,
|
||||||
RecipeCard: () => import('@/components/RecipeCard.vue')
|
RecipeCard: () => import("@/components/RecipeCard.vue"),
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
entryEditing: {},
|
entryEditing: {},
|
||||||
missing_recipe: false,
|
missing_recipe: false,
|
||||||
missing_meal_type: false,
|
missing_meal_type: false,
|
||||||
default_plan_share: []
|
default_plan_share: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
entry: {
|
entry: {
|
||||||
handler() {
|
handler() {
|
||||||
this.entryEditing = Object.assign({}, this.entry)
|
this.entryEditing = Object.assign({}, this.entry)
|
||||||
},
|
if (this.entryEditing_inital_servings) {
|
||||||
deep: true
|
this.entryEditing.servings = this.entryEditing_inital_servings
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted: function () {},
|
||||||
|
computed: {
|
||||||
|
autoMealPlan: function () {
|
||||||
|
return getUserPreference("mealplan_autoadd_shopping")
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
showModal() {
|
showModal() {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.listUserPreferences().then(result => {
|
apiClient.listUserPreferences().then((result) => {
|
||||||
if (this.entry.id === -1) {
|
if (this.entry.id === -1) {
|
||||||
this.entryEditing.shared = result.data[0].plan_share
|
this.entryEditing.shared = result.data[0].plan_share
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
editEntry() {
|
editEntry() {
|
||||||
this.missing_meal_type = false
|
this.missing_meal_type = false
|
||||||
this.missing_recipe = false
|
this.missing_recipe = false
|
||||||
@ -156,41 +175,44 @@ export default {
|
|||||||
this.missing_meal_type = true
|
this.missing_meal_type = true
|
||||||
cancel = true
|
cancel = true
|
||||||
}
|
}
|
||||||
if (this.entryEditing.recipe == null && this.entryEditing.title === '') {
|
if (this.entryEditing.recipe == null && this.entryEditing.title === "") {
|
||||||
this.missing_recipe = true
|
this.missing_recipe = true
|
||||||
cancel = true
|
cancel = true
|
||||||
}
|
}
|
||||||
if (!cancel) {
|
if (!cancel) {
|
||||||
this.$bvModal.hide(`edit-modal`);
|
this.$bvModal.hide(`edit-modal`)
|
||||||
this.$emit('save-entry', this.entryEditing)
|
this.$emit("save-entry", this.entryEditing)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteEntry() {
|
deleteEntry() {
|
||||||
this.$bvModal.hide(`edit-modal`);
|
this.$bvModal.hide(`edit-modal`)
|
||||||
this.$emit('delete-entry', this.entryEditing)
|
this.$emit("delete-entry", this.entryEditing)
|
||||||
},
|
},
|
||||||
selectMealType(event) {
|
selectMealType(event) {
|
||||||
this.missing_meal_type = false
|
this.missing_meal_type = false
|
||||||
if (event.val != null) {
|
if (event.val != null) {
|
||||||
this.entryEditing.meal_type = event.val;
|
this.entryEditing.meal_type = event.val
|
||||||
} else {
|
} else {
|
||||||
this.entryEditing.meal_type = null;
|
this.entryEditing.meal_type = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectShared(event) {
|
selectShared(event) {
|
||||||
if (event.val != null) {
|
if (event.val != null) {
|
||||||
this.entryEditing.shared = event.val;
|
this.entryEditing.shared = event.val
|
||||||
} else {
|
} else {
|
||||||
this.entryEditing.meal_type = null;
|
this.entryEditing.meal_type = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createMealType(event) {
|
createMealType(event) {
|
||||||
if (event != "") {
|
if (event != "") {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
apiClient.createMealType({name: event}).then(e => {
|
apiClient
|
||||||
this.$emit('reload-meal-types')
|
.createMealType({ name: event })
|
||||||
}).catch(error => {
|
.then((e) => {
|
||||||
|
this.$emit("reload-meal-types")
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -198,19 +220,17 @@ export default {
|
|||||||
selectRecipe(event) {
|
selectRecipe(event) {
|
||||||
this.missing_recipe = false
|
this.missing_recipe = false
|
||||||
if (event.val != null) {
|
if (event.val != null) {
|
||||||
this.entryEditing.recipe = event.val;
|
this.entryEditing.recipe = event.val
|
||||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||||
this.entryEditing.servings = this.entryEditing.recipe.servings
|
this.entryEditing.servings = this.entryEditing.recipe.servings
|
||||||
} else {
|
} else {
|
||||||
this.entryEditing.recipe = null;
|
this.entryEditing.recipe = null
|
||||||
this.entryEditing.title_placeholder = ""
|
this.entryEditing.title_placeholder = ""
|
||||||
this.entryEditing.servings = 1
|
this.entryEditing.servings = 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
122
vue/src/components/Modals/AddRecipeToBook.vue
Normal file
122
vue/src/components/Modals/AddRecipeToBook.vue
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b-modal class="modal" :id="`id_modal_add_book_${modal_id}`" :title="$t('Manage_Books')" :ok-title="$t('Add')"
|
||||||
|
:cancel-title="$t('Close')" @ok="addToBook()" @shown="loadBookEntries">
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center" v-for="be in this.recipe_book_list" v-bind:key="be.id">
|
||||||
|
{{ be.book_content.name }} <span class="btn btn-sm btn-danger" @click="removeFromBook(be)"><i class="fa fa-trash-alt"></i></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<multiselect
|
||||||
|
style="margin-top: 1vh"
|
||||||
|
v-model="selected_book"
|
||||||
|
:options="books_filtered"
|
||||||
|
:taggable="true"
|
||||||
|
@tag="createBook"
|
||||||
|
v-bind:tag-placeholder="$t('Create')"
|
||||||
|
:placeholder="$t('Select_Book')"
|
||||||
|
label="name"
|
||||||
|
track-by="id"
|
||||||
|
id="id_books"
|
||||||
|
:multiple="false"
|
||||||
|
:loading="books_loading"
|
||||||
|
@search-change="loadBooks">
|
||||||
|
</multiselect>
|
||||||
|
</b-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import Multiselect from 'vue-multiselect'
|
||||||
|
|
||||||
|
import moment from 'moment'
|
||||||
|
|
||||||
|
Vue.prototype.moment = moment
|
||||||
|
|
||||||
|
import Vue from "vue";
|
||||||
|
import {BootstrapVue} from "bootstrap-vue";
|
||||||
|
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||||
|
import {makeStandardToast, StandardToasts} from "@/utils/utils";
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AddRecipeToBook',
|
||||||
|
components: {
|
||||||
|
Multiselect
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
recipe: Object,
|
||||||
|
modal_id: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
books: [],
|
||||||
|
books_loading: false,
|
||||||
|
recipe_book_list: [],
|
||||||
|
selected_book: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
books_filtered: function () {
|
||||||
|
let books_filtered = []
|
||||||
|
|
||||||
|
this.books.forEach(b => {
|
||||||
|
if (this.recipe_book_list.filter(e => e.book === b.id).length === 0) {
|
||||||
|
books_filtered.push(b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return books_filtered
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadBooks: function (query) {
|
||||||
|
this.books_loading = true
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
apiFactory.listRecipeBooks({query: {query: query}}).then(results => {
|
||||||
|
this.books = results.data.filter(e => this.recipe_book_list.indexOf(e) === -1)
|
||||||
|
this.books_loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
createBook: function (name) {
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
apiFactory.createRecipeBook({name: name}).then(r => {
|
||||||
|
this.books.push(r.data)
|
||||||
|
this.selected_book = r.data
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addToBook: function () {
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
apiFactory.createRecipeBookEntry({book: this.selected_book.id, recipe: this.recipe.id}).then(r => {
|
||||||
|
this.recipe_book_list.push(r.data)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeFromBook: function (book_entry) {
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
apiFactory.destroyRecipeBookEntry(book_entry.id).then(r => {
|
||||||
|
this.recipe_book_list = this.recipe_book_list.filter(e => e.id !== book_entry.id)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
loadBookEntries: function () {
|
||||||
|
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
apiFactory.listRecipeBookEntrys({query: {recipe: this.recipe.id}}).then(r => {
|
||||||
|
this.recipe_book_list = r.data
|
||||||
|
this.loadBooks('')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
@ -1,20 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<b-modal :id="'modal_' + id" @hidden="cancelAction">
|
<b-modal :id="'modal_' + id" @hidden="cancelAction">
|
||||||
<template v-slot:modal-title
|
<template v-slot:modal-title>
|
||||||
><h4>{{ form.title }}</h4></template
|
<h4>{{ form.title }}</h4>
|
||||||
>
|
</template>
|
||||||
<div v-for="(f, i) in form.fields" v-bind:key="i">
|
<div v-for="(f, i) in form.fields" v-bind:key="i">
|
||||||
<p v-if="f.type == 'instruction'">{{ f.label }}</p>
|
<p v-if="visibleCondition(f, 'instruction')">{{ f.label }}</p>
|
||||||
<!-- this lookup is single selection -->
|
<lookup-input v-if="visibleCondition(f, 'lookup')" :form="f" :model="listModel(f.list)" @change="storeValue" />
|
||||||
<lookup-input v-if="f.type == 'lookup'" :form="f" :model="listModel(f.list)" @change="storeValue" />
|
<checkbox-input class="mb-3" v-if="visibleCondition(f, 'checkbox')" :label="f.label" :value="f.value" :field="f.field" />
|
||||||
<!-- TODO add ability to create new items associated with lookup -->
|
<text-input v-if="visibleCondition(f, 'text')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />
|
||||||
<!-- TODO: add multi-selection input list -->
|
<choice-input v-if="visibleCondition(f, 'choice')" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
|
||||||
<checkbox-input v-if="f.type == 'checkbox'" :label="f.label" :value="f.value" :field="f.field" />
|
<emoji-input v-if="visibleCondition(f, 'emoji')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
||||||
<text-input v-if="f.type == 'text'" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />
|
<file-input v-if="visibleCondition(f, 'file')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
||||||
<choice-input v-if="f.type == 'choice'" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
|
<small-text v-if="visibleCondition(f, 'smalltext')" :value="f.value" />
|
||||||
<emoji-input v-if="f.type == 'emoji'" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
|
||||||
<file-input v-if="f.type == 'file'" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-slot:modal-footer>
|
<template v-slot:modal-footer>
|
||||||
@ -28,7 +26,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)
|
||||||
|
|
||||||
@ -40,14 +38,20 @@ import TextInput from "@/components/Modals/TextInput"
|
|||||||
import EmojiInput from "@/components/Modals/EmojiInput"
|
import EmojiInput from "@/components/Modals/EmojiInput"
|
||||||
import ChoiceInput from "@/components/Modals/ChoiceInput"
|
import ChoiceInput from "@/components/Modals/ChoiceInput"
|
||||||
import FileInput from "@/components/Modals/FileInput"
|
import FileInput from "@/components/Modals/FileInput"
|
||||||
|
import SmallText from "@/components/Modals/SmallText"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "GenericModalForm",
|
name: "GenericModalForm",
|
||||||
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput },
|
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput, SmallText },
|
||||||
mixins: [ApiMixin, ToastMixin],
|
mixins: [ApiMixin, ToastMixin],
|
||||||
props: {
|
props: {
|
||||||
model: { required: true, type: Object },
|
model: { required: true, type: Object },
|
||||||
action: { type: Object },
|
action: {
|
||||||
|
type: Object,
|
||||||
|
default() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
item1: {
|
item1: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default() {
|
default() {
|
||||||
@ -84,6 +88,9 @@ 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)
|
||||||
|
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 {
|
||||||
@ -245,6 +252,21 @@ export default {
|
|||||||
apiClient.createAutomation(automation)
|
apiClient.createAutomation(automation)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
visibleCondition(field, field_type) {
|
||||||
|
let type_match = field?.type == field_type
|
||||||
|
let checks = true
|
||||||
|
if (type_match && field?.condition) {
|
||||||
|
if (field.condition?.condition === "exists") {
|
||||||
|
if ((this.item1[field.condition.field] != undefined) === field.condition.value) {
|
||||||
|
checks = true
|
||||||
|
} else {
|
||||||
|
checks = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return type_match && checks
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -80,8 +80,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
arrayValues = [{ id: -1, name: this_value }]
|
arrayValues = [{ id: -1, name: this_value }]
|
||||||
}
|
}
|
||||||
|
if (this.form?.ordered && this.first_run && arrayValues.length > 0) {
|
||||||
if (this.form?.ordered && this.first_run) {
|
|
||||||
return this.flattenItems(arrayValues)
|
return this.flattenItems(arrayValues)
|
||||||
} else {
|
} else {
|
||||||
return arrayValues
|
return arrayValues
|
||||||
|
177
vue/src/components/Modals/ShoppingModal.vue
Normal file
177
vue/src/components/Modals/ShoppingModal.vue
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<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()
|
||||||
|
.filter((x) => !x?.food?.food_onhand)
|
||||||
|
.map((x) => x.id),
|
||||||
|
]
|
||||||
|
this.recipe_servings = result.data?.servings
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// 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()
|
||||||
|
.filter((x) => !x.food.override_ignore)
|
||||||
|
.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>
|
20
vue/src/components/Modals/SmallText.vue
Normal file
20
vue/src/components/Modals/SmallText.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="small text-muted">
|
||||||
|
{{ value }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "TextInput",
|
||||||
|
props: {
|
||||||
|
value: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
watch: {},
|
||||||
|
methods: {},
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,26 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<b-form-group
|
<b-form-group v-bind:label="label" class="mb-3">
|
||||||
v-bind:label="label"
|
<b-form-input v-model="new_value" type="text" :placeholder="placeholder"></b-form-input>
|
||||||
class="mb-3">
|
|
||||||
<b-form-input
|
|
||||||
v-model="new_value"
|
|
||||||
type="string"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
></b-form-input>
|
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TextInput',
|
name: "TextInput",
|
||||||
props: {
|
props: {
|
||||||
field: {type: String, default: 'You Forgot To Set Field Name'},
|
field: { type: String, default: "You Forgot To Set Field Name" },
|
||||||
label: {type: String, default: 'Text Field'},
|
label: { type: String, default: "Text Field" },
|
||||||
value: {type: String, default: ''},
|
value: { type: String, default: "" },
|
||||||
placeholder: {type: String, default: 'You Should Add Placeholder Text'},
|
placeholder: { type: String, default: "You Should Add Placeholder Text" },
|
||||||
show_merge: { type: Boolean, default: false },
|
show_merge: { type: Boolean, default: false },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@ -32,11 +25,10 @@ export default {
|
|||||||
this.new_value = this.value
|
this.new_value = this.value
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'new_value': function () {
|
new_value: function () {
|
||||||
this.$root.$emit('change', this.field, this.new_value)
|
this.$root.$emit("change", this.field, this.new_value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {},
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
@ -1,74 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- <b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button> -->
|
|
||||||
<span>
|
|
||||||
<b-dropdown variant="link" toggle-class="text-decoration-none text-dark shadow-none" no-caret
|
|
||||||
style="boundary:window">
|
|
||||||
<template #button-content>
|
|
||||||
<i class="fas fa-chevron-down"></i>
|
|
||||||
</template>
|
|
||||||
<b-dropdown-item :href="resolveDjangoUrl('list_food')">
|
|
||||||
<i class="fas fa-leaf fa-fw"></i> {{ Models['FOOD'].name }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')">
|
|
||||||
<i class="fas fa-tags fa-fw"></i> {{ Models['KEYWORD'].name }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item :href="resolveDjangoUrl('list_unit')">
|
|
||||||
<i class="fas fa-balance-scale fa-fw"></i> {{ Models['UNIT'].name }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')">
|
|
||||||
<i class="fas fa-store-alt fa-fw"></i> {{ Models['SUPERMARKET'].name }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')">
|
|
||||||
<i class="fas fa-cubes fa-fw"></i> {{ Models['SHOPPING_CATEGORY'].name }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item :href="resolveDjangoUrl('list_automation')">
|
|
||||||
<i class="fas fa-robot fa-fw"></i> {{ Models['AUTOMATION'].name }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')">
|
|
||||||
<i class="fas fa-file fa-fw"></i> {{ Models['USERFILE'].name }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
<b-dropdown-item :href="resolveDjangoUrl('list_step')">
|
|
||||||
<i class="fas fa-puzzle-piece fa-fw"></i>{{ Models['STEP'].name }}
|
|
||||||
</b-dropdown-item>
|
|
||||||
|
|
||||||
</b-dropdown>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
import Vue from 'vue'
|
|
||||||
import {BootstrapVue} from 'bootstrap-vue'
|
|
||||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
|
||||||
|
|
||||||
import {Models} from "@/utils/models";
|
|
||||||
import {ResolveUrlMixin} from "@/utils/utils";
|
|
||||||
|
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ModelMenu',
|
|
||||||
mixins: [ResolveUrlMixin],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
Models: Models
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
gotoURL: function (model) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
@ -1,35 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<b-card no-body v-hover v-if="recipe">
|
||||||
|
|
||||||
<b-card no-body v-hover>
|
|
||||||
<a :href="clickUrl()">
|
<a :href="clickUrl()">
|
||||||
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src=recipe_image
|
<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>
|
||||||
v-bind:alt="$t('Recipe_Image')"
|
|
||||||
top></b-card-img-lazy>
|
|
||||||
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
|
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
|
||||||
<a>
|
<a>
|
||||||
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
|
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2"
|
<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">
|
||||||
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="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0"><i class="fa fa-clock"></i>
|
<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>
|
||||||
{{ 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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
||||||
<b-card-body class="p-4">
|
<b-card-body class="p-4">
|
||||||
<h6><a :href="clickUrl()">
|
<h6>
|
||||||
|
<a :href="clickUrl()">
|
||||||
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
||||||
<template v-else>{{ meal_plan.title }}</template>
|
<template v-else>{{ meal_plan.title }}</template>
|
||||||
</a></h6>
|
</a>
|
||||||
|
</h6>
|
||||||
|
|
||||||
<b-card-text style="text-overflow: ellipsis;">
|
<b-card-text style="text-overflow: ellipsis">
|
||||||
<template v-if="recipe !== null">
|
<template v-if="recipe !== null">
|
||||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||||
<template v-if="recipe.description !== null">
|
<template v-if="recipe.description !== null">
|
||||||
@ -45,71 +37,55 @@
|
|||||||
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
|
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
|
||||||
</p>
|
</p>
|
||||||
<transition name="fade" mode="in-out">
|
<transition name="fade" mode="in-out">
|
||||||
<div class="row mt-3" v-if="detailed">
|
<div class="row mt-3" v-if="show_detail">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h6>
|
<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 -->
|
<ingredients-card :steps="recipe.steps" :header="false" :detailed="false" :servings="recipe.servings" />
|
||||||
<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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge>
|
<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)))">
|
|
||||||
{{ $t('New') }}
|
|
||||||
</b-badge> -->
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ meal_plan.note }}</template>
|
<template v-else>{{ meal_plan.note }}</template>
|
||||||
</b-card-text>
|
</b-card-text>
|
||||||
</b-card-body>
|
</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-footer v-if="footer_text !== undefined">
|
|
||||||
<i v-bind:class="footer_icon"></i> {{ footer_text }}
|
|
||||||
</b-card-footer>
|
|
||||||
</b-card>
|
</b-card>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import RecipeContextMenu from "@/components/RecipeContextMenu";
|
import RecipeContextMenu from "@/components/RecipeContextMenu"
|
||||||
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils";
|
import KeywordsComponent from "@/components/KeywordsComponent"
|
||||||
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, KeywordsComponent, RecipeContextMenu, IngredientsCard },
|
||||||
],
|
|
||||||
components: {LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientComponent},
|
|
||||||
props: {
|
props: {
|
||||||
recipe: Object,
|
recipe: Object,
|
||||||
meal_plan: Object,
|
meal_plan: Object,
|
||||||
footer_text: String,
|
footer_text: String,
|
||||||
footer_icon: String
|
footer_icon: String,
|
||||||
|
detailed: { type: Boolean, default: true },
|
||||||
},
|
},
|
||||||
|
mounted() {},
|
||||||
computed: {
|
computed: {
|
||||||
detailed: function () {
|
show_detail: function () {
|
||||||
return this.recipe.steps !== undefined;
|
return this.recipe?.steps !== undefined && this.detailed
|
||||||
},
|
},
|
||||||
text_length: function () {
|
text_length: function () {
|
||||||
if (this.detailed) {
|
if (this.show_detail) {
|
||||||
return 200
|
return 200
|
||||||
} else {
|
} else {
|
||||||
return 120
|
return 120
|
||||||
@ -121,36 +97,37 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
return this.recipe.image
|
return this.recipe.image
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// TODO: convert this to genericAPI
|
// TODO: convert this to genericAPI
|
||||||
clickUrl: function () {
|
clickUrl: function () {
|
||||||
if (this.recipe !== null) {
|
if (this.recipe !== null) {
|
||||||
return resolveDjangoUrl('view_recipe', this.recipe.id)
|
return resolveDjangoUrl("view_recipe", this.recipe.id)
|
||||||
} else {
|
} else {
|
||||||
return resolveDjangoUrl('view_plan_entry', this.meal_plan.id)
|
return resolveDjangoUrl("view_plan_entry", this.meal_plan.id)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
directives: {
|
directives: {
|
||||||
hover: {
|
hover: {
|
||||||
inserted: function (el) {
|
inserted: function (el) {
|
||||||
el.addEventListener('mouseenter', () => {
|
el.addEventListener("mouseenter", () => {
|
||||||
el.classList.add("shadow")
|
el.classList.add("shadow")
|
||||||
});
|
})
|
||||||
el.addEventListener('mouseleave', () => {
|
el.addEventListener("mouseleave", () => {
|
||||||
el.classList.remove("shadow")
|
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;
|
||||||
|
@ -1,58 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<div class="dropdown d-print-none">
|
<div class="dropdown d-print-none">
|
||||||
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink"
|
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
|
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
|
||||||
|
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
|
||||||
|
|
||||||
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i
|
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
|
||||||
class="fas fa-pencil-alt fa-fw"></i> {{ $t('Edit') }}</a>
|
|
||||||
|
|
||||||
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i
|
|
||||||
class="fas fa-exchange-alt fa-fw"></i> {{ $t('convert_internal') }}</a>
|
|
||||||
|
|
||||||
<a href="javascript:void(0);">
|
<a href="javascript:void(0);">
|
||||||
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)">
|
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}</button>
|
||||||
<i class="fas fa-bookmark fa-fw"></i> {{ $t('Manage_Books') }}
|
|
||||||
</button>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping') }?r=[${recipe.id},${servings_value}]`"
|
<a class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`" v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
|
||||||
v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
|
<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" v-if="recipe.internal" @click="addToShopping" href="#"> <i class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
|
||||||
|
|
||||||
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
|
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
|
||||||
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
|
|
||||||
|
<a href="javascript:void(0);">
|
||||||
|
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}</button>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="javascript:void(0);">
|
<a href="javascript:void(0);">
|
||||||
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i
|
<button class="dropdown-item" onclick="window.print()"><i class="fas fa-print fa-fw"></i> {{ $t("Print") }}</button>
|
||||||
class="fas fa-clipboard-list fa-fw"></i> {{ $t('Log_Cooking') }}
|
|
||||||
</button>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="javascript:void(0);">
|
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank" rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
|
||||||
<button class="dropdown-item" onclick="window.print()"><i
|
|
||||||
class="fas fa-print fa-fw"></i> {{ $t('Print') }}
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank"
|
|
||||||
rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t('Export') }}</a>
|
|
||||||
|
|
||||||
<a href="javascript:void(0);">
|
<a href="javascript:void(0);">
|
||||||
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i
|
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}</button>
|
||||||
class="fas fa-share-alt fa-fw"></i> {{ $t('Share') }}
|
|
||||||
</button>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<cook-log :recipe="recipe" :modal_id="modal_id"></cook-log>
|
<cook-log :recipe="recipe" :modal_id="modal_id"></cook-log>
|
||||||
@ -61,46 +43,50 @@
|
|||||||
<b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer>
|
<b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<label v-if="recipe_share_link !== undefined">{{ $t('Public share link') }}</label>
|
<label v-if="recipe_share_link !== undefined">{{ $t("Public share link") }}</label>
|
||||||
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link" />
|
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link" />
|
||||||
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary"
|
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary" @click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }} </b-button>
|
||||||
@click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t('Close') }}
|
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{ $t("Copy") }}</b-button>
|
||||||
</b-button>
|
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{ $t("Share") }} <i class="fa fa-share-alt"></i></b-button>
|
||||||
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{ $t('Copy') }}</b-button>
|
|
||||||
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{ $t('Share') }} <i
|
|
||||||
class="fa fa-share-alt"></i></b-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</b-modal>
|
</b-modal>
|
||||||
|
|
||||||
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]"
|
<meal-plan-edit-modal
|
||||||
:entry-editing_initial_meal_type="[]" @save-entry="saveMealPlan"
|
:entry="entryEditing"
|
||||||
:modal_id="`modal-meal-plan_${modal_id}`" :allow_delete="false" :modal_title="$t('Create_Meal_Plan_Entry')"></meal-plan-edit-modal>
|
:entryEditing_initial_recipe="[recipe]"
|
||||||
|
:entryEditing_inital_servings="recipe.servings"
|
||||||
|
: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>
|
||||||
|
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
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 "@/components/Modals/AddRecipeToBook"
|
||||||
import AddRecipeToBook from "./AddRecipeToBook";
|
import MealPlanEditModal from "@/components/MealPlanEditModal"
|
||||||
import MealPlanEditModal from "@/components/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"
|
||||||
|
|
||||||
Vue.prototype.moment = moment
|
Vue.prototype.moment = moment
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RecipeContextMenu',
|
name: "RecipeContextMenu",
|
||||||
mixins: [
|
mixins: [ResolveUrlMixin],
|
||||||
ResolveUrlMixin
|
|
||||||
],
|
|
||||||
components: {
|
components: {
|
||||||
AddRecipeToBook,
|
AddRecipeToBook,
|
||||||
CookLog,
|
CookLog,
|
||||||
MealPlanEditModal
|
MealPlanEditModal,
|
||||||
|
ShoppingModal,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -117,9 +103,9 @@ export default {
|
|||||||
recipe: null,
|
recipe: null,
|
||||||
servings: 1,
|
servings: 1,
|
||||||
shared: [],
|
shared: [],
|
||||||
title: '',
|
title: "",
|
||||||
title_placeholder: this.$t('Title')
|
title_placeholder: this.$t("Title"),
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
entryEditing: {},
|
entryEditing: {},
|
||||||
}
|
}
|
||||||
@ -128,56 +114,62 @@ export default {
|
|||||||
recipe: Object,
|
recipe: Object,
|
||||||
servings: {
|
servings: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: -1
|
default: -1,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.servings_value = ((this.servings === -1) ? this.recipe.servings : this.servings)
|
this.servings_value = this.servings === -1 ? this.recipe.servings : this.servings
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
saveMealPlan: function (entry) {
|
saveMealPlan: function (entry) {
|
||||||
entry.date = moment(entry.date).format("YYYY-MM-DD")
|
entry.date = moment(entry.date).format("YYYY-MM-DD")
|
||||||
|
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
apiClient
|
||||||
apiClient.createMealPlan(entry).then(result => {
|
.createMealPlan(entry)
|
||||||
|
.then((result) => {
|
||||||
this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`)
|
this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`)
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
}).catch(error => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
createMealPlan(data) {
|
createMealPlan(data) {
|
||||||
this.entryEditing = this.options.entryEditing
|
this.entryEditing = this.options.entryEditing
|
||||||
this.entryEditing.recipe = this.recipe
|
this.entryEditing.recipe = this.recipe
|
||||||
this.entryEditing.date = moment(new Date()).format('YYYY-MM-DD')
|
this.entryEditing.date = moment(new Date()).format("YYYY-MM-DD")
|
||||||
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
|
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
|
||||||
},
|
},
|
||||||
createShareLink: function () {
|
createShareLink: function () {
|
||||||
axios.get(resolveDjangoUrl('api_share_link', this.recipe.id)).then(result => {
|
axios
|
||||||
|
.get(resolveDjangoUrl("api_share_link", this.recipe.id))
|
||||||
|
.then((result) => {
|
||||||
this.$bvModal.show(`modal-share-link_${this.modal_id}`)
|
this.$bvModal.show(`modal-share-link_${this.modal_id}`)
|
||||||
this.recipe_share_link = result.data.link
|
this.recipe_share_link = result.data.link
|
||||||
}).catch(err => {
|
})
|
||||||
|
.catch((err) => {
|
||||||
if (err.response.status === 403) {
|
if (err.response.status === 403) {
|
||||||
makeToast(this.$t('Share'), this.$t('Sharing is not enabled for this space.'), 'danger')
|
makeToast(this.$t("Share"), this.$t("Sharing is not enabled for this space."), "danger")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
},
|
},
|
||||||
copyShareLink: function () {
|
copyShareLink: function () {
|
||||||
let share_input = this.$refs.share_link_ref;
|
let share_input = this.$refs.share_link_ref
|
||||||
share_input.select();
|
share_input.select()
|
||||||
document.execCommand("copy");
|
document.execCommand("copy")
|
||||||
},
|
},
|
||||||
shareIntend: function () {
|
shareIntend: function () {
|
||||||
let shareData = {
|
let shareData = {
|
||||||
title: this.recipe.name,
|
title: this.recipe.name,
|
||||||
text: `${this.$t('Check out this recipe: ')} ${this.recipe.name}`,
|
text: `${this.$t("Check out this recipe: ")} ${this.recipe.name}`,
|
||||||
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>
|
||||||
|
299
vue/src/components/ShoppingLineItem.vue
Normal file
299
vue/src/components/ShoppingLineItem.vue
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
<template>
|
||||||
|
<div id="shopping_line_item">
|
||||||
|
<div class="col-12">
|
||||||
|
<b-container fluid>
|
||||||
|
<!-- summary rows -->
|
||||||
|
<b-row align-h="start">
|
||||||
|
<b-col cols="12" sm="2">
|
||||||
|
<div style="position: static" class="btn-group">
|
||||||
|
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
|
||||||
|
<button
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
type="button"
|
||||||
|
class="btn dropdown-toggle btn-link text-decoration-none text-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>
|
||||||
|
</b-col>
|
||||||
|
<b-col cols="12" sm="10">
|
||||||
|
<b-row>
|
||||||
|
<b-col cols="6" 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>
|
||||||
|
</b-col>
|
||||||
|
|
||||||
|
<b-col cols="6" sm="7">
|
||||||
|
{{ formatFood }}
|
||||||
|
</b-col>
|
||||||
|
<b-col cols="6" sm="2" data-html2canvas-ignore="true">
|
||||||
|
<b-button size="sm" @click="showDetails = !showDetails" class="mr-2" variant="link">
|
||||||
|
<div class="text-nowrap">{{ showDetails ? "Hide" : "Show" }} Details</div>
|
||||||
|
</b-button>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
<b-row align-h="center">
|
||||||
|
<b-col cols="12">
|
||||||
|
<div class="small text-muted text-truncate">{{ formatHint }}</div>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
</b-container>
|
||||||
|
<!-- detail rows -->
|
||||||
|
<div class="card no-body" v-if="showDetails">
|
||||||
|
<b-container fluid>
|
||||||
|
<div v-for="e in entries" :key="e.id">
|
||||||
|
<b-row class="ml-2 small">
|
||||||
|
<b-col cols="6" md="4" class="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>
|
||||||
|
</b-col>
|
||||||
|
<b-col cols="6" md="4" class="col-md-4 text-muted">{{ formatOneMealPlan(e) }}</b-col>
|
||||||
|
<b-col cols="12" md="4" class="col-md-4 text-muted text-right overflow-hidden text-nowrap">
|
||||||
|
{{ formatOneCreatedBy(e) }}
|
||||||
|
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
|
||||||
|
<b-row class="ml-2 light">
|
||||||
|
<b-col cols="12" sm="2">
|
||||||
|
<div style="position: static" class="btn-group">
|
||||||
|
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
|
||||||
|
<button
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
type="button"
|
||||||
|
class="btn dropdown-toggle btn-link text-decoration-none text-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>
|
||||||
|
</b-col>
|
||||||
|
<b-col cols="12" sm="10">
|
||||||
|
<b-row>
|
||||||
|
<b-col cols="2" sm="2" md="1" class="text-nowrap">{{ formatOneAmount(e) }}</b-col>
|
||||||
|
<b-col cols="10" sm="4" md="2" class="text-nowrap">{{ formatOneUnit(e) }}</b-col>
|
||||||
|
|
||||||
|
<b-col cols="12" sm="6" md="4" class="text-nowrap">{{ formatOneFood(e) }}</b-col>
|
||||||
|
|
||||||
|
<b-col cols="12" sm="6" md="5">
|
||||||
|
<div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
|
||||||
|
<hr class="w-75" />
|
||||||
|
</div>
|
||||||
|
</b-container>
|
||||||
|
</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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for (const [k, v] of Object.entries(amount)) {
|
||||||
|
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
|
||||||
|
}
|
||||||
|
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(" ")
|
||||||
|
|
||||||
|
return mealplan_name
|
||||||
|
.map((x) => {
|
||||||
|
return this.formatOneMealPlan(x)
|
||||||
|
})
|
||||||
|
.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 false
|
||||||
|
}
|
||||||
|
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
|
||||||
|
},
|
||||||
|
formatOneFood: function (item) {
|
||||||
|
return item.food.name
|
||||||
|
},
|
||||||
|
formatOneDelayUntil: function (item) {
|
||||||
|
if (!item.delay_until || (item.delay_until && item.checked)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
|
||||||
|
},
|
||||||
|
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 [this.$t("Added_by"), 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) {
|
||||||
|
let update = undefined
|
||||||
|
if (!item) {
|
||||||
|
update = { entries: this.entries.map((x) => x.id), checked: !this.formatChecked }
|
||||||
|
} else {
|
||||||
|
update = { entries: [item], checked: !item.checked }
|
||||||
|
}
|
||||||
|
this.$emit("update-checkbox", update)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</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>
|
@ -1,5 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
@ -8,47 +7,38 @@
|
|||||||
<div class="col col-md-8">
|
<div class="col col-md-8">
|
||||||
<h5 class="text-primary">
|
<h5 class="text-primary">
|
||||||
<template v-if="step.name">{{ step.name }}</template>
|
<template v-if="step.name">{{ step.name }}</template>
|
||||||
<template v-else>{{ $t('Step') }} {{ index + 1 }}</template>
|
<template v-else>{{ $t("Step") }} {{ index + 1 }}</template>
|
||||||
<small style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fas fa-user-clock"></i>
|
<small style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fas fa-user-clock"></i> {{ step.time }} {{ $t("min") }} </small>
|
||||||
{{ step.time }} {{ $t('min') }}
|
|
||||||
|
|
||||||
</small>
|
|
||||||
<small v-if="start_time !== ''" class="d-print-none">
|
<small v-if="start_time !== ''" class="d-print-none">
|
||||||
<b-link :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#">
|
<b-link :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#">
|
||||||
{{ moment(start_time).add(step.time_offset, 'minutes').format('HH:mm') }}
|
{{ moment(start_time).add(step.time_offset, "minutes").format("HH:mm") }}
|
||||||
</b-link>
|
</b-link>
|
||||||
</small>
|
</small>
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-4" style="text-align: right">
|
<div class="col col-md-4" style="text-align: right">
|
||||||
<b-button @click="details_visible = !details_visible" style="border: none; background: none"
|
<b-button
|
||||||
|
@click="details_visible = !details_visible"
|
||||||
|
style="border: none; background: none"
|
||||||
class="shadow-none d-print-none"
|
class="shadow-none d-print-none"
|
||||||
:class="{ 'text-primary': details_visible, 'text-success': !details_visible}">
|
:class="{ 'text-primary': details_visible, 'text-success': !details_visible }"
|
||||||
|
>
|
||||||
<i class="far fa-check-circle"></i>
|
<i class="far fa-check-circle"></i>
|
||||||
</b-button>
|
</b-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="step.type === 'TEXT'">
|
<template v-if="step.type === 'TEXT'">
|
||||||
|
|
||||||
<b-collapse id="collapse-1" v-model="details_visible">
|
<b-collapse id="collapse-1" v-model="details_visible">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<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 :steps="[step]" :ingredient_factor="ingredient_factor" @checked-state-changed="$emit('checked-state-changed', $event)" />
|
||||||
<template v-for="i in step.ingredients">
|
|
||||||
<Ingredient-component v-bind:ingredient="i" :ingredient_factor="ingredient_factor" :key="i.id"
|
|
||||||
@checked-state-changed="$emit('checked-state-changed', i)"></Ingredient-component>
|
|
||||||
</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 }">
|
||||||
<compile-component :code="step.ingredients_markdown"
|
<compile-component :code="step.ingredients_markdown" :ingredient_factor="ingredient_factor"></compile-component>
|
||||||
:ingredient_factor="ingredient_factor"></compile-component>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</b-collapse>
|
</b-collapse>
|
||||||
@ -59,20 +49,21 @@
|
|||||||
<div class="col-md-8 offset-md-2" style="text-align: center">
|
<div class="col-md-8 offset-md-2" style="text-align: center">
|
||||||
<h4 class="text-primary">
|
<h4 class="text-primary">
|
||||||
<template v-if="step.name">{{ step.name }}</template>
|
<template v-if="step.name">{{ step.name }}</template>
|
||||||
<template v-else>{{ $t('Step') }} {{ index + 1 }}</template>
|
<template v-else>{{ $t("Step") }} {{ index + 1 }}</template>
|
||||||
</h4>
|
</h4>
|
||||||
<span style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fa fa-stopwatch"></i>
|
<span style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fa fa-stopwatch"></i> {{ step.time }} {{ $t("min") }}</span>
|
||||||
{{ step.time }} {{ $t('min') }}</span>
|
<b-link class="d-print-none" :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#" v-if="start_time !== ''">
|
||||||
<b-link class="d-print-none" :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#"
|
{{ moment(start_time).add(step.time_offset, "minutes").format("HH:mm") }}
|
||||||
v-if="start_time !== ''">
|
|
||||||
{{ moment(start_time).add(step.time_offset, 'minutes').format('HH:mm') }}
|
|
||||||
</b-link>
|
</b-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-2" style="text-align: right">
|
<div class="col-md-2" style="text-align: right">
|
||||||
<b-button @click="details_visible = !details_visible" style="border: none; background: none"
|
<b-button
|
||||||
|
@click="details_visible = !details_visible"
|
||||||
|
style="border: none; background: none"
|
||||||
class="shadow-none d-print-none"
|
class="shadow-none d-print-none"
|
||||||
:class="{ 'text-primary': details_visible, 'text-success': !details_visible}">
|
:class="{ 'text-primary': details_visible, 'text-success': !details_visible }"
|
||||||
|
>
|
||||||
<i class="far fa-check-circle"></i>
|
<i class="far fa-check-circle"></i>
|
||||||
</b-button>
|
</b-button>
|
||||||
</div>
|
</div>
|
||||||
@ -81,8 +72,7 @@
|
|||||||
<b-collapse id="collapse-1" v-model="details_visible">
|
<b-collapse id="collapse-1" v-model="details_visible">
|
||||||
<div class="row" v-if="step.instruction !== ''">
|
<div class="row" v-if="step.instruction !== ''">
|
||||||
<div class="col col-md-12" style="text-align: center">
|
<div class="col col-md-12" style="text-align: center">
|
||||||
<compile-component :code="step.ingredients_markdown"
|
<compile-component :code="step.ingredients_markdown" :ingredient_factor="ingredient_factor"></compile-component>
|
||||||
:ingredient_factor="ingredient_factor"></compile-component>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</b-collapse>
|
</b-collapse>
|
||||||
@ -91,20 +81,16 @@
|
|||||||
<div class="row" style="text-align: center">
|
<div class="row" style="text-align: center">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<template v-if="step.file !== null">
|
<template v-if="step.file !== null">
|
||||||
<div
|
<div v-if="step.file.file.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
||||||
v-if="step.file.file.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
<img :src="step.file.file" style="max-width: 50vw; max-height: 50vh" />
|
||||||
<img :src="step.file.file" style="max-width: 50vw; max-height: 50vh">
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<a :href="step.file.file" target="_blank" rel="noreferrer nofollow">{{ $t('Download') }} {{
|
<a :href="step.file.file" target="_blank" rel="noreferrer nofollow">{{ $t("Download") }} {{ $t("File") }}</a>
|
||||||
$t('File')
|
|
||||||
}}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="card" v-if="step.type === 'RECIPE' && step.step_recipe_data !== null">
|
<div class="card" v-if="step.type === 'RECIPE' && step.step_recipe_data !== null">
|
||||||
<b-collapse id="collapse-1" v-model="details_visible">
|
<b-collapse id="collapse-1" v-model="details_visible">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@ -112,72 +98,54 @@
|
|||||||
<a :href="resolveDjangoUrl('view_recipe', step.step_recipe_data.id)">{{ step.step_recipe_data.name }}</a>
|
<a :href="resolveDjangoUrl('view_recipe', step.step_recipe_data.id)">{{ step.step_recipe_data.name }}</a>
|
||||||
</h2>
|
</h2>
|
||||||
<div v-for="(sub_step, index) in step.step_recipe_data.steps" v-bind:key="`substep_${sub_step.id}`">
|
<div v-for="(sub_step, index) in step.step_recipe_data.steps" v-bind:key="`substep_${sub_step.id}`">
|
||||||
<step-component :recipe="step.step_recipe_data" :step="sub_step" :ingredient_factor="ingredient_factor" :index="index"
|
<step-component
|
||||||
:start_time="start_time" :force_ingredients="true"></step-component>
|
:recipe="step.step_recipe_data"
|
||||||
|
:step="sub_step"
|
||||||
|
:ingredient_factor="ingredient_factor"
|
||||||
|
:index="index"
|
||||||
|
:start_time="start_time"
|
||||||
|
:force_ingredients="true"
|
||||||
|
></step-component>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</b-collapse>
|
</b-collapse>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div v-if="start_time !== ''">
|
<div v-if="start_time !== ''">
|
||||||
<b-popover
|
<b-popover :target="`id_reactive_popover_${step.id}`" triggers="click" placement="bottom" :ref="`id_reactive_popover_${step.id}`" :title="$t('Step start time')">
|
||||||
:target="`id_reactive_popover_${step.id}`"
|
|
||||||
triggers="click"
|
|
||||||
placement="bottom"
|
|
||||||
:ref="`id_reactive_popover_${step.id}`"
|
|
||||||
:title="$t('Step start time')">
|
|
||||||
<div>
|
<div>
|
||||||
<b-form-group
|
<b-form-group label="Time" label-for="popover-input-1" label-cols="3" class="mb-1">
|
||||||
label="Time"
|
<b-form-input type="datetime-local" id="popover-input-1" v-model.datetime-local="set_time_input" size="sm"></b-form-input>
|
||||||
label-for="popover-input-1"
|
|
||||||
label-cols="3"
|
|
||||||
class="mb-1">
|
|
||||||
<b-form-input
|
|
||||||
type="datetime-local"
|
|
||||||
id="popover-input-1"
|
|
||||||
v-model.datetime-local="set_time_input"
|
|
||||||
size="sm"
|
|
||||||
></b-form-input>
|
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top: 1vh">
|
<div class="row" style="margin-top: 1vh">
|
||||||
<div class="col-12" style="text-align: right">
|
<div class="col-12" style="text-align: right">
|
||||||
<b-button @click="closePopover" size="sm" variant="secondary" style="margin-right:8px">Cancel</b-button>
|
<b-button @click="closePopover" size="sm" variant="secondary" style="margin-right: 8px">{{ $t("Cancel") }}</b-button>
|
||||||
<b-button @click="updateTime" size="sm" variant="primary">Ok</b-button>
|
<b-button @click="updateTime" size="sm" variant="primary">{{ $t("Ok") }}</b-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</b-popover>
|
</b-popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { calculateAmount } from "@/utils/utils"
|
||||||
|
|
||||||
import {calculateAmount} from "@/utils/utils";
|
import { GettextMixin } from "@/utils/utils"
|
||||||
|
|
||||||
import {GettextMixin} from "@/utils/utils";
|
import CompileComponent from "@/components/CompileComponent"
|
||||||
|
import IngredientsCard from "@/components/IngredientsCard"
|
||||||
import CompileComponent from "@/components/CompileComponent";
|
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";
|
|
||||||
import IngredientComponent from "@/components/IngredientComponent";
|
|
||||||
|
|
||||||
Vue.prototype.moment = moment
|
Vue.prototype.moment = moment
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'StepComponent',
|
name: "StepComponent",
|
||||||
mixins: [
|
mixins: [GettextMixin, ResolveUrlMixin],
|
||||||
GettextMixin,
|
components: { CompileComponent, IngredientsCard },
|
||||||
ResolveUrlMixin,
|
|
||||||
],
|
|
||||||
components: {
|
|
||||||
IngredientComponent,
|
|
||||||
CompileComponent,
|
|
||||||
},
|
|
||||||
props: {
|
props: {
|
||||||
step: Object,
|
step: Object,
|
||||||
ingredient_factor: Number,
|
ingredient_factor: Number,
|
||||||
@ -186,17 +154,17 @@ export default {
|
|||||||
start_time: String,
|
start_time: String,
|
||||||
force_ingredients: {
|
force_ingredients: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
details_visible: true,
|
details_visible: true,
|
||||||
set_time_input: '',
|
set_time_input: "",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.set_time_input = moment(this.start_time).add(this.step.time_offset, 'minutes').format('yyyy-MM-DDTHH:mm')
|
this.set_time_input = moment(this.start_time).add(this.step.time_offset, "minutes").format("yyyy-MM-DDTHH:mm")
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
calculateAmount: function (x) {
|
calculateAmount: function (x) {
|
||||||
@ -204,17 +172,19 @@ export default {
|
|||||||
return calculateAmount(x, this.ingredient_factor)
|
return calculateAmount(x, this.ingredient_factor)
|
||||||
},
|
},
|
||||||
updateTime: function () {
|
updateTime: function () {
|
||||||
let new_start_time = moment(this.set_time_input).add(this.step.time_offset * -1, 'minutes').format('yyyy-MM-DDTHH:mm')
|
let new_start_time = moment(this.set_time_input)
|
||||||
|
.add(this.step.time_offset * -1, "minutes")
|
||||||
|
.format("yyyy-MM-DDTHH:mm")
|
||||||
|
|
||||||
this.$emit('update-start-time', new_start_time)
|
this.$emit("update-start-time", new_start_time)
|
||||||
this.closePopover()
|
this.closePopover()
|
||||||
},
|
},
|
||||||
closePopover: function () {
|
closePopover: function () {
|
||||||
this.$refs[`id_reactive_popover_${this.step.id}`].$emit('close')
|
this.$refs[`id_reactive_popover_${this.step.id}`].$emit("close")
|
||||||
},
|
},
|
||||||
openPopover: function () {
|
openPopover: function () {
|
||||||
this.$refs[`id_reactive_popover_${this.step.id}`].$emit('open')
|
this.$refs[`id_reactive_popover_${this.step.id}`].$emit("open")
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -131,6 +131,7 @@
|
|||||||
"Root": "Root",
|
"Root": "Root",
|
||||||
"Ignore_Shopping": "Ignore Shopping",
|
"Ignore_Shopping": "Ignore Shopping",
|
||||||
"Shopping_Category": "Shopping Category",
|
"Shopping_Category": "Shopping Category",
|
||||||
|
"Shopping_Categories": "Shopping Categories",
|
||||||
"Edit_Food": "Edit Food",
|
"Edit_Food": "Edit Food",
|
||||||
"Move_Food": "Move Food",
|
"Move_Food": "Move Food",
|
||||||
"New_Food": "New Food",
|
"New_Food": "New Food",
|
||||||
@ -173,6 +174,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": "Currently 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 +204,11 @@
|
|||||||
"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",
|
||||||
"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",
|
||||||
@ -206,6 +221,35 @@
|
|||||||
"Current_Period": "Current Period",
|
"Current_Period": "Current Period",
|
||||||
"Next_Day": "Next Day",
|
"Next_Day": "Next Day",
|
||||||
"Previous_Day": "Previous Day",
|
"Previous_Day": "Previous Day",
|
||||||
|
"Inherit": "Inherit",
|
||||||
|
"InheritFields": "Inherit Fields Values",
|
||||||
|
"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 Food 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 adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are currently on hand.",
|
||||||
|
"mealplan_autoinclude_related_desc": "When adding a meal plan to the shopping list (manually or automatically), include all related recipes.",
|
||||||
|
"default_delay_desc": "Default number of hours to delay a shopping list entry.",
|
||||||
|
"filter_to_supermarket": "Filter to Supermarket",
|
||||||
"Coming_Soon": "Coming-Soon",
|
"Coming_Soon": "Coming-Soon",
|
||||||
"Auto_Planner": "Auto-Planner",
|
"Auto_Planner": "Auto-Planner",
|
||||||
"New_Cookbook": "New cookbook",
|
"New_Cookbook": "New cookbook",
|
||||||
@ -214,5 +258,23 @@
|
|||||||
"err_move_self": "Cannot move item to itself",
|
"err_move_self": "Cannot move item to itself",
|
||||||
"nothing": "Nothing to do",
|
"nothing": "Nothing to do",
|
||||||
"err_merge_self": "Cannot merge item with itself",
|
"err_merge_self": "Cannot merge item with itself",
|
||||||
"show_sql": "Show SQL"
|
"show_sql": "Show SQL",
|
||||||
|
"filter_to_supermarket_desc": "By default, filter shopping list to only include categories for selected supermarket.",
|
||||||
|
"CategoryName": "Category Name",
|
||||||
|
"SupermarketName": "Supermarket Name",
|
||||||
|
"CategoryInstruction": "Drag categories to change the order categories appear in shopping list.",
|
||||||
|
"shopping_recent_days_desc": "Days of recent shopping list entries to display.",
|
||||||
|
"shopping_recent_days": "Recent Days",
|
||||||
|
"create_shopping_new": "Add to NEW Shopping List",
|
||||||
|
"download_pdf": "Download PDF",
|
||||||
|
"download_csv": "Download CSV",
|
||||||
|
"csv_delim_help": "Delimiter to use for CSV exports.",
|
||||||
|
"csv_delim_label": "CSV Delimiter",
|
||||||
|
"SuccessClipboard": "Shopping list copied to clipboard",
|
||||||
|
"copy_to_clipboard": "Copy to Clipboard",
|
||||||
|
"csv_prefix_help": "Prefix to add when copying list to the clipboard.",
|
||||||
|
"csv_prefix_label": "List Prefix",
|
||||||
|
"copy_markdown_table": "Copy as Markdown Table",
|
||||||
|
"in_shopping": "In Shopping List",
|
||||||
|
"DelayUntil": "Delay Until"
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
@ -48,3 +49,7 @@ function handleError(error, message) {
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
@ -65,14 +65,19 @@ export class Models {
|
|||||||
paginated: true,
|
paginated: true,
|
||||||
move: true,
|
move: true,
|
||||||
merge: true,
|
merge: true,
|
||||||
|
shop: true,
|
||||||
|
onhand: true,
|
||||||
badges: {
|
badges: {
|
||||||
linked_recipe: true,
|
linked_recipe: true,
|
||||||
|
food_onhand: 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", "food_onhand", "supermarket_category", "inherit", "inherit_fields"]],
|
||||||
|
|
||||||
form: {
|
form: {
|
||||||
name: {
|
name: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
@ -98,8 +103,8 @@ export class Models {
|
|||||||
shopping: {
|
shopping: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
field: "ignore_shopping",
|
field: "food_onhand",
|
||||||
label: i18n.t("Ignore_Shopping"),
|
label: i18n.t("OnHand"),
|
||||||
},
|
},
|
||||||
shopping_category: {
|
shopping_category: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
@ -109,8 +114,30 @@ export class Models {
|
|||||||
label: i18n.t("Shopping_Category"),
|
label: i18n.t("Shopping_Category"),
|
||||||
allow_create: true,
|
allow_create: true,
|
||||||
},
|
},
|
||||||
|
inherit_fields: {
|
||||||
|
form_field: true,
|
||||||
|
type: "lookup",
|
||||||
|
multiple: true,
|
||||||
|
field: "inherit_fields",
|
||||||
|
list: "FOOD_INHERIT_FIELDS",
|
||||||
|
label: i18n.t("InheritFields"),
|
||||||
|
condition: { field: "parent", value: true, condition: "exists" },
|
||||||
|
},
|
||||||
|
full_name: {
|
||||||
|
form_field: true,
|
||||||
|
type: "smalltext",
|
||||||
|
field: "full_name",
|
||||||
|
},
|
||||||
|
form_function: "FoodCreateDefault",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
shopping: {
|
||||||
|
params: ["id", ["id", "amount", "unit", "_delete"]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
static FOOD_INHERIT_FIELDS = {
|
||||||
|
name: i18n.t("FoodInherit"),
|
||||||
|
apiName: "FoodInheritField",
|
||||||
}
|
}
|
||||||
|
|
||||||
static KEYWORD = {
|
static KEYWORD = {
|
||||||
@ -147,6 +174,11 @@ export class Models {
|
|||||||
field: "icon",
|
field: "icon",
|
||||||
label: i18n.t("Icon"),
|
label: i18n.t("Icon"),
|
||||||
},
|
},
|
||||||
|
full_name: {
|
||||||
|
form_field: true,
|
||||||
|
type: "smalltext",
|
||||||
|
field: "full_name",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -180,6 +212,30 @@ 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"]],
|
||||||
|
form: {
|
||||||
|
unit: {
|
||||||
|
form_field: true,
|
||||||
|
type: "lookup",
|
||||||
|
field: "unit",
|
||||||
|
list: "UNIT",
|
||||||
|
label: i18n.t("Unit"),
|
||||||
|
allow_create: true,
|
||||||
|
},
|
||||||
|
food: {
|
||||||
|
form_field: true,
|
||||||
|
type: "lookup",
|
||||||
|
field: "food",
|
||||||
|
list: "FOOD",
|
||||||
|
label: i18n.t("Food"),
|
||||||
|
allow_create: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
static RECIPE_BOOK = {
|
static RECIPE_BOOK = {
|
||||||
@ -370,41 +426,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 +491,19 @@ export class Models {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
static USER = {
|
||||||
|
name: i18n.t("User"),
|
||||||
|
apiName: "User",
|
||||||
|
paginated: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
static STEP = {
|
||||||
|
name: i18n.t("Step"),
|
||||||
|
apiName: "Step",
|
||||||
|
list: {
|
||||||
|
params: ["recipe", "query", "page", "pageSize", "options"],
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Actions {
|
export class Actions {
|
||||||
@ -639,4 +682,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) {
|
||||||
@ -358,6 +364,10 @@ export function getForm(model, action, item1, item2) {
|
|||||||
if (f === "partialUpdate" && Object.keys(config).length == 0) {
|
if (f === "partialUpdate" && Object.keys(config).length == 0) {
|
||||||
config = { ...Actions.CREATE?.form, ...model.model_type?.["create"]?.form, ...model?.["create"]?.form }
|
config = { ...Actions.CREATE?.form, ...model.model_type?.["create"]?.form, ...model?.["create"]?.form }
|
||||||
config["title"] = { ...action?.form_title, ...model.model_type?.[f]?.form_title, ...model?.[f]?.form_title }
|
config["title"] = { ...action?.form_title, ...model.model_type?.[f]?.form_title, ...model?.[f]?.form_title }
|
||||||
|
// form functions should not be inherited
|
||||||
|
if (config?.["form_function"]?.includes("Create")) {
|
||||||
|
delete config["form_function"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let form = { fields: [] }
|
let form = { fields: [] }
|
||||||
let value = ""
|
let value = ""
|
||||||
@ -525,3 +535,10 @@ const specialCases = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formFunctions = {
|
||||||
|
FoodCreateDefault: function (form) {
|
||||||
|
form.fields.filter((x) => x.field === "inherit_fields")[0].value = getUserPreference("food_inherit_default")
|
||||||
|
return form
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user