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:
@@ -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,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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user