mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-24 02:39:20 -05:00
Merge shopping_list develop
This commit is contained in:
@@ -1,23 +1,22 @@
|
||||
from django.conf import settings
|
||||
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.utils import translation
|
||||
from django_scopes import scopes_disabled
|
||||
from treebeard.admin import TreeAdmin
|
||||
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 .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):
|
||||
def has_add_permission(self, request, obj=None):
|
||||
@@ -129,6 +128,7 @@ def sort_tree(modeladmin, request, queryset):
|
||||
class KeywordAdmin(TreeAdmin):
|
||||
form = movenodeform_factory(Keyword)
|
||||
ordering = ('space', 'path',)
|
||||
search_fields = ('name', )
|
||||
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(Unit)
|
||||
# admin.site.register(FoodInheritField)
|
||||
|
||||
|
||||
class FoodAdmin(TreeAdmin):
|
||||
form = movenodeform_factory(Keyword)
|
||||
ordering = ('space', 'path',)
|
||||
search_fields = ('name', )
|
||||
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
|
||||
|
||||
|
||||
@@ -280,7 +282,7 @@ admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -12,29 +12,27 @@ class CookbookConfig(AppConfig):
|
||||
name = 'cookbook'
|
||||
|
||||
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:
|
||||
# # when starting up run fix_tree to:
|
||||
# # a) make sure that nodes are sorted when switching between sort modes
|
||||
# # b) fix problems, if any, with tree consistency
|
||||
# with scopes_disabled():
|
||||
# try:
|
||||
# from cookbook.models import Food, Keyword
|
||||
# Keyword.fix_tree(fix_paths=True)
|
||||
# Food.fix_tree(fix_paths=True)
|
||||
# except OperationalError:
|
||||
# if DEBUG:
|
||||
# traceback.print_exc()
|
||||
# pass # if model does not exist there is no need to fix it
|
||||
# except ProgrammingError:
|
||||
# if DEBUG:
|
||||
# traceback.print_exc()
|
||||
# pass # if migration has not been run database cannot be fixed yet
|
||||
# except Exception:
|
||||
# if DEBUG:
|
||||
# traceback.print_exc()
|
||||
# pass # dont break startup just because fix could not run, need to investigate cases when this happens
|
||||
|
||||
if not settings.DISABLE_TREE_FIX_STARTUP:
|
||||
# when starting up run fix_tree to:
|
||||
# a) make sure that nodes are sorted when switching between sort modes
|
||||
# b) fix problems, if any, with tree consistency
|
||||
with scopes_disabled():
|
||||
try:
|
||||
from cookbook.models import Keyword, Food
|
||||
#Keyword.fix_tree(fix_paths=True) # disabled for now, causes to many unknown issues
|
||||
#Food.fix_tree(fix_paths=True)
|
||||
except OperationalError:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
pass # if model does not exist there is no need to fix it
|
||||
except ProgrammingError:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
pass # if migration has not been run database cannot be fixed yet
|
||||
except Exception:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
pass # dont break startup just because fix could not run, need to investigate cases when this happens
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
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_scopes import scopes_disabled
|
||||
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
||||
from hcaptcha.fields import hCaptchaField
|
||||
|
||||
from .models import (Comment, InviteLink, Keyword, MealPlan, Recipe,
|
||||
RecipeBook, RecipeBookEntry, Storage, Sync, User,
|
||||
UserPreference, MealType, Space,
|
||||
SearchPreference)
|
||||
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference)
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
@@ -37,7 +35,10 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
prefix = 'preference'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
if x := kwargs.get('instance', None):
|
||||
space = x.space
|
||||
else:
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['plan_share'].queryset = User.objects.filter(userpreference__space=space).all()
|
||||
|
||||
@@ -46,8 +47,7 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
fields = (
|
||||
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
|
||||
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
|
||||
'plan_share', 'ingredient_decimals', 'shopping_auto_sync',
|
||||
'comments'
|
||||
'plan_share', 'ingredient_decimals', 'comments',
|
||||
)
|
||||
|
||||
labels = {
|
||||
@@ -74,8 +74,8 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
|
||||
# noqa: E501
|
||||
'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501
|
||||
'plan_share': _(
|
||||
'Users with whom newly created meal plan/shopping list entries should be shared by default.'),
|
||||
'plan_share': _('Users with whom newly created meal plans should be shared by default.'),
|
||||
'shopping_share': _('Users with whom to share shopping lists.'),
|
||||
# noqa: E501
|
||||
'show_recent': _('Show recently viewed recipes on search page.'), # 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
|
||||
'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 = {
|
||||
'plan_share': MultiSelectWidget
|
||||
'plan_share': MultiSelectWidget,
|
||||
'shopping_share': MultiSelectWidget,
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +227,7 @@ class StorageForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
# TODO: Deprecate
|
||||
class RecipeBookEntryForm(forms.ModelForm):
|
||||
prefix = 'bookmark'
|
||||
|
||||
@@ -263,6 +267,7 @@ class SyncForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class BatchEditForm(forms.Form):
|
||||
search = forms.CharField(label=_('Search String'))
|
||||
keywords = forms.ModelMultipleChoiceField(
|
||||
@@ -299,6 +304,7 @@ class ImportRecipeForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class MealPlanForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
@@ -466,3 +472,70 @@ class SearchPreferenceForm(forms.ModelForm):
|
||||
'trigram': MultiSelectWidget,
|
||||
'fulltext': MultiSelectWidget,
|
||||
}
|
||||
|
||||
|
||||
class ShoppingPreferenceForm(forms.ModelForm):
|
||||
prefix = 'shopping'
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
|
||||
fields = (
|
||||
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand',
|
||||
'mealplan_autoinclude_related', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix'
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'),
|
||||
'shopping_auto_sync': _(
|
||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
|
||||
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
|
||||
),
|
||||
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
|
||||
'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
|
||||
'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'),
|
||||
'default_delay': _('Default number of hours to delay a shopping list entry.'),
|
||||
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
|
||||
'shopping_recent_days': _('Days of recent shopping list entries to display.'),
|
||||
'csv_delim': _('Delimiter to use for CSV exports.'),
|
||||
'csv_prefix': _('Prefix to add when copying list to the clipboard.'),
|
||||
|
||||
}
|
||||
labels = {
|
||||
'shopping_share': _('Share Shopping List'),
|
||||
'shopping_auto_sync': _('Autosync'),
|
||||
'mealplan_autoadd_shopping': _('Auto Add Meal Plan'),
|
||||
'mealplan_autoexclude_onhand': _('Exclude On Hand'),
|
||||
'mealplan_autoinclude_related': _('Include Related'),
|
||||
'default_delay': _('Default Delay Hours'),
|
||||
'filter_to_supermarket': _('Filter to Supermarket'),
|
||||
'shopping_recent_days': _('Recent Days'),
|
||||
'csv_delim': _('CSV Delimiter'),
|
||||
"csv_prefix_label": _("List Prefix")
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'shopping_share': MultiSelectWidget
|
||||
}
|
||||
|
||||
|
||||
class SpacePreferenceForm(forms.ModelForm):
|
||||
prefix = 'space'
|
||||
reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False,
|
||||
help_text=_("Reset all food to inherit the fields configured."))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs) # populates the post
|
||||
self.fields['food_inherit'].queryset = Food.inheritable_fields
|
||||
|
||||
class Meta:
|
||||
model = Space
|
||||
|
||||
fields = ('food_inherit', 'reset_food_inherit',)
|
||||
|
||||
help_texts = {
|
||||
'food_inherit': _('Fields on food that should be inherited by default.'), }
|
||||
|
||||
widgets = {
|
||||
'food_inherit': MultiSelectWidget
|
||||
}
|
||||
|
||||
13
cookbook/helper/HelperFunctions.py
Normal file
13
cookbook/helper/HelperFunctions.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.db.models import Func
|
||||
|
||||
|
||||
class Round(Func):
|
||||
function = 'ROUND'
|
||||
template = '%(function)s(%(expressions)s, 0)'
|
||||
|
||||
|
||||
def str2bool(v):
|
||||
if type(v) == bool:
|
||||
return v
|
||||
else:
|
||||
return v.lower() in ("yes", "true", "1")
|
||||
@@ -2,11 +2,9 @@
|
||||
Source: https://djangosnippets.org/snippets/1703/
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.cache import caches
|
||||
|
||||
from cookbook.models import ShareLink
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.core.cache import caches
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpResponseRedirect
|
||||
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.permissions import SAFE_METHODS
|
||||
|
||||
from cookbook.models import ShareLink
|
||||
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
"""
|
||||
@@ -205,6 +205,9 @@ class CustomIsShared(permissions.BasePermission):
|
||||
return request.user.is_authenticated
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -8,24 +8,13 @@ from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone, translation
|
||||
|
||||
from cookbook.filters import RecipeFilter
|
||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||
from cookbook.helper.permission_helper import has_group_permission
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog
|
||||
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 consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
|
||||
def search_recipes(request, queryset, params):
|
||||
@@ -41,7 +30,6 @@ def search_recipes(request, queryset, params):
|
||||
search_steps = params.getlist('steps', [])
|
||||
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_foods_or = str2bool(params.get('foods_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_random = str2bool(params.get('random', 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 = []
|
||||
|
||||
# only sort by recent not otherwise filtering/sorting
|
||||
@@ -208,24 +196,18 @@ def search_recipes(request, queryset, params):
|
||||
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):
|
||||
"""
|
||||
Gets an annotated list from a queryset.
|
||||
:param qs:
|
||||
|
||||
recipe queryset to build facets from
|
||||
|
||||
:param request:
|
||||
|
||||
the web request that contains the necessary query parameters
|
||||
|
||||
:param use_cache:
|
||||
|
||||
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
|
||||
|
||||
:param hash_key:
|
||||
|
||||
the cache key of the recipe list to process
|
||||
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'))
|
||||
food_a = annotated_qs(foods, root=True, fill=True)
|
||||
|
||||
# TODO add rating facet
|
||||
facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list)
|
||||
facets['Foods'] = fill_annotated_parents(food_a, food_list)
|
||||
# TODO add book facet
|
||||
@@ -373,8 +354,6 @@ def annotated_qs(qs, root=False, fill=False):
|
||||
dirty = False
|
||||
current_node = node_queue[-1]
|
||||
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
|
||||
if root and depth > 1 and parent_id not in nodes_list:
|
||||
parent_id = current_node.parent
|
||||
|
||||
155
cookbook/helper/shopping_helper.py
Normal file
155
cookbook/helper/shopping_helper.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.db.models import F, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||
from cookbook.models import (Ingredient, ShoppingListEntry, ShoppingListRecipe,
|
||||
SupermarketCategoryRelation)
|
||||
from recipes import settings
|
||||
|
||||
|
||||
def shopping_helper(qs, request):
|
||||
supermarket = request.query_params.get('supermarket', None)
|
||||
checked = request.query_params.get('checked', 'recent')
|
||||
user = request.user
|
||||
|
||||
supermarket_order = ['food__supermarket_category__name', 'food__name']
|
||||
|
||||
# TODO created either scheduled task or startup task to delete very old shopping list entries
|
||||
# TODO create user preference to define 'very old'
|
||||
if supermarket:
|
||||
supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category'))
|
||||
qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999)))
|
||||
supermarket_order = ['supermarket_order'] + supermarket_order
|
||||
if checked in ['false', 0, '0']:
|
||||
qs = qs.filter(checked=False)
|
||||
elif checked in ['true', 1, '1']:
|
||||
qs = qs.filter(checked=True)
|
||||
elif checked in ['recent']:
|
||||
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
|
||||
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
|
||||
supermarket_order = ['checked'] + supermarket_order
|
||||
|
||||
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
|
||||
|
||||
|
||||
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
|
||||
"""
|
||||
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
||||
:param list_recipe: Modify an existing ShoppingListRecipe
|
||||
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
||||
:param mealplan: alternatively use a mealplan recipe as source of ingredients
|
||||
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
||||
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
||||
:param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
|
||||
"""
|
||||
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
||||
if not r:
|
||||
raise ValueError(_("You must supply a recipe or mealplan"))
|
||||
|
||||
created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
|
||||
if not created_by:
|
||||
raise ValueError(_("You must supply a created_by"))
|
||||
|
||||
try:
|
||||
servings = float(servings)
|
||||
except (ValueError, TypeError):
|
||||
servings = getattr(mealplan, 'servings', 1.0)
|
||||
|
||||
servings_factor = servings / r.servings
|
||||
|
||||
shared_users = list(created_by.get_shopping_share())
|
||||
shared_users.append(created_by)
|
||||
if list_recipe:
|
||||
created = False
|
||||
else:
|
||||
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
||||
created = True
|
||||
|
||||
related_step_ing = []
|
||||
if servings == 0 and not created:
|
||||
list_recipe.delete()
|
||||
return []
|
||||
elif ingredients:
|
||||
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
||||
else:
|
||||
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
|
||||
|
||||
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
|
||||
ingredients = ingredients.exclude(food__food_onhand=True)
|
||||
|
||||
if related := created_by.userpreference.mealplan_autoinclude_related:
|
||||
# TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
|
||||
related_recipes = r.get_related_recipes()
|
||||
|
||||
for x in related_recipes:
|
||||
# related recipe is a Step serving size is driven by recipe serving size
|
||||
# TODO once/if Steps can have a serving size this needs to be refactored
|
||||
if exclude_onhand:
|
||||
# if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
|
||||
related_step_ing += Ingredient.objects.filter(step__recipe=x, food__food_onhand=False, space=space).values_list('id', flat=True)
|
||||
else:
|
||||
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
|
||||
|
||||
x_ing = []
|
||||
if ingredients.filter(food__recipe=x).exists():
|
||||
for ing in ingredients.filter(food__recipe=x):
|
||||
if exclude_onhand:
|
||||
x_ing = Ingredient.objects.filter(step__recipe=x, food__food_onhand=False, space=space)
|
||||
else:
|
||||
x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
|
||||
for i in [x for x in x_ing]:
|
||||
ShoppingListEntry.objects.create(
|
||||
list_recipe=list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(servings_factor),
|
||||
created_by=created_by,
|
||||
space=space,
|
||||
)
|
||||
# dont' add food to the shopping list that are actually recipes that will be added as ingredients
|
||||
ingredients = ingredients.exclude(food__recipe=x)
|
||||
|
||||
add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
|
||||
if not append:
|
||||
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||
# delete shopping list entries not included in ingredients
|
||||
existing_list.exclude(ingredient__in=ingredients).delete()
|
||||
# add shopping list entries that did not previously exist
|
||||
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||
|
||||
# if servings have changed, update the ShoppingListRecipe and existing Entrys
|
||||
if servings <= 0:
|
||||
servings = 1
|
||||
|
||||
if not created and list_recipe.servings != servings:
|
||||
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
||||
list_recipe.servings = servings
|
||||
list_recipe.save()
|
||||
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
||||
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||
sle.save()
|
||||
|
||||
# add any missing Entrys
|
||||
for i in [x for x in add_ingredients if x.food]:
|
||||
|
||||
ShoppingListEntry.objects.create(
|
||||
list_recipe=list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(servings_factor),
|
||||
created_by=created_by,
|
||||
space=space,
|
||||
)
|
||||
|
||||
# return all shopping list items
|
||||
return list_recipe
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import traceback
|
||||
import uuid
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile, BadZipFile
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from bs4 import Tag
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
import annoying.fields
|
||||
from django.conf import settings
|
||||
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.models import deletion
|
||||
from django_scopes import scopes_disabled
|
||||
from django.utils import translation
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
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):
|
||||
@@ -16,8 +18,6 @@ def set_default_search_vector(apps, schema_editor):
|
||||
return
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
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(
|
||||
name_search_vector=SearchVector('name__unaccent', weight='A', config=language),
|
||||
desc_search_vector=SearchVector('description__unaccent', weight='B', config=language)
|
||||
|
||||
144
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
144
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# Generated by Django 3.2.7 on 2021-10-01 20:52
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import PermissionModelMixin, ShoppingListEntry
|
||||
|
||||
|
||||
def copy_values_to_sle(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
entries = ShoppingListEntry.objects.all()
|
||||
for entry in entries:
|
||||
if entry.shoppinglist_set.first():
|
||||
entry.created_by = entry.shoppinglist_set.first().created_by
|
||||
entry.space = entry.shoppinglist_set.first().space
|
||||
if entries:
|
||||
ShoppingListEntry.objects.bulk_update(entries, ["created_by", "space", ])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0158_userpreference_use_kj'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='completed_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.user'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='shopping_share',
|
||||
field=models.ManyToManyField(blank=True, related_name='shopping_share', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistrecipe',
|
||||
name='mealplan',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.mealplan'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistrecipe',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, default='', max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='ingredient',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.ingredient'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shoppinglistentry',
|
||||
name='unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.unit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='mealplan_autoadd_shopping',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='mealplan_autoexclude_onhand',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shoppinglistentry',
|
||||
name='list_recipe',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='cookbook.shoppinglistrecipe'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FoodInheritField',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('field', models.CharField(max_length=32, unique=True)),
|
||||
('name', models.CharField(max_length=64, unique=True)),
|
||||
],
|
||||
bases=(models.Model, PermissionModelMixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='mealplan_autoinclude_related',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='inherit_fields',
|
||||
field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='food_inherit',
|
||||
field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='delay_until',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='default_delay',
|
||||
field=models.DecimalField(decimal_places=4, default=4, max_digits=8),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='filter_to_supermarket',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='shopping_recent_days',
|
||||
field=models.PositiveIntegerField(default=7),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='food',
|
||||
old_name='ignore_shopping',
|
||||
new_name='food_onhand',
|
||||
),
|
||||
migrations.RunPython(copy_values_to_sle),
|
||||
]
|
||||
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal file
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 3.2.7 on 2021-10-01 22:34
|
||||
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import utc
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import FoodInheritField, ShoppingListEntry
|
||||
|
||||
|
||||
def delete_orphaned_sle(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
# shopping list entry is orphaned - delete it
|
||||
ShoppingListEntry.objects.filter(shoppinglist=None).delete()
|
||||
|
||||
|
||||
def create_inheritfields(apps, schema_editor):
|
||||
FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category')
|
||||
FoodInheritField.objects.create(name='On Hand', field='food_onhand')
|
||||
FoodInheritField.objects.create(name='Diet', field='diet')
|
||||
FoodInheritField.objects.create(name='Substitute', field='substitute')
|
||||
FoodInheritField.objects.create(name='Substitute Children', field='substitute_children')
|
||||
FoodInheritField.objects.create(name='Substitute Siblings', field='substitute_siblings')
|
||||
|
||||
|
||||
def set_completed_at(apps, schema_editor):
|
||||
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||
# arbitrary - keeping all of the closed shopping list items out of the 'recent' view
|
||||
month_ago = today_start - timedelta(days=30)
|
||||
with scopes_disabled():
|
||||
ShoppingListEntry.objects.filter(checked=True).update(completed_at=month_ago)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0159_add_shoppinglistentry_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(delete_orphaned_sle),
|
||||
migrations.RunPython(create_inheritfields),
|
||||
migrations.RunPython(set_completed_at),
|
||||
]
|
||||
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal file
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.8 on 2021-11-03 23:19
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0160_delete_shoppinglist_orphans'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='shoppinglistentry',
|
||||
name='food',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_entries', to='cookbook.food'),
|
||||
),
|
||||
]
|
||||
23
cookbook/migrations/0162_userpreference_csv_delim.py
Normal file
23
cookbook/migrations/0162_userpreference_csv_delim.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.9 on 2021-11-30 22:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0161_alter_shoppinglistentry_food'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='csv_delim',
|
||||
field=models.CharField(default=',', max_length=2),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='csv_prefix',
|
||||
field=models.CharField(blank=True, max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -35,7 +35,20 @@ def get_user_name(self):
|
||||
return self.username
|
||||
|
||||
|
||||
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_shopping_share', get_shopping_share)
|
||||
|
||||
|
||||
def get_model_name(model):
|
||||
@@ -54,6 +67,9 @@ class TreeManager(MP_NodeManager):
|
||||
except self.model.DoesNotExist:
|
||||
with scopes_disabled():
|
||||
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
|
||||
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}
|
||||
@@ -78,6 +94,13 @@ class TreeModel(MP_Node):
|
||||
else:
|
||||
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
|
||||
def parent(self):
|
||||
parent = self.get_parent()
|
||||
@@ -124,6 +147,47 @@ class TreeModel(MP_Node):
|
||||
with scopes_disabled():
|
||||
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:
|
||||
abstract = True
|
||||
|
||||
@@ -157,6 +221,18 @@ class PermissionModelMixin:
|
||||
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):
|
||||
name = models.CharField(max_length=128, default='Default')
|
||||
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)
|
||||
allow_sharing = models.BooleanField(default=True)
|
||||
demo = models.BooleanField(default=False)
|
||||
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -245,10 +322,21 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
plan_share = models.ManyToManyField(
|
||||
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)
|
||||
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
|
||||
shopping_auto_sync = models.IntegerField(default=5)
|
||||
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)
|
||||
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)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
description = models.TextField(default="", blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate
|
||||
updated_at = models.DateTimeField(auto_now=True) # TODO deprecate
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||
@@ -393,13 +481,18 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
||||
|
||||
|
||||
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:
|
||||
node_order_by = ['name']
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
ignore_shopping = models.BooleanField(default=False)
|
||||
food_onhand = models.BooleanField(default=False) # inherited field
|
||||
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)
|
||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||
@@ -413,6 +506,35 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
else:
|
||||
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:
|
||||
constraints = [
|
||||
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):
|
||||
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():
|
||||
indexes = (
|
||||
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):
|
||||
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)
|
||||
mealplan = models.ForeignKey(MealPlan, on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@@ -677,20 +816,26 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
|
||||
|
||||
def get_owner(self):
|
||||
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:
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE)
|
||||
unit = models.ForeignKey(Unit, 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, related_name='shopping_entries')
|
||||
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)
|
||||
order = models.IntegerField(default=0)
|
||||
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
|
||||
def get_space_key():
|
||||
@@ -702,12 +847,14 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
|
||||
def __str__(self):
|
||||
return f'Shopping list entry {self.id}'
|
||||
|
||||
# TODO deprecate
|
||||
def get_shared(self):
|
||||
return self.shoppinglist_set.first().shared.all()
|
||||
|
||||
# TODO deprecate
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.shoppinglist_set.first().created_by
|
||||
return self.created_by or self.shoppinglist_set.first().created_by
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@ from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
|
||||
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, ImportLog,
|
||||
Ingredient, Keyword, MealPlan, MealType, NutritionInformation, Recipe,
|
||||
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket,
|
||||
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit,
|
||||
UserFile, UserPreference, ViewLog)
|
||||
from cookbook.helper.shopping_helper import list_from_recipe
|
||||
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
|
||||
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
|
||||
NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
|
||||
RecipeImport, ShareLink, ShoppingList, ShoppingListEntry,
|
||||
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
|
||||
UserPreference, ViewLog)
|
||||
from cookbook.templatetags.custom_tags import markdown
|
||||
|
||||
|
||||
@@ -37,9 +39,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
if bool(int(
|
||||
self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
|
||||
return fields
|
||||
except AttributeError:
|
||||
pass
|
||||
except KeyError:
|
||||
except (AttributeError, KeyError) as e:
|
||||
pass
|
||||
try:
|
||||
del fields['image']
|
||||
@@ -95,6 +95,8 @@ class CustomDecimalField(serializers.Field):
|
||||
class SpaceFilterSerializer(serializers.ListSerializer):
|
||||
|
||||
def to_representation(self, data):
|
||||
if self.context.get('request', None) is None:
|
||||
return
|
||||
if (type(data) == QuerySet and data.query.is_sliced):
|
||||
# if query is sliced it came from api request not nested serializer
|
||||
return super().to_representation(data)
|
||||
@@ -136,20 +138,45 @@ class UserNameSerializer(WritableNestedModelSerializer):
|
||||
fields = ('id', 'username')
|
||||
|
||||
|
||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||
plan_share = UserNameSerializer(many=True, read_only=True)
|
||||
class FoodInheritFieldSerializer(WritableNestedModelSerializer):
|
||||
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):
|
||||
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()
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# don't allow writing to FoodInheritField via API
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = (
|
||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page',
|
||||
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
|
||||
'comments'
|
||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
|
||||
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
|
||||
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', 'filter_to_supermarket'
|
||||
)
|
||||
|
||||
|
||||
@@ -255,25 +282,11 @@ class KeywordLabelSerializer(serializers.ModelSerializer):
|
||||
|
||||
class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
label = serializers.SerializerMethodField('get_label')
|
||||
# image = serializers.SerializerMethodField('get_image')
|
||||
# numrecipe = serializers.SerializerMethodField('count_recipes')
|
||||
recipe_filter = 'keywords'
|
||||
|
||||
def get_label(self, 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):
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
@@ -286,26 +299,13 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
model = Keyword
|
||||
fields = (
|
||||
'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at',
|
||||
'updated_at')
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image')
|
||||
'updated_at', 'full_name')
|
||||
read_only_fields = ('id', 'label', 'numchild', 'parent', 'image')
|
||||
|
||||
|
||||
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
# image = serializers.SerializerMethodField('get_image')
|
||||
# numrecipe = serializers.SerializerMethodField('count_recipes')
|
||||
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):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
validated_data['space'] = self.context['request'].space
|
||||
@@ -369,27 +369,13 @@ class RecipeSimpleSerializer(serializers.ModelSerializer):
|
||||
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
|
||||
recipe = RecipeSimpleSerializer(allow_null=True, required=False)
|
||||
# image = serializers.SerializerMethodField('get_image')
|
||||
# numrecipe = serializers.SerializerMethodField('count_recipes')
|
||||
shopping = serializers.SerializerMethodField('get_shopping_status')
|
||||
inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False)
|
||||
|
||||
recipe_filter = 'steps__ingredients__food'
|
||||
|
||||
# def get_image(self, obj):
|
||||
# if obj.recipe and obj.space == obj.recipe.space:
|
||||
# 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 get_shopping_status(self, obj):
|
||||
return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
@@ -403,16 +389,17 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
return obj
|
||||
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = (
|
||||
'id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent',
|
||||
'numchild',
|
||||
'numrecipe')
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image')
|
||||
'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
|
||||
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name'
|
||||
)
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
||||
|
||||
|
||||
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
|
||||
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
||||
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):
|
||||
return markdown(obj.note)
|
||||
|
||||
def in_shopping(self, obj):
|
||||
return ShoppingListRecipe.objects.filter(mealplan=obj.id).exists()
|
||||
|
||||
def create(self, validated_data):
|
||||
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:
|
||||
model = MealPlan
|
||||
fields = (
|
||||
'id', 'title', 'recipe', 'servings', 'note', 'note_markdown',
|
||||
'date', 'meal_type', 'created_by', 'shared', 'recipe_name',
|
||||
'meal_type_name'
|
||||
'meal_type_name', 'shopping'
|
||||
)
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
|
||||
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
||||
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
|
||||
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:
|
||||
model = ShoppingListRecipe
|
||||
fields = ('id', 'recipe', 'recipe_name', 'servings')
|
||||
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||
food = FoodSerializer(allow_null=True)
|
||||
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()
|
||||
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:
|
||||
model = ShoppingListEntry
|
||||
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 Meta:
|
||||
model = ShoppingListEntry
|
||||
fields = ('id', 'checked')
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class ShoppingListSerializer(WritableNestedModelSerializer):
|
||||
recipes = ShoppingListRecipeSerializer(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',)
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
|
||||
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
|
||||
|
||||
@@ -802,7 +861,7 @@ class FoodExportSerializer(FoodSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('name', 'ignore_shopping', 'supermarket_category')
|
||||
fields = ('name', 'food_onhand', 'supermarket_category',)
|
||||
|
||||
|
||||
class IngredientExportSerializer(WritableNestedModelSerializer):
|
||||
@@ -847,3 +906,24 @@ class RecipeExportSerializer(WritableNestedModelSerializer):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
|
||||
list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Existing shopping list to update"))
|
||||
ingredients = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_(
|
||||
"List of ingredient IDs from the recipe to add, if not provided all ingredients will be added."))
|
||||
servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_("Providing a list_recipe ID and servings of 0 will delete that shopping list."))
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['id', 'list_recipe', 'ingredients', 'servings', ]
|
||||
|
||||
|
||||
class FoodShoppingUpdateSerializer(serializers.ModelSerializer):
|
||||
amount = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Amount of food to add to the shopping list"))
|
||||
unit = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("ID of unit to use for the shopping list"))
|
||||
delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True, help_text=_("When set to true will delete all food from active shopping lists."))
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['id', 'amount', 'unit', 'delete', ]
|
||||
|
||||
@@ -1,47 +1,123 @@
|
||||
from decimal import Decimal
|
||||
from functools import wraps
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
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.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
|
||||
|
||||
|
||||
def skip_signal(signal_func):
|
||||
@wraps(signal_func)
|
||||
def _decorator(sender, instance, **kwargs):
|
||||
if not instance:
|
||||
return None
|
||||
if hasattr(instance, 'skip_signal'):
|
||||
return None
|
||||
return signal_func(sender, instance, **kwargs)
|
||||
return _decorator
|
||||
|
||||
|
||||
# TODO there is probably a way to generalize this
|
||||
@receiver(post_save, sender=Recipe)
|
||||
@skip_signal
|
||||
def update_recipe_search_vector(sender, instance=None, created=False, **kwargs):
|
||||
if not instance:
|
||||
if SQLITE:
|
||||
return
|
||||
|
||||
# needed to ensure search vector update doesn't trigger recursion
|
||||
if hasattr(instance, '_dirty'):
|
||||
return
|
||||
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language)
|
||||
instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language)
|
||||
|
||||
try:
|
||||
instance._dirty = True
|
||||
instance.skip_signal = True
|
||||
instance.save()
|
||||
finally:
|
||||
del instance._dirty
|
||||
del instance.skip_signal
|
||||
|
||||
|
||||
@receiver(post_save, sender=Step)
|
||||
@skip_signal
|
||||
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:
|
||||
return
|
||||
|
||||
# needed to ensure search vector update doesn't trigger recursion
|
||||
if hasattr(instance, '_dirty'):
|
||||
inherit = instance.inherit_fields.all()
|
||||
# nothing to apply from parent and nothing to apply to children
|
||||
if (not instance.parent or inherit.count() == 0) and instance.numchild == 0:
|
||||
return
|
||||
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language)
|
||||
inherit = inherit.values_list('field', flat=True)
|
||||
# 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:
|
||||
instance.skip_signal = True
|
||||
instance.save()
|
||||
finally:
|
||||
del instance.skip_signal
|
||||
|
||||
try:
|
||||
instance._dirty = True
|
||||
instance.save()
|
||||
finally:
|
||||
del instance._dirty
|
||||
# TODO figure out how to generalize this
|
||||
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
|
||||
_save = []
|
||||
for child in instance.get_children().filter(inherit_fields__field='food_onhand'):
|
||||
child.food_onhand = instance.food_onhand
|
||||
_save.append(child)
|
||||
# don't cascade empty supermarket category
|
||||
if instance.supermarket_category:
|
||||
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
|
||||
for child in instance.get_children().filter(inherit_fields__field='supermarket_category'):
|
||||
child.supermarket_category = instance.supermarket_category
|
||||
_save.append(child)
|
||||
for child in set(_save):
|
||||
child.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=MealPlan)
|
||||
def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs):
|
||||
user = instance.get_owner()
|
||||
if not user.userpreference.mealplan_autoadd_shopping:
|
||||
return
|
||||
|
||||
if not created and instance.shoppinglistrecipe_set.exists():
|
||||
for x in instance.shoppinglistrecipe_set.all():
|
||||
if instance.servings != x.servings:
|
||||
list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
|
||||
elif created:
|
||||
# if creating a mealplan - perform shopping list activities
|
||||
kwargs = {
|
||||
'mealplan': instance,
|
||||
'space': instance.space,
|
||||
'created_by': user,
|
||||
'servings': instance.servings
|
||||
}
|
||||
list_recipe = list_from_recipe(**kwargs)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -339,7 +339,6 @@
|
||||
{% user_prefs request as prefs%}
|
||||
{{ prefs|json_script:'user_preference' }}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% block script %}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% load render_bundle from webpack_loader %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% comment %} {% load l10n %} {% endcomment %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content_fluid %}
|
||||
|
||||
<div id="app" >
|
||||
<checklist-view></checklist-view>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
{{ config | json_script:"model_config" }}
|
||||
|
||||
{% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
|
||||
</script>
|
||||
|
||||
{% render_bundle 'checklist_view' %}
|
||||
{% endblock %}
|
||||
@@ -18,12 +18,23 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="table-container">
|
||||
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
||||
{% if create_url %}
|
||||
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
||||
<span class="col col-md-9">
|
||||
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
||||
{% if create_url %}
|
||||
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</span>
|
||||
{% if request.resolver_match.url_name in 'list_shopping_list' %}
|
||||
<span class="col-md-3">
|
||||
<a href="{% url 'view_shopping_new' %}" class="float-right">
|
||||
<button class="btn btn-outline-secondary shadow-none">
|
||||
<i class="fas fa-star"></i> {% trans 'Try the new shopping list' %}
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if filter %}
|
||||
<br/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% comment %} TODO: Deprecate {% endcomment %}
|
||||
|
||||
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
|
||||
<div class="modal-dialog" role="document">
|
||||
@@ -77,4 +78,4 @@
|
||||
$('#id_rating_show').html(rating.val() + '/5')
|
||||
});
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -48,6 +48,13 @@
|
||||
aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Search-Settings' %}</a>
|
||||
</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>
|
||||
|
||||
@@ -195,6 +202,17 @@
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
@@ -224,5 +242,26 @@
|
||||
$('.nav-tabs a').on('shown.bs.tab', function (e) {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
17
cookbook/templates/shoppinglist_template.html
Normal file
17
cookbook/templates/shoppinglist_template.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %} {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} {% block title %} {{ title }} {% endblock %} {% block content_fluid %}
|
||||
|
||||
<div id="app">
|
||||
<shopping-list-view></shopping-list-view>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block script %} {% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
|
||||
</script>
|
||||
|
||||
{% render_bundle 'shopping_list_view' %} {% endblock %}
|
||||
@@ -1,165 +1,188 @@
|
||||
{% extends "base.html" %}
|
||||
{% load django_tables2 %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Space Settings" %}{% endblock %}
|
||||
{%block title %} {% trans "Space Settings" %} {% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ form.media }}
|
||||
|
||||
{{ space_form.media }}
|
||||
{% include 'include/vue_base.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
<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="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
{% trans 'Number of objects' %}
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">{% trans 'Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes }} /
|
||||
{% if request.space.max_recipes > 0 %}
|
||||
{{ request.space.max_recipes }}{% else %}∞{% endif %}</span></li>
|
||||
<li class="list-group-item">{% trans 'Keywords' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li>
|
||||
<li class="list-group-item">{% 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
{% trans 'Objects stats' %}
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">{% trans 'Recipes without Keywords' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span></li>
|
||||
<li class="list-group-item">{% trans 'External Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_external }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Internal Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Comments' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.comments }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">{% trans 'Number of objects' %}</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
{% trans 'Recipes' %} :
|
||||
<span class="badge badge-pill badge-info"
|
||||
>{{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{%
|
||||
else %}∞{% endif %}</span
|
||||
>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Keywords' %} : <span class="badge badge-pill badge-info">{{ counts.keywords }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% 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>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
|
||||
<h4>{% trans 'Members' %} <small class="text-muted">{{ space_users|length }}/
|
||||
{% if request.space.max_users > 0 %}
|
||||
{{ request.space.max_users }}{% else %}∞{% endif %}</small>
|
||||
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"><i
|
||||
class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a>
|
||||
</h4>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">{% trans 'Objects stats' %}</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
{% trans 'Recipes without Keywords' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'External Recipes' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipes_external }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Internal Recipes' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Comments' %} : <span class="badge badge-pill badge-info">{{ counts.comments }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% if space_users %}
|
||||
<table class="table table-bordered">
|
||||
<tr>
|
||||
<th>{% trans 'User' %}</th>
|
||||
<th>{% trans 'Groups' %}</th>
|
||||
<th>{% trans 'Edit' %}</th>
|
||||
</tr>
|
||||
{% for u in space_users %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ u.user.username }}
|
||||
</td>
|
||||
<td>
|
||||
{{ u.user.groups.all |join:", " }}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.user != request.user %}
|
||||
<div class="input-group mb-3">
|
||||
<select v-model="users['{{ u.pk }}']" class="custom-select form-control"
|
||||
style="height: 44px">
|
||||
<option value="admin">{% trans 'admin' %}</option>
|
||||
<option value="user">{% trans 'user' %}</option>
|
||||
<option value="guest">{% trans 'guest' %}</option>
|
||||
<option value="remove">{% trans 'remove' %}</option>
|
||||
</select>
|
||||
<span class="input-group-append">
|
||||
<a class="btn btn-warning"
|
||||
:href="editUserUrl({{ u.pk }}, {{ u.space.pk }})">{% trans 'Update' %}</a>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% trans 'You cannot edit yourself.' %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>{% trans 'There are no members in your space yet!' %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<form action="." method="post">{% csrf_token %} {{ user_name_form|crispy }}</form>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4>
|
||||
{% trans 'Members' %}
|
||||
<small class="text-muted"
|
||||
>{{ space_users|length }}/ {% if request.space.max_users > 0 %} {{ request.space.max_users }}{% else
|
||||
%}∞{% endif %}</small
|
||||
>
|
||||
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"
|
||||
><i class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a
|
||||
>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4>{% trans 'Invite Links' %}</h4>
|
||||
{% render_table invite_links %}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% if space_users %}
|
||||
<table class="table table-bordered">
|
||||
<tr>
|
||||
<th>{% trans 'User' %}</th>
|
||||
<th>{% trans 'Groups' %}</th>
|
||||
<th>{% trans 'Edit' %}</th>
|
||||
</tr>
|
||||
{% for u in space_users %}
|
||||
<tr>
|
||||
<td>{{ u.user.username }}</td>
|
||||
<td>{{ u.user.groups.all |join:", " }}</td>
|
||||
<td>
|
||||
{% if u.user != request.user %}
|
||||
<div class="input-group mb-3">
|
||||
<select v-model="users['{{ u.pk }}']" class="custom-select form-control" style="height: 44px">
|
||||
<option value="admin">{% trans 'admin' %}</option>
|
||||
<option value="user">{% trans 'user' %}</option>
|
||||
<option value="guest">{% trans 'guest' %}</option>
|
||||
<option value="remove">{% trans 'remove' %}</option>
|
||||
</select>
|
||||
<span class="input-group-append">
|
||||
<a class="btn btn-warning" :href="editUserUrl({{ u.pk }}, {{ u.space.pk }})"
|
||||
>{% trans 'Update' %}</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
{% else %} {% trans 'You cannot edit yourself.' %} {% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>{% trans 'There are no members in your space yet!' %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4>{% trans 'Invite Links' %}</h4>
|
||||
{% render_table invite_links %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<div class="col col-md-12">
|
||||
<h4>{% trans 'Space Settings' %}</h4>
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ space_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="space_form"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% endblock %} {% block script %}
|
||||
|
||||
<script type="application/javascript">
|
||||
let app = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#id_base_container',
|
||||
data: {
|
||||
users: {
|
||||
{% for u in space_users %}
|
||||
'{{ u.pk }}': 'none',
|
||||
{% endfor %}
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
|
||||
},
|
||||
methods: {
|
||||
editUserUrl: function (user_id, space_id) {
|
||||
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
|
||||
<script type="application/javascript">
|
||||
let app = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#id_base_container',
|
||||
data: {
|
||||
users: {
|
||||
{% for u in space_users %}
|
||||
'{{ u.pk }}': 'none',
|
||||
{% endfor %}
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
|
||||
},
|
||||
methods: {
|
||||
editUserUrl: function (user_id, space_id) {
|
||||
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -715,7 +715,6 @@
|
||||
},
|
||||
methods: {
|
||||
makeToast: function (title, message, variant = null) {
|
||||
//TODO remove duplicate function in favor of central one
|
||||
this.$bvToast.toast(message, {
|
||||
title: title,
|
||||
variant: variant,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from django.contrib import auth
|
||||
from django_scopes import scopes_disabled
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from pytest_factoryboy import LazyFixture, register
|
||||
|
||||
|
||||
from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry
|
||||
from cookbook.models import Food, FoodInheritField, Ingredient, ShoppingList, ShoppingListEntry
|
||||
from cookbook.tests.factories import (FoodFactory, IngredientFactory, ShoppingListEntryFactory,
|
||||
SupermarketCategoryFactory)
|
||||
|
||||
# ------------------ IMPORTANT -------------------
|
||||
#
|
||||
@@ -27,78 +29,50 @@ else:
|
||||
node_location = 'last-child'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1(space_1):
|
||||
return Food.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||
register(FoodFactory, 'obj_1', space=LazyFixture('space_1'))
|
||||
register(FoodFactory, 'obj_2', space=LazyFixture('space_1'))
|
||||
register(FoodFactory, 'obj_3', space=LazyFixture('space_2'))
|
||||
register(SupermarketCategoryFactory, 'cat_1', space=LazyFixture('space_1'))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1_1(obj_1, space_1):
|
||||
return obj_1.add_child(name='test_1_1', space=space_1)
|
||||
|
||||
|
||||
@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
|
||||
# def true():
|
||||
# return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obj_2(space_1):
|
||||
return Food.objects.get_or_create(name='test_2', space=space_1)[0]
|
||||
def false():
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def non_exist():
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_3(space_2):
|
||||
return Food.objects.get_or_create(name='test_3', space=space_2)[0]
|
||||
def obj_tree_1(request, space_1):
|
||||
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()
|
||||
def ing_1_s1(obj_1, space_1):
|
||||
return Ingredient.objects.create(food=obj_1, space=space_1)
|
||||
|
||||
|
||||
@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
|
||||
objs[0].move(objs[1], node_location)
|
||||
objs[1].move(objs[2], node_location)
|
||||
return Food.objects.get(id=objs[1].id) # whenever you move/merge a tree it's safest to re-get the object
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
@@ -128,7 +102,10 @@ def test_list_filter(obj_1, obj_2, u1_s1):
|
||||
assert r.status_code == 200
|
||||
response = json.loads(r.content)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@@ -194,7 +171,6 @@ def test_add(arg, request, u1_s2):
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
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_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
|
||||
|
||||
|
||||
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():
|
||||
assert Food.objects.count() == 3
|
||||
assert Food.objects.count() == 4
|
||||
|
||||
r = u1_s2.delete(
|
||||
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
|
||||
with scopes_disabled():
|
||||
assert Food.objects.count() == 3
|
||||
assert Food.objects.count() == 4
|
||||
|
||||
# should delete self and child, leaving parent
|
||||
r = u1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1_1.id}
|
||||
args={obj_tree_1.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert Food.objects.count() == 1
|
||||
assert Food.objects.count() == 2
|
||||
assert Food.find_problems() == ([], [], [], [], [])
|
||||
|
||||
|
||||
@@ -283,13 +260,16 @@ def test_integrity(u1_s1, recipe_1_s1):
|
||||
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):
|
||||
url = reverse(MOVE_URL, args=[obj_1_1.id, obj_2.id])
|
||||
with scopes_disabled():
|
||||
assert obj_1.get_num_children() == 1
|
||||
assert obj_1.get_descendant_count() == 2
|
||||
def test_move(u1_s1, obj_tree_1, obj_2, 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
|
||||
|
||||
url = reverse(MOVE_URL, args=[obj_tree_1.id, obj_2.id])
|
||||
|
||||
# move child to new parent, only HTTP put method should work
|
||||
r = u1_s1.get(url)
|
||||
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
|
||||
with scopes_disabled():
|
||||
# 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)
|
||||
assert obj_1.get_num_children() == 0
|
||||
assert obj_1.get_descendant_count() == 0
|
||||
assert parent.get_num_children() == 0
|
||||
assert parent.get_descendant_count() == 0
|
||||
assert obj_2.get_num_children() == 1
|
||||
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
|
||||
with scopes_disabled():
|
||||
assert Food.find_problems() == ([], [], [], [], [])
|
||||
|
||||
|
||||
def test_merge(
|
||||
u1_s1,
|
||||
obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3,
|
||||
ing_1_s1, ing_2_s1, ing_3_s2, ing_1_1_s1,
|
||||
sle_1_s1, sle_2_s1, sle_3_s2, sle_1_1_s1,
|
||||
space_1
|
||||
):
|
||||
def test_move_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]
|
||||
# move child to root
|
||||
r = u1_s1.put(reverse(MOVE_URL, args=[obj_tree_1.id, 0]))
|
||||
assert r.status_code == 200
|
||||
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.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
|
||||
url = reverse(MERGE_URL, args=[obj_1_1_1.id, obj_2.id])
|
||||
# attempt to move to non-existent parent
|
||||
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)
|
||||
assert r.status_code == 405
|
||||
r = u1_s1.post(url)
|
||||
@@ -364,88 +390,142 @@ def test_merge(
|
||||
assert r.status_code == 405
|
||||
r = u1_s1.put(url)
|
||||
assert r.status_code == 200
|
||||
with scopes_disabled():
|
||||
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 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_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_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
|
||||
with scopes_disabled():
|
||||
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
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root=0').content)
|
||||
assert len(response['results']) == 2
|
||||
|
||||
with scopes_disabled():
|
||||
obj_2.move(obj_1, node_location)
|
||||
# should return direct children of obj_1 (obj_1_1, obj_2), ignoring query filters
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={obj_1.id}').content)
|
||||
obj_2.move(parent, node_location)
|
||||
# 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={parent.id}').content)
|
||||
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
|
||||
|
||||
|
||||
def test_tree_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
|
||||
with scopes_disabled():
|
||||
obj_2.move(obj_1, node_location)
|
||||
# should return full tree starting at obj_1 (obj_1_1_1, obj_2), ignoring query filters
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}').content)
|
||||
def test_tree_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]
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
# This is more about the model than the API - should this be moved to a different test?
|
||||
@pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [
|
||||
({'has_category': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'),
|
||||
({'has_category': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'),
|
||||
({'food_onhand': True, 'inherit': True}, 'food_onhand', True, 'false'),
|
||||
({'food_onhand': True, 'inherit': False}, 'food_onhand', False, 'false'),
|
||||
], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter
|
||||
def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
|
||||
with scope(space=obj_tree_1.space):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
|
||||
new_val = request.getfixturevalue(new_val)
|
||||
# if this test passes it demonstrates that inheritance works
|
||||
# when moving to a parent as each food is created with a different category
|
||||
assert (getattr(parent, field) == getattr(obj_tree_1, field)) in [inherit, True]
|
||||
assert (getattr(obj_tree_1, field) == getattr(child, field)) in [inherit, True]
|
||||
# change parent to a new value
|
||||
setattr(parent, field, new_val)
|
||||
with scope(space=parent.space):
|
||||
parent.save() # trigger post-save signal
|
||||
# get the objects again because values are cached
|
||||
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
|
||||
child = Food.objects.get(id=child.id)
|
||||
# when changing parent value the obj value should be same if inherited
|
||||
assert (getattr(obj_tree_1, field) == new_val) == inherit
|
||||
assert (getattr(child, field) == new_val) == inherit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("obj_tree_1", [
|
||||
({'has_category': True, 'inherit': False, 'food_onhand': True}),
|
||||
], indirect=['obj_tree_1'])
|
||||
def test_reset_inherit(obj_tree_1, space_1):
|
||||
with scope(space=space_1):
|
||||
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
obj_tree_1.food_onhand = False
|
||||
assert parent.food_onhand == child.food_onhand
|
||||
assert parent.food_onhand != obj_tree_1.food_onhand
|
||||
assert parent.supermarket_category != child.supermarket_category
|
||||
assert parent.supermarket_category != obj_tree_1.supermarket_category
|
||||
|
||||
parent.reset_inheritance(space=space_1)
|
||||
# djangotree bypasses ORM and need to be retrieved again
|
||||
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
assert parent.food_onhand == obj_tree_1.food_onhand == child.food_onhand
|
||||
assert parent.supermarket_category == obj_tree_1.supermarket_category == child.supermarket_category
|
||||
|
||||
96
cookbook/tests/api/test_api_food_shopping.py
Normal file
96
cookbook/tests/api/test_api_food_shopping.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# test create
|
||||
# test create units
|
||||
# test amounts
|
||||
# test create wrong space
|
||||
# test sharing
|
||||
# test delete
|
||||
# test delete checked (nothing should happen)
|
||||
# test delete not shared (nothing happens)
|
||||
# test delete shared
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
from cookbook.models import Food, ShoppingListEntry
|
||||
from cookbook.tests.factories import FoodFactory
|
||||
|
||||
SHOPPING_LIST_URL = 'api:shoppinglistentry-list'
|
||||
SHOPPING_FOOD_URL = 'api:food-shopping'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def food(request, space_1, u1_s1):
|
||||
return FoodFactory(space=space_1)
|
||||
|
||||
|
||||
def test_shopping_forbidden_methods(food, u1_s1):
|
||||
r = u1_s1.post(
|
||||
reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
assert r.status_code == 405
|
||||
|
||||
r = u1_s1.delete(
|
||||
reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
assert r.status_code == 405
|
||||
|
||||
r = u1_s1.get(
|
||||
reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
assert r.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 204],
|
||||
['u1_s2', 404],
|
||||
['a1_s1', 204],
|
||||
])
|
||||
def test_shopping_food_create(request, arg, food):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.put(reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 204:
|
||||
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 204],
|
||||
['u1_s2', 404],
|
||||
['a1_s1', 204],
|
||||
])
|
||||
def test_shopping_food_delete(request, arg, food):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.put(
|
||||
reverse(SHOPPING_FOOD_URL, args={food.id}),
|
||||
{'_delete': "true"},
|
||||
content_type='application/json'
|
||||
)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 204:
|
||||
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
|
||||
|
||||
def test_shopping_food_share(u1_s1, u2_s1, food, space_1):
|
||||
with scope(space=space_1):
|
||||
user1 = auth.get_user(u1_s1)
|
||||
user2 = auth.get_user(u2_s1)
|
||||
food2 = FoodFactory(space=space_1)
|
||||
r = u1_s1.put(reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
r = u2_s1.put(reverse(SHOPPING_FOOD_URL, args={food2.id}))
|
||||
sl_1 = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
sl_2 = json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert len(sl_1) == 1
|
||||
assert len(sl_2) == 1
|
||||
sl_1[0]['created_by']['id'] == user1.id
|
||||
sl_2[0]['created_by']['id'] == user2.id
|
||||
|
||||
with scopes_disabled():
|
||||
user1.userpreference.shopping_share.add(user2)
|
||||
user1.userpreference.save()
|
||||
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 1
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 2
|
||||
@@ -4,13 +4,16 @@ from datetime import datetime, timedelta
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
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.tests.factories import RecipeFactory
|
||||
|
||||
LIST_URL = 'api:mealplan-list'
|
||||
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()
|
||||
def meal_type(space_1, u1_s1):
|
||||
@@ -106,7 +109,7 @@ def test_add(arg, request, u1_s2, recipe_1_s1, meal_type):
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'recipe': {'id': recipe_1_s1.id, 'name': recipe_1_s1.name, 'keywords': []}, 'meal_type': {'id': meal_type.id, 'name': meal_type.name},
|
||||
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test','shared':[]},
|
||||
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test', 'shared': []},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
@@ -139,3 +142,17 @@ def test_delete(u1_s1, u1_s2, obj_1):
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert MealPlan.objects.count() == 0
|
||||
|
||||
|
||||
def test_add_with_shopping(u1_s1, meal_type):
|
||||
space = meal_type.space
|
||||
with scope(space=space):
|
||||
recipe = RecipeFactory.create(space=space)
|
||||
r = u1_s1.post(
|
||||
reverse(LIST_URL),
|
||||
{'recipe': {'id': recipe.id, 'name': recipe.name, 'keywords': []}, 'meal_type': {'id': meal_type.id, 'name': meal_type.name},
|
||||
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test', 'shared': [], 'addshopping': True},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse('api:shoppinglistentry-list')).content)) == 10
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
74
cookbook/tests/api/test_api_related_recipe.py
Normal file
74
cookbook/tests/api/test_api_related_recipe.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
|
||||
import factory
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
from pytest_factoryboy import LazyFixture, register
|
||||
|
||||
from cookbook.tests.factories import RecipeFactory
|
||||
|
||||
RELATED_URL = 'api:recipe-related'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def recipe(request, space_1, u1_s1):
|
||||
try:
|
||||
params = request.param # request.param is a magic variable
|
||||
except AttributeError:
|
||||
params = {}
|
||||
step_recipe = params.get('steps__count', 1)
|
||||
steps__recipe_count = params.get('steps__recipe_count', 0)
|
||||
steps__food_recipe_count = params.get('steps__food_recipe_count', {})
|
||||
created_by = params.get('created_by', auth.get_user(u1_s1))
|
||||
|
||||
return RecipeFactory.create(
|
||||
steps__recipe_count=steps__recipe_count,
|
||||
steps__food_recipe_count=steps__food_recipe_count,
|
||||
created_by=created_by,
|
||||
space=space_1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['g1_s1', 200],
|
||||
['u1_s1', 200],
|
||||
['u1_s2', 404],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
@pytest.mark.parametrize("recipe, related_count", [
|
||||
({}, 0),
|
||||
({'steps__recipe_count': 1}, 1), # shopping list from recipe with StepRecipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 1), # shopping list from recipe with food recipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 2), # shopping list from recipe with StepRecipe and food recipe
|
||||
], indirect=['recipe'])
|
||||
def test_get_related_recipes(request, arg, recipe, related_count, u1_s1, space_2):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.get(reverse(RELATED_URL, args={recipe.id}))
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
assert len(json.loads(r.content)) == related_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe", [
|
||||
({'steps__recipe_count': 1}), # shopping list from recipe with StepRecipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}}), # shopping list from recipe with food recipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}), # shopping list from recipe with StepRecipe and food recipe
|
||||
], indirect=['recipe'])
|
||||
def test_related_mixed_space(request, recipe, u1_s2):
|
||||
with scopes_disabled():
|
||||
recipe.space = auth.get_user(u1_s2).userpreference.space
|
||||
recipe.save()
|
||||
assert len(json.loads(
|
||||
u1_s2.get(
|
||||
reverse(RELATED_URL, args={recipe.id})).content)) == 0
|
||||
|
||||
|
||||
# TODO add tests for mealplan related when thats added
|
||||
# TODO if/when related recipes includes multiple levels (related recipes of related recipes) add the following tests
|
||||
# -- step recipes included in step recipes
|
||||
# -- step recipes included in food recipes
|
||||
# -- food recipes included in step recipes
|
||||
# -- food recipes included in food recipes
|
||||
# -- -- included recipes in the wrong space
|
||||
@@ -5,7 +5,7 @@ from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_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'
|
||||
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
|
||||
|
||||
|
||||
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", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 404],
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.forms import model_to_dict
|
||||
from django.urls import reverse
|
||||
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'
|
||||
DETAIL_URL = 'api:shoppinglistentry-detail'
|
||||
@@ -14,7 +14,7 @@ DETAIL_URL = 'api:shoppinglistentry-detail'
|
||||
|
||||
@pytest.fixture()
|
||||
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.entries.add(e)
|
||||
return e
|
||||
@@ -22,7 +22,7 @@ def obj_1(space_1, u1_s1):
|
||||
|
||||
@pytest.fixture
|
||||
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.entries.add(e)
|
||||
return e
|
||||
@@ -45,8 +45,11 @@ def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||
|
||||
with scopes_disabled():
|
||||
s = ShoppingList.objects.first()
|
||||
e = ShoppingListEntry.objects.first()
|
||||
s.space = space_2
|
||||
e.space = space_2
|
||||
s.save()
|
||||
e.save()
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
219
cookbook/tests/api/test_api_shopping_list_entryv2.py
Normal file
219
cookbook/tests/api/test_api_shopping_list_entryv2.py
Normal file
@@ -0,0 +1,219 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
import factory
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.forms import model_to_dict
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_scopes import scopes_disabled
|
||||
from pytest_factoryboy import LazyFixture, register
|
||||
|
||||
from cookbook.models import ShoppingListEntry
|
||||
from cookbook.tests.factories import ShoppingListEntryFactory
|
||||
|
||||
LIST_URL = 'api:shoppinglistentry-list'
|
||||
DETAIL_URL = 'api:shoppinglistentry-detail'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sle(space_1, u1_s1):
|
||||
user = auth.get_user(u1_s1)
|
||||
return ShoppingListEntryFactory.create_batch(10, space=space_1, created_by=user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sle_2(request):
|
||||
try:
|
||||
params = request.param # request.param is a magic variable
|
||||
except AttributeError:
|
||||
params = {}
|
||||
u = request.getfixturevalue(params.get('user', 'u1_s1'))
|
||||
user = auth.get_user(u)
|
||||
count = params.get('count', 10)
|
||||
return ShoppingListEntryFactory.create_batch(count, space=user.userpreference.space, created_by=user)
|
||||
|
||||
|
||||
@ pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 200],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
def test_list_permission(arg, request):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||
|
||||
|
||||
def test_list_space(sle, u1_s1, u1_s2, space_2):
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
with scopes_disabled():
|
||||
e = ShoppingListEntry.objects.first()
|
||||
e.space = space_2
|
||||
e.save()
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 9
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
|
||||
def test_get_detail(u1_s1, sle):
|
||||
r = u1_s1.get(reverse(
|
||||
DETAIL_URL,
|
||||
args={sle[0].id}
|
||||
))
|
||||
assert json.loads(r.content)['id'] == sle[0].id
|
||||
|
||||
|
||||
@ pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 404],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 404],
|
||||
['g1_s2', 404],
|
||||
['u1_s2', 404],
|
||||
['a1_s2', 404],
|
||||
])
|
||||
def test_update(arg, request, sle):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
new_val = float(sle[0].amount + 1)
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={sle[0].id}
|
||||
),
|
||||
{'amount': new_val},
|
||||
content_type='application/json'
|
||||
)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
response = json.loads(r.content)
|
||||
assert response['amount'] == new_val
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 201],
|
||||
['u1_s1', 201],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, sle):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'food': model_to_dict(sle[0].food), 'amount': 1},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
print(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 201:
|
||||
assert response['food']['id'] == sle[0].food.pk
|
||||
|
||||
|
||||
def test_delete(u1_s1, u1_s2, sle):
|
||||
r = u1_s2.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={sle[0].id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
r = u1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={sle[0].id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shared, count, sle_2", [
|
||||
('g1_s1', 20, {'user': 'g1_s1'}),
|
||||
('g1_s2', 10, {'user': 'g1_s2'}),
|
||||
('u2_s1', 20, {'user': 'u2_s1'}),
|
||||
('u1_s2', 10, {'user': 'u1_s2'}),
|
||||
('a1_s1', 20, {'user': 'a1_s1'}),
|
||||
('a1_s2', 10, {'user': 'a1_s2'}),
|
||||
], indirect=['sle_2'])
|
||||
def test_sharing(request, shared, count, sle_2, sle, u1_s1):
|
||||
user = auth.get_user(u1_s1)
|
||||
shared_client = request.getfixturevalue(shared)
|
||||
shared_user = auth.get_user(shared_client)
|
||||
|
||||
# confirm shared user can't access shopping list items created by u1_s1
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
|
||||
assert len(json.loads(shared_client.get(reverse(LIST_URL)).content)) == 10
|
||||
|
||||
user.userpreference.shopping_share.add(shared_user)
|
||||
# confirm sharing user only sees their shopping list
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
|
||||
r = shared_client.get(reverse(LIST_URL))
|
||||
# confirm shared user sees their list and the list that's shared with them
|
||||
assert len(json.loads(r.content)) == count
|
||||
|
||||
|
||||
def test_completed(sle, u1_s1):
|
||||
# check 1 entry
|
||||
#
|
||||
u1_s1.patch(
|
||||
reverse(DETAIL_URL, args={sle[0].id}),
|
||||
{'checked': True},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(LIST_URL)).content)
|
||||
assert len(r) == 10
|
||||
# count unchecked entries
|
||||
assert [x['checked'] for x in r].count(False) == 9
|
||||
# confirm completed_at is populated
|
||||
assert [(x['completed_at'] != None) for x in r if x['checked']].count(True) == 1
|
||||
|
||||
assert len(json.loads(u1_s1.get(f'{reverse(LIST_URL)}?checked=0').content)) == 9
|
||||
assert len(json.loads(u1_s1.get(f'{reverse(LIST_URL)}?checked=1').content)) == 1
|
||||
|
||||
# uncheck entry
|
||||
u1_s1.patch(
|
||||
reverse(DETAIL_URL, args={sle[0].id}),
|
||||
{'checked': False},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(LIST_URL)).content)
|
||||
assert [x['checked'] for x in r].count(False) == 10
|
||||
# confirm completed_at value cleared
|
||||
assert [(x['completed_at'] != None) for x in r if x['checked']].count(True) == 0
|
||||
|
||||
|
||||
def test_recent(sle, u1_s1):
|
||||
user = auth.get_user(u1_s1)
|
||||
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||
|
||||
# past_date within recent_days threshold
|
||||
past_date = today_start - timedelta(days=user.userpreference.shopping_recent_days - 1)
|
||||
sle[0].checked = True
|
||||
sle[0].completed_at = past_date
|
||||
sle[0].save()
|
||||
|
||||
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
|
||||
assert len(r) == 10
|
||||
assert [x['checked'] for x in r].count(False) == 9
|
||||
|
||||
# past_date outside recent_days threshold
|
||||
past_date = today_start - timedelta(days=user.userpreference.shopping_recent_days + 2)
|
||||
sle[0].completed_at = past_date
|
||||
sle[0].save()
|
||||
|
||||
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
|
||||
assert len(r) == 9
|
||||
assert [x['checked'] for x in r].count(False) == 9
|
||||
|
||||
# user preference moved to include entry again
|
||||
user.userpreference.shopping_recent_days = user.userpreference.shopping_recent_days + 4
|
||||
user.userpreference.save()
|
||||
|
||||
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
|
||||
assert len(r) == 10
|
||||
assert [x['checked'] for x in r].count(False) == 9
|
||||
243
cookbook/tests/api/test_api_shopping_recipe.py
Normal file
243
cookbook/tests/api/test_api_shopping_recipe.py
Normal file
@@ -0,0 +1,243 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
import factory
|
||||
import pytest
|
||||
# work around for bug described here https://stackoverflow.com/a/70312265/15762829
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.forms import model_to_dict
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from pytest_factoryboy import LazyFixture, register
|
||||
|
||||
from cookbook.models import Food, Ingredient, ShoppingListEntry, Step
|
||||
from cookbook.tests.factories import (IngredientFactory, MealPlanFactory, RecipeFactory,
|
||||
StepFactory, UserFactory)
|
||||
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
from django.db.backends.postgresql.features import DatabaseFeatures
|
||||
DatabaseFeatures.can_defer_constraint_checks = False
|
||||
|
||||
SHOPPING_LIST_URL = 'api:shoppinglistentry-list'
|
||||
SHOPPING_RECIPE_URL = 'api:recipe-shopping'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user2(request, u1_s1):
|
||||
try:
|
||||
params = request.param # request.param is a magic variable
|
||||
except AttributeError:
|
||||
params = {}
|
||||
user = auth.get_user(u1_s1)
|
||||
user.userpreference.mealplan_autoadd_shopping = params.get('mealplan_autoadd_shopping', True)
|
||||
user.userpreference.mealplan_autoinclude_related = params.get('mealplan_autoinclude_related', True)
|
||||
user.userpreference.mealplan_autoexclude_onhand = params.get('mealplan_autoexclude_onhand', True)
|
||||
user.userpreference.save()
|
||||
return u1_s1
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def recipe(request, space_1, u1_s1):
|
||||
try:
|
||||
params = request.param # request.param is a magic variable
|
||||
except AttributeError:
|
||||
params = {}
|
||||
# step_recipe = params.get('steps__count', 1)
|
||||
# steps__recipe_count = params.get('steps__recipe_count', 0)
|
||||
# steps__food_recipe_count = params.get('steps__food_recipe_count', {})
|
||||
params['created_by'] = params.get('created_by', auth.get_user(u1_s1))
|
||||
params['space'] = space_1
|
||||
return RecipeFactory(**params)
|
||||
|
||||
# return RecipeFactory.create(
|
||||
# steps__recipe_count=steps__recipe_count,
|
||||
# steps__food_recipe_count=steps__food_recipe_count,
|
||||
# created_by=created_by,
|
||||
# space=space_1,
|
||||
# )
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['g1_s1', 204],
|
||||
['u1_s1', 204],
|
||||
['u1_s2', 404],
|
||||
['a1_s1', 204],
|
||||
])
|
||||
@pytest.mark.parametrize("recipe, sle_count", [
|
||||
({}, 10),
|
||||
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe
|
||||
], indirect=['recipe'])
|
||||
def test_shopping_recipe_method(request, arg, recipe, sle_count, u1_s1, u2_s1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
user = auth.get_user(c)
|
||||
user.userpreference.mealplan_autoadd_shopping = True
|
||||
user.userpreference.save()
|
||||
|
||||
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
|
||||
url = reverse(SHOPPING_RECIPE_URL, args={recipe.id})
|
||||
r = c.put(url)
|
||||
assert r.status_code == arg[1]
|
||||
# only PUT method should work
|
||||
if r.status_code == 204: # skip anonymous user
|
||||
|
||||
r = json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert len(r) == sle_count # recipe factory creates 10 ingredients by default
|
||||
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
|
||||
# user in space can't see shopping list
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
user.userpreference.shopping_share.add(auth.get_user(u2_s1))
|
||||
# after share, user in space can see shopping list
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||
# confirm that the author of the recipe doesn't have access to shopping list
|
||||
if c != u1_s1:
|
||||
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
|
||||
r = c.get(url)
|
||||
assert r.status_code == 405
|
||||
r = c.post(url)
|
||||
assert r.status_code == 405
|
||||
r = c.delete(url)
|
||||
assert r.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe, sle_count", [
|
||||
({}, 10),
|
||||
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe
|
||||
], indirect=['recipe'])
|
||||
@pytest.mark.parametrize("use_mealplan", [(False), (True), ])
|
||||
def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u2_s1):
|
||||
# tests editing shopping list via recipe or mealplan
|
||||
with scopes_disabled():
|
||||
user = auth.get_user(u1_s1)
|
||||
user2 = auth.get_user(u2_s1)
|
||||
user.userpreference.mealplan_autoinclude_related = True
|
||||
user.userpreference.mealplan_autoadd_shopping = True
|
||||
user.userpreference.shopping_share.add(user2)
|
||||
user.userpreference.save()
|
||||
|
||||
if use_mealplan:
|
||||
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
|
||||
else:
|
||||
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
|
||||
all_ing = [x['ingredient'] for x in r]
|
||||
keep_ing = all_ing[1:-1] # remove first and last element
|
||||
del keep_ing[int(len(keep_ing)/2)] # remove a middle element
|
||||
list_recipe = r[0]['list_recipe']
|
||||
amount_sum = sum([x['amount'] for x in r])
|
||||
|
||||
# test modifying shopping list as different user
|
||||
# test increasing servings size of recipe shopping list
|
||||
if use_mealplan:
|
||||
mealplan.servings = 2*recipe.servings
|
||||
mealplan.save()
|
||||
else:
|
||||
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||
{'list_recipe': list_recipe, 'servings': 2*recipe.servings},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert sum([x['amount'] for x in r]) == amount_sum * 2
|
||||
assert len(r) == sle_count
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||
|
||||
# testing decreasing servings size of recipe shopping list
|
||||
if use_mealplan:
|
||||
mealplan.servings = .5 * recipe.servings
|
||||
mealplan.save()
|
||||
else:
|
||||
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||
{'list_recipe': list_recipe, 'servings': .5 * recipe.servings},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert sum([x['amount'] for x in r]) == amount_sum * .5
|
||||
assert len(r) == sle_count
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||
|
||||
# test removing 2 items from shopping list
|
||||
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||
{'list_recipe': list_recipe, 'ingredients': keep_ing},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert len(r) == sle_count - 3
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count - 3
|
||||
|
||||
# add all ingredients to existing shopping list - don't change serving size
|
||||
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||
{'list_recipe': list_recipe, 'ingredients': all_ing},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert sum([x['amount'] for x in r]) == amount_sum * .5
|
||||
assert len(r) == sle_count
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user2, sle_count", [
|
||||
({'mealplan_autoadd_shopping': False}, (0, 18)),
|
||||
({'mealplan_autoinclude_related': False}, (9, 9)),
|
||||
({'mealplan_autoexclude_onhand': False}, (20, 20)),
|
||||
({'mealplan_autoexclude_onhand': False, 'mealplan_autoinclude_related': False}, (10, 10)),
|
||||
], indirect=['user2'])
|
||||
@pytest.mark.parametrize("use_mealplan", [(False), (True), ])
|
||||
@pytest.mark.parametrize("recipe", [({'steps__recipe_count': 1})], indirect=['recipe'])
|
||||
def test_shopping_recipe_userpreference(recipe, sle_count, use_mealplan, user2):
|
||||
with scopes_disabled():
|
||||
user = auth.get_user(user2)
|
||||
# setup recipe with 10 ingredients, 1 step recipe with 10 ingredients, 2 food onhand(from recipe and step_recipe)
|
||||
ingredients = Ingredient.objects.filter(step__recipe=recipe)
|
||||
food = Food.objects.get(id=ingredients[2].food.id)
|
||||
food.food_onhand = True
|
||||
food.save()
|
||||
food = recipe.steps.filter(type=Step.RECIPE).first().step_recipe.steps.first().ingredients.first().food
|
||||
food = Food.objects.get(id=food.id)
|
||||
food.food_onhand = True
|
||||
food.save()
|
||||
|
||||
if use_mealplan:
|
||||
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
|
||||
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[0]
|
||||
else:
|
||||
user2.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
|
||||
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[1]
|
||||
|
||||
|
||||
def test_shopping_recipe_mixed_authors(u1_s1, u2_s1):
|
||||
with scopes_disabled():
|
||||
user1 = auth.get_user(u1_s1)
|
||||
user2 = auth.get_user(u2_s1)
|
||||
space = user1.userpreference.space
|
||||
user3 = UserFactory(space=space)
|
||||
recipe1 = RecipeFactory(created_by=user1, space=space)
|
||||
recipe2 = RecipeFactory(created_by=user2, space=space)
|
||||
recipe3 = RecipeFactory(created_by=user3, space=space)
|
||||
food = Food.objects.get(id=recipe1.steps.first().ingredients.first().food.id)
|
||||
food.recipe = recipe2
|
||||
food.save()
|
||||
recipe1.steps.add(StepFactory(step_recipe=recipe3, ingredients__count=0, space=space))
|
||||
recipe1.save()
|
||||
|
||||
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe1.id}))
|
||||
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 29
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
|
||||
|
||||
# TODO test adding recipe with ingredients that are not food
|
||||
@pytest.mark.parametrize("recipe", [{'steps__ingredients__header': 1}], indirect=['recipe'])
|
||||
def test_shopping_with_header_ingredient(u1_s1, recipe):
|
||||
# with scope(space=recipe.space):
|
||||
# recipe.step_set.first().ingredient_set.add(IngredientFactory(ingredients__header=1))
|
||||
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
|
||||
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 10
|
||||
assert len(json.loads(u1_s1.get(reverse('api:ingredient-list')).content)) == 11
|
||||
@@ -23,8 +23,8 @@ def test_list_permission(arg, request):
|
||||
|
||||
|
||||
def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 2
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0
|
||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 0
|
||||
|
||||
with scopes_disabled():
|
||||
recipe_1_s1.space = space_2
|
||||
@@ -32,9 +32,9 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
|
||||
Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
|
||||
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 len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 2
|
||||
|
||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 0
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
|
||||
@@ -49,7 +49,7 @@ def ing_3_s2(obj_3, space_2, u2_s2):
|
||||
|
||||
@pytest.fixture()
|
||||
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.entries.add(e)
|
||||
return e
|
||||
@@ -57,12 +57,12 @@ def sle_1_s1(obj_1, u1_s1, space_1):
|
||||
|
||||
@pytest.fixture()
|
||||
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()
|
||||
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.entries.add(e)
|
||||
return e
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from cookbook.models import UserPreference
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
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'
|
||||
DETAIL_URL = 'api:userpreference-detail'
|
||||
@@ -109,3 +109,32 @@ def test_preference_delete(u1_s1, u2_s1):
|
||||
)
|
||||
)
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
def test_default_inherit_fields(u1_s1, u1_s2, space_1, space_2):
|
||||
food_inherit_fields = Food.inheritable_fields
|
||||
assert len([x.field for x in food_inherit_fields]) > 0
|
||||
|
||||
# by default space food will not inherit any fields, so all of them will be ignored
|
||||
assert space_1.food_inherit.all().count() == 0
|
||||
r = u1_s1.get(
|
||||
reverse(DETAIL_URL, args={auth.get_user(u1_s1).id}),
|
||||
)
|
||||
assert len([x['field'] for x in json.loads(r.content)['food_inherit_default']]) == 0
|
||||
|
||||
# inherit all possible fields
|
||||
with scope(space=space_1):
|
||||
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True))
|
||||
|
||||
assert space_1.food_inherit.all().count() == Food.inheritable_fields.count() > 0
|
||||
# now by default, food is inheriting all of the possible fields
|
||||
r = u1_s1.get(
|
||||
reverse(DETAIL_URL, args={auth.get_user(u1_s1).id}),
|
||||
)
|
||||
assert len([x['field'] for x in json.loads(r.content)['food_inherit_default']]) == space_1.food_inherit.all().count()
|
||||
|
||||
# other spaces and users in those spaces not effected
|
||||
r = u1_s2.get(
|
||||
reverse(DETAIL_URL, args={auth.get_user(u1_s2).id}),
|
||||
)
|
||||
assert space_2.food_inherit.all().count() == 0 == len([x['field'] for x in json.loads(r.content)['food_inherit_default']])
|
||||
|
||||
@@ -5,14 +5,22 @@ import uuid
|
||||
|
||||
import pytest
|
||||
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 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
|
||||
# does not work on yield fixtures as only one yield can be used per fixture (i think)
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_fixture_setup(fixturedef, request):
|
||||
if inspect.isgeneratorfunction(fixturedef.func):
|
||||
@@ -27,23 +35,23 @@ def enable_db_access_for_all_tests(db):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def space_1():
|
||||
with scopes_disabled():
|
||||
return Space.objects.get_or_create(name='space_1')[0]
|
||||
# @pytest.fixture()
|
||||
# def space_1():
|
||||
# with scopes_disabled():
|
||||
# return Space.objects.get_or_create(name='space_1')[0]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def space_2():
|
||||
with scopes_disabled():
|
||||
return Space.objects.get_or_create(name='space_2')[0]
|
||||
# @pytest.fixture()
|
||||
# def space_2():
|
||||
# with scopes_disabled():
|
||||
# return Space.objects.get_or_create(name='space_2')[0]
|
||||
|
||||
|
||||
# ---------------------- OBJECT FIXTURES ---------------------
|
||||
|
||||
def get_random_recipe(space_1, u1_s1):
|
||||
r = Recipe.objects.create(
|
||||
name=uuid.uuid4(),
|
||||
name=str(uuid.uuid4()),
|
||||
waiting_time=20,
|
||||
working_time=20,
|
||||
servings=4,
|
||||
@@ -52,8 +60,8 @@ def get_random_recipe(space_1, u1_s1):
|
||||
internal=True,
|
||||
)
|
||||
|
||||
s1 = Step.objects.create(name=uuid.uuid4(), instruction=uuid.uuid4(), space=space_1, )
|
||||
s2 = 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=str(uuid.uuid4()), instruction=str(uuid.uuid4()), space=space_1, )
|
||||
|
||||
r.steps.add(s1)
|
||||
r.steps.add(s2)
|
||||
@@ -63,8 +71,8 @@ def get_random_recipe(space_1, u1_s1):
|
||||
Ingredient.objects.create(
|
||||
amount=1,
|
||||
food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0],
|
||||
unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ),
|
||||
note=uuid.uuid4(),
|
||||
unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ),
|
||||
note=str(uuid.uuid4()),
|
||||
space=space_1,
|
||||
)
|
||||
)
|
||||
@@ -73,8 +81,8 @@ def get_random_recipe(space_1, u1_s1):
|
||||
Ingredient.objects.create(
|
||||
amount=1,
|
||||
food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0],
|
||||
unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ),
|
||||
note=uuid.uuid4(),
|
||||
unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ),
|
||||
note=str(uuid.uuid4()),
|
||||
space=space_1,
|
||||
)
|
||||
)
|
||||
@@ -176,25 +184,17 @@ def create_user(client, space, **kwargs):
|
||||
c = copy.deepcopy(client)
|
||||
with scopes_disabled():
|
||||
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)
|
||||
return c
|
||||
|
||||
|
||||
# anonymous user
|
||||
@pytest.fixture()
|
||||
def a_u(client):
|
||||
return copy.deepcopy(client)
|
||||
|
||||
|
||||
# users without any group
|
||||
@pytest.fixture()
|
||||
def ng1_s1(client, space_1):
|
||||
return create_user(client, space_1)
|
||||
|
||||
372
cookbook/tests/factories/__init__.py
Normal file
372
cookbook/tests/factories/__init__.py
Normal file
@@ -0,0 +1,372 @@
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import factory
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django_scopes import scopes_disabled
|
||||
from faker import Factory as FakerFactory
|
||||
from pytest_factoryboy import register
|
||||
|
||||
from cookbook.models import Step
|
||||
|
||||
# this code will run immediately prior to creating the model object useful when you want a reverse relationship
|
||||
# log = factory.RelatedFactory(
|
||||
# UserLogFactory,
|
||||
# factory_related_name='user',
|
||||
# action=models.UserLog.ACTION_CREATE,
|
||||
# )
|
||||
faker = FakerFactory.create()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_db_access_for_all_tests(db):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_fixture_setup(fixturedef, request):
|
||||
if inspect.isgeneratorfunction(fixturedef.func):
|
||||
yield
|
||||
else:
|
||||
with scopes_disabled():
|
||||
yield
|
||||
|
||||
|
||||
@register
|
||||
class SpaceFactory(factory.django.DjangoModelFactory):
|
||||
"""Space factory."""
|
||||
name = factory.LazyAttribute(lambda x: faker.word())
|
||||
|
||||
@classmethod
|
||||
def _create(cls, model_class, **kwargs):
|
||||
with scopes_disabled():
|
||||
return super()._create(model_class, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.Space'
|
||||
|
||||
|
||||
@register
|
||||
class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
"""User factory."""
|
||||
username = factory.LazyAttribute(lambda x: faker.simple_profile()['username'])
|
||||
first_name = factory.LazyAttribute(lambda x: faker.first_name())
|
||||
last_name = factory.LazyAttribute(lambda x: faker.last_name())
|
||||
email = factory.LazyAttribute(lambda x: faker.email())
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
@factory.post_generation
|
||||
def groups(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
return
|
||||
|
||||
if extracted:
|
||||
self.groups.add(Group.objects.get(name=extracted))
|
||||
|
||||
@factory.post_generation
|
||||
def userpreference(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
return
|
||||
|
||||
if extracted:
|
||||
for prefs in extracted:
|
||||
self.userpreference[prefs] = extracted[prefs]/0 # intentionally break so it can be debugged later
|
||||
self.userpreference.space = self.space
|
||||
self.userpreference.save()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
|
||||
|
||||
@register
|
||||
class SupermarketCategoryFactory(factory.django.DjangoModelFactory):
|
||||
"""SupermarketCategory factory."""
|
||||
name = factory.LazyAttribute(lambda x: faker.word())
|
||||
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.SupermarketCategory'
|
||||
django_get_or_create = ('name', 'space',)
|
||||
|
||||
|
||||
# @factory.django.mute_signals(post_save)
|
||||
@register
|
||||
class FoodFactory(factory.django.DjangoModelFactory):
|
||||
"""Food factory."""
|
||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3, variable_nb_words=False))
|
||||
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||
supermarket_category = factory.Maybe(
|
||||
factory.LazyAttribute(lambda x: x.has_category),
|
||||
yes_declaration=factory.SubFactory(SupermarketCategoryFactory, space=factory.SelfAttribute('..space')),
|
||||
no_declaration=None
|
||||
)
|
||||
recipe = factory.Maybe(
|
||||
factory.LazyAttribute(lambda x: x.has_recipe),
|
||||
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
|
||||
no_declaration=None
|
||||
)
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
class Params:
|
||||
has_category = False
|
||||
has_recipe = False
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.Food'
|
||||
django_get_or_create = ('name', 'space',)
|
||||
|
||||
|
||||
@register
|
||||
class UnitFactory(factory.django.DjangoModelFactory):
|
||||
"""Unit factory."""
|
||||
name = factory.LazyAttribute(lambda x: faker.word())
|
||||
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.Unit'
|
||||
django_get_or_create = ('name', 'space',)
|
||||
|
||||
|
||||
@register
|
||||
class KeywordFactory(factory.django.DjangoModelFactory):
|
||||
"""Keyword factory."""
|
||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=2, variable_nb_words=False))
|
||||
# icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
num = None # used on upstream factories to generate num keywords
|
||||
|
||||
class Params:
|
||||
num = None
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.Keyword'
|
||||
django_get_or_create = ('name', 'space',)
|
||||
exclude = ('num')
|
||||
|
||||
|
||||
@register
|
||||
class IngredientFactory(factory.django.DjangoModelFactory):
|
||||
"""Ingredient factory."""
|
||||
food = factory.SubFactory(FoodFactory, space=factory.SelfAttribute('..space'))
|
||||
unit = factory.SubFactory(UnitFactory, space=factory.SelfAttribute('..space'))
|
||||
amount = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10))
|
||||
note = factory.LazyAttribute(lambda x: faker.sentence(nb_words=8))
|
||||
is_header = False
|
||||
no_amount = False
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.Ingredient'
|
||||
|
||||
|
||||
@register
|
||||
class MealTypeFactory(factory.django.DjangoModelFactory):
|
||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
|
||||
order = 0
|
||||
# icon =
|
||||
color = factory.LazyAttribute(lambda x: faker.safe_hex_color())
|
||||
default = False
|
||||
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.MealType'
|
||||
|
||||
|
||||
@register
|
||||
class MealPlanFactory(factory.django.DjangoModelFactory):
|
||||
recipe = factory.Maybe(
|
||||
factory.LazyAttribute(lambda x: x.has_recipe),
|
||||
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
|
||||
no_declaration=None
|
||||
)
|
||||
servings = factory.LazyAttribute(lambda x: Decimal(faker.random_int(min=1, max=1000)/100))
|
||||
title = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
|
||||
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||
meal_type = factory.SubFactory(MealTypeFactory, space=factory.SelfAttribute('..space'))
|
||||
note = factory.LazyAttribute(lambda x: faker.paragraph())
|
||||
date = factory.LazyAttribute(lambda x: faker.future_date())
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
class Params:
|
||||
has_recipe = True
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.MealPlan'
|
||||
|
||||
|
||||
@register
|
||||
class ShoppingListRecipeFactory(factory.django.DjangoModelFactory):
|
||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
|
||||
recipe = factory.Maybe(
|
||||
factory.LazyAttribute(lambda x: x.has_recipe),
|
||||
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
|
||||
no_declaration=None
|
||||
)
|
||||
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10))
|
||||
mealplan = factory.SubFactory(MealPlanFactory, space=factory.SelfAttribute('..space'))
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
class Params:
|
||||
has_recipe = False
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.ShoppingListRecipe'
|
||||
|
||||
|
||||
@register
|
||||
class ShoppingListEntryFactory(factory.django.DjangoModelFactory):
|
||||
"""ShoppingListEntry factory."""
|
||||
|
||||
list_recipe = factory.Maybe(
|
||||
factory.LazyAttribute(lambda x: x.has_mealplan),
|
||||
yes_declaration=factory.SubFactory(ShoppingListRecipeFactory, space=factory.SelfAttribute('..space')),
|
||||
no_declaration=None
|
||||
)
|
||||
food = factory.SubFactory(FoodFactory, space=factory.SelfAttribute('..space'))
|
||||
unit = factory.SubFactory(UnitFactory, space=factory.SelfAttribute('..space'))
|
||||
# # ingredient = factory.SubFactory(IngredientFactory)
|
||||
amount = factory.LazyAttribute(lambda x: Decimal(faker.random_int(min=1, max=100))/10)
|
||||
order = factory.Sequence(int)
|
||||
checked = False
|
||||
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||
created_at = factory.LazyAttribute(lambda x: faker.past_date())
|
||||
completed_at = None
|
||||
delay_until = None
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
class Params:
|
||||
has_mealplan = False
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.ShoppingListEntry'
|
||||
|
||||
|
||||
@register
|
||||
class StepFactory(factory.django.DjangoModelFactory):
|
||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
|
||||
instruction = factory.LazyAttribute(lambda x: ''.join(faker.paragraphs(nb=5)))
|
||||
# TODO add optional recipe food, make dependent on recipe, make number of recipes a Params
|
||||
ingredients__count = 10 # default number of ingredients to add
|
||||
ingredients__header = 0
|
||||
time = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=1000))
|
||||
order = factory.Sequence(lambda x: x)
|
||||
# file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True)
|
||||
show_as_header = True
|
||||
step_recipe__has_recipe = False
|
||||
ingredients__food_recipe_count = 0
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
@factory.post_generation
|
||||
def step_recipe(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
return
|
||||
if kwargs.get('has_recipe', False):
|
||||
self.step_recipe = RecipeFactory(space=self.space)
|
||||
self.type = Step.RECIPE
|
||||
elif extracted:
|
||||
self.step_recipe = extracted
|
||||
self.type = Step.RECIPE
|
||||
|
||||
@factory.post_generation
|
||||
def ingredients(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
return
|
||||
|
||||
num_ing = kwargs.get('count', 0)
|
||||
num_food_recipe = kwargs.get('food_recipe_count', 0)
|
||||
if num_ing > 0:
|
||||
for i in range(num_ing):
|
||||
if num_food_recipe > 0:
|
||||
has_recipe = True
|
||||
num_food_recipe = num_food_recipe-1
|
||||
else:
|
||||
has_recipe = False
|
||||
self.ingredients.add(IngredientFactory(space=self.space, food__has_recipe=has_recipe))
|
||||
num_header = kwargs.get('header', 0)
|
||||
#######################################################
|
||||
#######################################################
|
||||
if num_header > 0:
|
||||
for i in range(num_header):
|
||||
self.ingredients.add(IngredientFactory(food=None, unit=None, amount=0, is_header=True, space=self.space))
|
||||
elif extracted:
|
||||
for ing in extracted:
|
||||
self.ingredients.add(ing)
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.Step'
|
||||
|
||||
|
||||
@register
|
||||
class RecipeFactory(factory.django.DjangoModelFactory):
|
||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=7))
|
||||
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=20))
|
||||
servings_text = factory.LazyAttribute(lambda x: faker.sentence(nb_words=1)) # TODO generate list of expected servings text that can be iterated through
|
||||
keywords__count = 5 # default number of keywords to generate
|
||||
steps__count = 1 # default number of steps to create
|
||||
steps__recipe_count = 0 # default number of step recipes to create
|
||||
steps__food_recipe_count = {} # by default, don't create food recipes, to override {'steps__food_recipe_count': {'step': 0, 'count': 1}}
|
||||
working_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360))
|
||||
waiting_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360))
|
||||
internal = False
|
||||
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||
created_at = factory.LazyAttribute(lambda x: faker.date_this_decade())
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
@factory.post_generation
|
||||
def keywords(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
# Simple build, do nothing.
|
||||
return
|
||||
|
||||
num_kw = kwargs.get('count', 0)
|
||||
if num_kw > 0:
|
||||
for i in range(num_kw):
|
||||
self.keywords.add(KeywordFactory(space=self.space))
|
||||
elif extracted:
|
||||
for kw in extracted:
|
||||
self.keywords.add(kw)
|
||||
|
||||
@factory.post_generation
|
||||
def steps(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
return
|
||||
|
||||
food_recipe_count = kwargs.get('food_recipe_count', {})
|
||||
num_steps = kwargs.get('count', 0)
|
||||
num_recipe_steps = kwargs.get('recipe_count', 0)
|
||||
num_ing_headers = kwargs.get('ingredients__header', 0)
|
||||
if num_steps > 0:
|
||||
for i in range(num_steps):
|
||||
ing_recipe_count = 0
|
||||
if food_recipe_count.get('step', None) == i:
|
||||
ing_recipe_count = food_recipe_count.get('count', 0)
|
||||
self.steps.add(StepFactory(space=self.space, ingredients__food_recipe_count=ing_recipe_count, ingredients__header=num_ing_headers))
|
||||
num_ing_headers+-1
|
||||
if num_recipe_steps > 0:
|
||||
for j in range(num_recipe_steps):
|
||||
self.steps.add(StepFactory(space=self.space, step_recipe__has_recipe=True, ingredients__count=0))
|
||||
if extracted and (num_steps + num_recipe_steps == 0):
|
||||
for step in extracted:
|
||||
self.steps.add(step)
|
||||
|
||||
# image = models.ImageField(upload_to='recipes/', blank=True, null=True) #TODO test recipe image api https://factoryboy.readthedocs.io/en/stable/orms.html#factory.django.ImageField
|
||||
# storage = models.ForeignKey(
|
||||
# Storage, on_delete=models.PROTECT, blank=True, null=True
|
||||
# )
|
||||
# file_uid = models.CharField(max_length=256, default="", blank=True)
|
||||
# file_path = models.CharField(max_length=512, default="", blank=True)
|
||||
# link = models.CharField(max_length=512, null=True, blank=True)
|
||||
# cors_link = models.CharField(max_length=1024, null=True, blank=True)
|
||||
# nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
|
||||
# updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.Recipe'
|
||||
@@ -15,33 +15,35 @@ from .models import (Automation, Comment, Food, InviteLink, Keyword, MealPlan, R
|
||||
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register(r'user-name', api.UserNameViewSet, basename='username')
|
||||
router.register(r'user-preference', api.UserPreferenceViewSet)
|
||||
router.register(r'storage', api.StorageViewSet)
|
||||
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'automation', api.AutomationViewSet)
|
||||
router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
|
||||
router.register(r'cook-log', api.CookLogViewSet)
|
||||
router.register(r'food', api.FoodViewSet)
|
||||
router.register(r'step', api.StepViewSet)
|
||||
router.register(r'recipe', api.RecipeViewSet)
|
||||
router.register(r'food-inherit-field', api.FoodInheritFieldViewSet)
|
||||
router.register(r'import-log', api.ImportLogViewSet)
|
||||
router.register(r'ingredient', api.IngredientViewSet)
|
||||
router.register(r'keyword', api.KeywordViewSet)
|
||||
router.register(r'meal-plan', api.MealPlanViewSet)
|
||||
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-entry', api.ShoppingListEntryViewSet)
|
||||
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
|
||||
router.register(r'view-log', api.ViewLogViewSet)
|
||||
router.register(r'cook-log', api.CookLogViewSet)
|
||||
router.register(r'recipe-book', api.RecipeBookViewSet)
|
||||
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
|
||||
router.register(r'step', api.StepViewSet)
|
||||
router.register(r'storage', api.StorageViewSet)
|
||||
router.register(r'supermarket', api.SupermarketViewSet)
|
||||
router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
|
||||
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
|
||||
router.register(r'import-log', api.ImportLogViewSet)
|
||||
router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
|
||||
router.register(r'sync', api.SyncViewSet)
|
||||
router.register(r'sync-log', api.SyncLogViewSet)
|
||||
router.register(r'unit', api.UnitViewSet)
|
||||
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 = [
|
||||
path('', views.index, name='index'),
|
||||
|
||||
@@ -38,21 +38,25 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
|
||||
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
||||
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
|
||||
from cookbook.helper.recipe_url_import import get_from_scraper
|
||||
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, ImportLog, Ingredient,
|
||||
Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry,
|
||||
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Step,
|
||||
Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation,
|
||||
Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog)
|
||||
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper
|
||||
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
|
||||
ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry,
|
||||
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
|
||||
UserPreference, ViewLog)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
|
||||
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
|
||||
CookLogSerializer, FoodSerializer, ImportLogSerializer,
|
||||
CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer,
|
||||
FoodShoppingUpdateSerializer, ImportLogSerializer,
|
||||
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
|
||||
MealTypeSerializer, RecipeBookEntrySerializer,
|
||||
RecipeBookSerializer, RecipeImageSerializer,
|
||||
RecipeOverviewSerializer, RecipeSerializer,
|
||||
RecipeShoppingUpdateSerializer, RecipeSimpleSerializer,
|
||||
ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
|
||||
ShoppingListRecipeSerializer, ShoppingListSerializer,
|
||||
StepSerializer, StorageSerializer,
|
||||
@@ -221,6 +225,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
||||
root = int(root)
|
||||
except ValueError:
|
||||
self.queryset = self.model.objects.none()
|
||||
|
||||
if root == 0:
|
||||
self.queryset = self.model.get_root_nodes()
|
||||
else:
|
||||
@@ -390,6 +395,17 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin):
|
||||
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):
|
||||
queryset = Food.objects
|
||||
model = Food
|
||||
@@ -397,6 +413,26 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
permission_classes = [CustomIsUser]
|
||||
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):
|
||||
try:
|
||||
return (super().destroy(self, *args, **kwargs))
|
||||
@@ -424,7 +460,7 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet):
|
||||
- **recipe**: id of recipe - only return books for that recipe
|
||||
- **book**: id of book - only return recipes in that book
|
||||
|
||||
"""
|
||||
"""
|
||||
queryset = RecipeBookEntry.objects
|
||||
serializer_class = RecipeBookEntrySerializer
|
||||
permission_classes = [CustomIsOwner]
|
||||
@@ -504,8 +540,7 @@ class StepViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [CustomIsUser]
|
||||
pagination_class = DefaultPagination
|
||||
query_params = [
|
||||
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'),
|
||||
qtype='int'),
|
||||
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
|
||||
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
@@ -547,31 +582,27 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
pagination_class = RecipePagination
|
||||
# TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly
|
||||
query_params = [
|
||||
QueryParam(name='query', description=_(
|
||||
'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='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'),
|
||||
qtype='int'),
|
||||
QueryParam(name='query', description=_('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='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='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='keywords_or', description=_(
|
||||
'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='books_or', description=_(
|
||||
'If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
|
||||
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>'']')),
|
||||
QueryParam(name='keywords_or', description=_('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='books_or', description=_('If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
|
||||
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()
|
||||
|
||||
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)
|
||||
if not (share and self.detail):
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
@@ -625,6 +656,43 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
return Response(serializer.data)
|
||||
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):
|
||||
queryset = ShoppingListRecipe.objects
|
||||
@@ -632,9 +700,13 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
|
||||
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(
|
||||
Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter(
|
||||
shoppinglist__space=self.request.space).distinct().all()
|
||||
Q(shoppinglist__created_by=self.request.user)
|
||||
| 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):
|
||||
@@ -642,34 +714,46 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ShoppingListEntrySerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
query_params = [
|
||||
QueryParam(name='id',
|
||||
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'),
|
||||
qtype='int'),
|
||||
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
|
||||
QueryParam(
|
||||
name='checked',
|
||||
description=_(
|
||||
'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
|
||||
description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
|
||||
),
|
||||
QueryParam(name='supermarket',
|
||||
description=_('Returns the shopping list entries sorted by supermarket category order.'),
|
||||
qtype='int'),
|
||||
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(
|
||||
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(space=self.request.space)
|
||||
|
||||
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):
|
||||
queryset = ShoppingList.objects
|
||||
serializer_class = ShoppingListSerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
|
||||
space=self.request.space).distinct()
|
||||
return self.queryset.filter(
|
||||
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):
|
||||
try:
|
||||
|
||||
@@ -22,8 +22,8 @@ from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.permission_helper import group_required, has_group_permission
|
||||
from cookbook.helper.recipe_url_import import parse_cooktime
|
||||
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe,
|
||||
RecipeImport, Step, Sync, Unit, UserPreference)
|
||||
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, RecipeImport, Step, Sync,
|
||||
Unit, UserPreference)
|
||||
from cookbook.tables import SyncTable
|
||||
from recipes import settings
|
||||
|
||||
@@ -111,8 +111,8 @@ def batch_edit(request):
|
||||
'Batch edit done. %(count)d recipe was updated.',
|
||||
'Batch edit done. %(count)d Recipes where updated.',
|
||||
count) % {
|
||||
'count': count,
|
||||
}
|
||||
'count': count,
|
||||
}
|
||||
messages.add_message(request, messages.SUCCESS, msg)
|
||||
|
||||
return redirect('data_batch_edit')
|
||||
|
||||
@@ -7,10 +7,9 @@ from django_tables2 import RequestConfig
|
||||
|
||||
from cookbook.filters import ShoppingListFilter
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.models import (InviteLink, RecipeImport,
|
||||
ShoppingList, Storage, SyncLog, UserFile)
|
||||
from cookbook.tables import (ImportLogTable, InviteLinkTable,
|
||||
RecipeImportTable, ShoppingListTable, StorageTable)
|
||||
from cookbook.models import InviteLink, RecipeImport, ShoppingList, Storage, SyncLog, UserFile
|
||||
from cookbook.tables import (ImportLogTable, InviteLinkTable, RecipeImportTable, ShoppingListTable,
|
||||
StorageTable)
|
||||
|
||||
|
||||
@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')
|
||||
def shopping_list(request):
|
||||
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(space=request.space).filter(
|
||||
@@ -204,7 +189,7 @@ def automation(request):
|
||||
def user_file(request):
|
||||
try:
|
||||
current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[
|
||||
'file_size_kb__sum'] / 1000
|
||||
'file_size_kb__sum'] / 1000
|
||||
except TypeError:
|
||||
current_file_size_mb = 0
|
||||
|
||||
@@ -240,15 +225,11 @@ def step(request):
|
||||
|
||||
@group_required('user')
|
||||
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(
|
||||
request,
|
||||
'generic/checklist_template.html',
|
||||
'shoppinglist_template.html',
|
||||
{
|
||||
"title": _("New Shopping List"),
|
||||
"config": {
|
||||
'model': "SHOPPING_LIST", # *REQUIRED* name of the model in models.js
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
@@ -22,13 +22,13 @@ from django_tables2 import RequestConfig
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from cookbook.filters import RecipeFilter
|
||||
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm,
|
||||
SpaceJoinForm, User, UserCreateForm, UserNameForm, UserPreference,
|
||||
UserPreferenceForm)
|
||||
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm,
|
||||
SpaceCreateForm, SpaceJoinForm, SpacePreferenceForm, User,
|
||||
UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm)
|
||||
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,
|
||||
SearchFields, SearchPreference, ShareLink, ShoppingList, Space, Unit,
|
||||
UserFile, ViewLog)
|
||||
from cookbook.models import (Comment, CookLog, Food, FoodInheritField, InviteLink, Keyword,
|
||||
MealPlan, RecipeImport, SearchFields, SearchPreference, ShareLink,
|
||||
ShoppingList, Space, Unit, UserFile, ViewLog)
|
||||
from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall,
|
||||
ViewLogTable)
|
||||
from cookbook.views.data import Object
|
||||
@@ -254,13 +254,13 @@ def latest_shopping_list(request):
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def shopping_list(request, pk=None):
|
||||
def shopping_list(request, pk=None): # TODO deprecate
|
||||
html_list = request.GET.getlist('r')
|
||||
|
||||
recipes = []
|
||||
for r in html_list:
|
||||
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(',')
|
||||
if recipe := Recipe.objects.filter(pk=int(rid), space=request.space).first():
|
||||
recipes.append({'recipe': recipe.id, 'multiplier': multiplier})
|
||||
@@ -304,10 +304,6 @@ def user_settings(request):
|
||||
up.use_kj = form.cleaned_data['use_kj']
|
||||
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()
|
||||
|
||||
elif 'user_name_form' in request.POST:
|
||||
@@ -378,10 +374,31 @@ def user_settings(request):
|
||||
sp.trigram_threshold = 0.1
|
||||
|
||||
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:
|
||||
preference_form = UserPreferenceForm(instance=up, space=request.space)
|
||||
preference_form = UserPreferenceForm(instance=up)
|
||||
shopping_form = ShoppingPreferenceForm(instance=up)
|
||||
else:
|
||||
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(
|
||||
sp.fulltext.all())
|
||||
@@ -406,6 +423,7 @@ def user_settings(request):
|
||||
'user_name_form': user_name_form,
|
||||
'api_token': api_token,
|
||||
'search_form': search_form,
|
||||
'shopping_form': shopping_form,
|
||||
'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())
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
!!! success "Recommended Installation"
|
||||
Setting up this application using Docker is recommended. This does not mean that other options are bad, just that
|
||||
support is much easier for this setup.
|
||||
Setting up this application using Docker is recommended. This does not mean that other options are bad, just that
|
||||
support is much easier for this setup.
|
||||
|
||||
It is possible to install this application using many Docker configurations.
|
||||
|
||||
@@ -34,17 +34,17 @@ file in the GitHub repository to verify if additional environment variables are
|
||||
|
||||
### Versions
|
||||
|
||||
There are different versions (tags) released on docker hub.
|
||||
There are different versions (tags) released on docker hub.
|
||||
|
||||
- **latest** Default image. The one you should use if you don't know that you need anything else.
|
||||
- **beta** Partially stable version that gets updated every now and then. Expect to have some problems.
|
||||
- **develop** If you want the most bleeding edge version with potentially many breaking changes feel free to use this version (I don't recommend it!).
|
||||
- **X.Y.Z** each released version has its own image. If you need to revert to an old version or want to make sure you stay on one specific use these tags.
|
||||
- **latest** Default image. The one you should use if you don't know that you need anything else.
|
||||
- **beta** Partially stable version that gets updated every now and then. Expect to have some problems.
|
||||
- **develop** If you want the most bleeding edge version with potentially many breaking changes feel free to use this version (I don't recommend it!).
|
||||
- **X.Y.Z** each released version has its own image. If you need to revert to an old version or want to make sure you stay on one specific use these tags.
|
||||
|
||||
!!! danger "No Downgrading"
|
||||
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
|
||||
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
|
||||
That said **beta** should usually be working if you like frequent updates and new stuff.
|
||||
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
|
||||
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
|
||||
That said **beta** should usually be working if you like frequent updates and new stuff.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
@@ -65,29 +65,30 @@ 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
|
||||
```
|
||||
|
||||
~~~yaml
|
||||
{% include "./docker/plain/docker-compose.yml" %}
|
||||
~~~
|
||||
```yaml
|
||||
{ % include "./docker/plain/docker-compose.yml" % }
|
||||
```
|
||||
|
||||
### Reverse Proxy
|
||||
|
||||
Most deployments will likely use a reverse proxy.
|
||||
|
||||
#### Traefik
|
||||
|
||||
If you use traefik, this configuration is the one for you.
|
||||
|
||||
!!! info
|
||||
Traefik can be a little confusing to setup.
|
||||
Please refer to [their excellent documentation](https://doc.traefik.io/traefik/). If that does not help,
|
||||
[this little example](traefik.md) might be for you.
|
||||
Traefik can be a little confusing to setup.
|
||||
Please refer to [their excellent documentation](https://doc.traefik.io/traefik/). If that does not help,
|
||||
[this little example](traefik.md) might be for you.
|
||||
|
||||
```shell
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/traefik-nginx/docker-compose.yml
|
||||
```
|
||||
|
||||
~~~yaml
|
||||
{% include "./docker/traefik-nginx/docker-compose.yml" %}
|
||||
~~~
|
||||
```yaml
|
||||
{ % include "./docker/traefik-nginx/docker-compose.yml" % }
|
||||
```
|
||||
|
||||
#### 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.
|
||||
|
||||
Remember to add the appropriate environment variables to `.env` file:
|
||||
|
||||
```
|
||||
VIRTUAL_HOST=
|
||||
LETSENCRYPT_HOST=
|
||||
@@ -107,27 +109,49 @@ LETSENCRYPT_EMAIL=
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/nginx-proxy/docker-compose.yml
|
||||
```
|
||||
|
||||
~~~yaml
|
||||
{% include "./docker/nginx-proxy/docker-compose.yml" %}
|
||||
~~~
|
||||
```yaml
|
||||
{ % include "./docker/nginx-proxy/docker-compose.yml" % }
|
||||
```
|
||||
|
||||
#### Nginx Swag by LinuxServer
|
||||
|
||||
[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.
|
||||
|
||||
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:
|
||||
If your running Swag on a custom port, some headers must be changed:
|
||||
|
||||
- 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 correctly
|
||||
- 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 correctly
|
||||
|
||||
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.
|
||||
|
||||
@@ -136,6 +160,7 @@ Please refer to the [appropriate documentation](https://github.com/linuxserver/d
|
||||
## Additional Information
|
||||
|
||||
### Nginx vs Gunicorn
|
||||
|
||||
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**.
|
||||
|
||||
@@ -144,14 +169,14 @@ the WSGi server that handles the Python execution, explicitly state that it is n
|
||||
You will also likely not see any decrease in performance or a lot of space used as nginx is a very light container.
|
||||
|
||||
!!! info
|
||||
Even if you run behind a reverse proxy as described above, using an additional nginx container is the recommended option.
|
||||
Even if you run behind a reverse proxy as described above, using an additional nginx container is the recommended option.
|
||||
|
||||
If you run a small private deployment and don't care about performance, security and whatever else feel free to run
|
||||
without a ngix container.
|
||||
|
||||
!!! warning
|
||||
When running without nginx make sure to enable `GUNICORN_MEDIA` in the `.env`. Without it, media files will be uploaded
|
||||
but not shown on the page.
|
||||
When running without nginx make sure to enable `GUNICORN_MEDIA` in the `.env`. Without it, media files will be uploaded
|
||||
but not shown on the page.
|
||||
|
||||
For additional information please refer to the [0.9.0 Release](https://github.com/vabene1111/recipes/releases?after=0.9.0)
|
||||
and [Issue 201](https://github.com/vabene1111/recipes/issues/201) where these topics have been discussed.
|
||||
|
||||
@@ -61,9 +61,9 @@ def SqlPrintingMiddleware(get_response):
|
||||
sql = "\033[1;31m[%s]\033[0m %s" % (query['time'], nice_sql)
|
||||
total_time = total_time + float(query['time'])
|
||||
while len(sql) > width - indentation:
|
||||
#print("%s%s" % (" " * indentation, sql[:width - indentation]))
|
||||
# print("%s%s" % (" " * indentation, sql[:width - indentation]))
|
||||
sql = sql[width - indentation:]
|
||||
#print("%s%s\n" % (" " * indentation, sql))
|
||||
# print("%s%s\n" % (" " * indentation, sql))
|
||||
replace_tuple = (" " * indentation, str(total_time))
|
||||
print("%s\033[1;32m[TOTAL TIME: %s seconds]\033[0m" % replace_tuple)
|
||||
print("%s\033[1;32m[TOTAL QUERIES: %s]\033[0m" % (" " * indentation, len(connection.queries)))
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
Django==3.2.10
|
||||
cryptography==36.0.0
|
||||
Django==3.2.11
|
||||
cryptography==36.0.1
|
||||
django-annoying==0.10.6
|
||||
django-autocomplete-light==3.8.2
|
||||
django-cleanup==5.2.0
|
||||
django-crispy-forms==1.13.0
|
||||
django-filter==21.1
|
||||
django-tables2==2.4.1
|
||||
djangorestframework==3.12.4
|
||||
djangorestframework==3.13.1
|
||||
drf-writable-nested==0.6.3
|
||||
bleach==4.1.0
|
||||
bleach-allowlist==1.0.3
|
||||
gunicorn==20.1.0
|
||||
lxml==4.6.5
|
||||
lxml==4.7.1
|
||||
Markdown==3.3.6
|
||||
Pillow==8.4.0
|
||||
psycopg2-binary==2.9.2
|
||||
Pillow==9.0.0
|
||||
psycopg2-binary==2.9.3
|
||||
python-dotenv==0.19.2
|
||||
requests==2.26.0
|
||||
requests==2.27.0
|
||||
simplejson==3.17.6
|
||||
six==1.16.0
|
||||
webdavclient3==3.14.6
|
||||
@@ -29,17 +29,18 @@ microdata==0.7.2
|
||||
Jinja2==3.0.3
|
||||
django-webpack-loader==1.4.1
|
||||
django-js-reverse==0.9.1
|
||||
django-allauth==0.46.0
|
||||
recipe-scrapers==13.7.0
|
||||
django-allauth==0.47.0
|
||||
recipe-scrapers==13.10.1
|
||||
django-scopes==1.2.0
|
||||
pytest==6.2.5
|
||||
pytest-django==4.5.1
|
||||
pytest-django==4.5.2
|
||||
django-treebeard==4.5.1
|
||||
django-cors-headers==3.10.0
|
||||
django-cors-headers==3.10.1
|
||||
django-storages==1.12.3
|
||||
boto3==1.20.19
|
||||
django-prometheus==2.1.0
|
||||
boto3==1.20.27
|
||||
django-prometheus==2.2.0
|
||||
django-hCaptcha==0.1.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
|
||||
|
||||
23
vue/.gitignore
vendored
23
vue/.gitignore
vendored
@@ -1,23 +0,0 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
8
vue/.openapi-generator/FILES
Normal file
8
vue/.openapi-generator/FILES
Normal file
@@ -0,0 +1,8 @@
|
||||
.gitignore
|
||||
.npmignore
|
||||
api.ts
|
||||
base.ts
|
||||
common.ts
|
||||
configuration.ts
|
||||
git_push.sh
|
||||
index.ts
|
||||
1
vue/.openapi-generator/VERSION
Normal file
1
vue/.openapi-generator/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
5.2.1
|
||||
@@ -15,7 +15,8 @@
|
||||
"@riophae/vue-treeselect": "^0.4.0",
|
||||
"axios": "^0.24.0",
|
||||
"bootstrap-vue": "^2.21.2",
|
||||
"core-js": "^3.19.0",
|
||||
"core-js": "^3.20.2",
|
||||
"html2pdf.js": "^0.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
"prismjs": "^1.25.0",
|
||||
@@ -24,7 +25,7 @@
|
||||
"vue-click-outside": "^1.1.0",
|
||||
"vue-clickaway": "^2.2.2",
|
||||
"vue-cookies": "^1.7.4",
|
||||
"vue-i18n": "^8.26.5",
|
||||
"vue-i18n": "^8.26.8",
|
||||
"vue-infinite-loading": "^2.4.5",
|
||||
"vue-multiselect": "^2.1.6",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
@@ -45,7 +46,7 @@
|
||||
"@vue/cli-plugin-typescript": "^4.5.15",
|
||||
"@vue/cli-service": "~4.5.13",
|
||||
"@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",
|
||||
"eslint": "^7.28.0",
|
||||
"eslint-plugin-vue": "^8.0.3",
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div id="app" style="margin-bottom: 4vh" v-if="this_model">
|
||||
<generic-modal-form v-if="this_model"
|
||||
:model="this_model"
|
||||
:action="this_action"
|
||||
:item1="this_item"
|
||||
:item2="this_target"
|
||||
:show="show_modal"
|
||||
@finish-action="finishAction"/>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-2 d-none d-md-block">
|
||||
</div>
|
||||
<div class="col-xl-8 col-12">
|
||||
<div class="container-fluid d-flex flex-column flex-grow-1">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6" style="margin-top: 1vh">
|
||||
<h3>
|
||||
<!-- <model-menu/> Replace with a List Menu or a Checklist Menu? -->
|
||||
<span>{{ this.this_model.name }}</span>
|
||||
<span><b-button variant="link" @click="startAction({'action':'new'})"><i
|
||||
class="fas fa-plus-circle fa-2x"></i></b-button></span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
this is where shopping list items go
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
|
||||
import {ApiMixin} from "@/utils/utils";
|
||||
import {StandardToasts, ToastMixin} from "@/utils/utils";
|
||||
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm";
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
|
||||
// or i'm capturing it incorrectly
|
||||
name: 'ModelListView',
|
||||
mixins: [ApiMixin, ToastMixin],
|
||||
components: {GenericModalForm},
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
items: [],
|
||||
this_model: undefined,
|
||||
model_menu: undefined,
|
||||
this_action: undefined,
|
||||
this_item: {},
|
||||
show_modal: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// value is passed from lists.py
|
||||
let model_config = JSON.parse(document.getElementById('model_config').textContent)
|
||||
this.this_model = this.Models[model_config?.model]
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
startAction: function (e, param) {
|
||||
let source = e?.source ?? {}
|
||||
this.this_item = source
|
||||
// remove recipe from shopping list
|
||||
// mark on-hand
|
||||
// mark puchased
|
||||
// edit shopping category on food
|
||||
// delete food from shopping list
|
||||
// add food to shopping list
|
||||
// add other to shopping list
|
||||
// edit unit conversion
|
||||
// edit purchaseable unit
|
||||
switch (e.action) {
|
||||
case 'delete':
|
||||
this.this_action = this.Actions.DELETE
|
||||
this.show_modal = true
|
||||
break;
|
||||
case 'new':
|
||||
this.this_action = this.Actions.CREATE
|
||||
this.show_modal = true
|
||||
break;
|
||||
case 'edit':
|
||||
this.this_item = e.source
|
||||
this.this_action = this.Actions.UPDATE
|
||||
this.show_modal = true
|
||||
break;
|
||||
}
|
||||
},
|
||||
finishAction: function (e) {
|
||||
let update = undefined
|
||||
switch (e?.action) {
|
||||
case 'save':
|
||||
this.saveThis(e.form_data)
|
||||
break;
|
||||
}
|
||||
if (e !== 'cancel') {
|
||||
switch (this.this_action) {
|
||||
case this.Actions.DELETE:
|
||||
this.deleteThis(this.this_item.id)
|
||||
break;
|
||||
case this.Actions.CREATE:
|
||||
this.saveThis(e.form_data)
|
||||
break;
|
||||
case this.Actions.UPDATE:
|
||||
update = e.form_data
|
||||
update.id = this.this_item.id
|
||||
this.saveThis(update)
|
||||
break;
|
||||
case this.Actions.MERGE:
|
||||
this.mergeThis(this.this_item, e.form_data.target, false)
|
||||
break;
|
||||
case this.Actions.MOVE:
|
||||
this.moveThis(this.this_item.id, e.form_data.target.id)
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.clearState()
|
||||
},
|
||||
getItems: function (params) {
|
||||
this.genericAPI(this.this_model, this.Actions.LIST, params).then((results) => {
|
||||
if (results?.length) {
|
||||
this.items = results.data
|
||||
} else {
|
||||
console.log('no data returned')
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
})
|
||||
},
|
||||
getThis: function (id) {
|
||||
return this.genericAPI(this.this_model, this.Actions.FETCH, {'id': id})
|
||||
},
|
||||
saveThis: function (thisItem) {
|
||||
if (!thisItem?.id) { // if there is no item id assume it's a new item
|
||||
this.genericAPI(this.this_model, this.Actions.CREATE, thisItem).then((result) => {
|
||||
// this.items = result.data - refresh the list here
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
} else {
|
||||
this.genericAPI(this.this_model, this.Actions.UPDATE, thisItem).then((result) => {
|
||||
// this.refreshThis(thisItem.id) refresh the list here
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch((err) => {
|
||||
console.log(err, err.response)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
getRecipe: function (item) {
|
||||
// change to get pop up card? maybe same for unit and food?
|
||||
},
|
||||
deleteThis: function (id) {
|
||||
this.genericAPI(this.this_model, this.Actions.DELETE, {'id': id}).then((result) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
})
|
||||
},
|
||||
clearState: function () {
|
||||
this.show_modal = false
|
||||
this.this_action = undefined
|
||||
this.this_item = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
@@ -1,18 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import App from './ChecklistView'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
|
||||
let publicPath = localStorage.STATIC_URL + 'vue/'
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
publicPath = 'http://localhost:8080/'
|
||||
}
|
||||
export default __webpack_public_path__ = publicPath // eslint-disable-line
|
||||
|
||||
|
||||
new Vue({
|
||||
i18n,
|
||||
render: h => h(App),
|
||||
}).$mount('#app')
|
||||
@@ -1,158 +1,163 @@
|
||||
<template>
|
||||
<div id="app" class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1 offset">
|
||||
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 mt-3 mb-3">
|
||||
<b-input-group>
|
||||
<b-input class="form-control form-control-lg form-control-borderless form-control-search"
|
||||
v-model="search"
|
||||
v-bind:placeholder="$t('Search')"></b-input>
|
||||
<b-input-group-append>
|
||||
<b-button variant="primary"
|
||||
v-b-tooltip.hover :title="$t('Create')"
|
||||
@click="createNew">
|
||||
<i class="fas fa-plus"></i>
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" v-for="book in filteredBooks" :key="book.id">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<b-card class="d-flex flex-column" v-hover
|
||||
v-on:click="openBook(book.id)">
|
||||
<b-row no-gutters style="height:inherit;">
|
||||
<b-col no-gutters md="2" style="height:inherit;">
|
||||
<h3>{{ book.icon }}</h3>
|
||||
</b-col>
|
||||
<b-col no-gutters md="10" 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">
|
||||
<h5 class="m-0 mt-1 text-truncate">{{ 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="mt-auto mb-1 d-flex flex-row justify-content-end">
|
||||
<div id="app" class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1 offset">
|
||||
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 mt-3 mb-3">
|
||||
<b-input-group>
|
||||
<b-input
|
||||
class="form-control form-control-lg form-control-borderless form-control-search"
|
||||
v-model="search"
|
||||
v-bind:placeholder="$t('Search')"
|
||||
></b-input>
|
||||
<b-input-group-append>
|
||||
<b-button variant="primary" v-b-tooltip.hover :title="$t('Create')" @click="createNew">
|
||||
<i class="fas fa-plus"></i>
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</div>
|
||||
</div>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" v-for="book in filteredBooks" :key="book.id">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<b-card class="d-flex flex-column" v-hover v-on:click="openBook(book.id)">
|
||||
<b-row no-gutters style="height: inherit">
|
||||
<b-col no-gutters md="2" style="height: inherit">
|
||||
<h3>{{ book.icon }}</h3>
|
||||
</b-col>
|
||||
<b-col no-gutters md="10" 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">
|
||||
<h5 class="m-0 mt-1 text-truncate">
|
||||
{{ 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="mt-auto mb-1 d-flex flex-row justify-content-end"></div>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<loading-spinner v-if="current_book === book.id && loading"></loading-spinner>
|
||||
<transition name="slide-fade">
|
||||
<cookbook-slider :recipes="recipes" :book="book" :key="`slider_${book.id}`"
|
||||
v-if="current_book === book.id && !loading" v-on:refresh="refreshData"></cookbook-slider>
|
||||
</transition>
|
||||
<loading-spinner v-if="current_book === book.id && loading"></loading-spinner>
|
||||
<transition name="slide-fade">
|
||||
<cookbook-slider
|
||||
:recipes="recipes"
|
||||
:book="book"
|
||||
:key="`slider_${book.id}`"
|
||||
v-if="current_book === book.id && !loading"
|
||||
v-on:refresh="refreshData"
|
||||
></cookbook-slider>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import CookbookSlider from "@/components/CookbookSlider";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import {StandardToasts} from "@/utils/utils";
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
import CookbookSlider from "@/components/CookbookSlider"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { StandardToasts } from "@/utils/utils"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: 'CookbookView',
|
||||
mixins: [],
|
||||
components: {LoadingSpinner, CookbookSlider},
|
||||
data() {
|
||||
return {
|
||||
cookbooks: [],
|
||||
book_background: window.IMAGE_BOOK,
|
||||
recipes: [],
|
||||
current_book: undefined,
|
||||
loading: false,
|
||||
search: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredBooks: function () {
|
||||
return this.cookbooks.filter(book => {
|
||||
return book.name.toLowerCase().includes(this.search.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.refreshData()
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
},
|
||||
methods: {
|
||||
refreshData: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.listRecipeBooks().then(result => {
|
||||
this.cookbooks = result.data
|
||||
})
|
||||
name: "CookbookView",
|
||||
mixins: [],
|
||||
components: { LoadingSpinner, CookbookSlider },
|
||||
data() {
|
||||
return {
|
||||
cookbooks: [],
|
||||
book_background: window.IMAGE_BOOK,
|
||||
recipes: [],
|
||||
current_book: undefined,
|
||||
loading: false,
|
||||
search: "",
|
||||
}
|
||||
},
|
||||
openBook: function (book) {
|
||||
if (book === this.current_book) {
|
||||
this.current_book = undefined
|
||||
this.recipes = []
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
this.current_book = book
|
||||
apiClient.listRecipeBookEntrys({query: {book: book}}).then(result => {
|
||||
this.recipes = result.data
|
||||
this.loading = false
|
||||
})
|
||||
computed: {
|
||||
filteredBooks: function () {
|
||||
return this.cookbooks.filter((book) => {
|
||||
return book.name.toLowerCase().includes(this.search.toLowerCase())
|
||||
})
|
||||
},
|
||||
},
|
||||
createNew: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.createRecipeBook({name: this.$t('New_Cookbook'), description: '', icon: '', shared: []}).then(result => {
|
||||
let new_book = result.data
|
||||
mounted() {
|
||||
this.refreshData()
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}).catch(error => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: function (el) {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
el.classList.add("shadow")
|
||||
});
|
||||
el.addEventListener('mouseleave', () => {
|
||||
el.classList.remove("shadow")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
},
|
||||
methods: {
|
||||
refreshData: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.listRecipeBooks().then((result) => {
|
||||
this.cookbooks = result.data
|
||||
})
|
||||
},
|
||||
openBook: function (book) {
|
||||
if (book === this.current_book) {
|
||||
this.current_book = undefined
|
||||
this.recipes = []
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
this.current_book = book
|
||||
apiClient.listRecipeBookEntrys({ query: { book: book } }).then((result) => {
|
||||
this.recipes = result.data
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
createNew: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient
|
||||
.createRecipeBook({ name: this.$t("New_Cookbook"), description: "", icon: "", shared: [] })
|
||||
.then((result) => {
|
||||
let new_book = result.data
|
||||
this.refreshData()
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
},
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: function (el) {
|
||||
el.addEventListener("mouseenter", () => {
|
||||
el.classList.add("shadow")
|
||||
})
|
||||
el.addEventListener("mouseleave", () => {
|
||||
el.classList.remove("shadow")
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.slide-fade-enter-active {
|
||||
transition: all .6s ease;
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter, .slide-fade-leave-to
|
||||
/* .slide-fade-leave-active below version 2.1.8 */
|
||||
{
|
||||
transform: translateX(10px);
|
||||
opacity: 0;
|
||||
/* .slide-fade-leave-active below version 2.1.8 */ {
|
||||
transform: translateX(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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> -->
|
||||
<model-menu />
|
||||
<span>{{ this.this_model.name }}</span>
|
||||
<span v-if="this_model.name !== 'Step'"
|
||||
><b-button variant="link" @click="startAction({ action: 'new' })"><i class="fas fa-plus-circle fa-2x"></i></b-button></span
|
||||
<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
|
||||
><!-- TODO add proper field to model config to determine if create should be available or not -->
|
||||
</h3>
|
||||
</div>
|
||||
<div class="col-md-3" style="position: relative; margin-top: 1vh">
|
||||
<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
|
||||
>
|
||||
<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>
|
||||
{{ $t("show_split_screen") }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
@@ -42,46 +37,19 @@
|
||||
<div class="col" :class="{ 'col-md-6': show_split }">
|
||||
<!-- model isn't paginated and loads in one API call -->
|
||||
<div v-if="!paginated">
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
</div>
|
||||
<!-- 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')">
|
||||
<template v-slot:cards>
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
</template>
|
||||
</generic-infinite-cards>
|
||||
</div>
|
||||
<div class="col col-md-6" v-if="show_split">
|
||||
<generic-infinite-cards
|
||||
v-if="this_model"
|
||||
:card_counts="right_counts"
|
||||
:scroll="show_split"
|
||||
@search="getItems($event, 'right')"
|
||||
@reset="resetList('right')"
|
||||
>
|
||||
<generic-infinite-cards v-if="this_model" :card_counts="right_counts" :scroll="show_split" @search="getItems($event, 'right')" @reset="resetList('right')">
|
||||
<template v-slot:cards>
|
||||
<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"
|
||||
/>
|
||||
<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" />
|
||||
</template>
|
||||
</generic-infinite-cards>
|
||||
</div>
|
||||
@@ -104,7 +72,7 @@ import { StandardToasts, ToastMixin } from "@/utils/utils"
|
||||
import GenericInfiniteCards from "@/components/GenericInfiniteCards"
|
||||
import GenericHorizontalCard from "@/components/GenericHorizontalCard"
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm"
|
||||
import ModelMenu from "@/components/ModelMenu"
|
||||
import ModelMenu from "@/components/ContextMenu/ModelMenu"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
//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
|
||||
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.header_component_name}`)
|
||||
},
|
||||
apiName() {
|
||||
return this.this_model?.apiName
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// value is passed from lists.py
|
||||
@@ -236,6 +207,7 @@ export default {
|
||||
}
|
||||
},
|
||||
finishAction: function (e) {
|
||||
let update = undefined
|
||||
switch (e?.action) {
|
||||
case "save":
|
||||
this.saveThis(e.form_data)
|
||||
@@ -244,7 +216,6 @@ export default {
|
||||
if (e !== "cancel") {
|
||||
switch (this.this_action) {
|
||||
case this.Actions.DELETE:
|
||||
console.log("delete")
|
||||
this.deleteThis(this.this_item.id)
|
||||
break
|
||||
case this.Actions.CREATE:
|
||||
@@ -263,7 +234,7 @@ export default {
|
||||
}
|
||||
this.clearState()
|
||||
},
|
||||
getItems: function (params, col) {
|
||||
getItems: function (params = {}, col) {
|
||||
let column = col || "left"
|
||||
params.options = { query: { extended: 1 } } // returns extended values in API response
|
||||
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.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) {
|
||||
this.refreshThis(item.id)
|
||||
},
|
||||
@@ -334,8 +315,7 @@ export default {
|
||||
this.genericAPI(this.this_model, this.Actions.MOVE, { source: source_id, target: target_id })
|
||||
.then((result) => {
|
||||
this.moveUpdateItem(source_id, target_id)
|
||||
// TODO make standard toast
|
||||
this.makeToast(this.$t("Success"), "Succesfully moved resource", "success")
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_MOVE)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
@@ -374,8 +354,7 @@ export default {
|
||||
})
|
||||
.then((result) => {
|
||||
this.mergeUpdateItem(source_id, target_id)
|
||||
// TODO make standard toast
|
||||
this.makeToast(this.$t("Success"), "Succesfully merged resource", "success")
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_MERGE)
|
||||
})
|
||||
.catch((err) => {
|
||||
//TODO error checking not working with OpenAPI methods
|
||||
@@ -429,7 +408,7 @@ export default {
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast(this.$t("Error"), err.bodyText, "danger")
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
})
|
||||
},
|
||||
getRecipes: function (col, item) {
|
||||
|
||||
@@ -630,7 +630,6 @@ export default {
|
||||
|
||||
apiFactory.updateRecipe(this.recipe_id, this.recipe,
|
||||
{}).then((response) => {
|
||||
console.log(response)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
this.recipe_changed = false
|
||||
if (view_after) {
|
||||
|
||||
@@ -238,7 +238,7 @@ Vue.use(VueCookies)
|
||||
|
||||
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 GenericMultiselect from "@/components/GenericMultiselect"
|
||||
|
||||
@@ -1,274 +1,260 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<template v-if="loading">
|
||||
<loading-spinner></loading-spinner>
|
||||
</template>
|
||||
<div id="app">
|
||||
<template v-if="loading">
|
||||
<loading-spinner></loading-spinner>
|
||||
</template>
|
||||
|
||||
<div v-if="!loading">
|
||||
<div class="row">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<h3>{{ recipe.name }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row text-center">
|
||||
<div class="col col-md-12">
|
||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-auto">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<i>{{ recipe.description }}</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center">
|
||||
<keywords-component :recipe="recipe"></keywords-component>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<div class="row">
|
||||
<div class="col col-md-3">
|
||||
<div class="row d-flex" style="padding-left: 16px">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="fas fa-user-clock fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"><b>{{ $t('Preparation') }}</b></span><br/>
|
||||
{{ recipe.working_time }} {{ $t('min') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-3">
|
||||
<div class="row d-flex">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="far fa-clock fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"><b>{{ $t('Waiting') }}</b></span><br/>
|
||||
{{ recipe.waiting_time }} {{ $t('min') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-4 col-10 mt-2 mt-md-0 mt-lg-0 mt-xl-0">
|
||||
<div class="row d-flex" style="padding-left: 16px">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<input
|
||||
style="text-align: right; border-width:0px;border:none; padding:0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
|
||||
value="1" maxlength="3" min="0"
|
||||
type="number" class="form-control form-control-lg" v-model.number="servings"/>
|
||||
</div>
|
||||
<div class="my-auto ">
|
||||
<span class="text-primary"><b><template v-if="recipe.servings_text === ''">{{ $t('Servings') }}</template><template
|
||||
v-else>{{ recipe.servings_text }}</template></b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
|
||||
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
|
||||
<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="card border-primary">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col col-md-8">
|
||||
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h4>
|
||||
<div v-if="!loading">
|
||||
<div class="row">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<h3>{{ recipe.name }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="row text-center">
|
||||
<div class="col col-md-12">
|
||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
|
||||
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh;"
|
||||
:alt="$t( 'Recipe_Image')" v-if="recipe.image !== null">
|
||||
<div class="my-auto">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<i>{{ recipe.description }}</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
|
||||
<div class="col-12">
|
||||
<Nutrition-component :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition-component>
|
||||
<div style="text-align: center">
|
||||
<keywords-component :recipe="recipe"></keywords-component>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col col-md-3">
|
||||
<div class="row d-flex" style="padding-left: 16px">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="fas fa-user-clock fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"
|
||||
><b>{{ $t("Preparation") }}</b></span
|
||||
><br />
|
||||
{{ recipe.working_time }} {{ $t("min") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-3">
|
||||
<div class="row d-flex">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="far fa-clock fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"
|
||||
><b>{{ $t("Waiting") }}</b></span
|
||||
><br />
|
||||
{{ recipe.waiting_time }} {{ $t("min") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-4 col-10 mt-2 mt-md-0 mt-lg-0 mt-xl-0">
|
||||
<div class="row d-flex" style="padding-left: 16px">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<input
|
||||
style="text-align: right; border-width: 0px; border: none; padding: 0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
|
||||
value="1"
|
||||
maxlength="3"
|
||||
min="0"
|
||||
type="number"
|
||||
class="form-control form-control-lg"
|
||||
v-model.number="servings"
|
||||
/>
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<span class="text-primary"
|
||||
><b
|
||||
><template v-if="recipe.servings_text === ''">{{ $t("Servings") }}</template
|
||||
><template v-else>{{ recipe.servings_text }}</template></b
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
|
||||
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<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">
|
||||
<ingredients-card
|
||||
:recipe="recipe.id"
|
||||
:steps="recipe.steps"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:header="true"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
|
||||
<div class="col-12">
|
||||
<Nutrition-component :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition-component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!recipe.internal">
|
||||
<div v-if="recipe.file_path.includes('.pdf')">
|
||||
<PdfViewer :recipe="recipe"></PdfViewer>
|
||||
</div>
|
||||
<div v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
||||
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||
<step-component
|
||||
:recipe="recipe"
|
||||
:step="s"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:index="index"
|
||||
:start_time="start_time"
|
||||
@update-start-time="updateStartTime"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
></step-component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
|
||||
|
||||
</div>
|
||||
|
||||
<template v-if="!recipe.internal">
|
||||
<div v-if="recipe.file_path.includes('.pdf')">
|
||||
<PdfViewer :recipe="recipe"></PdfViewer>
|
||||
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
|
||||
<div class="col col-md-12">
|
||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t("Report Abuse") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
||||
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||
<step-component :recipe="recipe" :step="s" :ingredient_factor="ingredient_factor" :index="index" :start_time="start_time"
|
||||
@update-start-time="updateStartTime" @checked-state-changed="updateIngredientCheckedState"></step-component>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
|
||||
|
||||
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
|
||||
<div class="col col-md-12">
|
||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t('Report Abuse') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
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 {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
|
||||
import Ingredient from "@/components/IngredientComponent";
|
||||
import RecipeContextMenu from "@/components/RecipeContextMenu"
|
||||
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
|
||||
|
||||
import PdfViewer from "@/components/PdfViewer";
|
||||
import ImageViewer from "@/components/ImageViewer";
|
||||
import Nutrition from "@/components/NutritionComponent";
|
||||
import PdfViewer from "@/components/PdfViewer"
|
||||
import ImageViewer from "@/components/ImageViewer"
|
||||
|
||||
import moment from 'moment'
|
||||
import Keywords from "@/components/KeywordsComponent";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import AddRecipeToBook from "@/components/AddRecipeToBook";
|
||||
import RecipeRating from "@/components/RecipeRating";
|
||||
import LastCooked from "@/components/LastCooked";
|
||||
import IngredientComponent from "@/components/IngredientComponent";
|
||||
import StepComponent from "@/components/StepComponent";
|
||||
import KeywordsComponent from "@/components/KeywordsComponent";
|
||||
import NutritionComponent from "@/components/NutritionComponent";
|
||||
import moment from "moment"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||
import RecipeRating from "@/components/RecipeRating"
|
||||
import LastCooked from "@/components/LastCooked"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
import StepComponent from "@/components/StepComponent"
|
||||
import KeywordsComponent from "@/components/KeywordsComponent"
|
||||
import NutritionComponent from "@/components/NutritionComponent"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: 'RecipeView',
|
||||
mixins: [
|
||||
ResolveUrlMixin,
|
||||
ToastMixin,
|
||||
],
|
||||
components: {
|
||||
LastCooked,
|
||||
RecipeRating,
|
||||
PdfViewer,
|
||||
ImageViewer,
|
||||
IngredientComponent,
|
||||
StepComponent,
|
||||
RecipeContextMenu,
|
||||
NutritionComponent,
|
||||
KeywordsComponent,
|
||||
LoadingSpinner,
|
||||
AddRecipeToBook,
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings
|
||||
name: "RecipeView",
|
||||
mixins: [ResolveUrlMixin, ToastMixin],
|
||||
components: {
|
||||
LastCooked,
|
||||
RecipeRating,
|
||||
PdfViewer,
|
||||
ImageViewer,
|
||||
IngredientsCard,
|
||||
StepComponent,
|
||||
RecipeContextMenu,
|
||||
NutritionComponent,
|
||||
KeywordsComponent,
|
||||
LoadingSpinner,
|
||||
AddRecipeToBook,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
recipe: undefined,
|
||||
ingredient_count: 0,
|
||||
servings: 1,
|
||||
start_time: "",
|
||||
share_uid: window.SHARE_UID
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadRecipe(window.RECIPE_ID)
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
},
|
||||
methods: {
|
||||
loadRecipe: function (recipe_id) {
|
||||
apiLoadRecipe(recipe_id).then(recipe => {
|
||||
|
||||
if (window.USER_SERVINGS !== 0) {
|
||||
recipe.servings = window.USER_SERVINGS
|
||||
}
|
||||
this.servings = recipe.servings
|
||||
|
||||
let total_time = 0
|
||||
for (let step of recipe.steps) {
|
||||
this.ingredient_count += step.ingredients.length
|
||||
|
||||
for (let ingredient of step.ingredients) {
|
||||
this.$set(ingredient, 'checked', false)
|
||||
}
|
||||
|
||||
step.time_offset = total_time
|
||||
total_time += step.time
|
||||
}
|
||||
|
||||
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
||||
if (total_time > 0) {
|
||||
this.start_time = moment().format('yyyy-MM-DDTHH:mm')
|
||||
}
|
||||
|
||||
this.recipe = recipe
|
||||
this.loading = false
|
||||
})
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings
|
||||
},
|
||||
},
|
||||
updateStartTime: function (e) {
|
||||
this.start_time = e
|
||||
},
|
||||
updateIngredientCheckedState: function (e) {
|
||||
for (let step of this.recipe.steps) {
|
||||
for (let ingredient of step.ingredients) {
|
||||
if (ingredient.id === e.id) {
|
||||
this.$set(ingredient, 'checked', !ingredient.checked)
|
||||
}
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
recipe: undefined,
|
||||
ingredient_count: 0,
|
||||
servings: 1,
|
||||
start_time: "",
|
||||
share_uid: window.SHARE_UID,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
mounted() {
|
||||
this.loadRecipe(window.RECIPE_ID)
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
},
|
||||
methods: {
|
||||
loadRecipe: function (recipe_id) {
|
||||
apiLoadRecipe(recipe_id).then((recipe) => {
|
||||
if (window.USER_SERVINGS !== 0) {
|
||||
recipe.servings = window.USER_SERVINGS
|
||||
}
|
||||
this.servings = recipe.servings
|
||||
|
||||
let total_time = 0
|
||||
for (let step of recipe.steps) {
|
||||
this.ingredient_count += step.ingredients.length
|
||||
|
||||
for (let ingredient of step.ingredients) {
|
||||
this.$set(ingredient, "checked", false)
|
||||
}
|
||||
|
||||
step.time_offset = total_time
|
||||
total_time += step.time
|
||||
}
|
||||
|
||||
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
||||
if (total_time > 0) {
|
||||
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
|
||||
}
|
||||
|
||||
this.recipe = recipe
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
updateStartTime: function (e) {
|
||||
this.start_time = e
|
||||
},
|
||||
updateIngredientCheckedState: function (e) {
|
||||
for (let step of this.recipe.steps) {
|
||||
for (let ingredient of step.ingredients) {
|
||||
if (ingredient.id === e.id) {
|
||||
this.$set(ingredient, "checked", !ingredient.checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -276,4 +262,4 @@ export default {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
1187
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
1187
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
File diff suppressed because it is too large
Load Diff
17
vue/src/apps/ShoppingListView/main.js
Normal file
17
vue/src/apps/ShoppingListView/main.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import i18n from "@/i18n"
|
||||
import Vue from "vue"
|
||||
import App from "./ShoppingListView"
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
|
||||
let publicPath = localStorage.STATIC_URL + "vue/"
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
publicPath = "http://localhost:8080/"
|
||||
}
|
||||
export default __webpack_public_path__ = publicPath // eslint-disable-line
|
||||
|
||||
new Vue({
|
||||
i18n,
|
||||
render: (h) => h(App),
|
||||
}).$mount("#app")
|
||||
@@ -1,201 +1,178 @@
|
||||
<template>
|
||||
<!-- TODO: Deprecate -->
|
||||
<div id="app">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h2>{{ $t("Supermarket") }}</h2>
|
||||
|
||||
<div id="app">
|
||||
<multiselect v-model="selected_supermarket" track-by="id" label="name" :options="supermarkets" @input="selectedSupermarketChanged"> </multiselect>
|
||||
|
||||
<div class="row">
|
||||
<b-button class="btn btn-primary btn-block" style="margin-top: 1vh" v-b-modal.modal-supermarket>
|
||||
{{ $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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-12">
|
||||
<h2>{{ $t('Supermarket') }}</h2>
|
||||
<hr />
|
||||
|
||||
<multiselect v-model="selected_supermarket" track-by="id" label="name"
|
||||
:options="supermarkets" @input="selectedSupermarketChanged">
|
||||
</multiselect>
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
<h4>
|
||||
{{ $t("Categories") }}
|
||||
<button class="btn btn-success btn-sm" @click="selected_category = { new: true, name: '' }" v-b-modal.modal-category>{{ $t("New") }}</button>
|
||||
</h4>
|
||||
|
||||
<b-button class="btn btn-primary btn-block" style="margin-top: 1vh" v-b-modal.modal-supermarket>
|
||||
{{ $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>
|
||||
</div>
|
||||
<draggable :list="selectable_categories" group="supermarket_categories" :empty-insert-threshold="10">
|
||||
<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>
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<h4>{{ $t("Selected") }} {{ $t("Categories") }}</h4>
|
||||
|
||||
<draggable :list="supermarket_categories" group="supermarket_categories" :empty-insert-threshold="10" @change="selectedCategoriesChanged">
|
||||
<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>
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EDIT MODALS -->
|
||||
<b-modal id="modal-supermarket" v-bind:title="$t('Supermarket')" @ok="supermarketModalOk()">
|
||||
<label v-if="selected_supermarket !== undefined">
|
||||
{{ $t("Name") }}
|
||||
<b-input v-model="selected_supermarket.name"></b-input>
|
||||
</label>
|
||||
</b-modal>
|
||||
|
||||
<b-modal id="modal-category" v-bind:title="$t('Category')" @ok="categoryModalOk()">
|
||||
<label v-if="selected_category !== undefined">
|
||||
{{ $t("Name") }}
|
||||
<b-input v-model="selected_category.name"></b-input>
|
||||
</label>
|
||||
</b-modal>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
<h4>{{ $t('Categories') }}
|
||||
<button class="btn btn-success btn-sm" @click="selected_category = {new:true, name:''}"
|
||||
v-b-modal.modal-category>{{ $t('New') }}
|
||||
</button>
|
||||
</h4>
|
||||
|
||||
<draggable :list="selectable_categories" group="supermarket_categories"
|
||||
:empty-insert-threshold="10">
|
||||
<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>
|
||||
|
||||
</div>
|
||||
</draggable>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<h4>{{ $t('Selected') }} {{ $t('Categories') }}</h4>
|
||||
|
||||
<draggable :list="supermarket_categories" group="supermarket_categories"
|
||||
:empty-insert-threshold="10" @change="selectedCategoriesChanged">
|
||||
<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>
|
||||
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EDIT MODALS -->
|
||||
<b-modal id="modal-supermarket" v-bind:title="$t('Supermarket')" @ok="supermarketModalOk()">
|
||||
<label v-if="selected_supermarket !== undefined">
|
||||
{{ $t('Name') }}
|
||||
<b-input v-model="selected_supermarket.name"></b-input>
|
||||
|
||||
</label>
|
||||
</b-modal>
|
||||
|
||||
<b-modal id="modal-category" v-bind:title="$t('Category')" @ok="categoryModalOk()">
|
||||
<label v-if="selected_category !== undefined">
|
||||
{{ $t('Name') }}
|
||||
<b-input v-model="selected_category.name"></b-input>
|
||||
|
||||
</label>
|
||||
</b-modal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
import Vue from "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)
|
||||
import draggable from 'vuedraggable'
|
||||
import draggable from "vuedraggable"
|
||||
|
||||
import axios from 'axios'
|
||||
import Multiselect from "vue-multiselect";
|
||||
import axios from "axios"
|
||||
import Multiselect from "vue-multiselect"
|
||||
|
||||
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
|
||||
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||
axios.defaults.xsrfHeaderName = "X-CSRFToken"
|
||||
axios.defaults.xsrfCookieName = "csrftoken"
|
||||
|
||||
export default {
|
||||
name: 'SupermarketView',
|
||||
mixins: [
|
||||
ResolveUrlMixin,
|
||||
ToastMixin,
|
||||
],
|
||||
components: {
|
||||
Multiselect,
|
||||
draggable
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
supermarkets: [],
|
||||
categories: [],
|
||||
|
||||
selected_supermarket: {},
|
||||
selected_category: {},
|
||||
|
||||
|
||||
selectable_categories: [],
|
||||
supermarket_categories: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
this.loadInitial()
|
||||
},
|
||||
methods: {
|
||||
loadInitial: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.listSupermarkets().then(results => {
|
||||
this.supermarkets = results.data
|
||||
})
|
||||
apiClient.listSupermarketCategorys().then(results => {
|
||||
this.categories = results.data
|
||||
this.selectable_categories = this.categories
|
||||
})
|
||||
name: "SupermarketView",
|
||||
mixins: [ResolveUrlMixin, ToastMixin],
|
||||
components: {
|
||||
Multiselect,
|
||||
draggable,
|
||||
},
|
||||
selectedCategoriesChanged: function (data) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
data() {
|
||||
return {
|
||||
supermarkets: [],
|
||||
categories: [],
|
||||
|
||||
if ('removed' in data) {
|
||||
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === data.removed.element.id)[0]
|
||||
apiClient.destroySupermarketCategoryRelation(relation.id)
|
||||
}
|
||||
selected_supermarket: {},
|
||||
selected_category: {},
|
||||
|
||||
if ('added' in data) {
|
||||
apiClient.createSupermarketCategoryRelation({
|
||||
category: data.added.element,
|
||||
supermarket: this.selected_supermarket.id, order: 0
|
||||
}).then(results => {
|
||||
this.selected_supermarket.category_to_supermarket.push(results.data)
|
||||
})
|
||||
}
|
||||
|
||||
if ('moved' in data || 'added' in data) {
|
||||
this.supermarket_categories.forEach( (element,index) =>{
|
||||
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === element.id)[0]
|
||||
console.log(relation)
|
||||
apiClient.partialUpdateSupermarketCategoryRelation(relation.id, {order: index})
|
||||
})
|
||||
}
|
||||
selectable_categories: [],
|
||||
supermarket_categories: [],
|
||||
}
|
||||
},
|
||||
selectedSupermarketChanged: function (supermarket, id) {
|
||||
this.supermarket_categories = []
|
||||
this.selectable_categories = this.categories
|
||||
|
||||
for (let i of supermarket.category_to_supermarket) {
|
||||
this.supermarket_categories.push(i.category)
|
||||
this.selectable_categories = this.selectable_categories.filter(function (el) {
|
||||
return el.id !== i.category.id
|
||||
});
|
||||
}
|
||||
mounted() {
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
this.loadInitial()
|
||||
},
|
||||
supermarketModalOk: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
if (this.selected_supermarket.new) {
|
||||
apiClient.createSupermarket({name: this.selected_supermarket.name}).then(results => {
|
||||
this.selected_supermarket = undefined
|
||||
this.loadInitial()
|
||||
})
|
||||
} else {
|
||||
apiClient.partialUpdateSupermarket(this.selected_supermarket.id, {name: this.selected_supermarket.name})
|
||||
methods: {
|
||||
loadInitial: function() {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.listSupermarkets().then((results) => {
|
||||
this.supermarkets = results.data
|
||||
})
|
||||
apiClient.listSupermarketCategorys().then((results) => {
|
||||
this.categories = results.data
|
||||
this.selectable_categories = this.categories
|
||||
})
|
||||
},
|
||||
selectedCategoriesChanged: function(data) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
}
|
||||
if ("removed" in data) {
|
||||
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === data.removed.element.id)[0]
|
||||
apiClient.destroySupermarketCategoryRelation(relation.id)
|
||||
}
|
||||
|
||||
if ("added" in data) {
|
||||
apiClient
|
||||
.createSupermarketCategoryRelation({
|
||||
category: data.added.element,
|
||||
supermarket: this.selected_supermarket.id,
|
||||
order: 0,
|
||||
})
|
||||
.then((results) => {
|
||||
this.selected_supermarket.category_to_supermarket.push(results.data)
|
||||
})
|
||||
}
|
||||
|
||||
if ("moved" in data || "added" in data) {
|
||||
this.supermarket_categories.forEach((element, index) => {
|
||||
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === element.id)[0]
|
||||
console.log(relation)
|
||||
apiClient.partialUpdateSupermarketCategoryRelation(relation.id, { order: index })
|
||||
})
|
||||
}
|
||||
},
|
||||
selectedSupermarketChanged: function(supermarket, id) {
|
||||
this.supermarket_categories = []
|
||||
this.selectable_categories = this.categories
|
||||
|
||||
for (let i of supermarket.category_to_supermarket) {
|
||||
this.supermarket_categories.push(i.category)
|
||||
this.selectable_categories = this.selectable_categories.filter(function(el) {
|
||||
return el.id !== i.category.id
|
||||
})
|
||||
}
|
||||
},
|
||||
supermarketModalOk: function() {
|
||||
let apiClient = new ApiApiFactory()
|
||||
if (this.selected_supermarket.new) {
|
||||
apiClient.createSupermarket({ name: this.selected_supermarket.name }).then((results) => {
|
||||
this.selected_supermarket = undefined
|
||||
this.loadInitial()
|
||||
})
|
||||
} else {
|
||||
apiClient.partialUpdateSupermarket(this.selected_supermarket.id, { name: this.selected_supermarket.name })
|
||||
}
|
||||
},
|
||||
categoryModalOk: function() {
|
||||
let apiClient = new ApiApiFactory()
|
||||
if (this.selected_category.new) {
|
||||
apiClient.createSupermarketCategory({ name: this.selected_category.name }).then((results) => {
|
||||
this.selected_category = {}
|
||||
this.loadInitial()
|
||||
})
|
||||
} else {
|
||||
apiClient.partialUpdateSupermarketCategory(this.selected_category.id, { name: this.selected_category.name })
|
||||
}
|
||||
},
|
||||
},
|
||||
categoryModalOk: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
if (this.selected_category.new) {
|
||||
apiClient.createSupermarketCategory({name: this.selected_category.name}).then(results => {
|
||||
this.selected_category = {}
|
||||
this.loadInitial()
|
||||
})
|
||||
} else {
|
||||
apiClient.partialUpdateSupermarketCategory(this.selected_category.id, {name: this.selected_category.name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
|
||||
</style>
|
||||
<style></style>
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
<template>
|
||||
<span>
|
||||
<linked-recipe v-if="linkedRecipe"
|
||||
:item="item"/>
|
||||
<icon-badge v-if="Icon"
|
||||
:item="item"/>
|
||||
<linked-recipe v-if="linkedRecipe" :item="item" />
|
||||
<icon-badge v-if="Icon" :item="item" />
|
||||
<on-hand-badge v-if="OnHand" :item="item" />
|
||||
<shopping-badge v-if="Shopping" :item="item" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LinkedRecipe from "@/components/Badges/LinkedRecipe";
|
||||
import IconBadge from "@/components/Badges/Icon";
|
||||
import LinkedRecipe from "@/components/Badges/LinkedRecipe"
|
||||
import IconBadge from "@/components/Badges/Icon"
|
||||
import OnHandBadge from "@/components/Badges/OnHand"
|
||||
import ShoppingBadge from "@/components/Badges/Shopping"
|
||||
|
||||
export default {
|
||||
name: 'CardBadges',
|
||||
components: {LinkedRecipe, IconBadge},
|
||||
props: {
|
||||
item: {type: Object},
|
||||
model: {type: Object}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
computed: {
|
||||
linkedRecipe: function () {
|
||||
return this.model?.badges?.linked_recipe ?? false
|
||||
name: "CardBadges",
|
||||
components: { LinkedRecipe, IconBadge, OnHandBadge, ShoppingBadge },
|
||||
props: {
|
||||
item: { type: Object },
|
||||
model: { type: Object },
|
||||
},
|
||||
Icon: function () {
|
||||
return this.model?.badges?.icon ?? false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
mounted() {},
|
||||
computed: {
|
||||
linkedRecipe: function () {
|
||||
return this.model?.badges?.linked_recipe ?? false
|
||||
},
|
||||
Icon: function () {
|
||||
return this.model?.badges?.icon ?? false
|
||||
},
|
||||
OnHand: function () {
|
||||
return this.model?.badges?.food_onhand ?? false
|
||||
},
|
||||
Shopping: function () {
|
||||
return this.model?.badges?.shopping ?? false
|
||||
},
|
||||
},
|
||||
watch: {},
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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}}
|
||||
</b-button>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
|
||||
45
vue/src/components/Badges/OnHand.vue
Normal file
45
vue/src/components/Badges/OnHand.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button
|
||||
class="btn text-decoration-none fas px-1 py-0 border-0"
|
||||
variant="link"
|
||||
v-b-popover.hover.html
|
||||
:title="[onhand ? $t('FoodOnHand', { food: item.name }) : $t('FoodNotOnHand', { food: item.name })]"
|
||||
:class="[onhand ? 'text-success fa-clipboard-check' : 'text-muted fa-clipboard']"
|
||||
@click="toggleOnHand"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ApiMixin } from "@/utils/utils"
|
||||
|
||||
export default {
|
||||
name: "OnHandBadge",
|
||||
props: {
|
||||
item: { type: Object },
|
||||
},
|
||||
mixins: [ApiMixin],
|
||||
data() {
|
||||
return {
|
||||
onhand: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.onhand = this.item.food_onhand
|
||||
},
|
||||
watch: {
|
||||
"item.food_onhand": function (newVal, oldVal) {
|
||||
this.onhand = newVal
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleOnHand() {
|
||||
let params = { id: this.item.id, food_onhand: !this.onhand }
|
||||
this.genericAPI(this.Models.FOOD, this.Actions.UPDATE, params).then(() => {
|
||||
this.onhand = !this.onhand
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
88
vue/src/components/Badges/Shopping.vue
Normal file
88
vue/src/components/Badges/Shopping.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button class="btn text-decoration-none px-1 border-0" variant="link" :id="`shopping${item.id}`" @click="addShopping()">
|
||||
<i
|
||||
class="fas"
|
||||
v-b-popover.hover.html
|
||||
:title="[shopping ? $t('RemoveFoodFromShopping', { food: item.name }) : $t('AddFoodToShopping', { food: item.name })]"
|
||||
:class="[shopping ? 'text-success fa-shopping-cart' : 'text-muted fa-cart-plus']"
|
||||
/>
|
||||
</b-button>
|
||||
<b-popover v-if="shopping" :target="`${ShowConfirmation}`" :ref="'shopping' + item.id" triggers="focus" placement="top">
|
||||
<template #title>{{ DeleteConfirmation }}</template>
|
||||
<b-row align-h="end">
|
||||
<b-col cols="auto">
|
||||
<b-button class="btn btn-sm btn-info shadow-none px-1 border-0" @click="cancelDelete()">{{ $t("Cancel") }}</b-button>
|
||||
<b-button class="btn btn-sm btn-danger shadow-none px-1" @click="confirmDelete()">{{ $t("Confirm") }}</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-popover>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||
|
||||
export default {
|
||||
name: "ShoppingBadge",
|
||||
props: {
|
||||
item: { type: Object },
|
||||
},
|
||||
mixins: [ApiMixin],
|
||||
data() {
|
||||
return {
|
||||
shopping: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// let random = [true, false,]
|
||||
this.shopping = this.item?.shopping //?? random[Math.floor(Math.random() * random.length)]
|
||||
},
|
||||
computed: {
|
||||
DeleteConfirmation() {
|
||||
return this.$t("DeleteShoppingConfirm", { food: this.item.name })
|
||||
},
|
||||
ShowConfirmation() {
|
||||
if (this.shopping) {
|
||||
return "shopping" + this.item.id
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"item.shopping": function (newVal, oldVal) {
|
||||
this.shopping = newVal
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addShopping() {
|
||||
if (this.shopping) {
|
||||
return
|
||||
} // if item already in shopping list, excution handled after confirmation
|
||||
let params = {
|
||||
id: this.item.id,
|
||||
amount: 1,
|
||||
}
|
||||
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then((result) => {
|
||||
this.shopping = true
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
},
|
||||
cancelDelete() {
|
||||
this.$refs["shopping" + this.item.id].$emit("close")
|
||||
},
|
||||
confirmDelete() {
|
||||
let params = {
|
||||
id: this.item.id,
|
||||
_delete: "true",
|
||||
}
|
||||
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then(() => {
|
||||
this.shopping = false
|
||||
this.$refs["shopping" + this.item.id].$emit("close")
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
62
vue/src/components/Buttons/CopyToClipboard.vue
Normal file
62
vue/src/components/Buttons/CopyToClipboard.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div>
|
||||
<a v-if="!button" class="dropdown-item" @click="clipboard"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button v-if="button" @click="clipboard">{{ label }}</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { makeToast } from "@/utils/utils"
|
||||
export default {
|
||||
name: "CopyToClipboard",
|
||||
|
||||
props: {
|
||||
items: { type: Array },
|
||||
icon: { type: String },
|
||||
label: { type: String },
|
||||
button: { type: Boolean, default: false },
|
||||
settings: { type: Object },
|
||||
format: { type: String, default: "delim" },
|
||||
},
|
||||
methods: {
|
||||
clipboard: function () {
|
||||
let text = ""
|
||||
switch (this.format) {
|
||||
case "delim":
|
||||
text = this.delimited()
|
||||
break
|
||||
case "table":
|
||||
text = this.table()
|
||||
break
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(text).then(makeToast(this.$t("Success"), this.$t("SuccessClipboard"), "success"))
|
||||
},
|
||||
delimited: function () {
|
||||
let csvContent = ""
|
||||
let delim = this.settings.csv_delim || ","
|
||||
let prefix = this.settings.csv_prefix || ""
|
||||
csvContent += [prefix + Object.keys(this.items[0]).join(delim), ...this.items.map((x) => prefix + Object.values(x).join(delim))].join("\n").replace(/(^\[)|(\]$)/gm, "")
|
||||
return csvContent
|
||||
},
|
||||
table: function () {
|
||||
let table = ""
|
||||
let delim = "|"
|
||||
table += [
|
||||
delim + Object.keys(this.items[0]).join(delim) + delim,
|
||||
delim +
|
||||
Object.keys(this.items[0])
|
||||
.map((x) => {
|
||||
return ":---"
|
||||
})
|
||||
.join(delim) +
|
||||
delim,
|
||||
...this.items.map((x) => delim + Object.values(x).join(delim) + delim),
|
||||
]
|
||||
.join("\n")
|
||||
.replace(/(^\[)|(\]$)/gm, "")
|
||||
return table
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
33
vue/src/components/Buttons/DownloadCSV.vue
Normal file
33
vue/src/components/Buttons/DownloadCSV.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div>
|
||||
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "DownloadCSV",
|
||||
|
||||
props: {
|
||||
items: { type: Array },
|
||||
name: { type: String },
|
||||
icon: { type: String },
|
||||
label: { type: String },
|
||||
button: { type: Boolean, default: false },
|
||||
delim: { type: String, default: "," },
|
||||
},
|
||||
methods: {
|
||||
downloadFile() {
|
||||
let csvContent = "data:text/csv;charset=utf-8,"
|
||||
csvContent += [Object.keys(this.items[0]).join(this.delim), ...this.items.map((x) => Object.values(x).join(this.delim))].join("\n").replace(/(^\[)|(\]$)/gm, "")
|
||||
|
||||
const data = encodeURI(csvContent)
|
||||
const link = document.createElement("a")
|
||||
link.setAttribute("href", data)
|
||||
link.setAttribute("download", "export.csv")
|
||||
link.click()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
32
vue/src/components/Buttons/DownloadPDF.vue
Normal file
32
vue/src/components/Buttons/DownloadPDF.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import html2pdf from "html2pdf.js"
|
||||
|
||||
export default {
|
||||
name: "DownloadPDF",
|
||||
|
||||
props: {
|
||||
dom: { type: String },
|
||||
name: { type: String },
|
||||
icon: { type: String },
|
||||
label: { type: String },
|
||||
button: { type: Boolean, default: false },
|
||||
},
|
||||
methods: {
|
||||
downloadFile() {
|
||||
const doc = document.querySelector(this.dom)
|
||||
var options = {
|
||||
margin: 1,
|
||||
filename: this.name,
|
||||
}
|
||||
html2pdf().from(doc).set(options).save()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,127 +1,118 @@
|
||||
<template>
|
||||
<div
|
||||
class="context-menu"
|
||||
ref="popper"
|
||||
v-show="isVisible"
|
||||
tabindex="-1"
|
||||
v-click-outside="close"
|
||||
@contextmenu.capture.prevent>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<slot :contextData="contextData" name="menu"/>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="context-menu" ref="popper" v-show="isVisible" tabindex="-1" v-click-outside="close" @contextmenu.capture.prevent>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<slot :contextData="contextData" name="menu" />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Popper from 'popper.js';
|
||||
import Popper from "popper.js"
|
||||
|
||||
Popper.Defaults.modifiers.computeStyle.gpuAcceleration = false
|
||||
import ClickOutside from 'vue-click-outside'
|
||||
import ClickOutside from "vue-click-outside"
|
||||
|
||||
export default {
|
||||
name: "ContextMenu.vue",
|
||||
props: {
|
||||
boundariesElement: {
|
||||
type: String,
|
||||
default: 'body',
|
||||
},
|
||||
},
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
opened: false,
|
||||
contextData: {},
|
||||
};
|
||||
},
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
computed: {
|
||||
isVisible() {
|
||||
return this.opened;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
open(evt, contextData) {
|
||||
this.opened = true;
|
||||
this.contextData = contextData;
|
||||
|
||||
if (this.popper) {
|
||||
this.popper.destroy();
|
||||
}
|
||||
|
||||
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
|
||||
placement: 'right-start',
|
||||
modifiers: {
|
||||
preventOverflow: {
|
||||
boundariesElement: document.querySelector(this.boundariesElement),
|
||||
},
|
||||
name: "ContextMenu.vue",
|
||||
props: {
|
||||
boundariesElement: {
|
||||
type: String,
|
||||
default: "body",
|
||||
},
|
||||
});
|
||||
this.$nextTick(() => {
|
||||
this.popper.scheduleUpdate();
|
||||
});
|
||||
|
||||
},
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.contextData = null;
|
||||
},
|
||||
referenceObject(evt) {
|
||||
const left = evt.clientX;
|
||||
const top = evt.clientY;
|
||||
const right = left + 1;
|
||||
const bottom = top + 1;
|
||||
const clientWidth = 1;
|
||||
const clientHeight = 1;
|
||||
|
||||
function getBoundingClientRect() {
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
};
|
||||
}
|
||||
|
||||
const obj = {
|
||||
getBoundingClientRect,
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
};
|
||||
return obj;
|
||||
opened: false,
|
||||
contextData: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.popper !== undefined) {
|
||||
this.popper.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
computed: {
|
||||
isVisible() {
|
||||
return this.opened
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
open(evt, contextData) {
|
||||
this.opened = true
|
||||
this.contextData = contextData
|
||||
|
||||
if (this.popper) {
|
||||
this.popper.destroy()
|
||||
}
|
||||
|
||||
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
|
||||
placement: "right-start",
|
||||
modifiers: {
|
||||
preventOverflow: {
|
||||
boundariesElement: document.querySelector(this.boundariesElement),
|
||||
},
|
||||
},
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
this.popper.scheduleUpdate()
|
||||
})
|
||||
},
|
||||
close() {
|
||||
this.opened = false
|
||||
this.contextData = null
|
||||
},
|
||||
referenceObject(evt) {
|
||||
const left = evt.clientX
|
||||
const top = evt.clientY
|
||||
const right = left + 1
|
||||
const bottom = top + 1
|
||||
const clientWidth = 1
|
||||
const clientHeight = 1
|
||||
|
||||
function getBoundingClientRect() {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
}
|
||||
}
|
||||
|
||||
const obj = {
|
||||
getBoundingClientRect,
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
}
|
||||
return obj
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.popper !== undefined) {
|
||||
this.popper.destroy()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px 0 #eee;
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px 0 #eee;
|
||||
}
|
||||
|
||||
.context-menu:focus {
|
||||
outline: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.context-menu ul {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
display: block;
|
||||
position: relative;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<template>
|
||||
<li @click="$emit('click', $event)" role="presentation">
|
||||
<slot/>
|
||||
</li>
|
||||
<li @click="$emit('click', $event)" role="presentation">
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ContextMenuItem.vue",
|
||||
name: "ContextMenuItem.vue",
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
38
vue/src/components/ContextMenu/GenericContextMenu.vue
Normal file
38
vue/src/components/ContextMenu/GenericContextMenu.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-dropdown variant="link" toggle-class="text-decoration-none" right no-caret style="boundary:window">
|
||||
<template #button-content>
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</template>
|
||||
<b-dropdown-item v-on:click="$emit('item-action', 'edit')" v-if="show_edit"> <i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item v-on:click="$emit('item-action', 'delete')" v-if="show_delete"> <i class="fas fa-trash-alt fa-fw"></i> {{ $t("Delete") }} </b-dropdown-item>
|
||||
<b-dropdown-item v-on:click="$emit('item-action', 'add-shopping')" v-if="show_shopping">
|
||||
<i class="fas fa-cart-plus fa-fw"></i> {{ $t("Add_to_Shopping") }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item v-on:click="$emit('item-action', 'add-onhand')" v-if="show_onhand"> <i class="fas fa-clipboard-check fa-fw"></i> {{ $t("OnHand") }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item v-on:click="$emit('item-action', 'move')" v-if="show_move"> <i class="fas fa-expand-arrows-alt fa-fw"></i> {{ $t("Move") }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge')"> <i class="fas fa-compress-arrows-alt fa-fw"></i> {{ $t("Merge") }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge-automate')">
|
||||
<i class="fas fa-robot fa-fw"></i> {{ $t("Merge") }} & {{ $t("Automate") }} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "GenericContextMenu",
|
||||
props: {
|
||||
show_edit: { type: Boolean, default: true },
|
||||
show_delete: { type: Boolean, default: true },
|
||||
show_move: { type: Boolean, default: false },
|
||||
show_merge: { type: Boolean, default: false },
|
||||
show_shopping: { type: Boolean, default: false },
|
||||
show_onhand: { type: Boolean, default: false },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
52
vue/src/components/ContextMenu/ModelMenu.vue
Normal file
52
vue/src/components/ContextMenu/ModelMenu.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<!-- <b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button> -->
|
||||
<span>
|
||||
<b-dropdown variant="link" toggle-class="text-decoration-none text-dark shadow-none" no-caret style="boundary: window">
|
||||
<template #button-content>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</template>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_food')"> <i class="fas fa-leaf fa-fw"></i> {{ Models["FOOD"].name }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')"> <i class="fas fa-tags fa-fw"></i> {{ Models["KEYWORD"].name }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_unit')"> <i class="fas fa-balance-scale fa-fw"></i> {{ Models["UNIT"].name }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')"> <i class="fas fa-store-alt fa-fw"></i> {{ Models["SUPERMARKET"].name }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')"> <i class="fas fa-cubes fa-fw"></i> {{ Models["SHOPPING_CATEGORY"].name }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_automation')"> <i class="fas fa-robot fa-fw"></i> {{ Models["AUTOMATION"].name }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')"> <i class="fas fa-file fa-fw"></i> {{ Models["USERFILE"].name }} </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_step')"> <i class="fas fa-puzzle-piece fa-fw"></i>{{ Models["STEP"].name }} </b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import { Models } from "@/utils/models"
|
||||
import { ResolveUrlMixin } from "@/utils/utils"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "ModelMenu",
|
||||
mixins: [ResolveUrlMixin],
|
||||
data() {
|
||||
return {
|
||||
Models: Models,
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
methods: {
|
||||
gotoURL: function (model) {
|
||||
return
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-dropdown variant="link" toggle-class="text-decoration-none" right no-caret style="boundary:window">
|
||||
<template #button-content>
|
||||
<i class="fas fa-ellipsis-v" ></i>
|
||||
</template>
|
||||
<b-dropdown-item v-on:click="$emit('item-action', 'edit')" v-if="show_edit">
|
||||
<i class="fas fa-pencil-alt fa-fw"></i> {{ $t('Edit') }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item v-on:click="$emit('item-action', 'delete')" v-if="show_delete">
|
||||
<i class="fas fa-trash-alt fa-fw"></i> {{ $t('Delete') }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item v-on:click="$emit('item-action', 'move')" v-if="show_move">
|
||||
<i class="fas fa-expand-arrows-alt fa-fw"></i> {{ $t('Move') }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge')">
|
||||
<i class="fas fa-compress-arrows-alt fa-fw"></i> {{ $t('Merge') }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge-automate')">
|
||||
<i class="fas fa-robot fa-fw"></i> {{$t('Merge')}} & {{$t('Automate')}} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
|
||||
</b-dropdown-item>
|
||||
|
||||
</b-dropdown>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'GenericContextMenu',
|
||||
props: {
|
||||
show_edit: {type: Boolean, default: true},
|
||||
show_delete: {type: Boolean, default: true},
|
||||
show_move: {type: Boolean, default: false},
|
||||
show_merge: {type: Boolean, default: false},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,262 +1,296 @@
|
||||
<template>
|
||||
<div row style="margin: 4px">
|
||||
<!-- @[useDrag&&`dragover`] <== this syntax completely shuts off draggable -->
|
||||
<b-card no-body d-flex flex-column :class="{'border border-primary' : over, 'shake': isError}"
|
||||
:style="{'cursor:grab' : useDrag}"
|
||||
:draggable="useDrag"
|
||||
@[useDrag&&`dragover`].prevent
|
||||
@[useDrag&&`dragenter`].prevent
|
||||
@[useDrag&&`dragstart`]="handleDragStart($event)"
|
||||
@[useDrag&&`dragenter`]="handleDragEnter($event)"
|
||||
@[useDrag&&`dragleave`]="handleDragLeave($event)"
|
||||
@[useDrag&&`drop`]="handleDragDrop($event)">
|
||||
<b-row no-gutters >
|
||||
<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-col>
|
||||
<b-col no-gutters class="col-sm-9">
|
||||
<b-card-body class="m-0 py-0">
|
||||
<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>
|
||||
<div class= "m-0 text-truncate">{{ item[subtitle] }}</div>
|
||||
<!-- <span>{{this_item[itemTags.field]}}</span> -->
|
||||
<generic-pill v-for="x in itemTags" :key="x.field"
|
||||
:item_list="item[x.field]"
|
||||
:label="x.label"
|
||||
:color="x.color"/>
|
||||
<generic-ordered-pill v-for="x in itemOrderedTags" :key="x.field"
|
||||
:item_list="item[x.field]"
|
||||
:label="x.label"
|
||||
:color="x.color"
|
||||
:field="x.field"
|
||||
:item="item"
|
||||
@finish-action="finishAction"/>
|
||||
<div class="mt-auto mb-1" align="right">
|
||||
<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})">
|
||||
<div v-if="!item.show_children">{{ item[child_count] }} {{ itemName }}</div>
|
||||
<div v-else>{{ text.hide_children }}</div>
|
||||
</span>
|
||||
<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})">
|
||||
<div v-if="!item.show_recipes">{{ item[recipe_count] }} {{$t('Recipes')}}</div>
|
||||
<div v-else>{{$t('Hide_Recipes')}}</div>
|
||||
</span>
|
||||
</div>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
</b-col>
|
||||
<div class="card-img-overlay justify-content-right h-25 m-0 p-0 text-right">
|
||||
<badges :item="item" :model="model"/>
|
||||
<generic-context-menu class="p-0"
|
||||
:show_merge="useMerge"
|
||||
:show_move="useMove"
|
||||
@item-action="$emit('item-action', {'action': $event, 'source': item})">
|
||||
</generic-context-menu>
|
||||
<div row style="margin: 4px">
|
||||
<!-- @[useDrag&&`dragover`] <== this syntax completely shuts off draggable -->
|
||||
<b-card
|
||||
no-body
|
||||
d-flex
|
||||
flex-column
|
||||
:class="{ 'border border-primary': over, shake: isError }"
|
||||
:style="{ 'cursor:grab': useDrag }"
|
||||
:draggable="useDrag"
|
||||
@[useDrag&&`dragover`].prevent
|
||||
@[useDrag&&`dragenter`].prevent
|
||||
@[useDrag&&`dragstart`]="handleDragStart($event)"
|
||||
@[useDrag&&`dragenter`]="handleDragEnter($event)"
|
||||
@[useDrag&&`dragleave`]="handleDragLeave($event)"
|
||||
@[useDrag&&`drop`]="handleDragDrop($event)"
|
||||
>
|
||||
<b-row no-gutters>
|
||||
<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-col>
|
||||
<b-col no-gutters class="col-sm-9">
|
||||
<b-card-body class="m-0 py-0">
|
||||
<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>
|
||||
<div class="m-0 text-truncate">{{ item[subtitle] }}</div>
|
||||
<div class="m-0 text-truncate small text-muted" v-if="getFullname">{{ getFullname }}</div>
|
||||
|
||||
<generic-pill v-for="x in itemTags" :key="x.field" :item_list="item[x.field]" :label="x.label" :color="x.color" />
|
||||
<generic-ordered-pill
|
||||
v-for="x in itemOrderedTags"
|
||||
:key="x.field"
|
||||
:item_list="item[x.field]"
|
||||
:label="x.label"
|
||||
:color="x.color"
|
||||
:field="x.field"
|
||||
:item="item"
|
||||
@finish-action="finishAction"
|
||||
/>
|
||||
<div class="mt-auto mb-1" align="right">
|
||||
<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 })">
|
||||
<div v-if="!item.show_children">{{ item[child_count] }} {{ itemName }}</div>
|
||||
<div v-else>{{ text.hide_children }}</div>
|
||||
</span>
|
||||
<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 })">
|
||||
<div v-if="!item.show_recipes">{{ item[recipe_count] }} {{ $t("Recipes") }}</div>
|
||||
<div v-else>{{ $t("Hide_Recipes") }}</div>
|
||||
</span>
|
||||
</div>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
</b-col>
|
||||
<div class="card-img-overlay justify-content-right h-25 m-0 p-0 text-right">
|
||||
<badges :item="item" :model="model" />
|
||||
<generic-context-menu
|
||||
v-if="show_context_menu"
|
||||
class="p-0"
|
||||
:show_merge="useMerge"
|
||||
:show_move="useMove"
|
||||
:show_shopping="useShopping"
|
||||
:show_onhand="useOnhand"
|
||||
@item-action="$emit('item-action', { action: $event, source: item })"
|
||||
>
|
||||
</generic-context-menu>
|
||||
</div>
|
||||
</b-row>
|
||||
</b-card>
|
||||
<!-- recursively add child cards -->
|
||||
<div class="row" v-if="item.show_children">
|
||||
<div class="col-md-10 offset-md-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</b-row>
|
||||
</b-card>
|
||||
<!-- recursively add child cards -->
|
||||
<div class="row" v-if="item.show_children">
|
||||
<div class="col-md-10 offset-md-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- conditionally view recipes -->
|
||||
<div class="row" v-if="item.show_recipes">
|
||||
<div class="col-md-10 offset-md-2">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));grid-gap: 1rem;">
|
||||
<recipe-card v-for="r in item[recipes]"
|
||||
v-bind:key="r.id"
|
||||
:recipe="r">
|
||||
</recipe-card>
|
||||
<!-- conditionally view recipes -->
|
||||
<div class="row" v-if="item.show_recipes">
|
||||
<div class="col-md-10 offset-md-2">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 1rem">
|
||||
<recipe-card v-for="r in item[recipes]" v-bind:key="r.id" :recipe="r"> </recipe-card>
|
||||
</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-->
|
||||
<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()
|
||||
"
|
||||
>
|
||||
<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
|
||||
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
|
||||
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 action v-on:click="closeMenu()">
|
||||
<i class="fas fa-times fa-fw"></i> <b>{{ $t("Cancel") }}</b>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</div>
|
||||
<!-- 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-item 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 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 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 action v-on:click="closeMenu()">
|
||||
<i class="fas fa-times fa-fw"></i> <b>{{$t('Cancel')}}</b>
|
||||
</b-list-group-item>
|
||||
<!-- TODO add to shopping list -->
|
||||
<!-- TODO add to and/or manage pantry -->
|
||||
</b-list-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import GenericContextMenu from "@/components/GenericContextMenu";
|
||||
import Badges from "@/components/Badges";
|
||||
import GenericPill from "@/components/GenericPill";
|
||||
import GenericOrderedPill from "@/components/GenericOrderedPill";
|
||||
import RecipeCard from "@/components/RecipeCard";
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { createPopper } from '@popperjs/core';
|
||||
import GenericContextMenu from "@/components/ContextMenu/GenericContextMenu"
|
||||
import Badges from "@/components/Badges"
|
||||
import GenericPill from "@/components/GenericPill"
|
||||
import GenericOrderedPill from "@/components/GenericOrderedPill"
|
||||
import RecipeCard from "@/components/RecipeCard"
|
||||
import { mixin as clickaway } from "vue-clickaway"
|
||||
import { createPopper } from "@popperjs/core"
|
||||
|
||||
export default {
|
||||
name: "GenericHorizontalCard",
|
||||
components: { GenericContextMenu, RecipeCard, Badges, GenericPill, GenericOrderedPill},
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
item: {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
|
||||
subtitle: {type: String, default: 'description'},
|
||||
child_count: {type: String, default: 'numchild'},
|
||||
children: {type: String, default: 'children'},
|
||||
recipe_count: {type: String, default: 'numrecipe'},
|
||||
recipes: {type: String, default: 'recipes'}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
item_image: '',
|
||||
over: false,
|
||||
show_menu: false,
|
||||
dragMenu: undefined,
|
||||
isError: false,
|
||||
source: {'id': undefined, 'name': undefined},
|
||||
target: {'id': undefined, 'name': undefined},
|
||||
text: {
|
||||
'hide_children': '',
|
||||
},
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.item_image = this.item?.image ?? window.IMAGE_PLACEHOLDER
|
||||
this.dragMenu = this.$refs.tooltip
|
||||
this.text.hide_children = this.$t('Hide_' + this.itemName)
|
||||
},
|
||||
computed: {
|
||||
itemName: function() {
|
||||
return this.model?.name ?? "You Forgot To Set Model Name in model.js"
|
||||
name: "GenericHorizontalCard",
|
||||
components: { GenericContextMenu, RecipeCard, Badges, GenericPill, GenericOrderedPill },
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
item: { 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
|
||||
subtitle: { type: String, default: "description" },
|
||||
child_count: { type: String, default: "numchild" },
|
||||
children: { type: String, default: "children" },
|
||||
recipe_count: { type: String, default: "numrecipe" },
|
||||
recipes: { type: String, default: "recipes" },
|
||||
show_context_menu: { type: Boolean, default: true },
|
||||
},
|
||||
useMove: function() {
|
||||
return (this.model?.['move'] ?? false) ? true : false
|
||||
data() {
|
||||
return {
|
||||
item_image: "",
|
||||
over: false,
|
||||
show_menu: false,
|
||||
dragMenu: undefined,
|
||||
isError: false,
|
||||
source: { id: undefined, name: undefined },
|
||||
target: { id: undefined, name: undefined },
|
||||
text: {
|
||||
hide_children: "",
|
||||
},
|
||||
}
|
||||
},
|
||||
useMerge: function() {
|
||||
return (this.model?.['merge'] ?? false) ? true : false
|
||||
mounted() {
|
||||
this.item_image = this.item?.image ?? window.IMAGE_PLACEHOLDER
|
||||
this.dragMenu = this.$refs.tooltip
|
||||
this.text.hide_children = this.$t("Hide_" + this.itemName)
|
||||
},
|
||||
useDrag: function() {
|
||||
return this.useMove || this.useMerge
|
||||
computed: {
|
||||
itemName: function () {
|
||||
return this.model?.name ?? "You Forgot To Set Model Name in model.js"
|
||||
},
|
||||
useMove: function () {
|
||||
return this.model?.["move"] ?? false ? true : false
|
||||
},
|
||||
useMerge: function () {
|
||||
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 () {
|
||||
return this.useMove || this.useMerge
|
||||
},
|
||||
itemTags: function () {
|
||||
return this.model?.tags ?? []
|
||||
},
|
||||
itemOrderedTags: function () {
|
||||
return this.model?.ordered_tags ?? []
|
||||
},
|
||||
getFullname: function () {
|
||||
if (!this.item?.full_name?.includes(">")) {
|
||||
return undefined
|
||||
}
|
||||
return this.item?.full_name
|
||||
},
|
||||
},
|
||||
itemTags: function() {
|
||||
return this.model?.tags ?? []
|
||||
methods: {
|
||||
handleDragStart: function (e) {
|
||||
this.isError = false
|
||||
e.dataTransfer.setData("source", JSON.stringify(this.item))
|
||||
},
|
||||
handleDragEnter: function (e) {
|
||||
if (!e.currentTarget.contains(e.relatedTarget) && e.relatedTarget != null) {
|
||||
this.over = true
|
||||
}
|
||||
},
|
||||
handleDragLeave: function (e) {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
this.over = false
|
||||
}
|
||||
},
|
||||
handleDragDrop: function (e) {
|
||||
let source = JSON.parse(e.dataTransfer.getData("source"))
|
||||
if (source.id != this.item.id) {
|
||||
this.source = source
|
||||
let menuLocation = { getBoundingClientRect: this.generateLocation(e.clientX, e.clientY) }
|
||||
this.show_menu = true
|
||||
let popper = createPopper(menuLocation, this.dragMenu, {
|
||||
placement: "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
rootBoundary: "document",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flip",
|
||||
options: {
|
||||
fallbackPlacements: ["bottom-end", "top-start", "top-end", "left-start", "right-start"],
|
||||
rootBoundary: "document",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
popper.update()
|
||||
this.over = false
|
||||
this.$emit({ action: "drop", target: this.item, source: this.source })
|
||||
} else {
|
||||
this.isError = true
|
||||
}
|
||||
},
|
||||
generateLocation: function (x = 0, y = 0) {
|
||||
return () => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: y,
|
||||
right: x,
|
||||
bottom: y,
|
||||
left: x,
|
||||
})
|
||||
},
|
||||
closeMenu: function () {
|
||||
this.show_menu = false
|
||||
},
|
||||
finishAction: function (e) {
|
||||
this.$emit("finish-action", e)
|
||||
},
|
||||
},
|
||||
itemOrderedTags: function() {
|
||||
return this.model?.ordered_tags ?? []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleDragStart: function(e) {
|
||||
this.isError = false
|
||||
e.dataTransfer.setData('source', JSON.stringify(this.item))
|
||||
},
|
||||
handleDragEnter: function(e) {
|
||||
if (!e.currentTarget.contains(e.relatedTarget) && e.relatedTarget != null) {
|
||||
this.over = true
|
||||
}
|
||||
},
|
||||
handleDragLeave: function(e) {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
this.over = false
|
||||
}
|
||||
},
|
||||
handleDragDrop: function(e) {
|
||||
let source = JSON.parse(e.dataTransfer.getData('source'))
|
||||
if (source.id != this.item.id){
|
||||
this.source = source
|
||||
let menuLocation = {getBoundingClientRect: this.generateLocation(e.clientX, e.clientY),}
|
||||
this.show_menu = true
|
||||
let popper = createPopper(
|
||||
menuLocation,
|
||||
this.dragMenu,
|
||||
{
|
||||
placement: 'bottom-start',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: {
|
||||
rootBoundary: 'document',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
fallbackPlacements: ['bottom-end', 'top-start', 'top-end', 'left-start', 'right-start'],
|
||||
rootBoundary: 'document',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
popper.update()
|
||||
this.over = false
|
||||
this.$emit({'action': 'drop', 'target': this.item, 'source': this.source})
|
||||
} else {
|
||||
this.isError = true
|
||||
}
|
||||
},
|
||||
generateLocation: function (x = 0, y = 0) {
|
||||
return () => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: y,
|
||||
right: x,
|
||||
bottom: y,
|
||||
left: x,
|
||||
});
|
||||
},
|
||||
closeMenu: function(){
|
||||
this.show_menu = false
|
||||
},
|
||||
finishAction: function(e){
|
||||
this.$emit('finish-action', e)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shake {
|
||||
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%,
|
||||
90% {
|
||||
transform: translate3d(-1px, 0, 0);
|
||||
}
|
||||
10%,
|
||||
90% {
|
||||
transform: translate3d(-1px, 0, 0);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
transform: translate3d(2px, 0, 0);
|
||||
}
|
||||
20%,
|
||||
80% {
|
||||
transform: translate3d(2px, 0, 0);
|
||||
}
|
||||
|
||||
30%,
|
||||
50%,
|
||||
70% {
|
||||
transform: translate3d(-4px, 0, 0);
|
||||
}
|
||||
30%,
|
||||
50%,
|
||||
70% {
|
||||
transform: translate3d(-4px, 0, 0);
|
||||
}
|
||||
|
||||
40%,
|
||||
60% {
|
||||
transform: translate3d(4px, 0, 0);
|
||||
}
|
||||
40%,
|
||||
60% {
|
||||
transform: translate3d(4px, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
<template>
|
||||
<multiselect
|
||||
v-model="selected_objects"
|
||||
:options="objects"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:hide-selected="multiple"
|
||||
:preserve-search="true"
|
||||
:placeholder="lookupPlaceholder"
|
||||
:label="label"
|
||||
track-by="id"
|
||||
:multiple="multiple"
|
||||
:taggable="allow_create"
|
||||
:tag-placeholder="create_placeholder"
|
||||
:loading="loading"
|
||||
@search-change="search"
|
||||
@input="selectionChanged"
|
||||
@tag="addNew">
|
||||
</multiselect>
|
||||
<multiselect
|
||||
v-model="selected_objects"
|
||||
:options="objects"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:hide-selected="multiple"
|
||||
:preserve-search="true"
|
||||
:placeholder="lookupPlaceholder"
|
||||
:label="label"
|
||||
track-by="id"
|
||||
:multiple="multiple"
|
||||
:taggable="allow_create"
|
||||
:tag-placeholder="create_placeholder"
|
||||
:loading="loading"
|
||||
@search-change="search"
|
||||
@input="selectionChanged"
|
||||
@tag="addNew"
|
||||
>
|
||||
</multiselect>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Multiselect from 'vue-multiselect'
|
||||
import {ApiMixin} from "@/utils/utils";
|
||||
import Multiselect from "vue-multiselect"
|
||||
import { ApiMixin } from "@/utils/utils"
|
||||
|
||||
export default {
|
||||
name: "GenericMultiselect",
|
||||
components: {Multiselect},
|
||||
mixins: [ApiMixin],
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
loading: false,
|
||||
objects: [],
|
||||
selected_objects: [],
|
||||
}
|
||||
},
|
||||
props: {
|
||||
placeholder: {type: String, default: undefined},
|
||||
model: {
|
||||
type: Object, default() {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
label: {type: String, default: 'name'},
|
||||
parent_variable: {type: String, default: undefined},
|
||||
limit: {type: Number, default: 10,},
|
||||
sticky_options: {
|
||||
type: Array, default() {
|
||||
return []
|
||||
}
|
||||
},
|
||||
initial_selection: {
|
||||
type: Array, default() {
|
||||
return []
|
||||
}
|
||||
},
|
||||
multiple: {type: Boolean, default: true},
|
||||
allow_create: {type: Boolean, default: false}, // TODO: this will create option to add new drop-downs
|
||||
create_placeholder: {type: String, default: 'You Forgot to Add a Tag Placeholder'},
|
||||
},
|
||||
watch: {
|
||||
initial_selection: function (newVal, oldVal) { // watch it
|
||||
this.selected_objects = newVal
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.search('')
|
||||
this.selected_objects = this.initial_selection
|
||||
},
|
||||
computed: {
|
||||
lookupPlaceholder() {
|
||||
return this.placeholder || this.model.name || this.$t('Search')
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
search: function (query) {
|
||||
let options = {
|
||||
'page': 1,
|
||||
'pageSize': 10,
|
||||
'query': query
|
||||
}
|
||||
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
|
||||
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
|
||||
if (this.selected_objects.length === 0 && this.initial_selection.length === 0 && this.objects.length > 0) {
|
||||
this.objects.forEach((item) => {
|
||||
if ("default" in item) {
|
||||
if (item.default) {
|
||||
if(this.multiple) {
|
||||
this.selected_objects = [item]
|
||||
} else {
|
||||
this.selected_objects = item
|
||||
}
|
||||
this.selectionChanged()
|
||||
}
|
||||
}
|
||||
})
|
||||
name: "GenericMultiselect",
|
||||
components: { Multiselect },
|
||||
mixins: [ApiMixin],
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
loading: false,
|
||||
objects: [],
|
||||
selected_objects: [],
|
||||
}
|
||||
})
|
||||
},
|
||||
selectionChanged: function () {
|
||||
this.$emit('change', {var: this.parent_variable, val: this.selected_objects})
|
||||
|
||||
props: {
|
||||
placeholder: { type: String, default: undefined },
|
||||
model: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
label: { type: String, default: "name" },
|
||||
parent_variable: { type: String, default: undefined },
|
||||
limit: { type: Number, default: 10 },
|
||||
sticky_options: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
initial_selection: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
multiple: { type: Boolean, default: true },
|
||||
allow_create: { type: Boolean, default: false },
|
||||
create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" },
|
||||
},
|
||||
watch: {
|
||||
initial_selection: function (newVal, oldVal) {
|
||||
// watch it
|
||||
this.selected_objects = newVal
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.search("")
|
||||
this.selected_objects = this.initial_selection
|
||||
},
|
||||
computed: {
|
||||
lookupPlaceholder() {
|
||||
return this.placeholder || this.model.name || this.$t("Search")
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
search: function (query) {
|
||||
let options = {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
query: query,
|
||||
}
|
||||
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
|
||||
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
|
||||
if (this.selected_objects.length === 0 && this.initial_selection.length === 0 && this.objects.length > 0) {
|
||||
this.objects.forEach((item) => {
|
||||
if ("default" in item) {
|
||||
if (item.default) {
|
||||
if (this.multiple) {
|
||||
this.selected_objects = [item]
|
||||
} else {
|
||||
this.selected_objects = item
|
||||
}
|
||||
this.selectionChanged()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
selectionChanged: function () {
|
||||
this.$emit("change", { var: this.parent_variable, val: this.selected_objects })
|
||||
},
|
||||
addNew(e) {
|
||||
this.$emit("new", e)
|
||||
// could refactor as Promise - seems unecessary
|
||||
setTimeout(() => {
|
||||
this.search("")
|
||||
}, 750)
|
||||
},
|
||||
},
|
||||
addNew(e) {
|
||||
this.$emit('new', e)
|
||||
// could refactor as Promise - seems unecessary
|
||||
setTimeout(() => {
|
||||
this.search('');
|
||||
}, 750);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,72 +1,75 @@
|
||||
|
||||
<template>
|
||||
<draggable v-if="itemList" v-model="this_list" tag="span" group="ordered_items" z-index="500"
|
||||
@change="orderChanged">
|
||||
<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>
|
||||
</span>
|
||||
<draggable v-if="itemList" v-model="this_list" tag="span" group="ordered_items" z-index="500" @change="orderChanged">
|
||||
<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
|
||||
>
|
||||
</span>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
// you can't use this component with a horizontal card that is also draggable
|
||||
import draggable from 'vuedraggable'
|
||||
import draggable from "vuedraggable"
|
||||
|
||||
export default {
|
||||
name: 'GenericOrderedPill',
|
||||
components: {draggable},
|
||||
props: {
|
||||
item_list: {required: true, type: Array},
|
||||
label: {type: String, default: 'name'},
|
||||
color: {type: String, default: 'light'},
|
||||
field: {type: String, required: true},
|
||||
item: {type: Object},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
this_list: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
itemList: function() {
|
||||
if(Array.isArray(this.this_list)) {
|
||||
return this.this_list
|
||||
} else if (!this.this_list?.name) {
|
||||
return false
|
||||
} else {
|
||||
return [this.this_list]
|
||||
}
|
||||
name: "GenericOrderedPill",
|
||||
components: { draggable },
|
||||
props: {
|
||||
item_list: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
label: { type: String, default: "name" },
|
||||
color: { type: String, default: "light" },
|
||||
field: { type: String, required: true },
|
||||
item: { type: Object },
|
||||
},
|
||||
|
||||
},
|
||||
mounted() {
|
||||
this.this_list = this.item_list
|
||||
},
|
||||
watch: {
|
||||
'item_list': function (newVal) {
|
||||
this.this_list = newVal
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
thisLabel: function (item) {
|
||||
let fields = this.label.split('::')
|
||||
let value = item
|
||||
fields.forEach(x => {
|
||||
value = value[x]
|
||||
});
|
||||
return value
|
||||
data() {
|
||||
return {
|
||||
this_list: [],
|
||||
}
|
||||
},
|
||||
orderChanged: function(e){
|
||||
let order = 0
|
||||
this.this_list.forEach(x => {
|
||||
x['order'] = order
|
||||
order++
|
||||
})
|
||||
let new_order = {...this.item}
|
||||
new_order[this.field] = this.this_list
|
||||
this.$emit('finish-action', {'action':'save','form_data': new_order })
|
||||
computed: {
|
||||
itemList: function() {
|
||||
if (Array.isArray(this.this_list)) {
|
||||
return this.this_list
|
||||
} else if (!this.this_list?.name) {
|
||||
return false
|
||||
} else {
|
||||
return [this.this_list]
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.this_list = this.item_list
|
||||
},
|
||||
watch: {
|
||||
item_list: function(newVal) {
|
||||
this.this_list = newVal
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
thisLabel: function(item) {
|
||||
let fields = this.label.split("::")
|
||||
let value = item
|
||||
fields.forEach((x) => {
|
||||
value = value[x]
|
||||
})
|
||||
return value
|
||||
},
|
||||
orderChanged: function(e) {
|
||||
let order = 0
|
||||
this.this_list.forEach((x) => {
|
||||
x["order"] = order
|
||||
order++
|
||||
})
|
||||
let new_order = { ...this.item }
|
||||
new_order[this.field] = this.this_list
|
||||
this.$emit("finish-action", { action: "save", form_data: new_order })
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -10,12 +10,7 @@
|
||||
export default {
|
||||
name: "GenericPill",
|
||||
props: {
|
||||
item_list: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
item_list: { type: Object },
|
||||
label: { type: String, default: "name" },
|
||||
color: { type: String, default: "light" },
|
||||
},
|
||||
|
||||
@@ -1,88 +1,203 @@
|
||||
<template>
|
||||
|
||||
<tr @click="$emit('checked-state-changed', ingredient)">
|
||||
<template v-if="ingredient.is_header">
|
||||
<td colspan="5">
|
||||
<b>{{ ingredient.note }}</b>
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td class="d-print-non" v-if="detailed">
|
||||
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
||||
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="ingredient.food !== null">
|
||||
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null"
|
||||
target="_blank" rel="noopener noreferrer">{{ ingredient.food.name }}</a>
|
||||
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
|
||||
<tr>
|
||||
<template v-if="ingredient.is_header">
|
||||
<td colspan="5" @click="done">
|
||||
<b>{{ ingredient.note }}</b>
|
||||
</td>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="detailed">
|
||||
<div v-if="ingredient.note">
|
||||
<span v-b-popover.hover="ingredient.note"
|
||||
class="d-print-none touchable"> <i class="far fa-comment"></i>
|
||||
</span>
|
||||
<!-- v-if="ingredient.note.length > 15" -->
|
||||
<!-- <span v-else>-->
|
||||
<!-- {{ ingredient.note }}-->
|
||||
<!-- </span>-->
|
||||
|
||||
<div class="d-none d-print-block">
|
||||
<i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<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-primary" v-if="!ingredient.checked"></i>
|
||||
</td>
|
||||
<td class="text-nowrap" @click="done">
|
||||
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
|
||||
</td>
|
||||
<td @click="done">
|
||||
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
|
||||
</td>
|
||||
<td @click="done">
|
||||
<template v-if="ingredient.food !== null">
|
||||
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null" target="_blank" rel="noopener noreferrer">{{ ingredient.food.name }}</a>
|
||||
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="detailed && !show_shopping">
|
||||
<div v-if="ingredient.note">
|
||||
<span v-b-popover.hover="ingredient.note" class="d-print-none touchable">
|
||||
<i class="far fa-comment"></i>
|
||||
</span>
|
||||
|
||||
<div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td v-else-if="show_shopping" class="text-right text-nowrap">
|
||||
<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>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils";
|
||||
import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils"
|
||||
import OnHandBadge from "@/components/Badges/OnHand"
|
||||
|
||||
export default {
|
||||
name: 'IngredientComponent',
|
||||
props: {
|
||||
ingredient: Object,
|
||||
ingredient_factor: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
name: "IngredientComponent",
|
||||
components: { OnHandBadge },
|
||||
props: {
|
||||
ingredient: Object,
|
||||
ingredient_factor: { type: Number, default: 1 },
|
||||
detailed: { type: Boolean, default: true },
|
||||
recipe_list: { type: Number }, // ShoppingListRecipe ID, to filter ShoppingStatus
|
||||
show_shopping: { type: Boolean, default: false },
|
||||
add_shopping_mode: { type: Boolean, default: false },
|
||||
shopping_list: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
}, // list of unchecked ingredients in shopping list
|
||||
},
|
||||
mixins: [ResolveUrlMixin, ApiMixin],
|
||||
data() {
|
||||
return {
|
||||
checked: false,
|
||||
shopping_status: null, // 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: {
|
||||
calculateAmount: function (x) {
|
||||
return calculateAmount(x, this.ingredient_factor)
|
||||
},
|
||||
// sends parent recipe ingredient to notify complete has been toggled
|
||||
done: function () {
|
||||
this.$emit("checked-state-changed", this.ingredient)
|
||||
},
|
||||
// sends true/false to parent to save all ingredient shopping updates as a batch
|
||||
changeShopping: function () {
|
||||
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
|
||||
},
|
||||
},
|
||||
detailed: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
mixins: [
|
||||
ResolveUrlMixin
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
checked: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
calculateAmount: function (x) {
|
||||
return calculateAmount(x, this.ingredient_factor)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* increase size of hover/touchable space without changing spacing */
|
||||
.touchable {
|
||||
padding-right: 2em;
|
||||
padding-left: 2em;
|
||||
margin-right: -2em;
|
||||
margin-left: -2em;
|
||||
padding-right: 2em;
|
||||
padding-left: 2em;
|
||||
margin-right: -2em;
|
||||
margin-left: -2em;
|
||||
}
|
||||
</style>
|
||||
|
||||
187
vue/src/components/IngredientsCard.vue
Normal file
187
vue/src/components/IngredientsCard.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div :class="{ 'card border-primary no-border': header }">
|
||||
<div :class="{ 'card-body': header }">
|
||||
<div class="row" v-if="header">
|
||||
<div class="col col-md-6">
|
||||
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h4>
|
||||
</div>
|
||||
<div class="col col-md-6 text-right" v-if="header">
|
||||
<h4>
|
||||
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2" @click="saveShopping(true)"></i>
|
||||
<i v-if="show_shopping" class="fas fa-save text-success px-2" @click="saveShopping()"></i>
|
||||
<i class="fas fa-shopping-cart px-2" @click="getShopping()"></i>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
|
||||
<div class="col col-md-6 offset-md-6 text-right">
|
||||
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
|
||||
</div>
|
||||
</div>
|
||||
<br v-if="header" />
|
||||
<div class="row no-gutter">
|
||||
<div class="col-md-12">
|
||||
<table class="table table-sm">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="s in steps">
|
||||
<template v-for="i in s.ingredients">
|
||||
<ingredient-component
|
||||
:ingredient="i"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:key="i.id"
|
||||
:show_shopping="show_shopping"
|
||||
:shopping_list="shopping_list"
|
||||
:add_shopping_mode="add_shopping_mode"
|
||||
:detailed="detailed"
|
||||
:recipe_list="selected_shoppingrecipe"
|
||||
@checked-state-changed="$emit('checked-state-changed', $event)"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import IngredientComponent from "@/components/IngredientComponent"
|
||||
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "IngredientCard",
|
||||
mixins: [ApiMixin],
|
||||
components: { IngredientComponent },
|
||||
props: {
|
||||
steps: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
recipe: { type: Number },
|
||||
ingredient_factor: { type: Number, default: 1 },
|
||||
servings: { type: Number, default: 1 },
|
||||
detailed: { type: Boolean, default: true },
|
||||
header: { type: Boolean, default: false },
|
||||
add_shopping_mode: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show_shopping: false,
|
||||
shopping_list: [],
|
||||
update_shopping: [],
|
||||
selected_shoppingrecipe: undefined,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
ShoppingRecipes() {
|
||||
// returns open shopping lists associated with this recipe
|
||||
let recipe_in_list = this.shopping_list
|
||||
.map((x) => {
|
||||
return { value: x?.list_recipe, text: x?.recipe_mealplan?.name, recipe: x?.recipe_mealplan?.recipe ?? 0, servings: x?.recipe_mealplan?.servings }
|
||||
})
|
||||
.filter((x) => x?.recipe == this.recipe)
|
||||
return [...new Map(recipe_in_list.map((x) => [x["value"], x])).values()] // filter to unique lists
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
ShoppingRecipes: function (newVal, oldVal) {
|
||||
if (newVal.length === 0 || this.add_shopping_mode) {
|
||||
this.selected_shoppingrecipe = undefined
|
||||
} else if (newVal.length === 1) {
|
||||
this.selected_shoppingrecipe = newVal[0].value
|
||||
}
|
||||
},
|
||||
selected_shoppingrecipe: function (newVal, oldVal) {
|
||||
this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient)
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.add_shopping_mode) {
|
||||
this.show_shopping = true
|
||||
this.getShopping(false)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getShopping: function (toggle_shopping = true) {
|
||||
if (toggle_shopping) {
|
||||
this.show_shopping = !this.show_shopping
|
||||
}
|
||||
|
||||
if (this.show_shopping) {
|
||||
let ingredient_list = this.steps
|
||||
.map((x) => x.ingredients)
|
||||
.flat()
|
||||
.map((x) => x.food.id)
|
||||
|
||||
let params = {
|
||||
id: ingredient_list,
|
||||
checked: "false",
|
||||
}
|
||||
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => {
|
||||
this.shopping_list = result.data
|
||||
})
|
||||
}
|
||||
},
|
||||
saveShopping: function (del_shopping = false) {
|
||||
let servings = this.servings
|
||||
if (del_shopping) {
|
||||
servings = 0
|
||||
}
|
||||
let params = {
|
||||
id: this.recipe,
|
||||
list_recipe: this.selected_shoppingrecipe,
|
||||
ingredients: this.update_shopping,
|
||||
servings: servings,
|
||||
}
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params)
|
||||
.then(() => {
|
||||
if (del_shopping) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
} else if (this.selected_shoppingrecipe) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
} else {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.add_shopping_mode) {
|
||||
return this.getShopping(false)
|
||||
} else {
|
||||
this.$emit("shopping-added")
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (del_shopping) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
} else if (this.selected_shoppingrecipe) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
} else {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
}
|
||||
this.$emit("shopping-failed")
|
||||
})
|
||||
},
|
||||
addShopping: function (e) {
|
||||
// ALERT: this will all break if ingredients are re-used between recipes
|
||||
if (e.add) {
|
||||
this.update_shopping.push(e.item.id)
|
||||
} else {
|
||||
this.update_shopping = this.update_shopping.filter((x) => x !== e.item.id)
|
||||
}
|
||||
if (this.add_shopping_mode) {
|
||||
this.$emit("add-to-shopping", e)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,128 +1,132 @@
|
||||
<template>
|
||||
<div v-hover class="card cv-item meal-plan-card p-0" :key="value.id" :draggable="true"
|
||||
:style="`top:${top};max-height:${item_height}`"
|
||||
@dragstart="onDragItemStart(value, $event)"
|
||||
@click="onClickItem(value, $event)"
|
||||
:aria-grabbed="value == currentDragItem"
|
||||
:class="value.classes"
|
||||
@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}`">
|
||||
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null">{{
|
||||
entry.entry.meal_type.icon
|
||||
}}</span>
|
||||
<span class="font-light d-none d-md-inline">{{ entry.entry.meal_type.name }}</span>
|
||||
</div>
|
||||
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right p-0"
|
||||
v-if="detailed">
|
||||
<a>
|
||||
<div style="position: static;">
|
||||
<div class="dropdown b-dropdown position-static btn-group">
|
||||
<button aria-haspopup="true" aria-expanded="false" type="button"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-hover
|
||||
class="card cv-item meal-plan-card p-0"
|
||||
:key="value.id"
|
||||
:draggable="true"
|
||||
:style="`top:${top};max-height:${item_height}`"
|
||||
@dragstart="onDragItemStart(value, $event)"
|
||||
@click="onClickItem(value, $event)"
|
||||
:aria-grabbed="value == currentDragItem"
|
||||
:class="value.classes"
|
||||
@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}`">
|
||||
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null">{{ entry.entry.meal_type.icon }}</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 class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right p-0" v-if="detailed">
|
||||
<a>
|
||||
<div style="position: static">
|
||||
<div class="dropdown b-dropdown position-static btn-group">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-header p-1 text-center" v-if="detailed" :style="`background-color: ${background_color}`">
|
||||
<span class="font-light">{{ title }}</span>
|
||||
</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="image_placeholder" 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}`">
|
||||
<p>{{ entry.entry.note }}</p>
|
||||
</div>
|
||||
<div class="row p-1 flex-nowrap" v-if="!detailed" :style="`background-color: ${background_color}`">
|
||||
<div class="col-2">
|
||||
<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>
|
||||
<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 class="col-10 d-inline-block text-truncate" :style="`max-height:${item_height}`">
|
||||
<span class="font-light">{{ title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-header p-1 text-center" v-if="detailed" :style="`background-color: ${background_color}`">
|
||||
<span class="font-light">{{ title }}</span>
|
||||
</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="image_placeholder"
|
||||
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}`">
|
||||
<p>{{ entry.entry.note }}</p>
|
||||
</div>
|
||||
<div class="row p-1 flex-nowrap" v-if="!detailed" :style="`background-color: ${background_color}`">
|
||||
<div class="col-2">
|
||||
<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>
|
||||
<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 class="col-10 d-inline-block text-truncate" :style="`max-height:${item_height}`">
|
||||
<span class="font-light">{{ title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "MealPlanCard.vue",
|
||||
components: {},
|
||||
props: {
|
||||
value: Object,
|
||||
weekStartDate: Date,
|
||||
top: String,
|
||||
detailed: Boolean,
|
||||
item_height: String
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
dateSelectionOrigin: null,
|
||||
currentDragItem: null,
|
||||
image_placeholder: window.IMAGE_PLACEHOLDER
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
entry: function () {
|
||||
return this.value.originalItem
|
||||
name: "MealPlanCard.vue",
|
||||
components: {},
|
||||
props: {
|
||||
value: Object,
|
||||
weekStartDate: Date,
|
||||
top: String,
|
||||
detailed: Boolean,
|
||||
item_height: String,
|
||||
},
|
||||
title: function () {
|
||||
if (this.entry.entry.title != null && this.entry.entry.title !== '') {
|
||||
return this.entry.entry.title
|
||||
} else {
|
||||
return this.entry.entry.recipe_name
|
||||
}
|
||||
data: function () {
|
||||
return {
|
||||
dateSelectionOrigin: null,
|
||||
currentDragItem: null,
|
||||
image_placeholder: window.IMAGE_PLACEHOLDER,
|
||||
}
|
||||
},
|
||||
hasRecipe: function () {
|
||||
return this.entry.entry.recipe != null;
|
||||
mounted() {
|
||||
console.log(this.value)
|
||||
},
|
||||
background_color: function () {
|
||||
if (this.entry.entry.meal_type.color != null && this.entry.entry.meal_type.color !== '') {
|
||||
return this.entry.entry.meal_type.color
|
||||
} else {
|
||||
return "#fff"
|
||||
}
|
||||
computed: {
|
||||
entry: function () {
|
||||
return this.value.originalItem
|
||||
},
|
||||
title: function () {
|
||||
if (this.entry.entry.title != null && this.entry.entry.title !== "") {
|
||||
return this.entry.entry.title
|
||||
} else {
|
||||
return this.entry.entry.recipe_name
|
||||
}
|
||||
},
|
||||
hasRecipe: function () {
|
||||
return this.entry.entry.recipe != null
|
||||
},
|
||||
background_color: function () {
|
||||
if (this.entry.entry.meal_type.color != null && this.entry.entry.meal_type.color !== "") {
|
||||
return this.entry.entry.meal_type.color
|
||||
} else {
|
||||
return "#fff"
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onDragItemStart(calendarItem, windowEvent) {
|
||||
this.$emit("dragstart", calendarItem, windowEvent)
|
||||
return true
|
||||
methods: {
|
||||
onDragItemStart(calendarItem, windowEvent) {
|
||||
this.$emit("dragstart", calendarItem, windowEvent)
|
||||
return true
|
||||
},
|
||||
onContextMenuOpen(calendarItem, windowEvent) {
|
||||
this.$emit("dragstart", calendarItem, windowEvent)
|
||||
return true
|
||||
},
|
||||
onClickItem(calendarItem, windowEvent) {
|
||||
this.$emit("click-item", calendarItem)
|
||||
return true
|
||||
},
|
||||
},
|
||||
onContextMenuOpen(calendarItem, windowEvent) {
|
||||
this.$emit("dragstart", calendarItem, windowEvent)
|
||||
return true
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: (el) => {
|
||||
el.addEventListener("mouseenter", () => {
|
||||
el.classList.add("shadow")
|
||||
})
|
||||
el.addEventListener("mouseleave", () => {
|
||||
el.classList.remove("shadow")
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
onClickItem(calendarItem, windowEvent) {
|
||||
this.$emit("click-item", calendarItem)
|
||||
return true
|
||||
},
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: (el) => {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
el.classList.add("shadow")
|
||||
});
|
||||
el.addEventListener('mouseleave', () => {
|
||||
el.classList.remove("shadow")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.meal-plan-card {
|
||||
background-color: #fff;
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,216 +1,236 @@
|
||||
<template>
|
||||
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
|
||||
<div class="row">
|
||||
<div class="col-6 col-lg-9">
|
||||
<b-input-group>
|
||||
<b-form-input id="TitleInput" v-model="entryEditing.title"
|
||||
:placeholder="entryEditing.title_placeholder"
|
||||
@change="missing_recipe = false"></b-form-input>
|
||||
<b-input-group-append class="d-none d-lg-block">
|
||||
<b-button variant="primary" @click="entryEditing.title = ''"><i class="fa fa-eraser"></i></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<span class="text-danger" v-if="missing_recipe">{{ $t('Title_or_Recipe_Required') }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{ $t("Title") }}</small>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date">
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
||||
</div>
|
||||
<div class="col col-md-12">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="row">
|
||||
<div class="col-6 col-lg-9">
|
||||
<b-input-group>
|
||||
<b-form-input id="TitleInput" v-model="entryEditing.title" :placeholder="entryEditing.title_placeholder" @change="missing_recipe = false"></b-form-input>
|
||||
<b-input-group-append class="d-none d-lg-block">
|
||||
<b-button variant="primary" @click="entryEditing.title = ''"><i class="fa fa-eraser"></i></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<span class="text-danger" v-if="missing_recipe">{{ $t("Title_or_Recipe_Required") }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{ $t("Title") }}</small>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date" />
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12 col-lg-6 col-xl-6">
|
||||
<b-form-group>
|
||||
<generic-multiselect
|
||||
@change="selectRecipe"
|
||||
:initial_selection="entryEditing_initial_recipe"
|
||||
:label="'name'"
|
||||
:model="Models.RECIPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Recipe')"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect
|
||||
required
|
||||
@change="selectMealType"
|
||||
:label="'name'"
|
||||
:model="Models.MEAL_TYPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Meal_Type')"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
:initial_selection="entryEditing_initial_meal_type"
|
||||
:allow_create="true"
|
||||
:create_placeholder="$t('Create_New_Meal_Type')"
|
||||
@new="createMealType"
|
||||
></generic-multiselect>
|
||||
<span class="text-danger" v-if="missing_meal_type">{{ $t("Meal_Type_Required") }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{ $t("Meal_Type") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group label-for="NoteInput" :description="$t('Note')" class="mt-3">
|
||||
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note" :placeholder="$t('Note')"></textarea>
|
||||
</b-form-group>
|
||||
<b-input-group>
|
||||
<b-form-input id="ServingsInput" v-model="entryEditing.servings" :placeholder="$t('Servings')"></b-form-input>
|
||||
</b-input-group>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect
|
||||
required
|
||||
@change="entryEditing.shared = $event.val"
|
||||
parent_variable="entryEditing.shared"
|
||||
:label="'username'"
|
||||
:model="Models.USER_NAME"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Share')"
|
||||
:limit="10"
|
||||
:multiple="true"
|
||||
:initial_selection="entryEditing.shared"
|
||||
></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
|
||||
</b-form-group>
|
||||
<b-input-group v-if="!autoMealPlan">
|
||||
<b-form-checkbox id="AddToShopping" v-model="entryEditing.addshopping" />
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small>
|
||||
</b-input-group>
|
||||
</div>
|
||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
||||
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null" :detailed="false"></recipe-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12">
|
||||
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t("Delete") }} </b-button>
|
||||
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t("Save") }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12 col-lg-6 col-xl-6">
|
||||
<b-form-group>
|
||||
<generic-multiselect
|
||||
@change="selectRecipe"
|
||||
:initial_selection="entryEditing_initial_recipe"
|
||||
:label="'name'"
|
||||
:model="Models.RECIPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Recipe')" :limit="10"
|
||||
:multiple="false"></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect required
|
||||
@change="selectMealType"
|
||||
:label="'name'"
|
||||
:model="Models.MEAL_TYPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Meal_Type')" :limit="10"
|
||||
:multiple="false"
|
||||
:initial_selection="entryEditing_initial_meal_type"
|
||||
:allow_create="true"
|
||||
:create_placeholder="$t('Create_New_Meal_Type')"
|
||||
@new="createMealType"
|
||||
></generic-multiselect>
|
||||
<span class="text-danger" v-if="missing_meal_type">{{ $t('Meal_Type_Required') }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{ $t("Meal_Type") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group
|
||||
label-for="NoteInput"
|
||||
:description="$t('Note')" class="mt-3">
|
||||
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note"
|
||||
:placeholder="$t('Note')"></textarea>
|
||||
</b-form-group>
|
||||
<b-input-group>
|
||||
<b-form-input id="ServingsInput" v-model="entryEditing.servings"
|
||||
:placeholder="$t('Servings')"></b-form-input>
|
||||
</b-input-group>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect required
|
||||
@change="entryEditing.shared = $event.val" parent_variable="entryEditing.shared"
|
||||
:label="'username'"
|
||||
:model="Models.USER_NAME"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Share')" :limit="10"
|
||||
:multiple="true"
|
||||
:initial_selection="entryEditing.shared"
|
||||
></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
|
||||
</b-form-group>
|
||||
</div>
|
||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
||||
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12">
|
||||
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t('Delete') }}
|
||||
</b-button>
|
||||
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t('Save') }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import {BootstrapVue} from "bootstrap-vue";
|
||||
import GenericMultiselect from "./GenericMultiselect";
|
||||
import {ApiMixin} from "../utils/utils";
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
import { ApiMixin, getUserPreference } from "@/utils/utils"
|
||||
|
||||
const {ApiApiFactory} = require("@/utils/openapi/api");
|
||||
const { ApiApiFactory } = require("@/utils/openapi/api")
|
||||
|
||||
const {StandardToasts} = require("@/utils/utils");
|
||||
const { StandardToasts } = require("@/utils/utils")
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "MealPlanEditModal",
|
||||
props: {
|
||||
entry: Object,
|
||||
entryEditing_initial_recipe: Array,
|
||||
entryEditing_initial_meal_type: Array,
|
||||
modal_title: String,
|
||||
modal_id: {
|
||||
type: String,
|
||||
default: "edit-modal"
|
||||
name: "MealPlanEditModal",
|
||||
props: {
|
||||
entry: Object,
|
||||
entryEditing_initial_recipe: Array,
|
||||
entryEditing_initial_meal_type: Array,
|
||||
entryEditing_inital_servings: Number,
|
||||
modal_title: String,
|
||||
modal_id: {
|
||||
type: String,
|
||||
default: "edit-modal",
|
||||
},
|
||||
allow_delete: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
allow_delete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
mixins: [ApiMixin],
|
||||
components: {
|
||||
GenericMultiselect,
|
||||
RecipeCard: () => import('@/components/RecipeCard.vue')
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
entryEditing: {},
|
||||
missing_recipe: false,
|
||||
missing_meal_type: false,
|
||||
default_plan_share: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
entry: {
|
||||
handler() {
|
||||
this.entryEditing = Object.assign({}, this.entry)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showModal() {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.listUserPreferences().then(result => {
|
||||
if (this.entry.id === -1) {
|
||||
this.entryEditing.shared = result.data[0].plan_share
|
||||
mixins: [ApiMixin],
|
||||
components: {
|
||||
GenericMultiselect,
|
||||
RecipeCard: () => import("@/components/RecipeCard.vue"),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
entryEditing: {},
|
||||
missing_recipe: false,
|
||||
missing_meal_type: false,
|
||||
default_plan_share: [],
|
||||
}
|
||||
})
|
||||
},
|
||||
editEntry() {
|
||||
this.missing_meal_type = false
|
||||
this.missing_recipe = false
|
||||
let cancel = false
|
||||
if (this.entryEditing.meal_type == null) {
|
||||
this.missing_meal_type = true
|
||||
cancel = true
|
||||
}
|
||||
if (this.entryEditing.recipe == null && this.entryEditing.title === '') {
|
||||
this.missing_recipe = true
|
||||
cancel = true
|
||||
}
|
||||
if (!cancel) {
|
||||
this.$bvModal.hide(`edit-modal`);
|
||||
this.$emit('save-entry', this.entryEditing)
|
||||
}
|
||||
watch: {
|
||||
entry: {
|
||||
handler() {
|
||||
this.entryEditing = Object.assign({}, this.entry)
|
||||
if (this.entryEditing_inital_servings) {
|
||||
this.entryEditing.servings = this.entryEditing_inital_servings
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
deleteEntry() {
|
||||
this.$bvModal.hide(`edit-modal`);
|
||||
this.$emit('delete-entry', this.entryEditing)
|
||||
mounted: function () {},
|
||||
computed: {
|
||||
autoMealPlan: function () {
|
||||
return getUserPreference("mealplan_autoadd_shopping")
|
||||
},
|
||||
},
|
||||
selectMealType(event) {
|
||||
this.missing_meal_type = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.meal_type = event.val;
|
||||
} else {
|
||||
this.entryEditing.meal_type = null;
|
||||
}
|
||||
},
|
||||
selectShared(event) {
|
||||
if (event.val != null) {
|
||||
this.entryEditing.shared = event.val;
|
||||
} else {
|
||||
this.entryEditing.meal_type = null;
|
||||
}
|
||||
},
|
||||
createMealType(event) {
|
||||
if (event != "") {
|
||||
let apiClient = new ApiApiFactory()
|
||||
methods: {
|
||||
showModal() {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.createMealType({name: event}).then(e => {
|
||||
this.$emit('reload-meal-types')
|
||||
}).catch(error => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
apiClient.listUserPreferences().then((result) => {
|
||||
if (this.entry.id === -1) {
|
||||
this.entryEditing.shared = result.data[0].plan_share
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
editEntry() {
|
||||
this.missing_meal_type = false
|
||||
this.missing_recipe = false
|
||||
let cancel = false
|
||||
if (this.entryEditing.meal_type == null) {
|
||||
this.missing_meal_type = true
|
||||
cancel = true
|
||||
}
|
||||
if (this.entryEditing.recipe == null && this.entryEditing.title === "") {
|
||||
this.missing_recipe = true
|
||||
cancel = true
|
||||
}
|
||||
if (!cancel) {
|
||||
this.$bvModal.hide(`edit-modal`)
|
||||
this.$emit("save-entry", this.entryEditing)
|
||||
}
|
||||
},
|
||||
deleteEntry() {
|
||||
this.$bvModal.hide(`edit-modal`)
|
||||
this.$emit("delete-entry", this.entryEditing)
|
||||
},
|
||||
selectMealType(event) {
|
||||
this.missing_meal_type = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.meal_type = event.val
|
||||
} else {
|
||||
this.entryEditing.meal_type = null
|
||||
}
|
||||
},
|
||||
selectShared(event) {
|
||||
if (event.val != null) {
|
||||
this.entryEditing.shared = event.val
|
||||
} else {
|
||||
this.entryEditing.meal_type = null
|
||||
}
|
||||
},
|
||||
createMealType(event) {
|
||||
if (event != "") {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient
|
||||
.createMealType({ name: event })
|
||||
.then((e) => {
|
||||
this.$emit("reload-meal-types")
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectRecipe(event) {
|
||||
this.missing_recipe = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.recipe = event.val
|
||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||
this.entryEditing.servings = this.entryEditing.recipe.servings
|
||||
} else {
|
||||
this.entryEditing.recipe = null
|
||||
this.entryEditing.title_placeholder = ""
|
||||
this.entryEditing.servings = 1
|
||||
}
|
||||
},
|
||||
},
|
||||
selectRecipe(event) {
|
||||
this.missing_recipe = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.recipe = event.val;
|
||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||
this.entryEditing.servings = this.entryEditing.recipe.servings
|
||||
} else {
|
||||
this.entryEditing.recipe = null;
|
||||
this.entryEditing.title_placeholder = ""
|
||||
this.entryEditing.servings = 1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
122
vue/src/components/Modals/AddRecipeToBook.vue
Normal file
122
vue/src/components/Modals/AddRecipeToBook.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
|
||||
<div>
|
||||
<b-modal class="modal" :id="`id_modal_add_book_${modal_id}`" :title="$t('Manage_Books')" :ok-title="$t('Add')"
|
||||
:cancel-title="$t('Close')" @ok="addToBook()" @shown="loadBookEntries">
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center" v-for="be in this.recipe_book_list" v-bind:key="be.id">
|
||||
{{ be.book_content.name }} <span class="btn btn-sm btn-danger" @click="removeFromBook(be)"><i class="fa fa-trash-alt"></i></span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<multiselect
|
||||
style="margin-top: 1vh"
|
||||
v-model="selected_book"
|
||||
:options="books_filtered"
|
||||
:taggable="true"
|
||||
@tag="createBook"
|
||||
v-bind:tag-placeholder="$t('Create')"
|
||||
:placeholder="$t('Select_Book')"
|
||||
label="name"
|
||||
track-by="id"
|
||||
id="id_books"
|
||||
:multiple="false"
|
||||
:loading="books_loading"
|
||||
@search-change="loadBooks">
|
||||
</multiselect>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Multiselect from 'vue-multiselect'
|
||||
|
||||
import moment from 'moment'
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
import Vue from "vue";
|
||||
import {BootstrapVue} from "bootstrap-vue";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import {makeStandardToast, StandardToasts} from "@/utils/utils";
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: 'AddRecipeToBook',
|
||||
components: {
|
||||
Multiselect
|
||||
},
|
||||
props: {
|
||||
recipe: Object,
|
||||
modal_id: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
books: [],
|
||||
books_loading: false,
|
||||
recipe_book_list: [],
|
||||
selected_book: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
books_filtered: function () {
|
||||
let books_filtered = []
|
||||
|
||||
this.books.forEach(b => {
|
||||
if (this.recipe_book_list.filter(e => e.book === b.id).length === 0) {
|
||||
books_filtered.push(b)
|
||||
}
|
||||
})
|
||||
|
||||
return books_filtered
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
},
|
||||
methods: {
|
||||
loadBooks: function (query) {
|
||||
this.books_loading = true
|
||||
let apiFactory = new ApiApiFactory()
|
||||
apiFactory.listRecipeBooks({query: {query: query}}).then(results => {
|
||||
this.books = results.data.filter(e => this.recipe_book_list.indexOf(e) === -1)
|
||||
this.books_loading = false
|
||||
})
|
||||
},
|
||||
createBook: function (name) {
|
||||
let apiFactory = new ApiApiFactory()
|
||||
apiFactory.createRecipeBook({name: name}).then(r => {
|
||||
this.books.push(r.data)
|
||||
this.selected_book = r.data
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
},
|
||||
addToBook: function () {
|
||||
let apiFactory = new ApiApiFactory()
|
||||
apiFactory.createRecipeBookEntry({book: this.selected_book.id, recipe: this.recipe.id}).then(r => {
|
||||
this.recipe_book_list.push(r.data)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
},
|
||||
removeFromBook: function (book_entry) {
|
||||
let apiFactory = new ApiApiFactory()
|
||||
apiFactory.destroyRecipeBookEntry(book_entry.id).then(r => {
|
||||
this.recipe_book_list = this.recipe_book_list.filter(e => e.id !== book_entry.id)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
})
|
||||
},
|
||||
loadBookEntries: function () {
|
||||
|
||||
let apiFactory = new ApiApiFactory()
|
||||
apiFactory.listRecipeBookEntrys({query: {recipe: this.recipe.id}}).then(r => {
|
||||
this.recipe_book_list = r.data
|
||||
this.loadBooks('')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
@@ -1,20 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-modal :id="'modal_' + id" @hidden="cancelAction">
|
||||
<template v-slot:modal-title
|
||||
><h4>{{ form.title }}</h4></template
|
||||
>
|
||||
<template v-slot:modal-title>
|
||||
<h4>{{ form.title }}</h4>
|
||||
</template>
|
||||
<div v-for="(f, i) in form.fields" v-bind:key="i">
|
||||
<p v-if="f.type == 'instruction'">{{ f.label }}</p>
|
||||
<!-- this lookup is single selection -->
|
||||
<lookup-input v-if="f.type == 'lookup'" :form="f" :model="listModel(f.list)" @change="storeValue" />
|
||||
<!-- TODO add ability to create new items associated with lookup -->
|
||||
<!-- TODO: add multi-selection input list -->
|
||||
<checkbox-input v-if="f.type == 'checkbox'" :label="f.label" :value="f.value" :field="f.field" />
|
||||
<text-input v-if="f.type == 'text'" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />
|
||||
<choice-input v-if="f.type == 'choice'" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
|
||||
<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" />
|
||||
<p v-if="visibleCondition(f, 'instruction')">{{ f.label }}</p>
|
||||
<lookup-input v-if="visibleCondition(f, '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" />
|
||||
<text-input v-if="visibleCondition(f, 'text')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />
|
||||
<choice-input v-if="visibleCondition(f, 'choice')" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
|
||||
<emoji-input v-if="visibleCondition(f, 'emoji')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
||||
<file-input v-if="visibleCondition(f, 'file')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
||||
<small-text v-if="visibleCondition(f, 'smalltext')" :value="f.value" />
|
||||
</div>
|
||||
|
||||
<template v-slot:modal-footer>
|
||||
@@ -28,7 +26,7 @@
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import { getForm } from "@/utils/utils"
|
||||
import { getForm, formFunctions } from "@/utils/utils"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
@@ -40,14 +38,20 @@ import TextInput from "@/components/Modals/TextInput"
|
||||
import EmojiInput from "@/components/Modals/EmojiInput"
|
||||
import ChoiceInput from "@/components/Modals/ChoiceInput"
|
||||
import FileInput from "@/components/Modals/FileInput"
|
||||
import SmallText from "@/components/Modals/SmallText"
|
||||
|
||||
export default {
|
||||
name: "GenericModalForm",
|
||||
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput },
|
||||
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput, SmallText },
|
||||
mixins: [ApiMixin, ToastMixin],
|
||||
props: {
|
||||
model: { required: true, type: Object },
|
||||
action: { type: Object },
|
||||
action: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
item1: {
|
||||
type: Object,
|
||||
default() {
|
||||
@@ -84,6 +88,9 @@ export default {
|
||||
show: function () {
|
||||
if (this.show) {
|
||||
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.$bvModal.show("modal_" + this.id)
|
||||
} else {
|
||||
@@ -245,6 +252,21 @@ export default {
|
||||
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>
|
||||
|
||||
@@ -80,8 +80,7 @@ export default {
|
||||
} else {
|
||||
arrayValues = [{ id: -1, name: this_value }]
|
||||
}
|
||||
|
||||
if (this.form?.ordered && this.first_run) {
|
||||
if (this.form?.ordered && this.first_run && arrayValues.length > 0) {
|
||||
return this.flattenItems(arrayValues)
|
||||
} else {
|
||||
return arrayValues
|
||||
|
||||
177
vue/src/components/Modals/ShoppingModal.vue
Normal file
177
vue/src/components/Modals/ShoppingModal.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
|
||||
<template v-slot:modal-title
|
||||
><h4>{{ $t("Add_Servings_to_Shopping", { servings: servings }) }}</h4></template
|
||||
>
|
||||
<loading-spinner v-if="loading"></loading-spinner>
|
||||
<div class="accordion" role="tablist" v-if="!loading">
|
||||
<b-card no-body class="mb-1">
|
||||
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||
<b-button block v-b-toggle.accordion-0 class="text-left" variant="outline-info">{{ recipe.name }}</b-button>
|
||||
</b-card-header>
|
||||
<b-collapse id="accordion-0" visible accordion="my-accordion" role="tabpanel">
|
||||
<ingredients-card
|
||||
:steps="steps"
|
||||
:recipe="recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:header="false"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
</b-collapse>
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="r in related_recipes">
|
||||
<b-card no-body class="mb-1" :key="r.recipe.id">
|
||||
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||
<b-button btn-sm block v-b-toggle="'accordion-' + r.recipe.id" class="text-left" variant="outline-primary">{{ r.recipe.name }}</b-button>
|
||||
</b-card-header>
|
||||
<b-collapse :id="'accordion-' + r.recipe.id" accordion="my-accordion" role="tabpanel">
|
||||
<ingredients-card
|
||||
:steps="r.steps"
|
||||
:recipe="r.recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:header="false"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
</b-collapse>
|
||||
</b-card>
|
||||
</template>
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
</b-card>
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12 text-right">
|
||||
<b-button class="mx-2" variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
|
||||
<b-button class="mx-2" variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
const { ApiApiFactory } = require("@/utils/openapi/api")
|
||||
import { StandardToasts } from "@/utils/utils"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
export default {
|
||||
name: "ShoppingModal",
|
||||
components: { IngredientsCard, LoadingSpinner },
|
||||
mixins: [],
|
||||
props: {
|
||||
recipe: { required: true, type: Object },
|
||||
servings: { type: Number },
|
||||
modal_id: { required: true, type: Number },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
steps: [],
|
||||
recipe_servings: 0,
|
||||
add_shopping: [],
|
||||
related_recipes: [],
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings || this.recipe_servings
|
||||
},
|
||||
},
|
||||
watch: {},
|
||||
methods: {
|
||||
loadRecipe: function () {
|
||||
this.add_shopping = []
|
||||
this.related_recipes = []
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient
|
||||
.retrieveRecipe(this.recipe.id)
|
||||
.then((result) => {
|
||||
this.steps = result.data.steps
|
||||
// ALERT: this will all break if ingredients are re-used between recipes
|
||||
// ALERT: this also doesn't quite work right if the same recipe appears multiple time in the related recipes
|
||||
this.add_shopping = [
|
||||
...this.add_shopping,
|
||||
...this.steps
|
||||
.map((x) => x.ingredients)
|
||||
.flat()
|
||||
.filter((x) => !x?.food?.food_onhand)
|
||||
.map((x) => x.id),
|
||||
]
|
||||
this.recipe_servings = result.data?.servings
|
||||
this.loading = false
|
||||
})
|
||||
.then(() => {
|
||||
// get a list of related recipes
|
||||
apiClient
|
||||
.relatedRecipe(this.recipe.id)
|
||||
.then((result) => {
|
||||
return result.data
|
||||
})
|
||||
.then((related_recipes) => {
|
||||
let promises = []
|
||||
related_recipes.forEach((x) => {
|
||||
promises.push(
|
||||
apiClient.listSteps(x.id).then((recipe_steps) => {
|
||||
this.related_recipes.push({
|
||||
recipe: x,
|
||||
steps: recipe_steps.data.results.filter((x) => x.ingredients.length > 0),
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
return Promise.all(promises)
|
||||
})
|
||||
.then(() => {
|
||||
this.add_shopping = [
|
||||
...this.add_shopping,
|
||||
...this.related_recipes
|
||||
.map((x) => x.steps)
|
||||
.flat()
|
||||
.map((x) => x.ingredients)
|
||||
.flat()
|
||||
.filter((x) => !x.food.override_ignore)
|
||||
.map((x) => x.id),
|
||||
]
|
||||
})
|
||||
})
|
||||
},
|
||||
addShopping: function (e) {
|
||||
if (e.add) {
|
||||
this.add_shopping.push(e.item.id)
|
||||
} else {
|
||||
this.add_shopping = this.add_shopping.filter((x) => x !== e.item.id)
|
||||
}
|
||||
},
|
||||
saveShopping: function () {
|
||||
// another choice would be to create ShoppingListRecipes for each recipe - this bundles all related recipe under the parent recipe
|
||||
let shopping_recipe = {
|
||||
id: this.recipe.id,
|
||||
ingredients: this.add_shopping,
|
||||
servings: this.servings,
|
||||
}
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient
|
||||
.shoppingRecipe(this.recipe.id, shopping_recipe)
|
||||
.then((result) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
.catch((err) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
this.$bvModal.hide(`shopping_${this.modal_id}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
20
vue/src/components/Modals/SmallText.vue
Normal file
20
vue/src/components/Modals/SmallText.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="small text-muted">
|
||||
{{ value }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TextInput",
|
||||
props: {
|
||||
value: { type: String, default: "" },
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
mounted() {},
|
||||
watch: {},
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
@@ -1,42 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-group
|
||||
v-bind:label="label"
|
||||
class="mb-3">
|
||||
<b-form-input
|
||||
v-model="new_value"
|
||||
type="string"
|
||||
:placeholder="placeholder"
|
||||
></b-form-input>
|
||||
<b-form-group v-bind:label="label" class="mb-3">
|
||||
<b-form-input v-model="new_value" type="text" :placeholder="placeholder"></b-form-input>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'TextInput',
|
||||
props: {
|
||||
field: {type: String, default: 'You Forgot To Set Field Name'},
|
||||
label: {type: String, default: 'Text Field'},
|
||||
value: {type: String, default: ''},
|
||||
placeholder: {type: String, default: 'You Should Add Placeholder Text'},
|
||||
show_merge: {type: Boolean, default: false},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
new_value: undefined,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.new_value = this.value
|
||||
},
|
||||
watch: {
|
||||
'new_value': function () {
|
||||
this.$root.$emit('change', this.field, this.new_value)
|
||||
name: "TextInput",
|
||||
props: {
|
||||
field: { type: String, default: "You Forgot To Set Field Name" },
|
||||
label: { type: String, default: "Text Field" },
|
||||
value: { type: String, default: "" },
|
||||
placeholder: { type: String, default: "You Should Add Placeholder Text" },
|
||||
show_merge: { type: Boolean, default: false },
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
data() {
|
||||
return {
|
||||
new_value: undefined,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.new_value = this.value
|
||||
},
|
||||
watch: {
|
||||
new_value: function () {
|
||||
this.$root.$emit("change", this.field, this.new_value)
|
||||
},
|
||||
},
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<!-- <b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button> -->
|
||||
<span>
|
||||
<b-dropdown variant="link" toggle-class="text-decoration-none text-dark shadow-none" no-caret
|
||||
style="boundary:window">
|
||||
<template #button-content>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</template>
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_food')">
|
||||
<i class="fas fa-leaf fa-fw"></i> {{ Models['FOOD'].name }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')">
|
||||
<i class="fas fa-tags fa-fw"></i> {{ Models['KEYWORD'].name }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_unit')">
|
||||
<i class="fas fa-balance-scale fa-fw"></i> {{ Models['UNIT'].name }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')">
|
||||
<i class="fas fa-store-alt fa-fw"></i> {{ Models['SUPERMARKET'].name }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')">
|
||||
<i class="fas fa-cubes fa-fw"></i> {{ Models['SHOPPING_CATEGORY'].name }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_automation')">
|
||||
<i class="fas fa-robot fa-fw"></i> {{ Models['AUTOMATION'].name }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')">
|
||||
<i class="fas fa-file fa-fw"></i> {{ Models['USERFILE'].name }}
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item :href="resolveDjangoUrl('list_step')">
|
||||
<i class="fas fa-puzzle-piece fa-fw"></i>{{ Models['STEP'].name }}
|
||||
</b-dropdown-item>
|
||||
|
||||
</b-dropdown>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
|
||||
import {Models} from "@/utils/models";
|
||||
import {ResolveUrlMixin} from "@/utils/utils";
|
||||
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: 'ModelMenu',
|
||||
mixins: [ResolveUrlMixin],
|
||||
data() {
|
||||
return {
|
||||
Models: Models
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
methods: {
|
||||
gotoURL: function (model) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -1,158 +1,135 @@
|
||||
<template>
|
||||
|
||||
|
||||
<b-card no-body v-hover>
|
||||
<a :href="clickUrl()">
|
||||
<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>
|
||||
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
|
||||
<a>
|
||||
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2"
|
||||
v-if="recipe.working_time !== 0 || recipe.waiting_time !== 0">
|
||||
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0"><i class="fa fa-clock"></i>
|
||||
{{ recipe.working_time }} {{ $t('min') }}
|
||||
</b-badge>
|
||||
<b-badge pill variant="secondary" class="mt-1 font-weight-normal" v-if="recipe.waiting_time !== 0"><i class="fa fa-pause"></i>
|
||||
{{ recipe.waiting_time }} {{ $t('min') }}
|
||||
</b-badge>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
<b-card-body class="p-4">
|
||||
<h6><a :href="clickUrl()">
|
||||
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
||||
<template v-else>{{ meal_plan.title }}</template>
|
||||
</a></h6>
|
||||
|
||||
<b-card-text style="text-overflow: ellipsis;">
|
||||
<template v-if="recipe !== null">
|
||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||
<template v-if="recipe.description !== null">
|
||||
<span v-if="recipe.description.length > text_length">
|
||||
{{ recipe.description.substr(0, text_length) + "\u2026" }}
|
||||
</span>
|
||||
<span v-if="recipe.description.length <= text_length">
|
||||
{{ recipe.description }}
|
||||
</span>
|
||||
</template>
|
||||
<p class="mt-1">
|
||||
<last-cooked :recipe="recipe"></last-cooked>
|
||||
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
|
||||
</p>
|
||||
<transition name="fade" mode="in-out">
|
||||
<div class="row mt-3" v-if="detailed">
|
||||
<div class="col-md-12">
|
||||
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h6>
|
||||
<table class="table table-sm text-wrap">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="s in recipe.steps">
|
||||
<template v-for="i in s.ingredients">
|
||||
<Ingredient-component :detailed="false" :ingredient="i" :ingredient_factor="1" :key="i.id"></Ingredient-component>
|
||||
</template>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
||||
</table>
|
||||
</div>
|
||||
<b-card no-body v-hover v-if="recipe">
|
||||
<a :href="clickUrl()">
|
||||
<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>
|
||||
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
|
||||
<a>
|
||||
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
|
||||
</a>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2" v-if="recipe.working_time !== 0 || recipe.waiting_time !== 0">
|
||||
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0"><i class="fa fa-clock"></i> {{ recipe.working_time }} {{ $t("min") }} </b-badge>
|
||||
<b-badge pill variant="secondary" class="mt-1 font-weight-normal" v-if="recipe.waiting_time !== 0"><i class="fa fa-pause"></i> {{ recipe.waiting_time }} {{ $t("min") }} </b-badge>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<b-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> -->
|
||||
<b-card-body class="p-4">
|
||||
<h6>
|
||||
<a :href="clickUrl()">
|
||||
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
||||
<template v-else>{{ meal_plan.title }}</template>
|
||||
</a>
|
||||
</h6>
|
||||
|
||||
</template>
|
||||
<template v-else>{{ meal_plan.note }}</template>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
<b-card-text style="text-overflow: ellipsis">
|
||||
<template v-if="recipe !== null">
|
||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||
<template v-if="recipe.description !== null">
|
||||
<span v-if="recipe.description.length > text_length">
|
||||
{{ recipe.description.substr(0, text_length) + "\u2026" }}
|
||||
</span>
|
||||
<span v-if="recipe.description.length <= text_length">
|
||||
{{ recipe.description }}
|
||||
</span>
|
||||
</template>
|
||||
<p class="mt-1">
|
||||
<last-cooked :recipe="recipe"></last-cooked>
|
||||
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
|
||||
</p>
|
||||
<transition name="fade" mode="in-out">
|
||||
<div class="row mt-3" v-if="show_detail">
|
||||
<div class="col-md-12">
|
||||
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h6>
|
||||
|
||||
<ingredients-card :steps="recipe.steps" :header="false" :detailed="false" :servings="recipe.servings" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<b-card-footer v-if="footer_text !== undefined">
|
||||
<i v-bind:class="footer_icon"></i> {{ footer_text }}
|
||||
</b-card-footer>
|
||||
</b-card>
|
||||
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t("External") }}</b-badge>
|
||||
</template>
|
||||
<template v-else>{{ meal_plan.note }}</template>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
|
||||
<b-card-footer v-if="footer_text !== undefined"> <i v-bind:class="footer_icon"></i> {{ footer_text }} </b-card-footer>
|
||||
</b-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RecipeContextMenu from "@/components/RecipeContextMenu";
|
||||
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils";
|
||||
import RecipeRating from "@/components/RecipeRating";
|
||||
import moment from "moment/moment";
|
||||
import Vue from "vue";
|
||||
import LastCooked from "@/components/LastCooked";
|
||||
import KeywordsComponent from "@/components/KeywordsComponent";
|
||||
import IngredientComponent from "@/components/IngredientComponent";
|
||||
import RecipeContextMenu from "@/components/RecipeContextMenu"
|
||||
import KeywordsComponent from "@/components/KeywordsComponent"
|
||||
import { resolveDjangoUrl, ResolveUrlMixin } from "@/utils/utils"
|
||||
import RecipeRating from "@/components/RecipeRating"
|
||||
import moment from "moment/moment"
|
||||
import Vue from "vue"
|
||||
import LastCooked from "@/components/LastCooked"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
export default {
|
||||
name: "RecipeCard",
|
||||
mixins: [
|
||||
ResolveUrlMixin,
|
||||
],
|
||||
components: {LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientComponent},
|
||||
props: {
|
||||
recipe: Object,
|
||||
meal_plan: Object,
|
||||
footer_text: String,
|
||||
footer_icon: String
|
||||
},
|
||||
computed: {
|
||||
detailed: function () {
|
||||
return this.recipe.steps !== undefined;
|
||||
name: "RecipeCard",
|
||||
mixins: [ResolveUrlMixin],
|
||||
components: { LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientsCard },
|
||||
props: {
|
||||
recipe: Object,
|
||||
meal_plan: Object,
|
||||
footer_text: String,
|
||||
footer_icon: String,
|
||||
detailed: { type: Boolean, default: true },
|
||||
},
|
||||
text_length: function () {
|
||||
if (this.detailed) {
|
||||
return 200
|
||||
} else {
|
||||
return 120
|
||||
}
|
||||
mounted() {},
|
||||
computed: {
|
||||
show_detail: function () {
|
||||
return this.recipe?.steps !== undefined && this.detailed
|
||||
},
|
||||
text_length: function () {
|
||||
if (this.show_detail) {
|
||||
return 200
|
||||
} else {
|
||||
return 120
|
||||
}
|
||||
},
|
||||
recipe_image: function () {
|
||||
if (this.recipe == null || this.recipe.image === null) {
|
||||
return window.IMAGE_PLACEHOLDER
|
||||
} else {
|
||||
return this.recipe.image
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// TODO: convert this to genericAPI
|
||||
clickUrl: function () {
|
||||
if (this.recipe !== null) {
|
||||
return resolveDjangoUrl("view_recipe", this.recipe.id)
|
||||
} else {
|
||||
return resolveDjangoUrl("view_plan_entry", this.meal_plan.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: function (el) {
|
||||
el.addEventListener("mouseenter", () => {
|
||||
el.classList.add("shadow")
|
||||
})
|
||||
el.addEventListener("mouseleave", () => {
|
||||
el.classList.remove("shadow")
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
recipe_image: function () {
|
||||
if (this.recipe == null || this.recipe.image === null) {
|
||||
return window.IMAGE_PLACEHOLDER
|
||||
} else {
|
||||
return this.recipe.image
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// TODO: convert this to genericAPI
|
||||
clickUrl: function () {
|
||||
if (this.recipe !== null) {
|
||||
return resolveDjangoUrl('view_recipe', this.recipe.id)
|
||||
} else {
|
||||
return resolveDjangoUrl('view_plan_entry', this.meal_plan.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: function (el) {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
el.classList.add("shadow")
|
||||
});
|
||||
el.addEventListener('mouseleave', () => {
|
||||
el.classList.remove("shadow")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity .5s;
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,183 +1,175 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<div class="dropdown d-print-none">
|
||||
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</a>
|
||||
|
||||
<div class="dropdown d-print-none">
|
||||
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink"
|
||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</a>
|
||||
<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>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
|
||||
<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 class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i
|
||||
class="fas fa-pencil-alt fa-fw"></i> {{ $t('Edit') }}</a>
|
||||
<a href="javascript:void(0);">
|
||||
<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>
|
||||
</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 class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`" v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
|
||||
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t("Add_to_Shopping") }}
|
||||
</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 href="javascript:void(0);">
|
||||
<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>
|
||||
</a>
|
||||
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
|
||||
|
||||
<a class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping') }?r=[${recipe.id},${servings_value}]`"
|
||||
v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
|
||||
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
|
||||
</a>
|
||||
<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 class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
|
||||
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
|
||||
</a>
|
||||
<a href="javascript:void(0);">
|
||||
<button class="dropdown-item" onclick="window.print()"><i class="fas fa-print fa-fw"></i> {{ $t("Print") }}</button>
|
||||
</a>
|
||||
|
||||
<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 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);">
|
||||
<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);">
|
||||
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i
|
||||
class="fas fa-share-alt fa-fw"></i> {{ $t('Share') }}
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<cook-log :recipe="recipe" :modal_id="modal_id"></cook-log>
|
||||
<add-recipe-to-book :recipe="recipe" :modal_id="modal_id"></add-recipe-to-book>
|
||||
|
||||
<b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<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"/>
|
||||
<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>
|
||||
<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>
|
||||
<a href="javascript:void(0);">
|
||||
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
|
||||
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]"
|
||||
: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>
|
||||
</div>
|
||||
<cook-log :recipe="recipe" :modal_id="modal_id"></cook-log>
|
||||
<add-recipe-to-book :recipe="recipe" :modal_id="modal_id"></add-recipe-to-book>
|
||||
|
||||
<b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
</b-modal>
|
||||
|
||||
<meal-plan-edit-modal
|
||||
:entry="entryEditing"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils";
|
||||
import CookLog from "@/components/CookLog";
|
||||
import axios from "axios";
|
||||
import AddRecipeToBook from "./AddRecipeToBook";
|
||||
import MealPlanEditModal from "@/components/MealPlanEditModal";
|
||||
import moment from "moment";
|
||||
import Vue from "vue";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import { makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts } from "@/utils/utils"
|
||||
import CookLog from "@/components/CookLog"
|
||||
import axios from "axios"
|
||||
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||
import MealPlanEditModal from "@/components/MealPlanEditModal"
|
||||
import ShoppingModal from "@/components/Modals/ShoppingModal"
|
||||
import moment from "moment"
|
||||
import Vue from "vue"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
export default {
|
||||
name: 'RecipeContextMenu',
|
||||
mixins: [
|
||||
ResolveUrlMixin
|
||||
],
|
||||
components: {
|
||||
AddRecipeToBook,
|
||||
CookLog,
|
||||
MealPlanEditModal
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
servings_value: 0,
|
||||
recipe_share_link: undefined,
|
||||
modal_id: this.recipe.id + Math.round(Math.random() * 100000),
|
||||
options: {
|
||||
entryEditing: {
|
||||
date: null,
|
||||
id: -1,
|
||||
meal_type: null,
|
||||
note: "",
|
||||
note_markdown: "",
|
||||
recipe: null,
|
||||
servings: 1,
|
||||
shared: [],
|
||||
title: '',
|
||||
title_placeholder: this.$t('Title')
|
||||
name: "RecipeContextMenu",
|
||||
mixins: [ResolveUrlMixin],
|
||||
components: {
|
||||
AddRecipeToBook,
|
||||
CookLog,
|
||||
MealPlanEditModal,
|
||||
ShoppingModal,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
servings_value: 0,
|
||||
recipe_share_link: undefined,
|
||||
modal_id: this.recipe.id + Math.round(Math.random() * 100000),
|
||||
options: {
|
||||
entryEditing: {
|
||||
date: null,
|
||||
id: -1,
|
||||
meal_type: null,
|
||||
note: "",
|
||||
note_markdown: "",
|
||||
recipe: null,
|
||||
servings: 1,
|
||||
shared: [],
|
||||
title: "",
|
||||
title_placeholder: this.$t("Title"),
|
||||
},
|
||||
},
|
||||
entryEditing: {},
|
||||
}
|
||||
},
|
||||
entryEditing: {},
|
||||
}
|
||||
},
|
||||
props: {
|
||||
recipe: Object,
|
||||
servings: {
|
||||
type: Number,
|
||||
default: -1
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.servings_value = ((this.servings === -1) ? this.recipe.servings : this.servings)
|
||||
},
|
||||
methods: {
|
||||
saveMealPlan: function (entry) {
|
||||
entry.date = moment(entry.date).format("YYYY-MM-DD")
|
||||
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.createMealPlan(entry).then(result => {
|
||||
this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}).catch(error => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
},
|
||||
createMealPlan(data) {
|
||||
this.entryEditing = this.options.entryEditing
|
||||
this.entryEditing.recipe = this.recipe
|
||||
this.entryEditing.date = moment(new Date()).format('YYYY-MM-DD')
|
||||
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
|
||||
props: {
|
||||
recipe: Object,
|
||||
servings: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
},
|
||||
createShareLink: function () {
|
||||
axios.get(resolveDjangoUrl('api_share_link', this.recipe.id)).then(result => {
|
||||
this.$bvModal.show(`modal-share-link_${this.modal_id}`)
|
||||
this.recipe_share_link = result.data.link
|
||||
}).catch(err => {
|
||||
|
||||
if (err.response.status === 403) {
|
||||
makeToast(this.$t('Share'), this.$t('Sharing is not enabled for this space.'), 'danger')
|
||||
}
|
||||
})
|
||||
mounted() {
|
||||
this.servings_value = this.servings === -1 ? this.recipe.servings : this.servings
|
||||
},
|
||||
methods: {
|
||||
saveMealPlan: function (entry) {
|
||||
entry.date = moment(entry.date).format("YYYY-MM-DD")
|
||||
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient
|
||||
.createMealPlan(entry)
|
||||
.then((result) => {
|
||||
this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
},
|
||||
createMealPlan(data) {
|
||||
this.entryEditing = this.options.entryEditing
|
||||
this.entryEditing.recipe = this.recipe
|
||||
this.entryEditing.date = moment(new Date()).format("YYYY-MM-DD")
|
||||
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
|
||||
},
|
||||
createShareLink: function () {
|
||||
axios
|
||||
.get(resolveDjangoUrl("api_share_link", this.recipe.id))
|
||||
.then((result) => {
|
||||
this.$bvModal.show(`modal-share-link_${this.modal_id}`)
|
||||
this.recipe_share_link = result.data.link
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response.status === 403) {
|
||||
makeToast(this.$t("Share"), this.$t("Sharing is not enabled for this space."), "danger")
|
||||
}
|
||||
})
|
||||
},
|
||||
copyShareLink: function () {
|
||||
let share_input = this.$refs.share_link_ref
|
||||
share_input.select()
|
||||
document.execCommand("copy")
|
||||
},
|
||||
shareIntend: function () {
|
||||
let shareData = {
|
||||
title: this.recipe.name,
|
||||
text: `${this.$t("Check out this recipe: ")} ${this.recipe.name}`,
|
||||
url: this.recipe_share_link,
|
||||
}
|
||||
navigator.share(shareData)
|
||||
},
|
||||
addToShopping() {
|
||||
this.$bvModal.show(`shopping_${this.modal_id}`)
|
||||
},
|
||||
},
|
||||
copyShareLink: function () {
|
||||
let share_input = this.$refs.share_link_ref;
|
||||
share_input.select();
|
||||
document.execCommand("copy");
|
||||
},
|
||||
shareIntend: function () {
|
||||
let shareData = {
|
||||
title: this.recipe.name,
|
||||
text: `${this.$t('Check out this recipe: ')} ${this.recipe.name}`,
|
||||
url: this.recipe_share_link
|
||||
}
|
||||
navigator.share(shareData)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
299
vue/src/components/ShoppingLineItem.vue
Normal file
299
vue/src/components/ShoppingLineItem.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div id="shopping_line_item">
|
||||
<div class="col-12">
|
||||
<b-container fluid>
|
||||
<!-- summary rows -->
|
||||
<b-row align-h="start">
|
||||
<b-col cols="12" sm="2">
|
||||
<div style="position: static" class="btn-group">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||
@click.stop="$emit('open-context-menu', $event, entries)"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="checkbox" class="text-right mx-3 mt-2" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="12" sm="10">
|
||||
<b-row>
|
||||
<b-col cols="6" sm="3">
|
||||
<div v-if="Object.entries(formatAmount).length == 1">{{ Object.entries(formatAmount)[0][1] }}   {{ Object.entries(formatAmount)[0][0] }}</div>
|
||||
<div class="small" v-else v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }}   {{ x[0] }}</div>
|
||||
</b-col>
|
||||
|
||||
<b-col cols="6" sm="7">
|
||||
{{ formatFood }}
|
||||
</b-col>
|
||||
<b-col cols="6" sm="2" data-html2canvas-ignore="true">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="mr-2" variant="link">
|
||||
<div class="text-nowrap">{{ showDetails ? "Hide" : "Show" }} Details</div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row align-h="center">
|
||||
<b-col cols="12">
|
||||
<div class="small text-muted text-truncate">{{ formatHint }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-container>
|
||||
<!-- detail rows -->
|
||||
<div class="card no-body" v-if="showDetails">
|
||||
<b-container fluid>
|
||||
<div v-for="e in entries" :key="e.id">
|
||||
<b-row class="ml-2 small">
|
||||
<b-col cols="6" md="4" class="overflow-hidden text-nowrap">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn btn-link btn-sm m-0 p-0"
|
||||
style="text-overflow: ellipsis"
|
||||
@click.stop="openRecipeCard($event, e)"
|
||||
@mouseover="openRecipeCard($event, e)"
|
||||
>
|
||||
{{ formatOneRecipe(e) }}
|
||||
</button>
|
||||
</b-col>
|
||||
<b-col cols="6" md="4" class="col-md-4 text-muted">{{ formatOneMealPlan(e) }}</b-col>
|
||||
<b-col cols="12" md="4" class="col-md-4 text-muted text-right overflow-hidden text-nowrap">
|
||||
{{ formatOneCreatedBy(e) }}
|
||||
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-row class="ml-2 light">
|
||||
<b-col cols="12" sm="2">
|
||||
<div style="position: static" class="btn-group">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
|
||||
@click.stop="$emit('open-context-menu', $event, e)"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="checkbox" class="text-right mx-3 mt-2" :checked="e.checked" @change="updateChecked($event, e)" />
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="12" sm="10">
|
||||
<b-row>
|
||||
<b-col cols="2" sm="2" md="1" class="text-nowrap">{{ formatOneAmount(e) }}</b-col>
|
||||
<b-col cols="10" sm="4" md="2" class="text-nowrap">{{ formatOneUnit(e) }}</b-col>
|
||||
|
||||
<b-col cols="12" sm="6" md="4" class="text-nowrap">{{ formatOneFood(e) }}</b-col>
|
||||
|
||||
<b-col cols="12" sm="6" md="5">
|
||||
<div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<hr class="w-75" />
|
||||
</div>
|
||||
</b-container>
|
||||
</div>
|
||||
<hr class="m-1" />
|
||||
</div>
|
||||
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
|
||||
<template #menu="{ contextData }" v-if="recipe">
|
||||
<ContextMenuItem><RecipeCard :recipe="contextData" :detail="false"></RecipeCard></ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close()">
|
||||
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
|
||||
<template #label>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a>
|
||||
</template>
|
||||
<div @click.prevent.stop>
|
||||
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
|
||||
</div>
|
||||
</b-form-group>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||
import { ApiMixin } from "@/utils/utils"
|
||||
import RecipeCard from "./RecipeCard.vue"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
|
||||
// or i'm capturing it incorrectly
|
||||
name: "ShoppingLineItem",
|
||||
mixins: [ApiMixin],
|
||||
components: { RecipeCard, ContextMenu, ContextMenuItem },
|
||||
props: {
|
||||
entries: {
|
||||
type: Array,
|
||||
},
|
||||
groupby: { type: String },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showDetails: false,
|
||||
recipe: undefined,
|
||||
servings: 1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formatAmount: function () {
|
||||
let amount = {}
|
||||
this.entries.forEach((entry) => {
|
||||
let unit = entry?.unit?.name ?? "----"
|
||||
if (entry.amount) {
|
||||
if (amount[unit]) {
|
||||
amount[unit] += entry.amount
|
||||
} else {
|
||||
amount[unit] = entry.amount
|
||||
}
|
||||
}
|
||||
})
|
||||
for (const [k, v] of Object.entries(amount)) {
|
||||
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
|
||||
}
|
||||
return amount
|
||||
},
|
||||
formatCategory: function () {
|
||||
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
|
||||
},
|
||||
formatChecked: function () {
|
||||
return this.entries.map((x) => x.checked).every((x) => x === true)
|
||||
},
|
||||
formatHint: function () {
|
||||
if (this.groupby == "recipe") {
|
||||
return this.formatCategory
|
||||
} else {
|
||||
return this.formatRecipe
|
||||
}
|
||||
},
|
||||
formatFood: function () {
|
||||
return this.formatOneFood(this.entries[0])
|
||||
},
|
||||
formatUnit: function () {
|
||||
return this.formatOneUnit(this.entries[0])
|
||||
},
|
||||
formatRecipe: function () {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneMealPlan(this.entries[0]) || ""
|
||||
} else {
|
||||
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
|
||||
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
|
||||
|
||||
return mealplan_name
|
||||
.map((x) => {
|
||||
return this.formatOneMealPlan(x)
|
||||
})
|
||||
.join(" - ")
|
||||
}
|
||||
},
|
||||
formatNotes: function () {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneNote(this.entries[0]) || ""
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
watch: {},
|
||||
mounted() {
|
||||
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
|
||||
formatDate: function (datetime) {
|
||||
if (!datetime) {
|
||||
return
|
||||
}
|
||||
return Intl.DateTimeFormat(window.navigator.language, { dateStyle: "short", timeStyle: "short" }).format(Date.parse(datetime))
|
||||
},
|
||||
formatOneAmount: function (item) {
|
||||
return item?.amount ?? 1
|
||||
},
|
||||
formatOneUnit: function (item) {
|
||||
return item?.unit?.name ?? ""
|
||||
},
|
||||
formatOneCategory: function (item) {
|
||||
return item?.food?.supermarket_category?.name
|
||||
},
|
||||
formatOneCompletedAt: function (item) {
|
||||
if (!item.completed_at) {
|
||||
return false
|
||||
}
|
||||
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
|
||||
},
|
||||
formatOneFood: function (item) {
|
||||
return item.food.name
|
||||
},
|
||||
formatOneDelayUntil: function (item) {
|
||||
if (!item.delay_until || (item.delay_until && item.checked)) {
|
||||
return false
|
||||
}
|
||||
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
|
||||
},
|
||||
formatOneMealPlan: function (item) {
|
||||
return item?.recipe_mealplan?.name ?? ""
|
||||
},
|
||||
formatOneRecipe: function (item) {
|
||||
return item?.recipe_mealplan?.recipe_name ?? ""
|
||||
},
|
||||
formatOneNote: function (item) {
|
||||
if (!item) {
|
||||
item = this.entries[0]
|
||||
}
|
||||
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
|
||||
},
|
||||
formatOneCreatedBy: function (item) {
|
||||
return [this.$t("Added_by"), item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ")
|
||||
},
|
||||
openRecipeCard: function (e, item) {
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, { id: item.recipe_mealplan.recipe }).then((result) => {
|
||||
let recipe = result.data
|
||||
recipe.steps = undefined
|
||||
this.recipe = true
|
||||
this.$refs.recipe_card.open(e, recipe)
|
||||
})
|
||||
},
|
||||
updateChecked: function (e, item) {
|
||||
let update = undefined
|
||||
if (!item) {
|
||||
update = { entries: this.entries.map((x) => x.id), checked: !this.formatChecked }
|
||||
} else {
|
||||
update = { entries: [item], checked: !item.checked }
|
||||
}
|
||||
this.$emit("update-checkbox", update)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
|
||||
|
||||
<style>
|
||||
/* table { border-collapse:collapse } /* Ensure no space between cells */
|
||||
/* tr.strikeout td { position:relative } /* Setup a new coordinate system */
|
||||
/* tr.strikeout td:before { /* Create a new element that */
|
||||
/* content: " "; /* …has no text content */
|
||||
/* position: absolute; /* …is absolutely positioned */
|
||||
/* left: 0; top: 50%; width: 100%; /* …with the top across the middle */
|
||||
/* border-bottom: 1px solid #000; /* …and with a border on the top */
|
||||
/* } */
|
||||
</style>
|
||||
@@ -1,220 +1,190 @@
|
||||
<template>
|
||||
<div>
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<hr />
|
||||
|
||||
<template v-if="step.type === 'TEXT' || step.type === 'RECIPE'">
|
||||
<div class="row" v-if="recipe.steps.length > 1">
|
||||
<div class="col col-md-8">
|
||||
<h5 class="text-primary">
|
||||
<template v-if="step.name">{{ step.name }}</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>
|
||||
{{ step.time }} {{ $t('min') }}
|
||||
|
||||
</small>
|
||||
<small v-if="start_time !== ''" class="d-print-none">
|
||||
<b-link :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#">
|
||||
{{ moment(start_time).add(step.time_offset, 'minutes').format('HH:mm') }}
|
||||
</b-link>
|
||||
</small>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col col-md-4" style="text-align: right">
|
||||
<b-button @click="details_visible = !details_visible" style="border: none; background: none"
|
||||
class="shadow-none d-print-none"
|
||||
:class="{ 'text-primary': details_visible, 'text-success': !details_visible}">
|
||||
<i class="far fa-check-circle"></i>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<template v-if="step.type === 'TEXT'">
|
||||
|
||||
<b-collapse id="collapse-1" v-model="details_visible">
|
||||
<div class="row">
|
||||
<div class="col col-md-4"
|
||||
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
|
||||
<table class="table table-sm">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<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>
|
||||
</div>
|
||||
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1,}">
|
||||
<compile-component :code="step.ingredients_markdown"
|
||||
:ingredient_factor="ingredient_factor"></compile-component>
|
||||
</div>
|
||||
</div>
|
||||
</b-collapse>
|
||||
</template>
|
||||
|
||||
<template v-if="step.type === 'TIME' || step.type === 'FILE'">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2" style="text-align: center">
|
||||
<h4 class="text-primary">
|
||||
<template v-if="step.name">{{ step.name }}</template>
|
||||
<template v-else>{{ $t('Step') }} {{ index + 1 }}</template>
|
||||
</h4>
|
||||
<span style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fa fa-stopwatch"></i>
|
||||
{{ step.time }} {{ $t('min') }}</span>
|
||||
<b-link class="d-print-none" :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#"
|
||||
v-if="start_time !== ''">
|
||||
{{ moment(start_time).add(step.time_offset, 'minutes').format('HH:mm') }}
|
||||
</b-link>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2" style="text-align: right">
|
||||
<b-button @click="details_visible = !details_visible" style="border: none; background: none"
|
||||
class="shadow-none d-print-none"
|
||||
:class="{ 'text-primary': details_visible, 'text-success': !details_visible}">
|
||||
<i class="far fa-check-circle"></i>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-collapse id="collapse-1" v-model="details_visible">
|
||||
<div class="row" v-if="step.instruction !== ''">
|
||||
<div class="col col-md-12" style="text-align: center">
|
||||
<compile-component :code="step.ingredients_markdown"
|
||||
:ingredient_factor="ingredient_factor"></compile-component>
|
||||
</div>
|
||||
</div>
|
||||
</b-collapse>
|
||||
</template>
|
||||
|
||||
<div class="row" style="text-align: center">
|
||||
<div class="col col-md-12">
|
||||
<template v-if="step.file !== null">
|
||||
<div
|
||||
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">
|
||||
</div>
|
||||
<div v-else>
|
||||
<a :href="step.file.file" target="_blank" rel="noreferrer nofollow">{{ $t('Download') }} {{
|
||||
$t('File')
|
||||
}}</a>
|
||||
</div>
|
||||
<template v-if="step.type === 'TEXT' || step.type === 'RECIPE'">
|
||||
<div class="row" v-if="recipe.steps.length > 1">
|
||||
<div class="col col-md-8">
|
||||
<h5 class="text-primary">
|
||||
<template v-if="step.name">{{ step.name }}</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> {{ step.time }} {{ $t("min") }} </small>
|
||||
<small v-if="start_time !== ''" class="d-print-none">
|
||||
<b-link :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#">
|
||||
{{ moment(start_time).add(step.time_offset, "minutes").format("HH:mm") }}
|
||||
</b-link>
|
||||
</small>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="col col-md-4" style="text-align: right">
|
||||
<b-button
|
||||
@click="details_visible = !details_visible"
|
||||
style="border: none; background: none"
|
||||
class="shadow-none d-print-none"
|
||||
:class="{ 'text-primary': details_visible, 'text-success': !details_visible }"
|
||||
>
|
||||
<i class="far fa-check-circle"></i>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="step.type === 'TEXT'">
|
||||
<b-collapse id="collapse-1" v-model="details_visible">
|
||||
<div class="row">
|
||||
<div class="col col-md-4" v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
|
||||
<table class="table table-sm">
|
||||
<ingredients-card :steps="[step]" :ingredient_factor="ingredient_factor" @checked-state-changed="$emit('checked-state-changed', $event)" />
|
||||
</table>
|
||||
</div>
|
||||
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1 }">
|
||||
<compile-component :code="step.ingredients_markdown" :ingredient_factor="ingredient_factor"></compile-component>
|
||||
</div>
|
||||
</div>
|
||||
</b-collapse>
|
||||
</template>
|
||||
|
||||
<div class="card" v-if="step.type === 'RECIPE' && step.step_recipe_data !== null">
|
||||
<b-collapse id="collapse-1" v-model="details_visible">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<a :href="resolveDjangoUrl('view_recipe',step.step_recipe_data.id)">{{ step.step_recipe_data.name }}</a>
|
||||
</h2>
|
||||
<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"
|
||||
:start_time="start_time" :force_ingredients="true"></step-component>
|
||||
<template v-if="step.type === 'TIME' || step.type === 'FILE'">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2" style="text-align: center">
|
||||
<h4 class="text-primary">
|
||||
<template v-if="step.name">{{ step.name }}</template>
|
||||
<template v-else>{{ $t("Step") }} {{ index + 1 }}</template>
|
||||
</h4>
|
||||
<span style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fa fa-stopwatch"></i> {{ step.time }} {{ $t("min") }}</span>
|
||||
<b-link class="d-print-none" :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#" v-if="start_time !== ''">
|
||||
{{ moment(start_time).add(step.time_offset, "minutes").format("HH:mm") }}
|
||||
</b-link>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2" style="text-align: right">
|
||||
<b-button
|
||||
@click="details_visible = !details_visible"
|
||||
style="border: none; background: none"
|
||||
class="shadow-none d-print-none"
|
||||
:class="{ 'text-primary': details_visible, 'text-success': !details_visible }"
|
||||
>
|
||||
<i class="far fa-check-circle"></i>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-collapse id="collapse-1" v-model="details_visible">
|
||||
<div class="row" v-if="step.instruction !== ''">
|
||||
<div class="col col-md-12" style="text-align: center">
|
||||
<compile-component :code="step.ingredients_markdown" :ingredient_factor="ingredient_factor"></compile-component>
|
||||
</div>
|
||||
</div>
|
||||
</b-collapse>
|
||||
</template>
|
||||
|
||||
<div class="row" style="text-align: center">
|
||||
<div class="col col-md-12">
|
||||
<template v-if="step.file !== null">
|
||||
<div 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" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<a :href="step.file.file" target="_blank" rel="noreferrer nofollow">{{ $t("Download") }} {{ $t("File") }}</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-collapse>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="start_time !== ''">
|
||||
<b-popover
|
||||
:target="`id_reactive_popover_${step.id}`"
|
||||
triggers="click"
|
||||
placement="bottom"
|
||||
:ref="`id_reactive_popover_${step.id}`"
|
||||
:title="$t('Step start time')">
|
||||
<div>
|
||||
<b-form-group
|
||||
label="Time"
|
||||
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>
|
||||
<div class="card" v-if="step.type === 'RECIPE' && step.step_recipe_data !== null">
|
||||
<b-collapse id="collapse-1" v-model="details_visible">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<a :href="resolveDjangoUrl('view_recipe', step.step_recipe_data.id)">{{ step.step_recipe_data.name }}</a>
|
||||
</h2>
|
||||
<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"
|
||||
:start_time="start_time"
|
||||
:force_ingredients="true"
|
||||
></step-component>
|
||||
</div>
|
||||
</div>
|
||||
</b-collapse>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 1vh">
|
||||
<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="updateTime" size="sm" variant="primary">Ok</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="start_time !== ''">
|
||||
<b-popover :target="`id_reactive_popover_${step.id}`" triggers="click" placement="bottom" :ref="`id_reactive_popover_${step.id}`" :title="$t('Step start time')">
|
||||
<div>
|
||||
<b-form-group label="Time" 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>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 1vh">
|
||||
<div class="col-12" style="text-align: right">
|
||||
<b-button @click="closePopover" size="sm" variant="secondary" style="margin-right: 8px">{{ $t("Cancel") }}</b-button>
|
||||
<b-button @click="updateTime" size="sm" variant="primary">{{ $t("Ok") }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 Vue from "vue";
|
||||
import moment from "moment";
|
||||
import {ResolveUrlMixin} from "@/utils/utils";
|
||||
import IngredientComponent from "@/components/IngredientComponent";
|
||||
import CompileComponent from "@/components/CompileComponent"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
import Vue from "vue"
|
||||
import moment from "moment"
|
||||
import { ResolveUrlMixin } from "@/utils/utils"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
export default {
|
||||
name: 'StepComponent',
|
||||
mixins: [
|
||||
GettextMixin,
|
||||
ResolveUrlMixin,
|
||||
],
|
||||
components: {
|
||||
IngredientComponent,
|
||||
CompileComponent,
|
||||
},
|
||||
props: {
|
||||
step: Object,
|
||||
ingredient_factor: Number,
|
||||
index: Number,
|
||||
recipe: Object,
|
||||
start_time: String,
|
||||
force_ingredients: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
details_visible: true,
|
||||
set_time_input: '',
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.set_time_input = moment(this.start_time).add(this.step.time_offset, 'minutes').format('yyyy-MM-DDTHH:mm')
|
||||
},
|
||||
methods: {
|
||||
calculateAmount: function (x) {
|
||||
// used by the jinja2 template
|
||||
return calculateAmount(x, this.ingredient_factor)
|
||||
name: "StepComponent",
|
||||
mixins: [GettextMixin, ResolveUrlMixin],
|
||||
components: { CompileComponent, IngredientsCard },
|
||||
props: {
|
||||
step: Object,
|
||||
ingredient_factor: Number,
|
||||
index: Number,
|
||||
recipe: Object,
|
||||
start_time: String,
|
||||
force_ingredients: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
updateTime: function () {
|
||||
let new_start_time = moment(this.set_time_input).add(this.step.time_offset * -1, 'minutes').format('yyyy-MM-DDTHH:mm')
|
||||
data() {
|
||||
return {
|
||||
details_visible: true,
|
||||
set_time_input: "",
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.set_time_input = moment(this.start_time).add(this.step.time_offset, "minutes").format("yyyy-MM-DDTHH:mm")
|
||||
},
|
||||
methods: {
|
||||
calculateAmount: function (x) {
|
||||
// used by the jinja2 template
|
||||
return calculateAmount(x, this.ingredient_factor)
|
||||
},
|
||||
updateTime: function () {
|
||||
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.closePopover()
|
||||
this.$emit("update-start-time", new_start_time)
|
||||
this.closePopover()
|
||||
},
|
||||
closePopover: function () {
|
||||
this.$refs[`id_reactive_popover_${this.step.id}`].$emit("close")
|
||||
},
|
||||
openPopover: function () {
|
||||
this.$refs[`id_reactive_popover_${this.step.id}`].$emit("open")
|
||||
},
|
||||
},
|
||||
closePopover: function () {
|
||||
this.$refs[`id_reactive_popover_${this.step.id}`].$emit('close')
|
||||
},
|
||||
openPopover: function () {
|
||||
this.$refs[`id_reactive_popover_${this.step.id}`].$emit('open')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
"Root": "Root",
|
||||
"Ignore_Shopping": "Ignore Shopping",
|
||||
"Shopping_Category": "Shopping Category",
|
||||
"Shopping_Categories": "Shopping Categories",
|
||||
"Edit_Food": "Edit Food",
|
||||
"Move_Food": "Move Food",
|
||||
"New_Food": "New Food",
|
||||
@@ -173,6 +174,15 @@
|
||||
"Time": "Time",
|
||||
"Text": "Text",
|
||||
"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",
|
||||
"Edit_Meal_Plan_Entry": "Edit meal plan entry",
|
||||
"Title": "Title",
|
||||
@@ -194,6 +204,11 @@
|
||||
"Title_or_Recipe_Required": "Title or recipe selection required",
|
||||
"Color": "Color",
|
||||
"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",
|
||||
"Show_Week_Numbers": "Show week numbers ?",
|
||||
"Export_As_ICal": "Export current period to iCal format",
|
||||
@@ -206,6 +221,35 @@
|
||||
"Current_Period": "Current Period",
|
||||
"Next_Day": "Next 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",
|
||||
"Auto_Planner": "Auto-Planner",
|
||||
"New_Cookbook": "New cookbook",
|
||||
@@ -214,5 +258,23 @@
|
||||
"err_move_self": "Cannot move item to itself",
|
||||
"nothing": "Nothing to do",
|
||||
"err_merge_self": "Cannot merge item with itself",
|
||||
"show_sql": "Show SQL"
|
||||
"show_sql": "Show SQL",
|
||||
"filter_to_supermarket_desc": "By default, filter shopping list to only include categories for selected supermarket.",
|
||||
"CategoryName": "Category Name",
|
||||
"SupermarketName": "Supermarket Name",
|
||||
"CategoryInstruction": "Drag categories to change the order categories appear in shopping list.",
|
||||
"shopping_recent_days_desc": "Days of recent shopping list entries to display.",
|
||||
"shopping_recent_days": "Recent Days",
|
||||
"create_shopping_new": "Add to NEW Shopping List",
|
||||
"download_pdf": "Download PDF",
|
||||
"download_csv": "Download CSV",
|
||||
"csv_delim_help": "Delimiter to use for CSV exports.",
|
||||
"csv_delim_label": "CSV Delimiter",
|
||||
"SuccessClipboard": "Shopping list copied to clipboard",
|
||||
"copy_to_clipboard": "Copy to Clipboard",
|
||||
"csv_prefix_help": "Prefix to add when copying list to the clipboard.",
|
||||
"csv_prefix_label": "List Prefix",
|
||||
"copy_markdown_table": "Copy as Markdown Table",
|
||||
"in_shopping": "In Shopping List",
|
||||
"DelayUntil": "Delay Until"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from "axios";
|
||||
import {djangoGettext as _, makeToast} from "@/utils/utils";
|
||||
import {resolveDjangoUrl} from "@/utils/utils";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||
|
||||
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
|
||||
@@ -47,4 +48,8 @@ function handleError(error, message) {
|
||||
makeToast('Error', message, 'danger')
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Generic class to use OpenAPIs with parameters and provide generic modals
|
||||
* */
|
||||
16
vue/src/utils/apiv2.js
Normal file
16
vue/src/utils/apiv2.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Utility functions to use OpenAPIs generically
|
||||
* */
|
||||
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||
|
||||
import axios from "axios";
|
||||
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
|
||||
|
||||
export class GenericAPI {
|
||||
constructor(model, action) {
|
||||
this.model = model;
|
||||
this.action = action;
|
||||
this.function_name = action + model
|
||||
}
|
||||
}
|
||||
@@ -65,14 +65,19 @@ export class Models {
|
||||
paginated: true,
|
||||
move: true,
|
||||
merge: true,
|
||||
shop: true,
|
||||
onhand: true,
|
||||
badges: {
|
||||
linked_recipe: true,
|
||||
food_onhand: true,
|
||||
shopping: true,
|
||||
},
|
||||
tags: [{ field: "supermarket_category", label: "name", color: "info" }],
|
||||
// REQUIRED: unordered array of fields that can be set during create
|
||||
create: {
|
||||
// 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: {
|
||||
name: {
|
||||
form_field: true,
|
||||
@@ -98,8 +103,8 @@ export class Models {
|
||||
shopping: {
|
||||
form_field: true,
|
||||
type: "checkbox",
|
||||
field: "ignore_shopping",
|
||||
label: i18n.t("Ignore_Shopping"),
|
||||
field: "food_onhand",
|
||||
label: i18n.t("OnHand"),
|
||||
},
|
||||
shopping_category: {
|
||||
form_field: true,
|
||||
@@ -109,8 +114,30 @@ export class Models {
|
||||
label: i18n.t("Shopping_Category"),
|
||||
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 = {
|
||||
@@ -147,6 +174,11 @@ export class Models {
|
||||
field: "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 = {
|
||||
name: i18n.t("Shopping_list"),
|
||||
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 = {
|
||||
@@ -370,41 +426,15 @@ export class Models {
|
||||
name: i18n.t("Recipe"),
|
||||
apiName: "Recipe",
|
||||
list: {
|
||||
params: [
|
||||
"query",
|
||||
"keywords",
|
||||
"foods",
|
||||
"units",
|
||||
"rating",
|
||||
"books",
|
||||
"steps",
|
||||
"keywordsOr",
|
||||
"foodsOr",
|
||||
"booksOr",
|
||||
"internal",
|
||||
"random",
|
||||
"_new",
|
||||
"page",
|
||||
"pageSize",
|
||||
"options",
|
||||
],
|
||||
config: {
|
||||
foods: { type: "string" },
|
||||
keywords: { type: "string" },
|
||||
books: { type: "string" },
|
||||
},
|
||||
params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"],
|
||||
// 'config': {
|
||||
// 'foods': {'type': 'string'},
|
||||
// 'keywords': {'type': 'string'},
|
||||
// 'books': {'type': 'string'},
|
||||
// }
|
||||
},
|
||||
}
|
||||
|
||||
static STEP = {
|
||||
name: i18n.t("Step"),
|
||||
apiName: "Step",
|
||||
paginated: true,
|
||||
list: {
|
||||
header_component: {
|
||||
name: "BetaWarning",
|
||||
},
|
||||
params: ["query", "page", "pageSize", "options"],
|
||||
shopping: {
|
||||
params: ["id", ["id", "list_recipe", "ingredients", "servings"]],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -639,4 +682,7 @@ export class Actions {
|
||||
},
|
||||
},
|
||||
}
|
||||
static SHOPPING = {
|
||||
function: "shopping",
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user