diff --git a/cookbook/models.py b/cookbook/models.py index 91d51cd38..b704096bd 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -520,7 +520,9 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): obj = self.__class__.objects.get(id=self.id) if parent := obj.get_parent(): # child should inherit what the parent defines it should inherit - obj.inherit_fields.set(list(parent.child_inherit_fields.all() or parent.inherit_fields.all())) + fields = list(parent.child_inherit_fields.all() or parent.inherit_fields.all()) + if len(fields) > 0: + obj.inherit_fields.set(fields) obj.save() @staticmethod @@ -1028,6 +1030,7 @@ class ImportLog(models.Model, PermissionModelMixin): def __str__(self): return f"{self.created_at}:{self.type}" + class ExportLog(models.Model, PermissionModelMixin): type = models.CharField(max_length=32) running = models.BooleanField(default=True) diff --git a/cookbook/tests/api/test_api_shopping_recipe.py b/cookbook/tests/api/test_api_shopping_recipe.py index 0fc85d86a..9732250d5 100644 --- a/cookbook/tests/api/test_api_shopping_recipe.py +++ b/cookbook/tests/api/test_api_shopping_recipe.py @@ -45,19 +45,10 @@ def recipe(request, space_1, u1_s1): params = request.param # request.param is a magic variable except AttributeError: params = {} - # step_recipe = params.get('steps__count', 1) - # steps__recipe_count = params.get('steps__recipe_count', 0) - # steps__food_recipe_count = params.get('steps__food_recipe_count', {}) params['created_by'] = params.get('created_by', auth.get_user(u1_s1)) params['space'] = space_1 return RecipeFactory(**params) - # return RecipeFactory.create( - # steps__recipe_count=steps__recipe_count, - # steps__food_recipe_count=steps__food_recipe_count, - # created_by=created_by, - # space=space_1, - # ) @pytest.mark.parametrize("arg", [ diff --git a/cookbook/tests/factories/__init__.py b/cookbook/tests/factories/__init__.py index cd885c35c..a35506c06 100644 --- a/cookbook/tests/factories/__init__.py +++ b/cookbook/tests/factories/__init__.py @@ -129,39 +129,33 @@ class FoodFactory(factory.django.DjangoModelFactory): django_get_or_create = ('name', 'space',) -@register -class RecipeBookEntryFactory(factory.django.DjangoModelFactory): - """RecipeBookEntry factory.""" - book = None - recipe = None - - class Meta: - model = 'cookbook.RecipeBookEntry' - - @register class RecipeBookFactory(factory.django.DjangoModelFactory): """RecipeBook factory.""" - name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=2, variable_nb_words=False)) - # icon = models.CharField(max_length=16, blank=True, null=True) + name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3, variable_nb_words=False)) description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)) + icon = None + # shared = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space')) + filter = None space = factory.SubFactory(SpaceFactory) - recipe = None # used to add to RecipeBookEntry - recipe_book_entry = factory.RelatedFactory( - RecipeBookEntryFactory, - factory_related_name='book', - recipe=factory.LazyAttribute(lambda x: x.recipe), - ) - - class Params: - recipe = None class Meta: model = 'cookbook.RecipeBook' django_get_or_create = ('name', 'space',) +@register +class RecipeBookEntryFactory(factory.django.DjangoModelFactory): + """RecipeBookEntry factory.""" + book = factory.SubFactory(RecipeBookFactory, space=factory.SelfAttribute('..recipe.space')) + recipe = None + + class Meta: + model = 'cookbook.RecipeBookEntry' + django_get_or_create = ('book', 'recipe',) + + @register class UnitFactory(factory.django.DjangoModelFactory): """Unit factory.""" diff --git a/cookbook/tests/other/test_recipe_full_text_search.py b/cookbook/tests/other/test_recipe_full_text_search.py index 9416ea538..d1893c7f2 100644 --- a/cookbook/tests/other/test_recipe_full_text_search.py +++ b/cookbook/tests/other/test_recipe_full_text_search.py @@ -1,15 +1,17 @@ +import itertools +import json + import pytest from django.contrib import auth from django.urls import reverse from django_scopes import scope, scopes_disabled -from cookbook.models import Food, Recipe -from cookbook.tests.factories import FoodFactory, RecipeBookEntryFactory, RecipeFactory +from cookbook.models import Food, Recipe, SearchFields +from cookbook.tests.factories import (FoodFactory, IngredientFactory, KeywordFactory, + RecipeBookEntryFactory, RecipeFactory, UnitFactory) -# TODO food/keyword/book test or, and, or_not, and_not search # TODO recipe name/description/instructions/keyword/book/food test search with icontains, istarts with/ full text(?? probably when word changes based on conjugation??), trigram, unaccent -# TODO fuzzy lookup on units, keywords, food when not configured in main search settings # TODO test combining any/all of the above # TODO search rating as user or when another user rated @@ -21,6 +23,7 @@ from cookbook.tests.factories import FoodFactory, RecipeBookEntryFactory, Recipe # TODO test loading custom filter with overrided params # TODO makenow with above filters # TODO test search for number of times cooked (self vs others) +# TODO test including children LIST_URL = 'api:recipe-list' @@ -34,6 +37,24 @@ def unaccent(): return "aeiou" +@pytest.fixture +def user1(request, space_1, u1_s1): + user = auth.get_user(u1_s1) + params = {x[0]: x[1] for x in request.param} + if params.get('fuzzy_lookups', False): + user.searchpreference.lookup = True + if params.get('fuzzy_search', False): + user.searchpreference.trigram.set(SearchFields.objects.all()) + if params.get('unaccent', False): + user.searchpreference.unaccent.set(SearchFields.objects.all()) + result = 2 + else: + result = 1 + + user.userpreference.save() + return (u1_s1, result, params) + + @pytest.fixture def recipes(space_1): return RecipeFactory.create_batch(10, space=space_1) @@ -44,37 +65,123 @@ def found_recipe(request, space_1, accent, unaccent): recipe1 = RecipeFactory.create(space=space_1) recipe2 = RecipeFactory.create(space=space_1) recipe3 = RecipeFactory.create(space=space_1) - related = request.param.get('related', None) - # name = request.getfixturevalue(request.param.get('name', "unaccent")) - if related == 'food': - obj1 = Food.objects.filter(ingredient__step__recipe=recipe.id).first() - obj2 = Food.objects.filter(ingredient__step__recipe=recipe.id).last() - obj1.name = unaccent - obj1.save() - obj2.name = accent - obj2.save() - elif related == 'keyword': - obj1 = recipe.keywords.first() - obj2 = recipe.keywords.last() - obj1.name = unaccent - obj1.save() - obj2.name = accent - obj2.save() - elif related == 'book': - obj1 = RecipeBookEntryFactory.create(recipe=recipe) + if request.param.get('food', None): + obj1 = FoodFactory.create(name=unaccent, space=space_1) + obj2 = FoodFactory.create(name=accent, space=space_1) + + recipe1.steps.first().ingredients.add(IngredientFactory.create(food=obj1)) + recipe2.steps.first().ingredients.add(IngredientFactory.create(food=obj2)) + recipe3.steps.first().ingredients.add(IngredientFactory.create(food=obj1), IngredientFactory.create(food=obj2)) + if request.param.get('keyword', None): + obj1 = KeywordFactory.create(name=unaccent, space=space_1) + obj2 = KeywordFactory.create(name=accent, space=space_1) + recipe1.keywords.add(obj1) + recipe2.keywords.add(obj2) + recipe3.keywords.add(obj1, obj2) + if request.param.get('book', None): + obj1 = RecipeBookEntryFactory.create(recipe=recipe1).book + obj2 = RecipeBookEntryFactory.create(recipe=recipe2).book + RecipeBookEntryFactory.create(recipe=recipe3, book=obj1) + RecipeBookEntryFactory.create(recipe=recipe3, book=obj2) + if request.param.get('unit', None): + obj1 = UnitFactory.create(name=unaccent, space=space_1) + obj2 = UnitFactory.create(name=accent, space=space_1) + + recipe1.steps.first().ingredients.add(IngredientFactory.create(unit=obj1)) + recipe2.steps.first().ingredients.add(IngredientFactory.create(unit=obj2)) + recipe3.steps.first().ingredients.add(IngredientFactory.create(unit=obj1), IngredientFactory.create(unit=obj2)) return (recipe1, recipe2, recipe3, obj1, obj2) @pytest.mark.parametrize("found_recipe, param_type", [ - ({'related': 'food'}, 'foods'), - ({'related': 'keyword'}, 'keywords'), - ({'related': 'book'}, 'books'), + ({'food': True}, 'foods'), + ({'keyword': True}, 'keywords'), + ({'book': True}, 'books'), ], indirect=['found_recipe']) -@pytest.mark.parametrize('operator', ['_or', '_and', ]) -def test_search_lists(found_recipe, param_type, operator, recipes, u1_s1, space_1): +@pytest.mark.parametrize('operator', [('_or', 3, 0), ('_and', 1, 2), ]) +def test_search_or_and_not(found_recipe, param_type, operator, recipes, user1, space_1): with scope(space=space_1): - assert 1 == 2 - pass - assert u1_s1.get(reverse(LIST_URL) + f'?parm={share.uuid}') + param1 = f"{param_type}{operator[0]}={found_recipe[3].id}" + param2 = f"{param_type}{operator[0]}={found_recipe[4].id}" + param1_not = f"{param_type}{operator[0]}_not={found_recipe[3].id}" + param2_not = f"{param_type}{operator[0]}_not={found_recipe[4].id}" + + # testing include searches + r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param1}').content) + assert r['count'] == 2 + assert found_recipe[0].id in [x['id'] for x in r['results']] + assert found_recipe[2].id in [x['id'] for x in r['results']] + + r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param2}').content) + assert r['count'] == 2 + assert found_recipe[1].id in [x['id'] for x in r['results']] + assert found_recipe[2].id in [x['id'] for x in r['results']] + + r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param1}&{param2}').content) + assert r['count'] == operator[1] + assert found_recipe[2].id in [x['id'] for x in r['results']] + + # testing _not searches + r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param1_not}').content) + assert r['count'] == 11 + assert found_recipe[0].id not in [x['id'] for x in r['results']] + assert found_recipe[2].id not in [x['id'] for x in r['results']] + + r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param2_not}').content) + assert r['count'] == 11 + assert found_recipe[1].id not in [x['id'] for x in r['results']] + assert found_recipe[2].id not in [x['id'] for x in r['results']] + + r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param1_not}&{param2_not}').content) + assert r['count'] == 10 + operator[2] + assert found_recipe[2].id not in [x['id'] for x in r['results']] + + +@pytest.mark.parametrize("found_recipe", [ + ({'unit': True}), +], indirect=['found_recipe']) +def test_search_units(found_recipe, recipes, u1_s1, space_1): + with scope(space=space_1): + param1 = f"units={found_recipe[3].id}" + param2 = f"units={found_recipe[4].id}" + + # testing include searches + r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param1}').content) + assert r['count'] == 2 + assert found_recipe[0].id in [x['id'] for x in r['results']] + assert found_recipe[2].id in [x['id'] for x in r['results']] + + r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param2}').content) + assert r['count'] == 2 + assert found_recipe[1].id in [x['id'] for x in r['results']] + assert found_recipe[2].id in [x['id'] for x in r['results']] + + r = json.loads(u1_s1.get(reverse(LIST_URL) + f'?{param1}&{param2}').content) + assert r['count'] == 3 + assert found_recipe[2].id in [x['id'] for x in r['results']] + + +@pytest.mark.parametrize("user1", itertools.product( + [('fuzzy_lookups', True), ('fuzzy_lookups', False)], + [('fuzzy_search', True), ('fuzzy_search', False)], + [('unaccent', True), ('unaccent', False)] +), indirect=['user1']) +@pytest.mark.parametrize("found_recipe, param_type", [ + ({'unit': True}, 'unit'), + ({'keyword': True}, 'keyword'), + ({'food': True}, 'food'), +], indirect=['found_recipe']) +def test_fuzzy_lookup(found_recipe, recipes, param_type, user1, space_1): + with scope(space=space_1): + list_url = f'api:{param_type}-list' + param1 = "query=aeiou" + param2 = "query=aoieu" + + # test fuzzy off - also need search settings on/off + r = json.loads(user1[0].get(reverse(list_url) + f'?{param1}&limit=2').content) + assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[3].id, found_recipe[4].id]]) == user1[1] + + r = json.loads(user1[0].get(reverse(list_url) + f'?{param2}').content) + assert len([x['id'] for x in r['results'] if x['id'] in [found_recipe[3].id, found_recipe[4].id]]) == user1[1] diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 773ec44ae..f426994fa 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -141,17 +141,15 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin): def get_queryset(self): self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc()) query = self.request.query_params.get('query', None) - fuzzy = self.request.user.searchpreference.lookup + fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.trigram.values_list('field', flat=True)]) if query is not None and query not in ["''", '']: if fuzzy: - self.queryset = ( - self.queryset - .annotate(starts=Case(When(name__istartswith=query, then=(Value(.3, output_field=IntegerField()))), default=Value(0))) - .annotate(trigram=TrigramSimilarity('name', query)) - .annotate(sort=F('starts')+F('trigram')) - .order_by('-sort') - ) + if 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)) + self.queryset = self.queryset.order_by('-trigram') else: # TODO have this check unaccent search settings or other search preferences? filter = Q(name__icontains=query) diff --git a/recipes/settings.py b/recipes/settings.py index 3ea147a16..94d44eebb 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -188,7 +188,14 @@ if LDAP_AUTH: AUTH_LDAP_ALWAYS_UPDATE_USER = bool(int(os.getenv('AUTH_LDAP_ALWAYS_UPDATE_USER', True))) AUTH_LDAP_CACHE_TIMEOUT = int(os.getenv('AUTH_LDAP_CACHE_TIMEOUT', 3600)) if 'AUTH_LDAP_TLS_CACERTFILE' in os.environ: - AUTH_LDAP_GLOBAL_OPTIONS = { ldap.OPT_X_TLS_CACERTFILE: os.getenv('AUTH_LDAP_TLS_CACERTFILE') } + AUTH_LDAP_GLOBAL_OPTIONS = {ldap.OPT_X_TLS_CACERTFILE: os.getenv('AUTH_LDAP_TLS_CACERTFILE')} + if DEBUG: + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": {"console": {"class": "logging.StreamHandler"}}, + "loggers": {"django_auth_ldap": {"level": "DEBUG", "handlers": ["console"]}}, + } AUTHENTICATION_BACKENDS += [ 'django.contrib.auth.backends.ModelBackend', @@ -428,4 +435,3 @@ EMAIL_USE_TLS = bool(int(os.getenv('EMAIL_USE_TLS', False))) EMAIL_USE_SSL = bool(int(os.getenv('EMAIL_USE_SSL', False))) DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost') ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv('ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix -