diff --git a/projectroles/app_settings.py b/projectroles/app_settings.py index d98c0332..5587f74c 100644 --- a/projectroles/app_settings.py +++ b/projectroles/app_settings.py @@ -327,7 +327,6 @@ def get_default( s_def = s_defs[setting_name] if callable(s_def.default): try: - # callable_setting = s_def.default return s_def.default(project, user) except Exception: logger.error( @@ -669,7 +668,7 @@ def validate( """ Validate setting value according to its type. - :param setting_type: Setting type + :param setting_type: Setting type (string) :param setting_value: Setting value :param setting_options: Setting options (can be None) :param project: Project object (optional) @@ -680,34 +679,11 @@ def validate( cls._validate_value_in_options( setting_value, setting_options, project=project, user=user ) - # Test callable + # Test callable value if callable(setting_value): setting_value(project, user) - - if setting_type == APP_SETTING_TYPE_BOOLEAN: - if not isinstance(setting_value, bool): - raise ValueError( - 'Please enter a valid boolean value ({})'.format( - setting_value - ) - ) - elif setting_type == APP_SETTING_TYPE_INTEGER: - if ( - not isinstance(setting_value, int) - and not str(setting_value).isdigit() - ): - raise ValueError( - 'Please enter a valid integer value ({})'.format( - setting_value - ) - ) - elif setting_type == APP_SETTING_TYPE_JSON: - try: - json.dumps(setting_value) - except TypeError: - raise ValueError( - 'Please enter valid JSON ({})'.format(setting_value) - ) + else: # Else validate normal value + PluginAppSettingDef.validate_value(setting_type, setting_value) return True @classmethod diff --git a/projectroles/plugins.py b/projectroles/plugins.py index c71aaee3..74b70fbb 100644 --- a/projectroles/plugins.py +++ b/projectroles/plugins.py @@ -1,5 +1,7 @@ """Plugin point definitions and plugin API for apps based on projectroles""" +import json + from django.conf import settings from djangoplugins.point import PluginPoint @@ -14,7 +16,9 @@ 'APP_SETTING_SCOPE_PROJECT_USER' ] APP_SETTING_SCOPE_SITE = SODAR_CONSTANTS['APP_SETTING_SCOPE_SITE'] +APP_SETTING_TYPE_BOOLEAN = SODAR_CONSTANTS['APP_SETTING_TYPE_BOOLEAN'] APP_SETTING_TYPE_INTEGER = SODAR_CONSTANTS['APP_SETTING_TYPE_INTEGER'] +APP_SETTING_TYPE_JSON = SODAR_CONSTANTS['APP_SETTING_TYPE_JSON'] APP_SETTING_TYPE_STRING = SODAR_CONSTANTS['APP_SETTING_TYPE_STRING'] # Local constants @@ -697,14 +701,24 @@ def __init__( :parm widget_attrs: Form widget attributes (optional, dict) :raise: ValueError if an argument is not valid """ + # Validate provided values self.validate_scope(scope) self.validate_type(type) self.validate_type_options(type, options) + if not callable(default): + self.validate_value(type, default) + if ( + default is not None + and options is not None + and not callable(default) + and not callable(options) + ): + self.validate_default_in_options(default, options) # TODO: Prevent using the same name in the same plugin + # Set members self.name = name self.scope = scope self.type = type - # TODO: Validate default against type self.default = default self.label = label self.placeholder = placeholder @@ -756,6 +770,67 @@ def validate_type_options(cls, setting_type, setting_options): 'STRING' ) + @classmethod + def validate_default_in_options(cls, setting_default, setting_options): + """ + Validate existence of default value in uncallable options. + + :param setting_default: Default value + :param setting_options: Setting options + :raise: ValueError if default is not found in options + """ + if ( + setting_options is not None + and not callable(setting_options) + and setting_default is not None + and not callable(setting_default) + and setting_default not in setting_options + ): + raise ValueError( + 'Default value "{}" not found in options ({})'.format( + setting_default, + ', '.join([str(o) for o in setting_options]), + ) + ) + + @classmethod + def validate_value(cls, setting_type, setting_value): + """ + Validate non-callable value. + + :param setting_type: Setting type (string) + :param setting_value: Setting value + :raise: ValueError if value is invalid + """ + if setting_type == APP_SETTING_TYPE_BOOLEAN: + if not isinstance(setting_value, bool): + raise ValueError( + 'Please enter value as bool ({})'.format(setting_value) + ) + elif setting_type == APP_SETTING_TYPE_INTEGER: + if ( + not isinstance(setting_value, int) + and not str(setting_value).isdigit() + ): + raise ValueError( + 'Please enter a valid integer value ({})'.format( + setting_value + ) + ) + elif setting_type == APP_SETTING_TYPE_JSON: + if setting_value and not isinstance(setting_value, (dict, list)): + raise ValueError( + 'Please input JSON value as dict or list ({})'.format( + setting_value + ) + ) + try: + json.dumps(setting_value) + except TypeError: + raise ValueError( + 'Please enter valid JSON ({})'.format(setting_value) + ) + class PluginObjectLink: """ diff --git a/projectroles/tests/test_plugins.py b/projectroles/tests/test_plugins.py new file mode 100644 index 00000000..1e86f8c5 --- /dev/null +++ b/projectroles/tests/test_plugins.py @@ -0,0 +1,254 @@ +"""Tests for plugins in the projectroles Django app""" + +from test_plus.test import TestCase + +from projectroles.models import SODAR_CONSTANTS +from projectroles.plugins import PluginAppSettingDef + +# SODAR constants +PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] +PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] +APP_SETTING_SCOPE_PROJECT = SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'] +APP_SETTING_SCOPE_USER = SODAR_CONSTANTS['APP_SETTING_SCOPE_USER'] +APP_SETTING_SCOPE_PROJECT_USER = SODAR_CONSTANTS[ + 'APP_SETTING_SCOPE_PROJECT_USER' +] +APP_SETTING_SCOPE_SITE = SODAR_CONSTANTS['APP_SETTING_SCOPE_SITE'] +APP_SETTING_TYPE_BOOLEAN = SODAR_CONSTANTS['APP_SETTING_TYPE_BOOLEAN'] +APP_SETTING_TYPE_INTEGER = SODAR_CONSTANTS['APP_SETTING_TYPE_INTEGER'] +APP_SETTING_TYPE_JSON = SODAR_CONSTANTS['APP_SETTING_TYPE_JSON'] +APP_SETTING_TYPE_STRING = SODAR_CONSTANTS['APP_SETTING_TYPE_STRING'] + +# Local constants +DEF_SCOPE_INVALID = 'INVALID_SCOPE' +DEF_TYPE_INVALID = 'INVALID_TYPE' +DEF_NAME = 'test_app_setting' +DEF_LABEL = 'Label' +DEF_PLACEHOLDER = 'placeholder' +DEF_DESC = 'description' +DEF_WIDGET_ATTRS = {'class': 'text-danger'} +DEF_JSON_VALUE = { + 'Example': 'Value', + 'list': [1, 2, 3, 4, 5], + 'level_6': False, +} + + +# Callable default and option methods + + +def callable_default(project, user): + return True + + +def callable_options(project, user): + return [1, 2] + + +class TestPluginAppSettingDef(TestCase): + """Tests for PluginAppSettingDef""" + + def test_init(self): + """Test PluginAppSettingDef initialization""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_STRING, + ) + expected = { + 'name': DEF_NAME, + 'scope': APP_SETTING_SCOPE_PROJECT, + 'type': APP_SETTING_TYPE_STRING, + 'default': None, + 'label': None, + 'placeholder': None, + 'description': None, + 'options': [], + 'user_modifiable': True, + 'global_edit': False, + 'project_types': [PROJECT_TYPE_PROJECT], + 'widget_attrs': {}, + } + self.assertEqual(s_def.__dict__, expected) + + def test_init_no_defaults(self): + """Test init with no default values""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_STRING, + default='string', + label=DEF_LABEL, + placeholder=DEF_PLACEHOLDER, + description=DEF_DESC, + options=['string', 'string2'], + user_modifiable=False, + global_edit=True, + project_types=[PROJECT_TYPE_PROJECT, PROJECT_TYPE_CATEGORY], + widget_attrs=DEF_WIDGET_ATTRS, + ) + expected = { + 'name': DEF_NAME, + 'scope': APP_SETTING_SCOPE_PROJECT, + 'type': APP_SETTING_TYPE_STRING, + 'default': 'string', + 'label': DEF_LABEL, + 'placeholder': DEF_PLACEHOLDER, + 'description': DEF_DESC, + 'options': ['string', 'string2'], + 'user_modifiable': False, + 'global_edit': True, + 'project_types': [PROJECT_TYPE_PROJECT, PROJECT_TYPE_CATEGORY], + 'widget_attrs': DEF_WIDGET_ATTRS, + } + self.assertEqual(s_def.__dict__, expected) + + def test_init_invalid_scope(self): + """Test init with invalid scope""" + with self.assertRaises(ValueError): + PluginAppSettingDef( + name=DEF_NAME, + scope=DEF_SCOPE_INVALID, + type=APP_SETTING_TYPE_STRING, + ) + + def test_init_invalid_type(self): + """Test init with invalid type""" + with self.assertRaises(ValueError): + PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=DEF_TYPE_INVALID, + ) + + def test_init_invalid_option_type(self): + """Test init with invalid option type""" + with self.assertRaises(ValueError): + PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + options=['string', 'string2'], + ) + + def test_init_default_boolean(self): + """Test init with BOOLEAN type and valid default""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + default=True, + ) + self.assertIsNotNone(s_def) + + def test_init_default_boolean_invalid(self): + """Test init with BOOLEAN type and invalid default""" + with self.assertRaises(ValueError): + PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + default='abc', + ) + + def test_init_default_integer(self): + """Test init with INTEGER type and valid default""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_INTEGER, + default=0, + ) + self.assertIsNotNone(s_def) + + def test_init_default_integer_as_string(self): + """Test init with INTEGER type and valid default as string""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_INTEGER, + default='0', + ) + self.assertIsNotNone(s_def) + + def test_init_default_integer_invalid(self): + """Test init with INTEGER type and invalid default""" + with self.assertRaises(ValueError): + PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_INTEGER, + default='abc', + ) + + def test_init_default_json(self): + """Test init with JSON type and valid default""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_JSON, + default=DEF_JSON_VALUE, + ) + self.assertIsNotNone(s_def) + + def test_init_default_json_empty_dict(self): + """Test init with JSON type and valid empty dict default""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_JSON, + default={}, + ) + self.assertIsNotNone(s_def) + + def test_init_default_json_empty_list(self): + """Test init with JSON type and valid empty list default""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_JSON, + default=[], + ) + self.assertIsNotNone(s_def) + + def test_init_default_json_invalid(self): + """Test init with JSON type and invalid default""" + with self.assertRaises(ValueError): + PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_JSON, + default='{"x": "y"}', + ) + + def test_init_callable_default(self): + """Test init with callable default""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_STRING, + default=callable_default, + ) + self.assertIsNotNone(s_def) + + def test_init_callable_options(self): + """Test init with callable options""" + s_def = PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_INTEGER, + default=1, + options=callable_options, + ) + self.assertIsNotNone(s_def) + + def test_init_default_not_in_options(self): + """Test init with default value not in options""" + with self.assertRaises(ValueError): + PluginAppSettingDef( + name=DEF_NAME, + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_INTEGER, + default=0, + options=[1, 2], + )