diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index f1a083fed..531980447 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -4,13 +4,15 @@ from recipes import settings from django.contrib.postgres.search import ( SearchQuery, SearchRank, TrigramSimilarity ) -from django.db.models import Count, Q, Subquery, Case, When, Value +from django.db.models import Count, Max, Q, Subquery, Case, When, Value from django.utils import translation from cookbook.managers import DICTIONARY from cookbook.models import Food, Keyword, ViewLog +# 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): search_prefs = request.user.searchpreference search_string = params.get('query', '') @@ -19,6 +21,7 @@ def search_recipes(request, queryset, params): search_foods = params.getlist('foods', []) search_books = params.getlist('books', []) + # TODO I think default behavior should be 'AND' which is how most sites operate with facet/filters based on results search_keywords_or = params.get('keywords_or', True) search_foods_or = params.get('foods_or', True) search_books_or = params.get('books_or', True) @@ -33,18 +36,22 @@ def search_recipes(request, queryset, params): 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() + ).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.filter(pk__in=last_viewed_recipes[len(last_viewed_recipes) - min(len(last_viewed_recipes), search_last_viewed):]) + 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) + # TODO queryset.annotate(last_view=Max('viewlog__pk')).annotate(new=Case(When(pk__in=last_viewed_recipes, then=Value(100)), default=Value(0))).order_by('-new') orderby = [] + # TODO create setting for default ordering - most cooked, rating, + # TODO create options for live sorting if search_new == 'true': - queryset = queryset.annotate( - new_recipe=Case(When(created_at__gte=(datetime.now() - timedelta(days=7)), then=Value(100)), - default=Value(0), )) - orderby += ['new_recipe'] - else: - queryset = queryset + queryset = ( + queryset.annotate(new_recipe=Case( + When(created_at__gte=(datetime.now() - timedelta(days=81)), then=('pk')), default=Value(0),)) + ) + orderby += ['-new_recipe'] search_type = search_prefs.search or 'plain' if len(search_string) > 0: @@ -124,12 +131,12 @@ def search_recipes(request, queryset, params): queryset = queryset.filter(query_filter) if len(search_keywords) > 0: - # TODO creating setting to include descendants of keywords a setting if search_keywords_or == 'true': # when performing an 'or' search all descendants are included in the OR condition # so descendants are appended to filter all at once - for kw in Keyword.objects.filter(pk__in=search_keywords): - search_keywords += list(kw.get_descendants().values_list('pk', flat=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)) queryset = queryset.filter(keywords__id__in=search_keywords) else: # when performing an 'and' search returned recipes should include a parent OR any of its descedants @@ -165,24 +172,31 @@ def search_recipes(request, queryset, params): return queryset -def get_facet(qs, params): +def get_facet(qs, params, space): + # NOTE facet counts for tree models include self AND descendants facets = {} ratings = params.getlist('ratings', []) keyword_list = params.getlist('keywords', []) - ingredient_list = params.getlist('ingredient', []) + food_list = params.getlist('foods', []) book_list = params.getlist('book', []) + search_keywords_or = params.get('keywords_or', True) + search_foods_or = params.get('foods_or', True) + search_books_or = params.get('books_or', True) - # this returns a list of keywords in the queryset and how many times it appears - kws = Keyword.objects.filter(recipe__in=qs).annotate(kw_count=Count('recipe')) + # if using an OR search, will annotate all keywords, otherwise, just those that appear in results + if search_keywords_or: + keywords = Keyword.objects.filter(space=space).annotate(recipe_count=Count('recipe')) + else: + keywords = Keyword.objects.filter(recipe__in=qs, space=space).annotate(recipe_count=Count('recipe')) # custom django-tree function annotates a queryset to make building a tree easier. # see https://django-treebeard.readthedocs.io/en/latest/api.html#treebeard.models.Node.get_annotated_list_qs for details - kw_a = annotated_qs(kws, root=True, fill=True) + kw_a = annotated_qs(keywords, root=True, fill=True) # TODO add rating facet facets['Ratings'] = [] facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list) # TODO add food facet - facets['Ingredients'] = [] + facets['Foods'] = [] # TODO add book facet facets['Books'] = [] diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 5f8a4bd19..042a685bd 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -12,7 +12,7 @@ from django.contrib.auth.models import User from django.contrib.postgres.search import TrigramSimilarity from django.core.exceptions import FieldError, ValidationError from django.core.files import File -from django.db.models import Q +from django.db.models import Case, Q, Value, When from django.http import FileResponse, HttpResponse, JsonResponse from django_scopes import scopes_disabled from django.shortcuts import redirect, get_object_or_404 @@ -106,10 +106,19 @@ class FuzzyFilterMixin(ViewSetMixin): if query is not None and query not in ["''", '']: if fuzzy: - self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2).order_by("-trigram") + self.queryset = ( + self.queryset + .annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set + .annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2) + .order_by('-exact').order_by("-trigram") + ) else: - # TODO have this check unaccent search settings? - self.queryset = self.queryset.filter(name__icontains=query) + # TODO have this check unaccent search settings or other search preferences? + self.queryset = ( + self.queryset + .annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set + .filter(name__icontains=query).order_by('-exact') + ) updated_at = self.request.query_params.get('updated_at', None) if updated_at is not None: @@ -466,7 +475,7 @@ class RecipePagination(PageNumberPagination): max_page_size = 100 def paginate_queryset(self, queryset, request, view=None): - self.facets = get_facet(queryset, request.query_params) + self.facets = get_facet(queryset, request.query_params, request.space) return super().paginate_queryset(queryset, request, view) def get_paginated_response(self, data):