Merge branch 'develop' into generic_modal_v2
This commit is contained in:
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@ -9,14 +9,14 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
max-parallel: 4
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.9]
|
python-version: ['3.10']
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
- name: Set up Python 3.9
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: '3.10'
|
||||||
# Build Vue frontend
|
# Build Vue frontend
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
|
2
.github/workflows/docker-publish-release.yml
vendored
2
.github/workflows/docker-publish-release.yml
vendored
@ -49,4 +49,4 @@ jobs:
|
|||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@0.3.2
|
uses: Ilshidur/action-discord@0.3.2
|
||||||
with:
|
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 }}'
|
||||||
|
@ -17,6 +17,7 @@ class CookbookConfig(AppConfig):
|
|||||||
'django.db.backends.postgresql']:
|
'django.db.backends.postgresql']:
|
||||||
import cookbook.signals # noqa
|
import cookbook.signals # noqa
|
||||||
|
|
||||||
|
if not settings.DISABLE_TREE_FIX_STARTUP:
|
||||||
# when starting up run fix_tree to:
|
# when starting up run fix_tree to:
|
||||||
# a) make sure that nodes are sorted when switching between sort modes
|
# a) make sure that nodes are sorted when switching between sort modes
|
||||||
# b) fix problems, if any, with tree consistency
|
# b) fix problems, if any, with tree consistency
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import timedelta
|
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.core.cache import caches
|
||||||
from django.db.models import Avg, Case, Count, Func, Max, Q, Subquery, Value, When
|
from django.db.models import Avg, Case, Count, Func, Max, Q, Subquery, Value, When
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils import timezone, translation
|
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.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):
|
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')
|
# 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 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']
|
orderby += ['-recent']
|
||||||
|
|
||||||
# TODO create setting for default ordering - most cooked, rating,
|
# TODO create setting for default ordering - most cooked, rating,
|
||||||
@ -400,3 +400,13 @@ def annotated_qs(qs, root=False, fill=False):
|
|||||||
if start_depth and start_depth > 0:
|
if start_depth and start_depth > 0:
|
||||||
info['close'] = list(range(0, prev_depth - start_depth + 1))
|
info['close'] = list(range(0, prev_depth - start_depth + 1))
|
||||||
return result
|
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
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
cookbook/locale/ro/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/ro/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
cookbook/locale/sl/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/sl/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
@ -137,6 +137,7 @@ class UserNameSerializer(WritableNestedModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||||
|
plan_share = UserNameSerializer(many=True)
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if validated_data['user'] != self.context['request'].user:
|
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
|
meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed
|
||||||
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
||||||
servings = CustomDecimalField()
|
servings = CustomDecimalField()
|
||||||
|
shared = UserNameSerializer(many=True)
|
||||||
|
|
||||||
def get_note_markdown(self, obj):
|
def get_note_markdown(self, obj):
|
||||||
return markdown(obj.note)
|
return markdown(obj.note)
|
||||||
|
@ -349,6 +349,7 @@
|
|||||||
localStorage.setItem('SCRIPT_NAME', "{% base_path request 'script' %}")
|
localStorage.setItem('SCRIPT_NAME', "{% base_path request 'script' %}")
|
||||||
localStorage.setItem('BASE_PATH', "{% base_path request 'base' %}")
|
localStorage.setItem('BASE_PATH', "{% base_path request 'base' %}")
|
||||||
localStorage.setItem('STATIC_URL', "{% base_path request 'static_base' %}")
|
localStorage.setItem('STATIC_URL', "{% base_path request 'static_base' %}")
|
||||||
|
localStorage.setItem('DEBUG', "{% is_debug %}")
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {
|
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {
|
||||||
|
@ -94,10 +94,10 @@ def recipe_last(recipe, user):
|
|||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def page_help(page_name):
|
def page_help(page_name):
|
||||||
help_pages = {
|
help_pages = {
|
||||||
'edit_storage': 'https://vabene1111.github.io/recipes/features/external_recipes/',
|
'edit_storage': 'https://docs.tandoor.dev/features/external_recipes/',
|
||||||
'view_shopping': 'https://vabene1111.github.io/recipes/features/shopping/',
|
'view_shopping': 'https://docs.tandoor.dev/features/shopping/',
|
||||||
'view_import': 'https://vabene1111.github.io/recipes/features/import_export/',
|
'view_import': 'https://docs.tandoor.dev/features/import_export/',
|
||||||
'view_export': 'https://vabene1111.github.io/recipes/features/import_export/',
|
'view_export': 'https://docs.tandoor.dev/features/import_export/',
|
||||||
}
|
}
|
||||||
|
|
||||||
link = help_pages.get(page_name, '')
|
link = help_pages.get(page_name, '')
|
||||||
|
@ -106,7 +106,7 @@ def test_add(arg, request, u1_s2, recipe_1_s1, meal_type):
|
|||||||
r = c.post(
|
r = c.post(
|
||||||
reverse(LIST_URL),
|
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},
|
{'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'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
response = json.loads(r.content)
|
response = json.loads(r.content)
|
||||||
|
@ -23,8 +23,8 @@ def test_list_permission(arg, request):
|
|||||||
|
|
||||||
|
|
||||||
def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
|
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 len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 2
|
||||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 0
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0
|
||||||
|
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
recipe_1_s1.space = space_2
|
recipe_1_s1.space = space_2
|
||||||
@ -32,8 +32,8 @@ 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]))
|
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]))
|
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 len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0
|
||||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("arg", [
|
@pytest.mark.parametrize("arg", [
|
||||||
|
@ -18,10 +18,10 @@ def test_add(u1_s1, u2_s1):
|
|||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
UserPreference.objects.filter(user=auth.get_user(u1_s1)).delete()
|
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
|
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
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@ -2,17 +2,17 @@ from pydoc import locate
|
|||||||
|
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from recipes.version import VERSION_NUMBER
|
from rest_framework import permissions, routers
|
||||||
from rest_framework import routers, permissions
|
|
||||||
from rest_framework.schemas import get_schema_view
|
from rest_framework.schemas import get_schema_view
|
||||||
|
|
||||||
from cookbook.helper import dal
|
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,
|
from .models import (Automation, Comment, Food, InviteLink, Keyword, MealPlan, Recipe, RecipeBook,
|
||||||
RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList,
|
RecipeBookEntry, RecipeImport, ShoppingList, Step, Storage, Supermarket,
|
||||||
Storage, Supermarket, SupermarketCategory, Sync, SyncLog, Unit, get_model_name, Automation,
|
SupermarketCategory, Sync, SyncLog, Unit, UserFile, get_model_name)
|
||||||
UserFile, Step)
|
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
|
||||||
from .views import api, data, delete, edit, import_export, lists, new, views, telegram
|
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.register(r'user-name', api.UserNameViewSet, basename='username')
|
router.register(r'user-name', api.UserNameViewSet, basename='username')
|
||||||
@ -68,8 +68,6 @@ urlpatterns = [
|
|||||||
path('history/', views.history, name='view_history'),
|
path('history/', views.history, name='view_history'),
|
||||||
path('supermarket/', views.supermarket, name='view_supermarket'),
|
path('supermarket/', views.supermarket, name='view_supermarket'),
|
||||||
path('abuse/<slug:token>', views.report_share_abuse, name='view_report_share_abuse'),
|
path('abuse/<slug:token>', 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/', import_export.import_recipe, name='view_import'),
|
||||||
path('import-response/<int:pk>/', import_export.import_response, name='view_import_response'),
|
path('import-response/<int:pk>/', 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}'
|
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'))
|
||||||
|
@ -46,7 +46,9 @@ from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, Impor
|
|||||||
from cookbook.provider.dropbox import Dropbox
|
from cookbook.provider.dropbox import Dropbox
|
||||||
from cookbook.provider.local import Local
|
from cookbook.provider.local import Local
|
||||||
from cookbook.provider.nextcloud import Nextcloud
|
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,
|
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
|
||||||
CookLogSerializer, FoodSerializer, ImportLogSerializer,
|
CookLogSerializer, FoodSerializer, ImportLogSerializer,
|
||||||
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
|
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
|
||||||
@ -408,7 +410,7 @@ class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
|||||||
permission_classes = [CustomIsOwner]
|
permission_classes = [CustomIsOwner]
|
||||||
|
|
||||||
def get_queryset(self):
|
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()
|
return super().get_queryset()
|
||||||
|
|
||||||
|
|
||||||
@ -564,7 +566,16 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return super().get_queryset()
|
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
|
# TODO write extensive tests for permissions
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == 'list':
|
if self.action == 'list':
|
||||||
return RecipeOverviewSerializer
|
return RecipeOverviewSerializer
|
||||||
|
@ -13,7 +13,7 @@ from django.contrib.auth.password_validation import validate_password
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Avg, Q, Sum
|
from django.db.models import Avg, Q, Sum
|
||||||
from django.http import HttpResponseRedirect, JsonResponse
|
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.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -22,16 +22,15 @@ from django_tables2 import RequestConfig
|
|||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from cookbook.filters import RecipeFilter
|
from cookbook.filters import RecipeFilter
|
||||||
from cookbook.forms import (CommentForm, Recipe, User,
|
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm,
|
||||||
UserCreateForm, UserNameForm, UserPreference,
|
SpaceJoinForm, User, UserCreateForm, UserNameForm, UserPreference,
|
||||||
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm,
|
UserPreferenceForm)
|
||||||
SearchPreferenceForm)
|
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid
|
||||||
from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission
|
from cookbook.models import (Comment, CookLog, Food, InviteLink, Keyword, MealPlan, RecipeImport,
|
||||||
from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
|
SearchFields, SearchPreference, ShareLink, ShoppingList, Space, Unit,
|
||||||
ViewLog, ShoppingList, Space, Keyword, RecipeImport, Unit,
|
UserFile, ViewLog)
|
||||||
Food, UserFile, ShareLink, SearchPreference, SearchFields)
|
from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall,
|
||||||
from cookbook.tables import (CookLogTable, RecipeTable, RecipeTableSmall,
|
ViewLogTable)
|
||||||
ViewLogTable, InviteLinkTable)
|
|
||||||
from cookbook.views.data import Object
|
from cookbook.views.data import Object
|
||||||
from recipes.version import BUILD_REF, VERSION_NUMBER
|
from recipes.version import BUILD_REF, VERSION_NUMBER
|
||||||
|
|
||||||
@ -382,7 +381,7 @@ def user_settings(request):
|
|||||||
if up:
|
if up:
|
||||||
preference_form = UserPreferenceForm(instance=up, space=request.space)
|
preference_form = UserPreferenceForm(instance=up, space=request.space)
|
||||||
else:
|
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(
|
fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(
|
||||||
sp.fulltext.all())
|
sp.fulltext.all())
|
||||||
|
@ -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.
|
to offer the best experience possible and allow you to freely choose where your data goes.
|
||||||
|
|
||||||
!!! warning "WIP"
|
!!! 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.
|
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.
|
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.
|
if your favorite one is missing.
|
||||||
|
|
||||||
!!! info "Export"
|
!!! info "Export"
|
||||||
@ -41,7 +41,7 @@ Overview of the capabilities of the different integrations.
|
|||||||
✔ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
|
✔ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
|
||||||
|
|
||||||
## Default
|
## 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 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
|
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
|
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)
|
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
|
3. Upload the entire `.zip` file to the importer page and import everything
|
||||||
|
|
||||||
@ -118,7 +118,7 @@ Recipes.zip/
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Safron
|
## 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.
|
Then simply upload the entire `.zip` file to the importer.
|
||||||
|
|
||||||
!!! warning "Images"
|
!!! warning "Images"
|
||||||
@ -131,8 +131,8 @@ The `.paprikarecipes` file is basically just a zip with gzipped contents. Simply
|
|||||||
all your recipes.
|
all your recipes.
|
||||||
|
|
||||||
## Pepperplate
|
## Pepperplate
|
||||||
Pepperplate provides a `.zip` files contain all your recipes as `.txt` files. These files are well-structured and allow
|
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 loosing anything.
|
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
|
Simply export the recipes from Pepperplate and upload the zip to Tandoor. Images are not included in the export and
|
||||||
thus cannot be imported.
|
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
|
and leads to suboptimal results. Images are also not supported as they are not included in the export (at least
|
||||||
the tests I had).
|
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.
|
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.
|
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.
|
Meal master can be imported by uploading one or more meal master files.
|
||||||
The files should either be `.txt`, `.MMF` or `.MM` 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.
|
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.
|
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.
|
As I only had limited sample data feel free to open an issue if your RezKonv export cannot be imported.
|
||||||
|
|
||||||
## Recipekeeper
|
## 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.
|
This zip file can simply be imported into Tandoor.
|
||||||
|
|
||||||
## OpenEats
|
## OpenEats
|
||||||
@ -213,7 +213,7 @@ Store the outputted json string in a `.json` file and simply import it using the
|
|||||||
|
|
||||||
## Plantoeat
|
## 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
|
||||||
|
|
||||||
|
@ -4,12 +4,14 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
app: recipes
|
app: recipes
|
||||||
name: recipes-nginx-config
|
name: recipes-nginx-config
|
||||||
|
namespace: default
|
||||||
data:
|
data:
|
||||||
nginx-config: |-
|
nginx-config: |-
|
||||||
events {
|
events {
|
||||||
worker_connections 1024;
|
worker_connections 1024;
|
||||||
}
|
}
|
||||||
http {
|
http {
|
||||||
|
include mime.types;
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
@ -24,10 +26,5 @@ data:
|
|||||||
location /media/ {
|
location /media/ {
|
||||||
alias /media/;
|
alias /media/;
|
||||||
}
|
}
|
||||||
# pass requests for dynamic content to gunicorn
|
|
||||||
location / {
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_pass http://localhost:8080;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
13
docs/install/k8s/15-secrets.yaml
Normal file
13
docs/install/k8s/15-secrets.yaml
Normal file
@ -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==
|
5
docs/install/k8s/20-service-account.yml
Normal file
5
docs/install/k8s/20-service-account.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: recipes
|
||||||
|
namespace: default
|
@ -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"
|
|
@ -1,34 +1,13 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
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:
|
metadata:
|
||||||
name: recipes-media
|
name: recipes-media
|
||||||
|
namespace: default
|
||||||
labels:
|
labels:
|
||||||
app: recipes
|
app: recipes
|
||||||
spec:
|
spec:
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
tier: media
|
|
||||||
app: recipes
|
|
||||||
storageClassName: manual
|
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteMany
|
- ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 1Gi
|
storage: 1Gi
|
||||||
@ -37,16 +16,12 @@ apiVersion: v1
|
|||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: recipes-static
|
name: recipes-static
|
||||||
|
namespace: default
|
||||||
labels:
|
labels:
|
||||||
app: recipes
|
app: recipes
|
||||||
spec:
|
spec:
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
tier: static
|
|
||||||
app: recipes
|
|
||||||
storageClassName: manual
|
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteMany
|
- ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 1Gi
|
storage: 1Gi
|
||||||
|
142
docs/install/k8s/40-sts-postgresql.yaml
Normal file
142
docs/install/k8s/40-sts-postgresql.yaml
Normal file
@ -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
|
19
docs/install/k8s/45-service-db.yaml
Normal file
19
docs/install/k8s/45-service-db.yaml
Normal file
@ -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
|
@ -2,6 +2,7 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: recipes
|
name: recipes
|
||||||
|
namespace: default
|
||||||
labels:
|
labels:
|
||||||
app: recipes
|
app: recipes
|
||||||
environment: production
|
environment: production
|
||||||
@ -9,17 +10,78 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
strategy:
|
strategy:
|
||||||
type: RollingUpdate
|
type: Recreate
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: recipes
|
app: recipes
|
||||||
environment: production
|
environment: production
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
|
annotations:
|
||||||
|
backup.velero.io/backup-volumes: media,static
|
||||||
labels:
|
labels:
|
||||||
app: recipes
|
app: recipes
|
||||||
|
tier: frontend
|
||||||
environment: production
|
environment: production
|
||||||
spec:
|
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:
|
containers:
|
||||||
- name: recipes-nginx
|
- name: recipes-nginx
|
||||||
image: nginx:latest
|
image: nginx:latest
|
||||||
@ -28,69 +90,94 @@ spec:
|
|||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
name: http
|
name: http
|
||||||
|
- containerPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
name: gunicorn
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 64Mi
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: '/media'
|
- mountPath: /media
|
||||||
name: media
|
name: media
|
||||||
- mountPath: '/static'
|
# mount as subPath due to lost+found on ext4 pvc
|
||||||
|
subPath: files
|
||||||
|
- mountPath: /static
|
||||||
name: static
|
name: static
|
||||||
|
# mount as subPath due to lost+found on ext4 pvc
|
||||||
|
subPath: files
|
||||||
- name: nginx-config
|
- name: nginx-config
|
||||||
mountPath: /etc/nginx/nginx.conf
|
mountPath: /etc/nginx/nginx.conf
|
||||||
subPath: nginx-config
|
subPath: nginx-config
|
||||||
readOnly: true
|
readOnly: true
|
||||||
- name: recipes
|
- name: recipes
|
||||||
image: 'vabene1111/recipes:latest'
|
image: vabene1111/recipes:1.0.1
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
|
command:
|
||||||
|
- /opt/recipes/venv/bin/gunicorn
|
||||||
|
- -b
|
||||||
|
- :8080
|
||||||
|
- --access-logfile
|
||||||
|
- "-"
|
||||||
|
- --error-logfile
|
||||||
|
- "-"
|
||||||
|
- --log-level
|
||||||
|
- INFO
|
||||||
|
- recipes.wsgi
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
|
failureThreshold: 3
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
port: 8080
|
port: 8080
|
||||||
|
scheme: HTTP
|
||||||
|
periodSeconds: 30
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
port: 8080
|
port: 8080
|
||||||
|
scheme: HTTP
|
||||||
|
periodSeconds: 30
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 64Mi
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: '/opt/recipes/mediafiles'
|
- mountPath: /opt/recipes/mediafiles
|
||||||
name: media
|
name: media
|
||||||
- mountPath: '/opt/recipes/staticfiles'
|
# mount as subPath due to lost+found on ext4 pvc
|
||||||
|
subPath: files
|
||||||
|
- mountPath: /opt/recipes/staticfiles
|
||||||
name: static
|
name: static
|
||||||
|
# mount as subPath due to lost+found on ext4 pvc
|
||||||
|
subPath: files
|
||||||
env:
|
env:
|
||||||
- name: DEBUG
|
- name: DEBUG
|
||||||
value: "0"
|
value: "0"
|
||||||
- name: ALLOWED_HOSTS
|
- name: ALLOWED_HOSTS
|
||||||
value: '*'
|
value: '*'
|
||||||
- name: SECRET_KEY
|
- name: SECRET_KEY
|
||||||
value: # CHANGEME
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: recipes
|
||||||
|
key: secret-key
|
||||||
- name: DB_ENGINE
|
- name: DB_ENGINE
|
||||||
value: django.db.backends.postgresql_psycopg2
|
value: django.db.backends.postgresql_psycopg2
|
||||||
- name: POSTGRES_HOST
|
- name: POSTGRES_HOST
|
||||||
value: localhost
|
value: recipes-postgresql
|
||||||
- name: POSTGRES_PORT
|
- name: POSTGRES_PORT
|
||||||
value: "5432"
|
value: "5432"
|
||||||
- name: POSTGRES_USER
|
- name: POSTGRES_USER
|
||||||
value: recipes
|
value: postgres
|
||||||
- name: POSTGRES_DB
|
- name: POSTGRES_DB
|
||||||
value: recipes
|
value: recipes
|
||||||
- name: POSTGRES_PASSWORD
|
- name: POSTGRES_PASSWORD
|
||||||
value: # CHANGEME
|
valueFrom:
|
||||||
- name: recipes-db
|
secretKeyRef:
|
||||||
image: 'postgres:latest'
|
name: recipes
|
||||||
imagePullPolicy: IfNotPresent
|
key: postgresql-postgres-password
|
||||||
ports:
|
securityContext:
|
||||||
- containerPort: 5432
|
runAsUser: 65534
|
||||||
volumeMounts:
|
|
||||||
- mountPath: '/var/lib/postgresql/data'
|
|
||||||
name: database
|
|
||||||
env:
|
|
||||||
- name: POSTGRES_USER
|
|
||||||
value: recipes
|
|
||||||
- name: POSTGRES_DB
|
|
||||||
value: recipes
|
|
||||||
- name: POSTGRES_PASSWORD
|
|
||||||
value: # CHANGEME
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: database
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: recipes-db
|
|
||||||
- name: media
|
- name: media
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: recipes-media
|
claimName: recipes-media
|
||||||
|
@ -2,14 +2,21 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: recipes
|
name: recipes
|
||||||
|
namespace: default
|
||||||
labels:
|
labels:
|
||||||
app: recipes
|
app: recipes
|
||||||
|
tier: frontend
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: recipes
|
app: recipes
|
||||||
|
tier: frontend
|
||||||
environment: production
|
environment: production
|
||||||
ports:
|
ports:
|
||||||
- port: 80
|
- port: 80
|
||||||
targetPort: http
|
targetPort: http
|
||||||
name: http
|
name: http
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
|
- port: 8080
|
||||||
|
targetPort: gunicorn
|
||||||
|
name: gunicorn
|
||||||
|
protocol: TCP
|
||||||
|
38
docs/install/k8s/70-ingress.yaml
Normal file
38
docs/install/k8s/70-ingress.yaml
Normal file
@ -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
|
@ -1,31 +1,98 @@
|
|||||||
!!! info "Community Contributed"
|
**!!! info "Community Contributed" This guide was contributed by the community and is neither officially supported, nor updated or tested.**
|
||||||
This guide was contributed by the community and is neither officially supported, nor updated or tested.
|
|
||||||
|
|
||||||
This is a basic kubernetes setup.
|
# K8s 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!
|
|
||||||
|
|
||||||
All files con be found here in the Github Repo:
|
This is a setup which should be sufficent for production use. Be sure to replace the default secrets!
|
||||||
[docs/install/k8s](https://github.com/vabene1111/recipes/tree/develop/docs/install/k8s)
|
|
||||||
|
|
||||||
## 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/
|
kubectl apply -f ./docs/k8s/
|
||||||
```
|
~~~
|
||||||
|
Binary file not shown.
Binary file not shown.
@ -151,6 +151,7 @@ MIDDLEWARE = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
SORT_TREE_BY_NAME = bool(int(os.getenv('SORT_TREE_BY_NAME', False)))
|
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))):
|
if bool(int(os.getenv('SQL_DEBUG', False))):
|
||||||
MIDDLEWARE += ('recipes.middleware.SqlPrintingMiddleware',)
|
MIDDLEWARE += ('recipes.middleware.SqlPrintingMiddleware',)
|
||||||
|
@ -25,7 +25,7 @@ icalendar==4.0.9
|
|||||||
pyyaml==6.0
|
pyyaml==6.0
|
||||||
uritemplate==4.1.1
|
uritemplate==4.1.1
|
||||||
beautifulsoup4==4.10.0
|
beautifulsoup4==4.10.0
|
||||||
microdata==0.7.1
|
microdata==0.7.2
|
||||||
Jinja2==3.0.2
|
Jinja2==3.0.2
|
||||||
django-webpack-loader==1.4.1
|
django-webpack-loader==1.4.1
|
||||||
django-js-reverse==0.9.1
|
django-js-reverse==0.9.1
|
||||||
@ -40,5 +40,5 @@ django-storages==1.12.3
|
|||||||
boto3==1.19.7
|
boto3==1.19.7
|
||||||
django-prometheus==2.1.0
|
django-prometheus==2.1.0
|
||||||
django-hCaptcha==0.1.0
|
django-hCaptcha==0.1.0
|
||||||
python-ldap==3.3.1
|
python-ldap==3.4.0
|
||||||
django-auth-ldap==3.0.0
|
django-auth-ldap==3.0.0
|
@ -136,22 +136,22 @@
|
|||||||
<ContextMenu ref="menu">
|
<ContextMenu ref="menu">
|
||||||
<template #menu="{ contextData }">
|
<template #menu="{ contextData }">
|
||||||
<ContextMenuItem @click="$refs.menu.close();openEntryEdit(contextData.originalItem.entry)">
|
<ContextMenuItem @click="$refs.menu.close();openEntryEdit(contextData.originalItem.entry)">
|
||||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pen"></i> {{ $t("Edit") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();moveEntryLeft(contextData)">
|
<ContextMenuItem @click="$refs.menu.close();moveEntryLeft(contextData)">
|
||||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-left"></i> {{ $t("Move") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();moveEntryRight(contextData)">
|
<ContextMenuItem @click="$refs.menu.close();moveEntryRight(contextData)">
|
||||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-arrow-right"></i> {{ $t("Move") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();createEntry(contextData.originalItem.entry)">
|
<ContextMenuItem @click="$refs.menu.close();createEntry(contextData.originalItem.entry)">
|
||||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-copy"></i> {{ $t("Clone") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-copy"></i> {{ $t("Clone") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();addToShopping(contextData)">
|
<ContextMenuItem @click="$refs.menu.close();addToShopping(contextData)">
|
||||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
|
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-shopping-cart"></i> {{ $t("Add_to_Shopping") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem @click="$refs.menu.close();deleteEntry(contextData)">
|
<ContextMenuItem @click="$refs.menu.close();deleteEntry(contextData)">
|
||||||
<a class="dropdown-item p-2 text-danger" href="#"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
|
<a class="dropdown-item p-2 text-danger" href="javascript:void(0)"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</template>
|
</template>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@ -513,12 +513,18 @@ export default {
|
|||||||
return entry.id === id
|
return entry.id === id
|
||||||
})[0]
|
})[0]
|
||||||
},
|
},
|
||||||
moveEntry(null_object, target_date) {
|
moveEntry(null_object, target_date, drag_event) {
|
||||||
this.plan_entries.forEach((entry) => {
|
this.plan_entries.forEach((entry) => {
|
||||||
if (entry.id === this.dragged_item.id) {
|
if (entry.id === this.dragged_item.id) {
|
||||||
|
if (drag_event.ctrlKey) {
|
||||||
|
let new_entry = Object.assign({}, entry)
|
||||||
|
new_entry.date = target_date
|
||||||
|
this.createEntry(new_entry)
|
||||||
|
} else {
|
||||||
entry.date = target_date
|
entry.date = target_date
|
||||||
this.saveEntry(entry)
|
this.saveEntry(entry)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
moveEntryLeft(data) {
|
moveEntryLeft(data) {
|
||||||
|
@ -7,140 +7,100 @@
|
|||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-12 col-lg-10 col-xl-8 mt-3 mb-3">
|
<div class="col-12 col-lg-10 col-xl-8 mt-3 mb-3">
|
||||||
<b-input-group>
|
<b-input-group>
|
||||||
<b-input class="form-control form-control-lg form-control-borderless form-control-search" v-model="settings.search_input"
|
<b-input
|
||||||
v-bind:placeholder="$t('Search')"></b-input>
|
class="form-control form-control-lg form-control-borderless form-control-search"
|
||||||
|
v-model="settings.search_input"
|
||||||
|
v-bind:placeholder="$t('Search')"
|
||||||
|
></b-input>
|
||||||
<b-input-group-append>
|
<b-input-group-append>
|
||||||
<b-button variant="light"
|
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()">
|
||||||
v-b-tooltip.hover :title="$t('Random Recipes')"
|
<i class="fas fa-bug" style="font-size: 1.5em"></i>
|
||||||
@click="openRandom()">
|
</b-button>
|
||||||
|
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')" @click="openRandom()">
|
||||||
<i class="fas fa-dice-five" style="font-size: 1.5em"></i>
|
<i class="fas fa-dice-five" style="font-size: 1.5em"></i>
|
||||||
</b-button>
|
</b-button>
|
||||||
<b-button v-b-toggle.collapse_advanced_search
|
<b-button
|
||||||
v-b-tooltip.hover :title="$t('Advanced Settings')"
|
v-b-toggle.collapse_advanced_search
|
||||||
|
v-b-tooltip.hover
|
||||||
|
:title="$t('Advanced Settings')"
|
||||||
v-bind:variant="!isAdvancedSettingsSet() ? 'primary' : 'danger'"
|
v-bind:variant="!isAdvancedSettingsSet() ? 'primary' : 'danger'"
|
||||||
>
|
>
|
||||||
<!-- TODO consider changing this icon to a filter -->
|
<!-- TODO consider changing this icon to a filter -->
|
||||||
<i class="fas fa-caret-down" v-if="!settings.advanced_search_visible"></i>
|
<i class="fas fa-caret-down" v-if="!settings.advanced_search_visible"></i>
|
||||||
<i class="fas fa-caret-up" v-if="settings.advanced_search_visible"></i>
|
<i class="fas fa-caret-up" v-if="settings.advanced_search_visible"></i>
|
||||||
</b-button>
|
</b-button>
|
||||||
|
|
||||||
</b-input-group-append>
|
</b-input-group-append>
|
||||||
</b-input-group>
|
</b-input-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm" v-model="settings.advanced_search_visible">
|
<b-collapse id="collapse_advanced_search" class="mt-2 shadow-sm" v-model="settings.advanced_search_visible">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<a class="btn btn-primary btn-block text-uppercase"
|
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('new_recipe')">{{ $t("New_Recipe") }}</a>
|
||||||
:href="resolveDjangoUrl('new_recipe')">{{ $t('New_Recipe') }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<a class="btn btn-primary btn-block text-uppercase"
|
<a class="btn btn-primary btn-block text-uppercase" :href="resolveDjangoUrl('data_import_url')">{{ $t("Import") }}</a>
|
||||||
:href="resolveDjangoUrl('data_import_url')">{{ $t('Import') }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<button class="btn btn-block text-uppercase" v-b-tooltip.hover :title="$t('show_only_internal')"
|
<button
|
||||||
v-bind:class="{'btn-success':settings.search_internal, 'btn-primary':!settings.search_internal}"
|
class="btn btn-block text-uppercase"
|
||||||
@click="settings.search_internal = !settings.search_internal;refreshData()">
|
v-b-tooltip.hover
|
||||||
{{ $t('Internal') }}
|
:title="$t('show_only_internal')"
|
||||||
|
v-bind:class="{ 'btn-success': settings.search_internal, 'btn-primary': !settings.search_internal }"
|
||||||
|
@click="
|
||||||
|
settings.search_internal = !settings.search_internal
|
||||||
|
refreshData()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ $t("Internal") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<button id="id_settings_button" class="btn btn-primary btn-block text-uppercase"><i
|
<button id="id_settings_button" class="btn btn-primary btn-block text-uppercase"><i class="fas fa-cog fa-lg m-1"></i></button>
|
||||||
class="fas fa-cog fa-lg m-1"></i>
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<b-popover target="id_settings_button" triggers="click" placement="bottom" :title="$t('Settings')">
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<b-popover
|
|
||||||
target="id_settings_button"
|
|
||||||
triggers="click"
|
|
||||||
placement="bottom"
|
|
||||||
:title="$t('Settings')">
|
|
||||||
<div>
|
<div>
|
||||||
<b-form-group
|
<b-form-group v-bind:label="$t('Recently_Viewed')" label-for="popover-input-1" label-cols="6" class="mb-3">
|
||||||
v-bind:label="$t('Recently_Viewed')"
|
<b-form-input type="number" v-model="settings.recently_viewed" id="popover-input-1" size="sm"></b-form-input>
|
||||||
label-for="popover-input-1"
|
</b-form-group>
|
||||||
label-cols="6"
|
|
||||||
class="mb-3">
|
<b-form-group v-bind:label="$t('Recipes_per_page')" label-for="popover-input-page-count" label-cols="6" class="mb-3">
|
||||||
<b-form-input
|
<b-form-input type="number" v-model="settings.page_count" id="popover-input-page-count" size="sm"></b-form-input>
|
||||||
type="number"
|
</b-form-group>
|
||||||
v-model="settings.recently_viewed"
|
|
||||||
id="popover-input-1"
|
<b-form-group v-bind:label="$t('Meal_Plan')" label-for="popover-input-2" label-cols="6" class="mb-3">
|
||||||
size="sm"
|
<b-form-checkbox switch v-model="settings.show_meal_plan" id="popover-input-2" size="sm"></b-form-checkbox>
|
||||||
></b-form-input>
|
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
|
|
||||||
<b-form-group
|
<b-form-group
|
||||||
v-bind:label="$t('Recipes_per_page')"
|
v-if="settings.show_meal_plan"
|
||||||
label-for="popover-input-page-count"
|
|
||||||
label-cols="6"
|
|
||||||
class="mb-3">
|
|
||||||
<b-form-input
|
|
||||||
type="number"
|
|
||||||
v-model="settings.page_count"
|
|
||||||
id="popover-input-page-count"
|
|
||||||
size="sm"
|
|
||||||
></b-form-input>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-form-group
|
|
||||||
v-bind:label="$t('Meal_Plan')"
|
|
||||||
label-for="popover-input-2"
|
|
||||||
label-cols="6"
|
|
||||||
class="mb-3">
|
|
||||||
<b-form-checkbox
|
|
||||||
switch
|
|
||||||
v-model="settings.show_meal_plan"
|
|
||||||
id="popover-input-2"
|
|
||||||
size="sm"
|
|
||||||
></b-form-checkbox>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-form-group v-if="settings.show_meal_plan"
|
|
||||||
v-bind:label="$t('Meal_Plan_Days')"
|
v-bind:label="$t('Meal_Plan_Days')"
|
||||||
label-for="popover-input-5"
|
label-for="popover-input-5"
|
||||||
label-cols="6"
|
label-cols="6"
|
||||||
class="mb-3">
|
class="mb-3"
|
||||||
<b-form-input
|
>
|
||||||
type="number"
|
<b-form-input type="number" v-model="settings.meal_plan_days" id="popover-input-5" size="sm"></b-form-input>
|
||||||
v-model="settings.meal_plan_days"
|
|
||||||
id="popover-input-5"
|
|
||||||
size="sm"
|
|
||||||
></b-form-input>
|
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
|
|
||||||
<b-form-group
|
<b-form-group v-bind:label="$t('Sort_by_new')" label-for="popover-input-3" label-cols="6" class="mb-3">
|
||||||
v-bind:label="$t('Sort_by_new')"
|
<b-form-checkbox switch v-model="settings.sort_by_new" id="popover-input-3" size="sm"></b-form-checkbox>
|
||||||
label-for="popover-input-3"
|
|
||||||
label-cols="6"
|
|
||||||
class="mb-3">
|
|
||||||
<b-form-checkbox
|
|
||||||
switch
|
|
||||||
v-model="settings.sort_by_new"
|
|
||||||
id="popover-input-3"
|
|
||||||
size="sm"
|
|
||||||
></b-form-checkbox>
|
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top: 1vh">
|
<div class="row" style="margin-top: 1vh">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{ $t('Advanced Search Settings') }}</a>
|
<a :href="resolveDjangoUrl('view_settings') + '#search'">{{ $t("Advanced Search Settings") }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin-top: 1vh">
|
<div class="row" style="margin-top: 1vh">
|
||||||
<div class="col-12" style="text-align: right">
|
<div class="col-12" style="text-align: right">
|
||||||
<b-button size="sm" variant="secondary" style="margin-right:8px"
|
<b-button size="sm" variant="secondary" style="margin-right: 8px" @click="$root.$emit('bv::hide::popover')"
|
||||||
@click="$root.$emit('bv::hide::popover')">{{ $t('Close') }}
|
>{{ $t("Close") }}
|
||||||
</b-button>
|
</b-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -150,17 +110,28 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<b-input-group class="mt-2">
|
<b-input-group class="mt-2">
|
||||||
<treeselect v-model="settings.search_keywords" :options="facets.Keywords" :flat="true"
|
<treeselect
|
||||||
searchNested multiple :placeholder="$t('Keywords')" :normalizer="normalizer"
|
v-model="settings.search_keywords"
|
||||||
|
:options="facets.Keywords"
|
||||||
|
:flat="true"
|
||||||
|
searchNested
|
||||||
|
multiple
|
||||||
|
:placeholder="$t('Keywords')"
|
||||||
|
:normalizer="normalizer"
|
||||||
@input="refreshData(false)"
|
@input="refreshData(false)"
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"/>
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
/>
|
||||||
<b-input-group-append>
|
<b-input-group-append>
|
||||||
<b-input-group-text>
|
<b-input-group-text>
|
||||||
<b-form-checkbox v-model="settings.search_keywords_or" name="check-button"
|
<b-form-checkbox
|
||||||
|
v-model="settings.search_keywords_or"
|
||||||
|
name="check-button"
|
||||||
@change="refreshData(false)"
|
@change="refreshData(false)"
|
||||||
class="shadow-none" switch>
|
class="shadow-none"
|
||||||
<span class="text-uppercase" v-if="settings.search_keywords_or">{{ $t('or') }}</span>
|
switch
|
||||||
<span class="text-uppercase" v-else>{{ $t('and') }}</span>
|
>
|
||||||
|
<span class="text-uppercase" v-if="settings.search_keywords_or">{{ $t("or") }}</span>
|
||||||
|
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||||
</b-form-checkbox>
|
</b-form-checkbox>
|
||||||
</b-input-group-text>
|
</b-input-group-text>
|
||||||
</b-input-group-append>
|
</b-input-group-append>
|
||||||
@ -172,17 +143,28 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<b-input-group class="mt-2">
|
<b-input-group class="mt-2">
|
||||||
<treeselect v-model="settings.search_foods" :options="facets.Foods" :flat="true"
|
<treeselect
|
||||||
searchNested multiple :placeholder="$t('Ingredients')" :normalizer="normalizer"
|
v-model="settings.search_foods"
|
||||||
|
:options="facets.Foods"
|
||||||
|
:flat="true"
|
||||||
|
searchNested
|
||||||
|
multiple
|
||||||
|
:placeholder="$t('Ingredients')"
|
||||||
|
:normalizer="normalizer"
|
||||||
@input="refreshData(false)"
|
@input="refreshData(false)"
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"/>
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
/>
|
||||||
<b-input-group-append>
|
<b-input-group-append>
|
||||||
<b-input-group-text>
|
<b-input-group-text>
|
||||||
<b-form-checkbox v-model="settings.search_foods_or" name="check-button"
|
<b-form-checkbox
|
||||||
|
v-model="settings.search_foods_or"
|
||||||
|
name="check-button"
|
||||||
@change="refreshData(false)"
|
@change="refreshData(false)"
|
||||||
class="shadow-none" switch>
|
class="shadow-none"
|
||||||
<span class="text-uppercase" v-if="settings.search_foods_or">{{ $t('or') }}</span>
|
switch
|
||||||
<span class="text-uppercase" v-else>{{ $t('and') }}</span>
|
>
|
||||||
|
<span class="text-uppercase" v-if="settings.search_foods_or">{{ $t("or") }}</span>
|
||||||
|
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||||
</b-form-checkbox>
|
</b-form-checkbox>
|
||||||
</b-input-group-text>
|
</b-input-group-text>
|
||||||
</b-input-group-append>
|
</b-input-group-append>
|
||||||
@ -194,18 +176,27 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<b-input-group class="mt-2">
|
<b-input-group class="mt-2">
|
||||||
<generic-multiselect @change="genericSelectChanged" parent_variable="search_books"
|
<generic-multiselect
|
||||||
|
@change="genericSelectChanged"
|
||||||
|
parent_variable="search_books"
|
||||||
:initial_selection="settings.search_books"
|
:initial_selection="settings.search_books"
|
||||||
:model="Models.RECIPE_BOOK"
|
:model="Models.RECIPE_BOOK"
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
v-bind:placeholder="$t('Books')" :limit="50"></generic-multiselect>
|
v-bind:placeholder="$t('Books')"
|
||||||
|
:limit="50"
|
||||||
|
></generic-multiselect>
|
||||||
<b-input-group-append>
|
<b-input-group-append>
|
||||||
<b-input-group-text>
|
<b-input-group-text>
|
||||||
<b-form-checkbox v-model="settings.search_books_or" name="check-button"
|
<b-form-checkbox
|
||||||
|
v-model="settings.search_books_or"
|
||||||
|
name="check-button"
|
||||||
@change="refreshData(false)"
|
@change="refreshData(false)"
|
||||||
class="shadow-none" tyle="width: 100%" switch>
|
class="shadow-none"
|
||||||
<span class="text-uppercase" v-if="settings.search_books_or">{{ $t('or') }}</span>
|
tyle="width: 100%"
|
||||||
<span class="text-uppercase" v-else>{{ $t('and') }}</span>
|
switch
|
||||||
|
>
|
||||||
|
<span class="text-uppercase" v-if="settings.search_books_or">{{ $t("or") }}</span>
|
||||||
|
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||||
</b-form-checkbox>
|
</b-form-checkbox>
|
||||||
</b-input-group-text>
|
</b-input-group-text>
|
||||||
</b-input-group-append>
|
</b-input-group-append>
|
||||||
@ -217,103 +208,95 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<b-input-group class="mt-2">
|
<b-input-group class="mt-2">
|
||||||
<treeselect v-model="settings.search_ratings" :options="ratingOptions" :flat="true"
|
<treeselect
|
||||||
:placeholder="$t('Ratings')" :searchable="false"
|
v-model="settings.search_ratings"
|
||||||
|
:options="ratingOptions"
|
||||||
|
:flat="true"
|
||||||
|
:placeholder="$t('Ratings')"
|
||||||
|
:searchable="false"
|
||||||
@input="refreshData(false)"
|
@input="refreshData(false)"
|
||||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"/>
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
/>
|
||||||
<b-input-group-append>
|
<b-input-group-append>
|
||||||
<b-input-group-text style="width:85px">
|
<b-input-group-text style="width: 85px"> </b-input-group-text>
|
||||||
</b-input-group-text>
|
|
||||||
</b-input-group-append>
|
</b-input-group-append>
|
||||||
</b-input-group>
|
</b-input-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</b-collapse>
|
</b-collapse>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12 text-right" style="margin-top: 2vh">
|
<div class="col col-md-12 text-right" style="margin-top: 2vh">
|
||||||
<span class="text-muted">
|
<span class="text-muted">
|
||||||
{{ $t('Page') }} {{ settings.pagination_page }}/{{ Math.ceil(pagination_count/settings.page_count) }} <a href="#" @click="resetSearch"><i
|
{{ $t("Page") }} {{ settings.pagination_page }}/{{ Math.ceil(pagination_count / settings.page_count) }}
|
||||||
class="fas fa-times-circle"></i> {{ $t('Reset') }}</a>
|
<a href="#" @click="resetSearch"><i class="fas fa-times-circle"></i> {{ $t("Reset") }}</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));grid-gap: 0.8rem;" >
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
|
||||||
|
|
||||||
<template v-if="!searchFiltered">
|
<template v-if="!searchFiltered">
|
||||||
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe"
|
<recipe-card
|
||||||
:meal_plan="m" :footer_text="m.meal_type_name"
|
v-bind:key="`mp_${m.id}`"
|
||||||
footer_icon="far fa-calendar-alt"></recipe-card>
|
v-for="m in meal_plans"
|
||||||
|
:recipe="m.recipe"
|
||||||
|
:meal_plan="m"
|
||||||
|
:footer_text="m.meal_type_name"
|
||||||
|
footer_icon="far fa-calendar-alt"
|
||||||
|
></recipe-card>
|
||||||
</template>
|
</template>
|
||||||
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r"
|
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r" :footer_text="isRecentOrNew(r)[0]" :footer_icon="isRecentOrNew(r)[1]"> </recipe-card>
|
||||||
:footer_text="isRecentOrNew(r)[0]"
|
|
||||||
:footer_icon="isRecentOrNew(r)[1]">
|
|
||||||
</recipe-card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" style="margin-top: 2vh" v-if="!random_search">
|
<div class="row" style="margin-top: 2vh" v-if="!random_search">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<b-pagination pills
|
<b-pagination pills v-model="settings.pagination_page" :total-rows="pagination_count" :per-page="settings.page_count" @change="pageChange" align="center">
|
||||||
v-model="settings.pagination_page"
|
|
||||||
:total-rows="pagination_count"
|
|
||||||
:per-page="settings.page_count"
|
|
||||||
@change="pageChange"
|
|
||||||
align="center">
|
|
||||||
|
|
||||||
</b-pagination>
|
</b-pagination>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2 d-none d-md-block">
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-2 d-none d-md-block"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Vue from 'vue'
|
import Vue from "vue"
|
||||||
import {BootstrapVue} from 'bootstrap-vue'
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
|
|
||||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
import moment from 'moment'
|
import moment from "moment"
|
||||||
import _debounce from 'lodash/debounce'
|
import _debounce from "lodash/debounce"
|
||||||
|
|
||||||
import VueCookies from 'vue-cookies'
|
import VueCookies from "vue-cookies"
|
||||||
|
|
||||||
Vue.use(VueCookies)
|
Vue.use(VueCookies)
|
||||||
|
|
||||||
import {ApiMixin, ResolveUrlMixin} from "@/utils/utils";
|
import { ApiMixin, ResolveUrlMixin } from "@/utils/utils"
|
||||||
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner"; // is this deprecated?
|
import LoadingSpinner from "@/components/LoadingSpinner" // is this deprecated?
|
||||||
|
|
||||||
import RecipeCard from "@/components/RecipeCard";
|
import RecipeCard from "@/components/RecipeCard"
|
||||||
import GenericMultiselect from "@/components/GenericMultiselect";
|
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||||
import Treeselect from '@riophae/vue-treeselect'
|
import Treeselect from "@riophae/vue-treeselect"
|
||||||
import '@riophae/vue-treeselect/dist/vue-treeselect.css'
|
import "@riophae/vue-treeselect/dist/vue-treeselect.css"
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
let SETTINGS_COOKIE_NAME = 'search_settings'
|
let SETTINGS_COOKIE_NAME = "search_settings"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RecipeSearchView',
|
name: "RecipeSearchView",
|
||||||
mixins: [ResolveUrlMixin, ApiMixin],
|
mixins: [ResolveUrlMixin, ApiMixin],
|
||||||
components: {GenericMultiselect, RecipeCard, Treeselect},
|
components: { GenericMultiselect, RecipeCard, Treeselect },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
// this.Models and this.Actions inherited from ApiMixin
|
// this.Models and this.Actions inherited from ApiMixin
|
||||||
@ -324,7 +307,7 @@ export default {
|
|||||||
|
|
||||||
settings_loaded: false,
|
settings_loaded: false,
|
||||||
settings: {
|
settings: {
|
||||||
search_input: '',
|
search_input: "",
|
||||||
search_internal: false,
|
search_internal: false,
|
||||||
search_keywords: [],
|
search_keywords: [],
|
||||||
search_foods: [],
|
search_foods: [],
|
||||||
@ -344,30 +327,30 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
pagination_count: 0,
|
pagination_count: 0,
|
||||||
random_search: false
|
random_search: false,
|
||||||
|
debug: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
ratingOptions: function () {
|
ratingOptions: function () {
|
||||||
return [
|
return [
|
||||||
{'id': 5, 'label': '⭐⭐⭐⭐⭐' + ' (' + (this.facets.Ratings?.['5.0'] ?? 0) + ')' },
|
{ id: 5, label: "⭐⭐⭐⭐⭐" + " (" + (this.facets.Ratings?.["5.0"] ?? 0) + ")" },
|
||||||
{'id': 4, 'label': '⭐⭐⭐⭐ ' + this.$t('and_up') + ' (' + (this.facets.Ratings?.['4.0'] ?? 0) + ')' },
|
{ id: 4, label: "⭐⭐⭐⭐ " + this.$t("and_up") + " (" + (this.facets.Ratings?.["4.0"] ?? 0) + ")" },
|
||||||
{'id': 3, 'label': '⭐⭐⭐ ' + this.$t('and_up') + ' (' + (this.facets.Ratings?.['3.0'] ?? 0) + ')' },
|
{ id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + " (" + (this.facets.Ratings?.["3.0"] ?? 0) + ")" },
|
||||||
{'id': 2, 'label': '⭐⭐ ' + this.$t('and_up') + ' (' + (this.facets.Ratings?.['2.0'] ?? 0) + ')' },
|
{ id: 2, label: "⭐⭐ " + this.$t("and_up") + " (" + (this.facets.Ratings?.["2.0"] ?? 0) + ")" },
|
||||||
{'id': 1, 'label': '⭐ ' + this.$t("and_up") + ' (' + (this.facets.Ratings?.['1.0'] ?? 0) + ')' },
|
{ id: 1, label: "⭐ " + this.$t("and_up") + " (" + (this.facets.Ratings?.["1.0"] ?? 0) + ")" },
|
||||||
{'id': -1, 'label': this.$t('Unrated') + ' (' + (this.facets.Ratings?.['0.0'] ?? 0 )+ ')'},
|
{ id: -1, label: this.$t("Unrated") + " (" + (this.facets.Ratings?.["0.0"] ?? 0) + ")" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
searchFiltered: function () {
|
searchFiltered: function () {
|
||||||
if (
|
if (
|
||||||
this.settings?.search_input === ''
|
this.settings?.search_input === "" &&
|
||||||
&& this.settings?.search_keywords?.length === 0
|
this.settings?.search_keywords?.length === 0 &&
|
||||||
&& this.settings?.search_foods?.length === 0
|
this.settings?.search_foods?.length === 0 &&
|
||||||
&& this.settings?.search_books?.length === 0
|
this.settings?.search_books?.length === 0 &&
|
||||||
&& this.settings?.pagination_page === 1
|
this.settings?.pagination_page === 1 &&
|
||||||
&& !this.random_search
|
!this.random_search &&
|
||||||
&& this.settings?.search_ratings === undefined
|
this.settings?.search_ratings === undefined
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
@ -380,55 +363,56 @@ export default {
|
|||||||
if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) {
|
if (this.$cookies.isKey(SETTINGS_COOKIE_NAME)) {
|
||||||
this.settings = Object.assign({}, this.settings, this.$cookies.get(SETTINGS_COOKIE_NAME))
|
this.settings = Object.assign({}, this.settings, this.$cookies.get(SETTINGS_COOKIE_NAME))
|
||||||
}
|
}
|
||||||
let urlParams = new URLSearchParams(window.location.search);
|
let urlParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
if (urlParams.has('keyword')) {
|
if (urlParams.has("keyword")) {
|
||||||
this.settings.search_keywords = []
|
this.settings.search_keywords = []
|
||||||
this.facets.Keywords = []
|
this.facets.Keywords = []
|
||||||
for (let x of urlParams.getAll('keyword')) {
|
for (let x of urlParams.getAll("keyword")) {
|
||||||
this.settings.search_keywords.push(Number.parseInt(x))
|
this.settings.search_keywords.push(Number.parseInt(x))
|
||||||
this.facets.Keywords.push({'id':x, 'name': 'loading...'})
|
this.facets.Keywords.push({ id: x, name: "loading..." })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.facets.Foods = []
|
this.facets.Foods = []
|
||||||
for (let x of this.settings.search_foods) {
|
for (let x of this.settings.search_foods) {
|
||||||
this.facets.Foods.push({'id':x, 'name': 'loading...'})
|
this.facets.Foods.push({ id: x, name: "loading..." })
|
||||||
}
|
}
|
||||||
this.facets.Keywords = []
|
this.facets.Keywords = []
|
||||||
for (let x of this.settings.search_keywords) {
|
for (let x of this.settings.search_keywords) {
|
||||||
this.facets.Keywords.push({'id':x, 'name': 'loading...'})
|
this.facets.Keywords.push({ id: x, name: "loading..." })
|
||||||
}
|
}
|
||||||
this.facets.Books = []
|
this.facets.Books = []
|
||||||
for (let x of this.settings.search_books) {
|
for (let x of this.settings.search_books) {
|
||||||
this.facets.Books.push({'id':x, 'name': 'loading...'})
|
this.facets.Books.push({ id: x, name: "loading..." })
|
||||||
}
|
}
|
||||||
this.loadMealPlan()
|
this.loadMealPlan()
|
||||||
this.refreshData(false)
|
this.refreshData(false)
|
||||||
})
|
})
|
||||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||||
|
this.debug = localStorage.getItem("DEBUG") || false
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
settings: {
|
settings: {
|
||||||
handler() {
|
handler() {
|
||||||
this.$cookies.set(SETTINGS_COOKIE_NAME, this.settings, '4h')
|
this.$cookies.set(SETTINGS_COOKIE_NAME, this.settings, "4h")
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true,
|
||||||
},
|
},
|
||||||
'settings.show_meal_plan': function () {
|
"settings.show_meal_plan": function () {
|
||||||
this.loadMealPlan()
|
this.loadMealPlan()
|
||||||
},
|
},
|
||||||
'settings.meal_plan_days': function () {
|
"settings.meal_plan_days": function () {
|
||||||
this.loadMealPlan()
|
this.loadMealPlan()
|
||||||
},
|
},
|
||||||
'settings.recently_viewed': function () {
|
"settings.recently_viewed": function () {
|
||||||
this.refreshData(false)
|
this.refreshData(false)
|
||||||
},
|
},
|
||||||
'settings.search_input': _debounce(function () {
|
"settings.search_input": _debounce(function () {
|
||||||
this.settings.pagination_page = 1
|
this.settings.pagination_page = 1
|
||||||
this.pagination_count = 0
|
this.pagination_count = 0
|
||||||
this.refreshData(false)
|
this.refreshData(false)
|
||||||
}, 300),
|
}, 300),
|
||||||
'settings.page_count': _debounce(function () {
|
"settings.page_count": _debounce(function () {
|
||||||
this.refreshData(false)
|
this.refreshData(false)
|
||||||
}, 300),
|
}, 300),
|
||||||
},
|
},
|
||||||
@ -437,59 +421,59 @@ export default {
|
|||||||
refreshData: function (random) {
|
refreshData: function (random) {
|
||||||
this.random_search = random
|
this.random_search = random
|
||||||
let params = {
|
let params = {
|
||||||
'query': this.settings.search_input,
|
query: this.settings.search_input,
|
||||||
'keywords': this.settings.search_keywords,
|
keywords: this.settings.search_keywords,
|
||||||
'foods': this.settings.search_foods,
|
foods: this.settings.search_foods,
|
||||||
'rating': this.settings.search_ratings,
|
rating: this.settings.search_ratings,
|
||||||
'books': this.settings.search_books.map(function (A) {
|
books: this.settings.search_books.map(function (A) {
|
||||||
return A["id"];
|
return A["id"]
|
||||||
}),
|
}),
|
||||||
'keywordsOr': this.settings.search_keywords_or,
|
keywordsOr: this.settings.search_keywords_or,
|
||||||
'foodsOr': this.settings.search_foods_or,
|
foodsOr: this.settings.search_foods_or,
|
||||||
'booksOr': this.settings.search_books_or,
|
booksOr: this.settings.search_books_or,
|
||||||
'internal': this.settings.search_internal,
|
internal: this.settings.search_internal,
|
||||||
'random': this.random_search,
|
random: this.random_search,
|
||||||
'_new': this.settings.sort_by_new,
|
_new: this.settings.sort_by_new,
|
||||||
'page': this.settings.pagination_page,
|
page: this.settings.pagination_page,
|
||||||
'pageSize': this.settings.page_count
|
pageSize: this.settings.page_count,
|
||||||
}
|
}
|
||||||
if (!this.searchFiltered) {
|
if (!this.searchFiltered) {
|
||||||
params.options = {'query':{'last_viewed': this.settings.recently_viewed}}
|
params.options = { query: { last_viewed: this.settings.recently_viewed } }
|
||||||
}
|
}
|
||||||
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then(result => {
|
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0)
|
||||||
this.pagination_count = result.data.count
|
this.pagination_count = result.data.count
|
||||||
|
|
||||||
this.facets = result.data.facets
|
this.facets = result.data.facets
|
||||||
if(this.facets?.cache_key) {
|
if (this.facets?.cache_key) {
|
||||||
this.getFacets(this.facets.cache_key)
|
this.getFacets(this.facets.cache_key)
|
||||||
}
|
}
|
||||||
this.recipes = this.removeDuplicates(result.data.results, recipe => recipe.id)
|
this.recipes = this.removeDuplicates(result.data.results, (recipe) => recipe.id)
|
||||||
if (!this.searchFiltered){
|
if (!this.searchFiltered) {
|
||||||
// if meal plans are being shown - filter out any meal plan recipes from the recipe list
|
// if meal plans are being shown - filter out any meal plan recipes from the recipe list
|
||||||
let mealPlans = []
|
let mealPlans = []
|
||||||
this.meal_plans.forEach(x => mealPlans.push(x.recipe.id))
|
this.meal_plans.forEach((x) => mealPlans.push(x.recipe.id))
|
||||||
this.recipes = this.recipes.filter(recipe => !mealPlans.includes(recipe.id))
|
this.recipes = this.recipes.filter((recipe) => !mealPlans.includes(recipe.id))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
openRandom: function () {
|
openRandom: function () {
|
||||||
this.refreshData(true)
|
this.refreshData(true)
|
||||||
},
|
},
|
||||||
removeDuplicates: function(data, key) {
|
removeDuplicates: function (data, key) {
|
||||||
return [
|
return [...new Map(data.map((item) => [key(item), item])).values()]
|
||||||
...new Map(data.map(item => [key(item), item])).values()
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
loadMealPlan: function () {
|
loadMealPlan: function () {
|
||||||
if (this.settings.show_meal_plan) {
|
if (this.settings.show_meal_plan) {
|
||||||
let params = {
|
let params = {
|
||||||
'options': {'query':{
|
options: {
|
||||||
'from_date': moment().format('YYYY-MM-DD'),
|
query: {
|
||||||
'to_date': moment().add(this.settings.meal_plan_days, 'days').format('YYYY-MM-DD')
|
from_date: moment().format("YYYY-MM-DD"),
|
||||||
}}
|
to_date: moment().add(this.settings.meal_plan_days, "days").format("YYYY-MM-DD"),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
this.genericAPI(this.Models.MEAL_PLAN, this.Actions.LIST, params).then(result => {
|
this.genericAPI(this.Models.MEAL_PLAN, this.Actions.LIST, params).then((result) => {
|
||||||
this.meal_plans = result.data
|
this.meal_plans = result.data
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@ -501,7 +485,7 @@ export default {
|
|||||||
this.refreshData(false)
|
this.refreshData(false)
|
||||||
},
|
},
|
||||||
resetSearch: function () {
|
resetSearch: function () {
|
||||||
this.settings.search_input = ''
|
this.settings.search_input = ""
|
||||||
this.settings.search_internal = false
|
this.settings.search_internal = false
|
||||||
this.settings.search_keywords = []
|
this.settings.search_keywords = []
|
||||||
this.settings.search_foods = []
|
this.settings.search_foods = []
|
||||||
@ -515,20 +499,20 @@ export default {
|
|||||||
this.refreshData(false)
|
this.refreshData(false)
|
||||||
},
|
},
|
||||||
isAdvancedSettingsSet() {
|
isAdvancedSettingsSet() {
|
||||||
return ((this.settings.search_keywords.length + this.settings.search_foods.length + this.settings.search_books.length) > 0)
|
return this.settings.search_keywords.length + this.settings.search_foods.length + this.settings.search_books.length > 0
|
||||||
},
|
},
|
||||||
normalizer(node) {
|
normalizer(node) {
|
||||||
let count = (node?.count ? ' (' + node.count + ')' : '')
|
let count = node?.count ? " (" + node.count + ")" : ""
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
label: node.name + count,
|
label: node.name + count,
|
||||||
children: node.children,
|
children: node.children,
|
||||||
isDefaultExpanded: node.isDefaultExpanded
|
isDefaultExpanded: node.isDefaultExpanded,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isRecentOrNew: function(x) {
|
isRecentOrNew: function (x) {
|
||||||
let recent_recipe = [this.$t('Recently_Viewed'), "fas fa-eye"]
|
let recent_recipe = [this.$t("Recently_Viewed"), "fas fa-eye"]
|
||||||
let new_recipe = [this.$t('New_Recipe'), "fas fa-splotch"]
|
let new_recipe = [this.$t("New_Recipe"), "fas fa-splotch"]
|
||||||
if (x.new) {
|
if (x.new) {
|
||||||
return new_recipe
|
return new_recipe
|
||||||
} else if (this.facets.Recent.includes(x.id)) {
|
} else if (this.facets.Recent.includes(x.id)) {
|
||||||
@ -537,18 +521,43 @@ export default {
|
|||||||
return [undefined, undefined]
|
return [undefined, undefined]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getFacets: function(hash) {
|
getFacets: function (hash) {
|
||||||
this.genericGetAPI('api_get_facets', {hash: hash}).then((response) => {
|
this.genericGetAPI("api_get_facets", { hash: hash }).then((response) => {
|
||||||
this.facets = {...this.facets, ...response.data.facets}
|
this.facets = { ...this.facets, ...response.data.facets }
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
showSQL: function () {
|
||||||
|
// TODO refactor this so that it isn't a total copy of refreshData
|
||||||
|
let params = {
|
||||||
|
query: this.settings.search_input,
|
||||||
|
keywords: this.settings.search_keywords,
|
||||||
|
foods: this.settings.search_foods,
|
||||||
|
rating: this.settings.search_ratings,
|
||||||
|
books: this.settings.search_books.map(function (A) {
|
||||||
|
return A["id"]
|
||||||
|
}),
|
||||||
|
keywordsOr: this.settings.search_keywords_or,
|
||||||
|
foodsOr: this.settings.search_foods_or,
|
||||||
|
booksOr: this.settings.search_books_or,
|
||||||
|
internal: this.settings.search_internal,
|
||||||
|
random: this.random_search,
|
||||||
|
_new: this.settings.sort_by_new,
|
||||||
|
page: this.settings.pagination_page,
|
||||||
|
pageSize: this.settings.page_count,
|
||||||
}
|
}
|
||||||
|
if (!this.searchFiltered) {
|
||||||
|
params.options = { query: { last_viewed: this.settings.recently_viewed, debug: true } }
|
||||||
|
} else {
|
||||||
|
params.options = { query: { debug: true } }
|
||||||
}
|
}
|
||||||
|
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {
|
||||||
|
console.log(result.data)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||||
|
|
||||||
<style>
|
<style></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<b-card-body class="p-4">
|
<b-card-body class="p-4">
|
||||||
<ol style="max-height: 60vh;overflow-y:auto;-webkit-overflow-scrolling: touch;" class="mb-1">
|
<ol style="max-height: 60vh;overflow-y:auto;-webkit-overflow-scrolling: touch;" class="mb-1">
|
||||||
<li v-for="(recipe, index) in recipes" v-bind:key="index" v-on:click="$emit('switchRecipe', index)">
|
<li v-for="(recipe, index) in recipes" v-bind:key="index" v-on:click="$emit('switchRecipe', index)">
|
||||||
<a href="#">{{ recipe.recipe_content.name }} <recipe-rating :recipe="recipe"></recipe-rating> </a>
|
<a href="javascript:void(0)">{{ recipe.recipe_content.name }} <recipe-rating :recipe="recipe"></recipe-rating> </a>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<b-card-text v-if="recipes.length === 0">
|
<b-card-text v-if="recipes.length === 0">
|
||||||
|
@ -116,6 +116,8 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="">
|
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -60,6 +60,18 @@
|
|||||||
:placeholder="$t('Servings')"></b-form-input>
|
:placeholder="$t('Servings')"></b-form-input>
|
||||||
</b-input-group>
|
</b-input-group>
|
||||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
||||||
|
<b-form-group class="mt-3">
|
||||||
|
<generic-multiselect required
|
||||||
|
@change="entryEditing.shared = $event.val" parent_variable="entryEditing.shared"
|
||||||
|
:label="'username'"
|
||||||
|
:model="Models.USER_NAME"
|
||||||
|
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||||
|
v-bind:placeholder="$t('Share')" :limit="10"
|
||||||
|
:multiple="true"
|
||||||
|
:initial_selection="entryEditing.shared"
|
||||||
|
></generic-multiselect>
|
||||||
|
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
|
||||||
|
</b-form-group>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
||||||
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
|
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
|
||||||
@ -103,7 +115,7 @@ export default {
|
|||||||
allow_delete: {
|
allow_delete: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
mixins: [ApiMixin],
|
mixins: [ApiMixin],
|
||||||
components: {
|
components: {
|
||||||
@ -114,7 +126,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
entryEditing: {},
|
entryEditing: {},
|
||||||
missing_recipe: false,
|
missing_recipe: false,
|
||||||
missing_meal_type: false
|
missing_meal_type: false,
|
||||||
|
default_plan_share: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -126,6 +139,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
showModal() {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
|
||||||
|
apiClient.listUserPreferences().then(result => {
|
||||||
|
if (this.entry.id === -1) {
|
||||||
|
this.entryEditing.shared = result.data[0].plan_share
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
editEntry() {
|
editEntry() {
|
||||||
this.missing_meal_type = false
|
this.missing_meal_type = false
|
||||||
this.missing_recipe = false
|
this.missing_recipe = false
|
||||||
@ -155,6 +177,13 @@ export default {
|
|||||||
this.entryEditing.meal_type = null;
|
this.entryEditing.meal_type = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
selectShared(event) {
|
||||||
|
if (event.val != null) {
|
||||||
|
this.entryEditing.shared = event.val;
|
||||||
|
} else {
|
||||||
|
this.entryEditing.meal_type = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
createMealType(event) {
|
createMealType(event) {
|
||||||
if (event != "") {
|
if (event != "") {
|
||||||
let apiClient = new ApiApiFactory()
|
let apiClient = new ApiApiFactory()
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
|
|
||||||
<div class="dropdown d-print-none">
|
<div class="dropdown d-print-none">
|
||||||
<a class="btn shadow-none" href="#" role="button" id="dropdownMenuLink"
|
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink"
|
||||||
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||||
</a>
|
</a>
|
||||||
@ -15,7 +15,7 @@
|
|||||||
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i
|
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i
|
||||||
class="fas fa-exchange-alt fa-fw"></i> {{ $t('convert_internal') }}</a>
|
class="fas fa-exchange-alt fa-fw"></i> {{ $t('convert_internal') }}</a>
|
||||||
|
|
||||||
<a href="#">
|
<a href="javascript:void(0);">
|
||||||
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)">
|
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)">
|
||||||
<i class="fas fa-bookmark fa-fw"></i> {{ $t('Manage_Books') }}
|
<i class="fas fa-bookmark fa-fw"></i> {{ $t('Manage_Books') }}
|
||||||
</button>
|
</button>
|
||||||
@ -26,17 +26,17 @@
|
|||||||
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
|
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="dropdown-item" @click="createMealPlan" href="#"><i
|
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
|
||||||
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
|
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="#">
|
<a href="javascript:void(0);">
|
||||||
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i
|
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i
|
||||||
class="fas fa-clipboard-list fa-fw"></i> {{ $t('Log_Cooking') }}
|
class="fas fa-clipboard-list fa-fw"></i> {{ $t('Log_Cooking') }}
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="#">
|
<a href="javascript:void(0);">
|
||||||
<button class="dropdown-item" onclick="window.print()"><i
|
<button class="dropdown-item" onclick="window.print()"><i
|
||||||
class="fas fa-print fa-fw"></i> {{ $t('Print') }}
|
class="fas fa-print fa-fw"></i> {{ $t('Print') }}
|
||||||
</button>
|
</button>
|
||||||
@ -45,7 +45,7 @@
|
|||||||
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank"
|
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank"
|
||||||
rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t('Export') }}</a>
|
rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t('Export') }}</a>
|
||||||
|
|
||||||
<a href="#">
|
<a href="javascript:void(0);">
|
||||||
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i
|
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i
|
||||||
class="fas fa-share-alt fa-fw"></i> {{ $t('Share') }}
|
class="fas fa-share-alt fa-fw"></i> {{ $t('Share') }}
|
||||||
</button>
|
</button>
|
||||||
|
@ -213,5 +213,6 @@
|
|||||||
"Clear": "Clear",
|
"Clear": "Clear",
|
||||||
"err_move_self": "Cannot move item to itself",
|
"err_move_self": "Cannot move item to itself",
|
||||||
"nothing": "Nothing to do",
|
"nothing": "Nothing to do",
|
||||||
"err_merge_self": "Cannot merge item with itself"
|
"err_merge_self": "Cannot merge item with itself",
|
||||||
|
"show_sql": "Show SQL"
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user