mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-03 21:37:49 -05:00
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.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.utils import timezone, translation
|
||||
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.permission_helper import has_group_permission
|
||||
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
|
||||
|
||||
|
||||
# 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
|
||||
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)
|
||||
class RecipeSearch():
|
||||
_postgres = settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
|
||||
|
||||
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,
|
||||
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})]
|
||||
def __init__(self, request, **params):
|
||||
self._request = request
|
||||
self._queryset = None
|
||||
self._params = {**params}
|
||||
if self._request.user.is_authenticated:
|
||||
self._search_prefs = request.user.searchpreference
|
||||
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
|
||||
for f in icontains_include:
|
||||
filters += [Q(**{"%s__icontains" % f: search_string})]
|
||||
self._keywords_or = str2bool(self._params.get('keywords_or', True))
|
||||
self._foods_or = str2bool(self._params.get('foods_or', True))
|
||||
self._books_or = str2bool(self._params.get('books_or', True))
|
||||
|
||||
for f in istartswith_include:
|
||||
filters += [Q(**{"%s__istartswith" % f: search_string})]
|
||||
self._internal = str2bool(self._params.get('internal', False))
|
||||
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']:
|
||||
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,
|
||||
self._search_type = self._search_prefs.search or 'plain'
|
||||
if self._string:
|
||||
unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True)
|
||||
self._icontains_include = [x + '__unaccent' if x in unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)]
|
||||
self._istartswith_include = [x + '__unaccent' if x in unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
|
||||
self._trigram_include = None
|
||||
self._fulltext_include = None
|
||||
self._trigram = False
|
||||
if self._postgres and self._string:
|
||||
self._language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
self._trigram_include = [x + '__unaccent' if x in unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)]
|
||||
self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True)
|
||||
|
||||
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
|
||||
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)]
|
||||
def get_queryset(self, queryset):
|
||||
self._queryset = queryset
|
||||
self.recently_viewed_recipes(self._last_viewed)
|
||||
self._favorite_recipes()
|
||||
# self._last_viewed()
|
||||
# self._last_cooked()
|
||||
self.keyword_filters(keywords=self._keywords, operator=self._keywords_or)
|
||||
self.food_filters(foods=self._foods, operator=self._foods_or)
|
||||
self.book_filters(books=self._books, operator=self._books_or)
|
||||
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:
|
||||
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)))]
|
||||
def _apply_order_by(self):
|
||||
if self._random:
|
||||
self._queryset = self._queryset.order_by("?")
|
||||
else:
|
||||
if self._sort_order:
|
||||
self._queryset.order_by(*self._sort_order)
|
||||
return
|
||||
|
||||
order = [] # TODO add user preferences here: name, date cooked, rating, times cooked, date created, date viewed, random
|
||||
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
|
||||
for f in filters:
|
||||
for f in self._filters:
|
||||
if query_filter:
|
||||
query_filter |= f
|
||||
else:
|
||||
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
|
||||
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']
|
||||
if self._fuzzy_match is not None: # this annotation is full text, not trigram
|
||||
simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity')
|
||||
self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
|
||||
self.orderby += ['-simularity']
|
||||
else:
|
||||
queryset = queryset.filter(name__icontains=search_string)
|
||||
self._queryset = self._queryset.filter(name__icontains=self._string)
|
||||
|
||||
if len(search_keywords) > 0:
|
||||
if search_keywords_or:
|
||||
def recently_viewed_recipes(self, last_viewed=None):
|
||||
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
|
||||
# for kw in Keyword.objects.filter(pk__in=search_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)
|
||||
self._queryset = self._queryset.filter(keywords__in=Keyword.include_descendants(Keyword.objects.filter(pk__in=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__id__in=list(kw.get_descendants_and_self().values_list('pk', flat=True)))
|
||||
for kw in Keyword.objects.filter(pk__in=keywords):
|
||||
self._queryset = self._queryset.filter(keywords__in=list(kw.get_descendants_and_self()))
|
||||
|
||||
if len(search_foods) > 0:
|
||||
if search_foods_or:
|
||||
def food_filters(self, foods=None, operator=True):
|
||||
if not foods:
|
||||
return
|
||||
if operator == True:
|
||||
# TODO creating setting to include descendants of food a setting
|
||||
for fd in Food.objects.filter(pk__in=search_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)
|
||||
self._queryset = self._queryset.filter(steps__ingredients__food__in=Food.include_descendants(Food.objects.filter(pk__in=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__id__in=list(fd.get_descendants_and_self().values_list('pk', flat=True)))
|
||||
for fd in Food.objects.filter(pk__in=foods):
|
||||
self._queryset = self._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)
|
||||
def unit_filters(self, units=None, operator=True):
|
||||
if operator != True:
|
||||
raise NotImplementedError
|
||||
if not units:
|
||||
return
|
||||
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
|
||||
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)
|
||||
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0)))))
|
||||
if rating == 0:
|
||||
self._queryset = self._queryset.filter(rating=0)
|
||||
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
|
||||
if search_units:
|
||||
queryset = queryset.filter(steps__ingredients__unit__id=search_units)
|
||||
def internal_filter(self):
|
||||
self._queryset = self._queryset.filter(internal=True)
|
||||
|
||||
# probably only useful in Unit list view, so keeping it simple
|
||||
if search_steps:
|
||||
queryset = queryset.filter(steps__id__in=search_steps)
|
||||
def book_filters(self, books=None, operator=True):
|
||||
if not books:
|
||||
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:
|
||||
queryset = queryset.filter(internal=True)
|
||||
def step_filters(self, steps=None, operator=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:
|
||||
queryset = queryset.order_by("?")
|
||||
else:
|
||||
queryset = queryset.order_by(*orderby)
|
||||
return queryset
|
||||
def build_text_filters(self, string=None):
|
||||
if not string:
|
||||
return
|
||||
|
||||
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():
|
||||
@@ -223,6 +475,7 @@ class RecipeFacet():
|
||||
self.Foods = self._cache.get('Foods', None)
|
||||
self.Books = self._cache.get('Books', 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)
|
||||
|
||||
if self._queryset is not None:
|
||||
@@ -666,4 +919,10 @@ def old_search(request):
|
||||
# other[name] = [*other.get(name, []), x.name]
|
||||
# if x.hidden:
|
||||
# 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}")
|
||||
|
||||
Reference in New Issue
Block a user