more recipe search tests
This commit is contained in:
@ -1,8 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity
|
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
|
||||||
from django.core.cache import caches
|
from django.core.cache import caches
|
||||||
from django.db.models import Avg, Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, 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, Lower, Substr
|
from django.db.models.functions import Coalesce, Lower, Substr
|
||||||
@ -13,7 +13,8 @@ from cookbook.filters import RecipeFilter
|
|||||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||||
from cookbook.helper.permission_helper import has_group_permission
|
from cookbook.helper.permission_helper import has_group_permission
|
||||||
from cookbook.managers import DICTIONARY
|
from cookbook.managers import DICTIONARY
|
||||||
from cookbook.models import CookLog, CustomFilter, Food, Keyword, Recipe, SearchPreference, ViewLog
|
from cookbook.models import (CookLog, CustomFilter, Food, Keyword, Recipe, SearchFields,
|
||||||
|
SearchPreference, ViewLog)
|
||||||
from recipes import settings
|
from recipes import settings
|
||||||
|
|
||||||
|
|
||||||
@ -66,10 +67,12 @@ class RecipeSearch():
|
|||||||
self._internal = str2bool(self._params.get('internal', None))
|
self._internal = str2bool(self._params.get('internal', None))
|
||||||
self._random = str2bool(self._params.get('random', False))
|
self._random = str2bool(self._params.get('random', False))
|
||||||
self._new = str2bool(self._params.get('new', False))
|
self._new = str2bool(self._params.get('new', False))
|
||||||
self._last_viewed = int(self._params.get('last_viewed', 0))
|
self._num_recent = int(self._params.get('num_recent', 0))
|
||||||
self._include_children = str2bool(self._params.get('include_children', None))
|
self._include_children = str2bool(self._params.get('include_children', None))
|
||||||
self._timescooked = self._params.get('timescooked', None)
|
self._timescooked = self._params.get('timescooked', None)
|
||||||
self._lastcooked = self._params.get('lastcooked', None)
|
self._cookedon = self._params.get('cookedon', None)
|
||||||
|
self._createdon = self._params.get('createdon', None)
|
||||||
|
self._viewedon = self._params.get('viewedon', None)
|
||||||
# this supports hidden feature to find recipes missing X ingredients
|
# this supports hidden feature to find recipes missing X ingredients
|
||||||
try:
|
try:
|
||||||
self._makenow = int(makenow := self._params.get('makenow', None))
|
self._makenow = int(makenow := self._params.get('makenow', None))
|
||||||
@ -81,16 +84,16 @@ class RecipeSearch():
|
|||||||
|
|
||||||
self._search_type = self._search_prefs.search or 'plain'
|
self._search_type = self._search_prefs.search or 'plain'
|
||||||
if self._string:
|
if self._string:
|
||||||
unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True)
|
self._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._icontains_include = [x + '__unaccent' if x in self._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._istartswith_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
|
||||||
self._trigram_include = None
|
self._trigram_include = None
|
||||||
self._fulltext_include = None
|
self._fulltext_include = None
|
||||||
self._trigram = False
|
self._trigram = False
|
||||||
if self._postgres and self._string:
|
if self._postgres and self._string:
|
||||||
self._language = DICTIONARY.get(translation.get_language(), 'simple')
|
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._trigram_include = [x + '__unaccent' if x in self._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)
|
self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True) or None
|
||||||
|
|
||||||
if self._search_type not in ['websearch', 'raw'] and self._trigram_include:
|
if self._search_type not in ['websearch', 'raw'] and self._trigram_include:
|
||||||
self._trigram = True
|
self._trigram = True
|
||||||
@ -99,11 +102,7 @@ class RecipeSearch():
|
|||||||
search_type=self._search_type,
|
search_type=self._search_type,
|
||||||
config=self._language,
|
config=self._language,
|
||||||
)
|
)
|
||||||
self.search_rank = (
|
self.search_rank = None
|
||||||
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) # Is a large performance drag
|
|
||||||
)
|
|
||||||
self.orderby = []
|
self.orderby = []
|
||||||
self._default_sort = ['-favorite'] # TODO add user setting
|
self._default_sort = ['-favorite'] # TODO add user setting
|
||||||
self._filters = None
|
self._filters = None
|
||||||
@ -112,8 +111,10 @@ class RecipeSearch():
|
|||||||
def get_queryset(self, queryset):
|
def get_queryset(self, queryset):
|
||||||
self._queryset = queryset
|
self._queryset = queryset
|
||||||
self._build_sort_order()
|
self._build_sort_order()
|
||||||
self._recently_viewed(num_recent=self._last_viewed)
|
self._recently_viewed(num_recent=self._num_recent)
|
||||||
self._last_cooked(lastcooked=self._lastcooked)
|
self._cooked_on_filter(cooked_date=self._cookedon)
|
||||||
|
self._created_on_filter(created_date=self._createdon)
|
||||||
|
self._viewed_on_filter(viewed_date=self._viewedon)
|
||||||
self._favorite_recipes(timescooked=self._timescooked)
|
self._favorite_recipes(timescooked=self._timescooked)
|
||||||
self._new_recipes()
|
self._new_recipes()
|
||||||
self.keyword_filters(**self._keywords)
|
self.keyword_filters(**self._keywords)
|
||||||
@ -144,7 +145,7 @@ class RecipeSearch():
|
|||||||
# TODO add userpreference for default sort order and replace '-favorite'
|
# TODO add userpreference for default sort order and replace '-favorite'
|
||||||
default_order = ['-favorite']
|
default_order = ['-favorite']
|
||||||
# recent and new_recipe are always first; they float a few recipes to the top
|
# recent and new_recipe are always first; they float a few recipes to the top
|
||||||
if self._last_viewed:
|
if self._num_recent:
|
||||||
order += ['-recent']
|
order += ['-recent']
|
||||||
if self._new:
|
if self._new:
|
||||||
order += ['-new_recipe']
|
order += ['-new_recipe']
|
||||||
@ -162,7 +163,7 @@ class RecipeSearch():
|
|||||||
if '-score' in order:
|
if '-score' in order:
|
||||||
order.remove('-score')
|
order.remove('-score')
|
||||||
# if no sort order provided prioritize text search, followed by the default search
|
# if no sort order provided prioritize text search, followed by the default search
|
||||||
elif self._postgres and self._string:
|
elif self._postgres and self._string and (self._trigram or self._fulltext_include):
|
||||||
order += ['-score', *default_order]
|
order += ['-score', *default_order]
|
||||||
# otherwise sort by the remaining order_by attributes or favorite by default
|
# otherwise sort by the remaining order_by attributes or favorite by default
|
||||||
else:
|
else:
|
||||||
@ -180,13 +181,11 @@ class RecipeSearch():
|
|||||||
self.build_fulltext_filters(self._string)
|
self.build_fulltext_filters(self._string)
|
||||||
self.build_trigram(self._string)
|
self.build_trigram(self._string)
|
||||||
|
|
||||||
|
query_filter = Q()
|
||||||
if self._filters:
|
if self._filters:
|
||||||
query_filter = None
|
|
||||||
for f in self._filters:
|
for f in self._filters:
|
||||||
if query_filter:
|
query_filter |= f
|
||||||
query_filter |= f
|
|
||||||
else:
|
|
||||||
query_filter = f
|
|
||||||
self._queryset = self._queryset.filter(query_filter).distinct() # this creates duplicate records which can screw up other aggregates, see makenow for workaround
|
self._queryset = self._queryset.filter(query_filter).distinct() # this creates duplicate records which can screw up other aggregates, see makenow for workaround
|
||||||
if self._fulltext_include:
|
if self._fulltext_include:
|
||||||
if self._fuzzy_match is None:
|
if self._fuzzy_match is None:
|
||||||
@ -197,27 +196,56 @@ class RecipeSearch():
|
|||||||
if self._fuzzy_match is not None:
|
if self._fuzzy_match is not None:
|
||||||
simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity')
|
simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity')
|
||||||
if not self._fulltext_include:
|
if not self._fulltext_include:
|
||||||
self._queryset = self._queryset.annotate(score=Coalesce(Subquery(Max(simularity)), 0.0))
|
self._queryset = self._queryset.annotate(score=Coalesce(Subquery(simularity), 0.0))
|
||||||
else:
|
else:
|
||||||
self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
|
self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
|
||||||
if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None:
|
if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None:
|
||||||
self._queryset = self._queryset.annotate(score=Sum(F('rank')+F('simularity')))
|
self._queryset = self._queryset.annotate(score=Sum(F('rank')+F('simularity')))
|
||||||
else:
|
else:
|
||||||
self._queryset = self._queryset.filter(name__icontains=self._string)
|
query_filter = Q()
|
||||||
|
for f in [x + '__unaccent__iexact' if x in self._unaccent_include else x + '__iexact' for x in SearchFields.objects.all().values_list('field', flat=True)]:
|
||||||
|
query_filter |= Q(**{"%s" % f: self._string})
|
||||||
|
self._queryset = self._queryset.filter(query_filter).distinct()
|
||||||
|
|
||||||
def _last_cooked(self, lastcooked=None):
|
def _cooked_on_filter(self, cooked_date=None):
|
||||||
if self._sort_includes('lastcooked') or lastcooked:
|
if self._sort_includes('lastcooked') or cooked_date:
|
||||||
longTimeAgo = timezone.now() - timedelta(days=100000)
|
longTimeAgo = timezone.now() - timedelta(days=100000)
|
||||||
self._queryset = self._queryset.annotate(lastcooked=Coalesce(
|
self._queryset = self._queryset.annotate(lastcooked=Coalesce(
|
||||||
Max(Case(When(created_by=self._request.user, space=self._request.space, then='cooklog__created_at'))), Value(longTimeAgo)))
|
Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(longTimeAgo)))
|
||||||
if lastcooked is None:
|
if cooked_date is None:
|
||||||
return
|
return
|
||||||
lessthan = '-' in lastcooked[:1]
|
lessthan = '-' in cooked_date[:1]
|
||||||
|
cooked_date = date(*[int(x) for x in cooked_date.split('-') if x != ''])
|
||||||
|
|
||||||
if lessthan:
|
if lessthan:
|
||||||
self._queryset = self._queryset.filter(lastcooked__lte=lastcooked[1:]).exclude(lastcooked=longTimeAgo)
|
self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=longTimeAgo)
|
||||||
else:
|
else:
|
||||||
self._queryset = self._queryset.filter(lastcooked__gte=lastcooked).exclude(lastcooked=longTimeAgo)
|
self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=longTimeAgo)
|
||||||
|
|
||||||
|
def _created_on_filter(self, created_date=None):
|
||||||
|
if created_date is None:
|
||||||
|
return
|
||||||
|
lessthan = '-' in created_date[:1]
|
||||||
|
created_date = date(*[int(x) for x in created_date.split('-') if x != ''])
|
||||||
|
if lessthan:
|
||||||
|
self._queryset = self._queryset.filter(created_at__date__lte=created_date)
|
||||||
|
else:
|
||||||
|
self._queryset = self._queryset.filter(created_at__date__gte=created_date)
|
||||||
|
|
||||||
|
def _viewed_on_filter(self, viewed_date=None):
|
||||||
|
if self._sort_includes('lastviewed') or viewed_date:
|
||||||
|
longTimeAgo = timezone.now() - timedelta(days=100000)
|
||||||
|
self._queryset = self._queryset.annotate(lastviewed=Coalesce(
|
||||||
|
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo)))
|
||||||
|
if viewed_date is None:
|
||||||
|
return
|
||||||
|
lessthan = '-' in viewed_date[:1]
|
||||||
|
viewed_date = date(*[int(x) for x in viewed_date.split('-') if x != ''])
|
||||||
|
|
||||||
|
if lessthan:
|
||||||
|
self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||||
|
else:
|
||||||
|
self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||||
|
|
||||||
def _new_recipes(self, new_days=7):
|
def _new_recipes(self, new_days=7):
|
||||||
# TODO make new days a user-setting
|
# TODO make new days a user-setting
|
||||||
@ -232,12 +260,12 @@ class RecipeSearch():
|
|||||||
if not num_recent:
|
if not num_recent:
|
||||||
if self._sort_includes('lastviewed'):
|
if self._sort_includes('lastviewed'):
|
||||||
self._queryset = self._queryset.annotate(lastviewed=Coalesce(
|
self._queryset = self._queryset.annotate(lastviewed=Coalesce(
|
||||||
Max(Case(When(created_by=self._request.user, space=self._request.space, then='viewlog__pk'))), Value(0)))
|
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__pk'))), Value(0)))
|
||||||
return
|
return
|
||||||
|
|
||||||
last_viewed_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space).values(
|
num_recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space).values(
|
||||||
'recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
|
'recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
|
||||||
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=last_viewed_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
|
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
|
||||||
|
|
||||||
def _favorite_recipes(self, timescooked=None):
|
def _favorite_recipes(self, timescooked=None):
|
||||||
if self._sort_includes('favorite') or timescooked:
|
if self._sort_includes('favorite') or timescooked:
|
||||||
@ -388,18 +416,32 @@ class RecipeSearch():
|
|||||||
if not string:
|
if not string:
|
||||||
return
|
return
|
||||||
if self._fulltext_include:
|
if self._fulltext_include:
|
||||||
if not self._filters:
|
vectors = []
|
||||||
self._filters = []
|
rank = []
|
||||||
if 'name' in self._fulltext_include:
|
if 'name' in self._fulltext_include:
|
||||||
self._filters += [Q(name_search_vector=self.search_query)]
|
vectors.append('name_search_vector')
|
||||||
|
rank.append(SearchRank('name_search_vector', self.search_query, cover_density=True))
|
||||||
if 'description' in self._fulltext_include:
|
if 'description' in self._fulltext_include:
|
||||||
self._filters += [Q(desc_search_vector=self.search_query)]
|
vectors.append('desc_search_vector')
|
||||||
|
rank.append(SearchRank('desc_search_vector', self.search_query, cover_density=True))
|
||||||
if 'steps__instruction' in self._fulltext_include:
|
if 'steps__instruction' in self._fulltext_include:
|
||||||
self._filters += [Q(steps__search_vector=self.search_query)]
|
vectors.append('steps__search_vector')
|
||||||
|
rank.append(SearchRank('steps__search_vector', self.search_query, cover_density=True))
|
||||||
if 'keywords__name' in self._fulltext_include:
|
if 'keywords__name' in self._fulltext_include:
|
||||||
self._filters += [Q(keywords__in=Keyword.objects.filter(name__search=self.search_query))]
|
# explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields
|
||||||
|
vectors.append('keywords__name__unaccent')
|
||||||
|
rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True))
|
||||||
if 'steps__ingredients__food__name' in self._fulltext_include:
|
if 'steps__ingredients__food__name' in self._fulltext_include:
|
||||||
self._filters += [Q(steps__ingredients__food__in=Food.objects.filter(name__search=self.search_query))]
|
vectors.append('steps__ingredients__food__name__unaccent')
|
||||||
|
rank.append(SearchRank('steps__ingredients__food__name', self.search_query, cover_density=True))
|
||||||
|
|
||||||
|
for r in rank:
|
||||||
|
if self.search_rank is None:
|
||||||
|
self.search_rank = r
|
||||||
|
else:
|
||||||
|
self.search_rank += r
|
||||||
|
# modifying queryset will annotation creates duplicate results
|
||||||
|
self._filters.append(Q(id__in=Recipe.objects.annotate(vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
|
||||||
|
|
||||||
def build_text_filters(self, string=None):
|
def build_text_filters(self, string=None):
|
||||||
if not string:
|
if not string:
|
||||||
@ -653,7 +695,7 @@ class RecipeFacet():
|
|||||||
|
|
||||||
def _recipe_count_queryset(self, field, depth=1, steplen=4):
|
def _recipe_count_queryset(self, field, depth=1, steplen=4):
|
||||||
return Recipe.objects.filter(**{f'{field}__path__startswith': OuterRef('path')}, id__in=self._recipe_list, space=self._request.space
|
return Recipe.objects.filter(**{f'{field}__path__startswith': OuterRef('path')}, id__in=self._recipe_list, space=self._request.space
|
||||||
).values(child=Substr(f'{field}__path', 1, steplen)
|
).values(child=Substr(f'{field}__path', 1, steplen*depth)
|
||||||
).annotate(count=Count('pk', distinct=True)).values('count')
|
).annotate(count=Count('pk', distinct=True)).values('count')
|
||||||
|
|
||||||
def _keyword_queryset(self, queryset, keyword=None):
|
def _keyword_queryset(self, queryset, keyword=None):
|
||||||
|
@ -1106,7 +1106,7 @@ class SearchPreference(models.Model, PermissionModelMixin):
|
|||||||
istartswith = models.ManyToManyField(SearchFields, related_name="istartswith_fields", blank=True)
|
istartswith = models.ManyToManyField(SearchFields, related_name="istartswith_fields", blank=True)
|
||||||
trigram = models.ManyToManyField(SearchFields, related_name="trigram_fields", blank=True, default=nameSearchField)
|
trigram = models.ManyToManyField(SearchFields, related_name="trigram_fields", blank=True, default=nameSearchField)
|
||||||
fulltext = models.ManyToManyField(SearchFields, related_name="fulltext_fields", blank=True)
|
fulltext = models.ManyToManyField(SearchFields, related_name="fulltext_fields", blank=True)
|
||||||
trigram_threshold = models.DecimalField(default=0.1, decimal_places=2, max_digits=3)
|
trigram_threshold = models.DecimalField(default=0.2, decimal_places=2, max_digits=3)
|
||||||
|
|
||||||
|
|
||||||
class UserFile(ExportModelOperationsMixin('user_files'), models.Model, PermissionModelMixin):
|
class UserFile(ExportModelOperationsMixin('user_files'), models.Model, PermissionModelMixin):
|
||||||
|
@ -37,6 +37,7 @@ def update_recipe_search_vector(sender, instance=None, created=False, **kwargs):
|
|||||||
if SQLITE:
|
if SQLITE:
|
||||||
return
|
return
|
||||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||||
|
# these indexed fields are space wide, reading user preferences would lead to inconsistent behavior
|
||||||
instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language)
|
instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language)
|
||||||
instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language)
|
instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language)
|
||||||
try:
|
try:
|
||||||
|
@ -158,6 +158,31 @@ def dict_compare(d1, d2, details=False):
|
|||||||
return any([not added, not removed, not modified, not modified_dicts])
|
return any([not added, not removed, not modified, not modified_dicts])
|
||||||
|
|
||||||
|
|
||||||
|
def transpose(text, number=2):
|
||||||
|
|
||||||
|
# select random token
|
||||||
|
tokens = text.split()
|
||||||
|
positions = list(i for i, e in enumerate(tokens) if len(e) > 1)
|
||||||
|
|
||||||
|
if positions:
|
||||||
|
|
||||||
|
token_pos = random.choice(positions)
|
||||||
|
|
||||||
|
# select random positions in token
|
||||||
|
positions = random.sample(range(len(tokens[token_pos])), number)
|
||||||
|
|
||||||
|
# swap the positions
|
||||||
|
l = list(tokens[token_pos])
|
||||||
|
for first, second in zip(positions[::2], positions[1::2]):
|
||||||
|
l[first], l[second] = l[second], l[first]
|
||||||
|
|
||||||
|
# replace original tokens with swapped
|
||||||
|
tokens[token_pos] = ''.join(l)
|
||||||
|
|
||||||
|
# return text with the swapped token
|
||||||
|
return ' '.join(tokens)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def recipe_1_s1(space_1, u1_s1):
|
def recipe_1_s1(space_1, u1_s1):
|
||||||
return get_random_recipe(space_1, u1_s1)
|
return get_random_recipe(space_1, u1_s1)
|
||||||
|
@ -277,6 +277,15 @@ class ShoppingListEntryFactory(factory.django.DjangoModelFactory):
|
|||||||
delay_until = None
|
delay_until = None
|
||||||
space = factory.SubFactory(SpaceFactory)
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create(cls, target_class, *args, **kwargs): # override create to prevent auto_add_now from changing the created_at date
|
||||||
|
created_at = kwargs.pop('created_at', None)
|
||||||
|
obj = super(ShoppingListEntryFactory, cls)._create(target_class, *args, **kwargs)
|
||||||
|
if created_at is not None:
|
||||||
|
obj.created_at = created_at
|
||||||
|
obj.save()
|
||||||
|
return obj
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
has_mealplan = False
|
has_mealplan = False
|
||||||
|
|
||||||
@ -324,8 +333,6 @@ class StepFactory(factory.django.DjangoModelFactory):
|
|||||||
has_recipe = False
|
has_recipe = False
|
||||||
self.ingredients.add(IngredientFactory(space=self.space, food__has_recipe=has_recipe))
|
self.ingredients.add(IngredientFactory(space=self.space, food__has_recipe=has_recipe))
|
||||||
num_header = kwargs.get('header', 0)
|
num_header = kwargs.get('header', 0)
|
||||||
#######################################################
|
|
||||||
#######################################################
|
|
||||||
if num_header > 0:
|
if num_header > 0:
|
||||||
for i in range(num_header):
|
for i in range(num_header):
|
||||||
self.ingredients.add(IngredientFactory(food=None, unit=None, amount=0, is_header=True, space=self.space))
|
self.ingredients.add(IngredientFactory(food=None, unit=None, amount=0, is_header=True, space=self.space))
|
||||||
@ -354,6 +361,15 @@ class RecipeFactory(factory.django.DjangoModelFactory):
|
|||||||
created_at = factory.LazyAttribute(lambda x: faker.date_this_decade())
|
created_at = factory.LazyAttribute(lambda x: faker.date_this_decade())
|
||||||
space = factory.SubFactory(SpaceFactory)
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create(cls, target_class, *args, **kwargs): # override create to prevent auto_add_now from changing the created_at date
|
||||||
|
created_at = kwargs.pop('created_at', None)
|
||||||
|
obj = super(RecipeFactory, cls)._create(target_class, *args, **kwargs)
|
||||||
|
if created_at is not None:
|
||||||
|
obj.created_at = created_at
|
||||||
|
obj.save()
|
||||||
|
return obj
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def keywords(self, create, extracted, **kwargs):
|
def keywords(self, create, extracted, **kwargs):
|
||||||
if not create:
|
if not create:
|
||||||
@ -404,3 +420,47 @@ class RecipeFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'cookbook.Recipe'
|
model = 'cookbook.Recipe'
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class CookLogFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""CookLog factory."""
|
||||||
|
recipe = factory.SubFactory(RecipeFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
created_at = factory.LazyAttribute(lambda x: faker.date_this_decade())
|
||||||
|
rating = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=5))
|
||||||
|
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=32))
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create(cls, target_class, *args, **kwargs): # override create to prevent auto_add_now from changing the created_at date
|
||||||
|
created_at = kwargs.pop('created_at', None)
|
||||||
|
obj = super(CookLogFactory, cls)._create(target_class, *args, **kwargs)
|
||||||
|
if created_at is not None:
|
||||||
|
obj.created_at = created_at
|
||||||
|
obj.save()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.CookLog'
|
||||||
|
|
||||||
|
|
||||||
|
@register
|
||||||
|
class ViewLogFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""ViewLog factory."""
|
||||||
|
recipe = factory.SubFactory(RecipeFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
created_at = factory.LazyAttribute(lambda x: faker.past_datetime(start_date='-365d'))
|
||||||
|
space = factory.SubFactory(SpaceFactory)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _create(cls, target_class, *args, **kwargs): # override create to prevent auto_add_now from changing the created_at date
|
||||||
|
created_at = kwargs.pop('created_at', None)
|
||||||
|
obj = super(ViewLogFactory, cls)._create(target_class, *args, **kwargs)
|
||||||
|
if created_at is not None:
|
||||||
|
obj.created_at = created_at
|
||||||
|
obj.save()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = 'cookbook.ViewLog'
|
||||||
|
@ -1,58 +1,78 @@
|
|||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import auth
|
from django.contrib import auth
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django_scopes import scope, scopes_disabled
|
from django_scopes import scope, scopes_disabled
|
||||||
|
|
||||||
from cookbook.models import Food, Recipe, SearchFields
|
from cookbook.models import Food, Recipe, SearchFields
|
||||||
from cookbook.tests.factories import (FoodFactory, IngredientFactory, KeywordFactory,
|
from cookbook.tests.conftest import transpose
|
||||||
RecipeBookEntryFactory, RecipeFactory, UnitFactory)
|
from cookbook.tests.factories import (CookLogFactory, FoodFactory, IngredientFactory,
|
||||||
|
KeywordFactory, RecipeBookEntryFactory, RecipeFactory,
|
||||||
# TODO recipe name/description/instructions/keyword/book/food test search with icontains, istarts with/ full text(?? probably when word changes based on conjugation??), trigram, unaccent
|
UnitFactory, ViewLogFactory)
|
||||||
|
|
||||||
|
|
||||||
# TODO test combining any/all of the above
|
# TODO test combining any/all of the above
|
||||||
# TODO search rating as user or when another user rated
|
|
||||||
# TODO search last cooked
|
|
||||||
# TODO changing lsat_viewed ## to return on search
|
|
||||||
# TODO test sort_by
|
# TODO test sort_by
|
||||||
# TODO test sort_by new X number of recipes are new within last Y days
|
# TODO test sort_by new X number of recipes are new within last Y days
|
||||||
# TODO test loading custom filter
|
# TODO test loading custom filter
|
||||||
# TODO test loading custom filter with overrided params
|
# TODO test loading custom filter with overrided params
|
||||||
# TODO makenow with above filters
|
# TODO makenow with above filters
|
||||||
# TODO test search for number of times cooked (self vs others)
|
# TODO test search food/keywords including/excluding children
|
||||||
# TODO test including children
|
|
||||||
LIST_URL = 'api:recipe-list'
|
LIST_URL = 'api:recipe-list'
|
||||||
|
sqlite = settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def accent():
|
def accent():
|
||||||
return "àèìòù"
|
return "àbçđêf ğĦìĵķĽmñ öPqŕşŧ úvŵxyž"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def unaccent():
|
def unaccent():
|
||||||
return "aeiou"
|
return "abcdef ghijklmn opqrst uvwxyz"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def user1(request, space_1, u1_s1):
|
def user1(request, space_1, u1_s1, unaccent):
|
||||||
user = auth.get_user(u1_s1)
|
user = auth.get_user(u1_s1)
|
||||||
params = {x[0]: x[1] for x in request.param}
|
try:
|
||||||
|
params = {x[0]: x[1] for x in request.param}
|
||||||
|
except AttributeError:
|
||||||
|
params = {}
|
||||||
|
result = 1
|
||||||
|
misspelled_result = 0
|
||||||
|
search_term = unaccent
|
||||||
|
|
||||||
if params.get('fuzzy_lookups', False):
|
if params.get('fuzzy_lookups', False):
|
||||||
user.searchpreference.lookup = True
|
user.searchpreference.lookup = True
|
||||||
|
misspelled_result = 1
|
||||||
if params.get('fuzzy_search', False):
|
if params.get('fuzzy_search', False):
|
||||||
user.searchpreference.trigram.set(SearchFields.objects.all())
|
user.searchpreference.trigram.set(SearchFields.objects.all())
|
||||||
|
misspelled_result = 1
|
||||||
|
|
||||||
|
if params.get('icontains', False):
|
||||||
|
user.searchpreference.icontains.set(SearchFields.objects.all())
|
||||||
|
search_term = 'ghijklmn'
|
||||||
|
if params.get('istartswith', False):
|
||||||
|
user.searchpreference.istartswith.set(SearchFields.objects.all())
|
||||||
|
search_term = 'abcdef'
|
||||||
if params.get('unaccent', False):
|
if params.get('unaccent', False):
|
||||||
user.searchpreference.unaccent.set(SearchFields.objects.all())
|
user.searchpreference.unaccent.set(SearchFields.objects.all())
|
||||||
|
misspelled_result *= 2
|
||||||
|
result *= 2
|
||||||
|
# full text vectors are hard coded to use unaccent - put this after unaccent to override result
|
||||||
|
if params.get('fulltext', False):
|
||||||
|
user.searchpreference.fulltext.set(SearchFields.objects.all())
|
||||||
|
# user.searchpreference.search = 'websearch'
|
||||||
|
search_term = 'ghijklmn uvwxyz'
|
||||||
result = 2
|
result = 2
|
||||||
else:
|
user.searchpreference.save()
|
||||||
result = 1
|
misspelled_term = transpose(search_term, number=3)
|
||||||
|
return (u1_s1, result, misspelled_result, search_term, misspelled_term, params)
|
||||||
user.userpreference.save()
|
|
||||||
return (u1_s1, result, params)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@ -61,15 +81,21 @@ def recipes(space_1):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def found_recipe(request, space_1, accent, unaccent):
|
def found_recipe(request, space_1, accent, unaccent, u1_s1, u2_s1):
|
||||||
|
user1 = auth.get_user(u1_s1)
|
||||||
|
user2 = auth.get_user(u2_s1)
|
||||||
|
days_3 = timezone.now() - timedelta(days=3)
|
||||||
|
days_15 = timezone.now() - timedelta(days=15)
|
||||||
|
days_30 = timezone.now() - timedelta(days=30)
|
||||||
recipe1 = RecipeFactory.create(space=space_1)
|
recipe1 = RecipeFactory.create(space=space_1)
|
||||||
recipe2 = RecipeFactory.create(space=space_1)
|
recipe2 = RecipeFactory.create(space=space_1)
|
||||||
recipe3 = RecipeFactory.create(space=space_1)
|
recipe3 = RecipeFactory.create(space=space_1)
|
||||||
|
obj1 = None
|
||||||
|
obj2 = None
|
||||||
|
|
||||||
if request.param.get('food', None):
|
if request.param.get('food', None):
|
||||||
obj1 = FoodFactory.create(name=unaccent, space=space_1)
|
obj1 = FoodFactory.create(name=unaccent, space=space_1)
|
||||||
obj2 = FoodFactory.create(name=accent, space=space_1)
|
obj2 = FoodFactory.create(name=accent, space=space_1)
|
||||||
|
|
||||||
recipe1.steps.first().ingredients.add(IngredientFactory.create(food=obj1))
|
recipe1.steps.first().ingredients.add(IngredientFactory.create(food=obj1))
|
||||||
recipe2.steps.first().ingredients.add(IngredientFactory.create(food=obj2))
|
recipe2.steps.first().ingredients.add(IngredientFactory.create(food=obj2))
|
||||||
recipe3.steps.first().ingredients.add(IngredientFactory.create(food=obj1), IngredientFactory.create(food=obj2))
|
recipe3.steps.first().ingredients.add(IngredientFactory.create(food=obj1), IngredientFactory.create(food=obj2))
|
||||||
@ -79,6 +105,10 @@ def found_recipe(request, space_1, accent, unaccent):
|
|||||||
recipe1.keywords.add(obj1)
|
recipe1.keywords.add(obj1)
|
||||||
recipe2.keywords.add(obj2)
|
recipe2.keywords.add(obj2)
|
||||||
recipe3.keywords.add(obj1, obj2)
|
recipe3.keywords.add(obj1, obj2)
|
||||||
|
recipe1.name = unaccent
|
||||||
|
recipe2.name = accent
|
||||||
|
recipe1.save()
|
||||||
|
recipe2.save()
|
||||||
if request.param.get('book', None):
|
if request.param.get('book', None):
|
||||||
obj1 = RecipeBookEntryFactory.create(recipe=recipe1).book
|
obj1 = RecipeBookEntryFactory.create(recipe=recipe1).book
|
||||||
obj2 = RecipeBookEntryFactory.create(recipe=recipe2).book
|
obj2 = RecipeBookEntryFactory.create(recipe=recipe2).book
|
||||||
@ -87,12 +117,51 @@ def found_recipe(request, space_1, accent, unaccent):
|
|||||||
if request.param.get('unit', None):
|
if request.param.get('unit', None):
|
||||||
obj1 = UnitFactory.create(name=unaccent, space=space_1)
|
obj1 = UnitFactory.create(name=unaccent, space=space_1)
|
||||||
obj2 = UnitFactory.create(name=accent, space=space_1)
|
obj2 = UnitFactory.create(name=accent, space=space_1)
|
||||||
|
|
||||||
recipe1.steps.first().ingredients.add(IngredientFactory.create(unit=obj1))
|
recipe1.steps.first().ingredients.add(IngredientFactory.create(unit=obj1))
|
||||||
recipe2.steps.first().ingredients.add(IngredientFactory.create(unit=obj2))
|
recipe2.steps.first().ingredients.add(IngredientFactory.create(unit=obj2))
|
||||||
recipe3.steps.first().ingredients.add(IngredientFactory.create(unit=obj1), IngredientFactory.create(unit=obj2))
|
recipe3.steps.first().ingredients.add(IngredientFactory.create(unit=obj1), IngredientFactory.create(unit=obj2))
|
||||||
|
if request.param.get('name', None):
|
||||||
|
recipe1.name = unaccent
|
||||||
|
recipe2.name = accent
|
||||||
|
recipe1.save()
|
||||||
|
recipe2.save()
|
||||||
|
if request.param.get('description', None):
|
||||||
|
recipe1.description = unaccent
|
||||||
|
recipe2.description = accent
|
||||||
|
recipe1.save()
|
||||||
|
recipe2.save()
|
||||||
|
if request.param.get('instruction', None):
|
||||||
|
i1 = recipe1.steps.first()
|
||||||
|
i2 = recipe2.steps.first()
|
||||||
|
i1.instruction = unaccent
|
||||||
|
i2.instruction = accent
|
||||||
|
i1.save()
|
||||||
|
i2.save()
|
||||||
|
if request.param.get('createdon', None):
|
||||||
|
recipe1.created_at = days_3
|
||||||
|
recipe2.created_at = days_30
|
||||||
|
recipe3.created_at = days_15
|
||||||
|
recipe1.save()
|
||||||
|
recipe2.save()
|
||||||
|
recipe3.save()
|
||||||
|
if request.param.get('viewedon', None):
|
||||||
|
ViewLogFactory.create(recipe=recipe1, created_by=user1, created_at=days_3, space=space_1)
|
||||||
|
ViewLogFactory.create(recipe=recipe2, created_by=user1, created_at=days_30, space=space_1)
|
||||||
|
ViewLogFactory.create(recipe=recipe3, created_by=user2, created_at=days_15, space=space_1)
|
||||||
|
if request.param.get('cookedon', None):
|
||||||
|
CookLogFactory.create(recipe=recipe1, created_by=user1, created_at=days_3, space=space_1)
|
||||||
|
CookLogFactory.create(recipe=recipe2, created_by=user1, created_at=days_30, space=space_1)
|
||||||
|
CookLogFactory.create(recipe=recipe3, created_by=user2, created_at=days_15, space=space_1)
|
||||||
|
if request.param.get('timescooked', None):
|
||||||
|
CookLogFactory.create_batch(5, recipe=recipe1, created_by=user1, space=space_1)
|
||||||
|
CookLogFactory.create(recipe=recipe2, created_by=user1, space=space_1)
|
||||||
|
CookLogFactory.create_batch(3, recipe=recipe3, created_by=user2, space=space_1)
|
||||||
|
if request.param.get('rating', None):
|
||||||
|
CookLogFactory.create(recipe=recipe1, created_by=user1, rating=5.0, space=space_1)
|
||||||
|
CookLogFactory.create(recipe=recipe2, created_by=user1, rating=1.0, space=space_1)
|
||||||
|
CookLogFactory.create(recipe=recipe3, created_by=user2, rating=3.0, space=space_1)
|
||||||
|
|
||||||
return (recipe1, recipe2, recipe3, obj1, obj2)
|
return (recipe1, recipe2, recipe3, obj1, obj2, request.param)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("found_recipe, param_type", [
|
@pytest.mark.parametrize("found_recipe, param_type", [
|
||||||
@ -101,7 +170,7 @@ def found_recipe(request, space_1, accent, unaccent):
|
|||||||
({'book': True}, 'books'),
|
({'book': True}, 'books'),
|
||||||
], indirect=['found_recipe'])
|
], indirect=['found_recipe'])
|
||||||
@pytest.mark.parametrize('operator', [('_or', 3, 0), ('_and', 1, 2), ])
|
@pytest.mark.parametrize('operator', [('_or', 3, 0), ('_and', 1, 2), ])
|
||||||
def test_search_or_and_not(found_recipe, param_type, operator, recipes, user1, space_1):
|
def test_search_or_and_not(found_recipe, param_type, operator, recipes, u1_s1, space_1):
|
||||||
with scope(space=space_1):
|
with scope(space=space_1):
|
||||||
param1 = f"{param_type}{operator[0]}={found_recipe[3].id}"
|
param1 = f"{param_type}{operator[0]}={found_recipe[3].id}"
|
||||||
param2 = f"{param_type}{operator[0]}={found_recipe[4].id}"
|
param2 = f"{param_type}{operator[0]}={found_recipe[4].id}"
|
||||||
@ -163,9 +232,12 @@ def test_search_units(found_recipe, recipes, u1_s1, space_1):
|
|||||||
assert found_recipe[2].id in [x['id'] for x in r['results']]
|
assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sqlite, reason="requires PostgreSQL")
|
||||||
@pytest.mark.parametrize("user1", itertools.product(
|
@pytest.mark.parametrize("user1", itertools.product(
|
||||||
[('fuzzy_lookups', True), ('fuzzy_lookups', False)],
|
[
|
||||||
[('fuzzy_search', True), ('fuzzy_search', False)],
|
('fuzzy_search', True), ('fuzzy_search', False),
|
||||||
|
('fuzzy_lookups', True), ('fuzzy_lookups', False)
|
||||||
|
],
|
||||||
[('unaccent', True), ('unaccent', False)]
|
[('unaccent', True), ('unaccent', False)]
|
||||||
), indirect=['user1'])
|
), indirect=['user1'])
|
||||||
@pytest.mark.parametrize("found_recipe, param_type", [
|
@pytest.mark.parametrize("found_recipe, param_type", [
|
||||||
@ -176,12 +248,99 @@ def test_search_units(found_recipe, recipes, u1_s1, space_1):
|
|||||||
def test_fuzzy_lookup(found_recipe, recipes, param_type, user1, space_1):
|
def test_fuzzy_lookup(found_recipe, recipes, param_type, user1, space_1):
|
||||||
with scope(space=space_1):
|
with scope(space=space_1):
|
||||||
list_url = f'api:{param_type}-list'
|
list_url = f'api:{param_type}-list'
|
||||||
param1 = "query=aeiou"
|
param1 = f"query={user1[3]}"
|
||||||
param2 = "query=aoieu"
|
param2 = f"query={user1[4]}"
|
||||||
|
|
||||||
# test fuzzy off - also need search settings on/off
|
|
||||||
r = json.loads(user1[0].get(reverse(list_url) + f'?{param1}&limit=2').content)
|
r = json.loads(user1[0].get(reverse(list_url) + f'?{param1}&limit=2').content)
|
||||||
assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[3].id, found_recipe[4].id]]) == user1[1]
|
assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[3].id, found_recipe[4].id]]) == user1[1]
|
||||||
|
|
||||||
r = json.loads(user1[0].get(reverse(list_url) + f'?{param2}').content)
|
r = json.loads(user1[0].get(reverse(list_url) + f'?{param2}&limit=10').content)
|
||||||
assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[3].id, found_recipe[4].id]]) == user1[1]
|
assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[3].id, found_recipe[4].id]]) == user1[2]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sqlite, reason="requires PostgreSQL")
|
||||||
|
@pytest.mark.parametrize("user1", itertools.product(
|
||||||
|
[
|
||||||
|
('fuzzy_search', True), ('fuzzy_search', False),
|
||||||
|
('fulltext', True), ('fulltext', False),
|
||||||
|
('icontains', True), ('icontains', False),
|
||||||
|
('istartswith', True), ('istartswith', False),
|
||||||
|
],
|
||||||
|
[('unaccent', True), ('unaccent', False)]
|
||||||
|
), indirect=['user1'])
|
||||||
|
@pytest.mark.parametrize("found_recipe", [
|
||||||
|
({'name': True}),
|
||||||
|
({'description': True}),
|
||||||
|
({'instruction': True}),
|
||||||
|
({'keyword': True}),
|
||||||
|
({'food': True}),
|
||||||
|
], indirect=['found_recipe'])
|
||||||
|
def test_search_string(found_recipe, recipes, user1, space_1):
|
||||||
|
with scope(space=space_1):
|
||||||
|
param1 = f"query={user1[3]}"
|
||||||
|
param2 = f"query={user1[4]}"
|
||||||
|
|
||||||
|
r = json.loads(user1[0].get(reverse(LIST_URL) + f'?{param1}').content)
|
||||||
|
assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[0].id, found_recipe[1].id]]) == user1[1]
|
||||||
|
|
||||||
|
r = json.loads(user1[0].get(reverse(LIST_URL) + f'?{param2}').content)
|
||||||
|
assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[0].id, found_recipe[1].id]]) == user1[2]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("found_recipe, param_type, result", [
|
||||||
|
({'viewedon': True}, 'viewedon', (1, 1)),
|
||||||
|
({'cookedon': True}, 'cookedon', (1, 1)),
|
||||||
|
({'createdon': True}, 'createdon', (2, 12)), # created dates are not filtered by user
|
||||||
|
], indirect=['found_recipe'])
|
||||||
|
def test_search_date(found_recipe, recipes, param_type, result, u1_s1, u2_s1, space_1):
|
||||||
|
date = (timezone.now() - timedelta(days=15)).strftime("%Y-%m-%d")
|
||||||
|
param1 = f"?{param_type}={date}"
|
||||||
|
param2 = f"?{param_type}=-{date}"
|
||||||
|
r = json.loads(u1_s1.get(reverse(LIST_URL) + f'{param1}').content)
|
||||||
|
assert r['count'] == result[0]
|
||||||
|
assert found_recipe[0].id in [x['id'] for x in r['results']]
|
||||||
|
|
||||||
|
r = json.loads(u1_s1.get(reverse(LIST_URL) + f'{param2}').content)
|
||||||
|
assert r['count'] == result[1]
|
||||||
|
assert found_recipe[1].id in [x['id'] for x in r['results']]
|
||||||
|
|
||||||
|
# test today's date returns for lte and gte searches
|
||||||
|
r = json.loads(u2_s1.get(reverse(LIST_URL) + f'{param1}').content)
|
||||||
|
assert r['count'] == result[0]
|
||||||
|
assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||||
|
|
||||||
|
r = json.loads(u2_s1.get(reverse(LIST_URL) + f'{param2}').content)
|
||||||
|
assert r['count'] == result[1]
|
||||||
|
assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("found_recipe, param_type", [
|
||||||
|
({'rating': True}, 'rating'),
|
||||||
|
({'timescooked': True}, 'timescooked'),
|
||||||
|
], indirect=['found_recipe'])
|
||||||
|
def test_search_count(found_recipe, recipes, param_type, u1_s1, u2_s1, space_1):
|
||||||
|
param1 = f'?{param_type}=3'
|
||||||
|
param2 = f'?{param_type}=-3'
|
||||||
|
param3 = f'?{param_type}=0'
|
||||||
|
|
||||||
|
r = json.loads(u1_s1.get(reverse(LIST_URL) + param1).content)
|
||||||
|
assert r['count'] == 1
|
||||||
|
assert found_recipe[0].id in [x['id'] for x in r['results']]
|
||||||
|
|
||||||
|
r = json.loads(u1_s1.get(reverse(LIST_URL) + param2).content)
|
||||||
|
assert r['count'] == 1
|
||||||
|
assert found_recipe[1].id in [x['id'] for x in r['results']]
|
||||||
|
|
||||||
|
# test search for not rated/cooked
|
||||||
|
r = json.loads(u1_s1.get(reverse(LIST_URL) + param3).content)
|
||||||
|
assert r['count'] == 11
|
||||||
|
assert (found_recipe[0].id or found_recipe[1].id) not in [x['id'] for x in r['results']]
|
||||||
|
|
||||||
|
# test matched returns for lte and gte searches
|
||||||
|
r = json.loads(u2_s1.get(reverse(LIST_URL) + param1).content)
|
||||||
|
assert r['count'] == 1
|
||||||
|
assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||||
|
|
||||||
|
r = json.loads(u2_s1.get(reverse(LIST_URL) + param2).content)
|
||||||
|
assert r['count'] == 1
|
||||||
|
assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||||
|
@ -652,7 +652,9 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
|||||||
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
|
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
|
||||||
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
|
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
|
||||||
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
|
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
|
||||||
QueryParam(name='lastcooked', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
|
QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
|
||||||
|
QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
|
||||||
|
QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
|
||||||
QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
|
QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
|
||||||
]
|
]
|
||||||
schema = QueryParamAutoSchema()
|
schema = QueryParamAutoSchema()
|
||||||
|
@ -361,17 +361,18 @@ def user_settings(request):
|
|||||||
sp.istartswith.clear()
|
sp.istartswith.clear()
|
||||||
sp.trigram.set([SearchFields.objects.get(name='Name')])
|
sp.trigram.set([SearchFields.objects.get(name='Name')])
|
||||||
sp.fulltext.clear()
|
sp.fulltext.clear()
|
||||||
sp.trigram_threshold = 0.1
|
sp.trigram_threshold = 0.2
|
||||||
|
|
||||||
if search_form.cleaned_data['preset'] == 'precise':
|
if search_form.cleaned_data['preset'] == 'precise':
|
||||||
sp.search = SearchPreference.WEB
|
sp.search = SearchPreference.WEB
|
||||||
sp.lookup = True
|
sp.lookup = True
|
||||||
sp.unaccent.set(SearchFields.objects.all())
|
sp.unaccent.set(SearchFields.objects.all())
|
||||||
sp.icontains.clear()
|
# full text on food is very slow, add search_vector field and index it (including Admin functions and postsave signal to rebuild index)
|
||||||
|
sp.icontains.set([SearchFields.objects.get(name__in=['Name', 'Ingredients'])])
|
||||||
sp.istartswith.set([SearchFields.objects.get(name='Name')])
|
sp.istartswith.set([SearchFields.objects.get(name='Name')])
|
||||||
sp.trigram.clear()
|
sp.trigram.clear()
|
||||||
sp.fulltext.set(SearchFields.objects.all())
|
sp.fulltext.set(SearchFields.objects.filter(name__in=['Ingredients']))
|
||||||
sp.trigram_threshold = 0.1
|
sp.trigram_threshold = 0.2
|
||||||
|
|
||||||
sp.save()
|
sp.save()
|
||||||
elif 'shopping_form' in request.POST:
|
elif 'shopping_form' in request.POST:
|
||||||
|
@ -122,7 +122,7 @@
|
|||||||
<b-form-checkbox switch v-model="ui.show_makenow" id="popover-show_makenow" size="sm"></b-form-checkbox>
|
<b-form-checkbox switch v-model="ui.show_makenow" id="popover-show_makenow" size="sm"></b-form-checkbox>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-form-group v-if="ui.enable_expert" v-bind:label="$t('last_cooked')" label-for="popover-show_sortby" label-cols="8" class="mb-1">
|
<b-form-group v-if="ui.enable_expert" v-bind:label="$t('last_cooked')" label-for="popover-show_sortby" label-cols="8" class="mb-1">
|
||||||
<b-form-checkbox switch v-model="ui.show_lastcooked" id="popover-show_lastcooked" size="sm"></b-form-checkbox>
|
<b-form-checkbox switch v-model="ui.show_cookedon" id="popover-show_cookedon" size="sm"></b-form-checkbox>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
</b-tab>
|
</b-tab>
|
||||||
|
|
||||||
@ -393,7 +393,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- special switches -->
|
<!-- special switches -->
|
||||||
<div class="row g-0" v-if="ui.show_timescooked || ui.show_makenow || ui.show_lastcooked">
|
<div class="row g-0" v-if="ui.show_timescooked || ui.show_makenow || ui.show_cookedon">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<b-input-group class="mt-2">
|
<b-input-group class="mt-2">
|
||||||
<!-- times cooked -->
|
<!-- times cooked -->
|
||||||
@ -410,9 +410,9 @@
|
|||||||
</b-input-group-text>
|
</b-input-group-text>
|
||||||
</b-input-group-append>
|
</b-input-group-append>
|
||||||
<!-- date cooked -->
|
<!-- date cooked -->
|
||||||
<b-input-group-append v-if="ui.show_lastcooked">
|
<b-input-group-append v-if="ui.show_cookedon">
|
||||||
<b-form-datepicker
|
<b-form-datepicker
|
||||||
v-model="search.lastcooked"
|
v-model="search.cookedon"
|
||||||
:max="yesterday"
|
:max="yesterday"
|
||||||
no-highlight-today
|
no-highlight-today
|
||||||
reset-button
|
reset-button
|
||||||
@ -422,8 +422,8 @@
|
|||||||
@input="refreshData(false)"
|
@input="refreshData(false)"
|
||||||
/>
|
/>
|
||||||
<b-input-group-text>
|
<b-input-group-text>
|
||||||
<b-form-checkbox v-model="search.lastcooked_gte" name="check-button" @change="refreshData(false)" class="shadow-none" switch style="width: 4em">
|
<b-form-checkbox v-model="search.cookedon_gte" name="check-button" @change="refreshData(false)" class="shadow-none" switch style="width: 4em">
|
||||||
<span class="text-uppercase" v-if="search.lastcooked_gte">>=</span>
|
<span class="text-uppercase" v-if="search.cookedon_gte">>=</span>
|
||||||
<span class="text-uppercase" v-else><=</span>
|
<span class="text-uppercase" v-else><=</span>
|
||||||
</b-form-checkbox>
|
</b-form-checkbox>
|
||||||
</b-input-group-text>
|
</b-input-group-text>
|
||||||
@ -563,8 +563,12 @@ export default {
|
|||||||
timescooked: undefined,
|
timescooked: undefined,
|
||||||
timescooked_gte: true,
|
timescooked_gte: true,
|
||||||
makenow: false,
|
makenow: false,
|
||||||
lastcooked: undefined,
|
cookedon: undefined,
|
||||||
lastcooked_gte: true,
|
cookedon_gte: true,
|
||||||
|
createdon: undefined,
|
||||||
|
createdon_gte: true,
|
||||||
|
viewedon: undefined,
|
||||||
|
viewedon_gte: true,
|
||||||
sort_order: [],
|
sort_order: [],
|
||||||
pagination_page: 1,
|
pagination_page: 1,
|
||||||
expert_mode: false,
|
expert_mode: false,
|
||||||
@ -594,7 +598,7 @@ export default {
|
|||||||
show_sortby: false,
|
show_sortby: false,
|
||||||
show_timescooked: false,
|
show_timescooked: false,
|
||||||
show_makenow: false,
|
show_makenow: false,
|
||||||
show_lastcooked: false,
|
show_cookedon: false,
|
||||||
include_children: true,
|
include_children: true,
|
||||||
},
|
},
|
||||||
pagination_count: 0,
|
pagination_count: 0,
|
||||||
@ -681,7 +685,7 @@ export default {
|
|||||||
const field = [
|
const field = [
|
||||||
[this.$t("search_rank"), "score"],
|
[this.$t("search_rank"), "score"],
|
||||||
[this.$t("Name"), "name"],
|
[this.$t("Name"), "name"],
|
||||||
[this.$t("last_cooked"), "lastcooked"],
|
[this.$t("last_cooked"), "cookedon"],
|
||||||
[this.$t("Rating"), "rating"],
|
[this.$t("Rating"), "rating"],
|
||||||
[this.$t("times_cooked"), "favorite"],
|
[this.$t("times_cooked"), "favorite"],
|
||||||
[this.$t("date_created"), "created_at"],
|
[this.$t("date_created"), "created_at"],
|
||||||
@ -876,7 +880,7 @@ export default {
|
|||||||
this.search.pagination_page = 1
|
this.search.pagination_page = 1
|
||||||
this.search.timescooked = undefined
|
this.search.timescooked = undefined
|
||||||
this.search.makenow = false
|
this.search.makenow = false
|
||||||
this.search.lastcooked = undefined
|
this.search.cookedon = undefined
|
||||||
|
|
||||||
let fieldnum = {
|
let fieldnum = {
|
||||||
keywords: 1,
|
keywords: 1,
|
||||||
@ -976,9 +980,17 @@ export default {
|
|||||||
if (rating !== undefined && !this.search.search_rating_gte) {
|
if (rating !== undefined && !this.search.search_rating_gte) {
|
||||||
rating = rating * -1
|
rating = rating * -1
|
||||||
}
|
}
|
||||||
let lastcooked = this.search.lastcooked || undefined
|
let cookedon = this.search.cookedon || undefined
|
||||||
if (lastcooked !== undefined && !this.search.lastcooked_gte) {
|
if (cookedon !== undefined && !this.search.cookedon_gte) {
|
||||||
lastcooked = "-" + lastcooked
|
cookedon = "-" + cookedon
|
||||||
|
}
|
||||||
|
let createdon = this.search.createdon || undefined
|
||||||
|
if (createdon !== undefined && !this.search.createdon_gte) {
|
||||||
|
createdon = "-" + createdon
|
||||||
|
}
|
||||||
|
let viewedon = this.search.viewedon || undefined
|
||||||
|
if (viewedon !== undefined && !this.search.viewedon_gte) {
|
||||||
|
viewedon = "-" + viewedon
|
||||||
}
|
}
|
||||||
let timescooked = parseInt(this.search.timescooked)
|
let timescooked = parseInt(this.search.timescooked)
|
||||||
if (isNaN(timescooked)) {
|
if (isNaN(timescooked)) {
|
||||||
@ -999,7 +1011,9 @@ export default {
|
|||||||
random: this.random_search,
|
random: this.random_search,
|
||||||
timescooked: timescooked,
|
timescooked: timescooked,
|
||||||
makenow: this.search.makenow || undefined,
|
makenow: this.search.makenow || undefined,
|
||||||
lastcooked: lastcooked,
|
cookedon: cookedon,
|
||||||
|
createdon: createdon,
|
||||||
|
viewedon: viewedon,
|
||||||
page: this.search.pagination_page,
|
page: this.search.pagination_page,
|
||||||
pageSize: this.ui.page_size,
|
pageSize: this.ui.page_size,
|
||||||
}
|
}
|
||||||
@ -1009,7 +1023,7 @@ export default {
|
|||||||
include_children: this.ui.include_children,
|
include_children: this.ui.include_children,
|
||||||
}
|
}
|
||||||
if (!this.searchFiltered()) {
|
if (!this.searchFiltered()) {
|
||||||
params.options.query.last_viewed = this.ui.recently_viewed
|
params.options.query.num_recent = this.ui.recently_viewed //TODO refactor as num_recent
|
||||||
params._new = this.ui.sort_by_new
|
params._new = this.ui.sort_by_new
|
||||||
}
|
}
|
||||||
if (this.search.search_filter) {
|
if (this.search.search_filter) {
|
||||||
@ -1030,12 +1044,12 @@ export default {
|
|||||||
this.search?.search_rating !== undefined ||
|
this.search?.search_rating !== undefined ||
|
||||||
(this.search.timescooked !== undefined && this.search.timescooked !== "") ||
|
(this.search.timescooked !== undefined && this.search.timescooked !== "") ||
|
||||||
this.search.makenow !== false ||
|
this.search.makenow !== false ||
|
||||||
(this.search.lastcooked !== undefined && this.search.lastcooked !== "")
|
(this.search.cookedon !== undefined && this.search.cookedon !== "")
|
||||||
|
|
||||||
if (ignore_string) {
|
if (ignore_string) {
|
||||||
return filtered
|
return filtered
|
||||||
} else {
|
} else {
|
||||||
return filtered || this.search?.search_input != "" || this.search.sort_order.length <= 1
|
return filtered || this.search?.search_input != "" || this.search.sort_order.length >= 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addFields(field) {
|
addFields(field) {
|
||||||
|
@ -530,7 +530,9 @@ export class Models {
|
|||||||
"random",
|
"random",
|
||||||
"_new",
|
"_new",
|
||||||
"timescooked",
|
"timescooked",
|
||||||
"lastcooked",
|
"cookedon",
|
||||||
|
"createdon",
|
||||||
|
"viewedon",
|
||||||
"makenow",
|
"makenow",
|
||||||
"page",
|
"page",
|
||||||
"pageSize",
|
"pageSize",
|
||||||
|
Reference in New Issue
Block a user