remove facets and treeselect

This commit is contained in:
smilerz
2023-09-07 13:20:51 -05:00
parent d33b0d2254
commit 9ee4be621b
39 changed files with 12918 additions and 21842 deletions

View File

@@ -1,14 +1,11 @@
import json
from collections import Counter
from datetime import date, timedelta
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
from django.core.cache import cache, caches
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value,
When)
from django.core.cache import cache
from django.db.models import Avg, Case, Count, Exists, F, Max, OuterRef, Q, Subquery, Value, When
from django.db.models.functions import Coalesce, Lower, Substr
from django.utils import timezone, translation
from django.utils.translation import gettext as _
from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.managers import DICTIONARY
@@ -20,15 +17,17 @@ from recipes import settings
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected
# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
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):
self._request = request
self._queryset = None
if f := params.get('filter', None):
custom_filter = CustomFilter.objects.filter(id=f, space=self._request.space).filter(Q(created_by=self._request.user) |
Q(shared=self._request.user) | Q(recipebook__shared=self._request.user)).first()
custom_filter = (
CustomFilter.objects.filter(id=f, space=self._request.space)
.filter(Q(created_by=self._request.user) | Q(shared=self._request.user) | Q(recipebook__shared=self._request.user))
.first()
)
if custom_filter:
self._params = {**json.loads(custom_filter.search)}
self._original_params = {**(params or {})}
@@ -101,24 +100,18 @@ class RecipeSearch():
self._search_type = self._search_prefs.search or 'plain'
if self._string:
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:
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._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._icontains_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)]
self._istartswith_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
self._trigram_include = None
self._fulltext_include = None
self._trigram = False
if self._postgres and self._string:
self._language = DICTIONARY.get(
translation.get_language(), 'simple')
self._trigram_include = [
x + '__unaccent' if x in 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
self._language = DICTIONARY.get(translation.get_language(), 'simple')
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:
self._trigram = True
@@ -178,7 +171,6 @@ class RecipeSearch():
# if a sort order is provided by user - use that order
if self._sort_order:
if not isinstance(self._sort_order, list):
order += [self._sort_order]
else:
@@ -218,24 +210,18 @@ class RecipeSearch():
self._queryset = self._queryset.filter(query_filter).distinct()
if self._fulltext_include:
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:
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:
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:
self._queryset = self._queryset.annotate(
score=Coalesce(Subquery(simularity), 0.0))
self._queryset = self._queryset.annotate(score=Coalesce(Subquery(simularity), 0.0))
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:
self._queryset = self._queryset.annotate(
score=F('rank') + F('simularity'))
self._queryset = self._queryset.annotate(score=F('rank') + F('simularity'))
else:
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)]:
@@ -244,78 +230,69 @@ class RecipeSearch():
def _cooked_on_filter(self, cooked_date=None):
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:
default = timezone.now() - timedelta(days=100000)
else:
default = timezone.now()
self._queryset = self._queryset.annotate(lastcooked=Coalesce(
Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default)))
self._queryset = self._queryset.annotate(
lastcooked=Coalesce(Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default))
)
if cooked_date is None:
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:
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:
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):
if created_date is None:
return
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:
self._queryset = self._queryset.filter(
created_at__date__lte=created_date)
self._queryset = self._queryset.filter(created_at__date__lte=created_date)
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):
if updated_date is None:
return
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:
self._queryset = self._queryset.filter(
updated_at__date__lte=updated_date)
self._queryset = self._queryset.filter(updated_at__date__lte=updated_date)
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):
if self._sort_includes('lastviewed') or viewed_date:
longTimeAgo = timezone.now() - timedelta(days=100000)
self._queryset = self._queryset.annotate(lastviewed=Coalesce(
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo)))
self._queryset = self._queryset.annotate(
lastviewed=Coalesce(Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo))
)
if viewed_date is None:
return
lessthan = '-' in viewed_date[:1]
viewed_date = date(*[int(x)
for x in viewed_date.split('-') if x != ''])
viewed_date = date(*[int(x)for x in viewed_date.split('-') if x != ''])
if lessthan:
self._queryset = self._queryset.filter(
lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
else:
self._queryset = self._queryset.filter(
lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
def _new_recipes(self, new_days=7):
# TODO make new days a user-setting
if not self._new:
return
self._queryset = (
self._queryset.annotate(new_recipe=Case(
When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')), default=Value(0), ))
self._queryset = self._queryset.annotate(
new_recipe=Case(
When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')),
default=Value(0),
)
)
def _recently_viewed(self, num_recent=None):
@@ -325,34 +302,35 @@ class RecipeSearch():
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__pk'))), Value(0)))
return
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]
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(
pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
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]
)
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):
if self._sort_includes('favorite') or times_cooked:
less_than = '-' in (times_cooked or []
) and not self._sort_includes('-favorite')
less_than = '-' in (times_cooked or []) and not self._sort_includes('-favorite')
if less_than:
default = 1000
else:
default = 0
favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk')
).values('recipe').annotate(count=Count('pk', distinct=True)).values('count')
self._queryset = self._queryset.annotate(
favorite=Coalesce(Subquery(favorite_recipes), default))
favorite_recipes = (
CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk'))
.values('recipe')
.annotate(count=Count('pk', distinct=True))
.values('count')
)
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
if times_cooked is None:
return
if times_cooked == '0':
self._queryset = self._queryset.filter(favorite=0)
elif less_than:
self._queryset = self._queryset.filter(favorite__lte=int(
times_cooked.replace('-', ''))).exclude(favorite=0)
self._queryset = self._queryset.filter(favorite__lte=int(times_cooked.replace('-', ''))).exclude(favorite=0)
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):
if all([kwargs[x] is None for x in kwargs]):
@@ -385,8 +363,7 @@ class RecipeSearch():
else:
self._queryset = self._queryset.filter(f_and)
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):
if all([kwargs[x] is None for x in kwargs]):
@@ -400,8 +377,7 @@ class RecipeSearch():
foods = Food.objects.filter(pk__in=kwargs[fd_filter])
if 'or' in fd_filter:
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:
f_or = Q(steps__ingredients__food__in=foods)
@@ -413,8 +389,7 @@ class RecipeSearch():
recipes = Recipe.objects.all()
for food in foods:
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:
f_and = Q(steps__ingredients__food=food)
if 'not' in fd_filter:
@@ -422,8 +397,7 @@ class RecipeSearch():
else:
self._queryset = self._queryset.filter(f_and)
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):
if operator != True:
@@ -432,8 +406,7 @@ class RecipeSearch():
return
if not isinstance(units, list):
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):
if rating or self._sort_includes('rating'):
@@ -479,14 +452,11 @@ class RecipeSearch():
recipes = Recipe.objects.all()
for book in kwargs[bk_filter]:
if 'not' in bk_filter:
recipes = recipes.filter(
recipebookentry__book__id=book)
recipes = recipes.filter(recipebookentry__book__id=book)
else:
self._queryset = self._queryset.filter(
recipebookentry__book__id=book)
self._queryset = self._queryset.filter(recipebookentry__book__id=book)
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):
if operator != True:
@@ -505,25 +475,20 @@ class RecipeSearch():
rank = []
if 'name' in self._fulltext_include:
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:
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:
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:
# explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields
vectors.append('keywords__name__unaccent')
rank.append(SearchRank('keywords__name__unaccent',
self.search_query, cover_density=True))
rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True))
if 'steps__ingredients__food__name' in self._fulltext_include:
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:
if self.search_rank is None:
@@ -531,8 +496,7 @@ class RecipeSearch():
else:
self.search_rank += r
# modifying queryset will annotation creates duplicate results
self._filters.append(Q(id__in=Recipe.objects.annotate(
vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
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):
if not string:
@@ -557,15 +521,19 @@ class RecipeSearch():
trigram += TrigramSimilarity(f, self._string)
else:
trigram = TrigramSimilarity(f, self._string)
self._fuzzy_match = Recipe.objects.annotate(trigram=trigram).distinct(
).annotate(simularity=Max('trigram')).values('id', 'simularity').filter(simularity__gt=self._search_prefs.trigram_threshold)
self._fuzzy_match = (
Recipe.objects.annotate(trigram=trigram)
.distinct()
.annotate(simularity=Max('trigram'))
.values('id', 'simularity')
.filter(simularity__gt=self._search_prefs.trigram_threshold)
)
self._filters += [Q(pk__in=self._fuzzy_match.values('pk'))]
def _makenow_filter(self, missing=None):
if missing is None or (isinstance(missing, bool) and missing == False):
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 = (
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
@@ -575,264 +543,255 @@ class RecipeSearch():
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
)
makenow_recipes = Recipe.objects.annotate(
count_food=Count('steps__ingredients__food__pk', filter=Q(
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,
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_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(
shopping_users), then=Value(1)), default=Value(0))
count_food=Count('steps__ingredients__food__pk', filter=Q(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, 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_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)
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
def __children_substitute_filter(shopping_users=None):
children_onhand_subquery = Food.objects.filter(
path__startswith=OuterRef('path'),
depth__gt=OuterRef('depth'),
onhand_users__in=shopping_users
children_onhand_subquery = Food.objects.filter(path__startswith=OuterRef('path'), depth__gt=OuterRef('depth'), onhand_users__in=shopping_users)
return (
Food.objects.exclude( # list of foods that are onhand and children of: foods that are not onhand and are set to use children as substitutes
Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users)
)
.exclude(depth=1, numchild=0)
.filter(substitute_children=True)
.annotate(child_onhand_count=Exists(children_onhand_subquery))
.filter(child_onhand_count=True)
)
return Food.objects.exclude( # list of foods that are onhand and children of: foods that are not onhand and are set to use children as substitutes
Q(onhand_users__in=shopping_users)
| Q(ignore_shopping=True, recipe__isnull=True)
| Q(substitute__onhand_users__in=shopping_users)
).exclude(depth=1, numchild=0
).filter(substitute_children=True
).annotate(child_onhand_count=Exists(children_onhand_subquery)
).filter(child_onhand_count=True)
@staticmethod
def __sibling_substitute_filter(shopping_users=None):
sibling_onhand_subquery = Food.objects.filter(
path__startswith=Substr(
OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)),
depth=OuterRef('depth'),
onhand_users__in=shopping_users
path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)), depth=OuterRef('depth'), onhand_users__in=shopping_users
)
return Food.objects.exclude( # list of foods that are onhand and siblings of: foods that are not onhand and are set to use siblings as substitutes
Q(onhand_users__in=shopping_users)
| Q(ignore_shopping=True, recipe__isnull=True)
| Q(substitute__onhand_users__in=shopping_users)
).exclude(depth=1, numchild=0
).filter(substitute_siblings=True
).annotate(sibling_onhand=Exists(sibling_onhand_subquery)
).filter(sibling_onhand=True)
class RecipeFacet():
class CacheEmpty(Exception):
pass
def __init__(self, request, queryset=None, hash_key=None, cache_timeout=3600):
if hash_key is None and queryset is None:
raise ValueError(_("One of queryset or hash_key must be provided"))
self._request = request
self._queryset = queryset
self.hash_key = hash_key or str(hash(self._queryset.query))
self._SEARCH_CACHE_KEY = f"recipes_filter_{self.hash_key}"
self._cache_timeout = cache_timeout
self._cache = caches['default'].get(self._SEARCH_CACHE_KEY, {})
if self._cache is None and self._queryset is None:
raise self.CacheEmpty("No queryset provided and cache empty")
self.Keywords = self._cache.get('Keywords', None)
self.Foods = self._cache.get('Foods', None)
self.Books = self._cache.get('Books', None)
self.Ratings = self._cache.get('Ratings', None)
# TODO Move Recent to recipe annotation/serializer: requrires change in RecipeSearch(), RecipeSearchView.vue and serializer
self.Recent = self._cache.get('Recent', None)
if self._queryset is not None:
self._recipe_list = list(
self._queryset.values_list('id', flat=True))
self._search_params = {
'keyword_list': self._request.query_params.getlist('keywords', []),
'food_list': self._request.query_params.getlist('foods', []),
'book_list': self._request.query_params.getlist('book', []),
'search_keywords_or': str2bool(self._request.query_params.get('keywords_or', True)),
'search_foods_or': str2bool(self._request.query_params.get('foods_or', True)),
'search_books_or': str2bool(self._request.query_params.get('books_or', True)),
'space': self._request.space,
}
elif self.hash_key is not None:
self._recipe_list = self._cache.get('recipe_list', [])
self._search_params = {
'keyword_list': self._cache.get('keyword_list', None),
'food_list': self._cache.get('food_list', None),
'book_list': self._cache.get('book_list', None),
'search_keywords_or': self._cache.get('search_keywords_or', None),
'search_foods_or': self._cache.get('search_foods_or', None),
'search_books_or': self._cache.get('search_books_or', None),
'space': self._cache.get('space', None),
}
self._cache = {
**self._search_params,
'recipe_list': self._recipe_list,
'Ratings': self.Ratings,
'Recent': self.Recent,
'Keywords': self.Keywords,
'Foods': self.Foods,
'Books': self.Books
}
caches['default'].set(self._SEARCH_CACHE_KEY,
self._cache, self._cache_timeout)
def get_facets(self, from_cache=False):
if from_cache:
return {
'cache_key': self.hash_key or '',
'Ratings': self.Ratings or {},
'Recent': self.Recent or [],
'Keywords': self.Keywords or [],
'Foods': self.Foods or [],
'Books': self.Books or []
}
return {
'cache_key': self.hash_key,
'Ratings': self.get_ratings(),
'Recent': self.get_recent(),
'Keywords': self.get_keywords(),
'Foods': self.get_foods(),
'Books': self.get_books()
}
def set_cache(self, key, value):
self._cache = {**self._cache, key: value}
caches['default'].set(
self._SEARCH_CACHE_KEY,
self._cache,
self._cache_timeout
return (
Food.objects.exclude( # list of foods that are onhand and siblings of: foods that are not onhand and are set to use siblings as substitutes
Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users)
)
.exclude(depth=1, numchild=0)
.filter(substitute_siblings=True)
.annotate(sibling_onhand=Exists(sibling_onhand_subquery))
.filter(sibling_onhand=True)
)
def get_books(self):
if self.Books is None:
self.Books = []
return self.Books
def get_keywords(self):
if self.Keywords is None:
if self._search_params['search_keywords_or']:
keywords = Keyword.objects.filter(
space=self._request.space).distinct()
else:
keywords = Keyword.objects.filter(Q(recipe__in=self._recipe_list) | Q(
depth=1)).filter(space=self._request.space).distinct()
# class RecipeFacet():
# class CacheEmpty(Exception):
# pass
# set keywords to root objects only
keywords = self._keyword_queryset(keywords)
self.Keywords = [{**x, 'children': None}
if x['numchild'] > 0 else x for x in list(keywords)]
self.set_cache('Keywords', self.Keywords)
return self.Keywords
# def __init__(self, request, queryset=None, hash_key=None, cache_timeout=3600):
# if hash_key is None and queryset is None:
# raise ValueError(_("One of queryset or hash_key must be provided"))
def get_foods(self):
if self.Foods is None:
# # if using an OR search, will annotate all keywords, otherwise, just those that appear in results
if self._search_params['search_foods_or']:
foods = Food.objects.filter(
space=self._request.space).distinct()
else:
foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q(
depth=1)).filter(space=self._request.space).distinct()
# self._request = request
# self._queryset = queryset
# self.hash_key = hash_key or str(hash(self._queryset.query))
# self._SEARCH_CACHE_KEY = f"recipes_filter_{self.hash_key}"
# self._cache_timeout = cache_timeout
# self._cache = caches['default'].get(self._SEARCH_CACHE_KEY, {})
# if self._cache is None and self._queryset is None:
# raise self.CacheEmpty("No queryset provided and cache empty")
# set keywords to root objects only
foods = self._food_queryset(foods)
# self.Keywords = self._cache.get('Keywords', None)
# self.Foods = self._cache.get('Foods', None)
# self.Books = self._cache.get('Books', None)
# self.Ratings = self._cache.get('Ratings', None)
# # TODO Move Recent to recipe annotation/serializer: requrires change in RecipeSearch(), RecipeSearchView.vue and serializer
# self.Recent = self._cache.get('Recent', None)
self.Foods = [{**x, 'children': None}
if x['numchild'] > 0 else x for x in list(foods)]
self.set_cache('Foods', self.Foods)
return self.Foods
# if self._queryset is not None:
# self._recipe_list = list(
# self._queryset.values_list('id', flat=True))
# self._search_params = {
# 'keyword_list': self._request.query_params.getlist('keywords', []),
# 'food_list': self._request.query_params.getlist('foods', []),
# 'book_list': self._request.query_params.getlist('book', []),
# 'search_keywords_or': str2bool(self._request.query_params.get('keywords_or', True)),
# 'search_foods_or': str2bool(self._request.query_params.get('foods_or', True)),
# 'search_books_or': str2bool(self._request.query_params.get('books_or', True)),
# 'space': self._request.space,
# }
# elif self.hash_key is not None:
# self._recipe_list = self._cache.get('recipe_list', [])
# self._search_params = {
# 'keyword_list': self._cache.get('keyword_list', None),
# 'food_list': self._cache.get('food_list', None),
# 'book_list': self._cache.get('book_list', None),
# 'search_keywords_or': self._cache.get('search_keywords_or', None),
# 'search_foods_or': self._cache.get('search_foods_or', None),
# 'search_books_or': self._cache.get('search_books_or', None),
# 'space': self._cache.get('space', None),
# }
def get_ratings(self):
if self.Ratings is None:
if not self._request.space.demo and self._request.space.show_facet_count:
if self._queryset is None:
self._queryset = Recipe.objects.filter(
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))
else:
self.Rating = {}
self.set_cache('Ratings', self.Ratings)
return self.Ratings
# self._cache = {
# **self._search_params,
# 'recipe_list': self._recipe_list,
# 'Ratings': self.Ratings,
# 'Recent': self.Recent,
# 'Keywords': self.Keywords,
# 'Foods': self.Foods,
# 'Books': self.Books
def get_recent(self):
if self.Recent is None:
# TODO make days of recent recipe a setting
recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space, created_at__gte=timezone.now() - timedelta(days=14)
).values_list('recipe__pk', flat=True)
self.Recent = list(recent_recipes)
self.set_cache('Recent', self.Recent)
return self.Recent
# }
# caches['default'].set(self._SEARCH_CACHE_KEY,
# self._cache, self._cache_timeout)
def add_food_children(self, id):
try:
food = Food.objects.get(id=id)
nodes = food.get_ancestors()
except Food.DoesNotExist:
return self.get_facets()
foods = self._food_queryset(food.get_children(), food)
deep_search = self.Foods
for node in nodes:
index = next((i for i, x in enumerate(
deep_search) if x["id"] == node.id), None)
deep_search = deep_search[index]['children']
index = next((i for i, x in enumerate(
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)
return self.get_facets()
# def get_facets(self, from_cache=False):
# if from_cache:
# return {
# 'cache_key': self.hash_key or '',
# 'Ratings': self.Ratings or {},
# 'Recent': self.Recent or [],
# 'Keywords': self.Keywords or [],
# 'Foods': self.Foods or [],
# 'Books': self.Books or []
# }
# return {
# 'cache_key': self.hash_key,
# 'Ratings': self.get_ratings(),
# 'Recent': self.get_recent(),
# 'Keywords': self.get_keywords(),
# 'Foods': self.get_foods(),
# 'Books': self.get_books()
# }
def add_keyword_children(self, id):
try:
keyword = Keyword.objects.get(id=id)
nodes = keyword.get_ancestors()
except Keyword.DoesNotExist:
return self.get_facets()
keywords = self._keyword_queryset(keyword.get_children(), keyword)
deep_search = self.Keywords
for node in nodes:
index = next((i for i, x in enumerate(
deep_search) if x["id"] == node.id), None)
deep_search = deep_search[index]['children']
index = next((i for i, x in enumerate(deep_search)
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)
return self.get_facets()
# def set_cache(self, key, value):
# self._cache = {**self._cache, key: value}
# caches['default'].set(
# self._SEARCH_CACHE_KEY,
# self._cache,
# self._cache_timeout
# )
def _recipe_count_queryset(self, field, depth=1, steplen=4):
return Recipe.objects.filter(**{f'{field}__path__startswith': OuterRef('path'), f'{field}__depth__gte': depth}, id__in=self._recipe_list, space=self._request.space
).annotate(count=Coalesce(Func('pk', function='Count'), 0)).values('count')
# def get_books(self):
# if self.Books is None:
# self.Books = []
# return self.Books
def _keyword_queryset(self, queryset, keyword=None):
depth = getattr(keyword, 'depth', 0) + 1
steplen = depth * Keyword.steplen
# def get_keywords(self):
# if self.Keywords is None:
# if self._search_params['search_keywords_or']:
# keywords = Keyword.objects.filter(
# space=self._request.space).distinct()
# else:
# keywords = Keyword.objects.filter(Q(recipe__in=self._recipe_list) | Q(
# depth=1)).filter(space=self._request.space).distinct()
if not self._request.space.demo and self._request.space.show_facet_count:
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('keywords', depth, steplen)), 0)
).filter(depth=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
else:
return queryset.filter(depth=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
# # set keywords to root objects only
# keywords = self._keyword_queryset(keywords)
# self.Keywords = [{**x, 'children': None}
# if x['numchild'] > 0 else x for x in list(keywords)]
# self.set_cache('Keywords', self.Keywords)
# return self.Keywords
def _food_queryset(self, queryset, food=None):
depth = getattr(food, 'depth', 0) + 1
steplen = depth * Food.steplen
# def get_foods(self):
# if self.Foods is None:
# # # if using an OR search, will annotate all keywords, otherwise, just those that appear in results
# if self._search_params['search_foods_or']:
# foods = Food.objects.filter(
# space=self._request.space).distinct()
# else:
# foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q(
# depth=1)).filter(space=self._request.space).distinct()
if not self._request.space.demo and self._request.space.show_facet_count:
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('steps__ingredients__food', depth, steplen)), 0)
).filter(depth__lte=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
else:
return queryset.filter(depth__lte=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
# # set keywords to root objects only
# foods = self._food_queryset(foods)
# self.Foods = [{**x, 'children': None}
# if x['numchild'] > 0 else x for x in list(foods)]
# self.set_cache('Foods', self.Foods)
# return self.Foods
# def get_ratings(self):
# if self.Ratings is None:
# if not self._request.space.demo and self._request.space.show_facet_count:
# if self._queryset is None:
# self._queryset = Recipe.objects.filter(
# 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))
# else:
# self.Rating = {}
# self.set_cache('Ratings', self.Ratings)
# return self.Ratings
# def get_recent(self):
# if self.Recent is None:
# # TODO make days of recent recipe a setting
# recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space, created_at__gte=timezone.now() - timedelta(days=14)
# ).values_list('recipe__pk', flat=True)
# self.Recent = list(recent_recipes)
# self.set_cache('Recent', self.Recent)
# return self.Recent
# def add_food_children(self, id):
# try:
# food = Food.objects.get(id=id)
# nodes = food.get_ancestors()
# except Food.DoesNotExist:
# return self.get_facets()
# foods = self._food_queryset(food.get_children(), food)
# deep_search = self.Foods
# for node in nodes:
# index = next((i for i, x in enumerate(
# deep_search) if x["id"] == node.id), None)
# deep_search = deep_search[index]['children']
# index = next((i for i, x in enumerate(
# 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)
# return self.get_facets()
# def add_keyword_children(self, id):
# try:
# keyword = Keyword.objects.get(id=id)
# nodes = keyword.get_ancestors()
# except Keyword.DoesNotExist:
# return self.get_facets()
# keywords = self._keyword_queryset(keyword.get_children(), keyword)
# deep_search = self.Keywords
# for node in nodes:
# index = next((i for i, x in enumerate(
# deep_search) if x["id"] == node.id), None)
# deep_search = deep_search[index]['children']
# index = next((i for i, x in enumerate(deep_search)
# 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)
# return self.get_facets()
# def _recipe_count_queryset(self, field, depth=1, steplen=4):
# return Recipe.objects.filter(**{f'{field}__path__startswith': OuterRef('path'), f'{field}__depth__gte': depth}, id__in=self._recipe_list, space=self._request.space
# ).annotate(count=Coalesce(Func('pk', function='Count'), 0)).values('count')
# def _keyword_queryset(self, queryset, keyword=None):
# depth = getattr(keyword, 'depth', 0) + 1
# steplen = depth * Keyword.steplen
# if not self._request.space.demo and self._request.space.show_facet_count:
# return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('keywords', depth, steplen)), 0)
# ).filter(depth=depth, count__gt=0
# ).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
# else:
# return queryset.filter(depth=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
# def _food_queryset(self, queryset, food=None):
# depth = getattr(food, 'depth', 0) + 1
# steplen = depth * Food.steplen
# if not self._request.space.demo and self._request.space.show_facet_count:
# return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('steps__ingredients__food', depth, steplen)), 0)
# ).filter(depth__lte=depth, count__gt=0
# ).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
# else:
# return queryset.filter(depth__lte=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())

View File

@@ -1,9 +1,7 @@
# import random
import re
import traceback
from html import unescape
from django.core.cache import caches
from django.utils.dateparse import parse_duration
from django.utils.translation import gettext as _
from isodate import parse_duration as iso_parse_duration
@@ -11,15 +9,9 @@ from isodate.isoerror import ISO8601Error
from pytube import YouTube
from recipe_scrapers._utils import get_host_name, get_minutes
# from cookbook.helper import recipe_url_import as helper
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.models import Automation, Keyword, PropertyType
# from unicodedata import decomposition
# from recipe_scrapers._utils import get_minutes ## temporary until/unless upstream incorporates get_minutes() PR
def get_from_scraper(scrape, request):
# converting the scrape_me object to the existing json format based on ld+json
@@ -147,7 +139,7 @@ def get_from_scraper(scrape, request):
recipe_json['steps'] = []
try:
for i in parse_instructions(scrape.instructions()):
recipe_json['steps'].append({'instruction': i, 'ingredients': [], 'show_ingredients_table': request.user.userpreference.show_step_ingredients,})
recipe_json['steps'].append({'instruction': i, 'ingredients': [], 'show_ingredients_table': request.user.userpreference.show_step_ingredients, })
except Exception:
pass
if len(recipe_json['steps']) == 0:

View File

@@ -1,16 +1,13 @@
from datetime import timedelta
from decimal import Decimal
from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import F, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.utils.translation import gettext as _
from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
SupermarketCategoryRelation)
from recipes import settings
def shopping_helper(qs, request):
@@ -47,7 +44,7 @@ class RecipeShoppingEditor():
self.mealplan = self._kwargs.get('mealplan', None)
if type(self.mealplan) in [int, float]:
self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
if type(self.mealplan) == dict:
if isinstance(self.mealplan, dict):
self.mealplan = MealPlan.objects.filter(id=self.mealplan['id'], space=self.space).first()
self.id = self._kwargs.get('id', None)
@@ -69,11 +66,12 @@ class RecipeShoppingEditor():
@property
def _recipe_servings(self):
return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings', None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None)
return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings',
None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None)
@property
def _servings_factor(self):
return Decimal(self.servings)/Decimal(self._recipe_servings)
return Decimal(self.servings) / Decimal(self._recipe_servings)
@property
def _shared_users(self):
@@ -90,9 +88,10 @@ class RecipeShoppingEditor():
def get_recipe_ingredients(self, id, exclude_onhand=False):
if exclude_onhand:
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(food__onhand_users__id__in=[x.id for x in self._shared_users])
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(
food__onhand_users__id__in=[x.id for x in self._shared_users])
else:
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
@property
def _include_related(self):
@@ -109,7 +108,7 @@ class RecipeShoppingEditor():
self.servings = float(servings)
if mealplan := kwargs.get('mealplan', None):
if type(mealplan) == dict:
if isinstance(mealplan, dict):
self.mealplan = MealPlan.objects.filter(id=mealplan['id'], space=self.space).first()
else:
self.mealplan = mealplan
@@ -170,13 +169,13 @@ class RecipeShoppingEditor():
try:
self._shopping_list_recipe.delete()
return True
except:
except BaseException:
return False
def _add_ingredients(self, ingredients=None):
if not ingredients:
return
elif type(ingredients) == list:
elif isinstance(ingredients, list):
ingredients = Ingredient.objects.filter(id__in=ingredients)
existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
add_ingredients = ingredients.exclude(id__in=existing)
@@ -315,4 +314,4 @@ class RecipeShoppingEditor():
# )
# # return all shopping list items
# return list_recipe
# return list_recipe