refactor recipe search

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

View File

@ -3,7 +3,7 @@ from datetime import timedelta
from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity
from django.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}")

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -301,7 +301,7 @@ export default {
{ id: 3, label: "⭐⭐⭐ " + this.$t("and_up") + " (" + (this.facets.Ratings?.["3.0"] ?? 0) + ")" },
{ id: 2, label: "⭐⭐ " + this.$t("and_up") + " (" + (this.facets.Ratings?.["2.0"] ?? 0) + ")" },
{ id: 1, label: "⭐ " + this.$t("and_up") + " (" + (this.facets.Ratings?.["1.0"] ?? 0) + ")" },
{ id: -1, label: this.$t("Unrated") + " (" + (this.facets.Ratings?.["0.0"] ?? 0) + ")" },
{ id: 0, label: this.$t("Unrated") + " (" + (this.facets.Ratings?.["0.0"] ?? 0) + ")" },
]
},
searchFiltered: function () {
@ -483,7 +483,7 @@ export default {
let new_recipe = [this.$t("New_Recipe"), "fas fa-splotch"]
if (x.new) {
return new_recipe
} else if (this.facets.Recent.includes(x.id)) {
} else if (x.recent) {
return recent_recipe
} else {
return [undefined, undefined]

View File

@ -1,121 +1,117 @@
<template>
<div>
<b-modal class="modal" :id="`id_modal_add_book_${modal_id}`" :title="$t('Manage_Books')" :ok-title="$t('Add')" :cancel-title="$t('Close')" @ok="addToBook()" @shown="loadBookEntries">
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" v-for="be in this.recipe_book_list" v-bind:key="be.id">
{{ be.book_content.name }} <span class="btn btn-sm btn-danger" @click="removeFromBook(be)"><i class="fa fa-trash-alt"></i></span>
</li>
</ul>
<div>
<b-modal class="modal" :id="`id_modal_add_book_${modal_id}`" :title="$t('Manage_Books')" :ok-title="$t('Add')"
:cancel-title="$t('Close')" @ok="addToBook()" @shown="loadBookEntries">
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" v-for="be in this.recipe_book_list" v-bind:key="be.id">
{{ be.book_content.name }} <span class="btn btn-sm btn-danger" @click="removeFromBook(be)"><i class="fa fa-trash-alt"></i></span>
</li>
</ul>
<multiselect
style="margin-top: 1vh"
v-model="selected_book"
:options="books_filtered"
:taggable="true"
@tag="createBook"
v-bind:tag-placeholder="$t('Create')"
:placeholder="$t('Select_Book')"
label="name"
track-by="id"
id="id_books"
:multiple="false"
:loading="books_loading"
@search-change="loadBooks">
</multiselect>
</b-modal>
</div>
<multiselect
style="margin-top: 1vh"
v-model="selected_book"
:options="books_filtered"
:taggable="true"
@tag="createBook"
v-bind:tag-placeholder="$t('Create')"
:placeholder="$t('Select_Book')"
label="name"
track-by="id"
id="id_books"
:multiple="false"
:internal-search="false"
:loading="books_loading"
@search-change="loadBooks"
>
</multiselect>
</b-modal>
</div>
</template>
<script>
import Multiselect from "vue-multiselect"
import Multiselect from 'vue-multiselect'
import moment from 'moment'
import moment from "moment"
Vue.prototype.moment = moment
import Vue from "vue";
import {BootstrapVue} from "bootstrap-vue";
import {ApiApiFactory} from "@/utils/openapi/api";
import {makeStandardToast, StandardToasts} from "@/utils/utils";
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import { ApiApiFactory } from "@/utils/openapi/api"
import { makeStandardToast, StandardToasts } from "@/utils/utils"
Vue.use(BootstrapVue)
export default {
name: 'AddRecipeToBook',
components: {
Multiselect
},
props: {
recipe: Object,
modal_id: Number
},
data() {
return {
books: [],
books_loading: false,
recipe_book_list: [],
selected_book: null,
}
},
computed: {
books_filtered: function () {
let books_filtered = []
this.books.forEach(b => {
if (this.recipe_book_list.filter(e => e.book === b.id).length === 0) {
books_filtered.push(b)
name: "AddRecipeToBook",
components: {
Multiselect,
},
props: {
recipe: Object,
modal_id: Number,
},
data() {
return {
books: [],
books_loading: false,
recipe_book_list: [],
selected_book: null,
}
})
},
computed: {
books_filtered: function () {
let books_filtered = []
return books_filtered
}
},
mounted() {
this.books.forEach((b) => {
if (this.recipe_book_list.filter((e) => e.book === b.id).length === 0) {
books_filtered.push(b)
}
})
},
methods: {
loadBooks: function (query) {
this.books_loading = true
let apiFactory = new ApiApiFactory()
apiFactory.listRecipeBooks({query: {query: query}}).then(results => {
this.books = results.data.filter(e => this.recipe_book_list.indexOf(e) === -1)
this.books_loading = false
})
return books_filtered
},
},
createBook: function (name) {
let apiFactory = new ApiApiFactory()
apiFactory.createRecipeBook({name: name}).then(r => {
this.books.push(r.data)
this.selected_book = r.data
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
mounted() {},
methods: {
loadBooks: function (query) {
this.books_loading = true
let apiFactory = new ApiApiFactory()
apiFactory.listRecipeBooks({ query: { query: query } }).then((results) => {
this.books = results.data.filter((e) => this.recipe_book_list.indexOf(e) === -1)
this.books_loading = false
})
},
createBook: function (name) {
let apiFactory = new ApiApiFactory()
apiFactory.createRecipeBook({ name: name }).then((r) => {
this.books.push(r.data)
this.selected_book = r.data
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
},
addToBook: function () {
let apiFactory = new ApiApiFactory()
apiFactory.createRecipeBookEntry({ book: this.selected_book.id, recipe: this.recipe.id }).then((r) => {
this.recipe_book_list.push(r.data)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
},
removeFromBook: function (book_entry) {
let apiFactory = new ApiApiFactory()
apiFactory.destroyRecipeBookEntry(book_entry.id).then((r) => {
this.recipe_book_list = this.recipe_book_list.filter((e) => e.id !== book_entry.id)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
},
loadBookEntries: function () {
let apiFactory = new ApiApiFactory()
apiFactory.listRecipeBookEntrys({ query: { recipe: this.recipe.id } }).then((r) => {
this.recipe_book_list = r.data
this.loadBooks("")
})
},
},
addToBook: function () {
let apiFactory = new ApiApiFactory()
apiFactory.createRecipeBookEntry({book: this.selected_book.id, recipe: this.recipe.id}).then(r => {
this.recipe_book_list.push(r.data)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
},
removeFromBook: function (book_entry) {
let apiFactory = new ApiApiFactory()
apiFactory.destroyRecipeBookEntry(book_entry.id).then(r => {
this.recipe_book_list = this.recipe_book_list.filter(e => e.id !== book_entry.id)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
},
loadBookEntries: function () {
let apiFactory = new ApiApiFactory()
apiFactory.listRecipeBookEntrys({query: {recipe: this.recipe.id}}).then(r => {
this.recipe_book_list = r.data
this.loadBooks('')
})
}
}
}
</script>

View File

@ -6,6 +6,8 @@
:clear-on-select="true"
:hide-selected="multiple"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
:placeholder="lookupPlaceholder"
:label="label"
track-by="id"
@ -34,6 +36,7 @@ export default {
loading: false,
objects: [],
selected_objects: [],
options_limit: 25,
}
},
props: {
@ -89,6 +92,7 @@ export default {
page: 1,
pageSize: 10,
query: query,
limit: this.options_limit,
}
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)