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
fields = (
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
'sticky_navbar', 'default_page', 'plan_share', 'ingredient_decimals', 'comments', 'left_handed',
'show_step_ingredients',
'sticky_navbar', 'default_page', 'plan_share', 'ingredient_decimals', 'comments', 'left_handed', 'show_step_ingredients',
)
labels = {
@ -67,21 +66,19 @@ class UserPreferenceForm(forms.ModelForm):
help_texts = {
'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': _(
'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.'),
'shopping_share': _('Users with whom to share shopping lists.'),
'ingredient_decimals': _('Number of decimals to round ingredients.'),
'comments': _('If you want to be able to create and see comments underneath recipes.'),
'ingredient_decimals': _('Number of decimals to round ingredients.'),
'comments': _('If you want to be able to create and see comments underneath recipes.'),
'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 '
'of mobile data. If lower than instance limit it is reset when saving.'
'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.'
),
'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_autoexclude_onhand': _('Exclude ingredients that are on 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)
return result
class ImportForm(ImportExportBase):
files = MultipleFileField(required=True)
duplicates = forms.BooleanField(help_text=_(
@ -352,9 +350,8 @@ class MealPlanForm(forms.ModelForm):
)
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>')
}
widgets = {
@ -509,8 +506,8 @@ class ShoppingPreferenceForm(forms.ModelForm):
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_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 '
'of mobile data. If lower than instance limit it is reset when saving.'
'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.'
),
'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.'),
@ -554,11 +551,10 @@ class SpacePreferenceForm(forms.ModelForm):
class Meta:
model = Space
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count', 'use_plural')
fields = ('food_inherit', 'reset_food_inherit', 'use_plural')
help_texts = {
'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.'),
}

View File

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

View File

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

View File

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

View File

@ -1,20 +1,15 @@
import base64
import gzip
import json
import re
from gettext import gettext as _
from io import BytesIO
import requests
import validators
import yaml
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup,
iso_duration_to_minutes)
from cookbook.helper.scrapers.scrapers import text_scraper
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step
from cookbook.models import Ingredient, Recipe, Step
class CookBookApp(Integration):
@ -47,7 +42,8 @@ class CookBookApp(Integration):
pass
# 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:
step.instruction = step.instruction + '\n\n' + recipe_json['nutrition']
@ -62,7 +58,7 @@ class CookBookApp(Integration):
if unit := ingredient.get('unit', None):
u = ingredient_parser.get_unit(unit.get('name', None))
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:

View File

@ -1,4 +1,3 @@
import traceback
import datetime
import traceback
import uuid
@ -18,8 +17,7 @@ from lxml import etree
from cookbook.helper.image_processing import handle_image
from cookbook.models import Keyword, Recipe
from recipes.settings import DEBUG
from recipes.settings import EXPORT_FILE_CACHE_DURATION
from recipes.settings import DEBUG, EXPORT_FILE_CACHE_DURATION
class Integration:
@ -63,12 +61,10 @@ class Integration:
space=request.space
)
def do_export(self, recipes, el):
with scope(space=self.request.space):
el.total_recipes = len(recipes)
el.total_recipes = len(recipes)
el.cache_duration = EXPORT_FILE_CACHE_DURATION
el.save()
@ -80,7 +76,7 @@ class Integration:
export_file = file
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_stream = BytesIO()
export_obj = ZipFile(export_stream, 'w')
@ -91,8 +87,7 @@ class Integration:
export_obj.close()
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.save()
@ -100,7 +95,6 @@ class Integration:
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
return response
def import_file_name_filter(self, zip_info_object):
"""
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:
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)
else:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
@ -298,7 +292,6 @@ class Integration:
if DEBUG:
traceback.print_exc()
def get_export_file_name(self, format='zip'):
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
"""
descendants = Q()
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
nodes = queryset.values('path', 'depth')
for node in nodes:
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)
demo = models.BooleanField(default=False)
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
show_facet_count = models.BooleanField(default=False)
internal_note = models.TextField(blank=True, null=True)

View File

@ -1,4 +1,3 @@
import random
import traceback
import uuid
from datetime import datetime, timedelta
@ -7,34 +6,34 @@ from gettext import gettext as _
from html import escape
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.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.urls import reverse
from django.utils import timezone
from django_scopes import scopes_disabled
from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
from PIL import Image
from oauth2_provider.models import AccessToken
from PIL import Image
from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
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.property_helper import FoodPropertyHelper
from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter,
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink,
Keyword, MealPlan, MealType, NutritionInformation, Recipe, RecipeBook,
RecipeBookEntry, RecipeImport, ShareLink, ShoppingList,
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog, UnitConversion, Property,
PropertyType, Property)
Keyword, MealPlan, MealType, NutritionInformation, Property,
PropertyType, Recipe, RecipeBook, RecipeBookEntry, RecipeImport,
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
UserFile, UserPreference, UserSpace, ViewLog)
from cookbook.templatetags.custom_tags import markdown
from recipes.settings import AWS_ENABLED, MEDIA_URL
@ -60,7 +59,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
if str2bool(
self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
return fields
except (AttributeError, KeyError) as e:
except (AttributeError, KeyError):
pass
try:
del fields['image']
@ -104,9 +103,9 @@ class CustomDecimalField(serializers.Field):
return round(value, 2).normalize()
def to_internal_value(self, data):
if type(data) == int or type(data) == float:
if isinstance(data, int) or isinstance(data, float):
return data
elif type(data) == str:
elif isinstance(data, str):
if data == '':
return 0
try:
@ -146,11 +145,11 @@ class SpaceFilterSerializer(serializers.ListSerializer):
def to_representation(self, data):
if self.context.get('request', None) is None:
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
return super().to_representation(data)
if self.child.Meta.model == User:
if type(self.context['request'].user) == AnonymousUser:
if isinstance(self.context['request'].user, AnonymousUser):
data = []
else:
data = data.filter(userspace__space=self.context['request'].user.get_active_space()).all()
@ -302,7 +301,7 @@ class SpaceSerializer(WritableNestedModelSerializer):
model = Space
fields = (
'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',)
read_only_fields = (
'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:
model = Keyword
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')
read_only_fields = ('id', 'label', 'numchild', 'parent', 'image')
@ -528,7 +527,7 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer,
class Meta:
model = PropertyType
fields = ('id', 'name', 'unit', 'description', 'order', 'open_data_slug')
fields = ('id', 'name', 'unit', 'description', 'order', 'open_data_slug')
class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
@ -636,7 +635,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
validated_data['recipe'] = Recipe.objects.get(**recipe)
# 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())
if self.instance:
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
onhand = validated_data.get('food_onhand', None)
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())
if onhand:
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):
# check if root type is recipe to prevent infinite recursion
# 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
class Meta:
@ -956,8 +955,7 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer):
def create(self, validated_data):
book = validated_data['book']
recipe = validated_data['recipe']
if not book.get_owner() == self.context['request'].user and not self.context[
'request'].user in book.get_shared():
if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared():
raise NotFound(detail=None, code=None)
obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe)
return obj
@ -1023,10 +1021,10 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
value = value.quantize(
Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero
return (
obj.name
or getattr(obj.mealplan, 'title', None)
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
or obj.recipe.name
obj.name
or getattr(obj.mealplan, 'title', None)
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
or obj.recipe.name
) + f' ({value:.2g})'
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 cookbook.helper import dal
from recipes.settings import DEBUG, PLUGINS
from cookbook.version_info import TANDOOR_VERSION
from recipes.settings import DEBUG, PLUGINS
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, Step, Storage,
Supermarket, SupermarketCategory, Sync, SyncLog, Unit, UserFile,
get_model_name, UserSpace, Space, PropertyType, UnitConversion)
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, MealPlan,
PropertyType, Recipe, RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList,
Space, Step, Storage, Supermarket, SupermarketCategory, Sync, SyncLog, Unit,
UnitConversion, UserFile, UserSpace, get_model_name)
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
from .views.api import CustomAuthToken, ImportOpenData
# extend DRF default router class to allow including additional routers
class DefaultRouter(routers.DefaultRouter):
def extend(self, r):
@ -131,7 +132,6 @@ urlpatterns = [
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/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/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'),

View File

@ -3,7 +3,6 @@ import io
import json
import mimetypes
import pathlib
import random
import re
import threading
import traceback
@ -15,7 +14,6 @@ from zipfile import ZipFile
import requests
import validators
from PIL import UnidentifiedImageError
from annoying.decorators import ajax_request
from annoying.functions import get_object_or_None
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.exceptions import FieldError, ValidationError
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.functions import Coalesce, Lower
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 icalendar import Calendar, Event
from oauth2_provider.models import AccessToken
from PIL import UnidentifiedImageError
from recipe_scrapers import scrape_me
from recipe_scrapers._exceptions import NoSchemaFoundInWildMode
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.ingredient_parser import IngredientParser
from cookbook.helper.open_data_importer import OpenDataImporter
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner,
CustomIsOwnerReadOnly, CustomIsShared,
CustomIsSpaceOwner, CustomIsUser, group_required,
is_space_owner, switch_user_active_space, above_space_limit,
CustomRecipePermission, CustomUserPermission,
CustomTokenHasReadWriteScope, CustomTokenHasScope, has_group_permission, IsReadOnlyDRF)
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch
from cookbook.helper.recipe_url_import import get_from_youtube_scraper, get_images_from_soup, clean_dict
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly,
CustomIsShared, CustomIsSpaceOwner, CustomIsUser,
CustomRecipePermission, CustomTokenHasReadWriteScope,
CustomTokenHasScope, CustomUserPermission,
IsReadOnlyDRF, above_space_limit, group_required,
has_group_permission, is_space_owner,
switch_user_active_space)
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.shopping_helper import RecipeShoppingEditor, shopping_helper
from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food,
FoodInheritField, ImportLog, Ingredient, InviteLink, Keyword, MealPlan,
MealType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList,
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog, UnitConversion, PropertyType, Property)
MealType, Property, PropertyType, Recipe, RecipeBook, RecipeBookEntry,
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
UserFile, UserPreference, UserSpace, ViewLog)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
from cookbook.serializer import (AutomationSerializer, BookmarkletImportListSerializer,
from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
AutoMealPlanSerializer, BookmarkletImportListSerializer,
BookmarkletImportSerializer, CookLogSerializer,
CustomFilterSerializer, ExportLogSerializer,
FoodInheritFieldSerializer, FoodSerializer,
FoodShoppingUpdateSerializer, GroupSerializer, ImportLogSerializer,
IngredientSerializer, IngredientSimpleSerializer,
InviteLinkSerializer, KeywordSerializer, MealPlanSerializer,
MealTypeSerializer, RecipeBookEntrySerializer,
RecipeBookSerializer, RecipeFromSourceSerializer,
FoodShoppingUpdateSerializer, FoodSimpleSerializer,
GroupSerializer, ImportLogSerializer, IngredientSerializer,
IngredientSimpleSerializer, InviteLinkSerializer,
KeywordSerializer, MealPlanSerializer, MealTypeSerializer,
PropertySerializer, PropertyTypeSerializer,
RecipeBookEntrySerializer, RecipeBookSerializer,
RecipeExportSerializer, RecipeFromSourceSerializer,
RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer,
RecipeShoppingUpdateSerializer, RecipeSimpleSerializer,
ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
@ -94,11 +99,9 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportListSeri
SpaceSerializer, StepSerializer, StorageSerializer,
SupermarketCategoryRelationSerializer,
SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitSerializer,
UserFileSerializer, UserSerializer, UserPreferenceSerializer,
UserSpaceSerializer, ViewLogSerializer, AccessTokenSerializer, FoodSimpleSerializer,
RecipeExportSerializer, UnitConversionSerializer, PropertyTypeSerializer,
PropertySerializer, AutoMealPlanSerializer)
SyncLogSerializer, SyncSerializer, UnitConversionSerializer,
UnitSerializer, UserFileSerializer, UserPreferenceSerializer,
UserSerializer, UserSpaceSerializer, ViewLogSerializer)
from cookbook.views.import_export import get_integration
from recipes import settings
@ -186,7 +189,8 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
if query is not None and query not in ["''", '']:
if fuzzy and (settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'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))
else:
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')
content = {'msg': _(f'{child.name} was moved successfully to parent {parent.name}')}
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}
return Response(content, status=status.HTTP_400_BAD_REQUEST)
@ -775,8 +779,7 @@ class StepViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
pagination_class = DefaultPagination
query_params = [
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'),
qtype='int'),
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
]
schema = QueryParamAutoSchema()
@ -799,7 +802,6 @@ class RecipePagination(PageNumberPagination):
def paginate_queryset(self, queryset, request, view=None):
if queryset is None:
raise Exception
self.facets = RecipeFacet(request, queryset=queryset)
return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data):
@ -808,7 +810,6 @@ class RecipePagination(PageNumberPagination):
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data),
('facets', self.facets.get_facets(from_cache=True))
]))
@ -820,63 +821,33 @@ class RecipeViewSet(viewsets.ModelViewSet):
pagination_class = RecipePagination
query_params = [
QueryParam(name='query', description=_(
'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_or',
description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'),
qtype='int'),
QueryParam(name='keywords_and',
description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'),
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='query', description=_('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_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'),
QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), 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='rating', description=_(
'Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
QueryParam(name='rating', description=_('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_or',
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_or_not',
description=_('Book IDs, repeat for multiple. Exclude recipes with any 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='internal',
description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random',
description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new',
description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='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>'']')),
QueryParam(name='books_or', 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_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any 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='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='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()
@ -1095,17 +1066,10 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
serializer_class = ShoppingListEntrySerializer
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
query_params = [
QueryParam(name='id',
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'),
qtype='int'),
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.')
),
QueryParam(name='supermarket',
description=_('Returns the shopping list entries sorted by supermarket category order.'),
qtype='int'),
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
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.')
),
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
]
schema = QueryParamAutoSchema()
@ -1344,12 +1308,10 @@ def recipe_from_source(request):
}, status=status.HTTP_400_BAD_REQUEST)
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):
return Response({
'recipe_json': get_from_youtube_scraper(url, request),
# 'recipe_tree': '',
# 'recipe_html': '',
'recipe_images': [],
}, status=status.HTTP_200_OK)
if re.match(
@ -1412,8 +1374,6 @@ def recipe_from_source(request):
if scrape:
return Response({
'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))),
}, status=status.HTTP_200_OK)
@ -1437,7 +1397,7 @@ def reset_food_inheritance(request):
try:
Food.reset_inheritance(space=request.space)
return Response({'message': 'success', }, status=status.HTTP_200_OK)
except Exception as e:
except Exception:
traceback.print_exc()
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)
else:
return Response("not found", status=status.HTTP_404_NOT_FOUND)
except Exception as e:
except Exception:
traceback.print_exc()
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"'
return response
except Exception as e:
except Exception:
traceback.print_exc()
return Response({}, status=status.HTTP_400_BAD_REQUEST)
@ -1707,25 +1667,3 @@ def ingredient_from_string(request):
},
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 threading
from io import BytesIO
from django.contrib import messages
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.urls import reverse
from django.utils.translation import gettext as _
from cookbook.forms import ExportForm, ImportExportBase, ImportForm
from cookbook.helper.permission_helper import group_required, above_space_limit
from cookbook.forms import ExportForm, ImportExportBase
from cookbook.helper.permission_helper import group_required
from cookbook.helper.recipe_search import RecipeSearch
from cookbook.integration.cheftap import ChefTap
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.rezkonv import RezKonv
from cookbook.integration.saffron import Saffron
from cookbook.models import ExportLog, ImportLog, Recipe, UserPreference
from cookbook.models import ExportLog, Recipe
from recipes import settings

View File

@ -10,7 +10,6 @@
"dependencies": {
"@babel/eslint-parser": "^7.21.3",
"@popperjs/core": "^2.11.7",
"@riophae/vue-treeselect": "^0.4.0",
"@vue/cli": "^5.0.8",
"@vue/composition-api": "1.7.1",
"axios": "^1.2.0",
@ -37,7 +36,7 @@
"vue-multiselect": "^2.1.6",
"vue-property-decorator": "^9.1.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",
"vue2-touch-events": "^3.2.2",
"vuedraggable": "^2.24.3",

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -16,7 +16,6 @@
"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?",
"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",
"confirm_delete": "Jste si jisti že chcete odstranit tento {objekt}?",
"import_running": "Probíhá import, čekejte prosím!",

View File

@ -446,7 +446,6 @@
"Valid Until": "Gyldig indtil",
"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.",
"facet_count_info": "Vis opskriftsantal på søgeresultater.",
"Copy Link": "Kopier link",
"Copy Token": "Kopier token",
"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?",
"Copy Link": "Link Kopieren",
"Users": "Benutzer",
"facet_count_info": "Zeige die Anzahl der Rezepte auf den Suchfiltern.",
"Copy Token": "Kopiere Token",
"Invites": "Einladungen",
"Message": "Nachricht",

View File

@ -17,7 +17,6 @@
"recipe_property_info": "Μπορείτε επίσης να προσθέσετε ιδιότητες σε φαγητά ώστε να υπολογίζονται αυτόματα βάσει της συνταγής σας!",
"warning_space_delete": "Μπορείτε να διαγράψετε τον χώρο σας μαζί με όλες τις συνταγές, τις λίστες αγορών, τα προγράμματα γευμάτων και οτιδήποτε άλλο έχετε δημιουργήσει. Η ενέργεια αυτή είναι μη αναστρέψιμη! Θέλετε σίγουρα να το κάνετε;",
"food_inherit_info": "Πεδία σε φαγητά τα οποία πρέπει να κληρονομούνται αυτόματα.",
"facet_count_info": "Εμφάνιση του αριθμού των συνταγών στα φίλτρα αναζήτησης.",
"step_time_minutes": "Χρόνος βήματος σε λεπτά",
"confirm_delete": "Θέλετε σίγουρα να διαγράψετε αυτό το {object};",
"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!",
"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.",
"facet_count_info": "Show recipe counts on search filters.",
"step_time_minutes": "Step time in minutes",
"confirm_delete": "Are you sure you want to delete this {object}?",
"import_running": "Import running, please wait!",

View File

@ -419,7 +419,6 @@
"Users": "Usuarios",
"Invites": "Invitaciones",
"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 Token": "Copiar Token",
"Create_New_Shopping_Category": "Añadir nueva Categoría de Compras",

View File

@ -418,7 +418,6 @@
"Message": "Message",
"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.",
"facet_count_info": "Afficher les compteurs de recette sur les filtres de recherche.",
"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.",
"Use_Kj": "Utiliser kJ au lieu de kcal",

View File

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

View File

@ -16,7 +16,6 @@
"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?",
"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",
"confirm_delete": "Anda yakin ingin menghapus {object} ini?",
"import_running": "Impor berjalan, harap tunggu!",

View File

@ -304,7 +304,6 @@
"tree_select": "Usa selezione ad albero",
"sql_debug": "Debug SQL",
"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?",
"food_inherit_info": "Campi di alimenti che devono essere ereditati per impostazione predefinita.",
"enable_expert": "Abilita modalità esperto",

View File

@ -16,7 +16,6 @@
"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?",
"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",
"confirm_delete": "Er du sikker på at du vil slette dette {object}?",
"import_running": "Importering pågår. Vennligst vent!",

View File

@ -464,7 +464,6 @@
"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?",
"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.",
"Combine_All_Steps": "Voeg alle stappen samen tot een veld.",
"Plural": "Meervoud",

View File

@ -420,7 +420,6 @@
"Users": "Użytkownicy",
"Invites": "Zaprasza",
"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",
"Message": "Wiadomość",
"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.",
"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?",
"facet_count_info": "Mostrar quantidade de receitas nos filtros de busca.",
"Plural": "",
"plural_short": "",
"Use_Plural_Unit_Always": "",

View File

@ -387,7 +387,6 @@
"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?",
"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_short": "",
"Use_Plural_Unit_Always": "",

View File

@ -343,7 +343,6 @@
"Undefined": "Nedefinit",
"Select": "Selectare",
"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",
"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.",

View File

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

View File

@ -302,7 +302,6 @@
"Description_Replace": "Zamenjaj Opis",
"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?",
"facet_count_info": "Prikaži število receptov v iskalnih filtrih.",
"per_serving": "na porcijo",
"Ingredient Editor": "Urejevalnik Sestavin",
"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.",
"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?",
"facet_count_info": "Visa recept antal på sökfilter.",
"food_inherit_info": "Fält på mat som ska ärvas som standard.",
"Auto_Sort": "Automatisk Sortering",
"Day": "Dag",

View File

@ -16,7 +16,6 @@
"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?",
"food_inherit_info": "",
"facet_count_info": "",
"step_time_minutes": "Dakika olarak adım süresi",
"confirm_delete": "",
"import_running": "",

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff