diff --git a/cookbook/admin.py b/cookbook/admin.py index e53a27a86..73e86ecf5 100644 --- a/cookbook/admin.py +++ b/cookbook/admin.py @@ -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) diff --git a/cookbook/apps.py b/cookbook/apps.py index 5b2777f2b..c785e5536 100644 --- a/cookbook/apps.py +++ b/cookbook/apps.py @@ -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 diff --git a/cookbook/forms.py b/cookbook/forms.py index d4b8c6e85..d99ea770d 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -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 + } diff --git a/cookbook/helper/HelperFunctions.py b/cookbook/helper/HelperFunctions.py new file mode 100644 index 000000000..cf04c3e2d --- /dev/null +++ b/cookbook/helper/HelperFunctions.py @@ -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") diff --git a/cookbook/helper/permission_helper.py b/cookbook/helper/permission_helper.py index cb3f791de..c577b56ca 100644 --- a/cookbook/helper/permission_helper.py +++ b/cookbook/helper/permission_helper.py @@ -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) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 850eb87db..9ad2b81f5 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -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 diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py new file mode 100644 index 000000000..75a70d36b --- /dev/null +++ b/cookbook/helper/shopping_helper.py @@ -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 diff --git a/cookbook/integration/integration.py b/cookbook/integration/integration.py index e7b681407..3b8034e37 100644 --- a/cookbook/integration/integration.py +++ b/cookbook/integration/integration.py @@ -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 diff --git a/cookbook/migrations/0143_build_full_text_index.py b/cookbook/migrations/0143_build_full_text_index.py index ca58fb0e1..927eaa94c 100644 --- a/cookbook/migrations/0143_build_full_text_index.py +++ b/cookbook/migrations/0143_build_full_text_index.py @@ -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) diff --git a/cookbook/migrations/0159_add_shoppinglistentry_fields.py b/cookbook/migrations/0159_add_shoppinglistentry_fields.py new file mode 100644 index 000000000..32b0c9ce5 --- /dev/null +++ b/cookbook/migrations/0159_add_shoppinglistentry_fields.py @@ -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), + ] diff --git a/cookbook/migrations/0160_delete_shoppinglist_orphans.py b/cookbook/migrations/0160_delete_shoppinglist_orphans.py new file mode 100644 index 000000000..26e086564 --- /dev/null +++ b/cookbook/migrations/0160_delete_shoppinglist_orphans.py @@ -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), + ] diff --git a/cookbook/migrations/0161_alter_shoppinglistentry_food.py b/cookbook/migrations/0161_alter_shoppinglistentry_food.py new file mode 100644 index 000000000..ab5653545 --- /dev/null +++ b/cookbook/migrations/0161_alter_shoppinglistentry_food.py @@ -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'), + ), + ] diff --git a/cookbook/migrations/0162_userpreference_csv_delim.py b/cookbook/migrations/0162_userpreference_csv_delim.py new file mode 100644 index 000000000..b7e4e548b --- /dev/null +++ b/cookbook/migrations/0162_userpreference_csv_delim.py @@ -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), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index aed927bde..bc167ba47 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -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 diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 20c6fec21..c43b52a7c 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -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', ] diff --git a/cookbook/signals.py b/cookbook/signals.py index dc820c113..47e5bf9ea 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -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) diff --git a/cookbook/static/django_js_reverse/reverse.js b/cookbook/static/django_js_reverse/reverse.js index af9a0e1df..437faa00a 100644 --- a/cookbook/static/django_js_reverse/reverse.js +++ b/cookbook/static/django_js_reverse/reverse.js @@ -1,4 +1,4 @@ -this.Urls=(function(){"use strict";var data={"urls":[["account_change_password",[["accounts/password/change/",[]]]],["account_confirm_email",[["accounts/confirm-email/%(key)s/",["key"]]]],["account_email",[["accounts/email/",[]]]],["account_email_verification_sent",[["accounts/confirm-email/",[]]]],["account_inactive",[["accounts/inactive/",[]]]],["account_login",[["accounts/login/",[]]]],["account_logout",[["accounts/logout/",[]]]],["account_reset_password",[["accounts/password/reset/",[]]]],["account_reset_password_done",[["accounts/password/reset/done/",[]]]],["account_reset_password_from_key",[["accounts/password/reset/key/%(uidb36)s-%(key)s/",["uidb36","key"]]]],["account_reset_password_from_key_done",[["accounts/password/reset/key/done/",[]]]],["account_set_password",[["accounts/password/set/",[]]]],["account_signup",[["accounts/signup/",[]]]],["admin:account_emailaddress_add",[["admin/account/emailaddress/add/",[]]]],["admin:account_emailaddress_change",[["admin/account/emailaddress/%(object_id)s/change/",["object_id"]]]],["admin:account_emailaddress_changelist",[["admin/account/emailaddress/",[]]]],["admin:account_emailaddress_delete",[["admin/account/emailaddress/%(object_id)s/delete/",["object_id"]]]],["admin:account_emailaddress_history",[["admin/account/emailaddress/%(object_id)s/history/",["object_id"]]]],["admin:app_list",[["admin/%(app_label)s/",["app_label"]]]],["admin:auth_user_add",[["admin/auth/user/add/",[]]]],["admin:auth_user_change",[["admin/auth/user/%(object_id)s/change/",["object_id"]]]],["admin:auth_user_changelist",[["admin/auth/user/",[]]]],["admin:auth_user_delete",[["admin/auth/user/%(object_id)s/delete/",["object_id"]]]],["admin:auth_user_history",[["admin/auth/user/%(object_id)s/history/",["object_id"]]]],["admin:auth_user_password_change",[["admin/auth/user/%(id)s/password/",["id"]]]],["admin:authtoken_tokenproxy_add",[["admin/authtoken/tokenproxy/add/",[]]]],["admin:authtoken_tokenproxy_change",[["admin/authtoken/tokenproxy/%(object_id)s/change/",["object_id"]]]],["admin:authtoken_tokenproxy_changelist",[["admin/authtoken/tokenproxy/",[]]]],["admin:authtoken_tokenproxy_delete",[["admin/authtoken/tokenproxy/%(object_id)s/delete/",["object_id"]]]],["admin:authtoken_tokenproxy_history",[["admin/authtoken/tokenproxy/%(object_id)s/history/",["object_id"]]]],["admin:autocomplete",[["admin/autocomplete/",[]]]],["admin:cookbook_bookmarkletimport_add",[["admin/cookbook/bookmarkletimport/add/",[]]]],["admin:cookbook_bookmarkletimport_change",[["admin/cookbook/bookmarkletimport/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_bookmarkletimport_changelist",[["admin/cookbook/bookmarkletimport/",[]]]],["admin:cookbook_bookmarkletimport_delete",[["admin/cookbook/bookmarkletimport/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_bookmarkletimport_history",[["admin/cookbook/bookmarkletimport/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_comment_add",[["admin/cookbook/comment/add/",[]]]],["admin:cookbook_comment_change",[["admin/cookbook/comment/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_comment_changelist",[["admin/cookbook/comment/",[]]]],["admin:cookbook_comment_delete",[["admin/cookbook/comment/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_comment_history",[["admin/cookbook/comment/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_cooklog_add",[["admin/cookbook/cooklog/add/",[]]]],["admin:cookbook_cooklog_change",[["admin/cookbook/cooklog/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_cooklog_changelist",[["admin/cookbook/cooklog/",[]]]],["admin:cookbook_cooklog_delete",[["admin/cookbook/cooklog/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_cooklog_history",[["admin/cookbook/cooklog/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_food_add",[["admin/cookbook/food/add/",[]]]],["admin:cookbook_food_change",[["admin/cookbook/food/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_food_changelist",[["admin/cookbook/food/",[]]]],["admin:cookbook_food_delete",[["admin/cookbook/food/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_food_history",[["admin/cookbook/food/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_importlog_add",[["admin/cookbook/importlog/add/",[]]]],["admin:cookbook_importlog_change",[["admin/cookbook/importlog/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_importlog_changelist",[["admin/cookbook/importlog/",[]]]],["admin:cookbook_importlog_delete",[["admin/cookbook/importlog/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_importlog_history",[["admin/cookbook/importlog/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_ingredient_add",[["admin/cookbook/ingredient/add/",[]]]],["admin:cookbook_ingredient_change",[["admin/cookbook/ingredient/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_ingredient_changelist",[["admin/cookbook/ingredient/",[]]]],["admin:cookbook_ingredient_delete",[["admin/cookbook/ingredient/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_ingredient_history",[["admin/cookbook/ingredient/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_invitelink_add",[["admin/cookbook/invitelink/add/",[]]]],["admin:cookbook_invitelink_change",[["admin/cookbook/invitelink/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_invitelink_changelist",[["admin/cookbook/invitelink/",[]]]],["admin:cookbook_invitelink_delete",[["admin/cookbook/invitelink/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_invitelink_history",[["admin/cookbook/invitelink/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_keyword_add",[["admin/cookbook/keyword/add/",[]]]],["admin:cookbook_keyword_change",[["admin/cookbook/keyword/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_keyword_changelist",[["admin/cookbook/keyword/",[]]]],["admin:cookbook_keyword_delete",[["admin/cookbook/keyword/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_keyword_history",[["admin/cookbook/keyword/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_mealplan_add",[["admin/cookbook/mealplan/add/",[]]]],["admin:cookbook_mealplan_change",[["admin/cookbook/mealplan/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_mealplan_changelist",[["admin/cookbook/mealplan/",[]]]],["admin:cookbook_mealplan_delete",[["admin/cookbook/mealplan/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_mealplan_history",[["admin/cookbook/mealplan/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_mealtype_add",[["admin/cookbook/mealtype/add/",[]]]],["admin:cookbook_mealtype_change",[["admin/cookbook/mealtype/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_mealtype_changelist",[["admin/cookbook/mealtype/",[]]]],["admin:cookbook_mealtype_delete",[["admin/cookbook/mealtype/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_mealtype_history",[["admin/cookbook/mealtype/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_nutritioninformation_add",[["admin/cookbook/nutritioninformation/add/",[]]]],["admin:cookbook_nutritioninformation_change",[["admin/cookbook/nutritioninformation/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_nutritioninformation_changelist",[["admin/cookbook/nutritioninformation/",[]]]],["admin:cookbook_nutritioninformation_delete",[["admin/cookbook/nutritioninformation/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_nutritioninformation_history",[["admin/cookbook/nutritioninformation/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_recipe_add",[["admin/cookbook/recipe/add/",[]]]],["admin:cookbook_recipe_change",[["admin/cookbook/recipe/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_recipe_changelist",[["admin/cookbook/recipe/",[]]]],["admin:cookbook_recipe_delete",[["admin/cookbook/recipe/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_recipe_history",[["admin/cookbook/recipe/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_recipebook_add",[["admin/cookbook/recipebook/add/",[]]]],["admin:cookbook_recipebook_change",[["admin/cookbook/recipebook/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_recipebook_changelist",[["admin/cookbook/recipebook/",[]]]],["admin:cookbook_recipebook_delete",[["admin/cookbook/recipebook/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_recipebook_history",[["admin/cookbook/recipebook/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_recipebookentry_add",[["admin/cookbook/recipebookentry/add/",[]]]],["admin:cookbook_recipebookentry_change",[["admin/cookbook/recipebookentry/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_recipebookentry_changelist",[["admin/cookbook/recipebookentry/",[]]]],["admin:cookbook_recipebookentry_delete",[["admin/cookbook/recipebookentry/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_recipebookentry_history",[["admin/cookbook/recipebookentry/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_recipeimport_add",[["admin/cookbook/recipeimport/add/",[]]]],["admin:cookbook_recipeimport_change",[["admin/cookbook/recipeimport/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_recipeimport_changelist",[["admin/cookbook/recipeimport/",[]]]],["admin:cookbook_recipeimport_delete",[["admin/cookbook/recipeimport/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_recipeimport_history",[["admin/cookbook/recipeimport/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_searchpreference_add",[["admin/cookbook/searchpreference/add/",[]]]],["admin:cookbook_searchpreference_change",[["admin/cookbook/searchpreference/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_searchpreference_changelist",[["admin/cookbook/searchpreference/",[]]]],["admin:cookbook_searchpreference_delete",[["admin/cookbook/searchpreference/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_searchpreference_history",[["admin/cookbook/searchpreference/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_sharelink_add",[["admin/cookbook/sharelink/add/",[]]]],["admin:cookbook_sharelink_change",[["admin/cookbook/sharelink/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_sharelink_changelist",[["admin/cookbook/sharelink/",[]]]],["admin:cookbook_sharelink_delete",[["admin/cookbook/sharelink/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_sharelink_history",[["admin/cookbook/sharelink/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_shoppinglist_add",[["admin/cookbook/shoppinglist/add/",[]]]],["admin:cookbook_shoppinglist_change",[["admin/cookbook/shoppinglist/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_shoppinglist_changelist",[["admin/cookbook/shoppinglist/",[]]]],["admin:cookbook_shoppinglist_delete",[["admin/cookbook/shoppinglist/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_shoppinglist_history",[["admin/cookbook/shoppinglist/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_shoppinglistentry_add",[["admin/cookbook/shoppinglistentry/add/",[]]]],["admin:cookbook_shoppinglistentry_change",[["admin/cookbook/shoppinglistentry/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_shoppinglistentry_changelist",[["admin/cookbook/shoppinglistentry/",[]]]],["admin:cookbook_shoppinglistentry_delete",[["admin/cookbook/shoppinglistentry/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_shoppinglistentry_history",[["admin/cookbook/shoppinglistentry/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_shoppinglistrecipe_add",[["admin/cookbook/shoppinglistrecipe/add/",[]]]],["admin:cookbook_shoppinglistrecipe_change",[["admin/cookbook/shoppinglistrecipe/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_shoppinglistrecipe_changelist",[["admin/cookbook/shoppinglistrecipe/",[]]]],["admin:cookbook_shoppinglistrecipe_delete",[["admin/cookbook/shoppinglistrecipe/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_shoppinglistrecipe_history",[["admin/cookbook/shoppinglistrecipe/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_space_add",[["admin/cookbook/space/add/",[]]]],["admin:cookbook_space_change",[["admin/cookbook/space/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_space_changelist",[["admin/cookbook/space/",[]]]],["admin:cookbook_space_delete",[["admin/cookbook/space/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_space_history",[["admin/cookbook/space/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_step_add",[["admin/cookbook/step/add/",[]]]],["admin:cookbook_step_change",[["admin/cookbook/step/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_step_changelist",[["admin/cookbook/step/",[]]]],["admin:cookbook_step_delete",[["admin/cookbook/step/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_step_history",[["admin/cookbook/step/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_storage_add",[["admin/cookbook/storage/add/",[]]]],["admin:cookbook_storage_change",[["admin/cookbook/storage/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_storage_changelist",[["admin/cookbook/storage/",[]]]],["admin:cookbook_storage_delete",[["admin/cookbook/storage/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_storage_history",[["admin/cookbook/storage/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_supermarket_add",[["admin/cookbook/supermarket/add/",[]]]],["admin:cookbook_supermarket_change",[["admin/cookbook/supermarket/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_supermarket_changelist",[["admin/cookbook/supermarket/",[]]]],["admin:cookbook_supermarket_delete",[["admin/cookbook/supermarket/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_supermarket_history",[["admin/cookbook/supermarket/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_supermarketcategory_add",[["admin/cookbook/supermarketcategory/add/",[]]]],["admin:cookbook_supermarketcategory_change",[["admin/cookbook/supermarketcategory/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_supermarketcategory_changelist",[["admin/cookbook/supermarketcategory/",[]]]],["admin:cookbook_supermarketcategory_delete",[["admin/cookbook/supermarketcategory/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_supermarketcategory_history",[["admin/cookbook/supermarketcategory/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_sync_add",[["admin/cookbook/sync/add/",[]]]],["admin:cookbook_sync_change",[["admin/cookbook/sync/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_sync_changelist",[["admin/cookbook/sync/",[]]]],["admin:cookbook_sync_delete",[["admin/cookbook/sync/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_sync_history",[["admin/cookbook/sync/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_synclog_add",[["admin/cookbook/synclog/add/",[]]]],["admin:cookbook_synclog_change",[["admin/cookbook/synclog/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_synclog_changelist",[["admin/cookbook/synclog/",[]]]],["admin:cookbook_synclog_delete",[["admin/cookbook/synclog/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_synclog_history",[["admin/cookbook/synclog/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_telegrambot_add",[["admin/cookbook/telegrambot/add/",[]]]],["admin:cookbook_telegrambot_change",[["admin/cookbook/telegrambot/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_telegrambot_changelist",[["admin/cookbook/telegrambot/",[]]]],["admin:cookbook_telegrambot_delete",[["admin/cookbook/telegrambot/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_telegrambot_history",[["admin/cookbook/telegrambot/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_unit_add",[["admin/cookbook/unit/add/",[]]]],["admin:cookbook_unit_change",[["admin/cookbook/unit/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_unit_changelist",[["admin/cookbook/unit/",[]]]],["admin:cookbook_unit_delete",[["admin/cookbook/unit/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_unit_history",[["admin/cookbook/unit/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_userfile_add",[["admin/cookbook/userfile/add/",[]]]],["admin:cookbook_userfile_change",[["admin/cookbook/userfile/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_userfile_changelist",[["admin/cookbook/userfile/",[]]]],["admin:cookbook_userfile_delete",[["admin/cookbook/userfile/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_userfile_history",[["admin/cookbook/userfile/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_userpreference_add",[["admin/cookbook/userpreference/add/",[]]]],["admin:cookbook_userpreference_change",[["admin/cookbook/userpreference/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_userpreference_changelist",[["admin/cookbook/userpreference/",[]]]],["admin:cookbook_userpreference_delete",[["admin/cookbook/userpreference/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_userpreference_history",[["admin/cookbook/userpreference/%(object_id)s/history/",["object_id"]]]],["admin:cookbook_viewlog_add",[["admin/cookbook/viewlog/add/",[]]]],["admin:cookbook_viewlog_change",[["admin/cookbook/viewlog/%(object_id)s/change/",["object_id"]]]],["admin:cookbook_viewlog_changelist",[["admin/cookbook/viewlog/",[]]]],["admin:cookbook_viewlog_delete",[["admin/cookbook/viewlog/%(object_id)s/delete/",["object_id"]]]],["admin:cookbook_viewlog_history",[["admin/cookbook/viewlog/%(object_id)s/history/",["object_id"]]]],["admin:index",[["admin/",[]]]],["admin:javascript-catalog",[["admin/cookbook/food/jsi18n/",[]],["admin/cookbook/keyword/jsi18n/",[]]]],["admin:jsi18n",[["admin/jsi18n/",[]]]],["admin:login",[["admin/login/",[]]]],["admin:logout",[["admin/logout/",[]]]],["admin:password_change",[["admin/password_change/",[]]]],["admin:password_change_done",[["admin/password_change/done/",[]]]],["admin:sites_site_add",[["admin/sites/site/add/",[]]]],["admin:sites_site_change",[["admin/sites/site/%(object_id)s/change/",["object_id"]]]],["admin:sites_site_changelist",[["admin/sites/site/",[]]]],["admin:sites_site_delete",[["admin/sites/site/%(object_id)s/delete/",["object_id"]]]],["admin:sites_site_history",[["admin/sites/site/%(object_id)s/history/",["object_id"]]]],["admin:socialaccount_socialaccount_add",[["admin/socialaccount/socialaccount/add/",[]]]],["admin:socialaccount_socialaccount_change",[["admin/socialaccount/socialaccount/%(object_id)s/change/",["object_id"]]]],["admin:socialaccount_socialaccount_changelist",[["admin/socialaccount/socialaccount/",[]]]],["admin:socialaccount_socialaccount_delete",[["admin/socialaccount/socialaccount/%(object_id)s/delete/",["object_id"]]]],["admin:socialaccount_socialaccount_history",[["admin/socialaccount/socialaccount/%(object_id)s/history/",["object_id"]]]],["admin:socialaccount_socialapp_add",[["admin/socialaccount/socialapp/add/",[]]]],["admin:socialaccount_socialapp_change",[["admin/socialaccount/socialapp/%(object_id)s/change/",["object_id"]]]],["admin:socialaccount_socialapp_changelist",[["admin/socialaccount/socialapp/",[]]]],["admin:socialaccount_socialapp_delete",[["admin/socialaccount/socialapp/%(object_id)s/delete/",["object_id"]]]],["admin:socialaccount_socialapp_history",[["admin/socialaccount/socialapp/%(object_id)s/history/",["object_id"]]]],["admin:socialaccount_socialtoken_add",[["admin/socialaccount/socialtoken/add/",[]]]],["admin:socialaccount_socialtoken_change",[["admin/socialaccount/socialtoken/%(object_id)s/change/",["object_id"]]]],["admin:socialaccount_socialtoken_changelist",[["admin/socialaccount/socialtoken/",[]]]],["admin:socialaccount_socialtoken_delete",[["admin/socialaccount/socialtoken/%(object_id)s/delete/",["object_id"]]]],["admin:socialaccount_socialtoken_history",[["admin/socialaccount/socialtoken/%(object_id)s/history/",["object_id"]]]],["admin:view_on_site",[["admin/r/%(content_type_id)s/%(object_id)s/",["content_type_id","object_id"]]]],["api:api-root",[["api/.%(format)s",["format"]],["api/",[]]]],["api:automation-detail",[["api/automation/%(pk)s.%(format)s",["pk","format"]],["api/automation/%(pk)s/",["pk"]]]],["api:automation-list",[["api/automation.%(format)s",["format"]],["api/automation/",[]]]],["api:bookmarkletimport-detail",[["api/bookmarklet-import/%(pk)s.%(format)s",["pk","format"]],["api/bookmarklet-import/%(pk)s/",["pk"]]]],["api:bookmarkletimport-list",[["api/bookmarklet-import.%(format)s",["format"]],["api/bookmarklet-import/",[]]]],["api:cooklog-detail",[["api/cook-log/%(pk)s.%(format)s",["pk","format"]],["api/cook-log/%(pk)s/",["pk"]]]],["api:cooklog-list",[["api/cook-log.%(format)s",["format"]],["api/cook-log/",[]]]],["api:food-detail",[["api/food/%(pk)s.%(format)s",["pk","format"]],["api/food/%(pk)s/",["pk"]]]],["api:food-list",[["api/food.%(format)s",["format"]],["api/food/",[]]]],["api:food-merge",[["api/food/%(pk)s/merge/%(target)s.%(format)s",["pk","target","format"]],["api/food/%(pk)s/merge/%(target)s/",["pk","target"]]]],["api:food-move",[["api/food/%(pk)s/move/%(parent)s.%(format)s",["pk","parent","format"]],["api/food/%(pk)s/move/%(parent)s/",["pk","parent"]]]],["api:importlog-detail",[["api/import-log/%(pk)s.%(format)s",["pk","format"]],["api/import-log/%(pk)s/",["pk"]]]],["api:importlog-list",[["api/import-log.%(format)s",["format"]],["api/import-log/",[]]]],["api:ingredient-detail",[["api/ingredient/%(pk)s.%(format)s",["pk","format"]],["api/ingredient/%(pk)s/",["pk"]]]],["api:ingredient-list",[["api/ingredient.%(format)s",["format"]],["api/ingredient/",[]]]],["api:keyword-detail",[["api/keyword/%(pk)s.%(format)s",["pk","format"]],["api/keyword/%(pk)s/",["pk"]]]],["api:keyword-list",[["api/keyword.%(format)s",["format"]],["api/keyword/",[]]]],["api:keyword-merge",[["api/keyword/%(pk)s/merge/%(target)s.%(format)s",["pk","target","format"]],["api/keyword/%(pk)s/merge/%(target)s/",["pk","target"]]]],["api:keyword-move",[["api/keyword/%(pk)s/move/%(parent)s.%(format)s",["pk","parent","format"]],["api/keyword/%(pk)s/move/%(parent)s/",["pk","parent"]]]],["api:mealplan-detail",[["api/meal-plan/%(pk)s.%(format)s",["pk","format"]],["api/meal-plan/%(pk)s/",["pk"]]]],["api:mealplan-list",[["api/meal-plan.%(format)s",["format"]],["api/meal-plan/",[]]]],["api:mealtype-detail",[["api/meal-type/%(pk)s.%(format)s",["pk","format"]],["api/meal-type/%(pk)s/",["pk"]]]],["api:mealtype-list",[["api/meal-type.%(format)s",["format"]],["api/meal-type/",[]]]],["api:recipe-detail",[["api/recipe/%(pk)s.%(format)s",["pk","format"]],["api/recipe/%(pk)s/",["pk"]]]],["api:recipe-image",[["api/recipe/%(pk)s/image.%(format)s",["pk","format"]],["api/recipe/%(pk)s/image/",["pk"]]]],["api:recipe-list",[["api/recipe.%(format)s",["format"]],["api/recipe/",[]]]],["api:recipebook-detail",[["api/recipe-book/%(pk)s.%(format)s",["pk","format"]],["api/recipe-book/%(pk)s/",["pk"]]]],["api:recipebook-list",[["api/recipe-book.%(format)s",["format"]],["api/recipe-book/",[]]]],["api:recipebookentry-detail",[["api/recipe-book-entry/%(pk)s.%(format)s",["pk","format"]],["api/recipe-book-entry/%(pk)s/",["pk"]]]],["api:recipebookentry-list",[["api/recipe-book-entry.%(format)s",["format"]],["api/recipe-book-entry/",[]]]],["api:shoppinglist-detail",[["api/shopping-list/%(pk)s.%(format)s",["pk","format"]],["api/shopping-list/%(pk)s/",["pk"]]]],["api:shoppinglist-list",[["api/shopping-list.%(format)s",["format"]],["api/shopping-list/",[]]]],["api:shoppinglistentry-detail",[["api/shopping-list-entry/%(pk)s.%(format)s",["pk","format"]],["api/shopping-list-entry/%(pk)s/",["pk"]]]],["api:shoppinglistentry-list",[["api/shopping-list-entry.%(format)s",["format"]],["api/shopping-list-entry/",[]]]],["api:shoppinglistrecipe-detail",[["api/shopping-list-recipe/%(pk)s.%(format)s",["pk","format"]],["api/shopping-list-recipe/%(pk)s/",["pk"]]]],["api:shoppinglistrecipe-list",[["api/shopping-list-recipe.%(format)s",["format"]],["api/shopping-list-recipe/",[]]]],["api:step-detail",[["api/step/%(pk)s.%(format)s",["pk","format"]],["api/step/%(pk)s/",["pk"]]]],["api:step-list",[["api/step.%(format)s",["format"]],["api/step/",[]]]],["api:storage-detail",[["api/storage/%(pk)s.%(format)s",["pk","format"]],["api/storage/%(pk)s/",["pk"]]]],["api:storage-list",[["api/storage.%(format)s",["format"]],["api/storage/",[]]]],["api:supermarket-detail",[["api/supermarket/%(pk)s.%(format)s",["pk","format"]],["api/supermarket/%(pk)s/",["pk"]]]],["api:supermarket-list",[["api/supermarket.%(format)s",["format"]],["api/supermarket/",[]]]],["api:supermarketcategory-detail",[["api/supermarket-category/%(pk)s.%(format)s",["pk","format"]],["api/supermarket-category/%(pk)s/",["pk"]]]],["api:supermarketcategory-list",[["api/supermarket-category.%(format)s",["format"]],["api/supermarket-category/",[]]]],["api:supermarketcategoryrelation-detail",[["api/supermarket-category-relation/%(pk)s.%(format)s",["pk","format"]],["api/supermarket-category-relation/%(pk)s/",["pk"]]]],["api:supermarketcategoryrelation-list",[["api/supermarket-category-relation.%(format)s",["format"]],["api/supermarket-category-relation/",[]]]],["api:sync-detail",[["api/sync/%(pk)s.%(format)s",["pk","format"]],["api/sync/%(pk)s/",["pk"]]]],["api:sync-list",[["api/sync.%(format)s",["format"]],["api/sync/",[]]]],["api:synclog-detail",[["api/sync-log/%(pk)s.%(format)s",["pk","format"]],["api/sync-log/%(pk)s/",["pk"]]]],["api:synclog-list",[["api/sync-log.%(format)s",["format"]],["api/sync-log/",[]]]],["api:unit-detail",[["api/unit/%(pk)s.%(format)s",["pk","format"]],["api/unit/%(pk)s/",["pk"]]]],["api:unit-list",[["api/unit.%(format)s",["format"]],["api/unit/",[]]]],["api:unit-merge",[["api/unit/%(pk)s/merge/%(target)s.%(format)s",["pk","target","format"]],["api/unit/%(pk)s/merge/%(target)s/",["pk","target"]]]],["api:userfile-detail",[["api/user-file/%(pk)s.%(format)s",["pk","format"]],["api/user-file/%(pk)s/",["pk"]]]],["api:userfile-list",[["api/user-file.%(format)s",["format"]],["api/user-file/",[]]]],["api:username-detail",[["api/user-name/%(pk)s.%(format)s",["pk","format"]],["api/user-name/%(pk)s/",["pk"]]]],["api:username-list",[["api/user-name.%(format)s",["format"]],["api/user-name/",[]]]],["api:userpreference-detail",[["api/user-preference/%(pk)s.%(format)s",["pk","format"]],["api/user-preference/%(pk)s/",["pk"]]]],["api:userpreference-list",[["api/user-preference.%(format)s",["format"]],["api/user-preference/",[]]]],["api:viewlog-detail",[["api/view-log/%(pk)s.%(format)s",["pk","format"]],["api/view-log/%(pk)s/",["pk"]]]],["api:viewlog-list",[["api/view-log.%(format)s",["format"]],["api/view-log/",[]]]],["api_backup",[["api/backup/",[]]]],["api_get_external_file_link",[["api/get_external_file_link/%(recipe_id)s/",["recipe_id"]]]],["api_get_facets",[["api/get_facets/",[]]]],["api_get_plan_ical",[["api/plan-ical/%(from_date)s/%(to_date)s/",["from_date","to_date"]]]],["api_get_recipe_file",[["api/get_recipe_file/%(recipe_id)s/",["recipe_id"]]]],["api_ingredient_from_string",[["api/ingredient-from-string/",[]]]],["api_log_cooking",[["api/log_cooking/%(recipe_id)s/",["recipe_id"]]]],["api_recipe_from_source",[["api/recipe-from-source/",[]]]],["api_share_link",[["api/share-link/%(pk)s",["pk"]]]],["api_sync",[["api/sync_all/",[]]]],["change_space_member",[["space/member/%(user_id)s/%(space_id)s/%(group)s",["user_id","space_id","group"]]]],["dal_food",[["dal/food/",[]]]],["dal_keyword",[["dal/keyword/",[]]]],["dal_unit",[["dal/unit/",[]]]],["data_batch_edit",[["data/batch/edit",[]]]],["data_batch_import",[["data/batch/import",[]]]],["data_import_url",[["data/import/url",[]]]],["data_stats",[["data/statistics",[]]]],["data_sync",[["data/sync",[]]]],["data_sync_wait",[["data/sync/wait",[]]]],["delete_comment",[["delete/comment/%(pk)s/",["pk"]]]],["delete_invite_link",[["delete/invite-link/%(pk)s/",["pk"]]]],["delete_meal_plan",[["delete/meal-plan/%(pk)s/",["pk"]]]],["delete_recipe",[["delete/recipe/%(pk)s/",["pk"]]]],["delete_recipe_book",[["delete/recipe-book/%(pk)s/",["pk"]]]],["delete_recipe_book_entry",[["delete/recipe-book-entry/%(pk)s/",["pk"]]]],["delete_recipe_import",[["delete/recipe-import/%(pk)s/",["pk"]]]],["delete_recipe_source",[["delete/recipe-source/%(pk)s/",["pk"]]]],["delete_storage",[["delete/storage/%(pk)s/",["pk"]]]],["delete_sync",[["delete/sync/%(pk)s/",["pk"]]]],["docs_api",[["docs/api/",[]]]],["docs_markdown",[["docs/markdown/",[]]]],["docs_search",[["docs/search/",[]]]],["edit_comment",[["edit/comment/%(pk)s/",["pk"]]]],["edit_convert_recipe",[["edit/recipe/convert/%(pk)s/",["pk"]]]],["edit_external_recipe",[["edit/recipe/external/%(pk)s/",["pk"]]]],["edit_internal_recipe",[["edit/recipe/internal/%(pk)s/",["pk"]]]],["edit_meal_plan",[["edit/meal-plan/%(pk)s/",["pk"]]]],["edit_recipe",[["edit/recipe/%(pk)s/",["pk"]]]],["edit_storage",[["edit/storage/%(pk)s/",["pk"]]]],["edit_sync",[["edit/sync/%(pk)s/",["pk"]]]],["index",[["",[]]]],["javascript-catalog",[["jsi18n/",[]]]],["js_reverse",[["jsreverse.json",[]]]],["list_automation",[["list/automation/",[]]]],["list_food",[["list/food/",[]]]],["list_invite_link",[["list/invite-link/",[]]]],["list_keyword",[["list/keyword/",[]]]],["list_recipe_import",[["list/recipe-import/",[]]]],["list_shopping_list",[["list/shopping-list/",[]]]],["list_step",[["list/step/",[]]]],["list_storage",[["list/storage/",[]]]],["list_supermarket",[["list/supermarket/",[]]]],["list_supermarket_category",[["list/supermarket-category/",[]]]],["list_sync_log",[["list/sync-log/",[]]]],["list_unit",[["list/unit/",[]]]],["list_user_file",[["list/user-file/",[]]]],["new_invite_link",[["new/invite-link/",[]]]],["new_meal_plan",[["new/meal-plan/",[]]]],["new_recipe",[["new/recipe/",[]]]],["new_recipe_import",[["new/recipe-import/%(import_id)s/",["import_id"]]]],["new_share_link",[["new/share-link/%(pk)s/",["pk"]]]],["new_storage",[["new/storage/",[]]]],["openapi-schema",[["openapi/",[]]]],["rest_framework:login",[["api-auth/login/",[]]]],["rest_framework:logout",[["api-auth/logout/",[]]]],["service_worker",[["service-worker.js",[]]]],["set_language",[["i18n/setlang/",[]]]],["socialaccount_connections",[["accounts/social/connections/",[]]]],["socialaccount_login_cancelled",[["accounts/social/login/cancelled/",[]]]],["socialaccount_login_error",[["accounts/social/login/error/",[]]]],["socialaccount_signup",[["accounts/social/signup/",[]]]],["telegram_hook",[["telegram/hook/%(token)s/",["token"]]]],["telegram_remove",[["telegram/remove/%(pk)s",["pk"]]]],["telegram_setup",[["telegram/setup/%(pk)s",["pk"]]]],["view_books",[["books/",[]]]],["view_export",[["export/",[]]]],["view_history",[["history/",[]]]],["view_import",[["import/",[]]]],["view_import_response",[["import-response/%(pk)s/",["pk"]]]],["view_invite",[["invite/%(token)s",["token"]]]],["view_no_group",[["no-group",[]]]],["view_no_perm",[["no-perm",[]]]],["view_no_space",[["no-space",[]]]],["view_offline",[["offline/",[]]]],["view_plan",[["plan/",[]]]],["view_plan_entry",[["plan/entry/%(pk)s",["pk"]]]],["view_recipe",[["view/recipe/%(pk)s/%(share)s",["pk","share"]],["view/recipe/%(pk)s",["pk"]]]],["view_report_share_abuse",[["abuse/%(token)s",["token"]]]],["view_search",[["search/",[]]]],["view_search_v2",[["search/v2/",[]]]],["view_settings",[["settings/",[]]]],["view_setup",[["setup/",[]]]],["view_shopping",[["shopping/%(pk)s",["pk"]],["shopping/",[]]]],["view_shopping_latest",[["shopping/latest/",[]]]],["view_shopping_new",[["shopping/new/",[]]]],["view_signup",[["signup/%(token)s",["token"]]]],["view_space",[["space/",[]]]],["view_supermarket",[["supermarket/",[]]]],["view_system",[["system/",[]]]],["web_manifest",[["manifest.json",[]]]]],"prefix":"/"};function factory(d){var url_patterns=d.urls;var url_prefix=d.prefix;var Urls={};var self_url_patterns={};var _get_url=function(url_pattern){return function(){var _arguments,index,url,url_arg,url_args,_i,_len,_ref,_ref_list,match_ref,provided_keys,build_kwargs;_arguments=arguments;_ref_list=self_url_patterns[url_pattern];if(arguments.length==1&&typeof(arguments[0])=="object"){var provided_keys_list=Object.keys(arguments[0]);provided_keys={};for(_i=0;_i {% block script %} diff --git a/cookbook/templates/generic/checklist_template.html b/cookbook/templates/generic/checklist_template.html deleted file mode 100644 index e58ab5f72..000000000 --- a/cookbook/templates/generic/checklist_template.html +++ /dev/null @@ -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 %} - -
- -
- - -{% endblock %} - - -{% block script %} - {{ config | json_script:"model_config" }} - - {% if debug %} - - {% else %} - - {% endif %} - - - - {% render_bundle 'checklist_view' %} -{% endblock %} \ No newline at end of file diff --git a/cookbook/templates/generic/list_template.html b/cookbook/templates/generic/list_template.html index 673d9103e..7d4be443d 100644 --- a/cookbook/templates/generic/list_template.html +++ b/cookbook/templates/generic/list_template.html @@ -18,12 +18,23 @@ {% endif %}
-

{{ title }} {% trans 'List' %} - {% if create_url %} - + +

{{ title }} {% trans 'List' %} + {% if create_url %} + + + {% endif %} +

+ + {% if request.resolver_match.url_name in 'list_shopping_list' %} + + + - {% endif %} -

+ + {% endif %} {% if filter %}
diff --git a/cookbook/templates/include/log_cooking.html b/cookbook/templates/include/log_cooking.html index b7549f190..3c586c948 100644 --- a/cookbook/templates/include/log_cooking.html +++ b/cookbook/templates/include/log_cooking.html @@ -1,4 +1,5 @@ {% load i18n %} +{% comment %} TODO: Deprecate {% endcomment %} @@ -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 %} + } {% endblock %} diff --git a/cookbook/templates/shopping_list.html b/cookbook/templates/shopping_list.html index e16ce82a0..dcc4ba531 100644 --- a/cookbook/templates/shopping_list.html +++ b/cookbook/templates/shopping_list.html @@ -1,973 +1,921 @@ {% extends "base.html" %} +{% comment %} TODO: Deprecate {% endcomment %} {% load django_tables2 %} {% load crispy_forms_tags %} {% load static %} {% load i18n %} - {% block title %}{% trans "Shopping List" %}{% endblock %} - {% block extra_head %} - {% include 'include/vue_base.html' %} +{% include 'include/vue_base.html' %} - - + + - - + + - + - + - + {% endblock %} {% block content %} -
- -

{% trans 'Shopping List' %}

-
-
- {% trans 'Edit' %} -
+
+ +

{% trans 'Shopping List' %}

+
+ + + + + +
+ {% trans 'Edit' %}
+
-