refactor recipe search
This commit is contained in:
@ -3,7 +3,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
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, OuterRef, Q, Subquery, Value, When
|
from django.db.models import Avg, Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When
|
||||||
from django.db.models.functions import Coalesce, Substr
|
from django.db.models.functions import Coalesce, Substr
|
||||||
from django.utils import timezone, translation
|
from django.utils import timezone, translation
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -12,194 +12,446 @@ from cookbook.filters import RecipeFilter
|
|||||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||||
from cookbook.helper.permission_helper import has_group_permission
|
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, Recipe, SearchPreference, ViewLog
|
from cookbook.models import CookLog, Food, Keyword, Recipe, SearchPreference, ViewLog
|
||||||
from recipes import settings
|
from recipes import settings
|
||||||
|
|
||||||
|
|
||||||
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected
|
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected
|
||||||
# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
|
# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
|
||||||
def search_recipes(request, queryset, params):
|
class RecipeSearch():
|
||||||
if request.user.is_authenticated:
|
_postgres = settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
|
||||||
search_prefs = request.user.searchpreference
|
|
||||||
else:
|
|
||||||
search_prefs = SearchPreference()
|
|
||||||
search_string = params.get('query', '').strip()
|
|
||||||
search_rating = int(params.get('rating', 0))
|
|
||||||
search_keywords = params.getlist('keywords', [])
|
|
||||||
search_foods = params.getlist('foods', [])
|
|
||||||
search_books = params.getlist('books', [])
|
|
||||||
search_steps = params.getlist('steps', [])
|
|
||||||
search_units = params.get('units', None)
|
|
||||||
|
|
||||||
search_keywords_or = str2bool(params.get('keywords_or', True))
|
def __init__(self, request, **params):
|
||||||
search_foods_or = str2bool(params.get('foods_or', True))
|
self._request = request
|
||||||
search_books_or = str2bool(params.get('books_or', True))
|
self._queryset = None
|
||||||
|
self._params = {**params}
|
||||||
search_internal = str2bool(params.get('internal', False))
|
if self._request.user.is_authenticated:
|
||||||
search_random = str2bool(params.get('random', False))
|
self._search_prefs = request.user.searchpreference
|
||||||
search_new = str2bool(params.get('new', False))
|
|
||||||
search_last_viewed = int(params.get('last_viewed', 0)) # not included in schema currently?
|
|
||||||
orderby = []
|
|
||||||
|
|
||||||
# only sort by recent not otherwise filtering/sorting
|
|
||||||
if search_last_viewed > 0:
|
|
||||||
last_viewed_recipes = ViewLog.objects.filter(
|
|
||||||
created_by=request.user, space=request.space,
|
|
||||||
created_at__gte=timezone.now() - timedelta(days=14) # TODO make recent days a setting
|
|
||||||
).order_by('-pk').values_list('recipe__pk', flat=True)
|
|
||||||
last_viewed_recipes = list(dict.fromkeys(last_viewed_recipes))[:search_last_viewed] # removes duplicates from list prior to slicing
|
|
||||||
|
|
||||||
# return queryset.annotate(last_view=Max('viewlog__pk')).annotate(new=Case(When(pk__in=last_viewed_recipes, then=('last_view')), default=Value(0))).filter(new__gt=0).order_by('-new')
|
|
||||||
# queryset that only annotates most recent view (higher pk = lastest view)
|
|
||||||
queryset = queryset.annotate(recent=Coalesce(Max(Case(When(viewlog__created_by=request.user, then='viewlog__pk'))), Value(0)))
|
|
||||||
orderby += ['-recent']
|
|
||||||
|
|
||||||
# TODO create setting for default ordering - most cooked, rating,
|
|
||||||
# TODO create options for live sorting
|
|
||||||
# TODO make days of new recipe a setting
|
|
||||||
if search_new:
|
|
||||||
queryset = (
|
|
||||||
queryset.annotate(new_recipe=Case(
|
|
||||||
When(created_at__gte=(timezone.now() - timedelta(days=7)), then=('pk')), default=Value(0), ))
|
|
||||||
)
|
|
||||||
# only sort by new recipes if not otherwise filtering/sorting
|
|
||||||
orderby += ['-new_recipe']
|
|
||||||
|
|
||||||
search_type = search_prefs.search or 'plain'
|
|
||||||
if len(search_string) > 0:
|
|
||||||
unaccent_include = search_prefs.unaccent.values_list('field', flat=True)
|
|
||||||
|
|
||||||
icontains_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.icontains.values_list('field', flat=True)]
|
|
||||||
istartswith_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.istartswith.values_list('field', flat=True)]
|
|
||||||
trigram_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.trigram.values_list('field', flat=True)]
|
|
||||||
fulltext_include = search_prefs.fulltext.values_list('field', flat=True) # fulltext doesn't use field name directly
|
|
||||||
|
|
||||||
# if no filters are configured use name__icontains as default
|
|
||||||
if len(icontains_include) + len(istartswith_include) + len(trigram_include) + len(fulltext_include) == 0:
|
|
||||||
filters = [Q(**{"name__icontains": search_string})]
|
|
||||||
else:
|
else:
|
||||||
filters = []
|
self._search_prefs = SearchPreference()
|
||||||
|
self._string = params.get('query').strip() if params.get('query', None) else None
|
||||||
|
self._rating = self._params.get('rating', None)
|
||||||
|
self._keywords = self._params.get('keywords', None)
|
||||||
|
self._foods = self._params.get('foods', None)
|
||||||
|
self._books = self._params.get('books', None)
|
||||||
|
self._steps = self._params.get('steps', None)
|
||||||
|
self._units = self._params.get('units', None)
|
||||||
|
# TODO add created by
|
||||||
|
# TODO add created before/after
|
||||||
|
# TODO image exists
|
||||||
|
self._sort_order = self._params.get('sort_order', None)
|
||||||
|
# TODO add save
|
||||||
|
|
||||||
# dynamically build array of filters that will be applied
|
self._keywords_or = str2bool(self._params.get('keywords_or', True))
|
||||||
for f in icontains_include:
|
self._foods_or = str2bool(self._params.get('foods_or', True))
|
||||||
filters += [Q(**{"%s__icontains" % f: search_string})]
|
self._books_or = str2bool(self._params.get('books_or', True))
|
||||||
|
|
||||||
for f in istartswith_include:
|
self._internal = str2bool(self._params.get('internal', False))
|
||||||
filters += [Q(**{"%s__istartswith" % f: search_string})]
|
self._random = str2bool(self._params.get('random', False))
|
||||||
|
self._new = str2bool(self._params.get('new', False))
|
||||||
|
self._last_viewed = int(self._params.get('last_viewed', 0))
|
||||||
|
|
||||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
|
self._search_type = self._search_prefs.search or 'plain'
|
||||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
if self._string:
|
||||||
# django full text search https://docs.djangoproject.com/en/3.2/ref/contrib/postgres/search/#searchquery
|
unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True)
|
||||||
# TODO can options install this extension to further enhance search query language https://github.com/caub/pg-tsquery
|
self._icontains_include = [x + '__unaccent' if x in unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)]
|
||||||
# trigram breaks full text search 'websearch' and 'raw' capabilities and will be ignored if those methods are chosen
|
self._istartswith_include = [x + '__unaccent' if x in unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
|
||||||
if search_type in ['websearch', 'raw']:
|
self._trigram_include = None
|
||||||
search_trigram = False
|
self._fulltext_include = None
|
||||||
else:
|
self._trigram = False
|
||||||
search_trigram = True
|
if self._postgres and self._string:
|
||||||
search_query = SearchQuery(
|
self._language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||||
search_string,
|
self._trigram_include = [x + '__unaccent' if x in unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)]
|
||||||
search_type=search_type,
|
self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True)
|
||||||
config=language,
|
|
||||||
|
if self._search_type not in ['websearch', 'raw'] and self._trigram_include:
|
||||||
|
self._trigram = True
|
||||||
|
self.search_query = SearchQuery(
|
||||||
|
self._string,
|
||||||
|
search_type=self._search_type,
|
||||||
|
config=self._language,
|
||||||
)
|
)
|
||||||
|
self.search_rank = (
|
||||||
|
SearchRank('name_search_vector', self.search_query, cover_density=True)
|
||||||
|
+ SearchRank('desc_search_vector', self.search_query, cover_density=True)
|
||||||
|
+ SearchRank('steps__search_vector', self.search_query, cover_density=True)
|
||||||
|
)
|
||||||
|
self.orderby = []
|
||||||
|
self._default_sort = ['-favorite'] # TODO add user setting
|
||||||
|
self._filters = None
|
||||||
|
self._fuzzy_match = None
|
||||||
|
|
||||||
# iterate through fields to use in trigrams generating a single trigram
|
def get_queryset(self, queryset):
|
||||||
if search_trigram and len(trigram_include) > 0:
|
self._queryset = queryset
|
||||||
trigram = None
|
self.recently_viewed_recipes(self._last_viewed)
|
||||||
for f in trigram_include:
|
self._favorite_recipes()
|
||||||
if trigram:
|
# self._last_viewed()
|
||||||
trigram += TrigramSimilarity(f, search_string)
|
# self._last_cooked()
|
||||||
else:
|
self.keyword_filters(keywords=self._keywords, operator=self._keywords_or)
|
||||||
trigram = TrigramSimilarity(f, search_string)
|
self.food_filters(foods=self._foods, operator=self._foods_or)
|
||||||
queryset = queryset.annotate(similarity=trigram)
|
self.book_filters(books=self._books, operator=self._books_or)
|
||||||
filters += [Q(similarity__gt=search_prefs.trigram_threshold)]
|
self.rating_filter(rating=self._rating)
|
||||||
|
self.internal_filter()
|
||||||
|
self.step_filters(steps=self._steps)
|
||||||
|
self.unit_filters(units=self._units)
|
||||||
|
self.string_filters(string=self._string)
|
||||||
|
# self._queryset = self._queryset.distinct() # TODO 2x check. maybe add filter of recipe__in after orderby
|
||||||
|
self._apply_order_by()
|
||||||
|
return self._queryset.filter(space=self._request.space)
|
||||||
|
|
||||||
if 'name' in fulltext_include:
|
def _apply_order_by(self):
|
||||||
filters += [Q(name_search_vector=search_query)]
|
if self._random:
|
||||||
if 'description' in fulltext_include:
|
self._queryset = self._queryset.order_by("?")
|
||||||
filters += [Q(desc_search_vector=search_query)]
|
else:
|
||||||
if 'instructions' in fulltext_include:
|
if self._sort_order:
|
||||||
filters += [Q(steps__search_vector=search_query)]
|
self._queryset.order_by(*self._sort_order)
|
||||||
if 'keywords' in fulltext_include:
|
return
|
||||||
filters += [Q(keywords__in=Subquery(Keyword.objects.filter(name__search=search_query).values_list('id', flat=True)))]
|
|
||||||
if 'foods' in fulltext_include:
|
order = [] # TODO add user preferences here: name, date cooked, rating, times cooked, date created, date viewed, random
|
||||||
filters += [Q(steps__ingredients__food__in=Subquery(Food.objects.filter(name__search=search_query).values_list('id', flat=True)))]
|
if '-recent' in self.orderby and self._last_viewed:
|
||||||
|
order += ['-recent']
|
||||||
|
|
||||||
|
if '-rank' in self.orderby and '-simularity' in self.orderby:
|
||||||
|
self._queryset = self._queryset.annotate(score=Sum(F('rank')+F('simularity')))
|
||||||
|
order += ['-score']
|
||||||
|
elif '-rank' in self.orderby:
|
||||||
|
self._queryset = self._queryset.annotate(score=F('rank'))
|
||||||
|
order += ['-score']
|
||||||
|
elif '-simularity' in self.orderby:
|
||||||
|
self._queryset = self._queryset.annotate(score=F('simularity'))
|
||||||
|
order += ['-score']
|
||||||
|
for x in list(set(self.orderby)-set([*order, '-rank', '-simularity'])):
|
||||||
|
order += [x]
|
||||||
|
self._queryset = self._queryset.order_by(*order)
|
||||||
|
|
||||||
|
def string_filters(self, string=None):
|
||||||
|
if not string:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.build_text_filters(self._string)
|
||||||
|
if self._postgres:
|
||||||
|
self.build_fulltext_filters(self._string)
|
||||||
|
self.build_trigram(self._string)
|
||||||
|
|
||||||
|
if self._filters:
|
||||||
query_filter = None
|
query_filter = None
|
||||||
for f in filters:
|
for f in self._filters:
|
||||||
if query_filter:
|
if query_filter:
|
||||||
query_filter |= f
|
query_filter |= f
|
||||||
else:
|
else:
|
||||||
query_filter = f
|
query_filter = f
|
||||||
|
self._queryset = self._queryset.filter(query_filter).distinct()
|
||||||
|
# TODO add annotation for simularity
|
||||||
|
if self._fulltext_include:
|
||||||
|
self._queryset = self._queryset.annotate(rank=self.search_rank)
|
||||||
|
self.orderby += ['-rank']
|
||||||
|
|
||||||
# TODO add order by user settings - only do search rank and annotation if rank order is configured
|
if self._fuzzy_match is not None: # this annotation is full text, not trigram
|
||||||
search_rank = (
|
simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity')
|
||||||
SearchRank('name_search_vector', search_query, cover_density=True)
|
self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
|
||||||
+ SearchRank('desc_search_vector', search_query, cover_density=True)
|
self.orderby += ['-simularity']
|
||||||
+ SearchRank('steps__search_vector', search_query, cover_density=True)
|
|
||||||
)
|
|
||||||
queryset = queryset.filter(query_filter).annotate(rank=search_rank)
|
|
||||||
orderby += ['-rank']
|
|
||||||
else:
|
else:
|
||||||
queryset = queryset.filter(name__icontains=search_string)
|
self._queryset = self._queryset.filter(name__icontains=self._string)
|
||||||
|
|
||||||
if len(search_keywords) > 0:
|
def recently_viewed_recipes(self, last_viewed=None):
|
||||||
if search_keywords_or:
|
if not last_viewed:
|
||||||
|
return
|
||||||
|
|
||||||
|
last_viewed_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space).values(
|
||||||
|
'recipe').annotate(recent=Max('created_at')).order_by('-recent')
|
||||||
|
last_viewed_recipes = last_viewed_recipes[:last_viewed]
|
||||||
|
self.orderby += ['-recent']
|
||||||
|
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=last_viewed_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
|
||||||
|
|
||||||
|
def _favorite_recipes(self):
|
||||||
|
self.orderby += ['-favorite'] # default sort?
|
||||||
|
favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk')
|
||||||
|
).values('recipe').annotate(count=Count('pk', distinct=True)).values('count')
|
||||||
|
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), 0))
|
||||||
|
|
||||||
|
def keyword_filters(self, keywords=None, operator=True):
|
||||||
|
if not keywords:
|
||||||
|
return
|
||||||
|
if operator == True:
|
||||||
# TODO creating setting to include descendants of keywords a setting
|
# TODO creating setting to include descendants of keywords a setting
|
||||||
# for kw in Keyword.objects.filter(pk__in=search_keywords):
|
self._queryset = self._queryset.filter(keywords__in=Keyword.include_descendants(Keyword.objects.filter(pk__in=keywords)))
|
||||||
# search_keywords += list(kw.get_descendants().values_list('pk', flat=True))
|
|
||||||
for kw in Keyword.objects.filter(pk__in=search_keywords):
|
|
||||||
search_keywords = [*search_keywords, *list(kw.get_descendants_and_self().values_list('pk', flat=True))]
|
|
||||||
queryset = queryset.filter(keywords__id__in=search_keywords)
|
|
||||||
else:
|
else:
|
||||||
# when performing an 'and' search returned recipes should include a parent OR any of its descedants
|
# when performing an 'and' search returned recipes should include a parent OR any of its descedants
|
||||||
# AND other keywords selected so filters are appended using keyword__id__in the list of keywords and descendants
|
# AND other keywords selected so filters are appended using keyword__id__in the list of keywords and descendants
|
||||||
for kw in Keyword.objects.filter(pk__in=search_keywords):
|
for kw in Keyword.objects.filter(pk__in=keywords):
|
||||||
queryset = queryset.filter(keywords__id__in=list(kw.get_descendants_and_self().values_list('pk', flat=True)))
|
self._queryset = self._queryset.filter(keywords__in=list(kw.get_descendants_and_self()))
|
||||||
|
|
||||||
if len(search_foods) > 0:
|
def food_filters(self, foods=None, operator=True):
|
||||||
if search_foods_or:
|
if not foods:
|
||||||
|
return
|
||||||
|
if operator == True:
|
||||||
# TODO creating setting to include descendants of food a setting
|
# TODO creating setting to include descendants of food a setting
|
||||||
for fd in Food.objects.filter(pk__in=search_foods):
|
self._queryset = self._queryset.filter(steps__ingredients__food__in=Food.include_descendants(Food.objects.filter(pk__in=foods)))
|
||||||
search_foods = [*search_foods, *list(fd.get_descendants_and_self().values_list('pk', flat=True))]
|
|
||||||
queryset = queryset.filter(steps__ingredients__food__id__in=search_foods)
|
|
||||||
else:
|
else:
|
||||||
# when performing an 'and' search returned recipes should include a parent OR any of its descedants
|
# when performing an 'and' search returned recipes should include a parent OR any of its descedants
|
||||||
# AND other foods selected so filters are appended using steps__ingredients__food__id__in the list of foods and descendants
|
# AND other foods selected so filters are appended using steps__ingredients__food__id__in the list of foods and descendants
|
||||||
for fd in Food.objects.filter(pk__in=search_foods):
|
for fd in Food.objects.filter(pk__in=foods):
|
||||||
queryset = queryset.filter(steps__ingredients__food__id__in=list(fd.get_descendants_and_self().values_list('pk', flat=True)))
|
self._queryset = self._queryset.filter(steps__ingredients__food__in=list(fd.get_descendants_and_self()))
|
||||||
|
|
||||||
if len(search_books) > 0:
|
def unit_filters(self, units=None, operator=True):
|
||||||
if search_books_or:
|
if operator != True:
|
||||||
queryset = queryset.filter(recipebookentry__book__id__in=search_books)
|
raise NotImplementedError
|
||||||
else:
|
if not units:
|
||||||
for k in search_books:
|
return
|
||||||
queryset = queryset.filter(recipebookentry__book__id=k)
|
self._queryset = self._queryset.filter(steps__ingredients__unit__id=units)
|
||||||
|
|
||||||
if search_rating:
|
def rating_filter(self, rating=None):
|
||||||
|
if rating is None:
|
||||||
|
return
|
||||||
|
rating = int(rating)
|
||||||
# TODO make ratings a settings user-only vs all-users
|
# TODO make ratings a settings user-only vs all-users
|
||||||
queryset = queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=request.user, then='cooklog__rating'), default=Value(0)))))
|
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0)))))
|
||||||
if search_rating == -1:
|
if rating == 0:
|
||||||
queryset = queryset.filter(rating=0)
|
self._queryset = self._queryset.filter(rating=0)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.filter(rating__gte=search_rating)
|
self._queryset = self._queryset.filter(rating__gte=rating)
|
||||||
|
|
||||||
# probably only useful in Unit list view, so keeping it simple
|
def internal_filter(self):
|
||||||
if search_units:
|
self._queryset = self._queryset.filter(internal=True)
|
||||||
queryset = queryset.filter(steps__ingredients__unit__id=search_units)
|
|
||||||
|
|
||||||
# probably only useful in Unit list view, so keeping it simple
|
def book_filters(self, books=None, operator=True):
|
||||||
if search_steps:
|
if not books:
|
||||||
queryset = queryset.filter(steps__id__in=search_steps)
|
return
|
||||||
|
if operator == True:
|
||||||
|
self._queryset = self._queryset.filter(recipebookentry__book__id__in=books)
|
||||||
|
else:
|
||||||
|
for k in books:
|
||||||
|
self._queryset = self._queryset.filter(recipebookentry__book__id=k)
|
||||||
|
|
||||||
if search_internal:
|
def step_filters(self, steps=None, operator=True):
|
||||||
queryset = queryset.filter(internal=True)
|
if operator != True:
|
||||||
|
raise NotImplementedError
|
||||||
|
if not steps:
|
||||||
|
return
|
||||||
|
self._queryset = self._queryset.filter(steps__id__in=steps)
|
||||||
|
|
||||||
queryset = queryset.distinct()
|
def build_fulltext_filters(self, string=None):
|
||||||
|
if not string:
|
||||||
|
return
|
||||||
|
if self._fulltext_include:
|
||||||
|
if not self._filters:
|
||||||
|
self._filters = []
|
||||||
|
if 'name' in self._fulltext_include:
|
||||||
|
self._filters += [Q(name_search_vector=self.search_query)]
|
||||||
|
if 'description' in self._fulltext_include:
|
||||||
|
self._filters += [Q(desc_search_vector=self.search_query)]
|
||||||
|
if 'steps__instruction' in self._fulltext_include:
|
||||||
|
self._filters += [Q(steps__search_vector=self.search_query)]
|
||||||
|
if 'keywords__name' in self._fulltext_include:
|
||||||
|
self._filters += [Q(keywords__in=Keyword.objects.filter(name__search=self.search_query))]
|
||||||
|
if 'steps__ingredients__food__name' in self._fulltext_include:
|
||||||
|
self._filters += [Q(steps__ingredients__food__in=Food.objects.filter(name__search=self.search_query))]
|
||||||
|
|
||||||
if search_random:
|
def build_text_filters(self, string=None):
|
||||||
queryset = queryset.order_by("?")
|
if not string:
|
||||||
else:
|
return
|
||||||
queryset = queryset.order_by(*orderby)
|
|
||||||
return queryset
|
if not self._filters:
|
||||||
|
self._filters = []
|
||||||
|
# dynamically build array of filters that will be applied
|
||||||
|
for f in self._icontains_include:
|
||||||
|
self._filters += [Q(**{"%s__icontains" % f: self._string})]
|
||||||
|
|
||||||
|
for f in self._istartswith_include:
|
||||||
|
self._filters += [Q(**{"%s__istartswith" % f: self._string})]
|
||||||
|
|
||||||
|
def build_trigram(self, string=None):
|
||||||
|
if not string:
|
||||||
|
return
|
||||||
|
if self._trigram:
|
||||||
|
trigram = None
|
||||||
|
for f in self._trigram_include:
|
||||||
|
if trigram:
|
||||||
|
trigram += TrigramSimilarity(f, self._string)
|
||||||
|
else:
|
||||||
|
trigram = TrigramSimilarity(f, self._string)
|
||||||
|
self._fuzzy_match = Recipe.objects.annotate(trigram=trigram).distinct(
|
||||||
|
).annotate(simularity=Max('trigram')).values('id', 'simularity').filter(simularity__gt=self._search_prefs.trigram_threshold)
|
||||||
|
self._filters += [Q(pk__in=self._fuzzy_match.values('pk'))]
|
||||||
|
|
||||||
|
|
||||||
|
# def search_recipes(request, queryset, params):
|
||||||
|
# if request.user.is_authenticated:
|
||||||
|
# search_prefs = request.user.searchpreference
|
||||||
|
# else:
|
||||||
|
# search_prefs = SearchPreference()
|
||||||
|
# search_string = params.get('query', '').strip()
|
||||||
|
# search_rating = int(params.get('rating', 0))
|
||||||
|
# search_keywords = params.getlist('keywords', [])
|
||||||
|
# search_foods = params.getlist('foods', [])
|
||||||
|
# search_books = params.getlist('books', [])
|
||||||
|
# search_steps = params.getlist('steps', [])
|
||||||
|
# search_units = params.get('units', None)
|
||||||
|
|
||||||
|
# search_keywords_or = str2bool(params.get('keywords_or', True))
|
||||||
|
# search_foods_or = str2bool(params.get('foods_or', True))
|
||||||
|
# search_books_or = str2bool(params.get('books_or', True))
|
||||||
|
|
||||||
|
# search_internal = str2bool(params.get('internal', False))
|
||||||
|
# search_random = str2bool(params.get('random', False))
|
||||||
|
# search_new = str2bool(params.get('new', False))
|
||||||
|
# search_last_viewed = int(params.get('last_viewed', 0)) # not included in schema currently?
|
||||||
|
# orderby = []
|
||||||
|
|
||||||
|
# # only sort by recent not otherwise filtering/sorting
|
||||||
|
# if search_last_viewed > 0:
|
||||||
|
# last_viewed_recipes = ViewLog.objects.filter(created_by=request.user, space=request.space).values('recipe').annotate(recent=Max('created_at')).order_by('-recent')[:search_last_viewed]
|
||||||
|
# queryset = queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=last_viewed_recipes.values('recipe'), then='viewlog__pk'))), Value(0))).order_by('-recent')
|
||||||
|
# orderby += ['-recent']
|
||||||
|
# # TODO add sort by favorite
|
||||||
|
# favorite_recipes = CookLog.objects.filter(created_by=request.user, space=request.space, recipe=OuterRef('pk')).values('recipe').annotate(count=Count('pk', distinct=True)).values('count')
|
||||||
|
# # TODO add to serialization and RecipeCard and RecipeView
|
||||||
|
# queryset = queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), 0))
|
||||||
|
|
||||||
|
# # TODO create setting for default ordering - most cooked, rating,
|
||||||
|
# # TODO create options for live sorting
|
||||||
|
# # TODO make days of new recipe a setting
|
||||||
|
# if search_new:
|
||||||
|
# queryset = (
|
||||||
|
# queryset.annotate(new_recipe=Case(
|
||||||
|
# When(created_at__gte=(timezone.now() - timedelta(days=7)), then=('pk')), default=Value(0), ))
|
||||||
|
# )
|
||||||
|
# # TODO create setting for 'new' recipes
|
||||||
|
# # only sort by new recipes if not otherwise filtering/sorting
|
||||||
|
# orderby += ['-new_recipe']
|
||||||
|
# orderby += ['-favorite']
|
||||||
|
|
||||||
|
# search_type = search_prefs.search or 'plain'
|
||||||
|
# if len(search_string) > 0:
|
||||||
|
# unaccent_include = search_prefs.unaccent.values_list('field', flat=True)
|
||||||
|
|
||||||
|
# icontains_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.icontains.values_list('field', flat=True)]
|
||||||
|
# istartswith_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.istartswith.values_list('field', flat=True)]
|
||||||
|
# trigram_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.trigram.values_list('field', flat=True)]
|
||||||
|
# fulltext_include = search_prefs.fulltext.values_list('field', flat=True) # fulltext doesn't use field name directly
|
||||||
|
|
||||||
|
# # if no filters are configured use name__icontains as default
|
||||||
|
# if icontains_include or istartswith_include or trigram_include or fulltext_include:
|
||||||
|
# filters = [Q(**{"name__icontains": search_string})]
|
||||||
|
# else:
|
||||||
|
# filters = []
|
||||||
|
|
||||||
|
# # dynamically build array of filters that will be applied
|
||||||
|
# for f in icontains_include:
|
||||||
|
# filters += [Q(**{"%s__icontains" % f: search_string})]
|
||||||
|
|
||||||
|
# for f in istartswith_include:
|
||||||
|
# filters += [Q(**{"%s__istartswith" % f: search_string})]
|
||||||
|
|
||||||
|
# if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
|
||||||
|
# language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||||
|
# # django full text search https://docs.djangoproject.com/en/3.2/ref/contrib/postgres/search/#searchquery
|
||||||
|
# # TODO can options install this extension to further enhance search query language https://github.com/caub/pg-tsquery
|
||||||
|
# # trigram breaks full text search 'websearch' and 'raw' capabilities and will be ignored if those methods are chosen
|
||||||
|
# if search_type in ['websearch', 'raw']:
|
||||||
|
# search_trigram = False
|
||||||
|
# else:
|
||||||
|
# search_trigram = True
|
||||||
|
# search_query = SearchQuery(
|
||||||
|
# search_string,
|
||||||
|
# search_type=search_type,
|
||||||
|
# config=language,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # iterate through fields to use in trigrams generating a single trigram
|
||||||
|
# if search_trigram and len(trigram_include) > 0:
|
||||||
|
# trigram = None
|
||||||
|
# for f in trigram_include:
|
||||||
|
# if trigram:
|
||||||
|
# trigram += TrigramSimilarity(f, search_string)
|
||||||
|
# else:
|
||||||
|
# trigram = TrigramSimilarity(f, search_string)
|
||||||
|
# queryset = queryset.annotate(similarity=trigram)
|
||||||
|
# filters += [Q(similarity__gt=search_prefs.trigram_threshold)]
|
||||||
|
|
||||||
|
# if 'name' in fulltext_include:
|
||||||
|
# filters += [Q(name_search_vector=search_query)]
|
||||||
|
# if 'description' in fulltext_include:
|
||||||
|
# filters += [Q(desc_search_vector=search_query)]
|
||||||
|
# if 'instructions' in fulltext_include:
|
||||||
|
# filters += [Q(steps__search_vector=search_query)]
|
||||||
|
# if 'keywords' in fulltext_include:
|
||||||
|
# filters += [Q(keywords__in=Subquery(Keyword.objects.filter(name__search=search_query).values_list('id', flat=True)))]
|
||||||
|
# if 'foods' in fulltext_include:
|
||||||
|
# filters += [Q(steps__ingredients__food__in=Subquery(Food.objects.filter(name__search=search_query).values_list('id', flat=True)))]
|
||||||
|
# query_filter = None
|
||||||
|
# for f in filters:
|
||||||
|
# if query_filter:
|
||||||
|
# query_filter |= f
|
||||||
|
# else:
|
||||||
|
# query_filter = f
|
||||||
|
|
||||||
|
# # TODO add order by user settings - only do search rank and annotation if rank order is configured
|
||||||
|
# search_rank = (
|
||||||
|
# SearchRank('name_search_vector', search_query, cover_density=True)
|
||||||
|
# + SearchRank('desc_search_vector', search_query, cover_density=True)
|
||||||
|
# + SearchRank('steps__search_vector', search_query, cover_density=True)
|
||||||
|
# )
|
||||||
|
# queryset = queryset.filter(query_filter).annotate(rank=search_rank)
|
||||||
|
# orderby += ['-rank']
|
||||||
|
# else:
|
||||||
|
# queryset = queryset.filter(name__icontains=search_string)
|
||||||
|
|
||||||
|
# if len(search_keywords) > 0:
|
||||||
|
# if search_keywords_or:
|
||||||
|
# # TODO creating setting to include descendants of keywords a setting
|
||||||
|
# # for kw in Keyword.objects.filter(pk__in=search_keywords):
|
||||||
|
# # search_keywords += list(kw.get_descendants().values_list('pk', flat=True))
|
||||||
|
# queryset = queryset.filter(keywords__in=Keyword.include_descendants(Keyword.objects.filter(pk__in=search_keywords)))
|
||||||
|
# else:
|
||||||
|
# # when performing an 'and' search returned recipes should include a parent OR any of its descedants
|
||||||
|
# # AND other keywords selected so filters are appended using keyword__id__in the list of keywords and descendants
|
||||||
|
# for kw in Keyword.objects.filter(pk__in=search_keywords):
|
||||||
|
# queryset = queryset.filter(keywords__in=list(kw.get_descendants_and_self()))
|
||||||
|
|
||||||
|
# if len(search_foods) > 0:
|
||||||
|
# if search_foods_or:
|
||||||
|
# # TODO creating setting to include descendants of food a setting
|
||||||
|
# queryset = queryset.filter(steps__ingredients__food__in=Food.include_descendants(Food.objects.filter(pk__in=search_foods)))
|
||||||
|
# else:
|
||||||
|
# # when performing an 'and' search returned recipes should include a parent OR any of its descedants
|
||||||
|
# # AND other foods selected so filters are appended using steps__ingredients__food__id__in the list of foods and descendants
|
||||||
|
# for fd in Food.objects.filter(pk__in=search_foods):
|
||||||
|
# queryset = queryset.filter(steps__ingredients__food__in=list(fd.get_descendants_and_self()))
|
||||||
|
|
||||||
|
# if len(search_books) > 0:
|
||||||
|
# if search_books_or:
|
||||||
|
# queryset = queryset.filter(recipebookentry__book__id__in=search_books)
|
||||||
|
# else:
|
||||||
|
# for k in search_books:
|
||||||
|
# queryset = queryset.filter(recipebookentry__book__id=k)
|
||||||
|
|
||||||
|
# if search_rating:
|
||||||
|
# # TODO make ratings a settings user-only vs all-users
|
||||||
|
# queryset = queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=request.user, then='cooklog__rating'), default=Value(0)))))
|
||||||
|
# if search_rating == -1:
|
||||||
|
# queryset = queryset.filter(rating=0)
|
||||||
|
# else:
|
||||||
|
# queryset = queryset.filter(rating__gte=search_rating)
|
||||||
|
|
||||||
|
# # probably only useful in Unit list view, so keeping it simple
|
||||||
|
# if search_units:
|
||||||
|
# queryset = queryset.filter(steps__ingredients__unit__id=search_units)
|
||||||
|
|
||||||
|
# # probably only useful in Unit list view, so keeping it simple
|
||||||
|
# if search_steps:
|
||||||
|
# queryset = queryset.filter(steps__id__in=search_steps)
|
||||||
|
|
||||||
|
# if search_internal:
|
||||||
|
# queryset = queryset.filter(internal=True)
|
||||||
|
|
||||||
|
# queryset = queryset.distinct()
|
||||||
|
|
||||||
|
# if search_random:
|
||||||
|
# queryset = queryset.order_by("?")
|
||||||
|
# else:
|
||||||
|
# queryset = queryset.order_by(*orderby)
|
||||||
|
# return queryset
|
||||||
|
|
||||||
|
|
||||||
class RecipeFacet():
|
class RecipeFacet():
|
||||||
@ -223,6 +475,7 @@ class RecipeFacet():
|
|||||||
self.Foods = self._cache.get('Foods', None)
|
self.Foods = self._cache.get('Foods', None)
|
||||||
self.Books = self._cache.get('Books', None)
|
self.Books = self._cache.get('Books', None)
|
||||||
self.Ratings = self._cache.get('Ratings', None)
|
self.Ratings = self._cache.get('Ratings', None)
|
||||||
|
# TODO Move Recent to recipe annotation/serializer: requrires change in RecipeSearch(), RecipeSearchView.vue and serializer
|
||||||
self.Recent = self._cache.get('Recent', None)
|
self.Recent = self._cache.get('Recent', None)
|
||||||
|
|
||||||
if self._queryset is not None:
|
if self._queryset is not None:
|
||||||
@ -666,4 +919,10 @@ def old_search(request):
|
|||||||
# other[name] = [*other.get(name, []), x.name]
|
# other[name] = [*other.get(name, []), x.name]
|
||||||
# if x.hidden:
|
# if x.hidden:
|
||||||
# hidden[name] = [*hidden.get(name, []), x.name]
|
# hidden[name] = [*hidden.get(name, []), x.name]
|
||||||
# print('---', x.name, ' - ', x.db_type, x.remote_name)
|
# print('---', x.name, ' - ', x.db_type)
|
||||||
|
# for field_type in [(char, 'char'), (number, 'number'), (other, 'other'), (date, 'date'), (image, 'image'), (one_to_many, 'one_to_many'), (many_to_one, 'many_to_one'), (many_to_many, 'many_to_many')]:
|
||||||
|
# print(f"{field_type[1]}:")
|
||||||
|
# for model in field_type[0]:
|
||||||
|
# print(f"--{model}")
|
||||||
|
# for field in field_type[0][model]:
|
||||||
|
# print(f" --{field}")
|
||||||
|
@ -151,6 +151,7 @@ class TreeModel(MP_Node):
|
|||||||
return super().add_root(**kwargs)
|
return super().add_root(**kwargs)
|
||||||
|
|
||||||
# i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
|
# i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
|
||||||
|
@staticmethod
|
||||||
def include_descendants(queryset=None, filter=None):
|
def include_descendants(queryset=None, filter=None):
|
||||||
"""
|
"""
|
||||||
:param queryset: Model Queryset to add descendants
|
:param queryset: Model Queryset to add descendants
|
||||||
@ -1095,98 +1096,81 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
|
|||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
|
||||||
class ModelFilter(models.Model):
|
# class ModelFilter(models.Model):
|
||||||
EQUAL = 'EQUAL'
|
# EQUAL = 'EQUAL'
|
||||||
NOT_EQUAL = 'NOT_EQUAL'
|
# LESS_THAN = 'LESS_THAN'
|
||||||
LESS_THAN = 'LESS_THAN'
|
# GREATER_THAN = 'GREATER_THAN'
|
||||||
GREATER_THAN = 'GREATER_THAN'
|
# LESS_THAN_EQ = 'LESS_THAN_EQ'
|
||||||
LESS_THAN_EQ = 'LESS_THAN_EQ'
|
# GREATER_THAN_EQ = 'GREATER_THAN_EQ'
|
||||||
GREATER_THAN_EQ = 'GREATER_THAN_EQ'
|
# CONTAINS = 'CONTAINS'
|
||||||
CONTAINS = 'CONTAINS'
|
# STARTS_WITH = 'STARTS_WITH'
|
||||||
NOT_CONTAINS = 'NOT_CONTAINS'
|
# ENDS_WITH = 'ENDS_WITH'
|
||||||
STARTS_WITH = 'STARTS_WITH'
|
# INCLUDES = 'INCLUDES'
|
||||||
NOT_STARTS_WITH = 'NOT_STARTS_WITH'
|
|
||||||
ENDS_WITH = 'ENDS_WITH'
|
|
||||||
NOT_ENDS_WITH = 'NOT_ENDS_WITH'
|
|
||||||
INCLUDES = 'INCLUDES'
|
|
||||||
NOT_INCLUDES = 'NOT_INCLUDES'
|
|
||||||
COUNT_EQ = 'COUNT_EQ'
|
|
||||||
COUNT_NEQ = 'COUNT_NEQ'
|
|
||||||
COUNT_LT = 'COUNT_LT'
|
|
||||||
COUNT_GT = 'COUNT_GT'
|
|
||||||
|
|
||||||
OPERATION = (
|
# OPERATION = (
|
||||||
(EQUAL, _('is')),
|
# (EQUAL, _('is')),
|
||||||
(NOT_EQUAL, _('is not')),
|
# (LESS_THAN, _('less than')),
|
||||||
(LESS_THAN, _('less than')),
|
# (GREATER_THAN, _('greater than')),
|
||||||
(GREATER_THAN, _('greater than')),
|
# (LESS_THAN_EQ, _('less or equal')),
|
||||||
(LESS_THAN_EQ, _('less or equal')),
|
# (GREATER_THAN_EQ, _('greater or equal')),
|
||||||
(GREATER_THAN_EQ, _('greater or equal')),
|
# (CONTAINS, _('contains')),
|
||||||
(CONTAINS, _('contains')),
|
# (STARTS_WITH, _('starts with')),
|
||||||
(NOT_CONTAINS, _('does not contain')),
|
# (INCLUDES, _('includes')),
|
||||||
(STARTS_WITH, _('starts with')),
|
# )
|
||||||
(NOT_STARTS_WITH, _('does not start with')),
|
|
||||||
(INCLUDES, _('includes')),
|
|
||||||
(NOT_INCLUDES, _('does not include')),
|
|
||||||
(COUNT_EQ, _('count equals')),
|
|
||||||
(COUNT_NEQ, _('count does not equal')),
|
|
||||||
(COUNT_LT, _('count less than')),
|
|
||||||
(COUNT_GT, _('count greater than')),
|
|
||||||
)
|
|
||||||
|
|
||||||
STRING = 'STRING'
|
# STRING = 'STRING'
|
||||||
NUMBER = 'NUMBER'
|
# NUMBER = 'NUMBER'
|
||||||
BOOLEAN = 'BOOLEAN'
|
# BOOLEAN = 'BOOLEAN'
|
||||||
DATE = 'DATE'
|
# DATE = 'DATE'
|
||||||
|
|
||||||
FIELD_TYPE = (
|
# FIELD_TYPE = (
|
||||||
(STRING, _('string')),
|
# (STRING, _('string')),
|
||||||
(NUMBER, _('number')),
|
# (NUMBER, _('number')),
|
||||||
(BOOLEAN, _('boolean')),
|
# (BOOLEAN, _('boolean')),
|
||||||
(DATE, _('date')),
|
# (DATE, _('date')),
|
||||||
)
|
# )
|
||||||
|
|
||||||
field = models.CharField(max_length=32)
|
# field = models.CharField(max_length=32)
|
||||||
field_type = models.CharField(max_length=32, choices=(FIELD_TYPE))
|
# field_type = models.CharField(max_length=32, choices=(FIELD_TYPE))
|
||||||
operation = models.CharField(max_length=32, choices=(OPERATION))
|
# operation = models.CharField(max_length=32, choices=(OPERATION))
|
||||||
negate = models.BooleanField(default=False,)
|
# negate = models.BooleanField(default=False,)
|
||||||
target_value = models.CharField(max_length=128)
|
# target_value = models.CharField(max_length=128)
|
||||||
sort = models.BooleanField(default=False,)
|
# sort = models.BooleanField(default=False,)
|
||||||
ascending = models.BooleanField(default=True,)
|
# ascending = models.BooleanField(default=True,)
|
||||||
|
|
||||||
def __str__(self):
|
# def __str__(self):
|
||||||
return f"{self.field} - {self.operation} - {self.target_value}"
|
# return f"{self.field} - {self.operation} - {self.target_value}"
|
||||||
|
|
||||||
|
|
||||||
class SavedFilter(models.Model, PermissionModelMixin):
|
# class SavedFilter(models.Model, PermissionModelMixin):
|
||||||
FOOD = 'FOOD'
|
# FOOD = 'FOOD'
|
||||||
UNIT = 'UNIT'
|
# UNIT = 'UNIT'
|
||||||
KEYWORD = "KEYWORD"
|
# KEYWORD = "KEYWORD"
|
||||||
RECIPE = 'RECIPE'
|
# RECIPE = 'RECIPE'
|
||||||
BOOK = 'BOOK'
|
# BOOK = 'BOOK'
|
||||||
|
|
||||||
MODELS = (
|
# MODELS = (
|
||||||
(FOOD, _('Food')),
|
# (FOOD, _('Food')),
|
||||||
(UNIT, _('Unit')),
|
# (UNIT, _('Unit')),
|
||||||
(KEYWORD, _('Keyword')),
|
# (KEYWORD, _('Keyword')),
|
||||||
(RECIPE, _('Recipe')),
|
# (RECIPE, _('Recipe')),
|
||||||
(BOOK, _('Book'))
|
# (BOOK, _('Book'))
|
||||||
)
|
# )
|
||||||
|
|
||||||
name = models.CharField(max_length=128, )
|
# name = models.CharField(max_length=128, )
|
||||||
type = models.CharField(max_length=24, choices=(MODELS)),
|
# type = models.CharField(max_length=24, choices=(MODELS)),
|
||||||
description = models.CharField(max_length=256, blank=True)
|
# description = models.CharField(max_length=256, blank=True)
|
||||||
shared = models.ManyToManyField(User, blank=True, related_name='filter_share')
|
# shared = models.ManyToManyField(User, blank=True, related_name='filter_share')
|
||||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
# created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
filter = models.ForeignKey(ModelFilter, on_delete=models.PROTECT, null=True)
|
# filter = models.ForeignKey(ModelFilter, on_delete=models.PROTECT, null=True)
|
||||||
|
|
||||||
objects = ScopedManager(space='space')
|
# objects = ScopedManager(space='space')
|
||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
# space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
|
|
||||||
def __str__(self):
|
# def __str__(self):
|
||||||
return f"{self.type}: {self.name}"
|
# return f"{self.type}: {self.name}"
|
||||||
|
|
||||||
class Meta:
|
# class Meta:
|
||||||
constraints = [
|
# constraints = [
|
||||||
models.UniqueConstraint(fields=['space', 'name'], name='sf_unique_name_per_space')
|
# models.UniqueConstraint(fields=['space', 'name'], name='sf_unique_name_per_space')
|
||||||
]
|
# ]
|
||||||
|
@ -519,7 +519,7 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
|
|||||||
|
|
||||||
def get_recipe_last_cooked(self, obj):
|
def get_recipe_last_cooked(self, obj):
|
||||||
try:
|
try:
|
||||||
last = obj.cooklog_set.filter(created_by=self.context['request'].user).last()
|
last = obj.cooklog_set.filter(created_by=self.context['request'].user).order_by('created_at').last()
|
||||||
if last:
|
if last:
|
||||||
return last.created_at
|
return last.created_at
|
||||||
except TypeError:
|
except TypeError:
|
||||||
@ -539,6 +539,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
|
|||||||
rating = serializers.SerializerMethodField('get_recipe_rating')
|
rating = serializers.SerializerMethodField('get_recipe_rating')
|
||||||
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
|
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
|
||||||
new = serializers.SerializerMethodField('is_recipe_new')
|
new = serializers.SerializerMethodField('is_recipe_new')
|
||||||
|
recent = serializers.ReadOnlyField()
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
pass
|
pass
|
||||||
@ -551,7 +552,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
|
|||||||
fields = (
|
fields = (
|
||||||
'id', 'name', 'description', 'image', 'keywords', 'working_time',
|
'id', 'name', 'description', 'image', 'keywords', 'working_time',
|
||||||
'waiting_time', 'created_by', 'created_at', 'updated_at',
|
'waiting_time', 'created_by', 'created_at', 'updated_at',
|
||||||
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new'
|
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
|
||||||
)
|
)
|
||||||
read_only_fields = ['image', 'created_by', 'created_at']
|
read_only_fields = ['image', 'created_by', 'created_at']
|
||||||
|
|
||||||
|
@ -498,6 +498,8 @@
|
|||||||
:clear-on-select="true"
|
:clear-on-select="true"
|
||||||
:allow-empty="true"
|
:allow-empty="true"
|
||||||
:preserve-search="true"
|
:preserve-search="true"
|
||||||
|
:internal-search="false"
|
||||||
|
:limit="options_limit"
|
||||||
placeholder="{% trans 'Select one' %}"
|
placeholder="{% trans 'Select one' %}"
|
||||||
tag-placeholder="{% trans 'Select' %}"
|
tag-placeholder="{% trans 'Select' %}"
|
||||||
label="text"
|
label="text"
|
||||||
@ -536,6 +538,8 @@
|
|||||||
:clear-on-select="true"
|
:clear-on-select="true"
|
||||||
:allow-empty="false"
|
:allow-empty="false"
|
||||||
:preserve-search="true"
|
:preserve-search="true"
|
||||||
|
:internal-search="false"
|
||||||
|
:limit="options_limit"
|
||||||
label="text"
|
label="text"
|
||||||
track-by="id"
|
track-by="id"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
@ -586,6 +590,8 @@
|
|||||||
:clear-on-select="true"
|
:clear-on-select="true"
|
||||||
:hide-selected="true"
|
:hide-selected="true"
|
||||||
:preserve-search="true"
|
:preserve-search="true"
|
||||||
|
:internal-search="false"
|
||||||
|
:limit="options_limit"
|
||||||
placeholder="{% trans 'Select one' %}"
|
placeholder="{% trans 'Select one' %}"
|
||||||
tag-placeholder="{% trans 'Add Keyword' %}"
|
tag-placeholder="{% trans 'Add Keyword' %}"
|
||||||
:taggable="true"
|
:taggable="true"
|
||||||
@ -660,6 +666,7 @@
|
|||||||
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
|
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
|
||||||
|
|
||||||
Vue.component('vue-multiselect', window.VueMultiselect.default)
|
Vue.component('vue-multiselect', window.VueMultiselect.default)
|
||||||
|
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||||
|
|
||||||
let app = new Vue({
|
let app = new Vue({
|
||||||
components: {
|
components: {
|
||||||
@ -693,7 +700,8 @@
|
|||||||
import_duplicates: false,
|
import_duplicates: false,
|
||||||
recipe_files: [],
|
recipe_files: [],
|
||||||
images: [],
|
images: [],
|
||||||
mode: 'url'
|
mode: 'url',
|
||||||
|
options_limit:25
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
tabindex: {
|
tabindex: {
|
||||||
@ -703,9 +711,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted: function () {
|
mounted: function () {
|
||||||
this.searchKeywords('')
|
// this.searchKeywords('')
|
||||||
this.searchUnits('')
|
// this.searchUnits('')
|
||||||
this.searchIngredients('')
|
// this.searchIngredients('')
|
||||||
let uri = window.location.search.substring(1);
|
let uri = window.location.search.substring(1);
|
||||||
let params = new URLSearchParams(uri);
|
let params = new URLSearchParams(uri);
|
||||||
q = params.get("id")
|
q = params.get("id")
|
||||||
@ -877,51 +885,93 @@
|
|||||||
this.$set(this.$refs.ingredient[index].$data, 'search', this.recipe_data.recipeIngredient[index].ingredient.text)
|
this.$set(this.$refs.ingredient[index].$data, 'search', this.recipe_data.recipeIngredient[index].ingredient.text)
|
||||||
},
|
},
|
||||||
searchKeywords: function (query) {
|
searchKeywords: function (query) {
|
||||||
|
// this.keywords_loading = true
|
||||||
|
// this.$http.get("{% url 'dal_keyword' %}" + '?q=' + query).then((response) => {
|
||||||
|
// this.keywords = response.data.results;
|
||||||
|
// this.keywords_loading = false
|
||||||
|
// }).catch((err) => {
|
||||||
|
// console.log(err)
|
||||||
|
// this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||||
|
// })
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
|
||||||
this.keywords_loading = true
|
this.keywords_loading = true
|
||||||
this.$http.get("{% url 'dal_keyword' %}" + '?q=' + query).then((response) => {
|
apiFactory
|
||||||
this.keywords = response.data.results;
|
.listKeywords(query, undefined, undefined, 1, this.options_limit)
|
||||||
this.keywords_loading = false
|
.then((response) => {
|
||||||
}).catch((err) => {
|
this.keywords = response.data.results
|
||||||
console.log(err)
|
this.keywords_loading = false
|
||||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
})
|
||||||
})
|
.catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
searchUnits: function (query) {
|
searchUnits: function (query) {
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
|
||||||
this.units_loading = true
|
this.units_loading = true
|
||||||
this.$http.get("{% url 'dal_unit' %}" + '?q=' + query).then((response) => {
|
apiFactory
|
||||||
this.units = response.data.results;
|
.listUnits(query, 1, this.options_limit)
|
||||||
if (this.recipe_data !== undefined) {
|
.then((response) => {
|
||||||
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
|
this.units = response.data.results
|
||||||
if (x.unit !== null && x.unit.text !== '') {
|
|
||||||
this.units = this.units.filter(item => item.text !== x.unit.text)
|
if (this.recipe !== undefined) {
|
||||||
this.units.push(x.unit)
|
for (let s of this.recipe.steps) {
|
||||||
|
for (let i of s.ingredients) {
|
||||||
|
if (i.unit !== null && i.unit.id === undefined) {
|
||||||
|
this.units.push(i.unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
this.units_loading = false
|
||||||
this.units_loading = false
|
})
|
||||||
}).catch((err) => {
|
.catch((err) => {
|
||||||
console.log(err)
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
})
|
||||||
})
|
|
||||||
},
|
},
|
||||||
searchIngredients: function (query) {
|
searchIngredients: function (query) {
|
||||||
this.ingredients_loading = true
|
// this.ingredients_loading = true
|
||||||
this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => {
|
// this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => {
|
||||||
this.ingredients = response.data.results
|
// this.ingredients = response.data.results
|
||||||
if (this.recipe_data !== undefined) {
|
// if (this.recipe_data !== undefined) {
|
||||||
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
|
// for (let x of Array.from(this.recipe_data.recipeIngredient)) {
|
||||||
if (x.ingredient.text !== '') {
|
// if (x.ingredient.text !== '') {
|
||||||
this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text)
|
// this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text)
|
||||||
this.ingredients.push(x.ingredient)
|
// this.ingredients.push(x.ingredient)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// this.ingredients_loading = false
|
||||||
|
// }).catch((err) => {
|
||||||
|
// console.log(err)
|
||||||
|
// this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||||
|
// })
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
|
||||||
|
this.foods_loading = true
|
||||||
|
apiFactory
|
||||||
|
.listFoods(query, undefined, undefined, 1, this.options_limit)
|
||||||
|
.then((response) => {
|
||||||
|
this.foods = response.data.results
|
||||||
|
|
||||||
|
if (this.recipe !== undefined) {
|
||||||
|
for (let s of this.recipe.steps) {
|
||||||
|
for (let i of s.ingredients) {
|
||||||
|
if (i.food !== null && i.food.id === undefined) {
|
||||||
|
this.foods.push(i.food)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.ingredients_loading = false
|
this.foods_loading = false
|
||||||
}).catch((err) => {
|
})
|
||||||
console.log(err)
|
.catch((err) => {
|
||||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
deleteNode: function (node, item, e) {
|
deleteNode: function (node, item, e) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
@ -12,7 +12,8 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.postgres.search import TrigramSimilarity
|
from django.contrib.postgres.search import TrigramSimilarity
|
||||||
from django.core.exceptions import FieldError, ValidationError
|
from django.core.exceptions import FieldError, ValidationError
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When
|
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef, ProtectedError, Q,
|
||||||
|
Subquery, Value, When)
|
||||||
from django.db.models.fields.related import ForeignObjectRel
|
from django.db.models.fields.related import ForeignObjectRel
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.http import FileResponse, HttpResponse, JsonResponse
|
from django.http import FileResponse, HttpResponse, JsonResponse
|
||||||
@ -38,7 +39,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
|
|||||||
CustomIsShare, CustomIsShared, CustomIsUser,
|
CustomIsShare, CustomIsShared, CustomIsUser,
|
||||||
group_required)
|
group_required)
|
||||||
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
||||||
from cookbook.helper.recipe_search import RecipeFacet, old_search, search_recipes
|
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search
|
||||||
from cookbook.helper.recipe_url_import import get_from_scraper
|
from cookbook.helper.recipe_url_import import get_from_scraper
|
||||||
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper
|
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper
|
||||||
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
|
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
|
||||||
@ -145,18 +146,18 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
|
|||||||
if fuzzy:
|
if fuzzy:
|
||||||
self.queryset = (
|
self.queryset = (
|
||||||
self.queryset
|
self.queryset
|
||||||
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))),
|
.annotate(starts=Case(When(name__istartswith=query, then=(Value(.3, output_field=IntegerField()))), default=Value(0)))
|
||||||
default=Value(0))) # put exact matches at the top of the result set
|
.annotate(trigram=TrigramSimilarity('name', query))
|
||||||
.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2)
|
.annotate(sort=F('starts')+F('trigram'))
|
||||||
.order_by('-exact', '-trigram')
|
.order_by('-sort')
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# TODO have this check unaccent search settings or other search preferences?
|
# TODO have this check unaccent search settings or other search preferences?
|
||||||
self.queryset = (
|
self.queryset = (
|
||||||
self.queryset
|
self.queryset
|
||||||
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))),
|
.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
|
||||||
default=Value(0))) # put exact matches at the top of the result set
|
default=Value(0))) # put exact matches at the top of the result set
|
||||||
.filter(name__icontains=query).order_by('-exact', 'name')
|
.filter(name__icontains=query).order_by('-starts', 'name')
|
||||||
)
|
)
|
||||||
|
|
||||||
updated_at = self.request.query_params.get('updated_at', None)
|
updated_at = self.request.query_params.get('updated_at', None)
|
||||||
@ -652,8 +653,11 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
|||||||
if not (share and self.detail):
|
if not (share and self.detail):
|
||||||
self.queryset = self.queryset.filter(space=self.request.space)
|
self.queryset = self.queryset.filter(space=self.request.space)
|
||||||
|
|
||||||
self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
|
super().get_queryset()
|
||||||
return super().get_queryset().prefetch_related('cooklog_set')
|
# self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
|
||||||
|
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)}
|
||||||
|
self.queryset = RecipeSearch(self.request, **params).get_queryset(self.queryset).prefetch_related('cooklog_set')
|
||||||
|
return self.queryset
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
if self.request.GET.get('debug', False):
|
if self.request.GET.get('debug', False):
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -301,7 +301,7 @@ export default {
|
|||||||
{ 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: 0, label: this.$t("Unrated") + " (" + (this.facets.Ratings?.["0.0"] ?? 0) + ")" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
searchFiltered: function () {
|
searchFiltered: function () {
|
||||||
@ -483,7 +483,7 @@ export default {
|
|||||||
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 (x.recent) {
|
||||||
return recent_recipe
|
return recent_recipe
|
||||||
} else {
|
} else {
|
||||||
return [undefined, undefined]
|
return [undefined, undefined]
|
||||||
|
@ -1,122 +1,118 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
|
<b-modal class="modal" :id="`id_modal_add_book_${modal_id}`" :title="$t('Manage_Books')" :ok-title="$t('Add')" :cancel-title="$t('Close')" @ok="addToBook()" @shown="loadBookEntries">
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center" v-for="be in this.recipe_book_list" v-bind:key="be.id">
|
||||||
|
{{ be.book_content.name }} <span class="btn btn-sm btn-danger" @click="removeFromBook(be)"><i class="fa fa-trash-alt"></i></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div>
|
<multiselect
|
||||||
<b-modal class="modal" :id="`id_modal_add_book_${modal_id}`" :title="$t('Manage_Books')" :ok-title="$t('Add')"
|
style="margin-top: 1vh"
|
||||||
:cancel-title="$t('Close')" @ok="addToBook()" @shown="loadBookEntries">
|
v-model="selected_book"
|
||||||
<ul class="list-group">
|
:options="books_filtered"
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center" v-for="be in this.recipe_book_list" v-bind:key="be.id">
|
:taggable="true"
|
||||||
{{ be.book_content.name }} <span class="btn btn-sm btn-danger" @click="removeFromBook(be)"><i class="fa fa-trash-alt"></i></span>
|
@tag="createBook"
|
||||||
</li>
|
v-bind:tag-placeholder="$t('Create')"
|
||||||
</ul>
|
:placeholder="$t('Select_Book')"
|
||||||
|
label="name"
|
||||||
<multiselect
|
track-by="id"
|
||||||
style="margin-top: 1vh"
|
id="id_books"
|
||||||
v-model="selected_book"
|
:multiple="false"
|
||||||
:options="books_filtered"
|
:internal-search="false"
|
||||||
:taggable="true"
|
:loading="books_loading"
|
||||||
@tag="createBook"
|
@search-change="loadBooks"
|
||||||
v-bind:tag-placeholder="$t('Create')"
|
>
|
||||||
:placeholder="$t('Select_Book')"
|
</multiselect>
|
||||||
label="name"
|
</b-modal>
|
||||||
track-by="id"
|
</div>
|
||||||
id="id_books"
|
|
||||||
:multiple="false"
|
|
||||||
:loading="books_loading"
|
|
||||||
@search-change="loadBooks">
|
|
||||||
</multiselect>
|
|
||||||
</b-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import Multiselect from "vue-multiselect"
|
||||||
|
|
||||||
import Multiselect from 'vue-multiselect'
|
import moment from "moment"
|
||||||
|
|
||||||
import moment from 'moment'
|
|
||||||
|
|
||||||
Vue.prototype.moment = moment
|
Vue.prototype.moment = moment
|
||||||
|
|
||||||
import Vue from "vue";
|
import Vue from "vue"
|
||||||
import {BootstrapVue} from "bootstrap-vue";
|
import { BootstrapVue } from "bootstrap-vue"
|
||||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||||
import {makeStandardToast, StandardToasts} from "@/utils/utils";
|
import { makeStandardToast, StandardToasts } from "@/utils/utils"
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AddRecipeToBook',
|
name: "AddRecipeToBook",
|
||||||
components: {
|
components: {
|
||||||
Multiselect
|
Multiselect,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
recipe: Object,
|
recipe: Object,
|
||||||
modal_id: Number
|
modal_id: Number,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
books: [],
|
books: [],
|
||||||
books_loading: false,
|
books_loading: false,
|
||||||
recipe_book_list: [],
|
recipe_book_list: [],
|
||||||
selected_book: null,
|
selected_book: null,
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
books_filtered: function () {
|
|
||||||
let books_filtered = []
|
|
||||||
|
|
||||||
this.books.forEach(b => {
|
|
||||||
if (this.recipe_book_list.filter(e => e.book === b.id).length === 0) {
|
|
||||||
books_filtered.push(b)
|
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
computed: {
|
||||||
|
books_filtered: function () {
|
||||||
|
let books_filtered = []
|
||||||
|
|
||||||
return books_filtered
|
this.books.forEach((b) => {
|
||||||
}
|
if (this.recipe_book_list.filter((e) => e.book === b.id).length === 0) {
|
||||||
},
|
books_filtered.push(b)
|
||||||
mounted() {
|
}
|
||||||
|
})
|
||||||
|
|
||||||
},
|
return books_filtered
|
||||||
methods: {
|
},
|
||||||
loadBooks: function (query) {
|
|
||||||
this.books_loading = true
|
|
||||||
let apiFactory = new ApiApiFactory()
|
|
||||||
apiFactory.listRecipeBooks({query: {query: query}}).then(results => {
|
|
||||||
this.books = results.data.filter(e => this.recipe_book_list.indexOf(e) === -1)
|
|
||||||
this.books_loading = false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
createBook: function (name) {
|
mounted() {},
|
||||||
let apiFactory = new ApiApiFactory()
|
methods: {
|
||||||
apiFactory.createRecipeBook({name: name}).then(r => {
|
loadBooks: function (query) {
|
||||||
this.books.push(r.data)
|
this.books_loading = true
|
||||||
this.selected_book = r.data
|
let apiFactory = new ApiApiFactory()
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
apiFactory.listRecipeBooks({ query: { query: query } }).then((results) => {
|
||||||
})
|
this.books = results.data.filter((e) => this.recipe_book_list.indexOf(e) === -1)
|
||||||
|
this.books_loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
createBook: function (name) {
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
apiFactory.createRecipeBook({ name: name }).then((r) => {
|
||||||
|
this.books.push(r.data)
|
||||||
|
this.selected_book = r.data
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addToBook: function () {
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
apiFactory.createRecipeBookEntry({ book: this.selected_book.id, recipe: this.recipe.id }).then((r) => {
|
||||||
|
this.recipe_book_list.push(r.data)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeFromBook: function (book_entry) {
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
apiFactory.destroyRecipeBookEntry(book_entry.id).then((r) => {
|
||||||
|
this.recipe_book_list = this.recipe_book_list.filter((e) => e.id !== book_entry.id)
|
||||||
|
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
loadBookEntries: function () {
|
||||||
|
let apiFactory = new ApiApiFactory()
|
||||||
|
apiFactory.listRecipeBookEntrys({ query: { recipe: this.recipe.id } }).then((r) => {
|
||||||
|
this.recipe_book_list = r.data
|
||||||
|
this.loadBooks("")
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
addToBook: function () {
|
|
||||||
let apiFactory = new ApiApiFactory()
|
|
||||||
apiFactory.createRecipeBookEntry({book: this.selected_book.id, recipe: this.recipe.id}).then(r => {
|
|
||||||
this.recipe_book_list.push(r.data)
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
removeFromBook: function (book_entry) {
|
|
||||||
let apiFactory = new ApiApiFactory()
|
|
||||||
apiFactory.destroyRecipeBookEntry(book_entry.id).then(r => {
|
|
||||||
this.recipe_book_list = this.recipe_book_list.filter(e => e.id !== book_entry.id)
|
|
||||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
loadBookEntries: function () {
|
|
||||||
|
|
||||||
let apiFactory = new ApiApiFactory()
|
|
||||||
apiFactory.listRecipeBookEntrys({query: {recipe: this.recipe.id}}).then(r => {
|
|
||||||
this.recipe_book_list = r.data
|
|
||||||
this.loadBooks('')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||||
|
@ -6,6 +6,8 @@
|
|||||||
:clear-on-select="true"
|
:clear-on-select="true"
|
||||||
:hide-selected="multiple"
|
:hide-selected="multiple"
|
||||||
:preserve-search="true"
|
:preserve-search="true"
|
||||||
|
:internal-search="false"
|
||||||
|
:limit="options_limit"
|
||||||
:placeholder="lookupPlaceholder"
|
:placeholder="lookupPlaceholder"
|
||||||
:label="label"
|
:label="label"
|
||||||
track-by="id"
|
track-by="id"
|
||||||
@ -34,6 +36,7 @@ export default {
|
|||||||
loading: false,
|
loading: false,
|
||||||
objects: [],
|
objects: [],
|
||||||
selected_objects: [],
|
selected_objects: [],
|
||||||
|
options_limit: 25,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
@ -89,6 +92,7 @@ export default {
|
|||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
query: query,
|
query: query,
|
||||||
|
limit: this.options_limit,
|
||||||
}
|
}
|
||||||
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
|
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
|
||||||
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
|
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
|
||||||
|
Reference in New Issue
Block a user