From 09cc04e10afdc0d4a0f14ccf08d0e58ffd71ee55 Mon Sep 17 00:00:00 2001 From: Wentao Lyu <35-wentao.lyu@users.noreply.git.stereye.tech> Date: Wed, 5 Mar 2025 23:26:14 +0800 Subject: [PATCH] feat: Add generated field - derived_value --- constance/admin.py | 2 ++ constance/base.py | 21 +++++++++++++++++-- constance/checks.py | 27 ++++++++++++++++++++----- constance/forms.py | 12 ++++++++--- docs/index.rst | 37 +++++++++++++++++++++++++++++++++- example/cheeseshop/settings.py | 1 + example/cheeseshop/utils.py | 2 ++ tests/settings.py | 6 ++++++ tests/storage.py | 7 +++++++ tests/test_checks.py | 9 ++++++--- tests/test_utils.py | 3 +++ 11 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 example/cheeseshop/utils.py diff --git a/constance/admin.py b/constance/admin.py index 862759a..a4dc20a 100644 --- a/constance/admin.py +++ b/constance/admin.py @@ -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: diff --git a/constance/base.py b/constance/base.py index 614ed3e..6797d74 100644 --- a/constance/base.py +++ b/constance/base.py @@ -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): diff --git a/constance/checks.py b/constance/checks.py index 9b3b1ba..6d3ca2e 100644 --- a/constance/checks.py +++ b/constance/checks.py @@ -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 diff --git a/constance/forms.py b/constance/forms.py index 55893c4..bb120f4 100644 --- a/constance/forms.py +++ b/constance/forms.py @@ -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.') ) diff --git a/docs/index.rst b/docs/index.rst index 2e5ea67..117eaf3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/example/cheeseshop/settings.py b/example/cheeseshop/settings.py index 75bc6b2..92d13da 100644 --- a/example/cheeseshop/settings.py +++ b/example/cheeseshop/settings.py @@ -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 = { diff --git a/example/cheeseshop/utils.py b/example/cheeseshop/utils.py new file mode 100644 index 0000000..57fe399 --- /dev/null +++ b/example/cheeseshop/utils.py @@ -0,0 +1,2 @@ +def get_banner_with_owner(config): + return f'{config.BANNER} {config.OWNER}' \ No newline at end of file diff --git a/tests/settings.py b/tests/settings.py index 2b5b7e0..c7b8323 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -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 diff --git a/tests/storage.py b/tests/storage.py index 13e7e99..91b5d59 100644 --- a/tests/storage.py +++ b/tests/storage.py @@ -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 diff --git a/tests/test_checks.py b/tests/test_checks.py index 7880dfe..60fda65 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -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', {}) diff --git a/tests/test_utils.py b/tests/test_utils.py index 23cb364..47801dc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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', }, )