diff --git a/cookbook/admin.py b/cookbook/admin.py index 4c4fc522..2237c516 100644 --- a/cookbook/admin.py +++ b/cookbook/admin.py @@ -280,7 +280,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/forms.py b/cookbook/forms.py index 37a32638..1801efd9 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, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook, + RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference) class SelectWidget(widgets.Select): @@ -19,6 +17,7 @@ class SelectWidget(widgets.Select): class MultiSelectWidget(widgets.SelectMultiple): + class Media: js = ('custom/js/form_multiselect.js',) @@ -46,8 +45,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 = { @@ -75,20 +73,26 @@ class UserPreferenceForm(forms.ModelForm): # 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.'), + '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 'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501 '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 ), - '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, + } @@ -262,6 +266,7 @@ class SyncForm(forms.ModelForm): } +# TODO deprecate class BatchEditForm(forms.Form): search = forms.CharField(label=_('Search String')) keywords = forms.ModelMultipleChoiceField( @@ -298,6 +303,7 @@ class ImportRecipeForm(forms.ModelForm): } +# TODO deprecate class MealPlanForm(forms.ModelForm): def __init__(self, *args, **kwargs): space = kwargs.pop('space') @@ -420,10 +426,8 @@ class UserCreateForm(forms.Form): class SearchPreferenceForm(forms.ModelForm): prefix = 'search' - trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, - widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}), - help_text=_( - 'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).')) + trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}), + help_text=_('Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).')) preset = forms.CharField(widget=forms.HiddenInput(), required=False) class Meta: @@ -465,3 +469,59 @@ 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' + ) + + help_texts = { + 'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'), + 'shopping_auto_sync': _( + 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501 + 'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501 + ), + 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), + 'mealplan_autoexclude_onhand': _('When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.'), + 'mealplan_autoinclude_related': _('When automatically adding a meal plan to the shopping list, include all related recipes.'), + 'default_delay': _('Default number of hours to delay a shopping list entry.'), + 'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'), + } + labels = { + 'shopping_share': _('Share Shopping List'), + 'shopping_auto_sync': _('Autosync'), + 'mealplan_autoadd_shopping': _('Auto Add Meal Plan'), + 'mealplan_autoexclude_onhand': _('Exclude On Hand'), + 'mealplan_autoinclude_related': _('Include Related'), + 'default_delay': _('Default Delay Hours'), + 'filter_to_supermarket': _('Filter to Supermarket'), + } + + widgets = { + 'shopping_share': MultiSelectWidget + } + + +class SpacePreferenceForm(forms.ModelForm): + prefix = 'space' + reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False, + help_text=_("Reset all food to inherit the fields configured.")) + + class Meta: + model = Space + + fields = ('food_inherit', 'reset_food_inherit',) + + help_texts = { + 'food_inherit': _('Fields on food that should be inherited by default.'), } + + widgets = { + 'food_inherit': MultiSelectWidget + } diff --git a/cookbook/helper/HelperFunctions.py b/cookbook/helper/HelperFunctions.py new file mode 100644 index 00000000..cf04c3e2 --- /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 cb3f791d..ea6bcdb5 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): """ @@ -79,7 +79,11 @@ def is_object_shared(user, obj): # share checks for relevant objects if not user.is_authenticated: return False - return user in obj.get_shared() + if obj.__class__.__name__ == 'ShoppingListEntry': + # shopping lists are shared all or none and stored in user preferences + return obj.created_by in user.get_shopping_share() + else: + return user in obj.get_shared() def share_link_valid(recipe, share): diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 850eb87d..ce87e0aa 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): @@ -49,7 +38,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,6 +197,7 @@ 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. diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py new file mode 100644 index 00000000..26d590f8 --- /dev/null +++ b/cookbook/helper/shopping_helper.py @@ -0,0 +1,40 @@ +from datetime import timedelta + +from django.contrib.postgres.aggregates import ArrayAgg +from django.db.models import F, OuterRef, Q, Subquery, Value +from django.db.models.functions import Coalesce +from django.utils import timezone +from django.utils.translation import gettext as _ + +from cookbook.helper.HelperFunctions import Round, str2bool +from cookbook.models import SupermarketCategoryRelation +from recipes import settings + + +def shopping_helper(qs, request): + supermarket = request.query_params.get('supermarket', None) + checked = request.query_params.get('checked', 'recent') + + supermarket_order = ['food__supermarket_category__name', 'food__name'] + + # TODO created either scheduled task or startup task to delete very old shopping list entries + # TODO create user preference to define 'very old' + + # qs = qs.annotate(supermarket_category=Coalesce(F('food__supermarket_category__name'), Value(_('Undefined')))) + # TODO add supermarket to API - order by category order + if supermarket: + supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category')) + qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999))) + supermarket_order = ['supermarket_order'] + supermarket_order + if checked in ['false', 0, '0']: + qs = qs.filter(checked=False) + elif checked in ['true', 1, '1']: + qs = qs.filter(checked=True) + elif checked in ['recent']: + today_start = timezone.now().replace(hour=0, minute=0, second=0) + # TODO make recent a user setting + week_ago = today_start - timedelta(days=7) + qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago)) + supermarket_order = ['checked'] + supermarket_order + + return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe') diff --git a/cookbook/integration/integration.py b/cookbook/integration/integration.py index 2e5ac851..cecab4a2 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/0159_add_shoppinglistentry_fields.py b/cookbook/migrations/0159_add_shoppinglistentry_fields.py new file mode 100644 index 00000000..9df880d0 --- /dev/null +++ b/cookbook/migrations/0159_add_shoppinglistentry_fields.py @@ -0,0 +1,149 @@ +# Generated by Django 3.2.7 on 2021-10-01 20:52 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models +from django_scopes import scopes_disabled + +from cookbook.models import PermissionModelMixin, ShoppingListEntry + + +def copy_values_to_sle(apps, schema_editor): + with scopes_disabled(): + entries = ShoppingListEntry.objects.all() + for entry in entries: + if entry.shoppinglist_set.first(): + entry.created_by = entry.shoppinglist_set.first().created_by + entry.space = entry.shoppinglist_set.first().space + if entries: + ShoppingListEntry.objects.bulk_update(entries, ["created_by", "space", ]) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0158_userpreference_use_kj'), + ] + + operations = [ + migrations.AddField( + model_name='food', + name='on_hand', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='completed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='shoppinglistentry', + name='created_by', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.user'), + preserve_default=False, + ), + migrations.AddField( + model_name='userpreference', + name='shopping_share', + field=models.ManyToManyField(blank=True, related_name='shopping_share', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='space', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'), + preserve_default=False, + ), + migrations.AddField( + model_name='shoppinglistrecipe', + name='mealplan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.mealplan'), + ), + migrations.AddField( + model_name='shoppinglistrecipe', + name='name', + field=models.CharField(blank=True, default='', max_length=32), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='ingredient', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.ingredient'), + ), + migrations.AlterField( + model_name='shoppinglistentry', + name='unit', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.unit'), + ), + migrations.AddField( + model_name='userpreference', + name='mealplan_autoadd_shopping', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userpreference', + name='mealplan_autoexclude_onhand', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='shoppinglistentry', + name='list_recipe', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='cookbook.shoppinglistrecipe'), + ), + migrations.CreateModel( + name='FoodInheritField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('field', models.CharField(max_length=32, unique=True)), + ('name', models.CharField(max_length=64, unique=True)), + ], + bases=(models.Model, PermissionModelMixin), + ), + migrations.AddField( + model_name='food', + name='inherit', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userpreference', + name='mealplan_autoinclude_related', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='food', + name='ignore_inherit', + field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'), + ), + migrations.AddField( + model_name='space', + name='food_inherit', + field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'), + ), + migrations.AddField( + model_name='shoppinglistentry', + name='delay_until', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='userpreference', + name='default_delay', + field=models.DecimalField(decimal_places=4, default=4, max_digits=8), + ), + migrations.AddField( + model_name='userpreference', + name='filter_to_supermarket', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userpreference', + name='shopping_recent_days', + field=models.PositiveIntegerField(default=7), + ), + migrations.RunPython(copy_values_to_sle), + ] diff --git a/cookbook/migrations/0160_delete_shoppinglist_orphans.py b/cookbook/migrations/0160_delete_shoppinglist_orphans.py new file mode 100644 index 00000000..27fb0edb --- /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='Ignore Shopping', field='ignore_shopping') + FoodInheritField.objects.create(name='Diet', field='diet') + FoodInheritField.objects.create(name='Substitute', field='substitute') + FoodInheritField.objects.create(name='Substitute Children', field='substitute_children') + FoodInheritField.objects.create(name='Substitute Siblings', field='substitute_siblings') + + +def set_completed_at(apps, schema_editor): + today_start = timezone.now().replace(hour=0, minute=0, second=0) + # arbitrary - keeping all of the closed shopping list items out of the 'recent' view + month_ago = today_start - timedelta(days=30) + with scopes_disabled(): + ShoppingListEntry.objects.filter(checked=True).update(completed_at=month_ago) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0159_add_shoppinglistentry_fields'), + ] + + operations = [ + migrations.RunPython(delete_orphaned_sle), + migrations.RunPython(create_inheritfields), + migrations.RunPython(set_completed_at), + ] diff --git a/cookbook/migrations/0161_alter_shoppinglistentry_food.py b/cookbook/migrations/0161_alter_shoppinglistentry_food.py new file mode 100644 index 00000000..ab565354 --- /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/models.py b/cookbook/models.py index aed927bd..d4d7d698 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): @@ -78,6 +91,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 +144,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 +218,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 +240,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 +319,18 @@ 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.IntegerField(default=4) created_at = models.DateTimeField(auto_now_add=True) space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True) @@ -363,8 +445,8 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM name = models.CharField(max_length=64) 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,6 +475,10 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): + # exclude fields not implemented yet + inherit_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings']) + + # WARNING: Food inheritance relies on post_save signals, avoid using UPDATE to update Food objects unless you intend to bypass those signals if SORT_TREE_BY_NAME: node_order_by = ['name'] name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) @@ -400,6 +486,9 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) ignore_shopping = models.BooleanField(default=False) description = models.TextField(default='', blank=True) + on_hand = models.BooleanField(default=False) + inherit = models.BooleanField(default=False) + ignore_inherit = models.ManyToManyField(FoodInheritField, blank=True) # is this better as inherit instead of ignore inherit? which is more intuitive? space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space', _manager_class=TreeManager) @@ -413,6 +502,38 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): else: return super().delete() + @staticmethod + def reset_inheritance(space=None): + inherit = space.food_inherit.all() + ignore_inherit = Food.inherit_fields.difference(inherit) + + # food is going to inherit attributes + if space.food_inherit.all().count() > 0: + # using update to avoid creating a N*depth! save signals + Food.objects.filter(space=space).update(inherit=True) + # ManyToMany cannot be updated through an UPDATE operation + Through = Food.objects.first().ignore_inherit.through + Through.objects.all().delete() + for i in ignore_inherit: + Through.objects.bulk_create([ + Through(food_id=x, foodinheritfield_id=i.id) + for x in Food.objects.filter(space=space).values_list('id', flat=True) + ]) + + inherit = inherit.values_list('field', flat=True) + if 'ignore_shopping' in inherit: + # get food at root that have children that need updated + Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=True)).update(ignore_shopping=True) + Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=False)).update(ignore_shopping=False) + if 'supermarket_category' in inherit: + # when supermarket_category is null or blank assuming it is not set and not intended to be blank for all descedants + # find top node that has category set + category_roots = Food.exclude_descendants(queryset=Food.objects.filter(supermarket_category__isnull=False, numchild__gt=0, space=space)) + for root in category_roots: + root.get_descendants().update(supermarket_category=root.supermarket_category) + else: # food is not going to inherit any attributes + Food.objects.filter(space=space).update(inherit=False) + class Meta: constraints = [ models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space') @@ -534,6 +655,21 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel def __str__(self): 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"]), @@ -552,7 +688,7 @@ class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionMod objects = ScopedManager(space='recipe__space') - @staticmethod + @ staticmethod def get_space_key(): return 'recipe', 'space' @@ -600,7 +736,7 @@ class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, Pe objects = ScopedManager(space='book__space') - @staticmethod + @ staticmethod def get_space_key(): return 'book', 'space' @@ -647,6 +783,18 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space') + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.get_owner().userpreference.mealplan_autoadd_shopping: + kwargs = { + 'mealplan': self, + 'space': self.space, + 'created_by': self.get_owner() + } + if self.get_owner().userpreference.mealplan_autoexclude_onhand: + kwargs['ingredients'] = Ingredient.objects.filter(step__recipe=self.recipe, food__on_hand=False, space=self.space).values_list('id', flat=True) + ShoppingListEntry.list_from_recipe(**kwargs) + def get_label(self): if self.title: return self.title @@ -660,12 +808,14 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin): - 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') - @staticmethod + @ staticmethod def get_space_key(): return 'recipe', 'space' @@ -677,22 +827,101 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod def get_owner(self): try: - return self.shoppinglist_set.first().created_by + return self.entries.first().created_by or self.shoppinglist_set.first().created_by 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) + list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries') food = models.ForeignKey(Food, on_delete=models.CASCADE) - unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True) + unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True) + ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE, null=True, blank=True) amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) 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 + @classmethod + @atomic + def list_from_recipe(self, list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None): + """ + Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe + :param list_recipe: Modify an existing ShoppingListRecipe + :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required + :param mealplan: alternatively use a mealplan recipe as source of ingredients + :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted + :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used + """ + # TODO cascade to related recipes + r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None) + if not r: + raise ValueError(_("You must supply a recipe or mealplan")) + + created_by = created_by or getattr(mealplan, 'created_by', None) or getattr(list_recipe, 'created_by', None) + if not created_by: + raise ValueError(_("You must supply a created_by")) + + if type(servings) not in [int, float]: + servings = getattr(mealplan, 'servings', 1.0) + + shared_users = list(created_by.get_shopping_share()) + shared_users.append(created_by) + if list_recipe: + created = False + else: + list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings) + created = True + + if servings == 0 and not created: + list_recipe.delete() + return [] + elif ingredients: + ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space) + else: + ingredients = Ingredient.objects.filter(step__recipe=r, space=space) + existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe) + # delete shopping list entries not included in ingredients + existing_list.exclude(ingredient__in=ingredients).delete() + # add shopping list entries that did not previously exist + add_ingredients = set(ingredients.values_list('id', flat=True)) - set(existing_list.values_list('ingredient__id', flat=True)) + add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space) + + # if servings have changed, update the ShoppingListRecipe and existing Entrys + if servings <= 0: + servings = 1 + servings_factor = servings / r.servings + if not created and list_recipe.servings != servings: + update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True)) + for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients): + sle.amount = sle.ingredient.amount * Decimal(servings_factor) + sle.save() + + # add any missing Entrys + shoppinglist = [ + ShoppingListEntry( + list_recipe=list_recipe, + food=i.food, + unit=i.unit, + ingredient=i, + amount=i.amount * Decimal(servings_factor), + created_by=created_by, + space=space + ) + for i in [x for x in add_ingredients if not x.food.ignore_shopping] + ] + ShoppingListEntry.objects.bulk_create(shoppinglist) + # return all shopping list items + print('end of servings') + return ShoppingListEntry.objects.filter(list_recipe=list_recipe) + + @ staticmethod def get_space_key(): return 'shoppinglist', 'space' @@ -702,12 +931,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 @@ -863,7 +1094,7 @@ class SearchFields(models.Model, PermissionModelMixin): def __str__(self): return _(self.name) - @staticmethod + @ staticmethod def get_name(self): return _(self.name) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 20c6fec2..24c333f4 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -4,6 +4,7 @@ from decimal import Decimal from gettext import gettext as _ from django.contrib.auth.models import User +from django.db import transaction from django.db.models import Avg, QuerySet, Sum from django.urls import reverse from django.utils import timezone @@ -11,12 +12,13 @@ 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.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 @@ -61,7 +63,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): # probably not a tree pass if recipes.count() != 0: - return random.choice(recipes).image.url + return recipes.order_by('?')[:1][0].image.url else: return None @@ -78,7 +80,7 @@ class CustomDecimalField(serializers.Field): def to_representation(self, value): if not isinstance(value, Decimal): value = Decimal(value) - return round(value, 2).normalize() + return round(value, 3).normalize() def to_internal_value(self, data): if type(data) == int or type(data) == float: @@ -136,8 +138,27 @@ class UserNameSerializer(WritableNestedModelSerializer): fields = ('id', 'username') +class FoodInheritFieldSerializer(UniqueFieldsMixin): + + def create(self, validated_data): + # don't allow writing to FoodInheritField via API + return FoodInheritField.objects.get(**validated_data) + + def update(self, instance, validated_data): + # don't allow writing to FoodInheritField via API + return FoodInheritField.objects.get(**validated_data) + + class Meta: + model = FoodInheritField + fields = ['id', 'name', 'field', ] + + class UserPreferenceSerializer(serializers.ModelSerializer): - plan_share = UserNameSerializer(many=True, read_only=True) + # food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', read_only=True) + food_ignore_default = serializers.SerializerMethodField('get_ignore_default') + + def get_ignore_default(self, obj): + return FoodInheritFieldSerializer(Food.inherit_fields.difference(obj.space.food_inherit.all()), many=True).data def create(self, validated_data): if validated_data['user'] != self.context['request'].user: @@ -149,7 +170,8 @@ class UserPreferenceSerializer(serializers.ModelSerializer): fields = ( 'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'search_style', 'show_recent', 'plan_share', 'ingredient_decimals', - 'comments' + 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_ignore_default', 'default_delay', + 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share' ) @@ -255,25 +277,11 @@ class KeywordLabelSerializer(serializers.ModelSerializer): class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): 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 @@ -285,27 +293,14 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin): class Meta: model = Keyword fields = ( - 'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at', - 'updated_at') - read_only_fields = ('id', 'numchild', 'parent', 'image') + 'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe') + read_only_fields = ('id', 'label', 'image', 'parent', 'numchild', 'numrecipe') 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 +364,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') + ignore_inherit = FoodInheritFieldSerializer(many=True) + 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 +384,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', 'ignore_shopping', 'supermarket_category', + 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit', + ) + read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') class IngredientSerializer(WritableNestedModelSerializer): @@ -559,6 +541,9 @@ class RecipeSerializer(RecipeBaseSerializer): validated_data['space'] = self.context['request'].space return super().create(validated_data) + def update(self, instance, validated_data): + return super().update(instance, validated_data) + class RecipeImageSerializer(WritableNestedModelSerializer): class Meta: @@ -628,7 +613,10 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): 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): + ShoppingListEntry.list_from_recipe(mealplan=mealplan, space=validated_data['space'], created_by=validated_data['created_by']) + return mealplan class Meta: model = MealPlan @@ -640,34 +628,98 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): 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: + ShoppingListEntry.list_from_recipe( + list_recipe=instance, + servings=validated_data['servings'], + created_by=self.context['request'].user, + space=self.context['request'].space + ) + return super().update(instance, validated_data) + class Meta: 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) + + 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 +740,7 @@ class ShoppingListSerializer(WritableNestedModelSerializer): read_only_fields = ('id', 'created_by',) +# TODO deprecate class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer): entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True) @@ -802,7 +855,7 @@ class FoodExportSerializer(FoodSerializer): class Meta: model = Food - fields = ('name', 'ignore_shopping', 'supermarket_category') + fields = ('name', 'ignore_shopping', 'supermarket_category', 'on_hand') class IngredientExportSerializer(WritableNestedModelSerializer): @@ -847,3 +900,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 dc820c11..10ca5262 100644 --- a/cookbook/signals.py +++ b/cookbook/signals.py @@ -1,47 +1,80 @@ +from functools import wraps + 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.managers import DICTIONARY +from cookbook.models import Food, FoodInheritField, Recipe, Step + + +# wraps a signal with the ability to set 'skip_signal' to avoid creating recursive signals +def skip_signal(signal_func): + @wraps(signal_func) + def _decorator(sender, instance, **kwargs): + if not instance: + return None + if hasattr(instance, 'skip_signal'): + return None + return signal_func(sender, instance, **kwargs) + return _decorator # TODO there is probably a way to generalize this @receiver(post_save, sender=Recipe) +@skip_signal def update_recipe_search_vector(sender, instance=None, created=False, **kwargs): - if not instance: - return - - # needed to ensure search vector update doesn't trigger recursion - if hasattr(instance, '_dirty'): - return - language = DICTIONARY.get(translation.get_language(), 'simple') 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): + 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 = Food.inherit_fields.difference(instance.ignore_inherit.all()) + # nothing to apply from parent and nothing to apply to children + if (not instance.inherit or not instance.parent or inherit.count() == 0) and instance.numchild == 0: return - 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.inherit and instance.parent and inherit.count() > 0: + parent = instance.get_parent() + if 'ignore_shopping' in inherit: + instance.ignore_shopping = parent.ignore_shopping + # if supermarket_category is not set, do not cascade - if this becomes non-intuitive can change + if 'supermarket_category' in inherit and parent.supermarket_category: + instance.supermarket_category = parent.supermarket_category + try: + instance.skip_signal = True + instance.save() + finally: + del instance.skip_signal - try: - instance._dirty = True - instance.save() - finally: - del instance._dirty + # apply changes to direct children - depend on save signals for those objects to cascade inheritance down + instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='ignore_shopping').update(ignore_shopping=instance.ignore_shopping) + # don't cascade empty supermarket category + if instance.supermarket_category: + instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='supermarket_category').update(supermarket_category=instance.supermarket_category) diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html index b88570f4..6dd0943c 100644 --- a/cookbook/templates/base.html +++ b/cookbook/templates/base.html @@ -339,10 +339,10 @@ {% user_prefs request as prefs%} {{ prefs|json_script:'user_preference' }} - {% block script %} + {% endblock script %} - {% 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 673d9103..7d4be443 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/settings.html b/cookbook/templates/settings.html index ed591b9a..e33a3563 100644 --- a/cookbook/templates/settings.html +++ b/cookbook/templates/settings.html @@ -48,6 +48,13 @@ aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}"> {% trans 'Search-Settings' %} + @@ -195,6 +202,17 @@ class="fas fa-save"> {% trans 'Save' %}
+
+

{% trans 'Shopping Settings' %}

+ +
+ {% csrf_token %} + {{ shopping_form|crispy }} + +
+
@@ -224,5 +242,26 @@ $('.nav-tabs a').on('shown.bs.tab', function (e) { window.location.hash = e.target.hash; }) + // listen for events + $(document).ready(function(){ + hideShow() + // call hideShow when the user clicks on the mealplan_autoadd checkbox + $("#id_shopping-mealplan_autoadd_shopping").click(function(event){ + hideShow() + }); + }) + + function hideShow(){ + if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true) + { + $('#div_id_shopping-mealplan_autoexclude_onhand').show(); + $('#div_id_shopping-mealplan_autoinclude_related').show(); + } + else + { + $('#div_id_shopping-mealplan_autoexclude_onhand').hide(); + $('#div_id_shopping-mealplan_autoinclude_related').hide(); + } + } {% endblock %} diff --git a/cookbook/templates/shopping_list.html b/cookbook/templates/shopping_list.html index e16ce82a..b2b7175c 100644 --- a/cookbook/templates/shopping_list.html +++ b/cookbook/templates/shopping_list.html @@ -655,6 +655,7 @@ if (this.shopping_list.entries.length === 0) { this.edit_mode = true } + console.log(response.data) }).catch((err) => { console.log(err) this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger') diff --git a/cookbook/templates/shoppinglist_template.html b/cookbook/templates/shoppinglist_template.html new file mode 100644 index 00000000..95e88237 --- /dev/null +++ b/cookbook/templates/shoppinglist_template.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} {% block title %} {{ title }} {% endblock %} {% block content_fluid %} + +
+ +
+ +{% endblock %} {% block script %} {% if debug %} + +{% else %} + +{% endif %} + + + +{% render_bundle 'shopping_list_view' %} {% endblock %} diff --git a/cookbook/templates/space.html b/cookbook/templates/space.html index 1e21d3f3..1447527a 100644 --- a/cookbook/templates/space.html +++ b/cookbook/templates/space.html @@ -1,165 +1,188 @@ {% extends "base.html" %} {% load django_tables2 %} +{% load crispy_forms_tags %} {% load crispy_forms_filters %} {% load static %} {% load i18n %} -{% block title %}{% trans "Space Settings" %}{% endblock %} +{%block title %} {% trans "Space Settings" %} {% endblock %} {% block extra_head %} {{ form.media }} - + {{ space_form.media }} {% include 'include/vue_base.html' %} {% endblock %} {% block content %} - + -

{% trans 'Space:' %} {{ request.space.name }} {% if HOSTED %} - {% trans 'Manage Subscription' %}{% endif %}

+

+ {% trans 'Space:' %} {{ request.space.name }} + {% if HOSTED %} {% trans 'Manage Subscription' %}{% endif %} +

-
+
-
-
-
-
- {% trans 'Number of objects' %} -
-
    -
  • {% trans 'Recipes' %} : {{ counts.recipes }} / - {% if request.space.max_recipes > 0 %} - {{ request.space.max_recipes }}{% else %}∞{% endif %}
  • -
  • {% trans 'Keywords' %} : {{ counts.keywords }}
  • -
  • {% trans 'Units' %} : {{ counts.units }}
  • -
  • {% trans 'Ingredients' %} : {{ counts.ingredients }}
  • -
  • {% trans 'Recipe Imports' %} : {{ counts.recipe_import }}
  • -
-
-
-
-
-
- {% trans 'Objects stats' %} -
-
    -
  • {% trans 'Recipes without Keywords' %} : {{ counts.recipes_no_keyword }}
  • -
  • {% trans 'External Recipes' %} : {{ counts.recipes_external }}
  • -
  • {% trans 'Internal Recipes' %} : {{ counts.recipes_internal }}
  • -
  • {% trans 'Comments' %} : {{ counts.comments }}
  • -
-
+
+
+
+
{% trans 'Number of objects' %}
+
    +
  • + {% trans 'Recipes' %} : + {{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{% + else %}∞{% endif %} +
  • +
  • + {% trans 'Keywords' %} : {{ counts.keywords }} +
  • +
  • + {% trans 'Units' %} : {{ counts.units }} +
  • +
  • + {% trans 'Ingredients' %} : + {{ counts.ingredients }} +
  • +
  • + {% trans 'Recipe Imports' %} : + {{ counts.recipe_import }} +
  • +
-
-
-
-
- -

{% trans 'Members' %} {{ space_users|length }}/ - {% if request.space.max_users > 0 %} - {{ request.space.max_users }}{% else %}∞{% endif %} - {% trans 'Invite User' %} -

+
+
+
{% trans 'Objects stats' %}
+
    +
  • + {% trans 'Recipes without Keywords' %} : + {{ counts.recipes_no_keyword }} +
  • +
  • + {% trans 'External Recipes' %} : + {{ counts.recipes_external }} +
  • +
  • + {% trans 'Internal Recipes' %} : + {{ counts.recipes_internal }} +
  • +
  • + {% trans 'Comments' %} : {{ counts.comments }} +
  • +
-
- -
-
- {% if space_users %} - - - - - - - {% for u in space_users %} - - - - - - {% endfor %} -
{% trans 'User' %}{% trans 'Groups' %}{% trans 'Edit' %}
- {{ u.user.username }} - - {{ u.user.groups.all |join:", " }} - - {% if u.user != request.user %} -
- - - {% trans 'Update' %} - -
- {% else %} - {% trans 'You cannot edit yourself.' %} - {% endif %} -
- {% else %} -

{% trans 'There are no members in your space yet!' %}

- {% endif %} -
+
+
+
+
{% csrf_token %} {{ user_name_form|crispy }}
+
+
+

+ {% trans 'Members' %} + {{ space_users|length }}/ {% if request.space.max_users > 0 %} {{ request.space.max_users }}{% else + %}∞{% endif %} + {% trans 'Invite User' %} +

+
+
-
-
-

{% trans 'Invite Links' %}

- {% render_table invite_links %} -
+
+
+ {% if space_users %} + + + + + + + {% for u in space_users %} + + + + + + {% endfor %} +
{% trans 'User' %}{% trans 'Groups' %}{% trans 'Edit' %}
{{ u.user.username }}{{ u.user.groups.all |join:", " }} + {% if u.user != request.user %} +
+ + + {% trans 'Update' %} + +
+ {% else %} {% trans 'You cannot edit yourself.' %} {% endif %} +
+ {% else %} +

{% trans 'There are no members in your space yet!' %}

+ {% endif %}
+
-
-
-
+
+
+

{% trans 'Invite Links' %}

+ {% render_table invite_links %} +
+
+
+ +
+

{% trans 'Space Settings' %}

+
+ {% csrf_token %} + {{ space_form|crispy }} + +
+
+
+ +
+
+
+ +{% endblock %} {% block script %} + + {% endblock %} - -{% block script %} - - - -{% endblock %} \ No newline at end of file diff --git a/cookbook/tests/api/test_api_food.py b/cookbook/tests/api/test_api_food.py index a1b30d3c..cb6843fe 100644 --- a/cookbook/tests/api/test_api_food.py +++ b/cookbook/tests/api/test_api_food.py @@ -1,10 +1,9 @@ import json + import pytest - from django.contrib import auth -from django_scopes import scopes_disabled from django.urls import reverse - +from django_scopes import scopes_disabled from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry @@ -74,7 +73,7 @@ def ing_1_1_s1(obj_1_1, space_1): @pytest.fixture() def sle_1_s1(obj_1, u1_s1, space_1): - e = ShoppingListEntry.objects.create(food=obj_1) + e = ShoppingListEntry.objects.create(food=obj_1, created_by=auth.get_user(u1_s1), space=space_1,) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -82,12 +81,12 @@ def sle_1_s1(obj_1, u1_s1, space_1): @pytest.fixture() def sle_2_s1(obj_2, u1_s1, space_1): - return ShoppingListEntry.objects.create(food=obj_2) + return ShoppingListEntry.objects.create(food=obj_2, created_by=auth.get_user(u1_s1), space=space_1,) @pytest.fixture() def sle_3_s2(obj_3, u1_s2, space_2): - e = ShoppingListEntry.objects.create(food=obj_3) + e = ShoppingListEntry.objects.create(food=obj_3, created_by=auth.get_user(u1_s2), space=space_2) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s2), space=space_2, ) s.entries.add(e) return e @@ -95,7 +94,7 @@ def sle_3_s2(obj_3, u1_s2, space_2): @pytest.fixture() def sle_1_1_s1(obj_1_1, u1_s1, space_1): - e = ShoppingListEntry.objects.create(food=obj_1_1) + e = ShoppingListEntry.objects.create(food=obj_1_1, created_by=auth.get_user(u1_s1), space=space_1,) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -449,3 +448,10 @@ def test_tree_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1): assert response['count'] == 4 response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}&query={obj_2.name[4:]}').content) assert response['count'] == 4 + + +# TODO test inherit creating, moving for each field type +# TODO test ignore inherit for each field type +# TODO test with grand-children +# - flow from parent through child and grand-child +# - flow from parent stop when child is ignore inherit diff --git a/cookbook/tests/api/test_api_recipe.py b/cookbook/tests/api/test_api_recipe.py index feedcde2..e018d957 100644 --- a/cookbook/tests/api/test_api_recipe.py +++ b/cookbook/tests/api/test_api_recipe.py @@ -111,3 +111,16 @@ def test_delete(u1_s1, u1_s2, recipe_1_s1): assert r.status_code == 204 assert not Recipe.objects.filter(pk=recipe_1_s1.id).exists() + + +# TODO test related_recipes api +# -- step recipes +# -- ingredient recipes +# -- recipe wrong space +# -- steps wrong space +# -- ingredients wrong space +# -- step recipes included in step recipes +# -- step recipes included in food recipes +# -- food recipes included in step recipes +# -- food recipes included in food recipes +# -- included recipes in the wrong space \ No newline at end of file diff --git a/cookbook/tests/api/test_api_shopping_list_entry.py b/cookbook/tests/api/test_api_shopping_list_entry.py index 71a1f8f6..846f67d8 100644 --- a/cookbook/tests/api/test_api_shopping_list_entry.py +++ b/cookbook/tests/api/test_api_shopping_list_entry.py @@ -14,7 +14,7 @@ DETAIL_URL = 'api:shoppinglistentry-detail' @pytest.fixture() def obj_1(space_1, u1_s1): - e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 1', space=space_1)[0]) + e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -22,7 +22,7 @@ def obj_1(space_1, u1_s1): @pytest.fixture def obj_2(space_1, u1_s1): - e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 2', space=space_1)[0]) + e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -45,8 +45,11 @@ def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2): with scopes_disabled(): s = ShoppingList.objects.first() + e = ShoppingListEntry.objects.first() s.space = space_2 + e.space = space_2 s.save() + e.save() assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1 assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0 @@ -114,3 +117,15 @@ def test_delete(u1_s1, u1_s2, obj_1): ) assert r.status_code == 204 + + +# TODO test sharing +# TODO test completed entries still visible if today, but not yesterday +# TODO test create shopping list from recipe +# TODO test delete shopping list from recipe - include created by, shared with and not shared with +# TODO test create shopping list from food +# TODO test delete shopping list from food - include created by, shared with and not shared with +# TODO test create shopping list from mealplan +# TODO test create shopping list from recipe, excluding ingredients +# TODO test auto creating shopping list from meal plan +# TODO test excluding on-hand when auto creating shopping list diff --git a/cookbook/tests/api/test_api_step.py b/cookbook/tests/api/test_api_step.py index 209a8091..f2a80963 100644 --- a/cookbook/tests/api/test_api_step.py +++ b/cookbook/tests/api/test_api_step.py @@ -23,8 +23,8 @@ def test_list_permission(arg, request): def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2): - assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 2 - assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0 + assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2 + assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 0 with scopes_disabled(): recipe_1_s1.space = space_2 @@ -32,9 +32,9 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2): Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1])) Ingredient.objects.update(space=Subquery(Ingredient.objects.filter(pk=OuterRef('pk')).values('step__recipe__space')[:1])) - assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0 - assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 2 - + assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 0 + assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2 + @pytest.mark.parametrize("arg", [ ['a_u', 403], diff --git a/cookbook/tests/api/test_api_unit.py b/cookbook/tests/api/test_api_unit.py index 13d0aaec..8c4099ce 100644 --- a/cookbook/tests/api/test_api_unit.py +++ b/cookbook/tests/api/test_api_unit.py @@ -49,7 +49,7 @@ def ing_3_s2(obj_3, space_2, u2_s2): @pytest.fixture() def sle_1_s1(obj_1, u1_s1, space_1): - e = ShoppingListEntry.objects.create(unit=obj_1, food=random_food(space_1, u1_s1)) + e = ShoppingListEntry.objects.create(unit=obj_1, food=random_food(space_1, u1_s1), created_by=auth.get_user(u1_s1), space=space_1,) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -57,12 +57,12 @@ def sle_1_s1(obj_1, u1_s1, space_1): @pytest.fixture() def sle_2_s1(obj_2, u1_s1, space_1): - return ShoppingListEntry.objects.create(unit=obj_2, food=random_food(space_1, u1_s1)) + return ShoppingListEntry.objects.create(unit=obj_2, food=random_food(space_1, u1_s1), created_by=auth.get_user(u1_s1), space=space_1,) @pytest.fixture() def sle_3_s2(obj_3, u2_s2, space_2): - e = ShoppingListEntry.objects.create(unit=obj_3, food=random_food(space_2, u2_s2)) + e = ShoppingListEntry.objects.create(unit=obj_3, food=random_food(space_2, u2_s2), created_by=auth.get_user(u2_s2), space=space_2) s = ShoppingList.objects.create(created_by=auth.get_user(u2_s2), space=space_2) s.entries.add(e) return e diff --git a/cookbook/tests/api/test_api_userpreference.py b/cookbook/tests/api/test_api_userpreference.py index 4750fbaa..a78721c3 100644 --- a/cookbook/tests/api/test_api_userpreference.py +++ b/cookbook/tests/api/test_api_userpreference.py @@ -1,5 +1,3 @@ -from cookbook.models import UserPreference - import json import pytest @@ -7,6 +5,8 @@ from django.contrib import auth from django.urls import reverse from django_scopes import scopes_disabled +from cookbook.models import UserPreference + LIST_URL = 'api:userpreference-list' DETAIL_URL = 'api:userpreference-detail' @@ -109,3 +109,6 @@ def test_preference_delete(u1_s1, u2_s1): ) ) assert r.status_code == 204 + + +# TODO test existance of default food_inherit fields, test multiple users same space work and users in difference space do not diff --git a/cookbook/urls.py b/cookbook/urls.py index 690eae01..68ac160b 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -15,33 +15,35 @@ from .models import (Automation, Comment, Food, InviteLink, Keyword, MealPlan, R from .views import api, data, delete, edit, import_export, lists, new, telegram, views router = routers.DefaultRouter() -router.register(r'user-name', api.UserNameViewSet, basename='username') -router.register(r'user-preference', api.UserPreferenceViewSet) -router.register(r'storage', api.StorageViewSet) -router.register(r'sync', api.SyncViewSet) -router.register(r'sync-log', api.SyncLogViewSet) -router.register(r'keyword', api.KeywordViewSet) -router.register(r'unit', api.UnitViewSet) +router.register(r'automation', api.AutomationViewSet) +router.register(r'bookmarklet-import', api.BookmarkletImportViewSet) +router.register(r'cook-log', api.CookLogViewSet) router.register(r'food', api.FoodViewSet) -router.register(r'step', api.StepViewSet) -router.register(r'recipe', api.RecipeViewSet) +router.register(r'food-inherit-field', api.FoodInheritFieldViewSet) +router.register(r'import-log', api.ImportLogViewSet) router.register(r'ingredient', api.IngredientViewSet) +router.register(r'keyword', api.KeywordViewSet) router.register(r'meal-plan', api.MealPlanViewSet) router.register(r'meal-type', api.MealTypeViewSet) +router.register(r'recipe', api.RecipeViewSet) +router.register(r'recipe-book', api.RecipeBookViewSet) +router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet) router.register(r'shopping-list', api.ShoppingListViewSet) router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet) router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet) -router.register(r'view-log', api.ViewLogViewSet) -router.register(r'cook-log', api.CookLogViewSet) -router.register(r'recipe-book', api.RecipeBookViewSet) -router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet) +router.register(r'step', api.StepViewSet) +router.register(r'storage', api.StorageViewSet) router.register(r'supermarket', api.SupermarketViewSet) router.register(r'supermarket-category', api.SupermarketCategoryViewSet) router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet) -router.register(r'import-log', api.ImportLogViewSet) -router.register(r'bookmarklet-import', api.BookmarkletImportViewSet) +router.register(r'sync', api.SyncViewSet) +router.register(r'sync-log', api.SyncLogViewSet) +router.register(r'unit', api.UnitViewSet) router.register(r'user-file', api.UserFileViewSet) -router.register(r'automation', api.AutomationViewSet) +router.register(r'user-name', api.UserNameViewSet, basename='username') +router.register(r'user-preference', api.UserPreferenceViewSet) +router.register(r'view-log', api.ViewLogViewSet) + urlpatterns = [ path('', views.index, name='index'), diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 11a5d8ce..99c23498 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -38,21 +38,25 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus from cookbook.helper.recipe_html_import import get_recipe_from_source from cookbook.helper.recipe_search import get_facet, old_search, search_recipes from cookbook.helper.recipe_url_import import get_from_scraper -from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, ImportLog, Ingredient, - Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry, - ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Step, - Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation, - Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog) +from cookbook.helper.shopping_helper import shopping_helper +from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField, + ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook, + RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry, + ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory, + SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile, + UserPreference, ViewLog) from cookbook.provider.dropbox import Dropbox from cookbook.provider.local import Local from cookbook.provider.nextcloud import Nextcloud from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer, - CookLogSerializer, FoodSerializer, ImportLogSerializer, + CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer, + FoodShoppingUpdateSerializer, ImportLogSerializer, IngredientSerializer, KeywordSerializer, MealPlanSerializer, MealTypeSerializer, RecipeBookEntrySerializer, RecipeBookSerializer, RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer, + RecipeShoppingUpdateSerializer, RecipeSimpleSerializer, ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer, ShoppingListRecipeSerializer, ShoppingListSerializer, StepSerializer, StorageSerializer, @@ -359,8 +363,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin): permission_classes = [CustomIsUser] def get_queryset(self): - self.queryset = self.queryset.filter(space=self.request.space) - return super().get_queryset() + return self.queryset.filter(space=self.request.space) class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin): @@ -390,6 +393,16 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin): pagination_class = DefaultPagination +class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet): + queryset = FoodInheritField.objects + serializer_class = FoodInheritFieldSerializer + permission_classes = [CustomIsUser] + + def get_queryset(self): + # exclude fields not yet implemented + return Food.inherit_fields + + class FoodViewSet(viewsets.ModelViewSet, TreeMixin): queryset = Food.objects model = Food @@ -397,6 +410,23 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): permission_classes = [CustomIsUser] pagination_class = DefaultPagination + @decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,) + def shopping(self, request, pk): + obj = self.get_object() + shared_users = list(self.request.user.get_shopping_share()) + shared_users.append(request.user) + if request.data.get('_delete', False) == 'true': + ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space, created_by__in=shared_users).delete() + content = {'msg': _(f'{obj.name} was removed from the shopping list.')} + return Response(content, status=status.HTTP_204_NO_CONTENT) + + amount = request.data.get('amount', 1) + unit = request.data.get('unit', None) + content = {'msg': _(f'{obj.name} was added to the shopping list.')} + + ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space, created_by=request.user) + return Response(content, status=status.HTTP_204_NO_CONTENT) + def destroy(self, *args, **kwargs): try: return (super().destroy(self, *args, **kwargs)) @@ -547,27 +577,18 @@ class RecipeViewSet(viewsets.ModelViewSet): pagination_class = RecipePagination # TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly query_params = [ - QueryParam(name='query', description=_( - 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.')), - QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'), - qtype='int'), - QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), - qtype='int'), + QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')), + QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'), qtype='int'), + QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'), QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'), QueryParam(name='rating', description=_('Rating a recipe should have. [0 - 5]'), qtype='int'), QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')), - QueryParam(name='keywords_or', description=_( - 'If recipe should have all (AND=''false'') or any (OR=''true'') of the provided keywords.')), - QueryParam(name='foods_or', description=_( - 'If recipe should have all (AND=''false'') or any (OR=''true'') of the provided foods.')), - QueryParam(name='books_or', description=_( - 'If recipe should be in all (AND=''false'') or any (OR=''true'') of the provided books.')), - QueryParam(name='internal', - description=_('If only internal recipes should be returned. [''true''/''false'']')), - QueryParam(name='random', - description=_('Returns the results in randomized order. [''true''/''false'']')), - QueryParam(name='new', - description=_('Returns new results first in search results. [''true''/''false'']')), + QueryParam(name='keywords_or', description=_('If recipe should have all (AND=''false'') or any (OR=''true'') of the provided keywords.')), + QueryParam(name='foods_or', description=_('If recipe should have all (AND=''false'') or any (OR=''true'') of the provided foods.')), + QueryParam(name='books_or', description=_('If recipe should be in all (AND=''false'') or any (OR=''true'') of the provided books.')), + QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''false'']')), + QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''false'']')), + QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''false'']')), ] schema = QueryParamAutoSchema() @@ -625,16 +646,49 @@ class RecipeViewSet(viewsets.ModelViewSet): return Response(serializer.data) return Response(serializer.errors, 400) + @decorators.action( + detail=True, + methods=['PUT'], + serializer_class=RecipeShoppingUpdateSerializer, + ) + def shopping(self, request, pk): + obj = self.get_object() + ingredients = request.data.get('ingredients', None) + servings = request.data.get('servings', obj.servings) + list_recipe = request.data.get('list_recipe', None) + content = {'msg': _(f'{obj.name} was added to the shopping list.')} + # TODO: Consider if this should be a Recipe method + ShoppingListEntry.list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=request.user) + return Response(content, status=status.HTTP_204_NO_CONTENT) + + @decorators.action( + detail=True, + methods=['GET'], + serializer_class=RecipeSimpleSerializer + ) + def related(self, request, pk): + obj = self.get_object() + if obj.get_space() != request.space: + raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403) + qs = obj.get_related_recipes(levels=2) # TODO: make levels a user setting, included in request data, keep solely in the backend? + return Response(self.serializer_class(qs, many=True).data) + + +# TODO deprecate class ShoppingListRecipeViewSet(viewsets.ModelViewSet): queryset = ShoppingListRecipe.objects serializer_class = ShoppingListRecipeSerializer permission_classes = [CustomIsOwner | CustomIsShared] def get_queryset(self): + self.queryset = self.queryset.filter(Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space)) return self.queryset.filter( - Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter( - shoppinglist__space=self.request.space).distinct().all() + Q(shoppinglist__created_by=self.request.user) + | Q(shoppinglist__shared=self.request.user) + | Q(entries__created_by=self.request.user) + | Q(entries__created_by__in=list(self.request.user.get_shopping_share())) + ).distinct().all() class ShoppingListEntryViewSet(viewsets.ModelViewSet): @@ -642,35 +696,46 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet): serializer_class = ShoppingListEntrySerializer permission_classes = [CustomIsOwner | CustomIsShared] query_params = [ - QueryParam(name='id', - description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), - qtype='int'), + QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'), QueryParam( name='checked', - description=_( - 'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''recent'']
- ''recent'' includes unchecked items and recently completed items.') + description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''recent'']
- ''recent'' includes unchecked items and recently completed items.') ), - QueryParam(name='supermarket', - description=_('Returns the shopping list entries sorted by supermarket category order.'), - qtype='int'), + QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'), ] schema = QueryParamAutoSchema() def get_queryset(self): - return self.queryset.filter( - Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter( - shoppinglist__space=self.request.space).distinct().all() + self.queryset = self.queryset.filter(space=self.request.space) + + self.queryset = self.queryset.filter( + Q(created_by=self.request.user) + | Q(shoppinglist__shared=self.request.user) + | Q(created_by__in=list(self.request.user.get_shopping_share())) + ).distinct().all() + + if pk := self.request.query_params.getlist('id', []): + self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk]) + + if bool(int(self.request.query_params.get('recent', False))): + return shopping_helper(self.queryset, self.request) + + # TODO once old shopping list is removed this needs updated to sharing users in preferences + return self.queryset +# TODO deprecate class ShoppingListViewSet(viewsets.ModelViewSet): queryset = ShoppingList.objects serializer_class = ShoppingListSerializer permission_classes = [CustomIsOwner | CustomIsShared] + # TODO update to include settings shared user - make both work for a period of time def get_queryset(self): return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter( space=self.request.space).distinct() + # TODO deprecate def get_serializer_class(self): try: autosync = self.request.query_params.get('autosync', False) diff --git a/cookbook/views/data.py b/cookbook/views/data.py index 0900bb23..84d6fced 100644 --- a/cookbook/views/data.py +++ b/cookbook/views/data.py @@ -22,8 +22,8 @@ from cookbook.helper.image_processing import handle_image from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.permission_helper import group_required, has_group_permission from cookbook.helper.recipe_url_import import parse_cooktime -from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, - RecipeImport, Step, Sync, Unit, UserPreference) +from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, RecipeImport, Step, Sync, + Unit, UserPreference) from cookbook.tables import SyncTable from recipes import settings @@ -111,8 +111,8 @@ def batch_edit(request): 'Batch edit done. %(count)d recipe was updated.', 'Batch edit done. %(count)d Recipes where updated.', count) % { - 'count': count, - } + 'count': count, + } messages.add_message(request, messages.SUCCESS, msg) return redirect('data_batch_edit') diff --git a/cookbook/views/lists.py b/cookbook/views/lists.py index c6a1fca2..6d8bfdf6 100644 --- a/cookbook/views/lists.py +++ b/cookbook/views/lists.py @@ -7,10 +7,9 @@ from django_tables2 import RequestConfig from cookbook.filters import ShoppingListFilter from cookbook.helper.permission_helper import group_required -from cookbook.models import (InviteLink, RecipeImport, - ShoppingList, Storage, SyncLog, UserFile) -from cookbook.tables import (ImportLogTable, InviteLinkTable, - RecipeImportTable, ShoppingListTable, StorageTable) +from cookbook.models import InviteLink, RecipeImport, ShoppingList, Storage, SyncLog, UserFile +from cookbook.tables import (ImportLogTable, InviteLinkTable, RecipeImportTable, ShoppingListTable, + StorageTable) @group_required('admin') @@ -40,20 +39,6 @@ def recipe_import(request): ) -# @group_required('user') -# def food(request): -# f = FoodFilter(request.GET, queryset=Food.objects.filter(space=request.space).all().order_by('pk')) - -# table = IngredientTable(f.qs) -# RequestConfig(request, paginate={'per_page': 25}).configure(table) - -# return render( -# request, -# 'generic/list_template.html', -# {'title': _("Ingredients"), 'table': table, 'filter': f} -# ) - - @group_required('user') def shopping_list(request): f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(space=request.space).filter( @@ -204,7 +189,7 @@ def automation(request): def user_file(request): try: current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[ - 'file_size_kb__sum'] / 1000 + 'file_size_kb__sum'] / 1000 except TypeError: current_file_size_mb = 0 @@ -244,11 +229,9 @@ def shopping_list_new(request): # model-name is the models.js name of the model, probably ALL-CAPS return render( request, - 'generic/checklist_template.html', + 'shoppinglist_template.html', { "title": _("New Shopping List"), - "config": { - 'model': "SHOPPING_LIST", # *REQUIRED* name of the model in models.js - } + } ) diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 5b37cdc5..0eb2a485 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -22,13 +22,13 @@ from django_tables2 import RequestConfig from rest_framework.authtoken.models import Token from cookbook.filters import RecipeFilter -from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, - SpaceJoinForm, User, UserCreateForm, UserNameForm, UserPreference, - UserPreferenceForm) +from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm, + SpaceCreateForm, SpaceJoinForm, SpacePreferenceForm, User, + UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm) from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid -from cookbook.models import (Comment, CookLog, Food, InviteLink, Keyword, MealPlan, RecipeImport, - SearchFields, SearchPreference, ShareLink, ShoppingList, Space, Unit, - UserFile, ViewLog) +from cookbook.models import (Comment, CookLog, Food, FoodInheritField, InviteLink, Keyword, + MealPlan, RecipeImport, SearchFields, SearchPreference, ShareLink, + ShoppingList, Space, Unit, UserFile, ViewLog) from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall, ViewLogTable) from cookbook.views.data import Object @@ -304,10 +304,6 @@ def user_settings(request): up.use_kj = form.cleaned_data['use_kj'] up.sticky_navbar = form.cleaned_data['sticky_navbar'] - up.shopping_auto_sync = form.cleaned_data['shopping_auto_sync'] - if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: - up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL - up.save() elif 'user_name_form' in request.POST: @@ -378,10 +374,28 @@ def user_settings(request): sp.trigram_threshold = 0.1 sp.save() + elif 'shopping_form' in request.POST: + shopping_form = ShoppingPreferenceForm(request.POST, prefix='shopping') + if shopping_form.is_valid(): + if not up: + up = UserPreference(user=request.user) + + up.shopping_share.set(shopping_form.cleaned_data['shopping_share']) + up.mealplan_autoadd_shopping = shopping_form.cleaned_data['mealplan_autoadd_shopping'] + up.mealplan_autoexclude_onhand = shopping_form.cleaned_data['mealplan_autoexclude_onhand'] + up.mealplan_autoinclude_related = shopping_form.cleaned_data['mealplan_autoinclude_related'] + up.shopping_auto_sync = shopping_form.cleaned_data['shopping_auto_sync'] + up.filter_to_supermarket = shopping_form.cleaned_data['filter_to_supermarket'] + up.default_delay = shopping_form.cleaned_data['default_delay'] + if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: + up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL + up.save() if up: - preference_form = UserPreferenceForm(instance=up, space=request.space) + preference_form = UserPreferenceForm(instance=up) + shopping_form = ShoppingPreferenceForm(instance=up) else: preference_form = UserPreferenceForm(space=request.space) + shopping_form = ShoppingPreferenceForm(space=request.space) fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len( sp.fulltext.all()) @@ -406,6 +420,7 @@ def user_settings(request): 'user_name_form': user_name_form, 'api_token': api_token, 'search_form': search_form, + 'shopping_form': shopping_form, 'active_tab': active_tab }) @@ -541,7 +556,22 @@ def space(request): InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=request.space).all()) RequestConfig(request, paginate={'per_page': 25}).configure(invite_links) - return render(request, 'space.html', {'space_users': space_users, 'counts': counts, 'invite_links': invite_links}) + space_form = SpacePreferenceForm(instance=request.space) + + space_form.base_fields['food_inherit'].queryset = Food.inherit_fields + if request.method == "POST" and 'space_form' in request.POST: + form = SpacePreferenceForm(request.POST, prefix='space') + if form.is_valid(): + request.space.food_inherit.set(form.cleaned_data['food_inherit']) + if form.cleaned_data['reset_food_inherit']: + Food.reset_inheritance(space=request.space) + + return render(request, 'space.html', { + 'space_users': space_users, + 'counts': counts, + 'invite_links': invite_links, + 'space_form': space_form + }) # TODO super hacky and quick solution, safe but needs rework diff --git a/recipes/middleware.py b/recipes/middleware.py index 5843f75b..ebe9c51f 100644 --- a/recipes/middleware.py +++ b/recipes/middleware.py @@ -61,9 +61,9 @@ def SqlPrintingMiddleware(get_response): sql = "\033[1;31m[%s]\033[0m %s" % (query['time'], nice_sql) total_time = total_time + float(query['time']) while len(sql) > width - indentation: - #print("%s%s" % (" " * indentation, sql[:width - indentation])) + # print("%s%s" % (" " * indentation, sql[:width - indentation])) sql = sql[width - indentation:] - #print("%s%s\n" % (" " * indentation, sql)) + # print("%s%s\n" % (" " * indentation, sql)) replace_tuple = (" " * indentation, str(total_time)) print("%s\033[1;32m[TOTAL TIME: %s seconds]\033[0m" % replace_tuple) print("%s\033[1;32m[TOTAL QUERIES: %s]\033[0m" % (" " * indentation, len(connection.queries))) diff --git a/vue/.gitignore b/vue/.gitignore deleted file mode 100644 index 403adbc1..00000000 --- a/vue/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -.DS_Store -node_modules -/dist - - -# local env files -.env.local -.env.*.local - -# Log files -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Editor directories and files -.idea -.vscode -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/vue/.openapi-generator/FILES b/vue/.openapi-generator/FILES new file mode 100644 index 00000000..a80cd4f0 --- /dev/null +++ b/vue/.openapi-generator/FILES @@ -0,0 +1,8 @@ +.gitignore +.npmignore +api.ts +base.ts +common.ts +configuration.ts +git_push.sh +index.ts diff --git a/vue/.openapi-generator/VERSION b/vue/.openapi-generator/VERSION new file mode 100644 index 00000000..80444066 --- /dev/null +++ b/vue/.openapi-generator/VERSION @@ -0,0 +1 @@ +5.2.1 \ No newline at end of file diff --git a/vue/src/apps/ChecklistView/ChecklistView.vue b/vue/src/apps/ChecklistView/ChecklistView.vue deleted file mode 100644 index afbc241a..00000000 --- a/vue/src/apps/ChecklistView/ChecklistView.vue +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - - diff --git a/vue/src/apps/ChecklistView/main.js b/vue/src/apps/ChecklistView/main.js deleted file mode 100644 index 355d8eae..00000000 --- a/vue/src/apps/ChecklistView/main.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue' -import App from './ChecklistView' -import i18n from '@/i18n' - -Vue.config.productionTip = false - -// TODO move this and other default stuff to centralized JS file (verify nothing breaks) -let publicPath = localStorage.STATIC_URL + 'vue/' -if (process.env.NODE_ENV === 'development') { - publicPath = 'http://localhost:8080/' -} -export default __webpack_public_path__ = publicPath // eslint-disable-line - - -new Vue({ - i18n, - render: h => h(App), -}).$mount('#app') diff --git a/vue/src/apps/CookbookView/CookbookView.vue b/vue/src/apps/CookbookView/CookbookView.vue index d316a722..4ee27f0e 100644 --- a/vue/src/apps/CookbookView/CookbookView.vue +++ b/vue/src/apps/CookbookView/CookbookView.vue @@ -61,10 +61,10 @@ import Vue from 'vue' import {BootstrapVue} from 'bootstrap-vue' import 'bootstrap-vue/dist/bootstrap-vue.css' -import {ApiApiFactory} from "@/utils/openapi/api"; -import CookbookSlider from "@/components/CookbookSlider"; -import LoadingSpinner from "@/components/LoadingSpinner"; -import {StandardToasts} from "@/utils/utils"; +import {ApiApiFactory} from "@/utils/openapi/api.ts"; +import CookbookSlider from "../../components/CookbookSlider"; +import LoadingSpinner from "../../components/LoadingSpinner"; +import {StandardToasts} from "../../utils/utils"; Vue.use(BootstrapVue) diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index 7d451edf..d2680457 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -1,643 +1,670 @@ @@ -656,28 +683,28 @@ export default { } .calender-parent { - display: flex; - flex-direction: column; - flex-grow: 1; - overflow-x: hidden; - overflow-y: hidden; - height: 70vh; + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-x: hidden; + overflow-y: hidden; + height: 70vh; } .cv-item { - white-space: inherit !important; + white-space: inherit !important; } .isHovered { - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; } .cv-day.draghover { - box-shadow: inset 0 0 0.2em 0.2em rgb(221, 191, 134) !important; + box-shadow: inset 0 0 0.2em 0.2em rgb(221, 191, 134) !important; } .modal-backdrop { - opacity: 0.5; + opacity: 0.5; } /* @@ -693,84 +720,84 @@ having to override as much. .theme-default .cv-header, .theme-default .cv-header-day { - background-color: #f0f0f0; + background-color: #f0f0f0; } .theme-default .cv-header .periodLabel { - font-size: 1.5em; + font-size: 1.5em; } /* Grid */ .theme-default .cv-weeknumber { - background-color: #e0e0e0; - border-color: #ccc; - color: #808080; + background-color: #e0e0e0; + border-color: #ccc; + color: #808080; } .theme-default .cv-weeknumber span { - margin: 0; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + margin: 0; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } .theme-default .cv-day.past { - background-color: #fafafa; + background-color: #fafafa; } .theme-default .cv-day.outsideOfMonth { - background-color: #f7f7f7; + background-color: #f7f7f7; } .theme-default .cv-day.today { - background-color: #ffe; + background-color: #ffe; } .theme-default .cv-day[aria-selected] { - background-color: #ffc; + background-color: #ffc; } /* Events */ .theme-default .cv-item { - border-color: #e0e0f0; - border-radius: 0.5em; - background-color: #fff; - text-overflow: ellipsis; + border-color: #e0e0f0; + border-radius: 0.5em; + background-color: #fff; + text-overflow: ellipsis; } .theme-default .cv-item.purple { - background-color: #f0e0ff; - border-color: #e7d7f7; + background-color: #f0e0ff; + border-color: #e7d7f7; } .theme-default .cv-item.orange { - background-color: #ffe7d0; - border-color: #f7e0c7; + background-color: #ffe7d0; + border-color: #f7e0c7; } .theme-default .cv-item.continued::before, .theme-default .cv-item.toBeContinued::after { - content: " \21e2 "; - color: #999; + content: " \21e2 "; + color: #999; } .theme-default .cv-item.toBeContinued { - border-right-style: none; - border-top-right-radius: 0; - border-bottom-right-radius: 0; + border-right-style: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } .theme-default .cv-item.isHovered.hasUrl { - text-decoration: underline; + text-decoration: underline; } .theme-default .cv-item.continued { - border-left-style: none; - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border-left-style: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; } .cv-item.span3, @@ -778,20 +805,20 @@ having to override as much. .cv-item.span5, .cv-item.span6, .cv-item.span7 { - text-align: center; + text-align: center; } /* Event Times */ .theme-default .cv-item .startTime, .theme-default .cv-item .endTime { - font-weight: bold; - color: #666; + font-weight: bold; + color: #666; } /* Drag and drop */ .theme-default .cv-day.draghover { - box-shadow: inset 0 0 0.2em 0.2em yellow; + box-shadow: inset 0 0 0.2em 0.2em yellow; } - \ No newline at end of file + diff --git a/vue/src/apps/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index 2d79c865..c8a6bec7 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -25,14 +25,7 @@
- + {{ $t("show_split_screen") }}
@@ -42,46 +35,19 @@
- +
- +
@@ -98,13 +64,12 @@ import { BootstrapVue } from "bootstrap-vue" import "bootstrap-vue/dist/bootstrap-vue.css" -import { CardMixin, ApiMixin, getConfig } from "@/utils/utils" -import { StandardToasts, ToastMixin } from "@/utils/utils" +import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference, makeToast } from "@/utils/utils" import GenericInfiniteCards from "@/components/GenericInfiniteCards" import GenericHorizontalCard from "@/components/GenericHorizontalCard" import GenericModalForm from "@/components/Modals/GenericModalForm" -import ModelMenu from "@/components/ModelMenu" +import ModelMenu from "@/components/ContextMenu/ModelMenu" import { ApiApiFactory } from "@/utils/openapi/api" //import StorageQuota from "@/components/StorageQuota"; @@ -114,13 +79,8 @@ export default { // TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available // or i'm capturing it incorrectly name: "ModelListView", - mixins: [CardMixin, ApiMixin, ToastMixin], - components: { - GenericHorizontalCard, - GenericModalForm, - GenericInfiniteCards, - ModelMenu, - }, + mixins: [CardMixin, ApiMixin], + components: { GenericHorizontalCard, GenericModalForm, GenericInfiniteCards, ModelMenu }, data() { return { // this.Models and this.Actions inherited from ApiMixin @@ -236,6 +196,7 @@ export default { } }, finishAction: function (e) { + let update = undefined switch (e?.action) { case "save": this.saveThis(e.form_data) @@ -263,7 +224,7 @@ export default { } this.clearState() }, - getItems: function (params, col) { + getItems: function (params = {}, col) { let column = col || "left" params.options = { query: { extended: 1 } } // returns extended values in API response this.genericAPI(this.this_model, this.Actions.LIST, params) diff --git a/vue/src/apps/RecipeEditView/RecipeEditView.vue b/vue/src/apps/RecipeEditView/RecipeEditView.vue index 61cad5e0..3b98745e 100644 --- a/vue/src/apps/RecipeEditView/RecipeEditView.vue +++ b/vue/src/apps/RecipeEditView/RecipeEditView.vue @@ -629,7 +629,6 @@ export default { apiFactory.updateRecipe(this.recipe_id, this.recipe, {}).then((response) => { - console.log(response) StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE) this.recipe_changed = false if (view_after) { diff --git a/vue/src/apps/RecipeView/RecipeView.vue b/vue/src/apps/RecipeView/RecipeView.vue index f0343fde..de938729 100644 --- a/vue/src/apps/RecipeView/RecipeView.vue +++ b/vue/src/apps/RecipeView/RecipeView.vue @@ -1,90 +1,61 @@
-
-
+
-
-
-
+
+
+ +
- +
+
+
+ +
+
+ +
+
+ +
+
+
-
-
-
- + + +
+
-
+ -
- - - - -
- -
- - - - - - -
- + diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue new file mode 100644 index 00000000..3e93d30b --- /dev/null +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -0,0 +1,805 @@ + + + + + + + diff --git a/vue/src/apps/ShoppingListView/main.js b/vue/src/apps/ShoppingListView/main.js new file mode 100644 index 00000000..dda3184c --- /dev/null +++ b/vue/src/apps/ShoppingListView/main.js @@ -0,0 +1,10 @@ +import i18n from "@/i18n" +import Vue from "vue" +import App from "./ShoppingListView" + +Vue.config.productionTip = false + +new Vue({ + i18n, + render: (h) => h(App), +}).$mount("#app") diff --git a/vue/src/components/Badges.vue b/vue/src/components/Badges.vue index 2664edf0..f3599948 100644 --- a/vue/src/components/Badges.vue +++ b/vue/src/components/Badges.vue @@ -4,16 +4,22 @@ :item="item"/> + + \ No newline at end of file diff --git a/vue/src/components/Badges/Shopping.vue b/vue/src/components/Badges/Shopping.vue new file mode 100644 index 00000000..9c2b8576 --- /dev/null +++ b/vue/src/components/Badges/Shopping.vue @@ -0,0 +1,94 @@ + + + \ No newline at end of file diff --git a/vue/src/components/ContextMenu/ContextMenu.vue b/vue/src/components/ContextMenu/ContextMenu.vue index 73381f77..12c9efe1 100644 --- a/vue/src/components/ContextMenu/ContextMenu.vue +++ b/vue/src/components/ContextMenu/ContextMenu.vue @@ -1,127 +1,118 @@ diff --git a/vue/src/components/ContextMenu/ContextMenuItem.vue b/vue/src/components/ContextMenu/ContextMenuItem.vue index 660c6bb6..43ce9816 100644 --- a/vue/src/components/ContextMenu/ContextMenuItem.vue +++ b/vue/src/components/ContextMenu/ContextMenuItem.vue @@ -1,16 +1,13 @@ - - + diff --git a/vue/src/components/ContextMenu/ContextMenuSubmenu.vue b/vue/src/components/ContextMenu/ContextMenuSubmenu.vue new file mode 100644 index 00000000..4ff781f4 --- /dev/null +++ b/vue/src/components/ContextMenu/ContextMenuSubmenu.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/vue/src/components/GenericContextMenu.vue b/vue/src/components/ContextMenu/GenericContextMenu.vue similarity index 100% rename from vue/src/components/GenericContextMenu.vue rename to vue/src/components/ContextMenu/GenericContextMenu.vue diff --git a/vue/src/components/ModelMenu.vue b/vue/src/components/ContextMenu/ModelMenu.vue similarity index 100% rename from vue/src/components/ModelMenu.vue rename to vue/src/components/ContextMenu/ModelMenu.vue diff --git a/vue/src/components/RecipeContextMenu.vue b/vue/src/components/ContextMenu/RecipeContextMenu.vue similarity index 89% rename from vue/src/components/RecipeContextMenu.vue rename to vue/src/components/ContextMenu/RecipeContextMenu.vue index 3aad7738..f4b3067e 100644 --- a/vue/src/components/RecipeContextMenu.vue +++ b/vue/src/components/ContextMenu/RecipeContextMenu.vue @@ -26,7 +26,11 @@ {{ $t('Add_to_Shopping') }} - + New {{ $t('Add_to_Shopping') }} + + + {{ $t('Add_to_Plan') }} @@ -76,6 +80,7 @@ +
@@ -84,8 +89,9 @@ import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils"; import CookLog from "@/components/CookLog"; import axios from "axios"; -import AddRecipeToBook from "./AddRecipeToBook"; -import MealPlanEditModal from "@/components/MealPlanEditModal"; +import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"; +import MealPlanEditModal from "@/components/Modals/MealPlanEditModal"; +import ShoppingModal from "@/components/Modals/ShoppingModal"; import moment from "moment"; import Vue from "vue"; import {ApiApiFactory} from "@/utils/openapi/api"; @@ -100,7 +106,8 @@ export default { components: { AddRecipeToBook, CookLog, - MealPlanEditModal + MealPlanEditModal, + ShoppingModal }, data() { return { @@ -118,7 +125,7 @@ export default { servings: 1, shared: [], title: '', - title_placeholder: this.$t('Title') + title_placeholder: this.$t('Title'), } }, entryEditing: {}, @@ -177,7 +184,10 @@ export default { url: this.recipe_share_link } navigator.share(shareData) - } + }, + addToShopping() { + this.$bvModal.show(`shopping_${this.modal_id}`) + }, } } diff --git a/vue/src/components/GenericHorizontalCard.vue b/vue/src/components/GenericHorizontalCard.vue index 232c12eb..fcfe8094 100644 --- a/vue/src/components/GenericHorizontalCard.vue +++ b/vue/src/components/GenericHorizontalCard.vue @@ -92,13 +92,13 @@ {{$t('Cancel')}} - + diff --git a/vue/src/components/IngredientsCard.vue b/vue/src/components/IngredientsCard.vue new file mode 100644 index 00000000..8dc2c272 --- /dev/null +++ b/vue/src/components/IngredientsCard.vue @@ -0,0 +1,187 @@ + + + diff --git a/vue/src/components/MealPlanEditModal.vue b/vue/src/components/MealPlanEditModal.vue deleted file mode 100644 index ee74b80c..00000000 --- a/vue/src/components/MealPlanEditModal.vue +++ /dev/null @@ -1,216 +0,0 @@ - - - - - \ No newline at end of file diff --git a/vue/src/components/AddRecipeToBook.vue b/vue/src/components/Modals/AddRecipeToBook.vue similarity index 100% rename from vue/src/components/AddRecipeToBook.vue rename to vue/src/components/Modals/AddRecipeToBook.vue diff --git a/vue/src/components/Modals/GenericModalForm.vue b/vue/src/components/Modals/GenericModalForm.vue index 237fd903..1e2d2237 100644 --- a/vue/src/components/Modals/GenericModalForm.vue +++ b/vue/src/components/Modals/GenericModalForm.vue @@ -28,7 +28,7 @@ + + diff --git a/vue/src/components/Modals/ShoppingModal.vue b/vue/src/components/Modals/ShoppingModal.vue new file mode 100644 index 00000000..be008920 --- /dev/null +++ b/vue/src/components/Modals/ShoppingModal.vue @@ -0,0 +1,158 @@ + + + \ No newline at end of file diff --git a/vue/src/components/RecipeCard.vue b/vue/src/components/RecipeCard.vue index 0ebae44b..1728670d 100755 --- a/vue/src/components/RecipeCard.vue +++ b/vue/src/components/RecipeCard.vue @@ -1,158 +1,137 @@ - - - - - - - {{ footer_text }} - - - + {{ footer_text }} + diff --git a/vue/src/components/ShoppingLineItem.vue b/vue/src/components/ShoppingLineItem.vue new file mode 100644 index 00000000..30a3a5d1 --- /dev/null +++ b/vue/src/components/ShoppingLineItem.vue @@ -0,0 +1,269 @@ + + + + + + + diff --git a/vue/src/components/StepComponent.vue b/vue/src/components/StepComponent.vue index 403b5829..83976ac5 100644 --- a/vue/src/components/StepComponent.vue +++ b/vue/src/components/StepComponent.vue @@ -38,12 +38,11 @@
- - - +
@@ -161,6 +160,7 @@ import {calculateAmount} from "@/utils/utils"; import {GettextMixin} from "@/utils/utils"; import CompileComponent from "@/components/CompileComponent"; +import IngredientsCard from "@/components/IngredientsCard"; import Vue from "vue"; import moment from "moment"; import {ResolveUrlMixin} from "@/utils/utils"; @@ -174,10 +174,7 @@ export default { GettextMixin, ResolveUrlMixin, ], - components: { - IngredientComponent, - CompileComponent, - }, + components: { CompileComponent, IngredientsCard}, props: { step: Object, ingredient_factor: Number, diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index 589ee6fa..3a220d51 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -173,6 +173,15 @@ "Time": "Time", "Text": "Text", "Shopping_list": "Shopping List", + "Added_by": "Added By", + "Added_on": "Added On", + "AddToShopping": "Add to shopping list", + "IngredientInShopping": "This ingredient is in your shopping list.", + "NotInShopping": "{food} is not in your shopping list.", + "OnHand": "Have On Hand", + "FoodOnHand": "You have {food} on hand.", + "FoodNotOnHand": "You do not have {food} on hand.", + "Undefined": "Undefined", "Create_Meal_Plan_Entry": "Create meal plan entry", "Edit_Meal_Plan_Entry": "Edit meal plan entry", "Title": "Title", @@ -194,6 +203,41 @@ "Title_or_Recipe_Required": "Title or recipe selection required", "Color": "Color", "New_Meal_Type": "New Meal type", + "AddFoodToShopping": "Add {food} to your shopping list", + "RemoveFoodFromShopping": "Remove {food} from your shopping list", + "DeleteShoppingConfirm": "Are you sure that you want to remove all {food} from the shopping list?", + "IgnoredFood": "{food} is set to ignore shopping.", + "Add_Servings_to_Shopping": "Add {servings} Servings to Shopping", + "Inherit": "Inherit", + "IgnoreInherit": "Do Not Inherit Fields", + "FoodInherit": "Food Inheritable Fields", + "ShowUncategorizedFood": "Show Undefined", + "GroupBy": "Group By", + "SupermarketCategoriesOnly": "Supermarket Categories Only", + "MoveCategory": "Move To: ", + "CountMore": "...+{count} more", + "IgnoreThis": "Never auto-add {food} to shopping", + "DelayFor": "Delay for {hours} hours", + "Warning": "Warning", + "NoCategory": "No category selected.", + "InheritWarning": "{food} is set to inherit, changes may not persist.", + "ShowDelayed": "Show Delayed Items", + "Completed": "Completed", + "OfflineAlert": "You are offline, shopping list may not syncronize.", + "shopping_share": "Share Shopping List", + "shopping_auto_sync": "Autosync", + "mealplan_autoadd_shopping": "Auto Add Meal Plan", + "mealplan_autoexclude_onhand": "Exclude On Hand", + "mealplan_autoinclude_related": "Add Related Recipes", + "default_delay": "Default Delay Hours", + "shopping_share_desc": "Users will see all items you add to your shopping list. They must add you to see items on their list.", + "shopping_auto_sync_desc": "Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but will use mobile data.", + "mealplan_autoadd_shopping_desc": "Automatically add meal plan ingredients to shopping list.", + "mealplan_autoexclude_onhand_desc": "When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.", + "mealplan_autoinclude_related_desc": "When automatically adding a meal plan to the shopping list, include all related recipes.", + "default_delay_desc": "Default number of hours to delay a shopping list entry.", + "filter_to_supermarket": "Filter to Supermarket", + "filter_to_supermarket_desc": "Filter shopping list to only include supermarket categories.", "Week_Numbers": "Week numbers", "Show_Week_Numbers": "Show week numbers ?", "Export_As_ICal": "Export current period to iCal format", diff --git a/vue/src/utils/api.js b/vue/src/utils/api.js index ba164818..8d35049d 100644 --- a/vue/src/utils/api.js +++ b/vue/src/utils/api.js @@ -1,6 +1,7 @@ import axios from "axios"; import {djangoGettext as _, makeToast} from "@/utils/utils"; import {resolveDjangoUrl} from "@/utils/utils"; +import {ApiApiFactory} from "@/utils/openapi/api.ts"; axios.defaults.xsrfCookieName = 'csrftoken' axios.defaults.xsrfHeaderName = "X-CSRFTOKEN" @@ -47,4 +48,8 @@ function handleError(error, message) { makeToast('Error', message, 'danger') console.log(error) } -} \ No newline at end of file +} + +/* +* Generic class to use OpenAPIs with parameters and provide generic modals +* */ \ No newline at end of file diff --git a/vue/src/utils/apiv2.js b/vue/src/utils/apiv2.js new file mode 100644 index 00000000..540890a6 --- /dev/null +++ b/vue/src/utils/apiv2.js @@ -0,0 +1,16 @@ +/* +* Utility functions to use OpenAPIs generically +* */ +import {ApiApiFactory} from "@/utils/openapi/api.ts"; + +import axios from "axios"; +axios.defaults.xsrfCookieName = 'csrftoken' +axios.defaults.xsrfHeaderName = "X-CSRFTOKEN" + +export class GenericAPI { + constructor(model, action) { + this.model = model; + this.action = action; + this.function_name = action + model + } +} \ No newline at end of file diff --git a/vue/src/utils/models.js b/vue/src/utils/models.js index 23911b3d..312e6cc9 100644 --- a/vue/src/utils/models.js +++ b/vue/src/utils/models.js @@ -67,12 +67,15 @@ export class Models { merge: true, badges: { linked_recipe: true, + on_hand: true, + shopping: true, }, tags: [{ field: "supermarket_category", label: "name", color: "info" }], // REQUIRED: unordered array of fields that can be set during create create: { // if not defined partialUpdate will use the same parameters, prepending 'id' - params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category"]], + params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category", "on_hand", "inherit", "ignore_inherit"]], + form: { name: { form_field: true, @@ -101,6 +104,12 @@ export class Models { field: "ignore_shopping", label: i18n.t("Ignore_Shopping"), }, + onhand: { + form_field: true, + type: "checkbox", + field: "on_hand", + label: i18n.t("OnHand"), + }, shopping_category: { form_field: true, type: "lookup", @@ -109,8 +118,30 @@ export class Models { label: i18n.t("Shopping_Category"), allow_create: true, }, + inherit: { + form_field: true, + type: "checkbox", + field: "inherit", + label: i18n.t("Inherit"), + }, + ignore_inherit: { + form_field: true, + type: "lookup", + multiple: true, + field: "ignore_inherit", + list: "FOOD_INHERIT_FIELDS", + label: i18n.t("IgnoreInherit"), + }, + form_function: "FoodCreateDefault", }, }, + shopping: { + params: ["id", ["id", "amount", "unit", "_delete"]], + }, + } + static FOOD_INHERIT_FIELDS = { + name: i18n.t("FoodInherit"), + apiName: "FoodInheritField", } static KEYWORD = { @@ -180,6 +211,12 @@ export class Models { static SHOPPING_LIST = { name: i18n.t("Shopping_list"), apiName: "ShoppingListEntry", + list: { + params: ["id", "checked", "supermarket", "options"], + }, + create: { + params: [["amount", "unit", "food", "checked"]], + }, } static RECIPE_BOOK = { @@ -370,41 +407,15 @@ export class Models { name: i18n.t("Recipe"), apiName: "Recipe", list: { - params: [ - "query", - "keywords", - "foods", - "units", - "rating", - "books", - "steps", - "keywordsOr", - "foodsOr", - "booksOr", - "internal", - "random", - "_new", - "page", - "pageSize", - "options", - ], - config: { - foods: { type: "string" }, - keywords: { type: "string" }, - books: { type: "string" }, - }, + params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"], + // 'config': { + // 'foods': {'type': 'string'}, + // 'keywords': {'type': 'string'}, + // 'books': {'type': 'string'}, + // } }, - } - - static STEP = { - name: i18n.t("Step"), - apiName: "Step", - paginated: true, - list: { - header_component: { - name: "BetaWarning", - }, - params: ["query", "page", "pageSize", "options"], + shopping: { + params: ["id", ["id", "list_recipe", "ingredients", "servings"]], }, } @@ -461,6 +472,11 @@ export class Models { }, }, } + static USER = { + name: i18n.t("User"), + apiName: "User", + paginated: false, + } } export class Actions { @@ -639,4 +655,7 @@ export class Actions { }, }, } + static SHOPPING = { + function: "shopping", + } } diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index 5d4aabc5..3c20e90c 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -197,6 +197,12 @@ export interface Food { * @memberof Food */ description?: string; + /** + * + * @type {string} + * @memberof Food + */ + shopping?: string; /** * * @type {FoodRecipe} @@ -227,6 +233,74 @@ export interface Food { * @memberof Food */ numchild?: number; + /** + * + * @type {boolean} + * @memberof Food + */ + on_hand?: boolean; + /** + * + * @type {boolean} + * @memberof Food + */ + inherit?: boolean; + /** + * + * @type {Array} + * @memberof Food + */ + ignore_inherit?: Array | null; +} +/** + * + * @export + * @interface FoodIgnoreInherit + */ +export interface FoodIgnoreInherit { + /** + * + * @type {number} + * @memberof FoodIgnoreInherit + */ + id?: number; + /** + * + * @type {string} + * @memberof FoodIgnoreInherit + */ + name?: string; + /** + * + * @type {string} + * @memberof FoodIgnoreInherit + */ + field?: string; +} +/** + * + * @export + * @interface FoodInheritField + */ +export interface FoodInheritField { + /** + * + * @type {number} + * @memberof FoodInheritField + */ + id?: number; + /** + * + * @type {string} + * @memberof FoodInheritField + */ + name?: string; + /** + * + * @type {string} + * @memberof FoodInheritField + */ + field?: string; } /** * @@ -253,6 +327,46 @@ export interface FoodRecipe { */ url?: string; } +/** + * + * @export + * @interface FoodShoppingUpdate + */ +export interface FoodShoppingUpdate { + /** + * + * @type {number} + * @memberof FoodShoppingUpdate + */ + id?: number; + /** + * Amount of food to add to the shopping list + * @type {number} + * @memberof FoodShoppingUpdate + */ + amount?: number | null; + /** + * ID of unit to use for the shopping list + * @type {number} + * @memberof FoodShoppingUpdate + */ + unit?: number | null; + /** + * When set to true will delete all food from active shopping lists. + * @type {string} + * @memberof FoodShoppingUpdate + */ + _delete: FoodShoppingUpdateDeleteEnum; +} + +/** + * @export + * @enum {string} + */ +export enum FoodShoppingUpdateDeleteEnum { + True = 'true' +} + /** * * @export @@ -387,18 +501,6 @@ export interface ImportLogKeyword { * @memberof ImportLogKeyword */ numchild?: number; - /** - * - * @type {string} - * @memberof ImportLogKeyword - */ - created_at?: string; - /** - * - * @type {string} - * @memberof ImportLogKeyword - */ - updated_at?: string; } /** * @@ -414,10 +516,10 @@ export interface Ingredient { id?: number; /** * - * @type {StepFood} + * @type {IngredientFood} * @memberof Ingredient */ - food: StepFood | null; + food: IngredientFood | null; /** * * @type {FoodSupermarketCategory} @@ -455,6 +557,85 @@ export interface Ingredient { */ no_amount?: boolean; } +/** + * + * @export + * @interface IngredientFood + */ +export interface IngredientFood { + /** + * + * @type {number} + * @memberof IngredientFood + */ + id?: number; + /** + * + * @type {string} + * @memberof IngredientFood + */ + name: string; + /** + * + * @type {string} + * @memberof IngredientFood + */ + description?: string; + /** + * + * @type {string} + * @memberof IngredientFood + */ + shopping?: string; + /** + * + * @type {FoodRecipe} + * @memberof IngredientFood + */ + recipe?: FoodRecipe | null; + /** + * + * @type {boolean} + * @memberof IngredientFood + */ + ignore_shopping?: boolean; + /** + * + * @type {FoodSupermarketCategory} + * @memberof IngredientFood + */ + supermarket_category?: FoodSupermarketCategory | null; + /** + * + * @type {string} + * @memberof IngredientFood + */ + parent?: string; + /** + * + * @type {number} + * @memberof IngredientFood + */ + numchild?: number; + /** + * + * @type {boolean} + * @memberof IngredientFood + */ + on_hand?: boolean; + /** + * + * @type {boolean} + * @memberof IngredientFood + */ + inherit?: boolean; + /** + * + * @type {Array} + * @memberof IngredientFood + */ + ignore_inherit?: Array | null; +} /** * * @export @@ -481,10 +662,10 @@ export interface InlineResponse200 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse200 */ - results?: Array; + results?: Array; } /** * @@ -512,10 +693,10 @@ export interface InlineResponse2001 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2001 */ - results?: Array; + results?: Array; } /** * @@ -543,10 +724,10 @@ export interface InlineResponse2002 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2002 */ - results?: Array; + results?: Array; } /** * @@ -574,10 +755,10 @@ export interface InlineResponse2003 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2003 */ - results?: Array; + results?: Array; } /** * @@ -636,10 +817,10 @@ export interface InlineResponse2005 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2005 */ - results?: Array; + results?: Array; } /** * @@ -667,10 +848,10 @@ export interface InlineResponse2006 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2006 */ - results?: Array; + results?: Array; } /** * @@ -698,10 +879,10 @@ export interface InlineResponse2007 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2007 */ - results?: Array; + results?: Array; } /** * @@ -729,10 +910,10 @@ export interface InlineResponse2008 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2008 */ - results?: Array; + results?: Array; } /** * @@ -760,10 +941,10 @@ export interface InlineResponse2009 { previous?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof InlineResponse2009 */ - results?: Array; + results?: Array; } /** * @@ -813,18 +994,6 @@ export interface Keyword { * @memberof Keyword */ numchild?: number; - /** - * - * @type {string} - * @memberof Keyword - */ - created_at?: string; - /** - * - * @type {string} - * @memberof Keyword - */ - updated_at?: string; } /** * @@ -986,10 +1155,10 @@ export interface MealPlanRecipe { image?: any; /** * - * @type {Array} + * @type {Array} * @memberof MealPlanRecipe */ - keywords: Array; + keywords: Array; /** * * @type {number} @@ -1057,6 +1226,25 @@ export interface MealPlanRecipe { */ _new?: string; } +/** + * + * @export + * @interface MealPlanRecipeKeywords + */ +export interface MealPlanRecipeKeywords { + /** + * + * @type {number} + * @memberof MealPlanRecipeKeywords + */ + id?: number; + /** + * + * @type {string} + * @memberof MealPlanRecipeKeywords + */ + label?: string; +} /** * * @export @@ -1253,10 +1441,10 @@ export interface RecipeBook { icon?: string | null; /** * - * @type {Array} + * @type {Array} * @memberof RecipeBook */ - shared: Array; + shared: Array; /** * * @type {string} @@ -1301,6 +1489,50 @@ export interface RecipeBookEntry { */ recipe_content?: string; } +/** + * + * @export + * @interface RecipeBookShared + */ +export interface RecipeBookShared { + /** + * + * @type {number} + * @memberof RecipeBookShared + */ + id?: number; + /** + * + * @type {string} + * @memberof RecipeBookShared + */ + username?: string; +} +/** + * + * @export + * @interface RecipeFile + */ +export interface RecipeFile { + /** + * + * @type {string} + * @memberof RecipeFile + */ + name: string; + /** + * + * @type {any} + * @memberof RecipeFile + */ + file?: any; + /** + * + * @type {number} + * @memberof RecipeFile + */ + id?: number; +} /** * * @export @@ -1314,6 +1546,61 @@ export interface RecipeImage { */ image?: any | null; } +/** + * + * @export + * @interface RecipeIngredients + */ +export interface RecipeIngredients { + /** + * + * @type {number} + * @memberof RecipeIngredients + */ + id?: number; + /** + * + * @type {IngredientFood} + * @memberof RecipeIngredients + */ + food: IngredientFood | null; + /** + * + * @type {FoodSupermarketCategory} + * @memberof RecipeIngredients + */ + unit: FoodSupermarketCategory | null; + /** + * + * @type {string} + * @memberof RecipeIngredients + */ + amount: string; + /** + * + * @type {string} + * @memberof RecipeIngredients + */ + note?: string | null; + /** + * + * @type {number} + * @memberof RecipeIngredients + */ + order?: number; + /** + * + * @type {boolean} + * @memberof RecipeIngredients + */ + is_header?: boolean; + /** + * + * @type {boolean} + * @memberof RecipeIngredients + */ + no_amount?: boolean; +} /** * * @export @@ -1362,18 +1649,6 @@ export interface RecipeKeywords { * @memberof RecipeKeywords */ numchild?: number; - /** - * - * @type {string} - * @memberof RecipeKeywords - */ - created_at?: string; - /** - * - * @type {string} - * @memberof RecipeKeywords - */ - updated_at?: string; } /** * @@ -1450,10 +1725,10 @@ export interface RecipeOverview { image?: any; /** * - * @type {Array} + * @type {Array} * @memberof RecipeOverview */ - keywords: Array; + keywords: Array; /** * * @type {number} @@ -1524,21 +1799,58 @@ export interface RecipeOverview { /** * * @export - * @interface RecipeOverviewKeywords + * @interface RecipeShoppingUpdate */ -export interface RecipeOverviewKeywords { +export interface RecipeShoppingUpdate { /** * * @type {number} - * @memberof RecipeOverviewKeywords + * @memberof RecipeShoppingUpdate + */ + id?: number; + /** + * Existing shopping list to update + * @type {number} + * @memberof RecipeShoppingUpdate + */ + list_recipe?: number | null; + /** + * List of ingredient IDs from the recipe to add, if not provided all ingredients will be added. + * @type {number} + * @memberof RecipeShoppingUpdate + */ + ingredients?: number | null; + /** + * Providing a list_recipe ID and servings of 0 will delete that shopping list. + * @type {number} + * @memberof RecipeShoppingUpdate + */ + servings?: number | null; +} +/** + * + * @export + * @interface RecipeSimple + */ +export interface RecipeSimple { + /** + * + * @type {number} + * @memberof RecipeSimple */ id?: number; /** * * @type {string} - * @memberof RecipeOverviewKeywords + * @memberof RecipeSimple */ - label?: string; + name?: string; + /** + * + * @type {string} + * @memberof RecipeSimple + */ + url?: string; } /** * @@ -1572,10 +1884,10 @@ export interface RecipeSteps { instruction?: string; /** * - * @type {Array} + * @type {Array} * @memberof RecipeSteps */ - ingredients: Array; + ingredients: Array; /** * * @type {string} @@ -1608,10 +1920,10 @@ export interface RecipeSteps { show_as_header?: boolean; /** * - * @type {StepFile} + * @type {RecipeFile} * @memberof RecipeSteps */ - file?: StepFile | null; + file?: RecipeFile | null; /** * * @type {number} @@ -1681,10 +1993,10 @@ export interface ShoppingList { entries: Array | null; /** * - * @type {Array} + * @type {Array} * @memberof ShoppingList */ - shared: Array; + shared: Array; /** * * @type {boolean} @@ -1710,6 +2022,25 @@ export interface ShoppingList { */ created_at?: string; } +/** + * + * @export + * @interface ShoppingListCreatedBy + */ +export interface ShoppingListCreatedBy { + /** + * + * @type {number} + * @memberof ShoppingListCreatedBy + */ + id?: number; + /** + * + * @type {string} + * @memberof ShoppingListCreatedBy + */ + username?: string; +} /** * * @export @@ -1724,22 +2055,34 @@ export interface ShoppingListEntries { id?: number; /** * - * @type {number} + * @type {string} * @memberof ShoppingListEntries */ - list_recipe?: number | null; + list_recipe?: string; /** * - * @type {StepFood} + * @type {IngredientFood} * @memberof ShoppingListEntries */ - food: StepFood | null; + food: IngredientFood | null; /** * * @type {FoodSupermarketCategory} * @memberof ShoppingListEntries */ unit?: FoodSupermarketCategory | null; + /** + * + * @type {number} + * @memberof ShoppingListEntries + */ + ingredient?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListEntries + */ + ingredient_note?: string; /** * * @type {string} @@ -1758,6 +2101,30 @@ export interface ShoppingListEntries { * @memberof ShoppingListEntries */ checked?: boolean; + /** + * + * @type {ShoppingListRecipeMealplan} + * @memberof ShoppingListEntries + */ + recipe_mealplan?: ShoppingListRecipeMealplan; + /** + * + * @type {ShoppingListCreatedBy} + * @memberof ShoppingListEntries + */ + created_by?: ShoppingListCreatedBy; + /** + * + * @type {string} + * @memberof ShoppingListEntries + */ + created_at?: string; + /** + * + * @type {string} + * @memberof ShoppingListEntries + */ + completed_at?: string; } /** * @@ -1773,22 +2140,34 @@ export interface ShoppingListEntry { id?: number; /** * - * @type {number} + * @type {string} * @memberof ShoppingListEntry */ - list_recipe?: number | null; + list_recipe?: string; /** * - * @type {StepFood} + * @type {IngredientFood} * @memberof ShoppingListEntry */ - food: StepFood | null; + food: IngredientFood | null; /** * * @type {FoodSupermarketCategory} * @memberof ShoppingListEntry */ unit?: FoodSupermarketCategory | null; + /** + * + * @type {number} + * @memberof ShoppingListEntry + */ + ingredient?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListEntry + */ + ingredient_note?: string; /** * * @type {string} @@ -1807,6 +2186,30 @@ export interface ShoppingListEntry { * @memberof ShoppingListEntry */ checked?: boolean; + /** + * + * @type {ShoppingListRecipeMealplan} + * @memberof ShoppingListEntry + */ + recipe_mealplan?: ShoppingListRecipeMealplan; + /** + * + * @type {ShoppingListCreatedBy} + * @memberof ShoppingListEntry + */ + created_by?: ShoppingListCreatedBy; + /** + * + * @type {string} + * @memberof ShoppingListEntry + */ + created_at?: string; + /** + * + * @type {string} + * @memberof ShoppingListEntry + */ + completed_at?: string; } /** * @@ -1820,6 +2223,12 @@ export interface ShoppingListRecipe { * @memberof ShoppingListRecipe */ id?: number; + /** + * + * @type {string} + * @memberof ShoppingListRecipe + */ + name?: string; /** * * @type {number} @@ -1828,16 +2237,65 @@ export interface ShoppingListRecipe { recipe?: number | null; /** * - * @type {string} + * @type {number} * @memberof ShoppingListRecipe */ - recipe_name?: string; + mealplan?: number | null; /** * * @type {string} * @memberof ShoppingListRecipe */ servings: string; + /** + * + * @type {string} + * @memberof ShoppingListRecipe + */ + mealplan_note?: string; +} +/** + * + * @export + * @interface ShoppingListRecipeMealplan + */ +export interface ShoppingListRecipeMealplan { + /** + * + * @type {number} + * @memberof ShoppingListRecipeMealplan + */ + id?: number; + /** + * + * @type {string} + * @memberof ShoppingListRecipeMealplan + */ + name?: string; + /** + * + * @type {number} + * @memberof ShoppingListRecipeMealplan + */ + recipe?: number | null; + /** + * + * @type {number} + * @memberof ShoppingListRecipeMealplan + */ + mealplan?: number | null; + /** + * + * @type {string} + * @memberof ShoppingListRecipeMealplan + */ + servings: string; + /** + * + * @type {string} + * @memberof ShoppingListRecipeMealplan + */ + mealplan_note?: string; } /** * @@ -1851,6 +2309,12 @@ export interface ShoppingListRecipes { * @memberof ShoppingListRecipes */ id?: number; + /** + * + * @type {string} + * @memberof ShoppingListRecipes + */ + name?: string; /** * * @type {number} @@ -1859,35 +2323,22 @@ export interface ShoppingListRecipes { recipe?: number | null; /** * - * @type {string} + * @type {number} * @memberof ShoppingListRecipes */ - recipe_name?: string; + mealplan?: number | null; /** * * @type {string} * @memberof ShoppingListRecipes */ servings: string; -} -/** - * - * @export - * @interface ShoppingListShared - */ -export interface ShoppingListShared { - /** - * - * @type {number} - * @memberof ShoppingListShared - */ - id?: number; /** * * @type {string} - * @memberof ShoppingListShared + * @memberof ShoppingListRecipes */ - username?: string; + mealplan_note?: string; } /** * @@ -2008,10 +2459,10 @@ export interface Step { instruction?: string; /** * - * @type {Array} + * @type {Array} * @memberof Step */ - ingredients: Array; + ingredients: Array; /** * * @type {string} @@ -2044,10 +2495,10 @@ export interface Step { show_as_header?: boolean; /** * - * @type {StepFile} + * @type {RecipeFile} * @memberof Step */ - file?: StepFile | null; + file?: RecipeFile | null; /** * * @type {number} @@ -2079,141 +2530,6 @@ export enum StepTypeEnum { Recipe = 'RECIPE' } -/** - * - * @export - * @interface StepFile - */ -export interface StepFile { - /** - * - * @type {string} - * @memberof StepFile - */ - name: string; - /** - * - * @type {any} - * @memberof StepFile - */ - file?: any; - /** - * - * @type {number} - * @memberof StepFile - */ - id?: number; -} -/** - * - * @export - * @interface StepFood - */ -export interface StepFood { - /** - * - * @type {number} - * @memberof StepFood - */ - id?: number; - /** - * - * @type {string} - * @memberof StepFood - */ - name: string; - /** - * - * @type {string} - * @memberof StepFood - */ - description?: string; - /** - * - * @type {FoodRecipe} - * @memberof StepFood - */ - recipe?: FoodRecipe | null; - /** - * - * @type {boolean} - * @memberof StepFood - */ - ignore_shopping?: boolean; - /** - * - * @type {FoodSupermarketCategory} - * @memberof StepFood - */ - supermarket_category?: FoodSupermarketCategory | null; - /** - * - * @type {string} - * @memberof StepFood - */ - parent?: string; - /** - * - * @type {number} - * @memberof StepFood - */ - numchild?: number; -} -/** - * - * @export - * @interface StepIngredients - */ -export interface StepIngredients { - /** - * - * @type {number} - * @memberof StepIngredients - */ - id?: number; - /** - * - * @type {StepFood} - * @memberof StepIngredients - */ - food: StepFood | null; - /** - * - * @type {FoodSupermarketCategory} - * @memberof StepIngredients - */ - unit: FoodSupermarketCategory | null; - /** - * - * @type {string} - * @memberof StepIngredients - */ - amount: string; - /** - * - * @type {string} - * @memberof StepIngredients - */ - note?: string | null; - /** - * - * @type {number} - * @memberof StepIngredients - */ - order?: number; - /** - * - * @type {boolean} - * @memberof StepIngredients - */ - is_header?: boolean; - /** - * - * @type {boolean} - * @memberof StepIngredients - */ - no_amount?: boolean; -} /** * * @export @@ -2588,6 +2904,18 @@ export interface UserPreference { * @memberof UserPreference */ comments?: boolean; + /** + * + * @type {number} + * @memberof UserPreference + */ + shopping_auto_sync?: number; + /** + * + * @type {boolean} + * @memberof UserPreference + */ + mealplan_autoadd_shopping?: boolean; } /** @@ -4474,6 +4802,35 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listFoodInheritFields: async (options: any = {}): Promise => { + const localVarPath = `/api/food-inherit-field/`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -4778,24 +5135,23 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) /** * * @param {string} [query] Query string matched (fuzzy) against recipe name. In the future also fulltext search. - * @param {string} [keywords] Id of keyword a recipe should have. For multiple repeat parameter. - * @param {string} [foods] Id of food a recipe should have. For multiple repeat parameter. - * @param {number} [units] Id of unit a recipe should have. - * @param {number} [rating] Id of unit a recipe should have. - * @param {string} [books] Id of book a recipe should have. For multiple repeat parameter. - * @param {string} [steps] Id of a step a recipe should have. For multiple repeat parameter. - * @param {string} [keywordsOr] If recipe should have all (AND) or any (OR) of the provided keywords. - * @param {string} [foodsOr] If recipe should have all (AND) or any (OR) any of the provided foods. - * @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books. - * @param {string} [internal] true or false. If only internal recipes should be returned or not. - * @param {string} [random] true or false. returns the results in randomized order. - * @param {string} [_new] true or false. returns new results first in search results + * @param {number} [keywords] ID of keyword a recipe should have. For multiple repeat parameter. + * @param {number} [foods] ID of food a recipe should have. For multiple repeat parameter. + * @param {number} [units] ID of unit a recipe should have. + * @param {number} [rating] Rating a recipe should have. [0 - 5] + * @param {string} [books] ID of book a recipe should be in. For multiple repeat parameter. + * @param {string} [keywordsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided keywords. + * @param {string} [foodsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided foods. + * @param {string} [booksOr] If recipe should be in all (AND=false) or any (OR=<b>true</b>) of the provided books. + * @param {string} [internal] If only internal recipes should be returned. [true/<b>false</b>] + * @param {string} [random] Returns the results in randomized order. [true/<b>false</b>] + * @param {string} [_new] Returns new results first in search results. [true/<b>false</b>] * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRecipes: async (query?: string, keywords?: string, foods?: string, units?: number, rating?: number, books?: string, steps?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options: any = {}): Promise => { + listRecipes: async (query?: string, keywords?: number, foods?: number, units?: number, rating?: number, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options: any = {}): Promise => { const localVarPath = `/api/recipe/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -4881,10 +5237,13 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) }, /** * + * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. + * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. + * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listShoppingListEntrys: async (options: any = {}): Promise => { + listShoppingListEntrys: async (id?: number, checked?: string, supermarket?: number, options: any = {}): Promise => { const localVarPath = `/api/shopping-list-entry/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -4897,6 +5256,18 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + if (id !== undefined) { + localVarQueryParameter['id'] = id; + } + + if (checked !== undefined) { + localVarQueryParameter['checked'] = checked; + } + + if (supermarket !== undefined) { + localVarQueryParameter['supermarket'] = supermarket; + } + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); @@ -4968,13 +5339,13 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) }, /** * - * @param {string} [query] Query string matched (fuzzy) against object name. + * @param {number} [recipe] ID of recipe a step is part of. For multiple repeat parameter. * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSteps: async (query?: string, page?: number, pageSize?: number, options: any = {}): Promise => { + listSteps: async (recipe?: number, page?: number, pageSize?: number, options: any = {}): Promise => { const localVarPath = `/api/step/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -4987,8 +5358,8 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; - if (query !== undefined) { - localVarQueryParameter['query'] = query; + if (recipe !== undefined) { + localVarQueryParameter['recipe'] = recipe; } if (page !== undefined) { @@ -6526,6 +6897,39 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + relatedRecipe: async (id: string, options: any = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('relatedRecipe', 'id', id) + const localVarPath = `/api/recipe/{id}/related/` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -6649,6 +7053,39 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id A unique integer value identifying this food inherit field. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + retrieveFoodInheritField: async (id: string, options: any = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('retrieveFoodInheritField', 'id', id) + const localVarPath = `/api/food-inherit-field/{id}/` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -7417,6 +7854,80 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * + * @param {string} id A unique integer value identifying this food. + * @param {FoodShoppingUpdate} [foodShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + shoppingFood: async (id: string, foodShoppingUpdate?: FoodShoppingUpdate, options: any = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('shoppingFood', 'id', id) + const localVarPath = `/api/food/{id}/shopping/` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(foodShoppingUpdate, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {RecipeShoppingUpdate} [recipeShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + shoppingRecipe: async (id: string, recipeShoppingUpdate?: RecipeShoppingUpdate, options: any = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('shoppingRecipe', 'id', id) + const localVarPath = `/api/recipe/{id}/shopping/` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(recipeShoppingUpdate, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -8913,10 +9424,19 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listCookLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listCookLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listCookLogs(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listFoodInheritFields(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listFoodInheritFields(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} [query] Query string matched against food name. @@ -8927,7 +9447,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listFoods(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listFoods(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listFoods(query, root, tree, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -8938,7 +9458,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listImportLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listImportLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listImportLogs(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -8961,7 +9481,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listKeywords(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listKeywords(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listKeywords(query, root, tree, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9004,34 +9524,36 @@ export const ApiApiFp = function(configuration?: Configuration) { /** * * @param {string} [query] Query string matched (fuzzy) against recipe name. In the future also fulltext search. - * @param {string} [keywords] Id of keyword a recipe should have. For multiple repeat parameter. - * @param {string} [foods] Id of food a recipe should have. For multiple repeat parameter. - * @param {number} [units] Id of unit a recipe should have. - * @param {number} [rating] Id of unit a recipe should have. - * @param {string} [books] Id of book a recipe should have. For multiple repeat parameter. - * @param {string} [steps] Id of a step a recipe should have. For multiple repeat parameter. - * @param {string} [keywordsOr] If recipe should have all (AND) or any (OR) of the provided keywords. - * @param {string} [foodsOr] If recipe should have all (AND) or any (OR) any of the provided foods. - * @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books. - * @param {string} [internal] true or false. If only internal recipes should be returned or not. - * @param {string} [random] true or false. returns the results in randomized order. - * @param {string} [_new] true or false. returns new results first in search results + * @param {number} [keywords] ID of keyword a recipe should have. For multiple repeat parameter. + * @param {number} [foods] ID of food a recipe should have. For multiple repeat parameter. + * @param {number} [units] ID of unit a recipe should have. + * @param {number} [rating] Rating a recipe should have. [0 - 5] + * @param {string} [books] ID of book a recipe should be in. For multiple repeat parameter. + * @param {string} [keywordsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided keywords. + * @param {string} [foodsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided foods. + * @param {string} [booksOr] If recipe should be in all (AND=false) or any (OR=<b>true</b>) of the provided books. + * @param {string} [internal] If only internal recipes should be returned. [true/<b>false</b>] + * @param {string} [random] Returns the results in randomized order. [true/<b>false</b>] + * @param {string} [_new] Returns new results first in search results. [true/<b>false</b>] * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listRecipes(query?: string, keywords?: string, foods?: string, units?: number, rating?: number, books?: string, steps?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listRecipes(query, keywords, foods, units, rating, books, steps, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options); + async listRecipes(query?: string, keywords?: number, foods?: number, units?: number, rating?: number, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listRecipes(query, keywords, foods, units, rating, books, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** * + * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. + * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. + * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listShoppingListEntrys(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listShoppingListEntrys(options); + async listShoppingListEntrys(id?: number, checked?: string, supermarket?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listShoppingListEntrys(id, checked, supermarket, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -9054,14 +9576,14 @@ export const ApiApiFp = function(configuration?: Configuration) { }, /** * - * @param {string} [query] Query string matched (fuzzy) against object name. + * @param {number} [recipe] ID of recipe a step is part of. For multiple repeat parameter. * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listSteps(query?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listSteps(query, page, pageSize, options); + async listSteps(recipe?: number, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listSteps(recipe, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -9080,7 +9602,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listSupermarketCategoryRelations(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listSupermarketCategoryRelations(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listSupermarketCategoryRelations(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9109,7 +9631,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listSyncLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listSyncLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listSyncLogs(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9130,7 +9652,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listUnits(query?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listUnits(query?: string, page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listUnits(query, page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9168,7 +9690,7 @@ export const ApiApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listViewLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async listViewLogs(page?: number, pageSize?: number, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.listViewLogs(page, pageSize, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -9510,6 +10032,16 @@ export const ApiApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.partialUpdateViewLog(id, viewLog, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async relatedRecipe(id: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.relatedRecipe(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -9550,6 +10082,16 @@ export const ApiApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveFood(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id A unique integer value identifying this food inherit field. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async retrieveFoodInheritField(id: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveFoodInheritField(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id A unique integer value identifying this import log. @@ -9780,6 +10322,28 @@ export const ApiApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveViewLog(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id A unique integer value identifying this food. + * @param {FoodShoppingUpdate} [foodShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async shoppingFood(id: string, foodShoppingUpdate?: FoodShoppingUpdate, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.shoppingFood(id, foodShoppingUpdate, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {RecipeShoppingUpdate} [recipeShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async shoppingRecipe(id: string, recipeShoppingUpdate?: RecipeShoppingUpdate, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.shoppingRecipe(id, recipeShoppingUpdate, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -10554,9 +11118,17 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listCookLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { + listCookLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listCookLogs(page, pageSize, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listFoodInheritFields(options?: any): AxiosPromise> { + return localVarFp.listFoodInheritFields(options).then((request) => request(axios, basePath)); + }, /** * * @param {string} [query] Query string matched against food name. @@ -10567,7 +11139,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listFoods(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { + listFoods(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listFoods(query, root, tree, page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10577,7 +11149,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listImportLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { + listImportLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listImportLogs(page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10598,7 +11170,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listKeywords(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { + listKeywords(query?: string, root?: number, tree?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listKeywords(query, root, tree, page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10636,33 +11208,35 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: /** * * @param {string} [query] Query string matched (fuzzy) against recipe name. In the future also fulltext search. - * @param {string} [keywords] Id of keyword a recipe should have. For multiple repeat parameter. - * @param {string} [foods] Id of food a recipe should have. For multiple repeat parameter. - * @param {number} [units] Id of unit a recipe should have. - * @param {number} [rating] Id of unit a recipe should have. - * @param {string} [books] Id of book a recipe should have. For multiple repeat parameter. - * @param {string} [steps] Id of a step a recipe should have. For multiple repeat parameter. - * @param {string} [keywordsOr] If recipe should have all (AND) or any (OR) of the provided keywords. - * @param {string} [foodsOr] If recipe should have all (AND) or any (OR) any of the provided foods. - * @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books. - * @param {string} [internal] true or false. If only internal recipes should be returned or not. - * @param {string} [random] true or false. returns the results in randomized order. - * @param {string} [_new] true or false. returns new results first in search results + * @param {number} [keywords] ID of keyword a recipe should have. For multiple repeat parameter. + * @param {number} [foods] ID of food a recipe should have. For multiple repeat parameter. + * @param {number} [units] ID of unit a recipe should have. + * @param {number} [rating] Rating a recipe should have. [0 - 5] + * @param {string} [books] ID of book a recipe should be in. For multiple repeat parameter. + * @param {string} [keywordsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided keywords. + * @param {string} [foodsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided foods. + * @param {string} [booksOr] If recipe should be in all (AND=false) or any (OR=<b>true</b>) of the provided books. + * @param {string} [internal] If only internal recipes should be returned. [true/<b>false</b>] + * @param {string} [random] Returns the results in randomized order. [true/<b>false</b>] + * @param {string} [_new] Returns new results first in search results. [true/<b>false</b>] * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRecipes(query?: string, keywords?: string, foods?: string, units?: number, rating?: number, books?: string, steps?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { - return localVarFp.listRecipes(query, keywords, foods, units, rating, books, steps, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(axios, basePath)); + listRecipes(query?: string, keywords?: number, foods?: number, units?: number, rating?: number, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { + return localVarFp.listRecipes(query, keywords, foods, units, rating, books, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(axios, basePath)); }, /** * + * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. + * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. + * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listShoppingListEntrys(options?: any): AxiosPromise> { - return localVarFp.listShoppingListEntrys(options).then((request) => request(axios, basePath)); + listShoppingListEntrys(id?: number, checked?: string, supermarket?: number, options?: any): AxiosPromise> { + return localVarFp.listShoppingListEntrys(id, checked, supermarket, options).then((request) => request(axios, basePath)); }, /** * @@ -10682,14 +11256,14 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: }, /** * - * @param {string} [query] Query string matched (fuzzy) against object name. + * @param {number} [recipe] ID of recipe a step is part of. For multiple repeat parameter. * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSteps(query?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { - return localVarFp.listSteps(query, page, pageSize, options).then((request) => request(axios, basePath)); + listSteps(recipe?: number, page?: number, pageSize?: number, options?: any): AxiosPromise { + return localVarFp.listSteps(recipe, page, pageSize, options).then((request) => request(axios, basePath)); }, /** * @@ -10706,7 +11280,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSupermarketCategoryRelations(page?: number, pageSize?: number, options?: any): AxiosPromise { + listSupermarketCategoryRelations(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listSupermarketCategoryRelations(page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10732,7 +11306,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSyncLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { + listSyncLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listSyncLogs(page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10751,7 +11325,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listUnits(query?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { + listUnits(query?: string, page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listUnits(query, page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -10785,7 +11359,7 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listViewLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { + listViewLogs(page?: number, pageSize?: number, options?: any): AxiosPromise { return localVarFp.listViewLogs(page, pageSize, options).then((request) => request(axios, basePath)); }, /** @@ -11096,6 +11670,15 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: partialUpdateViewLog(id: string, viewLog?: ViewLog, options?: any): AxiosPromise { return localVarFp.partialUpdateViewLog(id, viewLog, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + relatedRecipe(id: string, options?: any): AxiosPromise { + return localVarFp.relatedRecipe(id, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -11132,6 +11715,15 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: retrieveFood(id: string, options?: any): AxiosPromise { return localVarFp.retrieveFood(id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} id A unique integer value identifying this food inherit field. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + retrieveFoodInheritField(id: string, options?: any): AxiosPromise { + return localVarFp.retrieveFoodInheritField(id, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id A unique integer value identifying this import log. @@ -11339,6 +11931,26 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?: retrieveViewLog(id: string, options?: any): AxiosPromise { return localVarFp.retrieveViewLog(id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} id A unique integer value identifying this food. + * @param {FoodShoppingUpdate} [foodShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + shoppingFood(id: string, foodShoppingUpdate?: FoodShoppingUpdate, options?: any): AxiosPromise { + return localVarFp.shoppingFood(id, foodShoppingUpdate, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {RecipeShoppingUpdate} [recipeShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + shoppingRecipe(id: string, recipeShoppingUpdate?: RecipeShoppingUpdate, options?: any): AxiosPromise { + return localVarFp.shoppingRecipe(id, recipeShoppingUpdate, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id A unique integer value identifying this automation. @@ -12199,6 +12811,16 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).listCookLogs(page, pageSize, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public listFoodInheritFields(options?: any) { + return ApiApiFp(this.configuration).listFoodInheritFields(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} [query] Query string matched against food name. @@ -12294,36 +12916,38 @@ export class ApiApi extends BaseAPI { /** * * @param {string} [query] Query string matched (fuzzy) against recipe name. In the future also fulltext search. - * @param {string} [keywords] Id of keyword a recipe should have. For multiple repeat parameter. - * @param {string} [foods] Id of food a recipe should have. For multiple repeat parameter. - * @param {number} [units] Id of unit a recipe should have. - * @param {number} [rating] Id of unit a recipe should have. - * @param {string} [books] Id of book a recipe should have. For multiple repeat parameter. - * @param {string} [steps] Id of a step a recipe should have. For multiple repeat parameter. - * @param {string} [keywordsOr] If recipe should have all (AND) or any (OR) of the provided keywords. - * @param {string} [foodsOr] If recipe should have all (AND) or any (OR) any of the provided foods. - * @param {string} [booksOr] If recipe should be in all (AND) or any (OR) any of the provided books. - * @param {string} [internal] true or false. If only internal recipes should be returned or not. - * @param {string} [random] true or false. returns the results in randomized order. - * @param {string} [_new] true or false. returns new results first in search results + * @param {number} [keywords] ID of keyword a recipe should have. For multiple repeat parameter. + * @param {number} [foods] ID of food a recipe should have. For multiple repeat parameter. + * @param {number} [units] ID of unit a recipe should have. + * @param {number} [rating] Rating a recipe should have. [0 - 5] + * @param {string} [books] ID of book a recipe should be in. For multiple repeat parameter. + * @param {string} [keywordsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided keywords. + * @param {string} [foodsOr] If recipe should have all (AND=false) or any (OR=<b>true</b>) of the provided foods. + * @param {string} [booksOr] If recipe should be in all (AND=false) or any (OR=<b>true</b>) of the provided books. + * @param {string} [internal] If only internal recipes should be returned. [true/<b>false</b>] + * @param {string} [random] Returns the results in randomized order. [true/<b>false</b>] + * @param {string} [_new] Returns new results first in search results. [true/<b>false</b>] * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ApiApi */ - public listRecipes(query?: string, keywords?: string, foods?: string, units?: number, rating?: number, books?: string, steps?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any) { - return ApiApiFp(this.configuration).listRecipes(query, keywords, foods, units, rating, books, steps, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(this.axios, this.basePath)); + public listRecipes(query?: string, keywords?: number, foods?: number, units?: number, rating?: number, books?: string, keywordsOr?: string, foodsOr?: string, booksOr?: string, internal?: string, random?: string, _new?: string, page?: number, pageSize?: number, options?: any) { + return ApiApiFp(this.configuration).listRecipes(query, keywords, foods, units, rating, books, keywordsOr, foodsOr, booksOr, internal, random, _new, page, pageSize, options).then((request) => request(this.axios, this.basePath)); } /** * + * @param {number} [id] Returns the shopping list entry with a primary key of id. Multiple values allowed. + * @param {string} [checked] Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includes unchecked items and recently completed items. + * @param {number} [supermarket] Returns the shopping list entries sorted by supermarket category order. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ApiApi */ - public listShoppingListEntrys(options?: any) { - return ApiApiFp(this.configuration).listShoppingListEntrys(options).then((request) => request(this.axios, this.basePath)); + public listShoppingListEntrys(id?: number, checked?: string, supermarket?: number, options?: any) { + return ApiApiFp(this.configuration).listShoppingListEntrys(id, checked, supermarket, options).then((request) => request(this.axios, this.basePath)); } /** @@ -12348,15 +12972,15 @@ export class ApiApi extends BaseAPI { /** * - * @param {string} [query] Query string matched (fuzzy) against object name. + * @param {number} [recipe] ID of recipe a step is part of. For multiple repeat parameter. * @param {number} [page] A page number within the paginated result set. * @param {number} [pageSize] Number of results to return per page. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof ApiApi */ - public listSteps(query?: string, page?: number, pageSize?: number, options?: any) { - return ApiApiFp(this.configuration).listSteps(query, page, pageSize, options).then((request) => request(this.axios, this.basePath)); + public listSteps(recipe?: number, page?: number, pageSize?: number, options?: any) { + return ApiApiFp(this.configuration).listSteps(recipe, page, pageSize, options).then((request) => request(this.axios, this.basePath)); } /** @@ -12846,6 +13470,17 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).partialUpdateViewLog(id, viewLog, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public relatedRecipe(id: string, options?: any) { + return ApiApiFp(this.configuration).relatedRecipe(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} id A unique integer value identifying this automation. @@ -12890,6 +13525,17 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).retrieveFood(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {string} id A unique integer value identifying this food inherit field. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public retrieveFoodInheritField(id: string, options?: any) { + return ApiApiFp(this.configuration).retrieveFoodInheritField(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} id A unique integer value identifying this import log. @@ -13143,6 +13789,30 @@ export class ApiApi extends BaseAPI { return ApiApiFp(this.configuration).retrieveViewLog(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {string} id A unique integer value identifying this food. + * @param {FoodShoppingUpdate} [foodShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public shoppingFood(id: string, foodShoppingUpdate?: FoodShoppingUpdate, options?: any) { + return ApiApiFp(this.configuration).shoppingFood(id, foodShoppingUpdate, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {string} id A unique integer value identifying this recipe. + * @param {RecipeShoppingUpdate} [recipeShoppingUpdate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiApi + */ + public shoppingRecipe(id: string, recipeShoppingUpdate?: RecipeShoppingUpdate, options?: any) { + return ApiApiFp(this.configuration).shoppingRecipe(id, recipeShoppingUpdate, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} id A unique integer value identifying this automation. diff --git a/vue/src/utils/utils.js b/vue/src/utils/utils.js index 2be64a46..e4bce0a6 100644 --- a/vue/src/utils/utils.js +++ b/vue/src/utils/utils.js @@ -16,6 +16,7 @@ import Vue from "vue" import { Actions, Models } from "./models" export const ToastMixin = { + name: "ToastMixin", methods: { makeToast: function (title, message, variant = null) { return makeToast(title, message, variant) @@ -147,12 +148,17 @@ export function resolveDjangoUrl(url, params = null) { /* * other utilities * */ - -export function getUserPreference(pref) { - if (window.USER_PREF === undefined) { +export function getUserPreference(pref = undefined) { + let user_preference + if (document.getElementById("user_preference")) { + user_preference = JSON.parse(document.getElementById("user_preference").textContent) + } else { return undefined } - return window.USER_PREF[pref] + if (pref) { + return user_preference[pref] + } + return user_preference } export function calculateAmount(amount, factor) { @@ -214,6 +220,11 @@ export const ApiMixin = { return { Models: Models, Actions: Actions, + FoodCreateDefault: function (form) { + form.inherit_ignore = getUserPreference("food_ignore_default") + form.inherit = form.supermarket_category.length > 0 + return form + }, } }, methods: { @@ -525,3 +536,11 @@ const specialCases = { }) }, } + +export const formFunctions = { + FoodCreateDefault: function (form) { + form.fields.filter((x) => x.field === "ignore_inherit")[0].value = getUserPreference("food_ignore_default") + form.fields.filter((x) => x.field === "inherit")[0].value = getUserPreference("food_ignore_default").length > 0 + return form + }, +} diff --git a/vue/vue.config.js b/vue/vue.config.js index cfbf9872..90cdf729 100644 --- a/vue/vue.config.js +++ b/vue/vue.config.js @@ -37,8 +37,8 @@ const pages = { entry: "./src/apps/MealPlanView/main.js", chunks: ["chunk-vendors"], }, - checklist_view: { - entry: "./src/apps/ChecklistView/main.js", + shopping_list_view: { + entry: "./src/apps/ShoppingListView/main.js", chunks: ["chunk-vendors"], }, } @@ -47,7 +47,7 @@ module.exports = { pages: pages, filenameHashing: false, productionSourceMap: false, - publicPath: process.env.NODE_ENV === "production" ? "" : "http://localhost:8080/", + publicPath: process.env.NODE_ENV === "production" ? "/static/vue" : "http://localhost:8080/", outputDir: "../cookbook/static/vue/", runtimeCompiler: true, pwa: { @@ -90,18 +90,9 @@ module.exports = { }, }, // TODO make this conditional on .env DEBUG = FALSE - config.optimization.minimize(true) + config.optimization.minimize(false) ) - //TODO somehow remov them as they are also added to the manifest config of the service worker - /* - Object.keys(pages).forEach(page => { - config.plugins.delete(`html-${page}`); - config.plugins.delete(`preload-${page}`); - config.plugins.delete(`prefetch-${page}`); - }) - */ - config.plugin("BundleTracker").use(BundleTracker, [{ relativePath: true, path: "../vue/" }]) config.resolve.alias.set("__STATIC__", "static")