2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-12 11:38:47 +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,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'
)