refactor recipe search

This commit is contained in:
smilerz
2022-01-17 08:26:34 -06:00
parent 6d9a90c6ba
commit 37971acb48
9 changed files with 1474 additions and 1196 deletions

View File

@ -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}")

View File

@ -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')
] # ]

View File

@ -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']

View File

@ -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()

View File

@ -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

View File

@ -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]

View File

@ -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>

View File

@ -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)