mirror of
https://github.com/jazzband/django-constance.git
synced 2026-04-12 11:11:06 +00:00
feat: Add generated field - derived_value
This commit is contained in:
parent
22bdb011db
commit
09cc04e10a
11 changed files with 113 additions and 14 deletions
|
|
@ -104,6 +104,8 @@ class ConstanceAdmin(admin.ModelAdmin):
|
|||
django_version=get_version(),
|
||||
)
|
||||
for name, options in settings.CONFIG.items():
|
||||
if len(options) == 3 and options[2] == 'derived_value':
|
||||
continue
|
||||
context['config_values'].append(self.get_config_value(name, options, form, initial))
|
||||
|
||||
if settings.CONFIG_FIELDSETS:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
from . import settings
|
||||
from . import utils
|
||||
import importlib
|
||||
|
||||
|
||||
def get_function_from_string(path):
|
||||
module_path, function_name = path.rsplit('.', 1)
|
||||
module = importlib.import_module(module_path)
|
||||
return getattr(module, function_name)
|
||||
|
||||
class Config:
|
||||
"""The global config wrapper that handles the backend."""
|
||||
|
||||
|
|
@ -10,11 +16,20 @@ class Config:
|
|||
|
||||
def __getattr__(self, key):
|
||||
try:
|
||||
if len(settings.CONFIG[key]) not in (2, 3):
|
||||
config_value = settings.CONFIG[key]
|
||||
if len(config_value) not in (2, 3):
|
||||
raise AttributeError(key)
|
||||
default = settings.CONFIG[key][0]
|
||||
default = config_value[0]
|
||||
derived = len(config_value) == 3 and config_value[2] == 'derived_value'
|
||||
except KeyError as e:
|
||||
raise AttributeError(key) from e
|
||||
|
||||
if derived:
|
||||
if isinstance(default, str):
|
||||
default = get_function_from_string(default)
|
||||
assert callable(default), "derived_value must have a callable default value"
|
||||
return default(self)
|
||||
|
||||
result = self._backend.get(key)
|
||||
if result is None:
|
||||
result = default
|
||||
|
|
@ -25,6 +40,8 @@ class Config:
|
|||
def __setattr__(self, key, value):
|
||||
if key not in settings.CONFIG:
|
||||
raise AttributeError(key)
|
||||
if len(settings.CONFIG[key]) == 3 and settings.CONFIG[key][2] == 'derived_value':
|
||||
raise AttributeError(key)
|
||||
self._backend.set(key, value)
|
||||
|
||||
def __dir__(self):
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ def check_fieldsets(*args, **kwargs) -> list[CheckMessage]:
|
|||
errors = []
|
||||
|
||||
if hasattr(settings, 'CONFIG_FIELDSETS') and settings.CONFIG_FIELDSETS:
|
||||
missing_keys, extra_keys = get_inconsistent_fieldnames()
|
||||
missing_keys, extra_keys, derived_value_in_fieldset_keys = get_inconsistent_fieldnames()
|
||||
if missing_keys:
|
||||
check = checks.Warning(
|
||||
_('CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in CONSTANCE_CONFIG.'),
|
||||
|
|
@ -32,14 +32,24 @@ def check_fieldsets(*args, **kwargs) -> list[CheckMessage]:
|
|||
id='constance.E002',
|
||||
)
|
||||
errors.append(check)
|
||||
if derived_value_in_fieldset_keys:
|
||||
check = checks.Warning(
|
||||
_('CONSTANCE_CONFIG_FIELDSETS contains field(s) that are derived_value type in CONFIG.'),
|
||||
hint=', '.join(sorted(derived_value_in_fieldset_keys)),
|
||||
obj='settings.CONSTANCE_CONFIG',
|
||||
id='constance.E003',
|
||||
)
|
||||
errors.append(check)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def get_inconsistent_fieldnames() -> tuple[set, set]:
|
||||
"""
|
||||
Returns a pair of values:
|
||||
1) set of keys from settings.CONFIG that are not accounted for in settings.CONFIG_FIELDSETS
|
||||
Returns three list of values:
|
||||
1) set of keys from settings.CONFIG that are not accounted for in settings.CONFIG_FIELDSETS except the derived_value type
|
||||
2) set of keys from settings.CONFIG_FIELDSETS that are not present in settings.CONFIG
|
||||
3) set of keys from settings.CONFIG_FIELDSETS that are derived_value type
|
||||
If there are no fieldnames in settings.CONFIG_FIELDSETS, returns an empty set.
|
||||
"""
|
||||
from . import settings
|
||||
|
|
@ -59,6 +69,13 @@ def get_inconsistent_fieldnames() -> tuple[set, set]:
|
|||
if not unique_field_names:
|
||||
return unique_field_names, unique_field_names
|
||||
config_keys = set(settings.CONFIG.keys())
|
||||
missing_keys = config_keys - unique_field_names
|
||||
config_derived_value_keys = {
|
||||
key for key, value in settings.CONFIG.items() if len(value) == 3 and value[2] == 'derived_value'
|
||||
}
|
||||
config_without_derived_value_keys = config_keys - config_derived_value_keys
|
||||
|
||||
missing_keys = config_without_derived_value_keys - unique_field_names
|
||||
extra_keys = unique_field_names - config_keys
|
||||
return missing_keys, extra_keys
|
||||
derived_value_in_fieldset_keys = [key for key in unique_field_names if key in config_derived_value_keys]
|
||||
|
||||
return missing_keys, extra_keys, derived_value_in_fieldset_keys
|
||||
|
|
|
|||
|
|
@ -91,6 +91,9 @@ class ConstanceForm(forms.Form):
|
|||
default = options[0]
|
||||
if len(options) == 3:
|
||||
config_type = options[2]
|
||||
if config_type == 'derived_value':
|
||||
print(f"Warning: {name} is a derived value and will not be displayed in the form.")
|
||||
continue
|
||||
if config_type not in settings.ADDITIONAL_FIELDS and not isinstance(default, config_type):
|
||||
raise ImproperlyConfigured(
|
||||
_(
|
||||
|
|
@ -128,7 +131,10 @@ class ConstanceForm(forms.Form):
|
|||
file = self.cleaned_data[file_field]
|
||||
self.cleaned_data[file_field] = default_storage.save(join(settings.FILE_ROOT, file.name), file)
|
||||
|
||||
for name in settings.CONFIG:
|
||||
for name, options in settings.CONFIG.items():
|
||||
if len(options) == 3 and options[2] == 'derived_value':
|
||||
continue
|
||||
|
||||
current = getattr(config, name)
|
||||
new = self.cleaned_data[name]
|
||||
|
||||
|
|
@ -163,8 +169,8 @@ class ConstanceForm(forms.Form):
|
|||
if not settings.CONFIG_FIELDSETS:
|
||||
return cleaned_data
|
||||
|
||||
missing_keys, extra_keys = get_inconsistent_fieldnames()
|
||||
if missing_keys or extra_keys:
|
||||
missing_keys, extra_keys, derived_value_in_fieldset_keys = get_inconsistent_fieldnames()
|
||||
if missing_keys or extra_keys or derived_value_in_fieldset_keys:
|
||||
raise forms.ValidationError(
|
||||
_('CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in CONSTANCE_CONFIG.')
|
||||
)
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ The supported types are:
|
|||
* ``time``
|
||||
* ``list``
|
||||
* ``dict``
|
||||
* ``derived_value``
|
||||
|
||||
For example, to force a value to be handled as a string:
|
||||
|
||||
|
|
@ -165,6 +166,40 @@ Images and files are uploaded to ``MEDIA_ROOT`` by default. You can specify a su
|
|||
|
||||
This will result in files being placed in ``media/constance`` within your ``BASE_DIR``. You can use deeper nesting in this setting (e.g. ``constance/images``) but other relative path components (e.g. ``../``) will be rejected.
|
||||
|
||||
|
||||
Derived value fields
|
||||
-------------
|
||||
Derived value fields are fields that are calculated based on other fields. They are read-only and are not visible/editable in the admin.
|
||||
|
||||
To define a derived value field, use the ``derived_value`` type in the ``CONSTANCE_CONFIG`` tuple, and give a callable that returns the value as the default.
|
||||
The callable will be called with the config object as the only argument, which can be used to access other config values like ``constance.config``.
|
||||
You can also use the ``config`` object to access the current value of the derived field, but be careful to avoid infinite loops.
|
||||
|
||||
Thoese callables are valid default parameters for the derived value fields:
|
||||
* A function object
|
||||
* A string containing the path to a function object
|
||||
* A labmda function
|
||||
|
||||
.. code-block:: python
|
||||
def get_answer(config):
|
||||
return 'The answer is %s' % config.THE_ANSWER
|
||||
|
||||
CONSTANCE_CONFIG = {
|
||||
'THE_ANSWER': (42, 'Answer to the Ultimate Question of Life, '
|
||||
'The Universe, and Everything'),
|
||||
'THE_QUESTION': ('What is the answer to the ultimate question of life, the universe, and everything?', 'The question'),
|
||||
'THE_QUESTION_ANSWERED_LAMBDA': (lambda config: 'The answer is %s' % config.THE_ANSWER, 'The question answered', 'derived_value'),
|
||||
'THE_QUESTION_ANSWERED_FUNCTION': (get_answer, 'The question answered', 'derived_value'),
|
||||
'THE_QUESTION_ANSWERED_FUNCTION_PATH': ('path.to.get_answer', 'The question answered', 'derived_value'),
|
||||
}
|
||||
|
||||
This will result in the following values:
|
||||
* THE_QUESTION_ANSWERED_LAMBDA: 'The answer is 42'
|
||||
* THE_QUESTION_ANSWERED_FUNCTION: 'The answer is 42'
|
||||
* THE_QUESTION_ANSWERED_FUNCTION_PATH: 'The answer is 42'
|
||||
|
||||
.. note:: The derived value fields are not editable in the admin, and the value is recalculated every time the config object is accessed. So that the derived value fields should never present in CONSTANCE_CONFIG_FIELDSETS.
|
||||
|
||||
Ordered Fields in Django Admin
|
||||
------------------------------
|
||||
|
||||
|
|
@ -199,7 +234,7 @@ You can define fieldsets to group settings together:
|
|||
'Theme Options': ('THEME',),
|
||||
}
|
||||
|
||||
.. note:: CONSTANCE_CONFIG_FIELDSETS must contain all fields from CONSTANCE_CONFIG.
|
||||
.. note:: CONSTANCE_CONFIG_FIELDSETS must contain all fields from CONSTANCE_CONFIG, except for derived value fields.
|
||||
|
||||
.. image:: _static/screenshot3.png
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ CONSTANCE_CONFIG = {
|
|||
'Logo image file',
|
||||
'image_field',
|
||||
),
|
||||
'BANNER_WITH_OWNER': ('cheeseshop.utils.get_banner_with_owner', 'banner with owner name', 'derived_value'),
|
||||
}
|
||||
|
||||
CONSTANCE_CONFIG_FIELDSETS = {
|
||||
|
|
|
|||
2
example/cheeseshop/utils.py
Normal file
2
example/cheeseshop/utils.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
def get_banner_with_owner(config):
|
||||
return f'{config.BANNER} {config.OWNER}'
|
||||
|
|
@ -57,6 +57,9 @@ CONSTANCE_ADDITIONAL_FIELDS = {
|
|||
|
||||
USE_TZ = True
|
||||
|
||||
def get_derived_value_func(config):
|
||||
return f'{config.STRING_VALUE} to {config.EMAIL_VALUE}'
|
||||
|
||||
CONSTANCE_CONFIG = {
|
||||
'INT_VALUE': (1, 'some int'),
|
||||
'BOOL_VALUE': (True, 'true or false'),
|
||||
|
|
@ -83,6 +86,9 @@ CONSTANCE_CONFIG = {
|
|||
'A JSON object',
|
||||
'json',
|
||||
),
|
||||
'DERIVED_VALUE_FUNC': (get_derived_value_func, 'Derived value from a function', 'derived_value'),
|
||||
'DERIVED_VALUE_FUNC_STR': ('tests.settings.get_derived_value_func', 'Derived value from a function str', 'derived_value'),
|
||||
'DERIVED_VALUE_LAMBDA': (lambda config: f'{config.STRING_VALUE} to {config.EMAIL_VALUE}', 'Derived value from a lambda expression', 'derived_value'),
|
||||
}
|
||||
|
||||
DEBUG = True
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ class StorageTestsMixin:
|
|||
self.assertEqual(self.config.EMAIL_VALUE, 'foo@bar.com')
|
||||
self.assertEqual(self.config.LIST_VALUE, [1, date(2020, 2, 2)])
|
||||
self.assertEqual(self.config.JSON_VALUE, {'key': 'OK'})
|
||||
self.assertEqual(self.config.DERIVED_VALUE_FUNC, 'Beware the weeping angel to foo@bar.com')
|
||||
self.assertEqual(self.config.DERIVED_VALUE_FUNC_STR, 'Beware the weeping angel to foo@bar.com')
|
||||
self.assertEqual(self.config.DERIVED_VALUE_LAMBDA, 'Beware the weeping angel to foo@bar.com')
|
||||
|
||||
def test_nonexistent(self):
|
||||
self.assertRaises(AttributeError, getattr, self.config, 'NON_EXISTENT')
|
||||
|
|
@ -91,6 +94,10 @@ class StorageTestsMixin:
|
|||
self.assertEqual(self.config.DATE_VALUE, date(2001, 12, 20))
|
||||
self.assertEqual(self.config.TIME_VALUE, time(1, 59, 0))
|
||||
self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=1, hours=2, minutes=3))
|
||||
self.assertEqual(self.config.DERIVED_VALUE_FUNC, 'Hello world to test@example.com')
|
||||
self.assertEqual(self.config.DERIVED_VALUE_FUNC_STR, 'Hello world to test@example.com')
|
||||
self.assertEqual(self.config.DERIVED_VALUE_LAMBDA, 'Hello world to test@example.com')
|
||||
|
||||
|
||||
def test_backend_retrieves_multiple_values(self):
|
||||
# Check corner cases such as falsy values
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ class ChecksTestCase(TestCase):
|
|||
Test that get_inconsistent_fieldnames returns an empty data and no checks fail
|
||||
if CONFIG_FIELDSETS accounts for every key in settings.CONFIG.
|
||||
"""
|
||||
missing_keys, extra_keys = get_inconsistent_fieldnames()
|
||||
missing_keys, extra_keys, derived_value_in_fieldset_keys = get_inconsistent_fieldnames()
|
||||
self.assertFalse(missing_keys)
|
||||
self.assertFalse(extra_keys)
|
||||
self.assertFalse(derived_value_in_fieldset_keys)
|
||||
|
||||
@mock.patch(
|
||||
'constance.settings.CONFIG_FIELDSETS',
|
||||
|
|
@ -27,9 +28,10 @@ class ChecksTestCase(TestCase):
|
|||
Test that get_inconsistent_fieldnames returns data and the check fails
|
||||
if CONFIG_FIELDSETS does not account for every key in settings.CONFIG.
|
||||
"""
|
||||
missing_keys, extra_keys = get_inconsistent_fieldnames()
|
||||
missing_keys, extra_keys, derived_value_in_fieldset_keys = get_inconsistent_fieldnames()
|
||||
self.assertTrue(missing_keys)
|
||||
self.assertFalse(extra_keys)
|
||||
self.assertFalse(derived_value_in_fieldset_keys)
|
||||
self.assertEqual(1, len(check_fieldsets()))
|
||||
|
||||
@mock.patch(
|
||||
|
|
@ -41,9 +43,10 @@ class ChecksTestCase(TestCase):
|
|||
Test that get_inconsistent_fieldnames returns data and the check fails
|
||||
if CONFIG_FIELDSETS contains extra key that is absent in settings.CONFIG.
|
||||
"""
|
||||
missing_keys, extra_keys = get_inconsistent_fieldnames()
|
||||
missing_keys, extra_keys, derived_value_in_fieldset_keys = get_inconsistent_fieldnames()
|
||||
self.assertFalse(missing_keys)
|
||||
self.assertTrue(extra_keys)
|
||||
self.assertFalse(derived_value_in_fieldset_keys)
|
||||
self.assertEqual(1, len(check_fieldsets()))
|
||||
|
||||
@mock.patch('constance.settings.CONFIG_FIELDSETS', {})
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ class UtilsTestCase(TestCase):
|
|||
'key5': datetime.date(2019, 1, 1),
|
||||
'key6': None,
|
||||
},
|
||||
'DERIVED_VALUE_FUNC': 'Hello world to test@example.com',
|
||||
'DERIVED_VALUE_FUNC_STR': 'Hello world to test@example.com',
|
||||
'DERIVED_VALUE_LAMBDA': 'Hello world to test@example.com',
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue