2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-11 19:27:02 +00:00

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
This commit is contained in:
Oliver
2026-06-06 11:39:21 +10:00
committed by GitHub
parent 37b409e991
commit 4c4e0eb202
9 changed files with 244 additions and 3 deletions
+1
View File
@@ -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.
+9
View File
@@ -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:
@@ -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
@@ -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)
+13
View File
@@ -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.
@@ -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:
+37
View File
@@ -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.
+14 -1
View File
@@ -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."""
+145 -1
View File
@@ -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'
)