From 37971acb48735a528cb696accc361c511821c2b8 Mon Sep 17 00:00:00 2001 From: smilerz Date: Mon, 17 Jan 2022 08:26:34 -0600 Subject: [PATCH] refactor recipe search --- cookbook/helper/recipe_search.py | 561 ++++-- cookbook/models.py | 150 +- cookbook/serializer.py | 5 +- cookbook/templates/url_import.html | 126 +- cookbook/views/api.py | 26 +- .../apps/RecipeEditView/RecipeEditView.vue | 1596 ++++++++--------- .../RecipeSearchView/RecipeSearchView.vue | 4 +- vue/src/components/AddRecipeToBook.vue | 198 +- vue/src/components/GenericMultiselect.vue | 4 + 9 files changed, 1474 insertions(+), 1196 deletions(-) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index cd6f79dd2..673b7d0d4 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -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}") diff --git a/cookbook/models.py b/cookbook/models.py index 07e230970..ad50db1b1 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -151,6 +151,7 @@ class TreeModel(MP_Node): return super().add_root(**kwargs) # i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet + @staticmethod def include_descendants(queryset=None, filter=None): """ :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) -class ModelFilter(models.Model): - EQUAL = 'EQUAL' - NOT_EQUAL = 'NOT_EQUAL' - LESS_THAN = 'LESS_THAN' - GREATER_THAN = 'GREATER_THAN' - LESS_THAN_EQ = 'LESS_THAN_EQ' - GREATER_THAN_EQ = 'GREATER_THAN_EQ' - CONTAINS = 'CONTAINS' - NOT_CONTAINS = 'NOT_CONTAINS' - STARTS_WITH = 'STARTS_WITH' - 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' +# class ModelFilter(models.Model): +# EQUAL = 'EQUAL' +# LESS_THAN = 'LESS_THAN' +# GREATER_THAN = 'GREATER_THAN' +# LESS_THAN_EQ = 'LESS_THAN_EQ' +# GREATER_THAN_EQ = 'GREATER_THAN_EQ' +# CONTAINS = 'CONTAINS' +# STARTS_WITH = 'STARTS_WITH' +# ENDS_WITH = 'ENDS_WITH' +# INCLUDES = 'INCLUDES' - OPERATION = ( - (EQUAL, _('is')), - (NOT_EQUAL, _('is not')), - (LESS_THAN, _('less than')), - (GREATER_THAN, _('greater than')), - (LESS_THAN_EQ, _('less or equal')), - (GREATER_THAN_EQ, _('greater or equal')), - (CONTAINS, _('contains')), - (NOT_CONTAINS, _('does not contain')), - (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')), - ) +# OPERATION = ( +# (EQUAL, _('is')), +# (LESS_THAN, _('less than')), +# (GREATER_THAN, _('greater than')), +# (LESS_THAN_EQ, _('less or equal')), +# (GREATER_THAN_EQ, _('greater or equal')), +# (CONTAINS, _('contains')), +# (STARTS_WITH, _('starts with')), +# (INCLUDES, _('includes')), +# ) - STRING = 'STRING' - NUMBER = 'NUMBER' - BOOLEAN = 'BOOLEAN' - DATE = 'DATE' +# STRING = 'STRING' +# NUMBER = 'NUMBER' +# BOOLEAN = 'BOOLEAN' +# DATE = 'DATE' - FIELD_TYPE = ( - (STRING, _('string')), - (NUMBER, _('number')), - (BOOLEAN, _('boolean')), - (DATE, _('date')), - ) +# FIELD_TYPE = ( +# (STRING, _('string')), +# (NUMBER, _('number')), +# (BOOLEAN, _('boolean')), +# (DATE, _('date')), +# ) - field = models.CharField(max_length=32) - field_type = models.CharField(max_length=32, choices=(FIELD_TYPE)) - operation = models.CharField(max_length=32, choices=(OPERATION)) - negate = models.BooleanField(default=False,) - target_value = models.CharField(max_length=128) - sort = models.BooleanField(default=False,) - ascending = models.BooleanField(default=True,) +# field = models.CharField(max_length=32) +# field_type = models.CharField(max_length=32, choices=(FIELD_TYPE)) +# operation = models.CharField(max_length=32, choices=(OPERATION)) +# negate = models.BooleanField(default=False,) +# target_value = models.CharField(max_length=128) +# sort = models.BooleanField(default=False,) +# ascending = models.BooleanField(default=True,) - def __str__(self): - return f"{self.field} - {self.operation} - {self.target_value}" +# def __str__(self): +# return f"{self.field} - {self.operation} - {self.target_value}" -class SavedFilter(models.Model, PermissionModelMixin): - FOOD = 'FOOD' - UNIT = 'UNIT' - KEYWORD = "KEYWORD" - RECIPE = 'RECIPE' - BOOK = 'BOOK' +# class SavedFilter(models.Model, PermissionModelMixin): +# FOOD = 'FOOD' +# UNIT = 'UNIT' +# KEYWORD = "KEYWORD" +# RECIPE = 'RECIPE' +# BOOK = 'BOOK' - MODELS = ( - (FOOD, _('Food')), - (UNIT, _('Unit')), - (KEYWORD, _('Keyword')), - (RECIPE, _('Recipe')), - (BOOK, _('Book')) - ) +# MODELS = ( +# (FOOD, _('Food')), +# (UNIT, _('Unit')), +# (KEYWORD, _('Keyword')), +# (RECIPE, _('Recipe')), +# (BOOK, _('Book')) +# ) - name = models.CharField(max_length=128, ) - type = models.CharField(max_length=24, choices=(MODELS)), - description = models.CharField(max_length=256, blank=True) - shared = models.ManyToManyField(User, blank=True, related_name='filter_share') - created_by = models.ForeignKey(User, on_delete=models.CASCADE) - filter = models.ForeignKey(ModelFilter, on_delete=models.PROTECT, null=True) +# name = models.CharField(max_length=128, ) +# type = models.CharField(max_length=24, choices=(MODELS)), +# description = models.CharField(max_length=256, blank=True) +# shared = models.ManyToManyField(User, blank=True, related_name='filter_share') +# created_by = models.ForeignKey(User, on_delete=models.CASCADE) +# filter = models.ForeignKey(ModelFilter, on_delete=models.PROTECT, null=True) - objects = ScopedManager(space='space') - space = models.ForeignKey(Space, on_delete=models.CASCADE) +# objects = ScopedManager(space='space') +# space = models.ForeignKey(Space, on_delete=models.CASCADE) - def __str__(self): - return f"{self.type}: {self.name}" +# def __str__(self): +# return f"{self.type}: {self.name}" - class Meta: - constraints = [ - models.UniqueConstraint(fields=['space', 'name'], name='sf_unique_name_per_space') - ] +# class Meta: +# constraints = [ +# models.UniqueConstraint(fields=['space', 'name'], name='sf_unique_name_per_space') +# ] diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 0fa2ee21a..fb1c3a224 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -519,7 +519,7 @@ class RecipeBaseSerializer(WritableNestedModelSerializer): def get_recipe_last_cooked(self, obj): 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: return last.created_at except TypeError: @@ -539,6 +539,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer): rating = serializers.SerializerMethodField('get_recipe_rating') last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked') new = serializers.SerializerMethodField('is_recipe_new') + recent = serializers.ReadOnlyField() def create(self, validated_data): pass @@ -551,7 +552,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer): fields = ( 'id', 'name', 'description', 'image', 'keywords', 'working_time', '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'] diff --git a/cookbook/templates/url_import.html b/cookbook/templates/url_import.html index 4a42a594b..933f744bd 100644 --- a/cookbook/templates/url_import.html +++ b/cookbook/templates/url_import.html @@ -498,6 +498,8 @@ :clear-on-select="true" :allow-empty="true" :preserve-search="true" + :internal-search="false" + :limit="options_limit" placeholder="{% trans 'Select one' %}" tag-placeholder="{% trans 'Select' %}" label="text" @@ -536,6 +538,8 @@ :clear-on-select="true" :allow-empty="false" :preserve-search="true" + :internal-search="false" + :limit="options_limit" label="text" track-by="id" :multiple="false" @@ -586,6 +590,8 @@ :clear-on-select="true" :hide-selected="true" :preserve-search="true" + :internal-search="false" + :limit="options_limit" placeholder="{% trans 'Select one' %}" tag-placeholder="{% trans 'Add Keyword' %}" :taggable="true" @@ -660,6 +666,7 @@ Vue.http.headers.common['X-CSRFToken'] = csrftoken; Vue.component('vue-multiselect', window.VueMultiselect.default) + import { ApiApiFactory } from "@/utils/openapi/api" let app = new Vue({ components: { @@ -693,7 +700,8 @@ import_duplicates: false, recipe_files: [], images: [], - mode: 'url' + mode: 'url', + options_limit:25 }, directives: { tabindex: { @@ -703,9 +711,9 @@ } }, mounted: function () { - this.searchKeywords('') - this.searchUnits('') - this.searchIngredients('') + // this.searchKeywords('') + // this.searchUnits('') + // this.searchIngredients('') let uri = window.location.search.substring(1); let params = new URLSearchParams(uri); q = params.get("id") @@ -877,51 +885,93 @@ this.$set(this.$refs.ingredient[index].$data, 'search', this.recipe_data.recipeIngredient[index].ingredient.text) }, 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.$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') - }) + apiFactory + .listKeywords(query, undefined, undefined, 1, this.options_limit) + .then((response) => { + this.keywords = response.data.results + this.keywords_loading = false + }) + .catch((err) => { + console.log(err) + StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH) + }) }, searchUnits: function (query) { + let apiFactory = new ApiApiFactory() + this.units_loading = true - this.$http.get("{% url 'dal_unit' %}" + '?q=' + query).then((response) => { - this.units = response.data.results; - if (this.recipe_data !== undefined) { - for (let x of Array.from(this.recipe_data.recipeIngredient)) { - if (x.unit !== null && x.unit.text !== '') { - this.units = this.units.filter(item => item.text !== x.unit.text) - this.units.push(x.unit) + apiFactory + .listUnits(query, 1, this.options_limit) + .then((response) => { + this.units = response.data.results + + if (this.recipe !== undefined) { + 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 - }).catch((err) => { - console.log(err) - this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger') - }) + this.units_loading = false + }) + .catch((err) => { + StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH) + }) }, searchIngredients: function (query) { - this.ingredients_loading = true - this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => { - this.ingredients = response.data.results - if (this.recipe_data !== undefined) { - for (let x of Array.from(this.recipe_data.recipeIngredient)) { - if (x.ingredient.text !== '') { - this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text) - this.ingredients.push(x.ingredient) + // this.ingredients_loading = true + // this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => { + // this.ingredients = response.data.results + // if (this.recipe_data !== undefined) { + // for (let x of Array.from(this.recipe_data.recipeIngredient)) { + // if (x.ingredient.text !== '') { + // this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text) + // 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 - }).catch((err) => { - console.log(err) - this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger') - }) + this.foods_loading = false + }) + .catch((err) => { + StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH) + }) }, deleteNode: function (node, item, e) { e.stopPropagation() diff --git a/cookbook/views/api.py b/cookbook/views/api.py index a65c22fa5..580942b27 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -12,7 +12,8 @@ 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 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.functions import Coalesce from django.http import FileResponse, HttpResponse, JsonResponse @@ -38,7 +39,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus CustomIsShare, CustomIsShared, CustomIsUser, group_required) 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.shopping_helper import list_from_recipe, shopping_helper from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField, @@ -145,18 +146,18 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin): if fuzzy: 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', '-trigram') + .annotate(starts=Case(When(name__istartswith=query, then=(Value(.3, output_field=IntegerField()))), default=Value(0))) + .annotate(trigram=TrigramSimilarity('name', query)) + .annotate(sort=F('starts')+F('trigram')) + .order_by('-sort') ) else: # 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', 'name') + .annotate(starts=Case(When(name__istartswith=query, then=(Value(100))), + default=Value(0))) # put exact matches at the top of the result set + .filter(name__icontains=query).order_by('-starts', 'name') ) updated_at = self.request.query_params.get('updated_at', None) @@ -652,8 +653,11 @@ class RecipeViewSet(viewsets.ModelViewSet): if not (share and self.detail): self.queryset = self.queryset.filter(space=self.request.space) - self.queryset = search_recipes(self.request, self.queryset, self.request.GET) - return super().get_queryset().prefetch_related('cooklog_set') + super().get_queryset() + # 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): if self.request.GET.get('debug', False): diff --git a/vue/src/apps/RecipeEditView/RecipeEditView.vue b/vue/src/apps/RecipeEditView/RecipeEditView.vue index f4810026a..09ceb6e07 100644 --- a/vue/src/apps/RecipeEditView/RecipeEditView.vue +++ b/vue/src/apps/RecipeEditView/RecipeEditView.vue @@ -1,861 +1,841 @@