feat: Add generated field - derived_value

This commit is contained in:
Wentao Lyu 2025-03-05 23:26:14 +08:00
parent 22bdb011db
commit 09cc04e10a
11 changed files with 113 additions and 14 deletions

View file

@ -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:

View file

@ -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):

View file

@ -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

View file

@ -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.')
)

View file

@ -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

View file

@ -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 = {

View file

@ -0,0 +1,2 @@
def get_banner_with_owner(config):
return f'{config.BANNER} {config.OWNER}'

View file

@ -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

View file

@ -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

View file

@ -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', {})

View file

@ -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',
},
)