From 56f3fe2d123d92121937e29e85ddd91869f1067f Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 4 Apr 2022 19:27:14 +0200 Subject: [PATCH 01/45] fixed deprecated model attribute on exporters --- cookbook/integration/recipesage.py | 13 ++++++------- cookbook/integration/saffron.py | 7 +++---- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/cookbook/integration/recipesage.py b/cookbook/integration/recipesage.py index d5292456f..29bf0eb4e 100644 --- a/cookbook/integration/recipesage.py +++ b/cookbook/integration/recipesage.py @@ -77,14 +77,13 @@ class RecipeSage(Integration): } for s in recipe.steps.all(): - if s.type != Step.TIME: - data['recipeInstructions'].append({ - '@type': 'HowToStep', - 'text': s.instruction - }) + data['recipeInstructions'].append({ + '@type': 'HowToStep', + 'text': s.instruction + }) - for i in s.ingredients.all(): - data['recipeIngredient'].append(f'{float(i.amount)} {i.unit} {i.food}') + for i in s.ingredients.all(): + data['recipeIngredient'].append(f'{float(i.amount)} {i.unit} {i.food}') return data diff --git a/cookbook/integration/saffron.py b/cookbook/integration/saffron.py index 0bfc4fbb3..a7ec34f10 100644 --- a/cookbook/integration/saffron.py +++ b/cookbook/integration/saffron.py @@ -71,11 +71,10 @@ class Saffron(Integration): recipeInstructions = [] recipeIngredient = [] for s in recipe.steps.all(): - if s.type != Step.TIME: - recipeInstructions.append(s.instruction) + recipeInstructions.append(s.instruction) - for i in s.ingredients.all(): - recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}') + for i in s.ingredients.all(): + recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}') data += "Ingredients: \n" for ingredient in recipeIngredient: From 3cedab45eeb2d5bb20960ad0fb1f292052b944f6 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 4 Apr 2022 19:45:37 +0200 Subject: [PATCH 02/45] fixed copy recipe nutrition link --- vue/src/components/RecipeContextMenu.vue | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/vue/src/components/RecipeContextMenu.vue b/vue/src/components/RecipeContextMenu.vue index 32812eea8..7fbdbcb69 100644 --- a/vue/src/components/RecipeContextMenu.vue +++ b/vue/src/components/RecipeContextMenu.vue @@ -206,11 +206,11 @@ export default { this.$bvModal.show(`shopping_${this.modal_id}`) }, copyToNew: function () { - let recipename = window.prompt(this.$t("copy_to_new"), this.$t("recipe_name")) + let recipe_name = window.prompt(this.$t("copy_to_new"), this.$t("recipe_name")) let apiClient = new ApiApiFactory() apiClient.retrieveRecipe(this.recipe.id).then((results) => { - let recipe = { ...results.data, ...{ id: undefined, name: recipename } } + let recipe = { ...results.data, ...{ id: undefined, name: recipe_name } } recipe.steps = recipe.steps.map((step) => { return { ...step, @@ -222,12 +222,14 @@ export default { }, } }) - console.log(recipe) + if (recipe.nutrition !== null){ + delete recipe.nutrition.id + } apiClient .createRecipe(recipe) - .then((newrecipe) => { + .then((new_recipe) => { StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) - window.open(this.resolveDjangoUrl("view_recipe", newrecipe.data.id)) + window.open(this.resolveDjangoUrl("view_recipe", new_recipe.data.id)) }) .catch((error) => { StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE) From 5bd9a15e4b9f74922dfe3e5ed697779ebc299bb8 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 4 Apr 2022 20:11:22 +0200 Subject: [PATCH 03/45] small fixes --- cookbook/templates/setup.html | 4 + .../apps/RecipeEditView/RecipeEditView.vue | 273 ++++++--- .../RecipeSearchView/RecipeSearchView.vue | 547 ++++++++++++------ 3 files changed, 559 insertions(+), 265 deletions(-) diff --git a/cookbook/templates/setup.html b/cookbook/templates/setup.html index f638028ab..85e422387 100644 --- a/cookbook/templates/setup.html +++ b/cookbook/templates/setup.html @@ -20,4 +20,8 @@ + + {% endblock %} \ No newline at end of file diff --git a/vue/src/apps/RecipeEditView/RecipeEditView.vue b/vue/src/apps/RecipeEditView/RecipeEditView.vue index 4aab4ef63..17a18dbae 100644 --- a/vue/src/apps/RecipeEditView/RecipeEditView.vue +++ b/vue/src/apps/RecipeEditView/RecipeEditView.vue @@ -11,7 +11,7 @@
- +
@@ -19,14 +19,16 @@ - +
- +
- + - +
- +
- -
+ +
- -
+ +
- -
+ +
- -
+ +
There is currently only very basic support for tracking nutritional information. A - big update is planned to improve on this in many different areas. + big update is planned to improve on this in many + different areas. - + - + - + - +
- {{ $t("additional_options") }} + {{ $t("additional_options") }} + @@ -150,8 +162,12 @@ - {{ $t("Name") }} - + {{ + $t("Name") + }} + + {{ $t("create_food_desc") }} @@ -162,7 +178,8 @@ - +
@@ -176,26 +193,33 @@
- @@ -206,30 +230,36 @@
- +
- + {{ $t("Time") }} - + {{ $t("Ingredients") }} - + {{ $t("Instructions") }} - + {{ $t("Recipe") }} - + {{ $t("File") }}
- +
@@ -333,16 +364,23 @@
- -
-
+ +
+
- +
-
-
+
+
-
+
- +
-
+
- +
-
+
-
-
- +
+
@@ -515,24 +575,39 @@ {{ $t("Add_Step") }} - +
-
-
-
-
-
-
+ +
+
+ + + +
+
+ + +
+
+
+
+
+
-
+
-
-
@@ -553,9 +631,11 @@ - +
  • - +
  • @@ -570,25 +650,34 @@ @cancel="paste_ingredients = paste_step = undefined" @close="paste_ingredients = paste_step = undefined" > - +
    - +
    + {% 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 7e47ee5ee..90c81d1e9 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 872489787..d1e88568c 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 26d80f6be..e5bca25fb 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 000000000..3a3ab2ba0 --- /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 000000000..c92257cd2 --- /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 8698ee912..977a4c01d 100644 --- a/vue/src/components/GenericMultiselect.vue +++ b/vue/src/components/GenericMultiselect.vue @@ -26,11 +26,11 @@ diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index afb90226f..12919e80f 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -440,7 +440,6 @@ :initial_selection="settings.shopping_share" label="username" :multiple="true" - :allow_create="false" style="flex-grow: 1; flex-shrink: 1; flex-basis: 0" :placeholder="$t('User')" /> diff --git a/vue/src/components/GenericMultiselect.vue b/vue/src/components/GenericMultiselect.vue index 977a4c01d..a5c3a0e7d 100644 --- a/vue/src/components/GenericMultiselect.vue +++ b/vue/src/components/GenericMultiselect.vue @@ -19,6 +19,7 @@ @search-change="search" @input="selectionChanged" @tag="addNew" + @open="selectOpened()" > @@ -26,7 +27,7 @@ diff --git a/cookbook/views/views.py b/cookbook/views/views.py index e5bca25fb..da1293451 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -228,7 +228,15 @@ def supermarket(request): @group_required('user') def ingredient_editor(request): - return render(request, 'ingredient_editor.html', {}) + template_vars = {'food_id': -1, 'unit_id': -1} + food_id = request.GET.get('food_id', None) + if food_id and re.match(r'^(\d)+$', food_id): + template_vars['food_id'] = food_id + + unit_id = request.GET.get('unit_id', None) + if unit_id and re.match(r'^(\d)+$', unit_id): + template_vars['unit_id'] = unit_id + return render(request, 'ingredient_editor.html', template_vars) @group_required('user') diff --git a/vue/src/apps/IngredientEditorView/IngredientEditorView.vue b/vue/src/apps/IngredientEditorView/IngredientEditorView.vue index e8958128d..c85d08690 100644 --- a/vue/src/apps/IngredientEditorView/IngredientEditorView.vue +++ b/vue/src/apps/IngredientEditorView/IngredientEditorView.vue @@ -88,7 +88,9 @@ - + @@ -137,6 +139,20 @@ export default { computed: {}, mounted() { this.$i18n.locale = window.CUSTOM_LOCALE + if (window.DEFAULT_FOOD !== -1) { + this.food = {id: window.DEFAULT_FOOD} + let apiClient = new ApiApiFactory() + apiClient.retrieveFood(this.food.id).then(r => { + this.food = r.data + }) + } + if (window.DEFAULT_UNIT !== -1) { + this.unit = {id: window.DEFAULT_UNIT} + let apiClient = new ApiApiFactory() + apiClient.retrieveUnit(this.unit.id).then(r => { + this.unit = r.data + }) + } this.refreshList() }, methods: { diff --git a/vue/src/apps/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index 8a2b49f81..09d523443 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -80,7 +80,7 @@ import {BootstrapVue} from "bootstrap-vue" import "bootstrap-vue/dist/bootstrap-vue.css" -import {CardMixin, ApiMixin, getConfig} from "@/utils/utils" +import {CardMixin, ApiMixin, getConfig, resolveDjangoUrl} from "@/utils/utils" import {StandardToasts, ToastMixin} from "@/utils/utils" import GenericInfiniteCards from "@/components/GenericInfiniteCards" @@ -158,7 +158,6 @@ export default { let target = e?.target ?? undefined this.this_item = source this.this_target = target - switch (e.action) { case "delete": this.this_action = this.Actions.DELETE @@ -173,6 +172,16 @@ export default { this.this_action = this.Actions.UPDATE this.show_modal = true break + case "ingredient-editor": { + let url = resolveDjangoUrl("view_ingredient_editor") + if (this.this_model === this.Models.FOOD) { + window.location.href = url + '?food_id=' + e.source.id + } + if (this.this_model === this.Models.UNIT) { + window.location.href = url + '?unit_id=' + e.source.id + } + break + } case "move": if (target == null) { this.this_item = e.source diff --git a/vue/src/components/ContextMenu/GenericContextMenu.vue b/vue/src/components/ContextMenu/GenericContextMenu.vue index b513bc645..c900f0fd1 100644 --- a/vue/src/components/ContextMenu/GenericContextMenu.vue +++ b/vue/src/components/ContextMenu/GenericContextMenu.vue @@ -6,7 +6,10 @@ {{ $t("Edit") }} + {{ $t("Delete") }} + {{ $t("Ingredient Editor") }} + {{ $t("Add_to_Shopping") }} @@ -24,8 +27,11 @@ diff --git a/vue/src/components/GenericHorizontalCard.vue b/vue/src/components/GenericHorizontalCard.vue index b12cf6a08..028f9af10 100644 --- a/vue/src/components/GenericHorizontalCard.vue +++ b/vue/src/components/GenericHorizontalCard.vue @@ -61,6 +61,7 @@ :show_move="useMove" :show_shopping="useShopping" :show_onhand="useOnhand" + :show_ingredient_editor="useIngredientEditor" @item-action="$emit('item-action', { action: $event, source: item })" > @@ -132,11 +133,12 @@ import GenericOrderedPill from "@/components/GenericOrderedPill" import RecipeCard from "@/components/RecipeCard" import { mixin as clickaway } from "vue-clickaway" import { createPopper } from "@popperjs/core" +import {ApiMixin} from "@/utils/utils"; export default { name: "GenericHorizontalCard", components: { GenericContextMenu, RecipeCard, Badges, GenericPill, GenericOrderedPill }, - mixins: [clickaway], + mixins: [clickaway, ApiMixin], props: { item: { type: Object }, model: { type: Object }, @@ -186,6 +188,9 @@ export default { useDrag: function () { return this.useMove || this.useMerge }, + useIngredientEditor: function (){ + return (this.model === this.Models.FOOD || this.model === this.Models.UNIT) + }, itemTags: function () { return this.model?.tags ?? [] }, diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index 284f4316f..3f89c6a22 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -64,6 +64,7 @@ "Make_Ingredient": "Make Ingredient", "Enable_Amount": "Enable Amount", "Disable_Amount": "Disable Amount", + "Ingredient Editor": "Ingredient Editor", "Add_Step": "Add Step", "Keywords": "Keywords", "Books": "Books", From d9dd0a594e813b8378e01ae88879731147dd9903 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Sun, 17 Apr 2022 23:25:22 +0200 Subject: [PATCH 40/45] show recipe and allow delete in ingredient editor --- cookbook/serializer.py | 6 +++- .../IngredientEditorView.vue | 32 +++++++++++++++++-- vue/src/utils/openapi/api.ts | 12 +++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 792b65e6c..e70a7e50a 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -496,8 +496,12 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR class IngredientSimpleSerializer(WritableNestedModelSerializer): food = FoodSimpleSerializer(allow_null=True) unit = UnitSerializer(allow_null=True) + used_in_recipes = serializers.SerializerMethodField('get_used_in_recipes') amount = CustomDecimalField() + def get_used_in_recipes(self, obj): + return list(Recipe.objects.filter(steps__ingredients=obj.id).values('id', 'name')) + def create(self, validated_data): validated_data['space'] = self.context['request'].space return super().create(validated_data) @@ -510,7 +514,7 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer): model = Ingredient fields = ( 'id', 'food', 'unit', 'amount', 'note', 'order', - 'is_header', 'no_amount', 'original_text' + 'is_header', 'no_amount', 'original_text', 'used_in_recipes', ) diff --git a/vue/src/apps/IngredientEditorView/IngredientEditorView.vue b/vue/src/apps/IngredientEditorView/IngredientEditorView.vue index c85d08690..b546e8be2 100644 --- a/vue/src/apps/IngredientEditorView/IngredientEditorView.vue +++ b/vue/src/apps/IngredientEditorView/IngredientEditorView.vue @@ -60,7 +60,14 @@ @@ -111,7 +125,7 @@ import Vue from "vue" import {BootstrapVue} from "bootstrap-vue" import "bootstrap-vue/dist/bootstrap-vue.css" -import {ApiMixin, StandardToasts} from "@/utils/utils" +import {ApiMixin, ResolveUrlMixin, StandardToasts} from "@/utils/utils" import {ApiApiFactory} from "@/utils/openapi/api"; import GenericMultiselect from "@/components/GenericMultiselect"; import GenericModalForm from "@/components/Modals/GenericModalForm"; @@ -122,7 +136,7 @@ Vue.use(BootstrapVue) export default { name: "IngredientEditorView", - mixins: [ApiMixin], + mixins: [ApiMixin, ResolveUrlMixin], components: {BetaWarning, LoadingSpinner, GenericMultiselect, GenericModalForm}, data() { return { @@ -199,6 +213,18 @@ export default { }) }) }, + deleteIngredient: function (i){ + if (confirm(this.$t('delete_confirmation', this.$t('Ingredient')))){ + let apiClient = new ApiApiFactory() + apiClient.destroyIngredient(i.id).then(r => { + StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE) + this.ingredients = this.ingredients.filter(li => li.id !== i.id) + }).catch(e => { + StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE) + }) + } + } + }, } diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts index be3171f29..b9c9b2553 100644 --- a/vue/src/utils/openapi/api.ts +++ b/vue/src/utils/openapi/api.ts @@ -752,6 +752,12 @@ export interface Ingredient { * @memberof Ingredient */ original_text?: string | null; + /** + * + * @type {string} + * @memberof Ingredient + */ + used_in_recipes?: string; } /** * @@ -1917,6 +1923,12 @@ export interface RecipeIngredients { * @memberof RecipeIngredients */ original_text?: string | null; + /** + * + * @type {string} + * @memberof RecipeIngredients + */ + used_in_recipes?: string; } /** * From a84ab0c049f4059790bb2972092eaa68b94f59e4 Mon Sep 17 00:00:00 2001 From: Roland Park Date: Sun, 17 Apr 2022 19:56:36 -0400 Subject: [PATCH 41/45] Add clickable keywords to recipe card --- vue/src/components/RecipeCard.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vue/src/components/RecipeCard.vue b/vue/src/components/RecipeCard.vue index 5d18aeeb8..51bb4409e 100755 --- a/vue/src/components/RecipeCard.vue +++ b/vue/src/components/RecipeCard.vue @@ -34,7 +34,7 @@

    - +

    From 1100826ed86be579e97578d41ba899571bb98cca Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 18 Apr 2022 12:52:20 +0200 Subject: [PATCH 42/45] fixed NC importer importing empty keywords --- cookbook/integration/nextcloud_cookbook.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cookbook/integration/nextcloud_cookbook.py b/cookbook/integration/nextcloud_cookbook.py index c61a6d236..d4720222a 100644 --- a/cookbook/integration/nextcloud_cookbook.py +++ b/cookbook/integration/nextcloud_cookbook.py @@ -40,7 +40,8 @@ class NextcloudCookbook(Integration): if 'keywords' in recipe_json: try: for x in recipe_json['keywords'].split(','): - recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0]) + if x.strip() != '': + recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0]) except Exception: pass From 45a86a22e3b26e897907371230894146bdc371e2 Mon Sep 17 00:00:00 2001 From: Kaibu Date: Mon, 18 Apr 2022 14:13:23 +0200 Subject: [PATCH 43/45] recipe switcher and navbar fixes also added left_hand to global preferences --- cookbook/forms.py | 6 +- cookbook/static/css/app.min.css | 8 ++ cookbook/templates/base.html | 97 ++++++++++++------- cookbook/views/views.py | 1 + vue/src/components/Buttons/RecipeSwitcher.vue | 69 +++++++------ 5 files changed, 115 insertions(+), 66 deletions(-) diff --git a/cookbook/forms.py b/cookbook/forms.py index caa129b51..94f287eeb 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -49,7 +49,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', 'comments', + 'plan_share', 'ingredient_decimals', 'comments', 'left_handed', ) labels = { @@ -65,7 +65,8 @@ class UserPreferenceForm(forms.ModelForm): 'plan_share': _('Plan sharing'), 'ingredient_decimals': _('Ingredient decimal places'), 'shopping_auto_sync': _('Shopping list auto sync period'), - 'comments': _('Comments') + 'comments': _('Comments'), + 'left_handed': _('Left-handed mode') } help_texts = { @@ -89,6 +90,7 @@ class UserPreferenceForm(forms.ModelForm): '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.'), + 'left_handed': _('Will optimize the UI for use with your left hand.') } widgets = { diff --git a/cookbook/static/css/app.min.css b/cookbook/static/css/app.min.css index 84a9f4ef4..2167eb22d 100644 --- a/cookbook/static/css/app.min.css +++ b/cookbook/static/css/app.min.css @@ -2,6 +2,14 @@ height: 40px; } +@media (max-width: 991.98px) { + .menu-dropdown-text { + font-size: 14px; + font-weight: 200; + } +} + + .spinner-tandoor { animation: rotation 3s infinite linear; content: url("../assets/spinner.svg"); diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html index 2b538e254..625a96fec 100644 --- a/cookbook/templates/base.html +++ b/cookbook/templates/base.html @@ -56,23 +56,46 @@ {% block extra_head %} {% endblock %} + + -