diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b996ffb08..a844103f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,14 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.9] + python-version: ['3.10'] steps: - uses: actions/checkout@v1 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v1 with: - python-version: 3.9 + python-version: '3.10' # Build Vue frontend - uses: actions/setup-node@v2 with: diff --git a/.github/workflows/docker-publish-release.yml b/.github/workflows/docker-publish-release.yml index 8333d22a2..9cf625fa2 100644 --- a/.github/workflows/docker-publish-release.yml +++ b/.github/workflows/docker-publish-release.yml @@ -49,4 +49,4 @@ jobs: DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} uses: Ilshidur/action-discord@0.3.2 with: - args: '🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of tandoor has been released 🥳 \nCheck it out https://github.com/vabene1111/recipes/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}' + args: '🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}' diff --git a/cookbook/apps.py b/cookbook/apps.py index 2e31731df..3297d6928 100644 --- a/cookbook/apps.py +++ b/cookbook/apps.py @@ -17,23 +17,24 @@ class CookbookConfig(AppConfig): 'django.db.backends.postgresql']: import cookbook.signals # noqa - # when starting up run fix_tree to: - # a) make sure that nodes are sorted when switching between sort modes - # b) fix problems, if any, with tree consistency - with scopes_disabled(): - try: - from cookbook.models import Keyword, Food - Keyword.fix_tree(fix_paths=True) - Food.fix_tree(fix_paths=True) - except OperationalError: - if DEBUG: - traceback.print_exc() - pass # if model does not exist there is no need to fix it - except ProgrammingError: - if DEBUG: - traceback.print_exc() - pass # if migration has not been run database cannot be fixed yet - except Exception: - if DEBUG: - traceback.print_exc() - pass # dont break startup just because fix could not run, need to investigate cases when this happens + if not settings.DISABLE_TREE_FIX_STARTUP: + # when starting up run fix_tree to: + # a) make sure that nodes are sorted when switching between sort modes + # b) fix problems, if any, with tree consistency + with scopes_disabled(): + try: + from cookbook.models import Keyword, Food + Keyword.fix_tree(fix_paths=True) + Food.fix_tree(fix_paths=True) + except OperationalError: + if DEBUG: + traceback.print_exc() + pass # if model does not exist there is no need to fix it + except ProgrammingError: + if DEBUG: + traceback.print_exc() + pass # if migration has not been run database cannot be fixed yet + except Exception: + if DEBUG: + traceback.print_exc() + pass # dont break startup just because fix could not run, need to investigate cases when this happens diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 195cafefe..850eb87db 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -1,17 +1,17 @@ from collections import Counter from datetime import timedelta -from recipes import settings -from django.contrib.postgres.search import ( - SearchQuery, SearchRank, TrigramSimilarity -) +from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity from django.core.cache import caches from django.db.models import Avg, Case, Count, Func, Max, Q, Subquery, Value, When from django.db.models.functions import Coalesce from django.utils import timezone, translation +from cookbook.filters import RecipeFilter +from cookbook.helper.permission_helper import has_group_permission from cookbook.managers import DICTIONARY -from cookbook.models import Food, Keyword, ViewLog, SearchPreference +from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog +from recipes import settings class Round(Func): @@ -62,7 +62,7 @@ def search_recipes(request, queryset, params): # return queryset.annotate(last_view=Max('viewlog__pk')).annotate(new=Case(When(pk__in=last_viewed_recipes, then=('last_view')), default=Value(0))).filter(new__gt=0).order_by('-new') # queryset that only annotates most recent view (higher pk = lastest view) - queryset = queryset.annotate(recent=Coalesce(Max('viewlog__pk'), Value(0))) + queryset = queryset.annotate(recent=Coalesce(Max(Case(When(viewlog__created_by=request.user, then='viewlog__pk'))), Value(0))) orderby += ['-recent'] # TODO create setting for default ordering - most cooked, rating, @@ -143,9 +143,9 @@ def search_recipes(request, queryset, params): # TODO add order by user settings - only do search rank and annotation if rank order is configured search_rank = ( - SearchRank('name_search_vector', search_query, cover_density=True) - + SearchRank('desc_search_vector', search_query, cover_density=True) - + SearchRank('steps__search_vector', search_query, cover_density=True) + SearchRank('name_search_vector', search_query, cover_density=True) + + SearchRank('desc_search_vector', search_query, cover_density=True) + + SearchRank('steps__search_vector', search_query, cover_density=True) ) queryset = queryset.filter(query_filter).annotate(rank=search_rank) orderby += ['-rank'] @@ -400,3 +400,13 @@ def annotated_qs(qs, root=False, fill=False): if start_depth and start_depth > 0: info['close'] = list(range(0, prev_depth - start_depth + 1)) return result + + +def old_search(request): + if has_group_permission(request.user, ('guest',)): + params = dict(request.GET) + params['internal'] = None + f = RecipeFilter(params, + queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by('name'), + space=request.space) + return f.qs diff --git a/cookbook/locale/de/LC_MESSAGES/django.mo b/cookbook/locale/de/LC_MESSAGES/django.mo index 69626d19d..3a35611ea 100644 Binary files a/cookbook/locale/de/LC_MESSAGES/django.mo and b/cookbook/locale/de/LC_MESSAGES/django.mo differ diff --git a/cookbook/locale/it/LC_MESSAGES/django.mo b/cookbook/locale/it/LC_MESSAGES/django.mo index c51de42b4..eabef4b84 100644 Binary files a/cookbook/locale/it/LC_MESSAGES/django.mo and b/cookbook/locale/it/LC_MESSAGES/django.mo differ diff --git a/cookbook/locale/nl/LC_MESSAGES/django.mo b/cookbook/locale/nl/LC_MESSAGES/django.mo index 32f5899be..2c852340a 100644 Binary files a/cookbook/locale/nl/LC_MESSAGES/django.mo and b/cookbook/locale/nl/LC_MESSAGES/django.mo differ diff --git a/cookbook/locale/pt/LC_MESSAGES/django.mo b/cookbook/locale/pt/LC_MESSAGES/django.mo index 53a3db114..5a8c6d573 100644 Binary files a/cookbook/locale/pt/LC_MESSAGES/django.mo and b/cookbook/locale/pt/LC_MESSAGES/django.mo differ diff --git a/cookbook/locale/ro/LC_MESSAGES/django.mo b/cookbook/locale/ro/LC_MESSAGES/django.mo new file mode 100644 index 000000000..6b1797ed9 Binary files /dev/null and b/cookbook/locale/ro/LC_MESSAGES/django.mo differ diff --git a/cookbook/locale/sl/LC_MESSAGES/django.mo b/cookbook/locale/sl/LC_MESSAGES/django.mo new file mode 100644 index 000000000..b88198305 Binary files /dev/null and b/cookbook/locale/sl/LC_MESSAGES/django.mo differ diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 115b5e96c..c7405faed 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -137,6 +137,7 @@ class UserNameSerializer(WritableNestedModelSerializer): class UserPreferenceSerializer(serializers.ModelSerializer): + plan_share = UserNameSerializer(many=True) def create(self, validated_data): if validated_data['user'] != self.context['request'].user: @@ -620,6 +621,7 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed note_markdown = serializers.SerializerMethodField('get_note_markdown') servings = CustomDecimalField() + shared = UserNameSerializer(many=True) def get_note_markdown(self, obj): return markdown(obj.note) diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html index cbd77aa7b..9448ac408 100644 --- a/cookbook/templates/base.html +++ b/cookbook/templates/base.html @@ -349,6 +349,7 @@ localStorage.setItem('SCRIPT_NAME', "{% base_path request 'script' %}") localStorage.setItem('BASE_PATH', "{% base_path request 'base' %}") localStorage.setItem('STATIC_URL', "{% base_path request 'static_base' %}") + localStorage.setItem('DEBUG', "{% is_debug %}") window.addEventListener("load", () => { if ("serviceWorker" in navigator) { navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) { diff --git a/cookbook/templatetags/custom_tags.py b/cookbook/templatetags/custom_tags.py index a21b9a2e8..d90e95b6f 100644 --- a/cookbook/templatetags/custom_tags.py +++ b/cookbook/templatetags/custom_tags.py @@ -94,10 +94,10 @@ def recipe_last(recipe, user): @register.simple_tag def page_help(page_name): help_pages = { - 'edit_storage': 'https://vabene1111.github.io/recipes/features/external_recipes/', - 'view_shopping': 'https://vabene1111.github.io/recipes/features/shopping/', - 'view_import': 'https://vabene1111.github.io/recipes/features/import_export/', - 'view_export': 'https://vabene1111.github.io/recipes/features/import_export/', + 'edit_storage': 'https://docs.tandoor.dev/features/external_recipes/', + 'view_shopping': 'https://docs.tandoor.dev/features/shopping/', + 'view_import': 'https://docs.tandoor.dev/features/import_export/', + 'view_export': 'https://docs.tandoor.dev/features/import_export/', } link = help_pages.get(page_name, '') diff --git a/cookbook/tests/api/test_api_meal_plan.py b/cookbook/tests/api/test_api_meal_plan.py index cf5841622..37b52e88e 100644 --- a/cookbook/tests/api/test_api_meal_plan.py +++ b/cookbook/tests/api/test_api_meal_plan.py @@ -106,7 +106,7 @@ def test_add(arg, request, u1_s2, recipe_1_s1, meal_type): r = c.post( reverse(LIST_URL), {'recipe': {'id': recipe_1_s1.id, 'name': recipe_1_s1.name, 'keywords': []}, 'meal_type': {'id': meal_type.id, 'name': meal_type.name}, - 'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test'}, + 'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test','shared':[]}, content_type='application/json' ) response = json.loads(r.content) diff --git a/cookbook/tests/api/test_api_step.py b/cookbook/tests/api/test_api_step.py index f2a809632..209a8091d 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 json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2 - assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 0 + 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 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 json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 0 - assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2 - + 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 + @pytest.mark.parametrize("arg", [ ['a_u', 403], diff --git a/cookbook/tests/api/test_api_userpreference.py b/cookbook/tests/api/test_api_userpreference.py index 31bf7faf9..4750fbaa4 100644 --- a/cookbook/tests/api/test_api_userpreference.py +++ b/cookbook/tests/api/test_api_userpreference.py @@ -18,10 +18,10 @@ def test_add(u1_s1, u2_s1): with scopes_disabled(): UserPreference.objects.filter(user=auth.get_user(u1_s1)).delete() - r = u2_s1.post(reverse(LIST_URL), {'user': auth.get_user(u1_s1).id}, content_type='application/json') + r = u2_s1.post(reverse(LIST_URL), {'user': auth.get_user(u1_s1).id, 'plan_share': []}, content_type='application/json') assert r.status_code == 404 - r = u1_s1.post(reverse(LIST_URL), {'user': auth.get_user(u1_s1).id}, content_type='application/json') + r = u1_s1.post(reverse(LIST_URL), {'user': auth.get_user(u1_s1).id, 'plan_share': []}, content_type='application/json') assert r.status_code == 200 diff --git a/cookbook/urls.py b/cookbook/urls.py index 9be128ac9..690eae013 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -2,17 +2,17 @@ from pydoc import locate from django.urls import include, path from django.views.generic import TemplateView -from recipes.version import VERSION_NUMBER -from rest_framework import routers, permissions +from rest_framework import permissions, routers from rest_framework.schemas import get_schema_view from cookbook.helper import dal +from recipes.settings import DEBUG +from recipes.version import VERSION_NUMBER -from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe, - RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, - Storage, Supermarket, SupermarketCategory, Sync, SyncLog, Unit, get_model_name, Automation, - UserFile, Step) -from .views import api, data, delete, edit, import_export, lists, new, views, telegram +from .models import (Automation, Comment, Food, InviteLink, Keyword, MealPlan, Recipe, RecipeBook, + RecipeBookEntry, RecipeImport, ShoppingList, Step, Storage, Supermarket, + SupermarketCategory, Sync, SyncLog, Unit, UserFile, get_model_name) +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') @@ -68,8 +68,6 @@ urlpatterns = [ path('history/', views.history, name='view_history'), path('supermarket/', views.supermarket, name='view_supermarket'), path('abuse/', views.report_share_abuse, name='view_report_share_abuse'), - path('test/', views.test, name='view_test'), - path('test2/', views.test2, name='view_test2'), path('import/', import_export.import_recipe, name='view_import'), path('import-response//', import_export.import_response, name='view_import_response'), @@ -189,3 +187,7 @@ for m in vue_models: f'list/{url_name}/', c, name=f'list_{py_name}' ) ) + +if DEBUG: + urlpatterns.append(path('test/', views.test, name='view_test')) + urlpatterns.append(path('test2/', views.test2, name='view_test2')) diff --git a/cookbook/views/api.py b/cookbook/views/api.py index bb0143cbd..bb404ac45 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -46,7 +46,9 @@ from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, Impor 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.schemas import FilterSchema, QueryOnlySchema, RecipeSchema, TreeSchema,QueryParamAutoSchema + from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer, CookLogSerializer, FoodSerializer, ImportLogSerializer, IngredientSerializer, KeywordSerializer, MealPlanSerializer, @@ -408,7 +410,7 @@ class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin): permission_classes = [CustomIsOwner] def get_queryset(self): - self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(space=self.request.space) + self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(space=self.request.space).distinct() return super().get_queryset() @@ -564,7 +566,16 @@ class RecipeViewSet(viewsets.ModelViewSet): return super().get_queryset() + def list(self, request, *args, **kwargs): + if self.request.GET.get('debug', False): + return JsonResponse({ + 'new': str(self.get_queryset().query), + 'old': str(old_search(request).query) + }) + return super().list(request, *args, **kwargs) + # TODO write extensive tests for permissions + def get_serializer_class(self): if self.action == 'list': return RecipeOverviewSerializer diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 23fafdd88..5b37cdc53 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -13,7 +13,7 @@ from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError from django.db.models import Avg, Q, Sum from django.http import HttpResponseRedirect, JsonResponse -from django.shortcuts import get_object_or_404, render, redirect +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.translation import gettext as _ @@ -22,16 +22,15 @@ from django_tables2 import RequestConfig from rest_framework.authtoken.models import Token from cookbook.filters import RecipeFilter -from cookbook.forms import (CommentForm, Recipe, User, - UserCreateForm, UserNameForm, UserPreference, - UserPreferenceForm, SpaceJoinForm, SpaceCreateForm, - SearchPreferenceForm) -from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission -from cookbook.models import (Comment, CookLog, InviteLink, MealPlan, - ViewLog, ShoppingList, Space, Keyword, RecipeImport, Unit, - Food, UserFile, ShareLink, SearchPreference, SearchFields) -from cookbook.tables import (CookLogTable, RecipeTable, RecipeTableSmall, - ViewLogTable, InviteLinkTable) +from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, + SpaceJoinForm, 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.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall, + ViewLogTable) from cookbook.views.data import Object from recipes.version import BUILD_REF, VERSION_NUMBER @@ -331,10 +330,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!')) @@ -382,7 +381,7 @@ def user_settings(request): if up: preference_form = UserPreferenceForm(instance=up, space=request.space) else: - preference_form = UserPreferenceForm( space=request.space) + preference_form = UserPreferenceForm(space=request.space) fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len( sp.fulltext.all()) diff --git a/docs/features/import_export.md b/docs/features/import_export.md index 0c5e4fe0b..d53c30c04 100644 --- a/docs/features/import_export.md +++ b/docs/features/import_export.md @@ -2,13 +2,13 @@ This application features a very versatile import and export feature in order to offer the best experience possible and allow you to freely choose where your data goes. !!! warning "WIP" - The Module is relatively new. There is a know issue with [Timeouts](https://github.com/vabene1111/recipes/issues/417) on large exports. + The Module is relatively new. There is a known issue with [Timeouts](https://github.com/vabene1111/recipes/issues/417) on large exports. A fix is being developed and will likely be released with the next version. -The Module is build with maximum flexibility and expandability in mind and allows to easily add new +The Module is built with maximum flexibility and expandability in mind and allows to easily add new integrations to allow you to both import and export your recipes into whatever format you desire. -Feel like there is an important integration missing ? Just take a look at the [integration issues](https://github.com/vabene1111/recipes/issues?q=is%3Aissue+is%3Aopen+label%3Aintegration) or open a new one +Feel like there is an important integration missing? Just take a look at the [integration issues](https://github.com/vabene1111/recipes/issues?q=is%3Aissue+is%3Aopen+label%3Aintegration) or open a new one if your favorite one is missing. !!! info "Export" @@ -41,7 +41,7 @@ Overview of the capabilities of the different integrations. ✔ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented ## Default -The default integration is the build in (and preferred) way to import and export recipes. +The default integration is the built in (and preferred) way to import and export recipes. It is maintained with new fields added and contains all data to transfer your recipes from one installation to another. It is also one of the few recipe formats that is actually structured in a way that allows for @@ -90,7 +90,7 @@ Mealie provides structured data similar to nextcloud. To migrate your recipes -1. Go to you Mealie settings and create a new Backup +1. Go to your Mealie settings and create a new Backup 2. Download the backup by clicking on it and pressing download (this wasn't working for me, so I had to manually pull it from the server) 3. Upload the entire `.zip` file to the importer page and import everything @@ -118,7 +118,7 @@ Recipes.zip/ ``` ## Safron -Go to you safron settings page and export your recipes. +Go to your safron settings page and export your recipes. Then simply upload the entire `.zip` file to the importer. !!! warning "Images" @@ -131,8 +131,8 @@ The `.paprikarecipes` file is basically just a zip with gzipped contents. Simply all your recipes. ## Pepperplate -Pepperplate provides a `.zip` files contain all your recipes as `.txt` files. These files are well-structured and allow -the import of all data without loosing anything. +Pepperplate provides a `.zip` file containing all of your recipes as `.txt` files. These files are well-structured and allow +the import of all data without losing anything. Simply export the recipes from Pepperplate and upload the zip to Tandoor. Images are not included in the export and thus cannot be imported. @@ -145,7 +145,7 @@ This format is basically completely unstructured and every export looks differen and leads to suboptimal results. Images are also not supported as they are not included in the export (at least the tests I had). -Usually the import should recognize all ingredients and put everything else into the instructions. If you import fails +Usually the import should recognize all ingredients and put everything else into the instructions. If your import fails or is worse than this feel free to provide me with more example data and I can try to improve the importer. As ChefTap cannot import these files anyway there won't be an exporter implemented in Tandoor. @@ -154,7 +154,7 @@ As ChefTap cannot import these files anyway there won't be an exporter implement Meal master can be imported by uploading one or more meal master files. The files should either be `.txt`, `.MMF` or `.MM` files. -The MealMaster spec allow for many variations. Currently, only the on column format for ingredients is supported. +The MealMaster spec allow for many variations. Currently, only the one column format for ingredients is supported. Second line notes to ingredients are currently also not imported as a note but simply put into the instructions. If you have MealMaster recipes that cannot be imported feel free to raise an issue. @@ -166,7 +166,7 @@ The generated file can simply be imported into Tandoor. As I only had limited sample data feel free to open an issue if your RezKonv export cannot be imported. ## Recipekeeper -Recipe keeper allows to export a zip file containing recipes and images using its apps. +Recipe keeper allows you to export a zip file containing recipes and images using its apps. This zip file can simply be imported into Tandoor. ## OpenEats @@ -213,8 +213,8 @@ Store the outputted json string in a `.json` file and simply import it using the ## Plantoeat -Plan to eat allow to export a text file containing all your recipes. Simply upload that text file to Tandoor to import all recipes +Plan to eat allows you to export a text file containing all your recipes. Simply upload that text file to Tandoor to import all recipes ## CookBookApp -CookBookApp can export .zip files containing .html files. Upload the entire ZIP to Tandoor to import all included recipes. \ No newline at end of file +CookBookApp can export .zip files containing .html files. Upload the entire ZIP to Tandoor to import all included recipes. diff --git a/docs/install/k8s/10-configmap.yaml b/docs/install/k8s/10-configmap.yaml index 8a939d081..181f80661 100644 --- a/docs/install/k8s/10-configmap.yaml +++ b/docs/install/k8s/10-configmap.yaml @@ -4,12 +4,14 @@ metadata: labels: app: recipes name: recipes-nginx-config + namespace: default data: nginx-config: |- events { worker_connections 1024; } http { + include mime.types; server { listen 80; server_name _; @@ -24,10 +26,5 @@ data: location /media/ { alias /media/; } - # pass requests for dynamic content to gunicorn - location / { - proxy_set_header Host $host; - proxy_pass http://localhost:8080; - } } } diff --git a/docs/install/k8s/15-secrets.yaml b/docs/install/k8s/15-secrets.yaml new file mode 100644 index 000000000..1a549b423 --- /dev/null +++ b/docs/install/k8s/15-secrets.yaml @@ -0,0 +1,13 @@ +kind: Secret +apiVersion: v1 +metadata: + name: recipes + namespace: default +type: Opaque +data: + # echo -n 'db-password' | base64 + postgresql-password: ZGItcGFzc3dvcmQ= + # echo -n 'postgres-user-password' | base64 + postgresql-postgres-password: cG9zdGdyZXMtdXNlci1wYXNzd29yZA== + # echo -n 'secret-key' | sha256sum | awk '{ printf $1 }' | base64 + secret-key: ODVkYmUxNWQ3NWVmOTMwOGM3YWUwZjMzYzdhMzI0Y2M2ZjRiZjUxOWEyZWQyZjMwMjdiZDMzYzE0MGE0ZjlhYQ== diff --git a/docs/install/k8s/20-service-account.yml b/docs/install/k8s/20-service-account.yml new file mode 100644 index 000000000..c4db988d8 --- /dev/null +++ b/docs/install/k8s/20-service-account.yml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: recipes + namespace: default diff --git a/docs/install/k8s/30-pv.yaml b/docs/install/k8s/30-pv.yaml deleted file mode 100644 index 810a5f34e..000000000 --- a/docs/install/k8s/30-pv.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: v1 -kind: PersistentVolume -metadata: - name: recipes-db - labels: - app: recipes - type: local - tier: db -spec: - storageClassName: manual - capacity: - storage: 1Gi - accessModes: - - ReadWriteMany - hostPath: - path: "/data/recipes/db" ---- -apiVersion: v1 -kind: PersistentVolume -metadata: - name: recipes-media - labels: - app: recipes - type: local - tier: media -spec: - storageClassName: manual - capacity: - storage: 1Gi - accessModes: - - ReadWriteMany - hostPath: - path: "/data/recipes/media" ---- -apiVersion: v1 -kind: PersistentVolume -metadata: - name: recipes-static - labels: - app: recipes - type: local - tier: static -spec: - storageClassName: manual - capacity: - storage: 1Gi - accessModes: - - ReadWriteMany - hostPath: - path: "/data/recipes/static" diff --git a/docs/install/k8s/30-pvc.yaml b/docs/install/k8s/30-pvc.yaml index 16b8b48af..c28d19f1c 100644 --- a/docs/install/k8s/30-pvc.yaml +++ b/docs/install/k8s/30-pvc.yaml @@ -1,34 +1,13 @@ apiVersion: v1 kind: PersistentVolumeClaim -metadata: - name: recipes-db - labels: - app: recipes -spec: - selector: - matchLabels: - tier: db - storageClassName: manual - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi ---- -apiVersion: v1 -kind: PersistentVolumeClaim metadata: name: recipes-media + namespace: default labels: app: recipes spec: - selector: - matchLabels: - tier: media - app: recipes - storageClassName: manual accessModes: - - ReadWriteMany + - ReadWriteOnce resources: requests: storage: 1Gi @@ -37,16 +16,12 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: name: recipes-static + namespace: default labels: app: recipes spec: - selector: - matchLabels: - tier: static - app: recipes - storageClassName: manual accessModes: - - ReadWriteMany + - ReadWriteOnce resources: requests: storage: 1Gi diff --git a/docs/install/k8s/40-sts-postgresql.yaml b/docs/install/k8s/40-sts-postgresql.yaml new file mode 100644 index 000000000..5c769dd10 --- /dev/null +++ b/docs/install/k8s/40-sts-postgresql.yaml @@ -0,0 +1,142 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + labels: + app: recipes + tier: database + name: recipes-postgresql + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: recipes + serviceName: recipes-postgresql + updateStrategy: + type: RollingUpdate + template: + metadata: + annotations: + backup.velero.io/backup-volumes: data + labels: + app: recipes + tier: database + name: recipes-postgresql + namespace: default + spec: + restartPolicy: Always + securityContext: + fsGroup: 999 + serviceAccount: recipes + serviceAccountName: recipes + terminationGracePeriodSeconds: 30 + containers: + - name: recipes-db + env: + - name: BITNAMI_DEBUG + value: "false" + - name: POSTGRESQL_PORT_NUMBER + value: "5432" + - name: POSTGRESQL_VOLUME_DIR + value: /bitnami/postgresql + - name: PGDATA + value: /bitnami/postgresql/data + - name: POSTGRES_USER + value: recipes + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: recipes + key: postgresql-password + - name: POSTGRESQL_POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: recipes + key: postgresql-postgres-password + - name: POSTGRES_DB + value: recipes + image: docker.io/bitnami/postgresql:11.5.0-debian-9-r60 + imagePullPolicy: IfNotPresent + livenessProbe: + exec: + command: + - sh + - -c + - exec pg_isready -U "postgres" -d "wiki" -h 127.0.0.1 -p 5432 + failureThreshold: 6 + initialDelaySeconds: 30 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 5 + ports: + - containerPort: 5432 + name: postgresql + protocol: TCP + readinessProbe: + exec: + command: + - sh + - -c + - -e + - | + pg_isready -U "postgres" -d "wiki" -h 127.0.0.1 -p 5432 + [ -f /opt/bitnami/postgresql/tmp/.initialized ] + failureThreshold: 6 + initialDelaySeconds: 5 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 5 + resources: + requests: + cpu: 250m + memory: 256Mi + securityContext: + runAsUser: 1001 + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + volumeMounts: + - mountPath: /bitnami/postgresql + name: data + dnsPolicy: ClusterFirst + initContainers: + - command: + - sh + - -c + - | + mkdir -p /bitnami/postgresql/data + chmod 700 /bitnami/postgresql/data + find /bitnami/postgresql -mindepth 0 -maxdepth 1 -not -name ".snapshot" -not -name "lost+found" | \ + xargs chown -R 1001:1001 + image: docker.io/bitnami/minideb:stretch + imagePullPolicy: Always + name: init-chmod-data + resources: + requests: + cpu: 250m + memory: 256Mi + securityContext: + runAsUser: 0 + volumeMounts: + - mountPath: /bitnami/postgresql + name: data + restartPolicy: Always + securityContext: + fsGroup: 1001 + serviceAccount: recipes + serviceAccountName: recipes + terminationGracePeriodSeconds: 30 + updateStrategy: + type: RollingUpdate + volumeClaimTemplates: + - apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: data + namespace: default + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + volumeMode: Filesystem diff --git a/docs/install/k8s/45-service-db.yaml b/docs/install/k8s/45-service-db.yaml new file mode 100644 index 000000000..5741d2bf5 --- /dev/null +++ b/docs/install/k8s/45-service-db.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: recipes + tier: database + name: recipes-postgresql + namespace: default +spec: + ports: + - name: postgresql + port: 5432 + protocol: TCP + targetPort: postgresql + selector: + app: recipes + tier: database + sessionAffinity: None + type: ClusterIP diff --git a/docs/install/k8s/50-deployment.yaml b/docs/install/k8s/50-deployment.yaml index a64e5e445..7bb6232ca 100644 --- a/docs/install/k8s/50-deployment.yaml +++ b/docs/install/k8s/50-deployment.yaml @@ -2,6 +2,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: recipes + namespace: default labels: app: recipes environment: production @@ -9,17 +10,78 @@ metadata: spec: replicas: 1 strategy: - type: RollingUpdate + type: Recreate selector: matchLabels: app: recipes environment: production template: metadata: + annotations: + backup.velero.io/backup-volumes: media,static labels: app: recipes + tier: frontend environment: production spec: + restartPolicy: Always + serviceAccount: recipes + serviceAccountName: recipes + initContainers: + - name: init-chmod-data + env: + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: recipes + key: secret-key + - name: DB_ENGINE + value: django.db.backends.postgresql_psycopg2 + - name: POSTGRES_HOST + value: recipes-postgresql + - name: POSTGRES_PORT + value: "5432" + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: recipes + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: recipes + key: postgresql-postgres-password + image: vabene1111/recipes:1.0.1 + imagePullPolicy: Always + resources: + requests: + cpu: 250m + memory: 64Mi + command: + - sh + - -c + - | + set -e + source venv/bin/activate + echo "Updating database" + python manage.py migrate + python manage.py collectstatic_js_reverse + python manage.py collectstatic --noinput + echo "Setting media file attributes" + chown -R 65534:65534 /opt/recipes/mediafiles + find /opt/recipes/mediafiles -type d | xargs -r chmod 755 + find /opt/recipes/mediafiles -type f | xargs -r chmod 644 + echo "Done" + securityContext: + runAsUser: 0 + volumeMounts: + - mountPath: /opt/recipes/mediafiles + name: media + # mount as subPath due to lost+found on ext4 pvc + subPath: files + - mountPath: /opt/recipes/staticfiles + name: static + # mount as subPath due to lost+found on ext4 pvc + subPath: files containers: - name: recipes-nginx image: nginx:latest @@ -28,69 +90,94 @@ spec: - containerPort: 80 protocol: TCP name: http + - containerPort: 8080 + protocol: TCP + name: gunicorn + resources: + requests: + cpu: 250m + memory: 64Mi volumeMounts: - - mountPath: '/media' + - mountPath: /media name: media - - mountPath: '/static' + # mount as subPath due to lost+found on ext4 pvc + subPath: files + - mountPath: /static name: static + # mount as subPath due to lost+found on ext4 pvc + subPath: files - name: nginx-config mountPath: /etc/nginx/nginx.conf subPath: nginx-config readOnly: true - name: recipes - image: 'vabene1111/recipes:latest' + image: vabene1111/recipes:1.0.1 imagePullPolicy: IfNotPresent + command: + - /opt/recipes/venv/bin/gunicorn + - -b + - :8080 + - --access-logfile + - "-" + - --error-logfile + - "-" + - --log-level + - INFO + - recipes.wsgi livenessProbe: + failureThreshold: 3 httpGet: path: / port: 8080 + scheme: HTTP + periodSeconds: 30 readinessProbe: httpGet: path: / port: 8080 + scheme: HTTP + periodSeconds: 30 + resources: + requests: + cpu: 250m + memory: 64Mi volumeMounts: - - mountPath: '/opt/recipes/mediafiles' + - mountPath: /opt/recipes/mediafiles name: media - - mountPath: '/opt/recipes/staticfiles' + # mount as subPath due to lost+found on ext4 pvc + subPath: files + - mountPath: /opt/recipes/staticfiles name: static + # mount as subPath due to lost+found on ext4 pvc + subPath: files env: - name: DEBUG value: "0" - name: ALLOWED_HOSTS value: '*' - name: SECRET_KEY - value: # CHANGEME + valueFrom: + secretKeyRef: + name: recipes + key: secret-key - name: DB_ENGINE value: django.db.backends.postgresql_psycopg2 - name: POSTGRES_HOST - value: localhost + value: recipes-postgresql - name: POSTGRES_PORT value: "5432" - name: POSTGRES_USER - value: recipes + value: postgres - name: POSTGRES_DB value: recipes - name: POSTGRES_PASSWORD - value: # CHANGEME - - name: recipes-db - image: 'postgres:latest' - imagePullPolicy: IfNotPresent - ports: - - containerPort: 5432 - volumeMounts: - - mountPath: '/var/lib/postgresql/data' - name: database - env: - - name: POSTGRES_USER - value: recipes - - name: POSTGRES_DB - value: recipes - - name: POSTGRES_PASSWORD - value: # CHANGEME + valueFrom: + secretKeyRef: + name: recipes + key: postgresql-postgres-password + securityContext: + runAsUser: 65534 volumes: - - name: database - persistentVolumeClaim: - claimName: recipes-db - name: media persistentVolumeClaim: claimName: recipes-media diff --git a/docs/install/k8s/60-service.yaml b/docs/install/k8s/60-service.yaml index 0becd59f3..5a8bd61a9 100644 --- a/docs/install/k8s/60-service.yaml +++ b/docs/install/k8s/60-service.yaml @@ -2,14 +2,21 @@ apiVersion: v1 kind: Service metadata: name: recipes + namespace: default labels: app: recipes + tier: frontend spec: selector: app: recipes + tier: frontend environment: production ports: - port: 80 targetPort: http name: http protocol: TCP + - port: 8080 + targetPort: gunicorn + name: gunicorn + protocol: TCP diff --git a/docs/install/k8s/70-ingress.yaml b/docs/install/k8s/70-ingress.yaml new file mode 100644 index 000000000..01bdeebd0 --- /dev/null +++ b/docs/install/k8s/70-ingress.yaml @@ -0,0 +1,38 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + #cert-manager.io/cluster-issuer: letsencrypt-prod + #kubernetes.io/ingress.class: nginx + name: recipes + namespace: default +spec: + rules: + - host: recipes.local + http: + paths: + - backend: + service: + name: recipes + port: + number: 8080 + path: / + pathType: Prefix + - backend: + service: + name: recipes + port: + number: 80 + path: /media + pathType: Prefix + - backend: + service: + name: recipes + port: + number: 80 + path: /static + pathType: Prefix + #tls: + #- hosts: + # - recipes.local + # secretName: recipes-local-tls diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 4f29f97f7..992be89c0 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -1,31 +1,98 @@ -!!! info "Community Contributed" - This guide was contributed by the community and is neither officially supported, nor updated or tested. +**!!! info "Community Contributed" This guide was contributed by the community and is neither officially supported, nor updated or tested.** -This is a basic kubernetes setup. -Please note that this does not necessarily follow Kubernetes best practices and should only used as a -basis to build your own setup from! +# K8s Setup -All files con be found here in the Github Repo: -[docs/install/k8s](https://github.com/vabene1111/recipes/tree/develop/docs/install/k8s) +This is a setup which should be sufficent for production use. Be sure to replace the default secrets! -## Important notes +# Files -State (database, static files and media files) is handled via `PersistentVolumes`. +## 10-configmap.yaml -Note that you will most likely have to change the `PersistentVolumes` in `30-pv.yaml`. The current setup is only usable for a single-node cluster because it uses local storage on the kubernetes worker nodes under `/data/recipes/`. It should just serve as an example. +The nginx config map. This is loaded as nginx.conf in the nginx sidecar to configure nginx to deliver static content. -Currently, the deployment in `50-deployment.yaml` just pulls the `latest` tag of all containers. In a production setup, you should set this to a fixed version! +## 15-secrets.yaml -See env variables tagged with `CHANGEME` in `50-deployment.yaml` and make sure to change those! A better setup would use kubernetes secrets but this is not implemented yet. +The secrets **replace them!!** This file is only here for a quick start. Be aware that changing secrets after installation will be messy and is not documented here. **You should set new secrets before the installation.** As you are reading this document **before** the installation ;-) -## Updates +Create your own postgresql passwords and the secret key for the django app -These manifests are not tested against new versions. +see also [Managing Secrets using kubectl](https://kubernetes.io/docs/tasks/configmap-secret/managing-secret-using-kubectl/) -## Apply the manifets +**Replace** `db-password`, `postgres-user-password` and `secret-key` **with something - well - secret :-)** -To apply the manifest with `kubectl`, use the following command: +~~~ +echo -n 'db-password' > ./db-password.txt +echo -n 'postgres-user-password' > ./postgres-password.txt +echo -n 'secret-key' | sha256sum | awk '{ printf $1 }' > ./secret-key.txt +~~~ -``` +Delete the default secrets file `15-secrets.yaml` and generate the K8s secret from your files. + +~~~ +kubectl create secret generic recipes \ + --from-file=postgresql-password=./db-password.txt \ + --from-file=postgresql-postgres-password=./postgres-password.txt \ + --from-file=secret-key=./secret-key.txt +~~~ + +## 20-service-account.yml + +Creating service account `recipes` for deployment and stateful set. + +## 30-pvc.yaml + +The creation of the persistent volume claims for media and static content. May you want to increase the size. This expects to have a storage class installed. + +## 40-sts-postgresql.yaml + +The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itsef runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipies app is doing some db migrations on startup, which needs super user privileges. + +## 45-service-db.yaml + +Creating the database service. + +## 50-deployment.yaml + +The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init conainer runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image. + +The deployment then runs two containers, the recipes-nginx and the recipes container which runs the gunicorn app. The nginx container gets it's nginx.conf via config map to deliver static content `/static` and `/media`. The guincorn container gets it's secret key and the database password from the secret `recipes`. `gunicorn` runs as user `nobody`. + +## 60-service.yaml + +Creating the app service. + +## 70-ingress.yaml + +Setting up the ingress for the recipes service. Requests for static content `/static` and `/media` are send to the nginx container, everything else to gunicorn. TLS setup via cert-manager is prepared. You have to **change the host** from `recipes.local` to your specific domain. + +# Conclusion + +All in all: + +- The database is set up as a stateful set. +- The database container runs as a low privileged user. +- Database and application use secrets. +- The application also runs as a low privileged user. +- nginx runs as root but forks children with a low privileged user. +- There's an ingress rule to access the application from outside. + +I tried the setup with [kind](https://kind.sigs.k8s.io/) and it runs well on my local cluster. + +There is a warning, when you check your system as super user: + +**Media Serving Warning** +Serving media files directly using gunicorn/python is not recommend! Please follow the steps described here to update your installation. + +I don't know how this check works, but this warning is simply wrong! ;-) Media and static files are routed by ingress to the nginx container - I promise :-) + +# Updates + +These manifests are tested against Release 1.0.1. Newer versions may not work without changes. + +# Apply the manifets + +To apply the manifest with kubectl, use the following command: + +~~~ kubectl apply -f ./docs/k8s/ -``` +~~~ diff --git a/recipes/locale/es/LC_MESSAGES/django.mo b/recipes/locale/es/LC_MESSAGES/django.mo index be2daf828..71cbdf3e9 100644 Binary files a/recipes/locale/es/LC_MESSAGES/django.mo and b/recipes/locale/es/LC_MESSAGES/django.mo differ diff --git a/recipes/locale/fr/LC_MESSAGES/django.mo b/recipes/locale/fr/LC_MESSAGES/django.mo index 41a1fcf92..2c90dd0c8 100644 Binary files a/recipes/locale/fr/LC_MESSAGES/django.mo and b/recipes/locale/fr/LC_MESSAGES/django.mo differ diff --git a/recipes/settings.py b/recipes/settings.py index 73dff9cd7..6edb406b7 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -151,6 +151,7 @@ MIDDLEWARE = [ ] SORT_TREE_BY_NAME = bool(int(os.getenv('SORT_TREE_BY_NAME', False))) +DISABLE_TREE_FIX_STARTUP = bool(int(os.getenv('DISABLE_TREE_FIX_STARTUP', False))) if bool(int(os.getenv('SQL_DEBUG', False))): MIDDLEWARE += ('recipes.middleware.SqlPrintingMiddleware',) diff --git a/requirements.txt b/requirements.txt index 1252243fa..66328887a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ icalendar==4.0.9 pyyaml==6.0 uritemplate==4.1.1 beautifulsoup4==4.10.0 -microdata==0.7.1 +microdata==0.7.2 Jinja2==3.0.2 django-webpack-loader==1.4.1 django-js-reverse==0.9.1 @@ -40,5 +40,5 @@ django-storages==1.12.3 boto3==1.19.7 django-prometheus==2.1.0 django-hCaptcha==0.1.0 -python-ldap==3.3.1 +python-ldap==3.4.0 django-auth-ldap==3.0.0 \ No newline at end of file diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index ea564c0fc..7d451edf4 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -136,22 +136,22 @@ @@ -513,11 +513,17 @@ export default { return entry.id === id })[0] }, - moveEntry(null_object, target_date) { + moveEntry(null_object, target_date, drag_event) { this.plan_entries.forEach((entry) => { if (entry.id === this.dragged_item.id) { - entry.date = target_date - this.saveEntry(entry) + if (drag_event.ctrlKey) { + let new_entry = Object.assign({}, entry) + new_entry.date = target_date + this.createEntry(new_entry) + } else { + entry.date = target_date + this.saveEntry(entry) + } } }) }, diff --git a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue index 9521fd6f4..e753d3a55 100644 --- a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue +++ b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue @@ -1,554 +1,563 @@ - + diff --git a/vue/src/components/CookbookToc.vue b/vue/src/components/CookbookToc.vue index 50a4a0411..81fd0b464 100644 --- a/vue/src/components/CookbookToc.vue +++ b/vue/src/components/CookbookToc.vue @@ -6,7 +6,7 @@
  1. - {{ recipe.recipe_content.name }} + {{ recipe.recipe_content.name }}
diff --git a/vue/src/components/GenericMultiselect.vue b/vue/src/components/GenericMultiselect.vue index 6f0db11f8..c1d3ece77 100644 --- a/vue/src/components/GenericMultiselect.vue +++ b/vue/src/components/GenericMultiselect.vue @@ -116,6 +116,8 @@ export default { } + + diff --git a/vue/src/components/MealPlanEditModal.vue b/vue/src/components/MealPlanEditModal.vue index 08f94f9e0..ee74b80c7 100644 --- a/vue/src/components/MealPlanEditModal.vue +++ b/vue/src/components/MealPlanEditModal.vue @@ -1,5 +1,5 @@