Merge pull request #2431 from smilerz/pytest_fixes

Pytest fixes
This commit is contained in:
vabene1111 2023-04-25 15:59:15 +02:00 committed by GitHub
commit 7830ddd4e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 647 additions and 377 deletions

View File

@ -3,9 +3,9 @@ from collections import Counter
from datetime import date, timedelta from datetime import date, timedelta
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
from django.core.cache import cache from django.core.cache import cache, caches
from django.core.cache import caches from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value,
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value, When, FilteredRelation) When)
from django.db.models.functions import Coalesce, Lower, Substr from django.db.models.functions import Coalesce, Lower, Substr
from django.utils import timezone, translation from django.utils import timezone, translation
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -20,7 +20,8 @@ from recipes import settings
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected # 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 # TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
class RecipeSearch(): class RecipeSearch():
_postgres = settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql'] _postgres = settings.DATABASES['default']['ENGINE'] in [
'django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
def __init__(self, request, **params): def __init__(self, request, **params):
self._request = request self._request = request
@ -45,7 +46,8 @@ class RecipeSearch():
cache.set(CACHE_KEY, self._search_prefs, timeout=10) cache.set(CACHE_KEY, self._search_prefs, timeout=10)
else: else:
self._search_prefs = SearchPreference() self._search_prefs = SearchPreference()
self._string = self._params.get('query').strip() if self._params.get('query', None) else None self._string = self._params.get('query').strip(
) if self._params.get('query', None) else None
self._rating = self._params.get('rating', None) self._rating = self._params.get('rating', None)
self._keywords = { self._keywords = {
'or': self._params.get('keywords_or', None) or self._params.get('keywords', None), 'or': self._params.get('keywords_or', None) or self._params.get('keywords', None),
@ -74,7 +76,8 @@ class RecipeSearch():
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._num_recent = int(self._params.get('num_recent', 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._cookedon = self._params.get('cookedon', None) self._cookedon = self._params.get('cookedon', None)
self._createdon = self._params.get('createdon', None) self._createdon = self._params.get('createdon', None)
@ -95,18 +98,24 @@ 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:
if self._postgres: if self._postgres:
self._unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True) self._unaccent_include = self._search_prefs.unaccent.values_list(
'field', flat=True)
else: else:
self._unaccent_include = [] self._unaccent_include = []
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._icontains_include = [
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)] 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 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(
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)] translation.get_language(), 'simple')
self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True) or None 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) 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
@ -182,8 +191,10 @@ class RecipeSearch():
# 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:
order += default_order order += default_order
order[:] = [Lower('name').asc() if x == 'name' else x for x in order] order[:] = [Lower('name').asc() if x ==
order[:] = [Lower('name').desc() if x == '-name' else x for x in order] 'name' else x for x in order]
order[:] = [Lower('name').desc() if x ==
'-name' else x for x in order]
self.orderby = order self.orderby = order
def string_filters(self, string=None): def string_filters(self, string=None):
@ -200,21 +211,28 @@ class RecipeSearch():
for f in self._filters: for f in self._filters:
query_filter |= f 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 # this creates duplicate records which can screw up other aggregates, see makenow for workaround
self._queryset = self._queryset.filter(query_filter).distinct()
if self._fulltext_include: if self._fulltext_include:
if self._fuzzy_match is None: if self._fuzzy_match is None:
self._queryset = self._queryset.annotate(score=Coalesce(Max(self.search_rank), 0.0)) self._queryset = self._queryset.annotate(
score=Coalesce(Max(self.search_rank), 0.0))
else: else:
self._queryset = self._queryset.annotate(rank=Coalesce(Max(self.search_rank), 0.0)) self._queryset = self._queryset.annotate(
rank=Coalesce(Max(self.search_rank), 0.0))
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(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=F('rank') + F('simularity')) self._queryset = self._queryset.annotate(
score=F('rank') + F('simularity'))
else: else:
query_filter = Q() 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)]: 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)]:
@ -223,7 +241,8 @@ class RecipeSearch():
def _cooked_on_filter(self, cooked_date=None): def _cooked_on_filter(self, cooked_date=None):
if self._sort_includes('lastcooked') or cooked_date: if self._sort_includes('lastcooked') or cooked_date:
lessthan = self._sort_includes('-lastcooked') or '-' in (cooked_date or [])[:1] lessthan = self._sort_includes(
'-lastcooked') or '-' in (cooked_date or [])[:1]
if lessthan: if lessthan:
default = timezone.now() - timedelta(days=100000) default = timezone.now() - timedelta(days=100000)
else: else:
@ -233,32 +252,41 @@ class RecipeSearch():
if cooked_date is None: if cooked_date is None:
return return
cooked_date = date(*[int(x) for x in cooked_date.split('-') if x != '']) cooked_date = date(*[int(x)
for x in cooked_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=default) self._queryset = self._queryset.filter(
lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
else: else:
self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=default) self._queryset = self._queryset.filter(
lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
def _created_on_filter(self, created_date=None): def _created_on_filter(self, created_date=None):
if created_date is None: if created_date is None:
return return
lessthan = '-' in created_date[:1] lessthan = '-' in created_date[:1]
created_date = date(*[int(x) for x in created_date.split('-') if x != '']) created_date = date(*[int(x)
for x in created_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter(created_at__date__lte=created_date) self._queryset = self._queryset.filter(
created_at__date__lte=created_date)
else: else:
self._queryset = self._queryset.filter(created_at__date__gte=created_date) self._queryset = self._queryset.filter(
created_at__date__gte=created_date)
def _updated_on_filter(self, updated_date=None): def _updated_on_filter(self, updated_date=None):
if updated_date is None: if updated_date is None:
return return
lessthan = '-' in updated_date[:1] lessthan = '-' in updated_date[:1]
updated_date = date(*[int(x) for x in updated_date.split('-') if x != '']) updated_date = date(*[int(x)
for x in updated_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter(updated_at__date__lte=updated_date) self._queryset = self._queryset.filter(
updated_at__date__lte=updated_date)
else: else:
self._queryset = self._queryset.filter(updated_at__date__gte=updated_date) self._queryset = self._queryset.filter(
updated_at__date__gte=updated_date)
def _viewed_on_filter(self, viewed_date=None): def _viewed_on_filter(self, viewed_date=None):
if self._sort_includes('lastviewed') or viewed_date: if self._sort_includes('lastviewed') or viewed_date:
@ -268,12 +296,15 @@ class RecipeSearch():
if viewed_date is None: if viewed_date is None:
return return
lessthan = '-' in viewed_date[:1] lessthan = '-' in viewed_date[:1]
viewed_date = date(*[int(x) for x in viewed_date.split('-') if x != '']) viewed_date = date(*[int(x)
for x in viewed_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo) self._queryset = self._queryset.filter(
lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
else: else:
self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo) 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
@ -293,27 +324,32 @@ class RecipeSearch():
num_recent_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=num_recent_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, times_cooked=None): def _favorite_recipes(self, times_cooked=None):
if self._sort_includes('favorite') or times_cooked: if self._sort_includes('favorite') or times_cooked:
less_than = '-' in (times_cooked or []) or not self._sort_includes('-favorite') less_than = '-' in (times_cooked or []
) and not self._sort_includes('-favorite')
if less_than: if less_than:
default = 1000 default = 1000
else: else:
default = 0 default = 0
favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk') 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') ).values('recipe').annotate(count=Count('pk', distinct=True)).values('count')
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default)) self._queryset = self._queryset.annotate(
favorite=Coalesce(Subquery(favorite_recipes), default))
if times_cooked is None: if times_cooked is None:
return return
if times_cooked == '0': if times_cooked == '0':
self._queryset = self._queryset.filter(favorite=0) self._queryset = self._queryset.filter(favorite=0)
elif less_than: elif less_than:
self._queryset = self._queryset.filter(favorite__lte=int(times_cooked[1:])).exclude(favorite=0) self._queryset = self._queryset.filter(favorite__lte=int(
times_cooked.replace('-', ''))).exclude(favorite=0)
else: else:
self._queryset = self._queryset.filter(favorite__gte=int(times_cooked)) self._queryset = self._queryset.filter(
favorite__gte=int(times_cooked))
def keyword_filters(self, **kwargs): def keyword_filters(self, **kwargs):
if all([kwargs[x] is None for x in kwargs]): if all([kwargs[x] is None for x in kwargs]):
@ -346,7 +382,8 @@ class RecipeSearch():
else: else:
self._queryset = self._queryset.filter(f_and) self._queryset = self._queryset.filter(f_and)
if 'not' in kw_filter: if 'not' in kw_filter:
self._queryset = self._queryset.exclude(id__in=recipes.values('id')) self._queryset = self._queryset.exclude(
id__in=recipes.values('id'))
def food_filters(self, **kwargs): def food_filters(self, **kwargs):
if all([kwargs[x] is None for x in kwargs]): if all([kwargs[x] is None for x in kwargs]):
@ -360,7 +397,8 @@ class RecipeSearch():
foods = Food.objects.filter(pk__in=kwargs[fd_filter]) foods = Food.objects.filter(pk__in=kwargs[fd_filter])
if 'or' in fd_filter: if 'or' in fd_filter:
if self._include_children: if self._include_children:
f_or = Q(steps__ingredients__food__in=Food.include_descendants(foods)) f_or = Q(
steps__ingredients__food__in=Food.include_descendants(foods))
else: else:
f_or = Q(steps__ingredients__food__in=foods) f_or = Q(steps__ingredients__food__in=foods)
@ -372,7 +410,8 @@ class RecipeSearch():
recipes = Recipe.objects.all() recipes = Recipe.objects.all()
for food in foods: for food in foods:
if self._include_children: if self._include_children:
f_and = Q(steps__ingredients__food__in=food.get_descendants_and_self()) f_and = Q(
steps__ingredients__food__in=food.get_descendants_and_self())
else: else:
f_and = Q(steps__ingredients__food=food) f_and = Q(steps__ingredients__food=food)
if 'not' in fd_filter: if 'not' in fd_filter:
@ -380,7 +419,8 @@ class RecipeSearch():
else: else:
self._queryset = self._queryset.filter(f_and) self._queryset = self._queryset.filter(f_and)
if 'not' in fd_filter: if 'not' in fd_filter:
self._queryset = self._queryset.exclude(id__in=recipes.values('id')) self._queryset = self._queryset.exclude(
id__in=recipes.values('id'))
def unit_filters(self, units=None, operator=True): def unit_filters(self, units=None, operator=True):
if operator != True: if operator != True:
@ -389,7 +429,8 @@ class RecipeSearch():
return return
if not isinstance(units, list): if not isinstance(units, list):
units = [units] units = [units]
self._queryset = self._queryset.filter(steps__ingredients__unit__in=units) self._queryset = self._queryset.filter(
steps__ingredients__unit__in=units)
def rating_filter(self, rating=None): def rating_filter(self, rating=None):
if rating or self._sort_includes('rating'): if rating or self._sort_includes('rating'):
@ -399,14 +440,16 @@ class RecipeSearch():
else: else:
default = 0 default = 0
# TODO make ratings a settings user-only vs all-users # TODO make ratings a settings user-only vs all-users
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=default)))) self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(
cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
if rating is None: if rating is None:
return return
if rating == '0': if rating == '0':
self._queryset = self._queryset.filter(rating=0) self._queryset = self._queryset.filter(rating=0)
elif lessthan: elif lessthan:
self._queryset = self._queryset.filter(rating__lte=int(rating[1:])).exclude(rating=0) self._queryset = self._queryset.filter(
rating__lte=int(rating[1:])).exclude(rating=0)
else: else:
self._queryset = self._queryset.filter(rating__gte=int(rating)) self._queryset = self._queryset.filter(rating__gte=int(rating))
@ -434,11 +477,14 @@ class RecipeSearch():
recipes = Recipe.objects.all() recipes = Recipe.objects.all()
for book in kwargs[bk_filter]: for book in kwargs[bk_filter]:
if 'not' in bk_filter: if 'not' in bk_filter:
recipes = recipes.filter(recipebookentry__book__id=book) recipes = recipes.filter(
recipebookentry__book__id=book)
else: else:
self._queryset = self._queryset.filter(recipebookentry__book__id=book) self._queryset = self._queryset.filter(
recipebookentry__book__id=book)
if 'not' in bk_filter: if 'not' in bk_filter:
self._queryset = self._queryset.exclude(id__in=recipes.values('id')) self._queryset = self._queryset.exclude(
id__in=recipes.values('id'))
def step_filters(self, steps=None, operator=True): def step_filters(self, steps=None, operator=True):
if operator != True: if operator != True:
@ -446,7 +492,7 @@ class RecipeSearch():
if not steps: if not steps:
return return
if not isinstance(steps, list): if not isinstance(steps, list):
steps = [unistepsts] steps = [steps]
self._queryset = self._queryset.filter(steps__id__in=steps) self._queryset = self._queryset.filter(steps__id__in=steps)
def build_fulltext_filters(self, string=None): def build_fulltext_filters(self, string=None):
@ -457,20 +503,25 @@ class RecipeSearch():
rank = [] rank = []
if 'name' in self._fulltext_include: if 'name' in self._fulltext_include:
vectors.append('name_search_vector') vectors.append('name_search_vector')
rank.append(SearchRank('name_search_vector', self.search_query, cover_density=True)) rank.append(SearchRank('name_search_vector',
self.search_query, cover_density=True))
if 'description' in self._fulltext_include: if 'description' in self._fulltext_include:
vectors.append('desc_search_vector') vectors.append('desc_search_vector')
rank.append(SearchRank('desc_search_vector', self.search_query, cover_density=True)) 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:
vectors.append('steps__search_vector') vectors.append('steps__search_vector')
rank.append(SearchRank('steps__search_vector', self.search_query, cover_density=True)) 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:
# explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields # explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields
vectors.append('keywords__name__unaccent') vectors.append('keywords__name__unaccent')
rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True)) 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:
vectors.append('steps__ingredients__food__name__unaccent') vectors.append('steps__ingredients__food__name__unaccent')
rank.append(SearchRank('steps__ingredients__food__name', self.search_query, cover_density=True)) rank.append(SearchRank('steps__ingredients__food__name',
self.search_query, cover_density=True))
for r in rank: for r in rank:
if self.search_rank is None: if self.search_rank is None:
@ -478,7 +529,8 @@ class RecipeSearch():
else: else:
self.search_rank += r self.search_rank += r
# modifying queryset will annotation creates duplicate results # 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)))) 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:
@ -510,23 +562,30 @@ class RecipeSearch():
def _makenow_filter(self, missing=None): def _makenow_filter(self, missing=None):
if missing is None or (type(missing) == bool and missing == False): if missing is None or (type(missing) == bool and missing == False):
return return
shopping_users = [*self._request.user.get_shopping_share(), self._request.user] shopping_users = [
*self._request.user.get_shopping_share(), self._request.user]
onhand_filter = ( onhand_filter = (
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
| Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users) # or substitute food onhand # or substitute food onhand
| Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users)) | Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users)
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users)) | Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users))
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
) )
makenow_recipes = Recipe.objects.annotate( makenow_recipes = Recipe.objects.annotate(
count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True), count_food=Count('steps__ingredients__food__pk', filter=Q(
count_onhand=Count('steps__ingredients__food__pk', filter=onhand_filter, distinct=True), steps__ingredients__food__isnull=False), distinct=True),
count_onhand=Count('steps__ingredients__food__pk',
filter=onhand_filter, distinct=True),
count_ignore_shopping=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__ignore_shopping=True, count_ignore_shopping=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__ignore_shopping=True,
steps__ingredients__food__recipe__isnull=True), distinct=True), steps__ingredients__food__recipe__isnull=True), distinct=True),
has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users), then=Value(1)), default=Value(0)), has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(
has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users), then=Value(1)), default=Value(0)) shopping_users), then=Value(1)), default=Value(0)),
has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(
shopping_users), then=Value(1)), default=Value(0))
).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood=missing) ).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood=missing)
self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values('id')) self._queryset = self._queryset.distinct().filter(
id__in=makenow_recipes.values('id'))
@staticmethod @staticmethod
def __children_substitute_filter(shopping_users=None): def __children_substitute_filter(shopping_users=None):
@ -547,7 +606,8 @@ class RecipeSearch():
@staticmethod @staticmethod
def __sibling_substitute_filter(shopping_users=None): def __sibling_substitute_filter(shopping_users=None):
sibling_onhand_subquery = Food.objects.filter( sibling_onhand_subquery = Food.objects.filter(
path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)), path__startswith=Substr(
OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)),
depth=OuterRef('depth'), depth=OuterRef('depth'),
onhand_users__in=shopping_users onhand_users__in=shopping_users
) )
@ -586,7 +646,8 @@ class RecipeFacet():
self.Recent = self._cache.get('Recent', None) self.Recent = self._cache.get('Recent', None)
if self._queryset is not None: if self._queryset is not None:
self._recipe_list = list(self._queryset.values_list('id', flat=True)) self._recipe_list = list(
self._queryset.values_list('id', flat=True))
self._search_params = { self._search_params = {
'keyword_list': self._request.query_params.getlist('keywords', []), 'keyword_list': self._request.query_params.getlist('keywords', []),
'food_list': self._request.query_params.getlist('foods', []), 'food_list': self._request.query_params.getlist('foods', []),
@ -618,7 +679,8 @@ class RecipeFacet():
'Books': self.Books 'Books': self.Books
} }
caches['default'].set(self._SEARCH_CACHE_KEY, self._cache, self._cache_timeout) caches['default'].set(self._SEARCH_CACHE_KEY,
self._cache, self._cache_timeout)
def get_facets(self, from_cache=False): def get_facets(self, from_cache=False):
if from_cache: if from_cache:
@ -655,13 +717,16 @@ class RecipeFacet():
def get_keywords(self): def get_keywords(self):
if self.Keywords is None: if self.Keywords is None:
if self._search_params['search_keywords_or']: if self._search_params['search_keywords_or']:
keywords = Keyword.objects.filter(space=self._request.space).distinct() keywords = Keyword.objects.filter(
space=self._request.space).distinct()
else: else:
keywords = Keyword.objects.filter(Q(recipe__in=self._recipe_list) | Q(depth=1)).filter(space=self._request.space).distinct() keywords = Keyword.objects.filter(Q(recipe__in=self._recipe_list) | Q(
depth=1)).filter(space=self._request.space).distinct()
# set keywords to root objects only # set keywords to root objects only
keywords = self._keyword_queryset(keywords) keywords = self._keyword_queryset(keywords)
self.Keywords = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)] self.Keywords = [{**x, 'children': None}
if x['numchild'] > 0 else x for x in list(keywords)]
self.set_cache('Keywords', self.Keywords) self.set_cache('Keywords', self.Keywords)
return self.Keywords return self.Keywords
@ -669,28 +734,28 @@ class RecipeFacet():
if self.Foods is None: if self.Foods is None:
# # if using an OR search, will annotate all keywords, otherwise, just those that appear in results # # if using an OR search, will annotate all keywords, otherwise, just those that appear in results
if self._search_params['search_foods_or']: if self._search_params['search_foods_or']:
foods = Food.objects.filter(space=self._request.space).distinct() foods = Food.objects.filter(
space=self._request.space).distinct()
else: else:
foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q(depth=1)).filter(space=self._request.space).distinct() foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q(
depth=1)).filter(space=self._request.space).distinct()
# set keywords to root objects only # set keywords to root objects only
foods = self._food_queryset(foods) foods = self._food_queryset(foods)
self.Foods = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)] self.Foods = [{**x, 'children': None}
if x['numchild'] > 0 else x for x in list(foods)]
self.set_cache('Foods', self.Foods) self.set_cache('Foods', self.Foods)
return self.Foods return self.Foods
def get_books(self):
if self.Books is None:
self.Books = []
return self.Books
def get_ratings(self): def get_ratings(self):
if self.Ratings is None: if self.Ratings is None:
if not self._request.space.demo and self._request.space.show_facet_count: if not self._request.space.demo and self._request.space.show_facet_count:
if self._queryset is None: if self._queryset is None:
self._queryset = Recipe.objects.filter(id__in=self._recipe_list) self._queryset = Recipe.objects.filter(
rating_qs = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0))))) id__in=self._recipe_list)
rating_qs = self._queryset.annotate(rating=Round(Avg(Case(When(
cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0)))))
self.Ratings = dict(Counter(r.rating for r in rating_qs)) self.Ratings = dict(Counter(r.rating for r in rating_qs))
else: else:
self.Rating = {} self.Rating = {}
@ -715,10 +780,13 @@ class RecipeFacet():
foods = self._food_queryset(food.get_children(), food) foods = self._food_queryset(food.get_children(), food)
deep_search = self.Foods deep_search = self.Foods
for node in nodes: for node in nodes:
index = next((i for i, x in enumerate(deep_search) if x["id"] == node.id), None) index = next((i for i, x in enumerate(
deep_search) if x["id"] == node.id), None)
deep_search = deep_search[index]['children'] deep_search = deep_search[index]['children']
index = next((i for i, x in enumerate(deep_search) if x["id"] == food.id), None) index = next((i for i, x in enumerate(
deep_search[index]['children'] = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)] deep_search) if x["id"] == food.id), None)
deep_search[index]['children'] = [
{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)]
self.set_cache('Foods', self.Foods) self.set_cache('Foods', self.Foods)
return self.get_facets() return self.get_facets()
@ -731,10 +799,13 @@ class RecipeFacet():
keywords = self._keyword_queryset(keyword.get_children(), keyword) keywords = self._keyword_queryset(keyword.get_children(), keyword)
deep_search = self.Keywords deep_search = self.Keywords
for node in nodes: for node in nodes:
index = next((i for i, x in enumerate(deep_search) if x["id"] == node.id), None) index = next((i for i, x in enumerate(
deep_search) if x["id"] == node.id), None)
deep_search = deep_search[index]['children'] deep_search = deep_search[index]['children']
index = next((i for i, x in enumerate(deep_search) if x["id"] == keyword.id), None) index = next((i for i, x in enumerate(deep_search)
deep_search[index]['children'] = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)] if x["id"] == keyword.id), None)
deep_search[index]['children'] = [
{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)]
self.set_cache('Keywords', self.Keywords) self.set_cache('Keywords', self.Keywords)
return self.get_facets() return self.get_facets()

View File

@ -6,7 +6,7 @@ from django.urls import reverse
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from pytest_factoryboy import LazyFixture, register from pytest_factoryboy import LazyFixture, register
from cookbook.models import Food, FoodInheritField, Ingredient, ShoppingList, ShoppingListEntry from cookbook.models import Food, Ingredient, ShoppingListEntry
from cookbook.tests.factories import (FoodFactory, IngredientFactory, ShoppingListEntryFactory, from cookbook.tests.factories import (FoodFactory, IngredientFactory, ShoppingListEntryFactory,
SupermarketCategoryFactory) SupermarketCategoryFactory)
@ -56,23 +56,32 @@ def obj_tree_1(request, space_1):
params = request.param # request.param is a magic variable params = request.param # request.param is a magic variable
except AttributeError: except AttributeError:
params = {} params = {}
objs = []
inherit = params.pop('inherit', False) inherit = params.pop('inherit', False)
objs.extend(FoodFactory.create_batch(3, space=space_1, **params)) FoodFactory.create_batch(3, space=space_1, **params)
objs = Food.objects.values_list('id', flat=True)
obj_id = objs[1]
child_id = objs[0]
parent_id = objs[2]
# set all foods to inherit everything # set all foods to inherit everything
if inherit: if inherit:
inherit = Food.inheritable_fields inherit = Food.inheritable_fields
Through = Food.objects.filter(space=space_1).first().inherit_fields.through Through = Food.objects.filter(
space=space_1).first().inherit_fields.through
for i in inherit: for i in inherit:
Through.objects.bulk_create([ Through.objects.bulk_create([
Through(food_id=x, foodinheritfield_id=i.id) Through(food_id=x, foodinheritfield_id=i.id)
for x in Food.objects.filter(space=space_1).values_list('id', flat=True) for x in Food.objects.filter(space=space_1).values_list('id', flat=True)
]) ])
objs[0].move(objs[1], node_location) Food.objects.get(id=child_id).move(
objs[1].move(objs[2], node_location) Food.objects.get(id=obj_id), node_location)
return Food.objects.get(id=objs[1].id) # whenever you move/merge a tree it's safest to re-get the object
Food.objects.get(id=obj_id).move(
Food.objects.get(id=parent_id), node_location)
# whenever you move/merge a tree it's safest to re-get the object
return Food.objects.get(id=obj_id)
@pytest.mark.parametrize("arg", [ @pytest.mark.parametrize("arg", [
@ -107,19 +116,23 @@ def test_list_filter(obj_1, obj_2, u1_s1):
assert obj_2.name in [x['name'] for x in response['results']] assert obj_2.name in [x['name'] for x in response['results']]
assert response['results'][0]['name'] < response['results'][1]['name'] assert response['results'][0]['name'] < response['results'][1]['name']
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?page_size=1').content) response = json.loads(
u1_s1.get(f'{reverse(LIST_URL)}?page_size=1').content)
assert len(response['results']) == 1 assert len(response['results']) == 1
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?limit=1').content) response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?limit=1').content)
assert len(response['results']) == 1 assert len(response['results']) == 1
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query=''&limit=1').content) response = json.loads(
u1_s1.get(f'{reverse(LIST_URL)}?query=''&limit=1').content)
assert len(response['results']) == 1 assert len(response['results']) == 1
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query=chicken').content) response = json.loads(
u1_s1.get(f'{reverse(LIST_URL)}?query=chicken').content)
assert response['count'] == 0 assert response['count'] == 0
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query={obj_1.name[:-4]}').content) response = json.loads(
u1_s1.get(f'{reverse(LIST_URL)}?query={obj_1.name[:-4]}').content)
assert response['count'] == 1 assert response['count'] == 1
@ -262,8 +275,9 @@ def test_integrity(u1_s1, recipe_1_s1):
def test_move(u1_s1, obj_tree_1, obj_2, obj_3, space_1): def test_move(u1_s1, obj_tree_1, obj_2, obj_3, space_1):
with scope(space=space_1): with scope(space=space_1):
# for some reason the 'path' attribute changes between the factory and the test when using both obj_tree and obj
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
assert parent.get_num_children() == 1 assert parent.get_num_children() == 1
assert parent.get_descendant_count() == 2 assert parent.get_descendant_count() == 2
assert Food.get_root_nodes().filter(space=space_1).count() == 2 assert Food.get_root_nodes().filter(space=space_1).count() == 2
@ -295,8 +309,9 @@ def test_move(u1_s1, obj_tree_1, obj_2, obj_3, space_1):
def test_move_errors(u1_s1, obj_tree_1, obj_3, space_1): def test_move_errors(u1_s1, obj_tree_1, obj_3, space_1):
with scope(space=space_1): with scope(space=space_1):
# for some reason the 'path' attribute changes between the factory and the test when using both obj_tree and obj
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
# move child to root # move child to root
r = u1_s1.put(reverse(MOVE_URL, args=[obj_tree_1.id, 0])) r = u1_s1.put(reverse(MOVE_URL, args=[obj_tree_1.id, 0]))
assert r.status_code == 200 assert r.status_code == 200
@ -351,7 +366,7 @@ def test_merge_shopping_entries(obj_tree_1, u1_s1, space_1):
with scope(space=space_1): with scope(space=space_1):
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0] child = obj_tree_1.get_descendants()[0]
ShoppingListEntryFactory.create(food=parent, space=space_1) ShoppingListEntryFactory.create(food=parent, space=space_1)
ShoppingListEntryFactory.create(food=child, space=space_1) ShoppingListEntryFactory.create(food=child, space=space_1)
assert parent.get_num_children() == 1 assert parent.get_num_children() == 1
assert parent.get_descendant_count() == 2 assert parent.get_descendant_count() == 2
@ -371,8 +386,10 @@ def test_merge_shopping_entries(obj_tree_1, u1_s1, space_1):
assert obj_tree_1.shopping_entries.count() == 1 # now has child's ingredient assert obj_tree_1.shopping_entries.count() == 1 # now has child's ingredient
def test_merge(u1_s1, obj_tree_1, obj_1, obj_3, space_1): def test_merge(u1_s1, obj_tree_1, obj_1, obj_3, space_1):
with scope(space=space_1): with scope(space=space_1):
# for some reason the 'path' attribute changes between the factory and the test when using both obj_tree and obj
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0] child = obj_tree_1.get_descendants()[0]
assert parent.get_num_children() == 1 assert parent.get_num_children() == 1
@ -416,8 +433,9 @@ def test_merge(u1_s1, obj_tree_1, obj_1, obj_3, space_1):
def test_merge_errors(u1_s1, obj_tree_1, obj_3, space_1): def test_merge_errors(u1_s1, obj_tree_1, obj_3, space_1):
with scope(space=space_1): with scope(space=space_1):
# for some reason the 'path' attribute changes between the factory and the test when using both obj_tree and obj
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
# attempt to merge with non-existent parent # attempt to merge with non-existent parent
r = u1_s1.put( r = u1_s1.put(
@ -451,44 +469,63 @@ def test_merge_errors(u1_s1, obj_tree_1, obj_3, space_1):
def test_root_filter(obj_tree_1, obj_2, obj_3, u1_s1): def test_root_filter(obj_tree_1, obj_2, obj_3, u1_s1):
with scope(space=obj_tree_1.space): with scope(space=obj_tree_1.space):
# for some reason the 'path' attribute changes between the factory and the test when using both obj_tree and obj
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
# should return root objects in the space (obj_1, obj_2), ignoring query filters # should return root objects in the space (obj_1, obj_2), ignoring query filters
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root=0').content) response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root=0').content)
assert len(response['results']) == 2 assert len(response['results']) == 2
# django_tree bypasses ORM - best to retrieve all changed objects
with scopes_disabled(): with scopes_disabled():
obj_2.move(parent, node_location) obj_2.move(parent, node_location)
obj_2 = Food.objects.get(id=obj_2.id)
parent = Food.objects.get(id=parent.id)
# should return direct children of parent (obj_tree_1, obj_2), ignoring query filters # should return direct children of parent (obj_tree_1, obj_2), ignoring query filters
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={parent.id}').content) response = json.loads(
u1_s1.get(f'{reverse(LIST_URL)}?root={parent.id}').content)
assert response['count'] == 2 assert response['count'] == 2
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={parent.id}&query={obj_2.name[4:]}').content) response = json.loads(u1_s1.get(
f'{reverse(LIST_URL)}?root={parent.id}&query={obj_2.name[4:]}').content)
assert response['count'] == 2 assert response['count'] == 2
def test_tree_filter(obj_tree_1, obj_2, obj_3, u1_s1): def test_tree_filter(obj_tree_1, obj_2, obj_3, u1_s1):
with scope(space=obj_tree_1.space): with scope(space=obj_tree_1.space):
# for some reason the 'path' attribute changes between the factory and the test when using both obj_tree and obj
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
obj_2.move(parent, node_location) obj_2.move(parent, node_location)
obj_2 = Food.objects.get(id=obj_2.id)
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
parent = Food.objects.get(id=parent.id)
# should return full tree starting at parent (obj_tree_1, obj_2), ignoring query filters # should return full tree starting at parent (obj_tree_1, obj_2), ignoring query filters
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={parent.id}').content) response = json.loads(
u1_s1.get(f'{reverse(LIST_URL)}?tree={parent.id}').content)
assert response['count'] == 4 assert response['count'] == 4
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={parent.id}&query={obj_2.name[4:]}').content) response = json.loads(u1_s1.get(
f'{reverse(LIST_URL)}?tree={parent.id}&query={obj_2.name[4:]}').content)
assert response['count'] == 4 assert response['count'] == 4
# This is more about the model than the API - should this be moved to a different test? # This is more about the model than the API - should this be moved to a different test?
@pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [ @pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [
({'has_category': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'), ({'has_category': True, 'inherit': True},
({'has_category': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'), 'supermarket_category', True, 'cat_1'),
({'ignore_shopping': True, 'inherit': True}, 'ignore_shopping', True, 'false'), ({'has_category': True, 'inherit': False},
({'ignore_shopping': True, 'inherit': False}, 'ignore_shopping', False, 'false'), 'supermarket_category', False, 'cat_1'),
({'substitute_children': True, 'inherit': True}, 'substitute_children', True, 'false'), ({'ignore_shopping': True, 'inherit': True}, 'ignore_shopping', True, 'false'),
({'substitute_children': True, 'inherit': False}, 'substitute_children', False, 'false'), ({'ignore_shopping': True, 'inherit': False},
({'substitute_siblings': True, 'inherit': True}, 'substitute_siblings', True, 'false'), 'ignore_shopping', False, 'false'),
({'substitute_siblings': True, 'inherit': False}, 'substitute_siblings', False, 'false'), ({'substitute_children': True, 'inherit': True},
'substitute_children', True, 'false'),
({'substitute_children': True, 'inherit': False},
'substitute_children', False, 'false'),
({'substitute_siblings': True, 'inherit': True},
'substitute_siblings', True, 'false'),
({'substitute_siblings': True, 'inherit': False},
'substitute_siblings', False, 'false'),
], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter ], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter
def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1): def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
with scope(space=obj_tree_1.space): with scope(space=obj_tree_1.space):
@ -498,8 +535,10 @@ def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
new_val = request.getfixturevalue(new_val) new_val = request.getfixturevalue(new_val)
# if this test passes it demonstrates that inheritance works # if this test passes it demonstrates that inheritance works
# when moving to a parent as each food is created with a different category # when moving to a parent as each food is created with a different category
assert (getattr(parent, field) == getattr(obj_tree_1, field)) in [inherit, True] assert (getattr(parent, field) == getattr(
assert (getattr(obj_tree_1, field) == getattr(child, field)) in [inherit, True] obj_tree_1, field)) in [inherit, True]
assert (getattr(obj_tree_1, field) == getattr(
child, field)) in [inherit, True]
# change parent to a new value # change parent to a new value
setattr(parent, field, new_val) setattr(parent, field, new_val)
with scope(space=parent.space): with scope(space=parent.space):
@ -515,7 +554,8 @@ def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
@pytest.mark.parametrize("obj_tree_1", [ @pytest.mark.parametrize("obj_tree_1", [
({'has_category': True, 'inherit': False, 'ignore_shopping': True, 'substitute_children': True, 'substitute_siblings': True}), ({'has_category': True, 'inherit': False, 'ignore_shopping': True,
'substitute_children': True, 'substitute_siblings': True}),
], indirect=['obj_tree_1']) ], indirect=['obj_tree_1'])
@pytest.mark.parametrize("global_reset", [True, False]) @pytest.mark.parametrize("global_reset", [True, False])
@pytest.mark.parametrize("field", ['ignore_shopping', 'substitute_children', 'substitute_siblings', 'supermarket_category']) @pytest.mark.parametrize("field", ['ignore_shopping', 'substitute_children', 'substitute_siblings', 'supermarket_category'])
@ -534,10 +574,13 @@ def test_reset_inherit_space_fields(obj_tree_1, space_1, global_reset, field):
assert getattr(parent, field) != getattr(obj_tree_1, field) assert getattr(parent, field) != getattr(obj_tree_1, field)
if global_reset: if global_reset:
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields # set default inherit fields
space_1.food_inherit.add(
*Food.inheritable_fields.values_list('id', flat=True))
parent.reset_inheritance(space=space_1) parent.reset_inheritance(space=space_1)
else: else:
obj_tree_1.child_inherit_fields.set(Food.inheritable_fields.values_list('id', flat=True)) obj_tree_1.child_inherit_fields.set(
Food.inheritable_fields.values_list('id', flat=True))
obj_tree_1.save() obj_tree_1.save()
parent.reset_inheritance(space=space_1, food=obj_tree_1) parent.reset_inheritance(space=space_1, food=obj_tree_1)
# djangotree bypasses ORM and need to be retrieved again # djangotree bypasses ORM and need to be retrieved again
@ -545,12 +588,14 @@ def test_reset_inherit_space_fields(obj_tree_1, space_1, global_reset, field):
parent = Food.objects.get(id=parent.id) parent = Food.objects.get(id=parent.id)
child = Food.objects.get(id=child.id) child = Food.objects.get(id=child.id)
assert (getattr(parent, field) == getattr(obj_tree_1, field)) == global_reset assert (getattr(parent, field) == getattr(
obj_tree_1, field)) == global_reset
assert getattr(obj_tree_1, field) == getattr(child, field) assert getattr(obj_tree_1, field) == getattr(child, field)
@pytest.mark.parametrize("obj_tree_1", [ @pytest.mark.parametrize("obj_tree_1", [
({'has_category': True, 'inherit': False, 'ignore_shopping': True, 'substitute_children': True, 'substitute_siblings': True}), ({'has_category': True, 'inherit': False, 'ignore_shopping': True,
'substitute_children': True, 'substitute_siblings': True}),
], indirect=['obj_tree_1']) ], indirect=['obj_tree_1'])
@pytest.mark.parametrize("field", ['ignore_shopping', 'substitute_children', 'substitute_siblings', 'supermarket_category']) @pytest.mark.parametrize("field", ['ignore_shopping', 'substitute_children', 'substitute_siblings', 'supermarket_category'])
def test_reset_inherit_no_food_instances(obj_tree_1, space_1, field): def test_reset_inherit_no_food_instances(obj_tree_1, space_1, field):
@ -558,13 +603,17 @@ def test_reset_inherit_no_food_instances(obj_tree_1, space_1, field):
parent = obj_tree_1.get_parent() parent = obj_tree_1.get_parent()
Food.objects.all().delete() Food.objects.all().delete()
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields # set default inherit fields
space_1.food_inherit.add(
*Food.inheritable_fields.values_list('id', flat=True))
parent.reset_inheritance(space=space_1) parent.reset_inheritance(space=space_1)
def test_onhand(obj_1, u1_s1, u2_s1): def test_onhand(obj_1, u1_s1, u2_s1):
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False 'food_onhand'] == False
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
'food_onhand'] == False
u1_s1.patch( u1_s1.patch(
reverse( reverse(
@ -574,10 +623,13 @@ def test_onhand(obj_1, u1_s1, u2_s1):
{'food_onhand': True}, {'food_onhand': True},
content_type='application/json' content_type='application/json'
) )
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == True assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False 'food_onhand'] == True
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
'food_onhand'] == False
user1 = auth.get_user(u1_s1) user1 = auth.get_user(u1_s1)
user2 = auth.get_user(u2_s1) user2 = auth.get_user(u2_s1)
user1.userpreference.shopping_share.add(user2) user1.userpreference.shopping_share.add(user2)
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == True assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
'food_onhand'] == True

View File

@ -1,20 +1,14 @@
import json import json
from datetime import timedelta
import factory
import pytest import pytest
# work around for bug described here https://stackoverflow.com/a/70312265/15762829 # work around for bug described here https://stackoverflow.com/a/70312265/15762829
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.forms import model_to_dict
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django_scopes import scopes_disabled
from django_scopes import scope, scopes_disabled
from pytest_factoryboy import LazyFixture, register
from cookbook.models import Food, Ingredient, ShoppingListEntry, Step from cookbook.models import Food, Ingredient
from cookbook.tests.factories import (IngredientFactory, MealPlanFactory, RecipeFactory, from cookbook.tests.factories import MealPlanFactory, RecipeFactory, StepFactory, UserFactory
StepFactory, UserFactory)
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'django.db.backends.postgresql']: 'django.db.backends.postgresql']:
@ -32,9 +26,12 @@ def user2(request, u1_s1):
except AttributeError: except AttributeError:
params = {} params = {}
user = auth.get_user(u1_s1) user = auth.get_user(u1_s1)
user.userpreference.mealplan_autoadd_shopping = params.get('mealplan_autoadd_shopping', True) user.userpreference.mealplan_autoadd_shopping = params.get(
user.userpreference.mealplan_autoinclude_related = params.get('mealplan_autoinclude_related', True) 'mealplan_autoadd_shopping', True)
user.userpreference.mealplan_autoexclude_onhand = params.get('mealplan_autoexclude_onhand', True) user.userpreference.mealplan_autoinclude_related = params.get(
'mealplan_autoinclude_related', True)
user.userpreference.mealplan_autoexclude_onhand = params.get(
'mealplan_autoexclude_onhand', True)
user.userpreference.save() user.userpreference.save()
return u1_s1 return u1_s1
@ -50,7 +47,6 @@ def recipe(request, space_1, u1_s1):
return RecipeFactory(**params) return RecipeFactory(**params)
@pytest.mark.parametrize("arg", [ @pytest.mark.parametrize("arg", [
['g1_s1', 204], ['g1_s1', 204],
['u1_s1', 204], ['u1_s1', 204],
@ -59,11 +55,14 @@ def recipe(request, space_1, u1_s1):
]) ])
@pytest.mark.parametrize("recipe, sle_count", [ @pytest.mark.parametrize("recipe, sle_count", [
({}, 10), ({}, 10),
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe # shopping list from recipe with StepRecipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe ({'steps__recipe_count': 1}, 20),
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe # shopping list from recipe with food recipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19),
# shopping list from recipe with StepRecipe and food recipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29),
], indirect=['recipe']) ], indirect=['recipe'])
def test_shopping_recipe_method(request, arg, recipe, sle_count, u1_s1, u2_s1): def test_shopping_recipe_method(request, arg, recipe, sle_count, u1_s1, u2_s1):
c = request.getfixturevalue(arg[0]) c = request.getfixturevalue(arg[0])
user = auth.get_user(c) user = auth.get_user(c)
user.userpreference.mealplan_autoadd_shopping = True user.userpreference.mealplan_autoadd_shopping = True
@ -78,16 +77,20 @@ def test_shopping_recipe_method(request, arg, recipe, sle_count, u1_s1, u2_s1):
if r.status_code == 204: # skip anonymous user if r.status_code == 204: # skip anonymous user
r = json.loads(c.get(reverse(SHOPPING_LIST_URL)).content) r = json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)
assert len(r) == sle_count # recipe factory creates 10 ingredients by default # recipe factory creates 10 ingredients by default
assert len(r) == sle_count
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
# user in space can't see shopping list # user in space can't see shopping list
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0 assert len(json.loads(
u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
user.userpreference.shopping_share.add(auth.get_user(u2_s1)) user.userpreference.shopping_share.add(auth.get_user(u2_s1))
# after share, user in space can see shopping list # after share, user in space can see shopping list
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count assert len(json.loads(
u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
# confirm that the author of the recipe doesn't have access to shopping list # confirm that the author of the recipe doesn't have access to shopping list
if c != u1_s1: if c != u1_s1:
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0 assert len(json.loads(
u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
r = c.get(url) r = c.get(url)
assert r.status_code == 405 assert r.status_code == 405
@ -99,9 +102,12 @@ def test_shopping_recipe_method(request, arg, recipe, sle_count, u1_s1, u2_s1):
@pytest.mark.parametrize("recipe, sle_count", [ @pytest.mark.parametrize("recipe, sle_count", [
({}, 10), ({}, 10),
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe # shopping list from recipe with StepRecipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe ({'steps__recipe_count': 1}, 20),
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe # shopping list from recipe with food recipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19),
# shopping list from recipe with StepRecipe and food recipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29),
], indirect=['recipe']) ], indirect=['recipe'])
@pytest.mark.parametrize("use_mealplan", [(False), (True), ]) @pytest.mark.parametrize("use_mealplan", [(False), (True), ])
def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u2_s1): def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u2_s1):
@ -115,31 +121,33 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
user.userpreference.save() user.userpreference.save()
if use_mealplan: if use_mealplan:
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe) mealplan = MealPlanFactory(
space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
else: else:
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id})) u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content) r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
all_ing = [x['ingredient'] for x in r] all_ing = [x['ingredient'] for x in r]
keep_ing = all_ing[1:-1] # remove first and last element keep_ing = all_ing[1:-1] # remove first and last element
del keep_ing[int(len(keep_ing)/2)] # remove a middle element del keep_ing[int(len(keep_ing) / 2)] # remove a middle element
list_recipe = r[0]['list_recipe'] list_recipe = r[0]['list_recipe']
amount_sum = sum([x['amount'] for x in r]) amount_sum = sum([x['amount'] for x in r])
# test modifying shopping list as different user # test modifying shopping list as different user
# test increasing servings size of recipe shopping list # test increasing servings size of recipe shopping list
if use_mealplan: if use_mealplan:
mealplan.servings = 2*recipe.servings mealplan.servings = 2 * recipe.servings
mealplan.save() mealplan.save()
else: else:
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}), u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
{'list_recipe': list_recipe, 'servings': 2*recipe.servings}, {'list_recipe': list_recipe, 'servings': 2 * recipe.servings},
content_type='application/json' content_type='application/json'
) )
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content) r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert sum([x['amount'] for x in r]) == amount_sum * 2 assert sum([x['amount'] for x in r]) == amount_sum * 2
assert len(r) == sle_count assert len(r) == sle_count
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count assert len(json.loads(
u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
# testing decreasing servings size of recipe shopping list # testing decreasing servings size of recipe shopping list
if use_mealplan: if use_mealplan:
@ -153,7 +161,8 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content) r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert sum([x['amount'] for x in r]) == amount_sum * .5 assert sum([x['amount'] for x in r]) == amount_sum * .5
assert len(r) == sle_count assert len(r) == sle_count
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count assert len(json.loads(
u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
# test removing 3 items from shopping list # test removing 3 items from shopping list
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}), u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
@ -162,7 +171,8 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
) )
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content) r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert len(r) == sle_count - 3 assert len(r) == sle_count - 3
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count - 3 assert len(json.loads(
u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count - 3
# add all ingredients to existing shopping list - don't change serving size # add all ingredients to existing shopping list - don't change serving size
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}), u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
@ -172,14 +182,16 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content) r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert sum([x['amount'] for x in r]) == amount_sum * .5 assert sum([x['amount'] for x in r]) == amount_sum * .5
assert len(r) == sle_count assert len(r) == sle_count
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count assert len(json.loads(
u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
@pytest.mark.parametrize("user2, sle_count", [ @pytest.mark.parametrize("user2, sle_count", [
({'mealplan_autoadd_shopping': False}, (0, 18)), ({'mealplan_autoadd_shopping': False}, (0, 18)),
({'mealplan_autoinclude_related': False}, (9, 9)), ({'mealplan_autoinclude_related': False}, (9, 9)),
({'mealplan_autoexclude_onhand': False}, (20, 20)), ({'mealplan_autoexclude_onhand': False}, (20, 20)),
({'mealplan_autoexclude_onhand': False, 'mealplan_autoinclude_related': False}, (10, 10)), ({'mealplan_autoexclude_onhand': False,
'mealplan_autoinclude_related': False}, (10, 10)),
], indirect=['user2']) ], indirect=['user2'])
@pytest.mark.parametrize("use_mealplan", [(False), (True), ]) @pytest.mark.parametrize("use_mealplan", [(False), (True), ])
@pytest.mark.parametrize("recipe", [({'steps__recipe_count': 1})], indirect=['recipe']) @pytest.mark.parametrize("recipe", [({'steps__recipe_count': 1})], indirect=['recipe'])
@ -191,20 +203,24 @@ def test_shopping_recipe_userpreference(recipe, sle_count, use_mealplan, user2):
food = Food.objects.get(id=ingredients[2].food.id) food = Food.objects.get(id=ingredients[2].food.id)
food.onhand_users.add(user) food.onhand_users.add(user)
food.save() food.save()
food = recipe.steps.exclude(step_recipe=None).first().step_recipe.steps.first().ingredients.first().food food = recipe.steps.exclude(step_recipe=None).first(
).step_recipe.steps.first().ingredients.first().food
food = Food.objects.get(id=food.id) food = Food.objects.get(id=food.id)
food.onhand_users.add(user) food.onhand_users.add(user)
food.save() food.save()
if use_mealplan: if use_mealplan:
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe) MealPlanFactory(
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[0] space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
assert len(json.loads(
user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[0]
else: else:
user2.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id})) user2.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[1] assert len(json.loads(
user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[1]
def test_shopping_recipe_mixed_authors(u1_s1, u2_s1,space_1): def test_shopping_recipe_mixed_authors(u1_s1, u2_s1, space_1):
with scopes_disabled(): with scopes_disabled():
user1 = auth.get_user(u1_s1) user1 = auth.get_user(u1_s1)
user2 = auth.get_user(u2_s1) user2 = auth.get_user(u2_s1)
@ -213,15 +229,19 @@ def test_shopping_recipe_mixed_authors(u1_s1, u2_s1,space_1):
recipe1 = RecipeFactory(created_by=user1, space=space) recipe1 = RecipeFactory(created_by=user1, space=space)
recipe2 = RecipeFactory(created_by=user2, space=space) recipe2 = RecipeFactory(created_by=user2, space=space)
recipe3 = RecipeFactory(created_by=user3, space=space) recipe3 = RecipeFactory(created_by=user3, space=space)
food = Food.objects.get(id=recipe1.steps.first().ingredients.first().food.id) food = Food.objects.get(
id=recipe1.steps.first().ingredients.first().food.id)
food.recipe = recipe2 food.recipe = recipe2
food.save() food.save()
recipe1.steps.add(StepFactory(step_recipe=recipe3, ingredients__count=0, space=space)) recipe1.steps.add(StepFactory(step_recipe=recipe3,
ingredients__count=0, space=space))
recipe1.save() recipe1.save()
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe1.id})) u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe1.id}))
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 29 assert len(json.loads(
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0 u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 29
assert len(json.loads(
u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
@pytest.mark.parametrize("recipe", [{'steps__ingredients__header': 1}], indirect=['recipe']) @pytest.mark.parametrize("recipe", [{'steps__ingredients__header': 1}], indirect=['recipe'])
@ -230,4 +250,5 @@ def test_shopping_with_header_ingredient(u1_s1, recipe):
# recipe.step_set.first().ingredient_set.add(IngredientFactory(ingredients__header=1)) # recipe.step_set.first().ingredient_set.add(IngredientFactory(ingredients__header=1))
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id})) u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 10 assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 10
assert len(json.loads(u1_s1.get(reverse('api:ingredient-list')).content)['results']) == 11 assert len(json.loads(
u1_s1.get(reverse('api:ingredient-list')).content)['results']) == 11

View File

@ -5,12 +5,11 @@ import uuid
import pytest import pytest
from django.contrib import auth from django.contrib import auth
from django.contrib.auth.models import Group, User
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pytest_factoryboy import LazyFixture, register from pytest_factoryboy import register
from cookbook.models import Food, Ingredient, Recipe, Space, Step, Unit from cookbook.models import Food, Ingredient, Recipe, Step, Unit
from cookbook.tests.factories import FoodFactory, SpaceFactory, UserFactory from cookbook.tests.factories import SpaceFactory, UserFactory
register(SpaceFactory, 'space_1') register(SpaceFactory, 'space_1')
register(SpaceFactory, 'space_2') register(SpaceFactory, 'space_2')
@ -60,8 +59,10 @@ def get_random_recipe(space_1, u1_s1):
internal=True, internal=True,
) )
s1 = Step.objects.create(name=str(uuid.uuid4()), instruction=str(uuid.uuid4()), space=space_1, ) s1 = Step.objects.create(name=str(uuid.uuid4()),
s2 = Step.objects.create(name=str(uuid.uuid4()), instruction=str(uuid.uuid4()), space=space_1, ) instruction=str(uuid.uuid4()), space=space_1, )
s2 = Step.objects.create(name=str(uuid.uuid4()),
instruction=str(uuid.uuid4()), space=space_1, )
r.steps.add(s1) r.steps.add(s1)
r.steps.add(s2) r.steps.add(s2)
@ -70,8 +71,10 @@ def get_random_recipe(space_1, u1_s1):
s1.ingredients.add( s1.ingredients.add(
Ingredient.objects.create( Ingredient.objects.create(
amount=1, amount=1,
food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0], food=Food.objects.get_or_create(
unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ), name=str(uuid.uuid4()), space=space_1)[0],
unit=Unit.objects.create(
name=str(uuid.uuid4()), space=space_1, ),
note=str(uuid.uuid4()), note=str(uuid.uuid4()),
space=space_1, space=space_1,
) )
@ -80,8 +83,10 @@ def get_random_recipe(space_1, u1_s1):
s2.ingredients.add( s2.ingredients.add(
Ingredient.objects.create( Ingredient.objects.create(
amount=1, amount=1,
food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0], food=Food.objects.get_or_create(
unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ), name=str(uuid.uuid4()), space=space_1)[0],
unit=Unit.objects.create(
name=str(uuid.uuid4()), space=space_1, ),
note=str(uuid.uuid4()), note=str(uuid.uuid4()),
space=space_1, space=space_1,
) )
@ -99,8 +104,10 @@ def get_random_json_recipe():
{ {
"instruction": str(uuid.uuid4()), "instruction": str(uuid.uuid4()),
"ingredients": [ "ingredients": [
{"food": {"name": str(uuid.uuid4())}, "unit": {"name": str(uuid.uuid4())}, "amount": random.randint(0, 10)}, {"food": {"name": str(uuid.uuid4())}, "unit": {"name": str(
{"food": {"name": str(uuid.uuid4())}, "unit": {"name": str(uuid.uuid4())}, "amount": random.randint(0, 10)}, uuid.uuid4())}, "amount": random.randint(0, 10)},
{"food": {"name": str(uuid.uuid4())}, "unit": {"name": str(
uuid.uuid4())}, "amount": random.randint(0, 10)},
], ],
} }
], ],
@ -133,7 +140,8 @@ def validate_recipe(expected, recipe):
for key in expected_lists: for key in expected_lists:
for k in expected_lists[key]: for k in expected_lists[key]:
try: try:
print('comparing ', any([dict_compare(k, i) for i in target_lists[key]])) print('comparing ', any([dict_compare(k, i)
for i in target_lists[key]]))
assert any([dict_compare(k, i) for i in target_lists[key]]) assert any([dict_compare(k, i) for i in target_lists[key]])
except AssertionError: except AssertionError:
for result in [dict_compare(k, i, details=True) for i in target_lists[key]]: for result in [dict_compare(k, i, details=True) for i in target_lists[key]]:
@ -152,7 +160,8 @@ def dict_compare(d1, d2, details=False):
added = d1_keys - d2_keys added = d1_keys - d2_keys
removed = d2_keys - d1_keys removed = d2_keys - d1_keys
modified = {o: (d1[o], d2[o]) for o in not_dicts if d1[o] != d2[o]} modified = {o: (d1[o], d2[o]) for o in not_dicts if d1[o] != d2[o]}
modified_dicts = {o: (d1[o], d2[o]) for o in sub_dicts if not d1[o].items() <= d2[o].items()} modified_dicts = {o: (d1[o], d2[o])
for o in sub_dicts if not d1[o].items() <= d2[o].items()}
if details: if details:
return added, removed, modified, modified_dicts return added, removed, modified, modified_dicts
else: else:
@ -173,12 +182,12 @@ def transpose(text, number=2):
positions = random.sample(range(len(tokens[token_pos])), number) positions = random.sample(range(len(tokens[token_pos])), number)
# swap the positions # swap the positions
l = list(tokens[token_pos]) lt = list(tokens[token_pos])
for first, second in zip(positions[::2], positions[1::2]): for first, second in zip(positions[::2], positions[1::2]):
l[first], l[second] = l[second], l[first] lt[first], lt[second] = lt[second], lt[first]
# replace original tokens with swapped # replace original tokens with swapped
tokens[token_pos] = ''.join(l) tokens[token_pos] = ''.join(lt)
# return text with the swapped token # return text with the swapped token
return ' '.join(tokens) return ' '.join(tokens)

View File

@ -4,13 +4,12 @@ from decimal import Decimal
import factory import factory
import pytest import pytest
from django.contrib import auth
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from faker import Factory as FakerFactory from faker import Factory as FakerFactory
from pytest_factoryboy import register from pytest_factoryboy import register
from cookbook.models import Recipe, Step, UserSpace from cookbook.models import UserSpace
# this code will run immediately prior to creating the model object useful when you want a reverse relationship # this code will run immediately prior to creating the model object useful when you want a reverse relationship
# log = factory.RelatedFactory( # log = factory.RelatedFactory(
@ -53,7 +52,8 @@ class SpaceFactory(factory.django.DjangoModelFactory):
class UserFactory(factory.django.DjangoModelFactory): class UserFactory(factory.django.DjangoModelFactory):
"""User factory.""" """User factory."""
username = factory.LazyAttribute(lambda x: faker.simple_profile()['username']) username = factory.LazyAttribute(
lambda x: faker.simple_profile()['username'])
first_name = factory.LazyAttribute(lambda x: faker.first_name()) first_name = factory.LazyAttribute(lambda x: faker.first_name())
last_name = factory.LazyAttribute(lambda x: faker.last_name()) last_name = factory.LazyAttribute(lambda x: faker.last_name())
email = factory.LazyAttribute(lambda x: faker.email()) email = factory.LazyAttribute(lambda x: faker.email())
@ -65,7 +65,8 @@ class UserFactory(factory.django.DjangoModelFactory):
return return
if extracted: if extracted:
us = UserSpace.objects.create(space=self.space, user=self, active=True) us = UserSpace.objects.create(
space=self.space, user=self, active=True)
us.groups.add(Group.objects.get(name=extracted)) us.groups.add(Group.objects.get(name=extracted))
@factory.post_generation @factory.post_generation
@ -75,10 +76,12 @@ class UserFactory(factory.django.DjangoModelFactory):
if extracted: if extracted:
for prefs in extracted: for prefs in extracted:
self.userpreference[prefs] = extracted[prefs]/0 # intentionally break so it can be debugged later # intentionally break so it can be debugged later
self.userpreference[prefs] = extracted[prefs] / 0
class Meta: class Meta:
model = User model = User
django_get_or_create = ('username', 'space',)
@register @register
@ -98,18 +101,22 @@ class SupermarketCategoryFactory(factory.django.DjangoModelFactory):
class FoodFactory(factory.django.DjangoModelFactory): class FoodFactory(factory.django.DjangoModelFactory):
"""Food factory.""" """Food factory."""
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)[:128]) name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)[:128])
plural_name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3, variable_nb_words=False)) plural_name = factory.LazyAttribute(
lambda x: faker.sentence(nb_words=3, variable_nb_words=False))
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
supermarket_category = factory.Maybe( supermarket_category = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_category), factory.LazyAttribute(lambda x: x.has_category),
yes_declaration=factory.SubFactory(SupermarketCategoryFactory, space=factory.SelfAttribute('..space')), yes_declaration=factory.SubFactory(
SupermarketCategoryFactory, space=factory.SelfAttribute('..space')),
no_declaration=None no_declaration=None
) )
recipe = factory.Maybe( recipe = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_recipe), factory.LazyAttribute(lambda x: x.has_recipe),
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')), yes_declaration=factory.SubFactory(
'cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
no_declaration=None no_declaration=None
) )
path = None
space = factory.SubFactory(SpaceFactory) space = factory.SubFactory(SpaceFactory)
@factory.post_generation @factory.post_generation
@ -127,17 +134,19 @@ class FoodFactory(factory.django.DjangoModelFactory):
class Meta: class Meta:
model = 'cookbook.Food' model = 'cookbook.Food'
django_get_or_create = ('name', 'plural_name', 'space',) django_get_or_create = ('name', 'plural_name', 'path', 'space',)
@register @register
class RecipeBookFactory(factory.django.DjangoModelFactory): class RecipeBookFactory(factory.django.DjangoModelFactory):
"""RecipeBook factory.""" """RecipeBook factory."""
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3, variable_nb_words=False)) name = factory.LazyAttribute(lambda x: faker.sentence(
nb_words=3, variable_nb_words=False))
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
icon = None icon = None
# shared = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) # shared = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) created_by = factory.SubFactory(
UserFactory, space=factory.SelfAttribute('..space'))
filter = None filter = None
space = factory.SubFactory(SpaceFactory) space = factory.SubFactory(SpaceFactory)
@ -149,7 +158,8 @@ class RecipeBookFactory(factory.django.DjangoModelFactory):
@register @register
class RecipeBookEntryFactory(factory.django.DjangoModelFactory): class RecipeBookEntryFactory(factory.django.DjangoModelFactory):
"""RecipeBookEntry factory.""" """RecipeBookEntry factory."""
book = factory.SubFactory(RecipeBookFactory, space=factory.SelfAttribute('..recipe.space')) book = factory.SubFactory(
RecipeBookFactory, space=factory.SelfAttribute('..recipe.space'))
recipe = None recipe = None
class Meta: class Meta:
@ -173,7 +183,8 @@ class UnitFactory(factory.django.DjangoModelFactory):
@register @register
class KeywordFactory(factory.django.DjangoModelFactory): class KeywordFactory(factory.django.DjangoModelFactory):
"""Keyword factory.""" """Keyword factory."""
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=2, variable_nb_words=False)) name = factory.LazyAttribute(lambda x: faker.sentence(
nb_words=2, variable_nb_words=False))
# icon = models.CharField(max_length=16, blank=True, null=True) # icon = models.CharField(max_length=16, blank=True, null=True)
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
space = factory.SubFactory(SpaceFactory) space = factory.SubFactory(SpaceFactory)
@ -184,15 +195,17 @@ class KeywordFactory(factory.django.DjangoModelFactory):
class Meta: class Meta:
model = 'cookbook.Keyword' model = 'cookbook.Keyword'
django_get_or_create = ('name', 'space',) django_get_or_create = ('name', 'space')
exclude = ('num') exclude = ('num')
@register @register
class IngredientFactory(factory.django.DjangoModelFactory): class IngredientFactory(factory.django.DjangoModelFactory):
"""Ingredient factory.""" """Ingredient factory."""
food = factory.SubFactory(FoodFactory, space=factory.SelfAttribute('..space')) food = factory.SubFactory(
unit = factory.SubFactory(UnitFactory, space=factory.SelfAttribute('..space')) FoodFactory, space=factory.SelfAttribute('..space'))
unit = factory.SubFactory(
UnitFactory, space=factory.SelfAttribute('..space'))
amount = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10)) amount = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10))
note = factory.LazyAttribute(lambda x: faker.sentence(nb_words=8)) note = factory.LazyAttribute(lambda x: faker.sentence(nb_words=8))
is_header = False is_header = False
@ -210,7 +223,8 @@ class MealTypeFactory(factory.django.DjangoModelFactory):
# icon = # icon =
color = factory.LazyAttribute(lambda x: faker.safe_hex_color()) color = factory.LazyAttribute(lambda x: faker.safe_hex_color())
default = False default = False
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) created_by = factory.SubFactory(
UserFactory, space=factory.SelfAttribute('..space'))
space = factory.SubFactory(SpaceFactory) space = factory.SubFactory(SpaceFactory)
class Meta: class Meta:
@ -220,14 +234,18 @@ class MealTypeFactory(factory.django.DjangoModelFactory):
@register @register
class MealPlanFactory(factory.django.DjangoModelFactory): class MealPlanFactory(factory.django.DjangoModelFactory):
recipe = factory.Maybe( recipe = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_recipe), factory.LazyAttribute(lambda x: x.has_recipe),
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')), yes_declaration=factory.SubFactory(
'cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
no_declaration=None no_declaration=None
) )
servings = factory.LazyAttribute(lambda x: Decimal(faker.random_int(min=1, max=1000)/100)) servings = factory.LazyAttribute(
lambda x: Decimal(faker.random_int(min=1, max=1000) / 100))
title = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5)) title = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) created_by = factory.SubFactory(
meal_type = factory.SubFactory(MealTypeFactory, space=factory.SelfAttribute('..space')) UserFactory, space=factory.SelfAttribute('..space'))
meal_type = factory.SubFactory(
MealTypeFactory, space=factory.SelfAttribute('..space'))
note = factory.LazyAttribute(lambda x: faker.paragraph()) note = factory.LazyAttribute(lambda x: faker.paragraph())
date = factory.LazyAttribute(lambda x: faker.future_date()) date = factory.LazyAttribute(lambda x: faker.future_date())
space = factory.SubFactory(SpaceFactory) space = factory.SubFactory(SpaceFactory)
@ -243,12 +261,14 @@ class MealPlanFactory(factory.django.DjangoModelFactory):
class ShoppingListRecipeFactory(factory.django.DjangoModelFactory): class ShoppingListRecipeFactory(factory.django.DjangoModelFactory):
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5)) name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
recipe = factory.Maybe( recipe = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_recipe), factory.LazyAttribute(lambda x: x.has_recipe),
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')), yes_declaration=factory.SubFactory(
'cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
no_declaration=None no_declaration=None
) )
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10)) servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10))
mealplan = factory.SubFactory(MealPlanFactory, space=factory.SelfAttribute('..space')) mealplan = factory.SubFactory(
MealPlanFactory, space=factory.SelfAttribute('..space'))
space = factory.SubFactory(SpaceFactory) space = factory.SubFactory(SpaceFactory)
class Params: class Params:
@ -263,26 +283,33 @@ class ShoppingListEntryFactory(factory.django.DjangoModelFactory):
"""ShoppingListEntry factory.""" """ShoppingListEntry factory."""
list_recipe = factory.Maybe( list_recipe = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_mealplan), factory.LazyAttribute(lambda x: x.has_mealplan),
yes_declaration=factory.SubFactory(ShoppingListRecipeFactory, space=factory.SelfAttribute('..space')), yes_declaration=factory.SubFactory(
ShoppingListRecipeFactory, space=factory.SelfAttribute('..space')),
no_declaration=None no_declaration=None
) )
food = factory.SubFactory(FoodFactory, space=factory.SelfAttribute('..space')) food = factory.SubFactory(
unit = factory.SubFactory(UnitFactory, space=factory.SelfAttribute('..space')) FoodFactory, space=factory.SelfAttribute('..space'))
unit = factory.SubFactory(
UnitFactory, space=factory.SelfAttribute('..space'))
# # ingredient = factory.SubFactory(IngredientFactory) # # ingredient = factory.SubFactory(IngredientFactory)
amount = factory.LazyAttribute(lambda x: Decimal(faker.random_int(min=1, max=100))/10) amount = factory.LazyAttribute(
lambda x: Decimal(faker.random_int(min=1, max=100)) / 10)
order = factory.Sequence(int) order = factory.Sequence(int)
checked = False checked = False
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) created_by = factory.SubFactory(
UserFactory, space=factory.SelfAttribute('..space'))
created_at = factory.LazyAttribute(lambda x: faker.past_date()) created_at = factory.LazyAttribute(lambda x: faker.past_date())
completed_at = None completed_at = None
delay_until = None delay_until = None
space = factory.SubFactory(SpaceFactory) space = factory.SubFactory(SpaceFactory)
@classmethod @classmethod
def _create(cls, target_class, *args, **kwargs): # override create to prevent auto_add_now from changing the created_at date # override create to prevent auto_add_now from changing the created_at date
def _create(cls, target_class, *args, **kwargs):
created_at = kwargs.pop('created_at', None) created_at = kwargs.pop('created_at', None)
obj = super(ShoppingListEntryFactory, cls)._create(target_class, *args, **kwargs) obj = super(ShoppingListEntryFactory, cls)._create(
target_class, *args, **kwargs)
if created_at is not None: if created_at is not None:
obj.created_at = created_at obj.created_at = created_at
obj.save() obj.save()
@ -298,7 +325,8 @@ class ShoppingListEntryFactory(factory.django.DjangoModelFactory):
@register @register
class StepFactory(factory.django.DjangoModelFactory): class StepFactory(factory.django.DjangoModelFactory):
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5)) name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
instruction = factory.LazyAttribute(lambda x: ''.join(faker.paragraphs(nb=5))) instruction = factory.LazyAttribute(
lambda x: ''.join(faker.paragraphs(nb=5)))
# TODO add optional recipe food, make dependent on recipe, make number of recipes a Params # TODO add optional recipe food, make dependent on recipe, make number of recipes a Params
ingredients__count = 10 # default number of ingredients to add ingredients__count = 10 # default number of ingredients to add
ingredients__header = 0 ingredients__header = 0
@ -330,14 +358,16 @@ class StepFactory(factory.django.DjangoModelFactory):
for i in range(num_ing): for i in range(num_ing):
if num_food_recipe > 0: if num_food_recipe > 0:
has_recipe = True has_recipe = True
num_food_recipe = num_food_recipe-1 num_food_recipe = num_food_recipe - 1
else: else:
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))
elif extracted: elif extracted:
for ing in extracted: for ing in extracted:
self.ingredients.add(ing) self.ingredients.add(ing)
@ -351,20 +381,27 @@ class RecipeFactory(factory.django.DjangoModelFactory):
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=7)) name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=7))
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=20)) servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=20))
servings_text = factory.LazyAttribute(lambda x: faker.sentence(nb_words=1)) # TODO generate list of expected servings text that can be iterated through # TODO generate list of expected servings text that can be iterated through
servings_text = factory.LazyAttribute(lambda x: faker.sentence(nb_words=1))
keywords__count = 5 # default number of keywords to generate keywords__count = 5 # default number of keywords to generate
steps__count = 1 # default number of steps to create steps__count = 1 # default number of steps to create
steps__recipe_count = 0 # default number of step recipes to create steps__recipe_count = 0 # default number of step recipes to create
steps__food_recipe_count = {} # by default, don't create food recipes, to override {'steps__food_recipe_count': {'step': 0, 'count': 1}} # by default, don't create food recipes, to override {'steps__food_recipe_count': {'step': 0, 'count': 1}}
working_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360)) steps__food_recipe_count = {}
waiting_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360)) working_time = factory.LazyAttribute(
lambda x: faker.random_int(min=0, max=360))
waiting_time = factory.LazyAttribute(
lambda x: faker.random_int(min=0, max=360))
internal = False internal = False
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) created_by = factory.SubFactory(
created_at = factory.LazyAttribute(lambda x: faker.date_between_dates(date_start=date(2000, 1, 1), date_end=date(2020, 12, 31))) UserFactory, space=factory.SelfAttribute('..space'))
created_at = factory.LazyAttribute(lambda x: faker.date_between_dates(
date_start=date(2000, 1, 1), date_end=date(2020, 12, 31)))
space = factory.SubFactory(SpaceFactory) space = factory.SubFactory(SpaceFactory)
@classmethod @classmethod
def _create(cls, target_class, *args, **kwargs): # override create to prevent auto_add_now from changing the created_at date # override create to prevent auto_add_now from changing the created_at date
def _create(cls, target_class, *args, **kwargs):
created_at = kwargs.pop('created_at', None) created_at = kwargs.pop('created_at', None)
# updated_at = kwargs.pop('updated_at', None) # updated_at = kwargs.pop('updated_at', None)
obj = super(RecipeFactory, cls)._create(target_class, *args, **kwargs) obj = super(RecipeFactory, cls)._create(target_class, *args, **kwargs)
@ -401,11 +438,13 @@ class RecipeFactory(factory.django.DjangoModelFactory):
ing_recipe_count = 0 ing_recipe_count = 0
if food_recipe_count.get('step', None) == i: if food_recipe_count.get('step', None) == i:
ing_recipe_count = food_recipe_count.get('count', 0) ing_recipe_count = food_recipe_count.get('count', 0)
self.steps.add(StepFactory(space=self.space, ingredients__food_recipe_count=ing_recipe_count, ingredients__header=num_ing_headers)) self.steps.add(StepFactory(
num_ing_headers+-1 space=self.space, ingredients__food_recipe_count=ing_recipe_count, ingredients__header=num_ing_headers))
num_ing_headers + - 1
if num_recipe_steps > 0: if num_recipe_steps > 0:
for j in range(num_recipe_steps): for j in range(num_recipe_steps):
self.steps.add(StepFactory(space=self.space, step_recipe__has_recipe=True, ingredients__count=0)) self.steps.add(StepFactory(
space=self.space, step_recipe__has_recipe=True, ingredients__count=0))
if extracted and (num_steps + num_recipe_steps == 0): if extracted and (num_steps + num_recipe_steps == 0):
for step in extracted: for step in extracted:
self.steps.add(step) self.steps.add(step)
@ -428,15 +467,18 @@ class RecipeFactory(factory.django.DjangoModelFactory):
@register @register
class CookLogFactory(factory.django.DjangoModelFactory): class CookLogFactory(factory.django.DjangoModelFactory):
"""CookLog factory.""" """CookLog factory."""
recipe = factory.SubFactory(RecipeFactory, space=factory.SelfAttribute('..space')) recipe = factory.SubFactory(
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) RecipeFactory, space=factory.SelfAttribute('..space'))
created_by = factory.SubFactory(
UserFactory, space=factory.SelfAttribute('..space'))
created_at = factory.LazyAttribute(lambda x: faker.date_this_decade()) created_at = factory.LazyAttribute(lambda x: faker.date_this_decade())
rating = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=5)) rating = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=5))
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=32)) servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=32))
space = factory.SubFactory(SpaceFactory) space = factory.SubFactory(SpaceFactory)
@classmethod @classmethod
def _create(cls, target_class, *args, **kwargs): # override create to prevent auto_add_now from changing the created_at date # override create to prevent auto_add_now from changing the created_at date
def _create(cls, target_class, *args, **kwargs):
created_at = kwargs.pop('created_at', None) created_at = kwargs.pop('created_at', None)
obj = super(CookLogFactory, cls)._create(target_class, *args, **kwargs) obj = super(CookLogFactory, cls)._create(target_class, *args, **kwargs)
if created_at is not None: if created_at is not None:
@ -451,13 +493,17 @@ class CookLogFactory(factory.django.DjangoModelFactory):
@register @register
class ViewLogFactory(factory.django.DjangoModelFactory): class ViewLogFactory(factory.django.DjangoModelFactory):
"""ViewLog factory.""" """ViewLog factory."""
recipe = factory.SubFactory(RecipeFactory, space=factory.SelfAttribute('..space')) recipe = factory.SubFactory(
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) RecipeFactory, space=factory.SelfAttribute('..space'))
created_at = factory.LazyAttribute(lambda x: faker.past_datetime(start_date='-365d')) 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) space = factory.SubFactory(SpaceFactory)
@classmethod @classmethod
def _create(cls, target_class, *args, **kwargs): # override create to prevent auto_add_now from changing the created_at date # override create to prevent auto_add_now from changing the created_at date
def _create(cls, target_class, *args, **kwargs):
created_at = kwargs.pop('created_at', None) created_at = kwargs.pop('created_at', None)
obj = super(ViewLogFactory, cls)._create(target_class, *args, **kwargs) obj = super(ViewLogFactory, cls)._create(target_class, *args, **kwargs)
if created_at is not None: if created_at is not None:

View File

@ -11,6 +11,11 @@ from cookbook.tests.factories import FoodFactory, RecipeFactory
# TODO returns recipes with all ingredients via child substitute # TODO returns recipes with all ingredients via child substitute
# TODO returns recipes with all ingredients via sibling substitute # TODO returns recipes with all ingredients via sibling substitute
if (Food.node_order_by):
node_location = 'sorted-child'
else:
node_location = 'last-child'
@pytest.fixture @pytest.fixture
def recipes(space_1): def recipes(space_1):
@ -19,7 +24,8 @@ def recipes(space_1):
@pytest.fixture @pytest.fixture
def makenow_recipe(request, space_1): def makenow_recipe(request, space_1):
onhand_user = auth.get_user(request.getfixturevalue(request.param.get('onhand_users', 'u1_s1'))) onhand_user = auth.get_user(request.getfixturevalue(
request.param.get('onhand_users', 'u1_s1')))
recipe = RecipeFactory.create(space=space_1) recipe = RecipeFactory.create(space=space_1)
for food in Food.objects.filter(ingredient__step__recipe=recipe.id): for food in Food.objects.filter(ingredient__step__recipe=recipe.id):
@ -55,13 +61,16 @@ def test_makenow_ignoreshopping(recipes, makenow_recipe, user1, space_1):
request = type('', (object,), {'space': space_1, 'user': user1})() request = type('', (object,), {'space': space_1, 'user': user1})()
search = RecipeSearch(request, makenow='true') search = RecipeSearch(request, makenow='true')
with scope(space=space_1): with scope(space=space_1):
food = Food.objects.filter(ingredient__step__recipe=makenow_recipe.id).first() food = Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id).first()
food.onhand_users.clear() food.onhand_users.clear()
assert search.get_queryset(Recipe.objects.all()).count() == 0 assert search.get_queryset(Recipe.objects.all()).count() == 0
food.ignore_shopping = True food.ignore_shopping = True
food.save() food.save()
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9 assert Food.objects.filter(
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, ignore_shopping=True).count() == 1 ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9
assert Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id, ignore_shopping=True).count() == 1
search = search.get_queryset(Recipe.objects.all()) search = search.get_queryset(Recipe.objects.all())
assert search.count() == 1 assert search.count() == 1
assert search.first().id == makenow_recipe.id assert search.first().id == makenow_recipe.id
@ -74,13 +83,17 @@ def test_makenow_substitute(recipes, makenow_recipe, user1, space_1):
request = type('', (object,), {'space': space_1, 'user': user1})() request = type('', (object,), {'space': space_1, 'user': user1})()
search = RecipeSearch(request, makenow='true') search = RecipeSearch(request, makenow='true')
with scope(space=space_1): with scope(space=space_1):
food = Food.objects.filter(ingredient__step__recipe=makenow_recipe.id).first() food = Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id).first()
onhand_user = food.onhand_users.first() onhand_user = food.onhand_users.first()
food.onhand_users.clear() food.onhand_users.clear()
assert search.get_queryset(Recipe.objects.all()).count() == 0 assert search.get_queryset(Recipe.objects.all()).count() == 0
food.substitute.add(FoodFactory.create(space=space_1, onhand_users=[onhand_user])) food.substitute.add(FoodFactory.create(
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9 space=space_1, onhand_users=[onhand_user]))
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, substitute__isnull=False).count() == 1 assert Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9
assert Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id, substitute__isnull=False).count() == 1
search = search.get_queryset(Recipe.objects.all()) search = search.get_queryset(Recipe.objects.all())
assert search.count() == 1 assert search.count() == 1
@ -94,16 +107,20 @@ def test_makenow_child_substitute(recipes, makenow_recipe, user1, space_1):
request = type('', (object,), {'space': space_1, 'user': user1})() request = type('', (object,), {'space': space_1, 'user': user1})()
search = RecipeSearch(request, makenow='true') search = RecipeSearch(request, makenow='true')
with scope(space=space_1): with scope(space=space_1):
food = Food.objects.filter(ingredient__step__recipe=makenow_recipe.id).first() food = Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id).first()
onhand_user = food.onhand_users.first() onhand_user = food.onhand_users.first()
food.onhand_users.clear() food.onhand_users.clear()
food.substitute_children = True food.substitute_children = True
food.save() food.save()
assert search.get_queryset(Recipe.objects.all()).count() == 0 assert search.get_queryset(Recipe.objects.all()).count() == 0
new_food = FoodFactory.create(space=space_1, onhand_users=[onhand_user]) new_food = FoodFactory.create(
new_food.move(food, 'first-child') space=space_1, onhand_users=[onhand_user])
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9 new_food.move(food, node_location)
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, numchild__gt=0).count() == 1 assert Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9
assert Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id, numchild__gt=0).count() == 1
search = search.get_queryset(Recipe.objects.all()) search = search.get_queryset(Recipe.objects.all())
assert search.count() == 1 assert search.count() == 1
assert search.first().id == makenow_recipe.id assert search.first().id == makenow_recipe.id
@ -116,18 +133,22 @@ def test_makenow_sibling_substitute(recipes, makenow_recipe, user1, space_1):
request = type('', (object,), {'space': space_1, 'user': user1})() request = type('', (object,), {'space': space_1, 'user': user1})()
search = RecipeSearch(request, makenow='true') search = RecipeSearch(request, makenow='true')
with scope(space=space_1): with scope(space=space_1):
food = Food.objects.filter(ingredient__step__recipe=makenow_recipe.id).first() food = Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id).first()
onhand_user = food.onhand_users.first() onhand_user = food.onhand_users.first()
food.onhand_users.clear() food.onhand_users.clear()
food.substitute_siblings = True food.substitute_siblings = True
food.save() food.save()
assert search.get_queryset(Recipe.objects.all()).count() == 0 assert search.get_queryset(Recipe.objects.all()).count() == 0
new_parent = FoodFactory.create(space=space_1) new_parent = FoodFactory.create(space=space_1)
new_sibling = FoodFactory.create(space=space_1, onhand_users=[onhand_user]) new_sibling = FoodFactory.create(
new_sibling.move(new_parent, 'first-child') space=space_1, onhand_users=[onhand_user])
food.move(new_parent, 'first-child') new_sibling.move(new_parent, node_location)
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9 food.move(new_parent, node_location)
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, depth=2).count() == 1 assert Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9
assert Food.objects.filter(
ingredient__step__recipe=makenow_recipe.id, depth=2).count() == 1
search = search.get_queryset(Recipe.objects.all()) search = search.get_queryset(Recipe.objects.all())
assert search.count() == 1 assert search.count() == 1
assert search.first().id == makenow_recipe.id assert search.first().id == makenow_recipe.id

View File

@ -7,9 +7,9 @@ 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.utils import timezone
from django_scopes import scope, scopes_disabled from django_scopes import scope
from cookbook.models import Food, Recipe, SearchFields from cookbook.models import Recipe, SearchFields
from cookbook.tests.conftest import transpose from cookbook.tests.conftest import transpose
from cookbook.tests.factories import (CookLogFactory, FoodFactory, IngredientFactory, from cookbook.tests.factories import (CookLogFactory, FoodFactory, IngredientFactory,
KeywordFactory, RecipeBookEntryFactory, RecipeFactory, KeywordFactory, RecipeBookEntryFactory, RecipeFactory,
@ -23,7 +23,8 @@ from cookbook.tests.factories import (CookLogFactory, FoodFactory, IngredientFac
# TODO makenow with above filters # TODO makenow with above filters
# TODO test search food/keywords including/excluding children # TODO test search food/keywords including/excluding 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'] sqlite = settings.DATABASES['default']['ENGINE'] not in [
'django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
@pytest.fixture @pytest.fixture
@ -50,26 +51,43 @@ def user1(request, space_1, u1_s1, unaccent):
if params.get('fuzzy_lookups', False): if params.get('fuzzy_lookups', False):
user.searchpreference.lookup = True user.searchpreference.lookup = True
misspelled_result = 1 misspelled_result = 1
else:
user.searchpreference.lookup = False
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 misspelled_result = 1
else:
user.searchpreference.trigram.set([])
if params.get('icontains', False): if params.get('icontains', False):
user.searchpreference.icontains.set(SearchFields.objects.all()) user.searchpreference.icontains.set(SearchFields.objects.all())
search_term = 'ghijklmn' search_term = 'ghijklmn'
else:
user.searchpreference.icontains.set([])
if params.get('istartswith', False): if params.get('istartswith', False):
user.searchpreference.istartswith.set(SearchFields.objects.all()) user.searchpreference.istartswith.set(SearchFields.objects.all())
search_term = 'abcdef' search_term = 'abcdef'
else:
user.searchpreference.istartswith.set([])
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 misspelled_result *= 2
result *= 2 result *= 2
else:
user.searchpreference.unaccent.set([])
# full text vectors are hard coded to use unaccent - put this after unaccent to override result # full text vectors are hard coded to use unaccent - put this after unaccent to override result
if params.get('fulltext', False): if params.get('fulltext', False):
user.searchpreference.fulltext.set(SearchFields.objects.all()) user.searchpreference.fulltext.set(SearchFields.objects.all())
# user.searchpreference.search = 'websearch' # user.searchpreference.search = 'websearch'
search_term = 'ghijklmn uvwxyz' search_term = 'ghijklmn uvwxyz'
result = 2 result = 2
else:
user.searchpreference.fulltext.set([])
user.searchpreference.save() user.searchpreference.save()
misspelled_term = transpose(search_term, number=3) misspelled_term = transpose(search_term, number=3)
return (u1_s1, result, misspelled_result, search_term, misspelled_term, params) return (u1_s1, result, misspelled_result, search_term, misspelled_term, params)
@ -104,7 +122,8 @@ def found_recipe(request, space_1, accent, unaccent, u1_s1, u2_s1):
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))
if request.param.get('keyword', None): if request.param.get('keyword', None):
obj1 = KeywordFactory.create(name=unaccent, space=space_1) obj1 = KeywordFactory.create(name=unaccent, space=space_1)
obj2 = KeywordFactory.create(name=accent, space=space_1) obj2 = KeywordFactory.create(name=accent, space=space_1)
@ -125,7 +144,8 @@ def found_recipe(request, space_1, accent, unaccent, u1_s1, u2_s1):
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): if request.param.get('name', None):
recipe1.name = unaccent recipe1.name = unaccent
recipe2.name = accent recipe2.name = accent
@ -145,21 +165,32 @@ def found_recipe(request, space_1, accent, unaccent, u1_s1, u2_s1):
i2.save() i2.save()
if request.param.get('viewedon', None): if request.param.get('viewedon', None):
ViewLogFactory.create(recipe=recipe1, created_by=user1, created_at=days_3, space=space_1) ViewLogFactory.create(recipe=recipe1, created_by=user1,
ViewLogFactory.create(recipe=recipe2, created_by=user1, created_at=days_30, space=space_1) created_at=days_3, space=space_1)
ViewLogFactory.create(recipe=recipe3, created_by=user2, created_at=days_15, 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): if request.param.get('cookedon', None):
CookLogFactory.create(recipe=recipe1, created_by=user1, created_at=days_3, space=space_1) CookLogFactory.create(recipe=recipe1, created_by=user1,
CookLogFactory.create(recipe=recipe2, created_by=user1, created_at=days_30, space=space_1) created_at=days_3, space=space_1)
CookLogFactory.create(recipe=recipe3, created_by=user2, created_at=days_15, 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): if request.param.get('timescooked', None):
CookLogFactory.create_batch(5, recipe=recipe1, created_by=user1, space=space_1) CookLogFactory.create_batch(
CookLogFactory.create(recipe=recipe2, created_by=user1, space=space_1) 5, recipe=recipe1, created_by=user1, space=space_1)
CookLogFactory.create_batch(3, recipe=recipe3, created_by=user2, 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): if request.param.get('rating', None):
CookLogFactory.create(recipe=recipe1, created_by=user1, rating=5.0, space=space_1) CookLogFactory.create(
CookLogFactory.create(recipe=recipe2, created_by=user1, rating=1.0, space=space_1) recipe=recipe1, created_by=user1, rating=5.0, space=space_1)
CookLogFactory.create(recipe=recipe3, created_by=user2, rating=3.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, request.param) return (recipe1, recipe2, recipe3, obj1, obj2, request.param)
@ -188,7 +219,8 @@ def test_search_or_and_not(found_recipe, param_type, operator, recipes, u1_s1, s
assert found_recipe[1].id in [x['id'] for x in r['results']] assert found_recipe[1].id in [x['id'] for x in r['results']]
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']]
r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param1}&{param2}').content) r = json.loads(u1_s1.get(reverse(LIST_URL) +
f'?{param1}&{param2}').content)
assert r['count'] == operator[1] assert r['count'] == operator[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']]
@ -203,7 +235,8 @@ def test_search_or_and_not(found_recipe, param_type, operator, recipes, u1_s1, s
assert found_recipe[1].id not in [x['id'] for x in r['results']] assert found_recipe[1].id not in [x['id'] for x in r['results']]
assert found_recipe[2].id not in [x['id'] for x in r['results']] assert found_recipe[2].id not in [x['id'] for x in r['results']]
r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param1_not}&{param2_not}').content) r = json.loads(u1_s1.get(reverse(LIST_URL) +
f'?{param1_not}&{param2_not}').content)
assert r['count'] == 10 + operator[2] assert r['count'] == 10 + operator[2]
assert found_recipe[2].id not in [x['id'] for x in r['results']] assert found_recipe[2].id not in [x['id'] for x in r['results']]
@ -227,13 +260,14 @@ def test_search_units(found_recipe, recipes, u1_s1, space_1):
assert found_recipe[1].id in [x['id'] for x in r['results']] assert found_recipe[1].id in [x['id'] for x in r['results']]
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']]
r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param1}&{param2}').content) r = json.loads(u1_s1.get(reverse(LIST_URL) +
f'?{param1}&{param2}').content)
assert r['count'] == 3 assert r['count'] == 3
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.skipif(sqlite, reason="requires PostgreSQL")
@pytest.mark.parametrize("user1", itertools.product( @pytest.mark.parametrize("user1", itertools.product(
[ [
('fuzzy_search', True), ('fuzzy_search', False), ('fuzzy_search', True), ('fuzzy_search', False),
('fuzzy_lookups', True), ('fuzzy_lookups', False) ('fuzzy_lookups', True), ('fuzzy_lookups', False)
@ -245,22 +279,26 @@ def test_search_units(found_recipe, recipes, u1_s1, space_1):
({'keyword': True}, 'keyword'), ({'keyword': True}, 'keyword'),
({'food': True}, 'food'), ({'food': True}, 'food'),
], indirect=['found_recipe']) ], indirect=['found_recipe'])
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 = f"query={user1[3]}" param1 = f"query={user1[3]}"
param2 = f"query={user1[4]}" param2 = f"query={user1[4]}"
r = json.loads(user1[0].get(reverse(list_url) + f'?{param1}&limit=2').content) r = json.loads(user1[0].get(reverse(list_url) +
assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[3].id, found_recipe[4].id]]) == user1[1] 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]
r = json.loads(user1[0].get(reverse(list_url) + f'?{param2}&limit=10').content) r = json.loads(user1[0].get(reverse(list_url) +
assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[3].id, found_recipe[4].id]]) == user1[2] 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[2]
# commenting this out for general use - it is really slow # commenting this out for general use - it is really slow
# it should be run on occasion to ensure everything still works # it should be run on occasion to ensure everything still works
# @pytest.mark.skipif(sqlite and True, reason="requires PostgreSQL") # @pytest.mark.skipif(sqlite and True, reason="requires PostgreSQL")
# @pytest.mark.parametrize("user1", itertools.product( # @pytest.mark.parametrize("user1", itertools.product(
# [ # [
# ('fuzzy_search', True), ('fuzzy_search', False), # ('fuzzy_search', True), ('fuzzy_search', False),
# ('fulltext', True), ('fulltext', False), # ('fulltext', True), ('fulltext', False),
@ -276,29 +314,35 @@ def test_fuzzy_lookup(found_recipe, recipes, param_type, user1, space_1):
# ({'keyword': True}), # ({'keyword': True}),
# ({'food': True}), # ({'food': True}),
# ], indirect=['found_recipe']) # ], indirect=['found_recipe'])
# def test_search_string(found_recipe, recipes, user1, space_1): # # user array contains: user client, expected count of search, expected count of mispelled search, search string, mispelled search string, user search preferences
# def test_search_string(found_recipe, recipes, user1, space_1):
# with scope(space=space_1): # with scope(space=space_1):
# param1 = f"query={user1[3]}" # param1 = f"query={user1[3]}"
# param2 = f"query={user1[4]}" # param2 = f"query={user1[4]}"
# r = json.loads(user1[0].get(reverse(LIST_URL) + f'?{param1}').content) # 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] # 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) # 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] # 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", [ @pytest.mark.parametrize("found_recipe, param_type, result", [
({'viewedon': True}, 'viewedon', (1, 1)), ({'viewedon': True}, 'viewedon', (1, 1)),
({'cookedon': True}, 'cookedon', (1, 1)), ({'cookedon': True}, 'cookedon', (1, 1)),
({'createdon': True}, 'createdon', (2, 12)), # created dates are not filtered by user # created dates are not filtered by user
({'createdon': True}, 'updatedon', (2, 12)), # updated dates are not filtered by user ({'createdon': True}, 'createdon', (2, 12)),
# updated dates are not filtered by user
({'createdon': True}, 'updatedon', (2, 12)),
], indirect=['found_recipe']) ], indirect=['found_recipe'])
def test_search_date(found_recipe, recipes, param_type, result, u1_s1, u2_s1, space_1): def test_search_date(found_recipe, recipes, param_type, result, u1_s1, u2_s1, space_1):
# force updated_at to equal created_at datetime # force updated_at to equal created_at datetime
with scope(space=space_1): with scope(space=space_1):
for recipe in Recipe.objects.all(): for recipe in Recipe.objects.all():
Recipe.objects.filter(id=recipe.id).update(updated_at=recipe.created_at) Recipe.objects.filter(id=recipe.id).update(
updated_at=recipe.created_at)
date = (timezone.now() - timedelta(days=15)).strftime("%Y-%m-%d") date = (timezone.now() - timedelta(days=15)).strftime("%Y-%m-%d")
param1 = f"?{param_type}={date}" param1 = f"?{param_type}={date}"
@ -321,34 +365,34 @@ def test_search_date(found_recipe, recipes, param_type, result, u1_s1, u2_s1, sp
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']]
# TODO this is somehow screwed, probably the search itself, dont want to fix it for now @pytest.mark.parametrize("found_recipe, param_type", [
# @pytest.mark.parametrize("found_recipe, param_type", [ ({'rating': True}, 'rating'),
# ({'rating': True}, 'rating'), ({'timescooked': True}, 'timescooked'),
# ({'timescooked': True}, 'timescooked'), ], indirect=['found_recipe'])
# ], indirect=['found_recipe']) def test_search_count(found_recipe, recipes, param_type, u1_s1, u2_s1, space_1):
# def test_search_count(found_recipe, recipes, param_type, u1_s1, u2_s1, space_1): param1 = f'?{param_type}=3'
# param1 = f'?{param_type}=3' param2 = f'?{param_type}=-3'
# param2 = f'?{param_type}=-3' param3 = f'?{param_type}=0'
# param3 = f'?{param_type}=0'
# r = json.loads(u1_s1.get(reverse(LIST_URL) + param1).content)
# r = json.loads(u1_s1.get(reverse(LIST_URL) + param1).content) assert r['count'] == 1
# assert r['count'] == 1 assert found_recipe[0].id in [x['id'] for x in r['results']]
# assert found_recipe[0].id in [x['id'] for x in r['results']]
# r = json.loads(u1_s1.get(reverse(LIST_URL) + param2).content)
# r = json.loads(u1_s1.get(reverse(LIST_URL) + param2).content) assert r['count'] == 1
# assert r['count'] == 1 assert found_recipe[1].id in [x['id'] for x in r['results']]
# assert found_recipe[1].id in [x['id'] for x in r['results']]
# # test search for not rated/cooked
# # test search for not rated/cooked r = json.loads(u1_s1.get(reverse(LIST_URL) + param3).content)
# r = json.loads(u1_s1.get(reverse(LIST_URL) + param3).content) assert r['count'] == 11
# assert r['count'] == 11 assert (found_recipe[0].id or found_recipe[1].id) not in [
# assert (found_recipe[0].id or found_recipe[1].id) not in [x['id'] for x in r['results']] x['id'] for x in r['results']]
#
# # test matched returns for lte and gte searches # test matched returns for lte and gte searches
# r = json.loads(u2_s1.get(reverse(LIST_URL) + param1).content) r = json.loads(u2_s1.get(reverse(LIST_URL) + param1).content)
# assert r['count'] == 1 assert r['count'] == 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']]
#
# r = json.loads(u2_s1.get(reverse(LIST_URL) + param2).content) r = json.loads(u2_s1.get(reverse(LIST_URL) + param2).content)
# assert r['count'] == 1 assert r['count'] == 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']]

View File

@ -24,7 +24,8 @@ load_dotenv()
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Get vars from .env files # Get vars from .env files
SECRET_KEY = os.getenv('SECRET_KEY') if os.getenv('SECRET_KEY') else 'INSECURE_STANDARD_KEY_SET_IN_ENV' SECRET_KEY = os.getenv('SECRET_KEY') if os.getenv(
'SECRET_KEY') else 'INSECURE_STANDARD_KEY_SET_IN_ENV'
DEBUG = bool(int(os.getenv('DEBUG', True))) DEBUG = bool(int(os.getenv('DEBUG', True)))
DEBUG_TOOLBAR = bool(int(os.getenv('DEBUG_TOOLBAR', True))) DEBUG_TOOLBAR = bool(int(os.getenv('DEBUG_TOOLBAR', True)))
@ -35,9 +36,11 @@ SOCIAL_DEFAULT_GROUP = os.getenv('SOCIAL_DEFAULT_GROUP', 'guest')
SPACE_DEFAULT_MAX_RECIPES = int(os.getenv('SPACE_DEFAULT_MAX_RECIPES', 0)) SPACE_DEFAULT_MAX_RECIPES = int(os.getenv('SPACE_DEFAULT_MAX_RECIPES', 0))
SPACE_DEFAULT_MAX_USERS = int(os.getenv('SPACE_DEFAULT_MAX_USERS', 0)) SPACE_DEFAULT_MAX_USERS = int(os.getenv('SPACE_DEFAULT_MAX_USERS', 0))
SPACE_DEFAULT_MAX_FILES = int(os.getenv('SPACE_DEFAULT_MAX_FILES', 0)) SPACE_DEFAULT_MAX_FILES = int(os.getenv('SPACE_DEFAULT_MAX_FILES', 0))
SPACE_DEFAULT_ALLOW_SHARING = bool(int(os.getenv('SPACE_DEFAULT_ALLOW_SHARING', True))) SPACE_DEFAULT_ALLOW_SHARING = bool(
int(os.getenv('SPACE_DEFAULT_ALLOW_SHARING', True)))
INTERNAL_IPS = os.getenv('INTERNAL_IPS').split(',') if os.getenv('INTERNAL_IPS') else ['127.0.0.1'] INTERNAL_IPS = os.getenv('INTERNAL_IPS').split(
',') if os.getenv('INTERNAL_IPS') else ['127.0.0.1']
# allow djangos wsgi server to server mediafiles # allow djangos wsgi server to server mediafiles
GUNICORN_MEDIA = bool(int(os.getenv('GUNICORN_MEDIA', True))) GUNICORN_MEDIA = bool(int(os.getenv('GUNICORN_MEDIA', True)))
@ -51,9 +54,11 @@ KJ_PREF_DEFAULT = bool(int(os.getenv('KJ_PREF_DEFAULT', False)))
STICKY_NAV_PREF_DEFAULT = bool(int(os.getenv('STICKY_NAV_PREF_DEFAULT', True))) STICKY_NAV_PREF_DEFAULT = bool(int(os.getenv('STICKY_NAV_PREF_DEFAULT', True)))
# minimum interval that users can set for automatic sync of shopping lists # minimum interval that users can set for automatic sync of shopping lists
SHOPPING_MIN_AUTOSYNC_INTERVAL = int(os.getenv('SHOPPING_MIN_AUTOSYNC_INTERVAL', 5)) SHOPPING_MIN_AUTOSYNC_INTERVAL = int(
os.getenv('SHOPPING_MIN_AUTOSYNC_INTERVAL', 5))
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS').split(',') if os.getenv('ALLOWED_HOSTS') else ['*'] ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS').split(
',') if os.getenv('ALLOWED_HOSTS') else ['*']
if os.getenv('CSRF_TRUSTED_ORIGINS'): if os.getenv('CSRF_TRUSTED_ORIGINS'):
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS').split(',') CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS').split(',')
@ -131,7 +136,8 @@ try:
plugin_module = f'recipes.plugins.{d}.apps.{app_config_classname}' plugin_module = f'recipes.plugins.{d}.apps.{app_config_classname}'
if plugin_module not in INSTALLED_APPS: if plugin_module not in INSTALLED_APPS:
INSTALLED_APPS.append(plugin_module) INSTALLED_APPS.append(plugin_module)
plugin_class = getattr(sys.modules[apps_path], app_config_classname) plugin_class = getattr(
sys.modules[apps_path], app_config_classname)
plugin_config = { plugin_config = {
'name': plugin_class.verbose_name if hasattr(plugin_class, 'verbose_name') else plugin_class.name, 'name': plugin_class.verbose_name if hasattr(plugin_class, 'verbose_name') else plugin_class.name,
'module': f'recipes.plugins.{d}', 'module': f'recipes.plugins.{d}',
@ -148,7 +154,8 @@ except Exception:
if DEBUG: if DEBUG:
print('ERROR failed to initialize plugins') print('ERROR failed to initialize plugins')
SOCIAL_PROVIDERS = os.getenv('SOCIAL_PROVIDERS').split(',') if os.getenv('SOCIAL_PROVIDERS') else [] SOCIAL_PROVIDERS = os.getenv('SOCIAL_PROVIDERS').split(
',') if os.getenv('SOCIAL_PROVIDERS') else []
SOCIALACCOUNT_EMAIL_VERIFICATION = 'none' SOCIALACCOUNT_EMAIL_VERIFICATION = 'none'
INSTALLED_APPS = INSTALLED_APPS + SOCIAL_PROVIDERS INSTALLED_APPS = INSTALLED_APPS + SOCIAL_PROVIDERS
@ -194,7 +201,8 @@ if DEBUG_TOOLBAR:
INSTALLED_APPS += ('debug_toolbar',) INSTALLED_APPS += ('debug_toolbar',)
SORT_TREE_BY_NAME = bool(int(os.getenv('SORT_TREE_BY_NAME', False))) SORT_TREE_BY_NAME = bool(int(os.getenv('SORT_TREE_BY_NAME', False)))
DISABLE_TREE_FIX_STARTUP = bool(int(os.getenv('DISABLE_TREE_FIX_STARTUP', False))) DISABLE_TREE_FIX_STARTUP = bool(
int(os.getenv('DISABLE_TREE_FIX_STARTUP', False)))
if bool(int(os.getenv('SQL_DEBUG', False))): if bool(int(os.getenv('SQL_DEBUG', False))):
MIDDLEWARE += ('recipes.middleware.SqlPrintingMiddleware',) MIDDLEWARE += ('recipes.middleware.SqlPrintingMiddleware',)
@ -225,10 +233,12 @@ if LDAP_AUTH:
'last_name': 'sn', 'last_name': 'sn',
'email': 'mail', 'email': 'mail',
} }
AUTH_LDAP_ALWAYS_UPDATE_USER = bool(int(os.getenv('AUTH_LDAP_ALWAYS_UPDATE_USER', True))) AUTH_LDAP_ALWAYS_UPDATE_USER = bool(
int(os.getenv('AUTH_LDAP_ALWAYS_UPDATE_USER', True)))
AUTH_LDAP_CACHE_TIMEOUT = int(os.getenv('AUTH_LDAP_CACHE_TIMEOUT', 3600)) AUTH_LDAP_CACHE_TIMEOUT = int(os.getenv('AUTH_LDAP_CACHE_TIMEOUT', 3600))
if 'AUTH_LDAP_TLS_CACERTFILE' in os.environ: if 'AUTH_LDAP_TLS_CACERTFILE' in os.environ:
AUTH_LDAP_GLOBAL_OPTIONS = {ldap.OPT_X_TLS_CACERTFILE: os.getenv('AUTH_LDAP_TLS_CACERTFILE')} AUTH_LDAP_GLOBAL_OPTIONS = {
ldap.OPT_X_TLS_CACERTFILE: os.getenv('AUTH_LDAP_TLS_CACERTFILE')}
if DEBUG: if DEBUG:
LOGGING = { LOGGING = {
"version": 1, "version": 1,
@ -249,7 +259,8 @@ ACCOUNT_ADAPTER = 'cookbook.helper.AllAuthCustomAdapter'
if REVERSE_PROXY_AUTH: if REVERSE_PROXY_AUTH:
MIDDLEWARE.insert(8, 'recipes.middleware.CustomRemoteUser') MIDDLEWARE.insert(8, 'recipes.middleware.CustomRemoteUser')
AUTHENTICATION_BACKENDS.append('django.contrib.auth.backends.RemoteUserBackend') AUTHENTICATION_BACKENDS.append(
'django.contrib.auth.backends.RemoteUserBackend')
# Password validation # Password validation
# https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators
@ -444,7 +455,8 @@ LANGUAGES = [
SCRIPT_NAME = os.getenv('SCRIPT_NAME', '') SCRIPT_NAME = os.getenv('SCRIPT_NAME', '')
# path for django_js_reverse to generate the javascript file containing all urls. Only done because the default command (collectstatic_js_reverse) fails to update the manifest # path for django_js_reverse to generate the javascript file containing all urls. Only done because the default command (collectstatic_js_reverse) fails to update the manifest
JS_REVERSE_OUTPUT_PATH = os.path.join(BASE_DIR, "cookbook/static/django_js_reverse") JS_REVERSE_OUTPUT_PATH = os.path.join(
BASE_DIR, "cookbook/static/django_js_reverse")
JS_REVERSE_SCRIPT_PREFIX = os.getenv('JS_REVERSE_SCRIPT_PREFIX', SCRIPT_NAME) JS_REVERSE_SCRIPT_PREFIX = os.getenv('JS_REVERSE_SCRIPT_PREFIX', SCRIPT_NAME)
STATIC_URL = os.getenv('STATIC_URL', '/static/') STATIC_URL = os.getenv('STATIC_URL', '/static/')
@ -499,4 +511,5 @@ EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
EMAIL_USE_TLS = bool(int(os.getenv('EMAIL_USE_TLS', False))) EMAIL_USE_TLS = bool(int(os.getenv('EMAIL_USE_TLS', False)))
EMAIL_USE_SSL = bool(int(os.getenv('EMAIL_USE_SSL', False))) EMAIL_USE_SSL = bool(int(os.getenv('EMAIL_USE_SSL', False)))
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost') DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv('ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv(
'ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix

View File

@ -30,11 +30,11 @@ Jinja2==3.1.2
django-webpack-loader==1.8.1 django-webpack-loader==1.8.1
git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82 git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82
django-allauth==0.52.0 django-allauth==0.52.0
recipe-scrapers==14.35.0 recipe-scrapers==14.36.1
django-scopes==1.2.0.post1 django-scopes==1.2.0.post1
pytest==7.2.2 pytest==7.3.1
pytest-django==4.5.2 pytest-django==4.5.2
django-treebeard==4.5.1 django-treebeard==4.7
django-cors-headers==3.13.0 django-cors-headers==3.13.0
django-storages==1.13.2 django-storages==1.13.2
boto3==1.26.41 boto3==1.26.41
@ -42,7 +42,7 @@ django-prometheus==2.2.0
django-hCaptcha==0.2.0 django-hCaptcha==0.2.0
python-ldap==3.4.3 python-ldap==3.4.3
django-auth-ldap==4.2.0 django-auth-ldap==4.2.0
pytest-factoryboy==2.5.0 pytest-factoryboy==2.5.1
pyppeteer==1.0.2 pyppeteer==1.0.2
validators==0.20.0 validators==0.20.0
pytube==12.1.0 pytube==12.1.0

View File

@ -12,18 +12,17 @@
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i> <i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
</td> </td>
<td class="text-nowrap" @click="done"> <td class="text-nowrap" @click="done">
<span v-if="ingredient.amount !== 0 && !ingredient.no_amount" <span v-if="ingredient.amount !== 0 && !ingredient.no_amount" v-html="calculateAmount(ingredient.amount)"></span>
v-html="calculateAmount(ingredient.amount)"></span>
</td> </td>
<td @click="done"> <td @click="done">
<template v-if="ingredient.unit !== null && !ingredient.no_amount"> <template v-if="ingredient.unit !== null && !ingredient.no_amount">
<template > <template>
<template v-if="ingredient.unit.plural_name === '' || ingredient.unit.plural_name === null"> <template v-if="ingredient.unit.plural_name === '' || ingredient.unit.plural_name === null">
<span>{{ ingredient.unit.name }}</span> <span>{{ ingredient.unit.name }}</span>
</template> </template>
<template v-else> <template v-else>
<span v-if="ingredient.always_use_plural_unit">{{ ingredient.unit.plural_name}}</span> <span v-if="ingredient.always_use_plural_unit">{{ ingredient.unit.plural_name }}</span>
<span v-else-if="(ingredient.amount * this.ingredient_factor) > 1">{{ ingredient.unit.plural_name }}</span> <span v-else-if="ingredient.amount * this.ingredient_factor > 1">{{ ingredient.unit.plural_name }}</span>
<span v-else>{{ ingredient.unit.name }}</span> <span v-else>{{ ingredient.unit.name }}</span>
</template> </template>
</template> </template>
@ -31,11 +30,10 @@
</td> </td>
<td @click="done"> <td @click="done">
<template v-if="ingredient.food !== null"> <template v-if="ingredient.food !== null">
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" <a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null" target="_blank" rel="noopener noreferrer">{{
v-if="ingredient.food.recipe !== null" target="_blank" ingredient.food.name
rel="noopener noreferrer">{{ ingredient.food.name }}</a> }}</a>
<template v-if="ingredient.food.recipe === null"> <template v-if="ingredient.food.recipe === null">
<template> <template>
<template v-if="ingredient.food.plural_name === '' || ingredient.food.plural_name === null"> <template v-if="ingredient.food.plural_name === '' || ingredient.food.plural_name === null">
<span>{{ ingredient.food.name }}</span> <span>{{ ingredient.food.name }}</span>
@ -43,7 +41,7 @@
<template v-else> <template v-else>
<span v-if="ingredient.always_use_plural_food">{{ ingredient.food.plural_name }}</span> <span v-if="ingredient.always_use_plural_food">{{ ingredient.food.plural_name }}</span>
<span v-else-if="ingredient.no_amount">{{ ingredient.food.name }}</span> <span v-else-if="ingredient.no_amount">{{ ingredient.food.name }}</span>
<span v-else-if="(ingredient.amount * this.ingredient_factor) > 1">{{ ingredient.food.plural_name }}</span> <span v-else-if="ingredient.amount * this.ingredient_factor > 1">{{ ingredient.food.plural_name }}</span>
<span v-else>{{ ingredient.food.name }}</span> <span v-else>{{ ingredient.food.name }}</span>
</template> </template>
</template> </template>
@ -51,35 +49,32 @@
</template> </template>
</td> </td>
<td v-if="detailed"> <td v-if="detailed">
<div v-if="ingredient.note"> <template v-if="ingredient.note">
<span v-b-popover.hover="ingredient.note" class="d-print-none touchable p-0 pl-md-2 pr-md-2"> <span v-b-popover.hover="ingredient.note" class="d-print-none touchable py-0 px-2">
<i class="far fa-comment"></i> <i class="far fa-comment"></i>
</span> </span>
<div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ <div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
ingredient.note </template>
}}
</div>
</div>
</td> </td>
</template> </template>
</tr> </tr>
</template> </template>
<script> <script>
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils" import { calculateAmount, ResolveUrlMixin } from "@/utils/utils"
import Vue from "vue" import Vue from "vue"
import VueSanitize from "vue-sanitize"; import VueSanitize from "vue-sanitize"
Vue.use(VueSanitize); Vue.use(VueSanitize)
export default { export default {
name: "IngredientComponent", name: "IngredientComponent",
props: { props: {
ingredient: Object, ingredient: Object,
ingredient_factor: {type: Number, default: 1}, ingredient_factor: { type: Number, default: 1 },
detailed: {type: Boolean, default: true}, detailed: { type: Boolean, default: true },
}, },
mixins: [ResolveUrlMixin], mixins: [ResolveUrlMixin],
data() { data() {
@ -88,9 +83,7 @@ export default {
} }
}, },
watch: {}, watch: {},
mounted() { mounted() {},
},
methods: { methods: {
calculateAmount: function (x) { calculateAmount: function (x) {
return this.$sanitize(calculateAmount(x, this.ingredient_factor)) return this.$sanitize(calculateAmount(x, this.ingredient_factor))
@ -106,9 +99,9 @@ export default {
<style scoped> <style scoped>
/* increase size of hover/touchable space without changing spacing */ /* increase size of hover/touchable space without changing spacing */
.touchable { .touchable {
padding-right: 2em; /* padding-right: 2em;
padding-left: 2em; padding-left: 2em; */
margin-right: -2em; margin-right: -1em;
margin-left: -2em; margin-left: -1em;
} }
</style> </style>