from datetime import datetime, timedelta from recipes import settings from django.contrib.postgres.search import ( SearchQuery, SearchRank, TrigramSimilarity ) from django.db.models import Q, Subquery, Case, When, Value from django.utils import translation from cookbook.managers import DICTIONARY from cookbook.models import Food, Keyword, ViewLog def search_recipes(request, queryset, params): search_prefs = request.user.searchpreference search_string = params.get('query', '') search_keywords = params.getlist('keywords', []) search_foods = params.getlist('foods', []) search_books = params.getlist('books', []) search_keywords_or = params.get('keywords_or', True) search_foods_or = params.get('foods_or', True) search_books_or = params.get('books_or', True) search_internal = params.get('internal', None) search_random = params.get('random', False) search_last_viewed = int(params.get('last_viewed', 0)) if search_last_viewed > 0: last_viewed_recipes = ViewLog.objects.filter( created_by=request.user, space=request.space, created_at__gte=datetime.now() - timedelta(days=14) ).order_by('pk').values_list('recipe__pk', flat=True).distinct() return queryset.filter(pk__in=last_viewed_recipes[len(last_viewed_recipes) - min(len(last_viewed_recipes), search_last_viewed):]) queryset = queryset.annotate( new_recipe=Case(When( created_at__gte=(datetime.now() - timedelta(days=7)), then=Value(100)), default=Value(0), )).order_by('-new_recipe', 'name') search_type = search_prefs.search or 'plain' search_sort = None 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: 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 & len(trigram_include) > 1: trigram = None for f in trigram_include: if trigram: trigram += TrigramSimilarity(f, search_string) else: trigram = TrigramSimilarity(f, search_string) queryset.annotate(simularity=trigram) # TODO allow user to play with trigram scores filters += [Q(simularity__gt=0.5)] 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 this is kind of a dumb method to sort. create settings to choose rank vs most often made, date created or rating 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) else: queryset = queryset.filter(query_filter) if len(search_keywords) > 0: if search_keywords_or == 'true': queryset = queryset.filter(keywords__id__in=search_keywords) else: for k in search_keywords: queryset = queryset.filter(keywords__id=k) if len(search_foods) > 0: if search_foods_or == 'true': queryset = queryset.filter(steps__ingredients__food__id__in=search_foods) else: for k in search_foods: queryset = queryset.filter(steps__ingredients__food__id=k) if len(search_books) > 0: if search_books_or == 'true': queryset = queryset.filter(recipebookentry__book__id__in=search_books) else: for k in search_books: queryset = queryset.filter(recipebookentry__book__id=k) if search_internal == 'true': queryset = queryset.filter(internal=True) queryset = queryset.distinct() if search_random == 'true': queryset = queryset.order_by("?") elif search_sort == 'rank': queryset = queryset.order_by('-rank') # kw = Keyword.objects.filter(recipe__in=queryset).annotate(kw_count=Count('recipe')) # Keyword.get_annotated_list_qs(Keyword.objects.filter(id__in=[item.id for k in kw for item in k.get_ancestors_and_self()])) # print(time.time()-start, len(queryset), len(kw)) # Keyword.get_annotated_list_qs( # Keyword.objects.filter(recipe__in=queryset).annotate(kw_count=Count('recipe')) # | Keyword.objects.all().filter(id__in=set([k.parent for k in Keyword.objects.filter(recipe__in=queryset).annotate(kw_count=Count('recipe'))]))) return queryset # this returns a list of keywords in the queryset and how many times it appears # Keyword.objects.filter(recipe__in=queryset).annotate(kw_count=Count('recipe'))