2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-03 13:58:47 +00:00

Added required attribute to settings/plugins, refactor: allValues (#5224)

* Added required attribute to settings/plugins, refactor: allValues

- added 'required' attribute to InvenTreeBaseSetting
- added 'check_all_settings'
- added 'all_settings' to get a list of all defined settings
- refactored 'allValues' to use new 'all_settings' function
- added docs for new 'check_setting' function on plugin SettingsMixin

* Fix typing to be compatible with python 3.9

* trigger: ci

* Fixed **kwargs bug and added tests
This commit is contained in:
Lukas 2023-07-12 00:19:19 +02:00 committed by GitHub
parent b3dcc28bd9
commit ee274739a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 142 additions and 26 deletions

View File

@ -127,6 +127,7 @@ class SettingsKeyType(TypedDict, total=False):
before_save: Function that gets called after save with *args, **kwargs (optional) before_save: Function that gets called after save with *args, **kwargs (optional)
after_save: Function that gets called after save with *args, **kwargs (optional) after_save: Function that gets called after save with *args, **kwargs (optional)
protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False) protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False)
required: Is this setting required to work, can be used in combination with .check_all_settings(...) (optional, default: False)
model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional) model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional)
""" """
@ -140,6 +141,7 @@ class SettingsKeyType(TypedDict, total=False):
before_save: Callable[..., None] before_save: Callable[..., None]
after_save: Callable[..., None] after_save: Callable[..., None]
protected: bool protected: bool
required: bool
model: str model: str
@ -250,13 +252,15 @@ class BaseInvenTreeSetting(models.Model):
return {key: getattr(self, key, None) for key in self.extra_unique_fields if hasattr(self, key)} return {key: getattr(self, key, None) for key in self.extra_unique_fields if hasattr(self, key)}
@classmethod @classmethod
def allValues(cls, exclude_hidden=False, **kwargs): def all_settings(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
"""Return a dict of "all" defined global settings. """Return a list of "all" defined settings.
This performs a single database lookup, This performs a single database lookup,
and then any settings which are not *in* the database and then any settings which are not *in* the database
are assigned their default values are assigned their default values
""" """
filters = cls.get_filters(**kwargs)
results = cls.objects.all() results = cls.objects.all()
if exclude_hidden: if exclude_hidden:
@ -264,45 +268,83 @@ class BaseInvenTreeSetting(models.Model):
results = results.exclude(key__startswith='_') results = results.exclude(key__startswith='_')
# Optionally filter by other keys # Optionally filter by other keys
results = results.filter(**cls.get_filters(**kwargs)) results = results.filter(**filters)
settings: Dict[str, BaseInvenTreeSetting] = {}
# Query the database # Query the database
settings = {}
for setting in results: for setting in results:
if setting.key: if setting.key:
settings[setting.key.upper()] = setting.value settings[setting.key.upper()] = setting
# Specify any "default" values which are not in the database # Specify any "default" values which are not in the database
for key in cls.SETTINGS.keys(): settings_definition = settings_definition or cls.SETTINGS
for key, setting in settings_definition.items():
if key.upper() not in settings: if key.upper() not in settings:
settings[key.upper()] = cls.get_setting_default(key) settings[key.upper()] = cls(
key=key.upper(),
value=cls.get_setting_default(key, **filters),
**filters
)
if exclude_hidden: # remove any hidden settings
hidden = cls.SETTINGS[key].get('hidden', False) if exclude_hidden and setting.get("hidden", False):
if hidden:
# Remove hidden items
del settings[key.upper()] del settings[key.upper()]
for key, value in settings.items(): # format settings values and remove protected
validator = cls.get_setting_validator(key) for key, setting in settings.items():
validator = cls.get_setting_validator(key, **filters)
if cls.is_protected(key): if cls.is_protected(key, **filters) and setting.value != "":
value = '***' setting.value = '***'
elif cls.validator_is_bool(validator): elif cls.validator_is_bool(validator):
value = InvenTree.helpers.str2bool(value) setting.value = InvenTree.helpers.str2bool(setting.value)
elif cls.validator_is_int(validator): elif cls.validator_is_int(validator):
try: try:
value = int(value) setting.value = int(setting.value)
except ValueError: except ValueError:
value = cls.get_setting_default(key) setting.value = cls.get_setting_default(key, **filters)
settings[key] = value
return settings return settings
@classmethod
def allValues(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
"""Return a dict of "all" defined global settings.
This performs a single database lookup,
and then any settings which are not *in* the database
are assigned their default values
"""
all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs)
settings: Dict[str, Any] = {}
for key, setting in all_settings.items():
settings[key] = setting.value
return settings
@classmethod
def check_all_settings(cls, *, exclude_hidden=False, settings_definition: Union[Dict[str, SettingsKeyType], None] = None, **kwargs):
"""Check if all required settings are set by definition.
Returns:
is_valid: Are all required settings defined
missing_settings: List of all settings that are missing (empty if is_valid is 'True')
"""
all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs)
missing_settings: List[str] = []
for setting in all_settings.values():
if setting.required:
value = setting.value or cls.get_setting_default(setting.key, **kwargs)
if value == "":
missing_settings.append(setting.key.upper())
return len(missing_settings) == 0, missing_settings
@classmethod @classmethod
def get_setting_definition(cls, key, **kwargs): def get_setting_definition(cls, key, **kwargs):
"""Return the 'definition' of a particular settings value, as a dict object. """Return the 'definition' of a particular settings value, as a dict object.
@ -829,7 +871,7 @@ class BaseInvenTreeSetting(models.Model):
@classmethod @classmethod
def is_protected(cls, key, **kwargs): def is_protected(cls, key, **kwargs):
"""Check if the setting value is protected.""" """Check if the setting value is protected."""
setting = cls.get_setting_definition(key, **kwargs) setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs))
return setting.get('protected', False) return setting.get('protected', False)
@ -838,6 +880,18 @@ class BaseInvenTreeSetting(models.Model):
"""Returns if setting is protected from rendering.""" """Returns if setting is protected from rendering."""
return self.__class__.is_protected(self.key, **self.get_filters_for_instance()) return self.__class__.is_protected(self.key, **self.get_filters_for_instance())
@classmethod
def is_required(cls, key, **kwargs):
"""Check if this setting value is required."""
setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs))
return setting.get("required", False)
@property
def required(self):
"""Returns if setting is required."""
return self.__class__.is_required(self.key, **self.get_filters_for_instance())
def settings_group_options(): def settings_group_options():
"""Build up group tuple for settings based on your choices.""" """Build up group tuple for settings based on your choices."""

View File

@ -5,6 +5,7 @@ import json
import time import time
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
from unittest import mock
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache from django.core.cache import cache
@ -105,6 +106,40 @@ class SettingsTest(InvenTreeTestCase):
self.assertIn('PART_COPY_TESTS', result) self.assertIn('PART_COPY_TESTS', result)
self.assertIn('STOCK_OWNERSHIP_CONTROL', result) self.assertIn('STOCK_OWNERSHIP_CONTROL', result)
self.assertIn('SIGNUP_GROUP', result) self.assertIn('SIGNUP_GROUP', result)
self.assertIn('SERVER_RESTART_REQUIRED', result)
result = InvenTreeSetting.allValues(exclude_hidden=True)
self.assertNotIn('SERVER_RESTART_REQUIRED', result)
def test_all_settings(self):
"""Make sure that the all_settings function returns correctly"""
result = InvenTreeSetting.all_settings()
self.assertIn("INVENTREE_INSTANCE", result)
self.assertIsInstance(result['INVENTREE_INSTANCE'], InvenTreeSetting)
@mock.patch("common.models.InvenTreeSetting.get_setting_definition")
def test_check_all_settings(self, get_setting_definition):
"""Make sure that the check_all_settings function returns correctly"""
# define partial schema
settings_definition = {
"AB": { # key that's has not already been accessed
"required": True,
},
"CD": {
"required": True,
"protected": True,
},
"EF": {}
}
def mocked(key, **kwargs):
return settings_definition.get(key, {})
get_setting_definition.side_effect = mocked
self.assertEqual(InvenTreeSetting.check_all_settings(settings_definition=settings_definition), (False, ["AB", "CD"]))
InvenTreeSetting.set_setting('AB', "hello", self.user)
InvenTreeSetting.set_setting('CD', "world", self.user)
self.assertEqual(InvenTreeSetting.check_all_settings(), (True, []))
def run_settings_check(self, key, setting): def run_settings_check(self, key, setting):
"""Test that all settings are valid. """Test that all settings are valid.

View File

@ -84,3 +84,16 @@ class SettingsMixin:
return return
PluginSetting.set_setting(key, value, user, plugin=plugin) PluginSetting.set_setting(key, value, user, plugin=plugin)
def check_settings(self):
"""Check if all required settings for this machine are defined.
Warning: This method cannot be used in the __init__ function of the plugin
Returns:
is_valid: Are all required settings defined
missing_settings: List of all settings that are missing (empty if is_valid is 'True')
"""
from plugin.models import PluginSetting
return PluginSetting.check_all_settings(settings_definition=self.settings, plugin=self.plugin_config())

View File

@ -44,6 +44,7 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
'API_KEY': { 'API_KEY': {
'name': _('API Key'), 'name': _('API Key'),
'description': _('Key required for accessing external API'), 'description': _('Key required for accessing external API'),
'required': True,
}, },
'NUMERICAL_SETTING': { 'NUMERICAL_SETTING': {
'name': _('Numerical'), 'name': _('Numerical'),

View File

@ -1,6 +1,7 @@
"""Unit tests for action plugins.""" """Unit tests for action plugins."""
from InvenTree.unit_test import InvenTreeTestCase from InvenTree.unit_test import InvenTreeTestCase
from plugin import registry
class SampleIntegrationPluginTests(InvenTreeTestCase): class SampleIntegrationPluginTests(InvenTreeTestCase):
@ -11,3 +12,13 @@ class SampleIntegrationPluginTests(InvenTreeTestCase):
response = self.client.get('/plugin/sample/ho/he/') response = self.client.get('/plugin/sample/ho/he/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'Hi there testuser this works') self.assertEqual(response.content, b'Hi there testuser this works')
def test_settings(self):
"""Check the SettingsMixin.check_settings function."""
plugin = registry.get_plugin('sample')
self.assertIsNotNone(plugin)
# check settings
self.assertEqual(plugin.check_settings(), (False, ['API_KEY']))
plugin.set_setting('API_KEY', "dsfiodsfjsfdjsf")
self.assertEqual(plugin.check_settings(), (True, []))

View File

@ -35,6 +35,7 @@ class PluginWithSettings(SettingsMixin, InvenTreePlugin):
'name': 'API Key', 'name': 'API Key',
'description': 'Security key for accessing remote API', 'description': 'Security key for accessing remote API',
'default': '', 'default': '',
'required': True,
}, },
'API_URL': { 'API_URL': {
'name': _('API URL'), 'name': _('API URL'),
@ -71,10 +72,11 @@ class PluginWithSettings(SettingsMixin, InvenTreePlugin):
!!! tip "Hidden Settings" !!! tip "Hidden Settings"
Plugin settings can be hidden from the settings page by marking them as 'hidden' Plugin settings can be hidden from the settings page by marking them as 'hidden'
This mixin defines the helper functions `plugin.get_setting` and `plugin.set_setting` to access all plugin specific settings: This mixin defines the helper functions `plugin.get_setting`, `plugin.set_setting` and `plugin.check_settings` to access all plugin specific settings. The `plugin.check_settings` function can be used to check if all settings marked with `'required': True` are defined and not equal to `''`. Note that these methods cannot be used in the `__init__` function of your plugin.
```python ```python
api_url = self.get_setting('API_URL', cache = False) api_url = self.get_setting('API_URL', cache = False)
self.set_setting('API_URL', 'some value') self.set_setting('API_URL', 'some value')
is_valid, missing_settings = self.check_settings()
``` ```
`get_setting` has an additional parameter which lets control if the value is taken directly from the database or from the cache. If it is left away `False` is the default that means the value is taken directly from the database. `get_setting` has an additional parameter which lets control if the value is taken directly from the database or from the cache. If it is left away `False` is the default that means the value is taken directly from the database.