From 4c4e0eb202059b83f208065e7f48660f97148956 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 6 Jun 2026 11:39:21 +1000 Subject: [PATCH] Locked plugin settings (#12093) * Framework for overriding plugin settings * Update serializer * Prevent writing of plugin setting values * Unit tests * Update API version and CHANGELOG * Update docs --- CHANGELOG.md | 1 + docs/docs/start/config.md | 9 ++ .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/settings.py | 15 ++ src/backend/InvenTree/common/settings.py | 13 ++ src/backend/InvenTree/config_template.yaml | 6 + src/backend/InvenTree/plugin/models.py | 37 +++++ src/backend/InvenTree/plugin/serializers.py | 15 +- src/backend/InvenTree/plugin/test_api.py | 146 +++++++++++++++++- 9 files changed, 244 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4eaac156..678181546f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#12093](https://github.com/inventree/InvenTree/pull/12093) adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API) - [#12079](https://github.com/inventree/InvenTree/pull/12079) adds the ability to save filter groups for table and calendar views in the user interface. This allows users to save and reuse commonly used filter configurations, improving the usability and efficiency of the interface. - [#12077](https://github.com/inventree/InvenTree/pull/12077) adds "tags" fields to multiple new model types and a /api/tag/ endpoint for fetching tags. Also adds the ability to filter various model types by tags. - [#12019](https://github.com/inventree/InvenTree/pull/12019) adds a "location" field to the StockCount API endpoint, allowing users to specify a location when performing a stock count. This field is optional, and if not provided, the stock count will be performed without changing the location of the stock item. If a location is provided, the stock item(s) will be moved to the specified location as part of the stock count operation. diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index ffff05e39d..55714be4e0 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -555,6 +555,15 @@ To override global settings, provide a "dictionary" of settings overrides in the {{ configtable() }} {{ configsetting("INVENTREE_GLOBAL_SETTINGS") }} JSON object containing global settings overrides | +## Override Plugin Settings + +If you have plugins installed which require configuration, you can provide plugin settings overrides in the configuration file, or via an environment variable. + +{{ configtable() }} +{{ configsetting("INVENTREE_PLUGIN_SETTINGS") }} JSON object containing plugin settings overrides | + +Note that plugin settings overrides require knowledge of the plugin "slug" and the particular settings which are being overridden. You should refer to the plugin documentation for more information on available settings. If plugin overrides are specified, but no matching plugin is found, the overrides will be ignored. + ## Other Settings Other available settings, not categorized above, are detailed in the table below: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index a80da8408d..bfd21e84c5 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 500 +INVENTREE_API_VERSION = 501 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v501 -> 2026-06-05 : https://github.com/inventree/InvenTree/pull/12093 + - Adds "read_only" attribute to PluginSetting API endpoint, which indicates whether a particular plugin setting is read-only (i.e. cannot be modified via the API) + v500 -> 2026-06-03 : https://github.com/inventree/InvenTree/pull/12077 - Adds "tags" fields to multiple new model types - Adds /api/tag/ endpoint for fetching tags diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 68a3c81bf4..d5f643594c 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -1100,6 +1100,21 @@ if len(GLOBAL_SETTINGS_OVERRIDES) > 0: # Set the global setting logger.debug('- Override value for %s = ********', key) +# Plugin settings overrides +# If provided, these values will override any plugin settings (and prevent them from being changed) +# Specified as a nested dict: {plugin_slug: {SETTING_KEY: value}} +PLUGIN_SETTING_OVERRIDES = get_setting( + 'INVENTREE_PLUGIN_SETTINGS', 'plugin_settings', typecast=dict +) + +if len(PLUGIN_SETTING_OVERRIDES) > 0: + logger.info( + 'INVE-I1: Plugin settings overrides: %s', list(PLUGIN_SETTING_OVERRIDES.keys()) + ) + for slug, overrides in PLUGIN_SETTING_OVERRIDES.items(): + for key in overrides: + logger.debug('- Override value for %s.%s = ********', slug, key) + # User interface customization values CUSTOM_LOGO = get_setting('INVENTREE_CUSTOM_LOGO', 'customize.logo', typecast=str) diff --git a/src/backend/InvenTree/common/settings.py b/src/backend/InvenTree/common/settings.py index 0e32fb99a5..5ca0d906c5 100644 --- a/src/backend/InvenTree/common/settings.py +++ b/src/backend/InvenTree/common/settings.py @@ -13,6 +13,19 @@ import InvenTree.ready logger = structlog.get_logger('inventree') +def plugin_setting_overrides(slug: str) -> dict: + """Return a dictionary of plugin setting overrides for the given plugin slug. + + These values are set via environment variables or configuration file. + """ + from django.conf import settings + + if hasattr(settings, 'PLUGIN_SETTING_OVERRIDES'): + return settings.PLUGIN_SETTING_OVERRIDES.get(slug, {}) or {} + + return {} + + def global_setting_overrides() -> dict: """Return a dictionary of global settings overrides. diff --git a/src/backend/InvenTree/config_template.yaml b/src/backend/InvenTree/config_template.yaml index 4b790e7c17..53f0802728 100644 --- a/src/backend/InvenTree/config_template.yaml +++ b/src/backend/InvenTree/config_template.yaml @@ -236,6 +236,12 @@ ldap: # INVENTREE_DEFAULT_CURRENCY: 'CNY' # INVENTREE_RESTRICT_ABOUT: true +# Override plugin settings (by plugin slug) +# Settings specified here will be locked and cannot be changed via the UI +#plugin_settings: +# plugin-slug: +# SETTING_KEY: 'value' + # Storage configuration # Ref: https://docs.inventree.org/en/stable/start/config/#storage-backends storage: diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index bed2d80c8b..adc17d9f29 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -302,6 +302,43 @@ class PluginSetting(common.models.BaseInvenTreeSetting): on_delete=models.CASCADE, ) + def save(self, *args, **kwargs): + """When saving a plugin setting, enforce any config-level overrides.""" + from common.settings import plugin_setting_overrides + + if self.plugin_id and self.plugin: + overrides = plugin_setting_overrides(self.plugin.key) + if self.key in overrides: + self.value = str(overrides[self.key]) + + super().save(*args, **kwargs) + + @classmethod + def get_setting_default(cls, key, **kwargs): + """Return the default value for a plugin setting, respecting config overrides.""" + from common.settings import plugin_setting_overrides + + plugin = kwargs.get('plugin') + if plugin: + overrides = plugin_setting_overrides(plugin.key) + if key in overrides: + return overrides[key] + + return super().get_setting_default(key, **kwargs) + + @classmethod + def get_setting(cls, key, backup_value=None, **kwargs): + """Get the value of a plugin setting, respecting config overrides.""" + from common.settings import plugin_setting_overrides + + plugin = kwargs.get('plugin') + if plugin: + overrides = plugin_setting_overrides(plugin.key) + if key in overrides: + return overrides[key] + + return super().get_setting(key, backup_value=backup_value, **kwargs) + @classmethod def get_setting_definition(cls, key, **kwargs): """In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS', which is a dict object that fully defines all the setting parameters. diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py index b12d0d3590..1b77b6063f 100644 --- a/src/backend/InvenTree/plugin/serializers.py +++ b/src/backend/InvenTree/plugin/serializers.py @@ -283,10 +283,23 @@ class PluginSettingSerializer(GenericReferencedSettingSerializer): """Serializer for the PluginSetting model.""" MODEL = PluginSetting - EXTRA_FIELDS = ['plugin'] + EXTRA_FIELDS = ['plugin', 'read_only'] plugin = serializers.CharField(source='plugin.key', read_only=True) + read_only = serializers.SerializerMethodField( + read_only=True, + help_text=_('Indicates if the setting is overridden by configuration'), + label=_('Override'), + ) + + def get_read_only(self, obj) -> bool: + """Return True if this plugin setting is locked by configuration.""" + from common.settings import plugin_setting_overrides + + overrides = plugin_setting_overrides(obj.plugin.key) + return obj.key in overrides + class PluginUserSettingSerializer(GenericReferencedSettingSerializer): """Serializer for the PluginUserSetting model.""" diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 820c5c96fa..9e22f40355 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -7,7 +7,7 @@ from rest_framework.exceptions import NotFound from InvenTree.unit_test import InvenTreeAPITestCase, PluginMixin from plugin.api import check_plugin -from plugin.models import PluginConfig +from plugin.models import PluginConfig, PluginSetting class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): @@ -672,3 +672,147 @@ class PluginFullAPITest(PluginMixin, InvenTreeAPITestCase): # Successful uninstallation with self.assertRaises(PluginConfig.DoesNotExist): PluginConfig.objects.get(key=slug) + + +class PluginLockedSettingsTest(PluginMixin, InvenTreeAPITestCase): + """Tests for locked plugin settings (overridden via configuration). + + When a plugin setting is specified in PLUGIN_SETTING_OVERRIDES: + - The API reports read_only=True for that setting + - The stored value always reflects the override, even after a PATCH attempt + - Other settings on the same plugin remain editable (read_only=False) + """ + + superuser = True + + PLUGIN_SLUG = 'sample' + + # Two settings from the sample plugin used across these tests + LOCKED_KEY = 'API_KEY' + LOCKED_VALUE = 'locked-api-key-value' + + LOCKED_NUMERIC_KEY = 'NUMERICAL_SETTING' + LOCKED_NUMERIC_VALUE = 42 + + UNLOCKED_KEY = 'CHOICE_SETTING' + + def setUp(self): + """Activate the sample plugin and confirm it is ready.""" + super().setUp() + + from plugin.registry import registry + + registry.set_plugin_state(self.PLUGIN_SLUG, True) + + self.cfg = PluginConfig.objects.filter(key=self.PLUGIN_SLUG).first() + self.assertIsNotNone(self.cfg) + + def _setting_url(self, key): + return reverse( + 'api-plugin-setting-detail', kwargs={'plugin': self.PLUGIN_SLUG, 'key': key} + ) + + # ------------------------------------------------------------------ + # Helper that supplies the override context for all locked-setting tests + # ------------------------------------------------------------------ + def _overrides(self): + return { + self.PLUGIN_SLUG: { + self.LOCKED_KEY: self.LOCKED_VALUE, + self.LOCKED_NUMERIC_KEY: self.LOCKED_NUMERIC_VALUE, + } + } + + def test_locked_setting_read_only_flag(self): + """Locked settings must be reported as read_only=True via the API.""" + with self.settings(PLUGIN_SETTING_OVERRIDES=self._overrides()): + response = self.get(self._setting_url(self.LOCKED_KEY), expected_code=200) + self.assertTrue(response.data['read_only']) + + def test_unlocked_setting_not_read_only(self): + """Settings that are NOT overridden must be reported as read_only=False.""" + with self.settings(PLUGIN_SETTING_OVERRIDES=self._overrides()): + response = self.get(self._setting_url(self.UNLOCKED_KEY), expected_code=200) + self.assertFalse(response.data['read_only']) + + def test_locked_setting_returns_override_value(self): + """GET on a locked setting must return the configured override value.""" + with self.settings(PLUGIN_SETTING_OVERRIDES=self._overrides()): + response = self.get(self._setting_url(self.LOCKED_KEY), expected_code=200) + self.assertEqual(response.data['value'], self.LOCKED_VALUE) + + def test_locked_setting_model_get(self): + """PluginSetting.get_setting() must return the override value directly.""" + with self.settings(PLUGIN_SETTING_OVERRIDES=self._overrides()): + value = PluginSetting.get_setting(self.LOCKED_KEY, plugin=self.cfg) + self.assertEqual(value, self.LOCKED_VALUE) + + numeric = PluginSetting.get_setting( + self.LOCKED_NUMERIC_KEY, plugin=self.cfg + ) + self.assertEqual(numeric, self.LOCKED_NUMERIC_VALUE) + + def test_locked_setting_patch_ignored(self): + """PATCH to a locked setting must not change the value; override is re-applied.""" + with self.settings(PLUGIN_SETTING_OVERRIDES=self._overrides()): + response = self.patch( + self._setting_url(self.LOCKED_KEY), + {'value': 'attacker-supplied-value'}, + expected_code=200, + ) + # The response value must reflect the override, not the submitted value + self.assertEqual(response.data['value'], self.LOCKED_VALUE) + + # Confirm persistence: a subsequent GET also returns the locked value + response = self.get(self._setting_url(self.LOCKED_KEY), expected_code=200) + self.assertEqual(response.data['value'], self.LOCKED_VALUE) + + def test_locked_numeric_setting_patch_ignored(self): + """PATCH to a locked numeric setting must not change the value.""" + with self.settings(PLUGIN_SETTING_OVERRIDES=self._overrides()): + response = self.patch( + self._setting_url(self.LOCKED_NUMERIC_KEY), + {'value': 999}, + expected_code=200, + ) + self.assertEqual(int(response.data['value']), self.LOCKED_NUMERIC_VALUE) + + def test_unlocked_setting_can_be_changed(self): + """Settings not in the override dict must still be freely editable.""" + with self.settings(PLUGIN_SETTING_OVERRIDES=self._overrides()): + # Set an initial known value + self.patch( + self._setting_url(self.UNLOCKED_KEY), {'value': 'A'}, expected_code=200 + ) + response = self.get(self._setting_url(self.UNLOCKED_KEY), expected_code=200) + self.assertEqual(response.data['value'], 'A') + + # Change it again to confirm mutability + self.patch( + self._setting_url(self.UNLOCKED_KEY), {'value': 'B'}, expected_code=200 + ) + response = self.get(self._setting_url(self.UNLOCKED_KEY), expected_code=200) + self.assertEqual(response.data['value'], 'B') + + def test_locked_setting_model_save_enforced(self): + """Saving a PluginSetting instance directly must enforce the override value.""" + with self.settings(PLUGIN_SETTING_OVERRIDES=self._overrides()): + # Retrieve (or create) the DB object and try to write a different value + obj = PluginSetting.get_setting_object(self.LOCKED_KEY, plugin=self.cfg) + self.assertIsNotNone(obj) + + obj.value = 'should-be-overwritten' + obj.save() + + # Re-fetch from DB to confirm the override was enforced + obj.refresh_from_db() + self.assertEqual(obj.value, self.LOCKED_VALUE) + + def test_no_overrides_settings_are_editable(self): + """Without any PLUGIN_SETTING_OVERRIDES, all settings default to read_only=False.""" + with self.settings(PLUGIN_SETTING_OVERRIDES={}): + for key in [self.LOCKED_KEY, self.LOCKED_NUMERIC_KEY, self.UNLOCKED_KEY]: + response = self.get(self._setting_url(key), expected_code=200) + self.assertFalse( + response.data['read_only'], msg=f'{key} should not be read_only' + )