diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 24b39713..4885d59f 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -42,7 +42,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): api_serializer = None # extended values are computationally expensive and not needed in normal circumstances try: - if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer: + if str2bool( + self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer: return fields except (AttributeError, KeyError) as e: pass @@ -96,7 +97,8 @@ class CustomOnHandField(serializers.Field): shared_users = getattr(request, '_shared_users', None) if shared_users is None: try: - shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id] + shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [ + self.context['request'].user.id] except AttributeError: # Anonymous users (using share links) don't have shared users shared_users = [] return obj.onhand_users.filter(id__in=shared_users).exists() @@ -170,7 +172,8 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer): class UserPreferenceSerializer(WritableNestedModelSerializer): - food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True) + food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, + required=False, read_only=True) plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True) shopping_share = UserNameSerializer(many=True, allow_null=True, required=False) food_children_exist = serializers.SerializerMethodField('get_food_children_exist') @@ -189,9 +192,12 @@ class UserPreferenceSerializer(WritableNestedModelSerializer): class Meta: model = UserPreference fields = ( - 'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style', 'show_recent', 'plan_share', - 'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay', - 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', + 'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style', + 'show_recent', 'plan_share', + 'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', + 'food_inherit_default', 'default_delay', + 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', + 'csv_delim', 'csv_prefix', 'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'food_children_exist' ) @@ -393,7 +399,6 @@ class FoodSimpleSerializer(serializers.ModelSerializer): class Meta: model = Food fields = ('id', 'name') - read_only_fields = ['id', 'name'] class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin): @@ -416,7 +421,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR shared_users = getattr(request, '_shared_users', None) if shared_users is None: try: - shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id] + shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [ + self.context['request'].user.id] except AttributeError: shared_users = [] filter = Q(id__in=obj.substitute.all()) @@ -487,8 +493,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') -class IngredientSerializer(WritableNestedModelSerializer): - food = FoodSerializer(allow_null=True) +class IngredientSimpleSerializer(WritableNestedModelSerializer): + food = FoodSimpleSerializer(allow_null=True) unit = UnitSerializer(allow_null=True) amount = CustomDecimalField() @@ -508,6 +514,10 @@ class IngredientSerializer(WritableNestedModelSerializer): ) +class IngredientSerializer(IngredientSimpleSerializer): + food = FoodSerializer(allow_null=True) + + class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin): ingredients = IngredientSerializer(many=True) ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown') @@ -699,7 +709,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer): def create(self, validated_data): book = validated_data['book'] recipe = validated_data['recipe'] - if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared(): + if not book.get_owner() == self.context['request'].user and not self.context[ + 'request'].user in book.get_shared(): raise NotFound(detail=None, code=None) obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe) return obj @@ -752,13 +763,14 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): 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 + 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})' + 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): # TODO remove once old shopping list @@ -829,7 +841,8 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): class Meta: model = ShoppingListEntry fields = ( - 'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan', + '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',) @@ -927,7 +940,10 @@ class ExportLogSerializer(serializers.ModelSerializer): class Meta: model = ExportLog - fields = ('id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration', 'possibly_not_expired', 'created_by', 'created_at') + fields = ( + 'id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration', + 'possibly_not_expired', + 'created_by', 'created_at') read_only_fields = ('created_by',) @@ -1039,10 +1055,12 @@ class RecipeExportSerializer(WritableNestedModelSerializer): class RecipeShoppingUpdateSerializer(serializers.ModelSerializer): - list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Existing shopping list to update")) + 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.")) + 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 @@ -1050,9 +1068,12 @@ class RecipeShoppingUpdateSerializer(serializers.ModelSerializer): 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.")) + 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 diff --git a/cookbook/templates/ingredient_editor.html b/cookbook/templates/ingredient_editor.html new file mode 100644 index 00000000..732173a7 --- /dev/null +++ b/cookbook/templates/ingredient_editor.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% load i18n %} +{% load l10n %} + +{% block title %}{% trans 'Ingredient Editor' %}{% endblock %} + +{% block content %} + +
+ +
+ + +{% endblock %} + + +{% block script %} + {% if debug %} + + {% else %} + + {% endif %} + + + + {% render_bundle 'ingredient_editor_view' %} +{% endblock %} \ No newline at end of file diff --git a/cookbook/urls.py b/cookbook/urls.py index 7e47ee5e..90c81d1e 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -47,7 +47,6 @@ 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'), path('setup/', views.setup, name='view_setup'), @@ -72,6 +71,7 @@ urlpatterns = [ path('settings/', views.user_settings, name='view_settings'), path('history/', views.history, name='view_history'), path('supermarket/', views.supermarket, name='view_supermarket'), + path('ingredient-editor/', views.ingredient_editor, name='view_ingredient_editor'), path('abuse/', views.report_share_abuse, name='view_report_share_abuse'), path('import/', import_export.import_recipe, name='view_import'), @@ -116,7 +116,8 @@ urlpatterns = [ path('api/share-link/', api.share_link, name='api_share_link'), path('api/get_facets/', api.get_facets, name='api_get_facets'), - path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'), # TODO is this deprecated? not yet, some old forms remain, could likely be changed to generic API endpoints + path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'), + # TODO is this deprecated? not yet, some old forms remain, could likely be changed to generic API endpoints path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'), # TODO is this deprecated? path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'), # TODO is this deprecated? diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 87248978..d1e88568 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -68,7 +68,7 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializ SupermarketCategorySerializer, SupermarketSerializer, SyncLogSerializer, SyncSerializer, UnitSerializer, UserFileSerializer, UserNameSerializer, UserPreferenceSerializer, - ViewLogSerializer) + ViewLogSerializer, IngredientSimpleSerializer) from recipes import settings @@ -119,14 +119,17 @@ class ExtendedRecipeMixin(): # add a recipe count annotation to the query # explanation on construction https://stackoverflow.com/a/43771738/15762829 - recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk')).values('count') + recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values( + recipe_filter).annotate(count=Count('pk')).values('count') queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0)) # add a recipe image annotation to the query - image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1] + image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude( + image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1] if tree: - image_children_subquery = Recipe.objects.filter(**{f"{recipe_filter}__path__startswith": OuterRef('path')}, - space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1] + image_children_subquery = Recipe.objects.filter( + **{f"{recipe_filter}__path__startswith": OuterRef('path')}, + space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1] else: image_children_subquery = None if images: @@ -142,11 +145,14 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin): def get_queryset(self): self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc()) query = self.request.query_params.get('query', None) - fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.trigram.values_list('field', flat=True)]) + fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in + self.request.user.searchpreference.trigram.values_list( + 'field', flat=True)]) if query is not None and query not in ["''", '']: if fuzzy: - if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): + if any([self.model.__name__.lower() in x for x in + self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query)) else: self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query)) @@ -154,7 +160,8 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin): else: # TODO have this check unaccent search settings or other search preferences? filter = Q(name__icontains=query) - if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): + if any([self.model.__name__.lower() in x for x in + self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): filter |= Q(name__unaccent__icontains=query) self.queryset = ( @@ -275,10 +282,12 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin): except self.model.DoesNotExist: self.queryset = self.model.objects.none() else: - return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True) + return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, + serializer=self.serializer_class, tree=True) self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc()) - return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True) + return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, + tree=True) @decorators.action(detail=True, url_path='move/(?P[^/.]+)', methods=['PUT'], ) @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) @@ -454,12 +463,16 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): pagination_class = DefaultPagination def get_queryset(self): - self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [self.request.user.id] + self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [ + self.request.user.id] self.queryset = super().get_queryset() - shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'), checked=False).values('id') + shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'), + checked=False).values('id') # onhand_status = self.queryset.annotate(onhand_status=Exists(onhand_users_set__in=[shared_users])) - return self.queryset.annotate(shopping_status=Exists(shopping_status)).prefetch_related('onhand_users', 'inherit_fields').select_related('recipe', 'supermarket_category') + return self.queryset.annotate(shopping_status=Exists(shopping_status)).prefetch_related('onhand_users', + 'inherit_fields').select_related( + 'recipe', 'supermarket_category') @decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer, ) # TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably @@ -470,7 +483,8 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): 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() + 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) @@ -478,7 +492,8 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): 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) + 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): @@ -577,8 +592,22 @@ class IngredientViewSet(viewsets.ModelViewSet): serializer_class = IngredientSerializer permission_classes = [CustomIsUser] + def get_serializer_class(self): + if self.request and self.request.query_params.get('simple', False): + return IngredientSimpleSerializer + return IngredientSerializer + def get_queryset(self): - return self.queryset.filter(step__recipe__space=self.request.space) + queryset = self.queryset.filter(step__recipe__space=self.request.space) + food = self.request.query_params.get('food', None) + if food and re.match(r'^([1-9])+$', food): + queryset = queryset.filter(food_id=food) + + unit = self.request.query_params.get('unit', None) + if unit and re.match(r'^([1-9])+$', unit): + queryset = queryset.filter(unit_id=unit) + + return queryset class StepViewSet(viewsets.ModelViewSet): @@ -587,7 +616,8 @@ class StepViewSet(viewsets.ModelViewSet): permission_classes = [CustomIsUser] pagination_class = DefaultPagination query_params = [ - QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'), + QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), + qtype='int'), QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'), ] schema = QueryParamAutoSchema() @@ -631,33 +661,63 @@ class RecipeViewSet(viewsets.ModelViewSet): pagination_class = RecipePagination 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. Equivalent to keywords_or'), qtype='int'), - QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'), - QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='int'), - QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='int'), - QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='int'), - QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'), - QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'), - QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'), - QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'), - QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), 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. Equivalent to keywords_or'), + qtype='int'), + QueryParam(name='keywords_or', + description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), + qtype='int'), + QueryParam(name='keywords_and', + description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), + qtype='int'), + QueryParam(name='keywords_or_not', + description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), + qtype='int'), + QueryParam(name='keywords_and_not', + description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), + qtype='int'), + QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), + qtype='int'), + QueryParam(name='foods_or', + description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'), + QueryParam(name='foods_and', + description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'), + QueryParam(name='foods_or_not', + description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'), + QueryParam(name='foods_and_not', + description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'), QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'), - QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'), + QueryParam(name='rating', description=_( + 'Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'), QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')), - QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'), - QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'), - QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'), - QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'), - 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='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'), - QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), - QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), - QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), - QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), - QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''false'']')), + QueryParam(name='books_or', + description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'), + QueryParam(name='books_and', + description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'), + QueryParam(name='books_or_not', + description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'), + QueryParam(name='books_and_not', + description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'), + 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='timescooked', description=_( + 'Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'), + QueryParam(name='cookedon', description=_( + 'Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), + QueryParam(name='createdon', description=_( + 'Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), + QueryParam(name='updatedon', description=_( + 'Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), + QueryParam(name='viewedon', description=_( + 'Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), + QueryParam(name='makenow', + description=_('Filter recipes that can be made with OnHand food. [''true''/''false'']')), ] schema = QueryParamAutoSchema() @@ -672,7 +732,8 @@ class RecipeViewSet(viewsets.ModelViewSet): if not (share and self.detail): self.queryset = self.queryset.filter(space=self.request.space) - params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)} + params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x + in list(self.request.GET)} search = RecipeSearch(self.request, **params) self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set') return self.queryset @@ -770,7 +831,8 @@ class RecipeViewSet(viewsets.ModelViewSet): levels = int(request.query_params.get('levels', 1)) except (ValueError, TypeError): levels = 1 - qs = obj.get_related_recipes(levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend? + qs = obj.get_related_recipes( + levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend? return Response(self.serializer_class(qs, many=True).data) @@ -780,7 +842,8 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet): permission_classes = [CustomIsOwner | CustomIsShared] def get_queryset(self): - self.queryset = self.queryset.filter(Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space)) + 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) @@ -794,12 +857,17 @@ 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() @@ -926,6 +994,7 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin): space=self.request.space).distinct() return super().get_queryset() + # -------------- non django rest api views -------------------- diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 26d80f6b..e5bca25f 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -61,7 +61,8 @@ def search(request): if request.user.userpreference.search_style == UserPreference.NEW: return search_v2(request) f = RecipeFilter(request.GET, - queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by(Lower('name').asc()), + queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by( + Lower('name').asc()), space=request.space) if request.user.userpreference.search_style == UserPreference.LARGE: table = RecipeTable(f.qs) @@ -225,6 +226,11 @@ def supermarket(request): return render(request, 'supermarket.html', {}) +@group_required('user') +def ingredient_editor(request): + return render(request, 'ingredient_editor.html', {}) + + @group_required('user') def meal_plan_entry(request, pk): plan = MealPlan.objects.filter(space=request.space).get(pk=pk) @@ -327,10 +333,10 @@ def user_settings(request): if not sp: sp = SearchPreferenceForm(user=request.user) fields_searched = ( - len(search_form.cleaned_data['icontains']) - + len(search_form.cleaned_data['istartswith']) - + len(search_form.cleaned_data['trigram']) - + len(search_form.cleaned_data['fulltext']) + len(search_form.cleaned_data['icontains']) + + len(search_form.cleaned_data['istartswith']) + + len(search_form.cleaned_data['trigram']) + + len(search_form.cleaned_data['fulltext']) ) if fields_searched == 0: search_form.add_error(None, _('You must select at least one field to search!')) diff --git a/vue/src/apps/IngredientEditorView/IngredientEditorView.vue b/vue/src/apps/IngredientEditorView/IngredientEditorView.vue new file mode 100644 index 00000000..3a3ab2ba --- /dev/null +++ b/vue/src/apps/IngredientEditorView/IngredientEditorView.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/vue/src/apps/IngredientEditorView/main.js b/vue/src/apps/IngredientEditorView/main.js new file mode 100644 index 00000000..c92257cd --- /dev/null +++ b/vue/src/apps/IngredientEditorView/main.js @@ -0,0 +1,18 @@ +import Vue from 'vue' +import App from './IngredientEditorView.vue' +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/components/GenericMultiselect.vue b/vue/src/components/GenericMultiselect.vue index 8698ee91..977a4c01 100644 --- a/vue/src/components/GenericMultiselect.vue +++ b/vue/src/components/GenericMultiselect.vue @@ -26,11 +26,11 @@