more recipe search tests
This commit is contained in:
parent
c2961eede4
commit
baa2aa51da
@ -520,7 +520,9 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
|||||||
obj = self.__class__.objects.get(id=self.id)
|
obj = self.__class__.objects.get(id=self.id)
|
||||||
if parent := obj.get_parent():
|
if parent := obj.get_parent():
|
||||||
# child should inherit what the parent defines it should inherit
|
# 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()
|
obj.save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -1028,6 +1030,7 @@ class ImportLog(models.Model, PermissionModelMixin):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.created_at}:{self.type}"
|
return f"{self.created_at}:{self.type}"
|
||||||
|
|
||||||
|
|
||||||
class ExportLog(models.Model, PermissionModelMixin):
|
class ExportLog(models.Model, PermissionModelMixin):
|
||||||
type = models.CharField(max_length=32)
|
type = models.CharField(max_length=32)
|
||||||
running = models.BooleanField(default=True)
|
running = models.BooleanField(default=True)
|
||||||
|
@ -45,19 +45,10 @@ def recipe(request, space_1, u1_s1):
|
|||||||
params = request.param # request.param is a magic variable
|
params = request.param # request.param is a magic variable
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
params = {}
|
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['created_by'] = params.get('created_by', auth.get_user(u1_s1))
|
||||||
params['space'] = space_1
|
params['space'] = space_1
|
||||||
return RecipeFactory(**params)
|
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", [
|
@pytest.mark.parametrize("arg", [
|
||||||
|
@ -129,39 +129,33 @@ class FoodFactory(factory.django.DjangoModelFactory):
|
|||||||
django_get_or_create = ('name', 'space',)
|
django_get_or_create = ('name', 'space',)
|
||||||
|
|
||||||
|
|
||||||
@register
|
|
||||||
class RecipeBookEntryFactory(factory.django.DjangoModelFactory):
|
|
||||||
"""RecipeBookEntry factory."""
|
|
||||||
book = None
|
|
||||||
recipe = None
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = 'cookbook.RecipeBookEntry'
|
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
class RecipeBookFactory(factory.django.DjangoModelFactory):
|
class RecipeBookFactory(factory.django.DjangoModelFactory):
|
||||||
"""RecipeBook factory."""
|
"""RecipeBook factory."""
|
||||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=2, variable_nb_words=False))
|
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3, variable_nb_words=False))
|
||||||
# icon = models.CharField(max_length=16, blank=True, null=True)
|
|
||||||
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||||
|
icon = None
|
||||||
|
# shared = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||||
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
|
||||||
|
filter = None
|
||||||
space = factory.SubFactory(SpaceFactory)
|
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:
|
class Meta:
|
||||||
model = 'cookbook.RecipeBook'
|
model = 'cookbook.RecipeBook'
|
||||||
django_get_or_create = ('name', 'space',)
|
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
|
@register
|
||||||
class UnitFactory(factory.django.DjangoModelFactory):
|
class UnitFactory(factory.django.DjangoModelFactory):
|
||||||
"""Unit factory."""
|
"""Unit factory."""
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib import auth
|
from django.contrib import auth
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_scopes import scope, scopes_disabled
|
from django_scopes import scope, scopes_disabled
|
||||||
|
|
||||||
from cookbook.models import Food, Recipe
|
from cookbook.models import Food, Recipe, SearchFields
|
||||||
from cookbook.tests.factories import FoodFactory, RecipeBookEntryFactory, RecipeFactory
|
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 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 test combining any/all of the above
|
||||||
# TODO search rating as user or when another user rated
|
# 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 test loading custom filter with overrided params
|
||||||
# TODO makenow with above filters
|
# TODO makenow with above filters
|
||||||
# TODO test search for number of times cooked (self vs others)
|
# TODO test search for number of times cooked (self vs others)
|
||||||
|
# TODO test including children
|
||||||
LIST_URL = 'api:recipe-list'
|
LIST_URL = 'api:recipe-list'
|
||||||
|
|
||||||
|
|
||||||
@ -34,6 +37,24 @@ def unaccent():
|
|||||||
return "aeiou"
|
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
|
@pytest.fixture
|
||||||
def recipes(space_1):
|
def recipes(space_1):
|
||||||
return RecipeFactory.create_batch(10, space=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)
|
recipe1 = RecipeFactory.create(space=space_1)
|
||||||
recipe2 = RecipeFactory.create(space=space_1)
|
recipe2 = RecipeFactory.create(space=space_1)
|
||||||
recipe3 = 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':
|
if request.param.get('food', None):
|
||||||
obj1 = Food.objects.filter(ingredient__step__recipe=recipe.id).first()
|
obj1 = FoodFactory.create(name=unaccent, space=space_1)
|
||||||
obj2 = Food.objects.filter(ingredient__step__recipe=recipe.id).last()
|
obj2 = FoodFactory.create(name=accent, space=space_1)
|
||||||
obj1.name = unaccent
|
|
||||||
obj1.save()
|
recipe1.steps.first().ingredients.add(IngredientFactory.create(food=obj1))
|
||||||
obj2.name = accent
|
recipe2.steps.first().ingredients.add(IngredientFactory.create(food=obj2))
|
||||||
obj2.save()
|
recipe3.steps.first().ingredients.add(IngredientFactory.create(food=obj1), IngredientFactory.create(food=obj2))
|
||||||
elif related == 'keyword':
|
if request.param.get('keyword', None):
|
||||||
obj1 = recipe.keywords.first()
|
obj1 = KeywordFactory.create(name=unaccent, space=space_1)
|
||||||
obj2 = recipe.keywords.last()
|
obj2 = KeywordFactory.create(name=accent, space=space_1)
|
||||||
obj1.name = unaccent
|
recipe1.keywords.add(obj1)
|
||||||
obj1.save()
|
recipe2.keywords.add(obj2)
|
||||||
obj2.name = accent
|
recipe3.keywords.add(obj1, obj2)
|
||||||
obj2.save()
|
if request.param.get('book', None):
|
||||||
elif related == 'book':
|
obj1 = RecipeBookEntryFactory.create(recipe=recipe1).book
|
||||||
obj1 = RecipeBookEntryFactory.create(recipe=recipe)
|
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)
|
return (recipe1, recipe2, recipe3, obj1, obj2)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("found_recipe, param_type", [
|
@pytest.mark.parametrize("found_recipe, param_type", [
|
||||||
({'related': 'food'}, 'foods'),
|
({'food': True}, 'foods'),
|
||||||
({'related': 'keyword'}, 'keywords'),
|
({'keyword': True}, 'keywords'),
|
||||||
({'related': 'book'}, 'books'),
|
({'book': True}, 'books'),
|
||||||
], indirect=['found_recipe'])
|
], indirect=['found_recipe'])
|
||||||
@pytest.mark.parametrize('operator', ['_or', '_and', ])
|
@pytest.mark.parametrize('operator', [('_or', 3, 0), ('_and', 1, 2), ])
|
||||||
def test_search_lists(found_recipe, param_type, operator, recipes, u1_s1, space_1):
|
def test_search_or_and_not(found_recipe, param_type, operator, recipes, user1, space_1):
|
||||||
with scope(space=space_1):
|
with scope(space=space_1):
|
||||||
assert 1 == 2
|
param1 = f"{param_type}{operator[0]}={found_recipe[3].id}"
|
||||||
pass
|
param2 = f"{param_type}{operator[0]}={found_recipe[4].id}"
|
||||||
assert u1_s1.get(reverse(LIST_URL) + f'?parm={share.uuid}')
|
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]
|
||||||
|
@ -141,17 +141,15 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
|
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
|
||||||
query = self.request.query_params.get('query', None)
|
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 query is not None and query not in ["''", '']:
|
||||||
if fuzzy:
|
if fuzzy:
|
||||||
self.queryset = (
|
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 = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
|
||||||
.annotate(starts=Case(When(name__istartswith=query, then=(Value(.3, output_field=IntegerField()))), default=Value(0)))
|
else:
|
||||||
.annotate(trigram=TrigramSimilarity('name', query))
|
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query))
|
||||||
.annotate(sort=F('starts')+F('trigram'))
|
self.queryset = self.queryset.order_by('-trigram')
|
||||||
.order_by('-sort')
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# TODO have this check unaccent search settings or other search preferences?
|
# TODO have this check unaccent search settings or other search preferences?
|
||||||
filter = Q(name__icontains=query)
|
filter = Q(name__icontains=query)
|
||||||
|
@ -188,7 +188,14 @@ if LDAP_AUTH:
|
|||||||
AUTH_LDAP_ALWAYS_UPDATE_USER = bool(int(os.getenv('AUTH_LDAP_ALWAYS_UPDATE_USER', True)))
|
AUTH_LDAP_ALWAYS_UPDATE_USER = bool(int(os.getenv('AUTH_LDAP_ALWAYS_UPDATE_USER', True)))
|
||||||
AUTH_LDAP_CACHE_TIMEOUT = int(os.getenv('AUTH_LDAP_CACHE_TIMEOUT', 3600))
|
AUTH_LDAP_CACHE_TIMEOUT = int(os.getenv('AUTH_LDAP_CACHE_TIMEOUT', 3600))
|
||||||
if 'AUTH_LDAP_TLS_CACERTFILE' in os.environ:
|
if 'AUTH_LDAP_TLS_CACERTFILE' in os.environ:
|
||||||
AUTH_LDAP_GLOBAL_OPTIONS = { ldap.OPT_X_TLS_CACERTFILE: os.getenv('AUTH_LDAP_TLS_CACERTFILE') }
|
AUTH_LDAP_GLOBAL_OPTIONS = {ldap.OPT_X_TLS_CACERTFILE: os.getenv('AUTH_LDAP_TLS_CACERTFILE')}
|
||||||
|
if DEBUG:
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {"console": {"class": "logging.StreamHandler"}},
|
||||||
|
"loggers": {"django_auth_ldap": {"level": "DEBUG", "handlers": ["console"]}},
|
||||||
|
}
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS += [
|
AUTHENTICATION_BACKENDS += [
|
||||||
'django.contrib.auth.backends.ModelBackend',
|
'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)))
|
EMAIL_USE_SSL = bool(int(os.getenv('EMAIL_USE_SSL', False)))
|
||||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
|
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
|
||||||
ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv('ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix
|
ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv('ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user