remove facets and treeselect

This commit is contained in:
smilerz 2023-09-07 13:20:51 -05:00
parent d33b0d2254
commit 9ee4be621b
No known key found for this signature in database
GPG Key ID: 39444C7606D47126
39 changed files with 12918 additions and 21842 deletions

View File

@ -45,8 +45,7 @@ class UserPreferenceForm(forms.ModelForm):
model = UserPreference model = UserPreference
fields = ( fields = (
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color', 'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
'sticky_navbar', 'default_page', 'plan_share', 'ingredient_decimals', 'comments', 'left_handed', 'sticky_navbar', 'default_page', 'plan_share', 'ingredient_decimals', 'comments', 'left_handed', 'show_step_ingredients',
'show_step_ingredients',
) )
labels = { labels = {
@ -67,21 +66,19 @@ class UserPreferenceForm(forms.ModelForm):
help_texts = { help_texts = {
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'), 'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'),
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'),
'use_fractions': _( 'use_fractions': _(
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'), 'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
'use_kj': _('Display nutritional energy amounts in joules instead of calories'),
'use_kj': _('Display nutritional energy amounts in joules instead of calories'),
'plan_share': _('Users with whom newly created meal plans should be shared by default.'), 'plan_share': _('Users with whom newly created meal plans should be shared by default.'),
'shopping_share': _('Users with whom to share shopping lists.'), 'shopping_share': _('Users with whom to share shopping lists.'),
'ingredient_decimals': _('Number of decimals to round ingredients.'), 'ingredient_decimals': _('Number of decimals to round ingredients.'),
'comments': _('If you want to be able to create and see comments underneath recipes.'), 'comments': _('If you want to be able to create and see comments underneath recipes.'),
'shopping_auto_sync': _( 'shopping_auto_sync': _(
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
'of mobile data. If lower than instance limit it is reset when saving.' 'of mobile data. If lower than instance limit it is reset when saving.'
), ),
'sticky_navbar': _('Makes the navbar stick to the top of the page.'), 'sticky_navbar': _('Makes the navbar stick to the top of the page.'),
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'), 'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'),
'left_handed': _('Will optimize the UI for use with your left hand.'), 'left_handed': _('Will optimize the UI for use with your left hand.'),
@ -187,6 +184,7 @@ class MultipleFileField(forms.FileField):
result = single_file_clean(data, initial) result = single_file_clean(data, initial)
return result return result
class ImportForm(ImportExportBase): class ImportForm(ImportExportBase):
files = MultipleFileField(required=True) files = MultipleFileField(required=True)
duplicates = forms.BooleanField(help_text=_( duplicates = forms.BooleanField(help_text=_(
@ -352,9 +350,8 @@ class MealPlanForm(forms.ModelForm):
) )
help_texts = { help_texts = {
'shared': _('You can list default users to share recipes with in the settings.'), 'shared': _('You can list default users to share recipes with in the settings.'),
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>') 'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>')
} }
widgets = { widgets = {
@ -509,8 +506,8 @@ class ShoppingPreferenceForm(forms.ModelForm):
help_texts = { help_texts = {
'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'), 'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'),
'shopping_auto_sync': _( 'shopping_auto_sync': _(
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
'of mobile data. If lower than instance limit it is reset when saving.' 'of mobile data. If lower than instance limit it is reset when saving.'
), ),
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'), 'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
@ -554,11 +551,10 @@ class SpacePreferenceForm(forms.ModelForm):
class Meta: class Meta:
model = Space model = Space
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count', 'use_plural') fields = ('food_inherit', 'reset_food_inherit', 'use_plural')
help_texts = { help_texts = {
'food_inherit': _('Fields on food that should be inherited by default.'), 'food_inherit': _('Fields on food that should be inherited by default.'),
'show_facet_count': _('Show recipe counts on search filters'),
'use_plural': _('Use the plural form for units and food inside this space.'), 'use_plural': _('Use the plural form for units and food inside this space.'),
} }

View File

@ -1,14 +1,11 @@
import json import json
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, caches from django.core.cache import cache
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, Max, OuterRef, Q, Subquery, Value, When
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 cookbook.helper.HelperFunctions import Round, str2bool from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.managers import DICTIONARY 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 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 [ _postgres = settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
'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
self._queryset = None self._queryset = None
if f := params.get('filter', 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) | custom_filter = (
Q(shared=self._request.user) | Q(recipebook__shared=self._request.user)).first() 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: if custom_filter:
self._params = {**json.loads(custom_filter.search)} self._params = {**json.loads(custom_filter.search)}
self._original_params = {**(params or {})} self._original_params = {**(params or {})}
@ -101,24 +100,18 @@ 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( self._unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True)
'field', flat=True)
else: else:
self._unaccent_include = [] self._unaccent_include = []
self._icontains_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)]
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._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( self._language = DICTIONARY.get(translation.get_language(), 'simple')
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._trigram_include = [ self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True) or None
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
@ -178,7 +171,6 @@ class RecipeSearch():
# if a sort order is provided by user - use that order # if a sort order is provided by user - use that order
if self._sort_order: if self._sort_order:
if not isinstance(self._sort_order, list): if not isinstance(self._sort_order, list):
order += [self._sort_order] order += [self._sort_order]
else: else:
@ -218,24 +210,18 @@ class RecipeSearch():
self._queryset = self._queryset.filter(query_filter).distinct() 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( self._queryset = self._queryset.annotate(score=Coalesce(Max(self.search_rank), 0.0))
score=Coalesce(Max(self.search_rank), 0.0))
else: else:
self._queryset = self._queryset.annotate( self._queryset = self._queryset.annotate(rank=Coalesce(Max(self.search_rank), 0.0))
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( simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity')
pk=OuterRef('pk')).values('simularity')
if not self._fulltext_include: if not self._fulltext_include:
self._queryset = self._queryset.annotate( self._queryset = self._queryset.annotate(score=Coalesce(Subquery(simularity), 0.0))
score=Coalesce(Subquery(simularity), 0.0))
else: else:
self._queryset = self._queryset.annotate( self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
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( self._queryset = self._queryset.annotate(score=F('rank') + F('simularity'))
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)]:
@ -244,78 +230,69 @@ 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( lessthan = self._sort_includes('-lastcooked') or '-' in (cooked_date or [])[:1]
'-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:
default = timezone.now() default = timezone.now()
self._queryset = self._queryset.annotate(lastcooked=Coalesce( self._queryset = self._queryset.annotate(
Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default))) 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: if cooked_date is None:
return return
cooked_date = date(*[int(x) cooked_date = date(*[int(x)for x in cooked_date.split('-') if x != ''])
for x in cooked_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
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) created_date = date(*[int(x) for x in created_date.split('-') if x != ''])
for x in created_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(created_at__date__lte=created_date)
created_at__date__lte=created_date)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(created_at__date__gte=created_date)
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) updated_date = date(*[int(x)for x in updated_date.split('-') if x != ''])
for x in updated_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(updated_at__date__lte=updated_date)
updated_at__date__lte=updated_date)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(updated_at__date__gte=updated_date)
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:
longTimeAgo = timezone.now() - timedelta(days=100000) longTimeAgo = timezone.now() - timedelta(days=100000)
self._queryset = self._queryset.annotate(lastviewed=Coalesce( self._queryset = self._queryset.annotate(
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo))) 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: if viewed_date is None:
return return
lessthan = '-' in viewed_date[:1] lessthan = '-' in viewed_date[:1]
viewed_date = date(*[int(x) viewed_date = date(*[int(x)for x in viewed_date.split('-') if x != ''])
for x in viewed_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
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
if not self._new: if not self._new:
return return
self._queryset = ( self._queryset = self._queryset.annotate(
self._queryset.annotate(new_recipe=Case( new_recipe=Case(
When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')), default=Value(0), )) When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')),
default=Value(0),
)
) )
def _recently_viewed(self, num_recent=None): 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))) Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__pk'))), Value(0)))
return return
num_recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space).values( num_recent_recipes = (
'recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent] ViewLog.objects.filter(created_by=self._request.user, space=self._request.space)
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When( .values('recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
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 [] less_than = '-' in (times_cooked or []) and not self._sort_includes('-favorite')
) 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 = (
).values('recipe').annotate(count=Count('pk', distinct=True)).values('count') CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk'))
self._queryset = self._queryset.annotate( .values('recipe')
favorite=Coalesce(Subquery(favorite_recipes), default)) .annotate(count=Count('pk', distinct=True))
.values('count')
)
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( self._queryset = self._queryset.filter(favorite__lte=int(times_cooked.replace('-', ''))).exclude(favorite=0)
times_cooked.replace('-', ''))).exclude(favorite=0)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(favorite__gte=int(times_cooked))
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]):
@ -385,8 +363,7 @@ 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( self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
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]):
@ -400,8 +377,7 @@ 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( f_or = Q(steps__ingredients__food__in=Food.include_descendants(foods))
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)
@ -413,8 +389,7 @@ 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( f_and = Q(steps__ingredients__food__in=food.get_descendants_and_self())
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:
@ -422,8 +397,7 @@ 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( self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
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:
@ -432,8 +406,7 @@ class RecipeSearch():
return return
if not isinstance(units, list): if not isinstance(units, list):
units = [units] units = [units]
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(steps__ingredients__unit__in=units)
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'):
@ -479,14 +452,11 @@ 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( recipes = recipes.filter(recipebookentry__book__id=book)
recipebookentry__book__id=book)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(recipebookentry__book__id=book)
recipebookentry__book__id=book)
if 'not' in bk_filter: if 'not' in bk_filter:
self._queryset = self._queryset.exclude( self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
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:
@ -505,25 +475,20 @@ 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', rank.append(SearchRank('name_search_vector', self.search_query, cover_density=True))
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', rank.append(SearchRank('desc_search_vector', self.search_query, cover_density=True))
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', rank.append(SearchRank('steps__search_vector', self.search_query, cover_density=True))
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', rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True))
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', rank.append(SearchRank('steps__ingredients__food__name', self.search_query, cover_density=True))
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:
@ -531,8 +496,7 @@ 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( self._filters.append(Q(id__in=Recipe.objects.annotate(vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
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:
@ -557,15 +521,19 @@ class RecipeSearch():
trigram += TrigramSimilarity(f, self._string) trigram += TrigramSimilarity(f, self._string)
else: else:
trigram = TrigramSimilarity(f, self._string) trigram = TrigramSimilarity(f, self._string)
self._fuzzy_match = Recipe.objects.annotate(trigram=trigram).distinct( self._fuzzy_match = (
).annotate(simularity=Max('trigram')).values('id', 'simularity').filter(simularity__gt=self._search_prefs.trigram_threshold) 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'))] self._filters += [Q(pk__in=self._fuzzy_match.values('pk'))]
def _makenow_filter(self, missing=None): def _makenow_filter(self, missing=None):
if missing is None or (isinstance(missing, bool) and missing == False): if missing is None or (isinstance(missing, bool) and missing == False):
return return
shopping_users = [ shopping_users = [*self._request.user.get_shopping_share(), self._request.user]
*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
@ -575,264 +543,255 @@ class RecipeSearch():
| Q(steps__ingredients__food__in=self.__sibling_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( count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True),
steps__ingredients__food__isnull=False), distinct=True), count_onhand=Count('steps__ingredients__food__pk', filter=onhand_filter, distinct=True),
count_onhand=Count('steps__ingredients__food__pk', count_ignore_shopping=Count(
filter=onhand_filter, distinct=True), 'steps__ingredients__food__pk', filter=Q(steps__ingredients__food__ignore_shopping=True, steps__ingredients__food__recipe__isnull=True), 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_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( self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values('id'))
id__in=makenow_recipes.values('id'))
@staticmethod @staticmethod
def __children_substitute_filter(shopping_users=None): def __children_substitute_filter(shopping_users=None):
children_onhand_subquery = Food.objects.filter( children_onhand_subquery = Food.objects.filter(path__startswith=OuterRef('path'), depth__gt=OuterRef('depth'), onhand_users__in=shopping_users)
path__startswith=OuterRef('path'), return (
depth__gt=OuterRef('depth'), 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
onhand_users__in=shopping_users 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 @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( path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)), depth=OuterRef('depth'), onhand_users__in=shopping_users
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 return (
Q(onhand_users__in=shopping_users) 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(ignore_shopping=True, recipe__isnull=True) Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users)
| Q(substitute__onhand_users__in=shopping_users) )
).exclude(depth=1, numchild=0 .exclude(depth=1, numchild=0)
).filter(substitute_siblings=True .filter(substitute_siblings=True)
).annotate(sibling_onhand=Exists(sibling_onhand_subquery) .annotate(sibling_onhand=Exists(sibling_onhand_subquery))
).filter(sibling_onhand=True) .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
) )
def get_books(self):
if self.Books is None:
self.Books = []
return self.Books
def get_keywords(self): # class RecipeFacet():
if self.Keywords is None: # class CacheEmpty(Exception):
if self._search_params['search_keywords_or']: # pass
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()
# set keywords to root objects only # def __init__(self, request, queryset=None, hash_key=None, cache_timeout=3600):
keywords = self._keyword_queryset(keywords) # if hash_key is None and queryset is None:
self.Keywords = [{**x, 'children': None} # raise ValueError(_("One of queryset or hash_key must be provided"))
if x['numchild'] > 0 else x for x in list(keywords)]
self.set_cache('Keywords', self.Keywords)
return self.Keywords
def get_foods(self): # self._request = request
if self.Foods is None: # self._queryset = queryset
# # if using an OR search, will annotate all keywords, otherwise, just those that appear in results # self.hash_key = hash_key or str(hash(self._queryset.query))
if self._search_params['search_foods_or']: # self._SEARCH_CACHE_KEY = f"recipes_filter_{self.hash_key}"
foods = Food.objects.filter( # self._cache_timeout = cache_timeout
space=self._request.space).distinct() # self._cache = caches['default'].get(self._SEARCH_CACHE_KEY, {})
else: # if self._cache is None and self._queryset is None:
foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q( # raise self.CacheEmpty("No queryset provided and cache empty")
depth=1)).filter(space=self._request.space).distinct()
# set keywords to root objects only # self.Keywords = self._cache.get('Keywords', None)
foods = self._food_queryset(foods) # 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 self._queryset is not None:
if x['numchild'] > 0 else x for x in list(foods)] # self._recipe_list = list(
self.set_cache('Foods', self.Foods) # self._queryset.values_list('id', flat=True))
return self.Foods # 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): # self._cache = {
if self.Ratings is None: # **self._search_params,
if not self._request.space.demo and self._request.space.show_facet_count: # 'recipe_list': self._recipe_list,
if self._queryset is None: # 'Ratings': self.Ratings,
self._queryset = Recipe.objects.filter( # 'Recent': self.Recent,
id__in=self._recipe_list) # 'Keywords': self.Keywords,
rating_qs = self._queryset.annotate(rating=Round(Avg(Case(When( # 'Foods': self.Foods,
cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0))))) # 'Books': self.Books
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: # caches['default'].set(self._SEARCH_CACHE_KEY,
# TODO make days of recent recipe a setting # self._cache, self._cache_timeout)
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): # def get_facets(self, from_cache=False):
try: # if from_cache:
food = Food.objects.get(id=id) # return {
nodes = food.get_ancestors() # 'cache_key': self.hash_key or '',
except Food.DoesNotExist: # 'Ratings': self.Ratings or {},
return self.get_facets() # 'Recent': self.Recent or [],
foods = self._food_queryset(food.get_children(), food) # 'Keywords': self.Keywords or [],
deep_search = self.Foods # 'Foods': self.Foods or [],
for node in nodes: # 'Books': self.Books or []
index = next((i for i, x in enumerate( # }
deep_search) if x["id"] == node.id), None) # return {
deep_search = deep_search[index]['children'] # 'cache_key': self.hash_key,
index = next((i for i, x in enumerate( # 'Ratings': self.get_ratings(),
deep_search) if x["id"] == food.id), None) # 'Recent': self.get_recent(),
deep_search[index]['children'] = [ # 'Keywords': self.get_keywords(),
{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)] # 'Foods': self.get_foods(),
self.set_cache('Foods', self.Foods) # 'Books': self.get_books()
return self.get_facets() # }
def add_keyword_children(self, id): # def set_cache(self, key, value):
try: # self._cache = {**self._cache, key: value}
keyword = Keyword.objects.get(id=id) # caches['default'].set(
nodes = keyword.get_ancestors() # self._SEARCH_CACHE_KEY,
except Keyword.DoesNotExist: # self._cache,
return self.get_facets() # self._cache_timeout
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): # def get_books(self):
return Recipe.objects.filter(**{f'{field}__path__startswith': OuterRef('path'), f'{field}__depth__gte': depth}, id__in=self._recipe_list, space=self._request.space # if self.Books is None:
).annotate(count=Coalesce(Func('pk', function='Count'), 0)).values('count') # self.Books = []
# return self.Books
def _keyword_queryset(self, queryset, keyword=None): # def get_keywords(self):
depth = getattr(keyword, 'depth', 0) + 1 # if self.Keywords is None:
steplen = depth * Keyword.steplen # 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: # # set keywords to root objects only
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('keywords', depth, steplen)), 0) # keywords = self._keyword_queryset(keywords)
).filter(depth=depth, count__gt=0 # self.Keywords = [{**x, 'children': None}
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200] # if x['numchild'] > 0 else x for x in list(keywords)]
else: # self.set_cache('Keywords', self.Keywords)
return queryset.filter(depth=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc()) # return self.Keywords
def _food_queryset(self, queryset, food=None): # def get_foods(self):
depth = getattr(food, 'depth', 0) + 1 # if self.Foods is None:
steplen = depth * Food.steplen # # # 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: # # set keywords to root objects only
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('steps__ingredients__food', depth, steplen)), 0) # foods = self._food_queryset(foods)
).filter(depth__lte=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200] # self.Foods = [{**x, 'children': None}
else: # if x['numchild'] > 0 else x for x in list(foods)]
return queryset.filter(depth__lte=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc()) # 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 re
import traceback import traceback
from html import unescape from html import unescape
from django.core.cache import caches
from django.utils.dateparse import parse_duration from django.utils.dateparse import parse_duration
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from isodate import parse_duration as iso_parse_duration from isodate import parse_duration as iso_parse_duration
@ -11,15 +9,9 @@ from isodate.isoerror import ISO8601Error
from pytube import YouTube from pytube import YouTube
from recipe_scrapers._utils import get_host_name, get_minutes 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.helper.ingredient_parser import IngredientParser
from cookbook.models import Automation, Keyword, PropertyType 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): def get_from_scraper(scrape, request):
# converting the scrape_me object to the existing json format based on ld+json # 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'] = [] recipe_json['steps'] = []
try: try:
for i in parse_instructions(scrape.instructions()): 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: except Exception:
pass pass
if len(recipe_json['steps']) == 0: if len(recipe_json['steps']) == 0:

View File

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

View File

@ -1,20 +1,15 @@
import base64
import gzip
import json
import re import re
from gettext import gettext as _
from io import BytesIO from io import BytesIO
import requests import requests
import validators import validators
import yaml
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup, from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup,
iso_duration_to_minutes) iso_duration_to_minutes)
from cookbook.helper.scrapers.scrapers import text_scraper from cookbook.helper.scrapers.scrapers import text_scraper
from cookbook.integration.integration import Integration from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step from cookbook.models import Ingredient, Recipe, Step
class CookBookApp(Integration): class CookBookApp(Integration):
@ -47,7 +42,8 @@ class CookBookApp(Integration):
pass pass
# assuming import files only contain single step # assuming import files only contain single step
step = Step.objects.create(instruction=recipe_json['steps'][0]['instruction'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, ) step = Step.objects.create(instruction=recipe_json['steps'][0]['instruction'], space=self.request.space,
show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
if 'nutrition' in recipe_json: if 'nutrition' in recipe_json:
step.instruction = step.instruction + '\n\n' + recipe_json['nutrition'] step.instruction = step.instruction + '\n\n' + recipe_json['nutrition']
@ -62,7 +58,7 @@ class CookBookApp(Integration):
if unit := ingredient.get('unit', None): if unit := ingredient.get('unit', None):
u = ingredient_parser.get_unit(unit.get('name', None)) u = ingredient_parser.get_unit(unit.get('name', None))
step.ingredients.add(Ingredient.objects.create( step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=ingredient.get('amount', None), note=ingredient.get('note', None), original_text=ingredient.get('original_text', None), space=self.request.space, food=f, unit=u, amount=ingredient.get('amount', None), note=ingredient.get('note', None), original_text=ingredient.get('original_text', None), space=self.request.space,
)) ))
if len(images) > 0: if len(images) > 0:

View File

@ -1,4 +1,3 @@
import traceback
import datetime import datetime
import traceback import traceback
import uuid import uuid
@ -18,8 +17,7 @@ from lxml import etree
from cookbook.helper.image_processing import handle_image from cookbook.helper.image_processing import handle_image
from cookbook.models import Keyword, Recipe from cookbook.models import Keyword, Recipe
from recipes.settings import DEBUG from recipes.settings import DEBUG, EXPORT_FILE_CACHE_DURATION
from recipes.settings import EXPORT_FILE_CACHE_DURATION
class Integration: class Integration:
@ -63,12 +61,10 @@ class Integration:
space=request.space space=request.space
) )
def do_export(self, recipes, el): def do_export(self, recipes, el):
with scope(space=self.request.space): with scope(space=self.request.space):
el.total_recipes = len(recipes) el.total_recipes = len(recipes)
el.cache_duration = EXPORT_FILE_CACHE_DURATION el.cache_duration = EXPORT_FILE_CACHE_DURATION
el.save() el.save()
@ -80,7 +76,7 @@ class Integration:
export_file = file export_file = file
else: else:
#zip the files if there is more then one file # zip the files if there is more then one file
export_filename = self.get_export_file_name() export_filename = self.get_export_file_name()
export_stream = BytesIO() export_stream = BytesIO()
export_obj = ZipFile(export_stream, 'w') export_obj = ZipFile(export_stream, 'w')
@ -91,8 +87,7 @@ class Integration:
export_obj.close() export_obj.close()
export_file = export_stream.getvalue() export_file = export_stream.getvalue()
cache.set('export_file_' + str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION)
cache.set('export_file_'+str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION)
el.running = False el.running = False
el.save() el.save()
@ -100,7 +95,6 @@ class Integration:
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"' response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
return response return response
def import_file_name_filter(self, zip_info_object): def import_file_name_filter(self, zip_info_object):
""" """
Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files
@ -164,7 +158,7 @@ class Integration:
for z in file_list: for z in file_list:
try: try:
if not hasattr(z, 'filename') or type(z) == Tag: if not hasattr(z, 'filename') or isinstance(z, Tag):
recipe = self.get_recipe_from_file(z) recipe = self.get_recipe_from_file(z)
else: else:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename))) recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
@ -298,7 +292,6 @@ class Integration:
if DEBUG: if DEBUG:
traceback.print_exc() traceback.print_exc()
def get_export_file_name(self, format='zip'): def get_export_file_name(self, format='zip'):
return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format) return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format)

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.10 on 2023-09-07 18:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0200_alter_propertytype_options_remove_keyword_icon_and_more'),
]
operations = [
migrations.RemoveField(
model_name='space',
name='show_facet_count',
),
]

View File

@ -185,7 +185,6 @@ class TreeModel(MP_Node):
:param filter: Filter (include) the descendants nodes with the provided Q filter :param filter: Filter (include) the descendants nodes with the provided Q filter
""" """
descendants = Q() descendants = Q()
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
nodes = queryset.values('path', 'depth') nodes = queryset.values('path', 'depth')
for node in nodes: for node in nodes:
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth']) descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
@ -265,7 +264,6 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
no_sharing_limit = models.BooleanField(default=False) no_sharing_limit = models.BooleanField(default=False)
demo = models.BooleanField(default=False) demo = models.BooleanField(default=False)
food_inherit = models.ManyToManyField(FoodInheritField, blank=True) food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
show_facet_count = models.BooleanField(default=False)
internal_note = models.TextField(blank=True, null=True) internal_note = models.TextField(blank=True, null=True)

View File

@ -1,4 +1,3 @@
import random
import traceback import traceback
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -7,34 +6,34 @@ from gettext import gettext as _
from html import escape from html import escape
from smtplib import SMTPException from smtplib import SMTPException
from django.contrib.auth.models import Group, User, AnonymousUser from django.contrib.auth.models import AnonymousUser, Group, User
from django.core.cache import caches from django.core.cache import caches
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db.models import Avg, Q, QuerySet, Sum from django.db.models import Q, QuerySet, Sum
from django.http import BadHeaderError from django.http import BadHeaderError
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
from PIL import Image
from oauth2_provider.models import AccessToken from oauth2_provider.models import AccessToken
from PIL import Image
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError from rest_framework.exceptions import NotFound, ValidationError
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
from cookbook.helper.HelperFunctions import str2bool from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.property_helper import FoodPropertyHelper
from cookbook.helper.permission_helper import above_space_limit from cookbook.helper.permission_helper import above_space_limit
from cookbook.helper.property_helper import FoodPropertyHelper
from cookbook.helper.shopping_helper import RecipeShoppingEditor from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.helper.unit_conversion_helper import UnitConversionHelper from cookbook.helper.unit_conversion_helper import UnitConversionHelper
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter, from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter,
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink, ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink,
Keyword, MealPlan, MealType, NutritionInformation, Recipe, RecipeBook, Keyword, MealPlan, MealType, NutritionInformation, Property,
RecipeBookEntry, RecipeImport, ShareLink, ShoppingList, PropertyType, Recipe, RecipeBook, RecipeBookEntry, RecipeImport,
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, Step, Storage, Supermarket, SupermarketCategory,
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog, UnitConversion, Property, SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
PropertyType, Property) UserFile, UserPreference, UserSpace, ViewLog)
from cookbook.templatetags.custom_tags import markdown from cookbook.templatetags.custom_tags import markdown
from recipes.settings import AWS_ENABLED, MEDIA_URL from recipes.settings import AWS_ENABLED, MEDIA_URL
@ -60,7 +59,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
if str2bool( if str2bool(
self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer: self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
return fields return fields
except (AttributeError, KeyError) as e: except (AttributeError, KeyError):
pass pass
try: try:
del fields['image'] del fields['image']
@ -104,9 +103,9 @@ class CustomDecimalField(serializers.Field):
return round(value, 2).normalize() return round(value, 2).normalize()
def to_internal_value(self, data): def to_internal_value(self, data):
if type(data) == int or type(data) == float: if isinstance(data, int) or isinstance(data, float):
return data return data
elif type(data) == str: elif isinstance(data, str):
if data == '': if data == '':
return 0 return 0
try: try:
@ -146,11 +145,11 @@ class SpaceFilterSerializer(serializers.ListSerializer):
def to_representation(self, data): def to_representation(self, data):
if self.context.get('request', None) is None: if self.context.get('request', None) is None:
return return
if (type(data) == QuerySet and data.query.is_sliced): if (isinstance(data, QuerySet) and data.query.is_sliced):
# if query is sliced it came from api request not nested serializer # if query is sliced it came from api request not nested serializer
return super().to_representation(data) return super().to_representation(data)
if self.child.Meta.model == User: if self.child.Meta.model == User:
if type(self.context['request'].user) == AnonymousUser: if isinstance(self.context['request'].user, AnonymousUser):
data = [] data = []
else: else:
data = data.filter(userspace__space=self.context['request'].user.get_active_space()).all() data = data.filter(userspace__space=self.context['request'].user.get_active_space()).all()
@ -302,7 +301,7 @@ class SpaceSerializer(WritableNestedModelSerializer):
model = Space model = Space
fields = ( fields = (
'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users', 'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
'allow_sharing', 'demo', 'food_inherit', 'show_facet_count', 'user_count', 'recipe_count', 'file_size_mb', 'allow_sharing', 'demo', 'food_inherit', 'user_count', 'recipe_count', 'file_size_mb',
'image', 'use_plural',) 'image', 'use_plural',)
read_only_fields = ( read_only_fields = (
'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing', 'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing',
@ -449,7 +448,7 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
class Meta: class Meta:
model = Keyword model = Keyword
fields = ( fields = (
'id', 'name', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at', 'id', 'name', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at',
'updated_at', 'full_name') 'updated_at', 'full_name')
read_only_fields = ('id', 'label', 'numchild', 'parent', 'image') read_only_fields = ('id', 'label', 'numchild', 'parent', 'image')
@ -528,7 +527,7 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer,
class Meta: class Meta:
model = PropertyType model = PropertyType
fields = ('id', 'name', 'unit', 'description', 'order', 'open_data_slug') fields = ('id', 'name', 'unit', 'description', 'order', 'open_data_slug')
class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer): class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
@ -636,7 +635,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
validated_data['recipe'] = Recipe.objects.get(**recipe) validated_data['recipe'] = Recipe.objects.get(**recipe)
# assuming if on hand for user also onhand for shopping_share users # assuming if on hand for user also onhand for shopping_share users
if not onhand is None: if onhand is not None:
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
if self.instance: if self.instance:
onhand_users = self.instance.onhand_users.all() onhand_users = self.instance.onhand_users.all()
@ -669,7 +668,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
# assuming if on hand for user also onhand for shopping_share users # assuming if on hand for user also onhand for shopping_share users
onhand = validated_data.get('food_onhand', None) onhand = validated_data.get('food_onhand', None)
reset_inherit = self.initial_data.get('reset_inherit', False) reset_inherit = self.initial_data.get('reset_inherit', False)
if not onhand is None: if onhand is not None:
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
if onhand: if onhand:
validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users
@ -764,7 +763,7 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
def get_step_recipe_data(self, obj): def get_step_recipe_data(self, obj):
# check if root type is recipe to prevent infinite recursion # check if root type is recipe to prevent infinite recursion
# can be improved later to allow multi level embedding # can be improved later to allow multi level embedding
if obj.step_recipe and type(self.parent.root) == RecipeSerializer: if obj.step_recipe and isinstance(self.parent.root, RecipeSerializer):
return StepRecipeSerializer(obj.step_recipe, context={'request': self.context['request']}).data return StepRecipeSerializer(obj.step_recipe, context={'request': self.context['request']}).data
class Meta: class Meta:
@ -956,8 +955,7 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
book = validated_data['book'] book = validated_data['book']
recipe = validated_data['recipe'] recipe = validated_data['recipe']
if not book.get_owner() == self.context['request'].user and not self.context[ if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared():
'request'].user in book.get_shared():
raise NotFound(detail=None, code=None) raise NotFound(detail=None, code=None)
obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe) obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe)
return obj return obj
@ -1023,10 +1021,10 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
value = value.quantize( value = value.quantize(
Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero
return ( return (
obj.name obj.name
or getattr(obj.mealplan, 'title', None) or getattr(obj.mealplan, 'title', None)
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)]) or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
or obj.recipe.name or obj.recipe.name
) + f' ({value:.2g})' ) + f' ({value:.2g})'
def update(self, instance, validated_data): def update(self, instance, validated_data):

View File

@ -6,16 +6,17 @@ from rest_framework import permissions, routers
from rest_framework.schemas import get_schema_view from rest_framework.schemas import get_schema_view
from cookbook.helper import dal from cookbook.helper import dal
from recipes.settings import DEBUG, PLUGINS
from cookbook.version_info import TANDOOR_VERSION from cookbook.version_info import TANDOOR_VERSION
from recipes.settings import DEBUG, PLUGINS
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, MealPlan, Recipe, from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, MealPlan,
RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, Step, Storage, PropertyType, Recipe, RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList,
Supermarket, SupermarketCategory, Sync, SyncLog, Unit, UserFile, Space, Step, Storage, Supermarket, SupermarketCategory, Sync, SyncLog, Unit,
get_model_name, UserSpace, Space, PropertyType, UnitConversion) UnitConversion, UserFile, UserSpace, get_model_name)
from .views import api, data, delete, edit, import_export, lists, new, telegram, views from .views import api, data, delete, edit, import_export, lists, new, telegram, views
from .views.api import CustomAuthToken, ImportOpenData from .views.api import CustomAuthToken, ImportOpenData
# extend DRF default router class to allow including additional routers # extend DRF default router class to allow including additional routers
class DefaultRouter(routers.DefaultRouter): class DefaultRouter(routers.DefaultRouter):
def extend(self, r): def extend(self, r):
@ -131,7 +132,6 @@ urlpatterns = [
path('api/backup/', api.get_backup, name='api_backup'), path('api/backup/', api.get_backup, name='api_backup'),
path('api/ingredient-from-string/', api.ingredient_from_string, name='api_ingredient_from_string'), path('api/ingredient-from-string/', api.ingredient_from_string, name='api_ingredient_from_string'),
path('api/share-link/<int:pk>', api.share_link, name='api_share_link'), path('api/share-link/<int:pk>', api.share_link, name='api_share_link'),
path('api/get_facets/', api.get_facets, name='api_get_facets'),
path('api/reset-food-inheritance/', api.reset_food_inheritance, name='api_reset_food_inheritance'), path('api/reset-food-inheritance/', api.reset_food_inheritance, name='api_reset_food_inheritance'),
path('api/switch-active-space/<int:space_id>/', api.switch_active_space, name='api_switch_active_space'), path('api/switch-active-space/<int:space_id>/', api.switch_active_space, name='api_switch_active_space'),
path('api/download-file/<int:file_id>/', api.download_file, name='api_download_file'), path('api/download-file/<int:file_id>/', api.download_file, name='api_download_file'),

View File

@ -3,7 +3,6 @@ import io
import json import json
import mimetypes import mimetypes
import pathlib import pathlib
import random
import re import re
import threading import threading
import traceback import traceback
@ -15,7 +14,6 @@ from zipfile import ZipFile
import requests import requests
import validators import validators
from PIL import UnidentifiedImageError
from annoying.decorators import ajax_request from annoying.decorators import ajax_request
from annoying.functions import get_object_or_None from annoying.functions import get_object_or_None
from django.contrib import messages from django.contrib import messages
@ -24,7 +22,7 @@ from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import caches from django.core.cache import caches
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.core.files import File from django.core.files import File
from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When, Avg, Max from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When
from django.db.models.fields.related import ForeignObjectRel from django.db.models.fields.related import ForeignObjectRel
from django.db.models.functions import Coalesce, Lower from django.db.models.functions import Coalesce, Lower
from django.db.models.signals import post_save from django.db.models.signals import post_save
@ -36,6 +34,7 @@ from django.utils.translation import gettext as _
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from icalendar import Calendar, Event from icalendar import Calendar, Event
from oauth2_provider.models import AccessToken from oauth2_provider.models import AccessToken
from PIL import UnidentifiedImageError
from recipe_scrapers import scrape_me from recipe_scrapers import scrape_me
from recipe_scrapers._exceptions import NoSchemaFoundInWildMode from recipe_scrapers._exceptions import NoSchemaFoundInWildMode
from requests.exceptions import MissingSchema from requests.exceptions import MissingSchema
@ -58,35 +57,41 @@ from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.image_processing import handle_image from cookbook.helper.image_processing import handle_image
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.open_data_importer import OpenDataImporter from cookbook.helper.open_data_importer import OpenDataImporter
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly,
CustomIsOwnerReadOnly, CustomIsShared, CustomIsShared, CustomIsSpaceOwner, CustomIsUser,
CustomIsSpaceOwner, CustomIsUser, group_required, CustomRecipePermission, CustomTokenHasReadWriteScope,
is_space_owner, switch_user_active_space, above_space_limit, CustomTokenHasScope, CustomUserPermission,
CustomRecipePermission, CustomUserPermission, IsReadOnlyDRF, above_space_limit, group_required,
CustomTokenHasReadWriteScope, CustomTokenHasScope, has_group_permission, IsReadOnlyDRF) has_group_permission, is_space_owner,
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch switch_user_active_space)
from cookbook.helper.recipe_url_import import get_from_youtube_scraper, get_images_from_soup, clean_dict from cookbook.helper.recipe_search import RecipeSearch
from cookbook.helper.recipe_url_import import (clean_dict, get_from_youtube_scraper,
get_images_from_soup)
from cookbook.helper.scrapers.scrapers import text_scraper from cookbook.helper.scrapers.scrapers import text_scraper
from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food, from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food,
FoodInheritField, ImportLog, Ingredient, InviteLink, Keyword, MealPlan, FoodInheritField, ImportLog, Ingredient, InviteLink, Keyword, MealPlan,
MealType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList, MealType, Property, PropertyType, Recipe, RecipeBook, RecipeBookEntry,
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, Step, Storage, Supermarket, SupermarketCategory,
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog, UnitConversion, PropertyType, Property) SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
UserFile, UserPreference, UserSpace, ViewLog)
from cookbook.provider.dropbox import Dropbox from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud from cookbook.provider.nextcloud import Nextcloud
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
from cookbook.serializer import (AutomationSerializer, BookmarkletImportListSerializer, from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
AutoMealPlanSerializer, BookmarkletImportListSerializer,
BookmarkletImportSerializer, CookLogSerializer, BookmarkletImportSerializer, CookLogSerializer,
CustomFilterSerializer, ExportLogSerializer, CustomFilterSerializer, ExportLogSerializer,
FoodInheritFieldSerializer, FoodSerializer, FoodInheritFieldSerializer, FoodSerializer,
FoodShoppingUpdateSerializer, GroupSerializer, ImportLogSerializer, FoodShoppingUpdateSerializer, FoodSimpleSerializer,
IngredientSerializer, IngredientSimpleSerializer, GroupSerializer, ImportLogSerializer, IngredientSerializer,
InviteLinkSerializer, KeywordSerializer, MealPlanSerializer, IngredientSimpleSerializer, InviteLinkSerializer,
MealTypeSerializer, RecipeBookEntrySerializer, KeywordSerializer, MealPlanSerializer, MealTypeSerializer,
RecipeBookSerializer, RecipeFromSourceSerializer, PropertySerializer, PropertyTypeSerializer,
RecipeBookEntrySerializer, RecipeBookSerializer,
RecipeExportSerializer, RecipeFromSourceSerializer,
RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer, RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer,
RecipeShoppingUpdateSerializer, RecipeSimpleSerializer, RecipeShoppingUpdateSerializer, RecipeSimpleSerializer,
ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer, ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
@ -94,11 +99,9 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportListSeri
SpaceSerializer, StepSerializer, StorageSerializer, SpaceSerializer, StepSerializer, StorageSerializer,
SupermarketCategoryRelationSerializer, SupermarketCategoryRelationSerializer,
SupermarketCategorySerializer, SupermarketSerializer, SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitSerializer, SyncLogSerializer, SyncSerializer, UnitConversionSerializer,
UserFileSerializer, UserSerializer, UserPreferenceSerializer, UnitSerializer, UserFileSerializer, UserPreferenceSerializer,
UserSpaceSerializer, ViewLogSerializer, AccessTokenSerializer, FoodSimpleSerializer, UserSerializer, UserSpaceSerializer, ViewLogSerializer)
RecipeExportSerializer, UnitConversionSerializer, PropertyTypeSerializer,
PropertySerializer, AutoMealPlanSerializer)
from cookbook.views.import_export import get_integration from cookbook.views.import_export import get_integration
from recipes import settings from recipes import settings
@ -186,7 +189,8 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
if query is not None and query not in ["''", '']: if query is not None and query not in ["''", '']:
if fuzzy and (settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', if fuzzy and (settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'django.db.backends.postgresql']): 'django.db.backends.postgresql']):
if self.request.user.is_authenticated and any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): if self.request.user.is_authenticated and any(
[self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query)) self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
else: else:
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query)) self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query))
@ -367,7 +371,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin):
child.move(parent, f'{node_location}-child') child.move(parent, f'{node_location}-child')
content = {'msg': _(f'{child.name} was moved successfully to parent {parent.name}')} content = {'msg': _(f'{child.name} was moved successfully to parent {parent.name}')}
return Response(content, status=status.HTTP_200_OK) return Response(content, status=status.HTTP_200_OK)
except (PathOverflow, InvalidMoveToDescendant, InvalidPosition) as e: except (PathOverflow, InvalidMoveToDescendant, InvalidPosition):
content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name} content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name}
return Response(content, status=status.HTTP_400_BAD_REQUEST) return Response(content, status=status.HTTP_400_BAD_REQUEST)
@ -775,8 +779,7 @@ class StepViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
pagination_class = DefaultPagination pagination_class = DefaultPagination
query_params = [ query_params = [
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
qtype='int'),
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'), QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
] ]
schema = QueryParamAutoSchema() schema = QueryParamAutoSchema()
@ -799,7 +802,6 @@ class RecipePagination(PageNumberPagination):
def paginate_queryset(self, queryset, request, view=None): def paginate_queryset(self, queryset, request, view=None):
if queryset is None: if queryset is None:
raise Exception raise Exception
self.facets = RecipeFacet(request, queryset=queryset)
return super().paginate_queryset(queryset, request, view) return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data): def get_paginated_response(self, data):
@ -808,7 +810,6 @@ class RecipePagination(PageNumberPagination):
('next', self.get_next_link()), ('next', self.get_next_link()),
('previous', self.get_previous_link()), ('previous', self.get_previous_link()),
('results', data), ('results', data),
('facets', self.facets.get_facets(from_cache=True))
])) ]))
@ -820,63 +821,33 @@ class RecipeViewSet(viewsets.ModelViewSet):
pagination_class = RecipePagination pagination_class = RecipePagination
query_params = [ query_params = [
QueryParam(name='query', description=_( QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
'Query string matched (fuzzy) against recipe name. In the future also fulltext search.')), QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='int'),
QueryParam(name='keywords', description=_( QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'),
'ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='int'),
qtype='int'), QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='int'),
QueryParam(name='keywords_or', QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='int'),
description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
qtype='int'), QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'),
QueryParam(name='keywords_and', QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'),
description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'),
qtype='int'), QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'),
QueryParam(name='keywords_or_not',
description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'),
qtype='int'),
QueryParam(name='keywords_and_not',
description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'),
qtype='int'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'),
qtype='int'),
QueryParam(name='foods_or',
description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'),
QueryParam(name='foods_and',
description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'),
QueryParam(name='foods_or_not',
description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'),
QueryParam(name='foods_and_not',
description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'),
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'), QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
QueryParam(name='rating', description=_( QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
'Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')), QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
QueryParam(name='books_or', QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'),
description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'), QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'),
QueryParam(name='books_and', QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'), QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
QueryParam(name='books_or_not', QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'), QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='books_and_not', QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'), QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
QueryParam(name='internal', QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')), QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='random', QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')), QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='new', QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='timescooked', description=_(
'Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
QueryParam(name='cookedon', description=_(
'Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='createdon', description=_(
'Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='updatedon', description=_(
'Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='viewedon', description=_(
'Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='makenow',
description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
] ]
schema = QueryParamAutoSchema() schema = QueryParamAutoSchema()
@ -1095,17 +1066,10 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
serializer_class = ShoppingListEntrySerializer serializer_class = ShoppingListEntrySerializer
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
query_params = [ query_params = [
QueryParam(name='id', QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), QueryParam(name='checked', description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
qtype='int'), ),
QueryParam( QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
name='checked',
description=_(
'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
),
QueryParam(name='supermarket',
description=_('Returns the shopping list entries sorted by supermarket category order.'),
qtype='int'),
] ]
schema = QueryParamAutoSchema() schema = QueryParamAutoSchema()
@ -1344,12 +1308,10 @@ def recipe_from_source(request):
}, status=status.HTTP_400_BAD_REQUEST) }, status=status.HTTP_400_BAD_REQUEST)
elif url and not data: elif url and not data:
if re.match('^(https?://)?(www\.youtube\.com|youtu\.be)/.+$', url): if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url):
if validators.url(url, public=True): if validators.url(url, public=True):
return Response({ return Response({
'recipe_json': get_from_youtube_scraper(url, request), 'recipe_json': get_from_youtube_scraper(url, request),
# 'recipe_tree': '',
# 'recipe_html': '',
'recipe_images': [], 'recipe_images': [],
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
if re.match( if re.match(
@ -1412,8 +1374,6 @@ def recipe_from_source(request):
if scrape: if scrape:
return Response({ return Response({
'recipe_json': helper.get_from_scraper(scrape, request), 'recipe_json': helper.get_from_scraper(scrape, request),
# 'recipe_tree': recipe_tree,
# 'recipe_html': recipe_html,
'recipe_images': list(dict.fromkeys(get_images_from_soup(scrape.soup, url))), 'recipe_images': list(dict.fromkeys(get_images_from_soup(scrape.soup, url))),
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@ -1437,7 +1397,7 @@ def reset_food_inheritance(request):
try: try:
Food.reset_inheritance(space=request.space) Food.reset_inheritance(space=request.space)
return Response({'message': 'success', }, status=status.HTTP_200_OK) return Response({'message': 'success', }, status=status.HTTP_200_OK)
except Exception as e: except Exception:
traceback.print_exc() traceback.print_exc()
return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({}, status=status.HTTP_400_BAD_REQUEST)
@ -1457,7 +1417,7 @@ def switch_active_space(request, space_id):
return Response(UserSpaceSerializer().to_representation(instance=user_space), status=status.HTTP_200_OK) return Response(UserSpaceSerializer().to_representation(instance=user_space), status=status.HTTP_200_OK)
else: else:
return Response("not found", status=status.HTTP_404_NOT_FOUND) return Response("not found", status=status.HTTP_404_NOT_FOUND)
except Exception as e: except Exception:
traceback.print_exc() traceback.print_exc()
return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({}, status=status.HTTP_400_BAD_REQUEST)
@ -1482,7 +1442,7 @@ def download_file(request, file_id):
response['Content-Disposition'] = 'attachment; filename="' + uf.name + '.zip"' response['Content-Disposition'] = 'attachment; filename="' + uf.name + '.zip"'
return response return response
except Exception as e: except Exception:
traceback.print_exc() traceback.print_exc()
return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({}, status=status.HTTP_400_BAD_REQUEST)
@ -1707,25 +1667,3 @@ def ingredient_from_string(request):
}, },
status=200 status=200
) )
@group_required('user')
def get_facets(request):
key = request.GET.get('hash', None)
food = request.GET.get('food', None)
keyword = request.GET.get('keyword', None)
facets = RecipeFacet(request, hash_key=key)
if food:
results = facets.add_food_children(food)
elif keyword:
results = facets.add_keyword_children(keyword)
else:
results = facets.get_facets()
return JsonResponse(
{
'facets': results,
},
status=200
)

View File

@ -1,16 +1,13 @@
import re import re
import threading import threading
from io import BytesIO
from django.contrib import messages
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from cookbook.forms import ExportForm, ImportExportBase, ImportForm from cookbook.forms import ExportForm, ImportExportBase
from cookbook.helper.permission_helper import group_required, above_space_limit from cookbook.helper.permission_helper import group_required
from cookbook.helper.recipe_search import RecipeSearch from cookbook.helper.recipe_search import RecipeSearch
from cookbook.integration.cheftap import ChefTap from cookbook.integration.cheftap import ChefTap
from cookbook.integration.chowdown import Chowdown from cookbook.integration.chowdown import Chowdown
@ -34,7 +31,7 @@ from cookbook.integration.recipesage import RecipeSage
from cookbook.integration.rezeptsuitede import Rezeptsuitede from cookbook.integration.rezeptsuitede import Rezeptsuitede
from cookbook.integration.rezkonv import RezKonv from cookbook.integration.rezkonv import RezKonv
from cookbook.integration.saffron import Saffron from cookbook.integration.saffron import Saffron
from cookbook.models import ExportLog, ImportLog, Recipe, UserPreference from cookbook.models import ExportLog, Recipe
from recipes import settings from recipes import settings

View File

@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@babel/eslint-parser": "^7.21.3", "@babel/eslint-parser": "^7.21.3",
"@popperjs/core": "^2.11.7", "@popperjs/core": "^2.11.7",
"@riophae/vue-treeselect": "^0.4.0",
"@vue/cli": "^5.0.8", "@vue/cli": "^5.0.8",
"@vue/composition-api": "1.7.1", "@vue/composition-api": "1.7.1",
"axios": "^1.2.0", "axios": "^1.2.0",
@ -37,7 +36,7 @@
"vue-multiselect": "^2.1.6", "vue-multiselect": "^2.1.6",
"vue-property-decorator": "^9.1.2", "vue-property-decorator": "^9.1.2",
"vue-sanitize": "^0.2.2", "vue-sanitize": "^0.2.2",
"vue-simple-calendar": "5.0.1", "vue-simple-calendar": "TandoorRecipes/vue-simple-calendar#lastvue2",
"vue-template-compiler": "2.7.14", "vue-template-compiler": "2.7.14",
"vue2-touch-events": "^3.2.2", "vue2-touch-events": "^3.2.2",
"vuedraggable": "^2.24.3", "vuedraggable": "^2.24.3",

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,6 @@
"file_upload_disabled": "", "file_upload_disabled": "",
"warning_space_delete": "", "warning_space_delete": "",
"food_inherit_info": "", "food_inherit_info": "",
"facet_count_info": "",
"step_time_minutes": "", "step_time_minutes": "",
"confirm_delete": "", "confirm_delete": "",
"import_running": "", "import_running": "",

View File

@ -16,7 +16,6 @@
"file_upload_disabled": "Nahrávání souborů není povoleno pro Váš prostor.", "file_upload_disabled": "Nahrávání souborů není povoleno pro Váš prostor.",
"warning_space_delete": "Můžete smazat váš prostor včetně všech receptů, nákupních seznamů, jídelníčků a všeho ostatního, co jste vytvořili. Tuto akci nemůžete vzít zpět! Jste si jisti, že chcete pokračovat?", "warning_space_delete": "Můžete smazat váš prostor včetně všech receptů, nákupních seznamů, jídelníčků a všeho ostatního, co jste vytvořili. Tuto akci nemůžete vzít zpět! Jste si jisti, že chcete pokračovat?",
"food_inherit_info": "Pole potravin, která budou standardně zděděna.", "food_inherit_info": "Pole potravin, která budou standardně zděděna.",
"facet_count_info": "Zobraz počet receptů u filtrů vyhledávání.",
"step_time_minutes": "Nastavte čas v minutách", "step_time_minutes": "Nastavte čas v minutách",
"confirm_delete": "Jste si jisti že chcete odstranit tento {objekt}?", "confirm_delete": "Jste si jisti že chcete odstranit tento {objekt}?",
"import_running": "Probíhá import, čekejte prosím!", "import_running": "Probíhá import, čekejte prosím!",

View File

@ -446,7 +446,6 @@
"Valid Until": "Gyldig indtil", "Valid Until": "Gyldig indtil",
"Private_Recipe_Help": "Opskriften er kun synlig for dig, og dem som den er delt med.", "Private_Recipe_Help": "Opskriften er kun synlig for dig, og dem som den er delt med.",
"food_inherit_info": "Felter på mad som skal nedarves automatisk.", "food_inherit_info": "Felter på mad som skal nedarves automatisk.",
"facet_count_info": "Vis opskriftsantal på søgeresultater.",
"Copy Link": "Kopier link", "Copy Link": "Kopier link",
"Copy Token": "Kopier token", "Copy Token": "Kopier token",
"show_ingredient_overview": "Vis en liste af alle ingredienser i starten af en opskrift.", "show_ingredient_overview": "Vis en liste af alle ingredienser i starten af en opskrift.",

View File

@ -411,7 +411,6 @@
"warning_space_delete": "Du kannst deinen Space inklusive all deiner Rezepte, Shoppinglisten, Essensplänen und allem anderen, das du erstellt hast löschen. Dieser Schritt kann nicht rückgängig gemacht werden! Bist du sicher, dass du das tun möchtest?", "warning_space_delete": "Du kannst deinen Space inklusive all deiner Rezepte, Shoppinglisten, Essensplänen und allem anderen, das du erstellt hast löschen. Dieser Schritt kann nicht rückgängig gemacht werden! Bist du sicher, dass du das tun möchtest?",
"Copy Link": "Link Kopieren", "Copy Link": "Link Kopieren",
"Users": "Benutzer", "Users": "Benutzer",
"facet_count_info": "Zeige die Anzahl der Rezepte auf den Suchfiltern.",
"Copy Token": "Kopiere Token", "Copy Token": "Kopiere Token",
"Invites": "Einladungen", "Invites": "Einladungen",
"Message": "Nachricht", "Message": "Nachricht",

View File

@ -17,7 +17,6 @@
"recipe_property_info": "Μπορείτε επίσης να προσθέσετε ιδιότητες σε φαγητά ώστε να υπολογίζονται αυτόματα βάσει της συνταγής σας!", "recipe_property_info": "Μπορείτε επίσης να προσθέσετε ιδιότητες σε φαγητά ώστε να υπολογίζονται αυτόματα βάσει της συνταγής σας!",
"warning_space_delete": "Μπορείτε να διαγράψετε τον χώρο σας μαζί με όλες τις συνταγές, τις λίστες αγορών, τα προγράμματα γευμάτων και οτιδήποτε άλλο έχετε δημιουργήσει. Η ενέργεια αυτή είναι μη αναστρέψιμη! Θέλετε σίγουρα να το κάνετε;", "warning_space_delete": "Μπορείτε να διαγράψετε τον χώρο σας μαζί με όλες τις συνταγές, τις λίστες αγορών, τα προγράμματα γευμάτων και οτιδήποτε άλλο έχετε δημιουργήσει. Η ενέργεια αυτή είναι μη αναστρέψιμη! Θέλετε σίγουρα να το κάνετε;",
"food_inherit_info": "Πεδία σε φαγητά τα οποία πρέπει να κληρονομούνται αυτόματα.", "food_inherit_info": "Πεδία σε φαγητά τα οποία πρέπει να κληρονομούνται αυτόματα.",
"facet_count_info": "Εμφάνιση του αριθμού των συνταγών στα φίλτρα αναζήτησης.",
"step_time_minutes": "Χρόνος βήματος σε λεπτά", "step_time_minutes": "Χρόνος βήματος σε λεπτά",
"confirm_delete": "Θέλετε σίγουρα να διαγράψετε αυτό το {object};", "confirm_delete": "Θέλετε σίγουρα να διαγράψετε αυτό το {object};",
"import_running": "Εισαγωγή σε εξέλιξη, παρακαλώ περιμένετε!", "import_running": "Εισαγωγή σε εξέλιξη, παρακαλώ περιμένετε!",

View File

@ -17,7 +17,6 @@
"recipe_property_info": "You can also add properties to foods to calculate them automatically based on your recipe!", "recipe_property_info": "You can also add properties to foods to calculate them automatically based on your recipe!",
"warning_space_delete": "You can delete your space including all recipes, shopping lists, meal plans and whatever else you have created. This cannot be undone! Are you sure you want to do this ?", "warning_space_delete": "You can delete your space including all recipes, shopping lists, meal plans and whatever else you have created. This cannot be undone! Are you sure you want to do this ?",
"food_inherit_info": "Fields on food that should be inherited by default.", "food_inherit_info": "Fields on food that should be inherited by default.",
"facet_count_info": "Show recipe counts on search filters.",
"step_time_minutes": "Step time in minutes", "step_time_minutes": "Step time in minutes",
"confirm_delete": "Are you sure you want to delete this {object}?", "confirm_delete": "Are you sure you want to delete this {object}?",
"import_running": "Import running, please wait!", "import_running": "Import running, please wait!",

View File

@ -419,7 +419,6 @@
"Users": "Usuarios", "Users": "Usuarios",
"Invites": "Invitaciones", "Invites": "Invitaciones",
"food_inherit_info": "Campos que han de ser heredados por defecto.", "food_inherit_info": "Campos que han de ser heredados por defecto.",
"facet_count_info": "Mostrar contadores de receta en los filtros de búsqueda.",
"Copy Link": "Copiar Enlace", "Copy Link": "Copiar Enlace",
"Copy Token": "Copiar Token", "Copy Token": "Copiar Token",
"Create_New_Shopping_Category": "Añadir nueva Categoría de Compras", "Create_New_Shopping_Category": "Añadir nueva Categoría de Compras",

View File

@ -418,7 +418,6 @@
"Message": "Message", "Message": "Message",
"Sticky_Nav_Help": "Toujours afficher le menu de navigation en haut de lécran.", "Sticky_Nav_Help": "Toujours afficher le menu de navigation en haut de lécran.",
"Combine_All_Steps": "Combiner toutes les étapes en un seul champ.", "Combine_All_Steps": "Combiner toutes les étapes en un seul champ.",
"facet_count_info": "Afficher les compteurs de recette sur les filtres de recherche.",
"Decimals": "Décimales", "Decimals": "Décimales",
"plan_share_desc": "Les nouvelles entrées de menu de la semaine seront partagées automatiquement avec des utilisateurs sélectionnés.", "plan_share_desc": "Les nouvelles entrées de menu de la semaine seront partagées automatiquement avec des utilisateurs sélectionnés.",
"Use_Kj": "Utiliser kJ au lieu de kcal", "Use_Kj": "Utiliser kJ au lieu de kcal",

View File

@ -17,7 +17,6 @@
"recipe_property_info": "ניתן גם להוסיף ערכים למאכלים בכדי לחשב אוטומטית בהתאם למתכון שלך!", "recipe_property_info": "ניתן גם להוסיף ערכים למאכלים בכדי לחשב אוטומטית בהתאם למתכון שלך!",
"warning_space_delete": "ניתן למחיק את המרחב כולל כל המתכונים, רשימות קניות, תוכניות אוכל וכל מה שנוצר. פעולה זו הינה בלתי הפיכה! האם אתה בטוח ?", "warning_space_delete": "ניתן למחיק את המרחב כולל כל המתכונים, רשימות קניות, תוכניות אוכל וכל מה שנוצר. פעולה זו הינה בלתי הפיכה! האם אתה בטוח ?",
"food_inherit_info": "ערכים על אוכל שאמורים להיות תורשתיים כברירת מחדל.", "food_inherit_info": "ערכים על אוכל שאמורים להיות תורשתיים כברירת מחדל.",
"facet_count_info": "הצג מספר מתכונים בסנני החיפוש.",
"step_time_minutes": "זמן הצעד בדקות", "step_time_minutes": "זמן הצעד בדקות",
"confirm_delete": "האם אתה בטוח רוצה למחק את {object}?", "confirm_delete": "האם אתה בטוח רוצה למחק את {object}?",
"import_running": "ייבוא מתבצע, נא להמתין!", "import_running": "ייבוא מתבצע, נא להמתין!",

View File

@ -16,7 +16,6 @@
"file_upload_disabled": "Unggahan file tidak diaktifkan untuk ruang Anda.", "file_upload_disabled": "Unggahan file tidak diaktifkan untuk ruang Anda.",
"warning_space_delete": "Anda dapat menghapus ruang Anda termasuk semua resep, daftar belanja, rencana makan, dan apa pun yang telah Anda buat. Ini tidak dapat dibatalkan! Apakah Anda yakin ingin melakukan ini?", "warning_space_delete": "Anda dapat menghapus ruang Anda termasuk semua resep, daftar belanja, rencana makan, dan apa pun yang telah Anda buat. Ini tidak dapat dibatalkan! Apakah Anda yakin ingin melakukan ini?",
"food_inherit_info": "Bidang pada makanan yang harus diwarisi secara default.", "food_inherit_info": "Bidang pada makanan yang harus diwarisi secara default.",
"facet_count_info": "Tampilkan jumlah resep pada filter pencarian.",
"step_time_minutes": "Langkah waktu dalam menit", "step_time_minutes": "Langkah waktu dalam menit",
"confirm_delete": "Anda yakin ingin menghapus {object} ini?", "confirm_delete": "Anda yakin ingin menghapus {object} ini?",
"import_running": "Impor berjalan, harap tunggu!", "import_running": "Impor berjalan, harap tunggu!",

View File

@ -304,7 +304,6 @@
"tree_select": "Usa selezione ad albero", "tree_select": "Usa selezione ad albero",
"sql_debug": "Debug SQL", "sql_debug": "Debug SQL",
"remember_search": "Ricorda ricerca", "remember_search": "Ricorda ricerca",
"facet_count_info": "Mostra il conteggio delle ricette nei filtri di ricerca.",
"warning_space_delete": "Stai per eliminare la tua istanza che include tutte le ricette, liste della spesa, piani alimentari e tutto ciò che hai creato. Questa azione non può essere annullata! Sei sicuro di voler procedere?", "warning_space_delete": "Stai per eliminare la tua istanza che include tutte le ricette, liste della spesa, piani alimentari e tutto ciò che hai creato. Questa azione non può essere annullata! Sei sicuro di voler procedere?",
"food_inherit_info": "Campi di alimenti che devono essere ereditati per impostazione predefinita.", "food_inherit_info": "Campi di alimenti che devono essere ereditati per impostazione predefinita.",
"enable_expert": "Abilita modalità esperto", "enable_expert": "Abilita modalità esperto",

View File

@ -16,7 +16,6 @@
"file_upload_disabled": "Opplasting av filer er ikke aktivert i området ditt.", "file_upload_disabled": "Opplasting av filer er ikke aktivert i området ditt.",
"warning_space_delete": "Du kan slette området, inkludert alle oppskrifter, handlelister, måltidsplaner og alt annet du har opprettet. Dette kan ikke angres! Er du sikker på at du vil gjøre dette?", "warning_space_delete": "Du kan slette området, inkludert alle oppskrifter, handlelister, måltidsplaner og alt annet du har opprettet. Dette kan ikke angres! Er du sikker på at du vil gjøre dette?",
"food_inherit_info": "Felter på matvarer som skal arves som standard.", "food_inherit_info": "Felter på matvarer som skal arves som standard.",
"facet_count_info": "Vis oppskriftsantall i søkefilter.",
"step_time_minutes": "Tid for trinn, i minutter", "step_time_minutes": "Tid for trinn, i minutter",
"confirm_delete": "Er du sikker på at du vil slette dette {object}?", "confirm_delete": "Er du sikker på at du vil slette dette {object}?",
"import_running": "Importering pågår. Vennligst vent!", "import_running": "Importering pågår. Vennligst vent!",

View File

@ -464,7 +464,6 @@
"Valid Until": "Geldig tot", "Valid Until": "Geldig tot",
"warning_space_delete": "Je kunt jouw space verwijderen inclusief alle recepten, boodschappenlijstjes, maaltijdplannen en alles wat je verder aangemaakt hebt. Dit kan niet ongedaan worden gemaakt! Weet je het zeker?", "warning_space_delete": "Je kunt jouw space verwijderen inclusief alle recepten, boodschappenlijstjes, maaltijdplannen en alles wat je verder aangemaakt hebt. Dit kan niet ongedaan worden gemaakt! Weet je het zeker?",
"food_inherit_info": "Voedselvelden die standaard geërfd worden.", "food_inherit_info": "Voedselvelden die standaard geërfd worden.",
"facet_count_info": "Geef receptenaantal bij zoekfilters weer.",
"Split_All_Steps": "Splits alle rijen in aparte stappen.", "Split_All_Steps": "Splits alle rijen in aparte stappen.",
"Combine_All_Steps": "Voeg alle stappen samen tot een veld.", "Combine_All_Steps": "Voeg alle stappen samen tot een veld.",
"Plural": "Meervoud", "Plural": "Meervoud",

View File

@ -420,7 +420,6 @@
"Users": "Użytkownicy", "Users": "Użytkownicy",
"Invites": "Zaprasza", "Invites": "Zaprasza",
"food_inherit_info": "Pola w pożywieniu, które powinny być domyślnie dziedziczone.", "food_inherit_info": "Pola w pożywieniu, które powinny być domyślnie dziedziczone.",
"facet_count_info": "Pokaż ilości przepisów w filtrach wyszukiwania.",
"Copy Token": "Kopiuj Token", "Copy Token": "Kopiuj Token",
"Message": "Wiadomość", "Message": "Wiadomość",
"reset_food_inheritance": "Zresetuj dziedziczenie", "reset_food_inheritance": "Zresetuj dziedziczenie",

View File

@ -382,7 +382,6 @@
"err_deleting_protected_resource": "O objeto que você está tentando deletar ainda está sendo utilizado, portanto não pode ser deletado.", "err_deleting_protected_resource": "O objeto que você está tentando deletar ainda está sendo utilizado, portanto não pode ser deletado.",
"food_inherit_info": "Campos no alimento que devem ser herdados por padrão.", "food_inherit_info": "Campos no alimento que devem ser herdados por padrão.",
"warning_space_delete": "Pode eliminar o seu espaço incluindo todas as receitas, listas de compras, planos de refeição e tudo o que tenha criado. Isto não pode ser desfeito! Tem a certeza que quer fazer isto?", "warning_space_delete": "Pode eliminar o seu espaço incluindo todas as receitas, listas de compras, planos de refeição e tudo o que tenha criado. Isto não pode ser desfeito! Tem a certeza que quer fazer isto?",
"facet_count_info": "Mostrar quantidade de receitas nos filtros de busca.",
"Plural": "", "Plural": "",
"plural_short": "", "plural_short": "",
"Use_Plural_Unit_Always": "", "Use_Plural_Unit_Always": "",

View File

@ -387,7 +387,6 @@
"Copy Token": "Copiar Token", "Copy Token": "Copiar Token",
"warning_space_delete": "Você pode deletar seu espaço, inclusive todas as receitas, listas de mercado, planos de comida e tudo mais que você criou. Esta ação não poderá ser desfeita! Você tem certeza que quer fazer isto?", "warning_space_delete": "Você pode deletar seu espaço, inclusive todas as receitas, listas de mercado, planos de comida e tudo mais que você criou. Esta ação não poderá ser desfeita! Você tem certeza que quer fazer isto?",
"food_inherit_info": "Campos no alimento que devem ser herdados por padrão.", "food_inherit_info": "Campos no alimento que devem ser herdados por padrão.",
"facet_count_info": "Mostrar quantidade de receitas nos filtros de busca.",
"Plural": "", "Plural": "",
"plural_short": "", "plural_short": "",
"Use_Plural_Unit_Always": "", "Use_Plural_Unit_Always": "",

View File

@ -343,7 +343,6 @@
"Undefined": "Nedefinit", "Undefined": "Nedefinit",
"Select": "Selectare", "Select": "Selectare",
"food_inherit_info": "Câmpuri pe alimente care ar trebui să fie moștenite în mod implicit.", "food_inherit_info": "Câmpuri pe alimente care ar trebui să fie moștenite în mod implicit.",
"facet_count_info": "Afișarea numărului de rețete pe filtrele de căutare.",
"Amount": "Cantitate", "Amount": "Cantitate",
"Auto_Sort_Help": "Mutați toate ingredientele la cel mai potrivit pas.", "Auto_Sort_Help": "Mutați toate ingredientele la cel mai potrivit pas.",
"search_create_help_text": "Creați o rețetă nouă direct în Tandoor.", "search_create_help_text": "Creați o rețetă nouă direct în Tandoor.",

View File

@ -343,7 +343,6 @@
"DelayFor": "Отложить на {hours} часов", "DelayFor": "Отложить на {hours} часов",
"New_Entry": "Новая запись", "New_Entry": "Новая запись",
"GroupBy": "Сгруппировать по", "GroupBy": "Сгруппировать по",
"facet_count_info": "Показывать количество рецептов в фильтрах поиска.",
"food_inherit_info": "Поля для продуктов питания, которые должны наследоваться по умолчанию.", "food_inherit_info": "Поля для продуктов питания, которые должны наследоваться по умолчанию.",
"warning_space_delete": "Вы можете удалить свое пространство, включая все рецепты, списки покупок, планы питания и все остальное, что вы создали. Этого нельзя отменить! Вы уверены, что хотите это сделать?", "warning_space_delete": "Вы можете удалить свое пространство, включая все рецепты, списки покупок, планы питания и все остальное, что вы создали. Этого нельзя отменить! Вы уверены, что хотите это сделать?",
"Description_Replace": "Изменить описание" "Description_Replace": "Изменить описание"

View File

@ -302,7 +302,6 @@
"Description_Replace": "Zamenjaj Opis", "Description_Replace": "Zamenjaj Opis",
"recipe_property_info": "Živilom lahko dodate tudi lastnosti, ki se samodejno izračunajo na podlagi vašega recepta!", "recipe_property_info": "Živilom lahko dodate tudi lastnosti, ki se samodejno izračunajo na podlagi vašega recepta!",
"warning_space_delete": "Izbrišete lahko svoj prostor, vključno z vsemi recepti, nakupovalnimi seznami, načrti obrokov in vsem drugim, kar ste ustvarili. Tega ni mogoče preklicati! Ste prepričani, da želite to storiti?", "warning_space_delete": "Izbrišete lahko svoj prostor, vključno z vsemi recepti, nakupovalnimi seznami, načrti obrokov in vsem drugim, kar ste ustvarili. Tega ni mogoče preklicati! Ste prepričani, da želite to storiti?",
"facet_count_info": "Prikaži število receptov v iskalnih filtrih.",
"per_serving": "na porcijo", "per_serving": "na porcijo",
"Ingredient Editor": "Urejevalnik Sestavin", "Ingredient Editor": "Urejevalnik Sestavin",
"Instruction_Replace": "Zamenjaj Navodila", "Instruction_Replace": "Zamenjaj Navodila",

View File

@ -425,7 +425,6 @@
"reusable_help_text": "Bör inbjudningslänken vara användbar för mer än en användare.", "reusable_help_text": "Bör inbjudningslänken vara användbar för mer än en användare.",
"Ingredient Editor": "Ingrediensredigerare", "Ingredient Editor": "Ingrediensredigerare",
"warning_space_delete": "Du kan ta bort ditt utrymme inklusive alla recept, inköpslistor, måltidsplaner och allt annat du har skapat. Detta kan inte ångras! Är du säker på att du vill göra detta?", "warning_space_delete": "Du kan ta bort ditt utrymme inklusive alla recept, inköpslistor, måltidsplaner och allt annat du har skapat. Detta kan inte ångras! Är du säker på att du vill göra detta?",
"facet_count_info": "Visa recept antal på sökfilter.",
"food_inherit_info": "Fält på mat som ska ärvas som standard.", "food_inherit_info": "Fält på mat som ska ärvas som standard.",
"Auto_Sort": "Automatisk Sortering", "Auto_Sort": "Automatisk Sortering",
"Day": "Dag", "Day": "Dag",

View File

@ -16,7 +16,6 @@
"file_upload_disabled": "Alanınız için dosya yükleme aktif değil.", "file_upload_disabled": "Alanınız için dosya yükleme aktif değil.",
"warning_space_delete": "Tüm tarifler, alışveriş listeleri, yemek planları ve oluşturduğunuz her şey dahil olmak üzere silinecektir. Bu geri alınamaz! Bunu yapmak istediğinizden emin misiniz?", "warning_space_delete": "Tüm tarifler, alışveriş listeleri, yemek planları ve oluşturduğunuz her şey dahil olmak üzere silinecektir. Bu geri alınamaz! Bunu yapmak istediğinizden emin misiniz?",
"food_inherit_info": "", "food_inherit_info": "",
"facet_count_info": "",
"step_time_minutes": "Dakika olarak adım süresi", "step_time_minutes": "Dakika olarak adım süresi",
"confirm_delete": "", "confirm_delete": "",
"import_running": "", "import_running": "",

View File

@ -421,7 +421,6 @@
"Use_Plural_Food_Simple": "", "Use_Plural_Food_Simple": "",
"plural_usage_info": "", "plural_usage_info": "",
"warning_space_delete": "Ви можете видалити ваш простір разом зі всіма рецептами, списками покупок, планами харчування і всім іншим, що ви створили. Ця дія незворотня! Ви впевнені, що бажаєте це зробити?", "warning_space_delete": "Ви можете видалити ваш простір разом зі всіма рецептами, списками покупок, планами харчування і всім іншим, що ви створили. Ця дія незворотня! Ви впевнені, що бажаєте це зробити?",
"facet_count_info": "Показати кількість рецептів на полі пошуку.",
"Amount": "Кількість", "Amount": "Кількість",
"Auto_Sort": "Автоматичне сортування", "Auto_Sort": "Автоматичне сортування",
"Auto_Sort_Help": "Перемістити всі інгредієнти до більш підходящого кроку.", "Auto_Sort_Help": "Перемістити всі інгредієнти до більш підходящого кроку.",

View File

@ -414,7 +414,6 @@
"Warning_Delete_Supermarket_Category": "删除超市类别也会删除与食品的所有关系。 你确定吗?", "Warning_Delete_Supermarket_Category": "删除超市类别也会删除与食品的所有关系。 你确定吗?",
"New_Supermarket": "创建新超市", "New_Supermarket": "创建新超市",
"warning_space_delete": "您可以删除您的空间,包括所有食谱、购物清单、膳食计划以及您创建的任何其他内容。 这不能被撤消! 你确定要这么做吗 ", "warning_space_delete": "您可以删除您的空间,包括所有食谱、购物清单、膳食计划以及您创建的任何其他内容。 这不能被撤消! 你确定要这么做吗 ",
"facet_count_info": "在搜索筛选器上显示食谱计数。",
"Hide_as_header": "隐藏标题", "Hide_as_header": "隐藏标题",
"food_inherit_info": "默认情况下应继承的食物上的字段。", "food_inherit_info": "默认情况下应继承的食物上的字段。",
"Custom Filter": "自定义筛选器", "Custom Filter": "自定义筛选器",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff