Merge shopping_list develop

This commit is contained in:
Tiago Rascazzi
2022-01-04 13:19:34 -05:00
104 changed files with 10372 additions and 5067 deletions

View File

@ -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)

View File

@ -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

View File

@ -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
}

View 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")

View File

@ -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)

View File

@ -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

View 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

View File

@ -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

View File

@ -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)

View 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),
]

View 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),
]

View 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'),
),
]

View 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),
),
]

View File

@ -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

View File

@ -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', ]

View File

@ -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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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/>

View File

@ -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">

View File

@ -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 %}

View File

@ -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

View 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 %}

View File

@ -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({

View File

@ -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,

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View 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

View File

@ -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],

View File

@ -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

View 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

View 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

View File

@ -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", [

View File

@ -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

View File

@ -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']])

View File

@ -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)

View 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'

View File

@ -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'),

View File

@ -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:

View File

@ -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

View File

@ -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
}
} }
) )

View File

@ -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

View File

@ -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**.

View File

@ -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
View File

@ -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?

View File

@ -0,0 +1,8 @@
.gitignore
.npmignore
api.ts
base.ts
common.ts
configuration.ts
git_push.sh
index.ts

View File

@ -0,0 +1 @@
5.2.1

View File

@ -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",

View File

@ -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>

View File

@ -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')

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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) {

View File

@ -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) {

View File

@ -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"

View File

@ -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>

File diff suppressed because it is too large Load Diff

View 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")

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -10,7 +10,4 @@ export default {
} }
</script> </script>
<style scoped> <style scoped></style>
</style>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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" },
}, },

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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>

View 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] }} &ensp; {{ Object.entries(formatAmount)[0][0] }}</div>
<div class="small" v-else v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }} &ensp; {{ 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>

View File

@ -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>

View File

@ -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"
} }

View File

@ -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
View 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
}
}

View File

@ -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

View File

@ -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