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:
@@ -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.
|
||||
|
||||
@@ -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,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