From 873ac0f6d79d0238d977b75ade7cfd2f21c46a0f Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Wed, 8 Jan 2025 16:13:47 +0100 Subject: [PATCH] update app setting definitions (#1456) --- CHANGELOG.rst | 4 + adminalerts/plugins.py | 34 +- docs/source/dev_resource.rst | 89 ++-- docs/source/major_changes.rst | 23 +- example_project_app/plugins.py | 501 +++++++++--------- filesfolders/plugins.py | 23 +- projectroles/app_settings.py | 412 ++++++-------- projectroles/checks.py | 39 +- projectroles/forms.py | 89 ++-- .../management/commands/cleanappsettings.py | 4 +- ...01_squashed_0032_alter_appsetting_value.py | 4 +- projectroles/plugins.py | 202 ++++++- projectroles/tests/test_app_settings.py | 322 ++--------- projectroles/tests/test_plugins.py | 251 +++++++++ projectroles/tests/test_views.py | 56 +- projectroles/views.py | 21 +- projectroles/views_api.py | 14 +- userprofile/forms.py | 71 ++- userprofile/plugins.py | 21 +- userprofile/views.py | 14 +- 20 files changed, 1195 insertions(+), 999 deletions(-) create mode 100644 projectroles/tests/test_plugins.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 49236ef4..34ca0895 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,14 +14,18 @@ Added - **Projectroles** - ``SODARUser.get_display_name()`` helper (#1487) - App setting type constants (#1458) + - ``PluginAppSettingDef`` class for app setting definitions (#1456) + - Django check for unique app setting names within each plugin (#1456) Changed ------- - **General** - Use ``SODARAPI*`` API view base classes instead of ``CoreAPI*`` (#1401) + - Declare app setting definitions as ``PluginAppSettingDef`` objects (#1456) - **Projectroles** - Deprecate ``get_user_display_name()``, use ``SODARUser.get_display_name()`` (#1487) + - Deprecate declaring app setting definitions as dict (#1456) Removed ------- diff --git a/adminalerts/plugins.py b/adminalerts/plugins.py index 40be4917..77d7d297 100644 --- a/adminalerts/plugins.py +++ b/adminalerts/plugins.py @@ -5,7 +5,7 @@ # Projectroles dependency from projectroles.models import SODAR_CONSTANTS -from projectroles.plugins import SiteAppPluginPoint +from projectroles.plugins import SiteAppPluginPoint, PluginAppSettingDef from adminalerts.models import AdminAlert from adminalerts.urls import urlpatterns @@ -15,6 +15,21 @@ APP_SETTING_SCOPE_USER = SODAR_CONSTANTS['APP_SETTING_SCOPE_USER'] APP_SETTING_TYPE_BOOLEAN = SODAR_CONSTANTS['APP_SETTING_TYPE_BOOLEAN'] +# Local constants +ADMINALERTS_APP_SETTINGS = [ + PluginAppSettingDef( + name='notify_email_alert', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_BOOLEAN, + default=True, + label='Receive email for admin alerts', + description=( + 'Receive email for important administrator alerts regarding e.g. ' + 'site downtime.' + ), + ) +] + class SiteAppPlugin(SiteAppPluginPoint): """Projectroles plugin for registering the app""" @@ -28,21 +43,8 @@ class SiteAppPlugin(SiteAppPluginPoint): #: UI URLs urls = urlpatterns - #: App settings definition - app_settings = { - 'notify_email_alert': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': True, - 'label': 'Receive email for admin alerts', - 'description': ( - 'Receive email for important administrator alerts regarding ' - 'e.g. site downtime.' - ), - 'user_modifiable': True, - 'global': False, - } - } + #: App setting definitions + app_settings = ADMINALERTS_APP_SETTINGS #: Iconify icon icon = 'mdi:alert' diff --git a/docs/source/dev_resource.rst b/docs/source/dev_resource.rst index 1b6c3c8c..5adc6f6a 100644 --- a/docs/source/dev_resource.rst +++ b/docs/source/dev_resource.rst @@ -263,6 +263,8 @@ Example: setup! +.. _dev_resource_app_settings: + App Settings ============ @@ -272,59 +274,56 @@ variables changeable in runtime for different purposes and scopes without the need to manage additional Django models in your apps. App settings are supported for plugins in project and site apps. -The settings are defined as Python dictionaries in your project or site app's -plugin. An example of a definition can be seen below: +The settings are defined as a list of ``PluginAppSettingDef`` objects in your +project or site app plugin. An example of a definition can be seen below: .. code-block:: python + from projectroles.models import SODAR_CONSTANTS + from projectroles.plugins import ProjectAppPluginPoint, PluginAppSettingDef + class ProjectAppPlugin(ProjectAppPluginPoint): # ... - app_settings = { - 'allow_public_links': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': 'BOOLEAN', - 'default': False, - 'label': 'Allow public links', - 'description': 'Allow generation of public links for files', - 'user_modifiable': True, - } + app_settings = [ + PluginAppSettingDef( + name='allow_public_links', + scope=SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'], + type=SODAR_CONSTANTS['APP_SETTING_TYPE_BOOLEAN'], + default=False, + label='Allow public links', + description='Allow generation of public links for files', + ) } -Each setting must define a ``scope``. The options for this are as follows, as -defined in ``SODAR_CONSTANTS``: - -``APP_SETTING_SCOPE_PROJECT`` - Setting related to a project and displayed in the project create/update - form. -``APP_SETTING_SCOPE_USER`` - Site-wide setting related to a user and displayed in the user profile form. -``APP_SETTING_SCOPE_PROJECT_USER`` - User setting related to a project, managed by your project app. -``APP_SETTING_SCOPE_SITE`` - Site-wide setting similar to Django settings but modifiable in runtime. - -The rest of the attributes are listed below: +The mandatory and optional attributes for an app setting definition are as +follows: +``name`` + Name for the setting. Preferably something short and clear to call in code. + Name must be unique within the settings of each plugin. +``scope`` + Scope of the setting, which determines whether the setting is unique per + project, user, project and user, or site. Must correspond to one of + ``APP_SETTING_SCOPE_*`` in ``SODAR_CONSTANTS``, see below for details + (string) ``type`` - Value type for the settings. Available options are ``BOOLEAN``, - ``INTEGER``, ``STRING`` and ``JSON``. + Setting type, must correspond to one of ``APP_SETTING_TYPE_*`` in + ``SODAR_CONSTANTS``. Available options are ``APP_SETTING_TYPE_BOOLEAN``, + ``APP_SETTING_TYPE_INTEGER``, ``APP_SETTING_TYPE_STRING`` and + ``APP_SETTING_TYPE_JSON``. ``default`` Default value for the setting. This is returned if no value has been set. Can alternatively be a callable with the signature ``callable(project=None, user=None)``. -``global`` - Boolean for allowing/disallowing editing in target sites for remote - projects. Relevant to ``SOURCE`` sites. If set ``True``, the value can not - be edited on target sites, the default value being ``False`` (optional). ``label`` - Label string to be displayed in forms for ``PROJECT`` and ``USER`` scope - settings (optional). + Label to be displayed in forms for ``PROJECT`` and ``USER`` scope settings + (string, optional). ``placeholder`` Placeholder string to be displayed in forms for ``PROJECT`` and ``USER`` scope settings (optional). ``description`` Description string shown in forms for ``PROJECT`` and ``USER`` scope - settings (optional). + settings (string, optional). ``options`` List of selectable options for ``INTEGER`` and ``STRING`` type settings. Can alternatively be a callable with the signature @@ -335,9 +334,27 @@ The rest of the attributes are listed below: user forms. If false, will be displayed only to superusers. Set to true if your app is expected to manage this setting. Applicable for ``PROJECT`` and ``USER`` scope settings (optional). +``global_edit`` + Allowing/disallow editing the setting on target sites for remote projects. + Relevant to ``SOURCE`` sites. If set ``True``, the value can not be edited + on target sites, the default value being ``False`` (boolean, optional). ``project_types`` - List of project types this setting is allowed to be set for. Defaults to - ``[PROJECT_TYPE_PROJECT]`` (optional). + Project types for which this setting is allowed to be set. Defaults to + ``[PROJECT_TYPE_PROJECT]`` (list of strings, optional). +``widget_attrs`` + Form widget attributes (optional, dict) + +Available project scopes for the ``scope`` attribute: + +``APP_SETTING_SCOPE_PROJECT`` + Setting related to a project and displayed in the project create/update + form. +``APP_SETTING_SCOPE_USER`` + Site-wide setting related to a user and displayed in the user profile form. +``APP_SETTING_SCOPE_PROJECT_USER`` + User setting related to a project, managed by your project app. +``APP_SETTING_SCOPE_SITE`` + Site-wide setting similar to Django settings but modifiable in runtime. The settings are retrieved using ``AppSettingAPI`` provided by the projectroles app. Below is an example of invoking the API. For the full API diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index fb39baec..ef8f6a8f 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -17,17 +17,32 @@ Release Highlights ================== - Add app setting type constants +- Add app setting definition as objects - Remove support for features deprecated in v1.0 - Remove squashed migrations Breaking Changes ================ +AppSettingAPI Definition Getter Return Data +------------------------------------------- + +With the upgrade of app setting definitions to ``PluginAppSettingDef`` objects +instead of dict, the return data of ``AppSettingAPI.get_definition()`` and +``AppSettingAPI.get_definitions()`` will contain definitions as objects of this +class. The return data is the same even if definitions have been provided in the +deprecated dictionary format. + Deprecated Features ------------------- These features have been deprecated in v1.1 and will be removed in v1.2. +App Setting Definitions as Dict + Declaring definitions for app settings as a dict has been deprecated. + Instead, you should provide a list of ``PluginAppSettingDef`` objects. See + the :ref:`app settings documentation ` for + details. ``projectroles.utils.get_user_display_name()`` This utility method has been deprecated. Please use ``SODARUser.get_display_name()`` instead. @@ -38,10 +53,10 @@ Previously Deprecated Features Removed These features were deprecated in v1.0 and have been removed in v1.1. Legacy API Versioning and Rendering - The following API base classes and mixins are removed: ``SODARAPIVersioning``, - ``SODARAPIRenderer`` and ``SODARAPIBaseMixin``. The legacy ``SODAR_API_*`` - settings have also been removed. You need to provide your own versioning and - renderers to your site's API(s). + The following API base classes and mixins are removed: + ``SODARAPIVersioning``, ``SODARAPIRenderer`` and ``SODARAPIBaseMixin``. The + legacy ``SODAR_API_*`` settings have also been removed. You need to provide + your own versioning and renderers to your site's API(s). Plugin Search Return Data Plugins implementing ``search()`` must return results as as a list of ``PluginSearchResult`` objects. Returning a ``dict`` was deprecated in v1.0. diff --git a/example_project_app/plugins.py b/example_project_app/plugins.py index dabb5a0f..ae59ac03 100644 --- a/example_project_app/plugins.py +++ b/example_project_app/plugins.py @@ -12,6 +12,7 @@ from projectroles.plugins import ( ProjectAppPluginPoint, ProjectModifyPluginMixin, + PluginAppSettingDef, ) from projectroles.utils import get_display_name @@ -41,6 +42,261 @@ INVALID_SETTING_MSG = 'INVALID_SETTING_VALUE detected' +# TODO: Unify naming +EXAMPLE_PROJECT_APP_SETTINGS = [ + PluginAppSettingDef( + name='project_str_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_STRING, + label='String setting', + default='', + description='Example string project setting', + placeholder='Example string', + ), + PluginAppSettingDef( + name='project_int_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_INTEGER, + label='Integer setting', + default=0, + description='Example integer project setting', + placeholder=0, + widget_attrs={'class': 'text-success'}, + ), + PluginAppSettingDef( + name='project_str_setting_options', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_STRING, + label='String setting with options', + default='string1', + description='Example string project setting with options', + options=['string1', 'string2'], + ), + PluginAppSettingDef( + name='project_int_setting_options', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_INTEGER, + label='Integer setting with options', + default=0, + description='Example integer project setting with options', + options=[0, 1], + widget_attrs={'class': 'text-success'}, + ), + PluginAppSettingDef( + name='project_bool_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + label='Boolean setting', + default=False, + description='Example boolean project setting', + ), + PluginAppSettingDef( + name='project_global_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + label='Global boolean setting', + default=False, + description='Example global boolean project setting', + global_edit=True, + ), + PluginAppSettingDef( + name='project_json_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_JSON, + label='JSON setting', + default={ + 'Example': 'Value', + 'list': [1, 2, 3, 4, 5], + 'level_6': False, + }, + description='Example JSON project setting', + widget_attrs={'class': 'text-danger'}, + ), + PluginAppSettingDef( + name='project_hidden_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_STRING, + label='Hidden setting', + default='', + description='Example hidden project setting', + user_modifiable=False, + ), + PluginAppSettingDef( + name='project_hidden_json_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_JSON, + label='Hidden JSON setting', + description='Example hidden JSON project setting', + user_modifiable=False, + ), + PluginAppSettingDef( + name='user_str_setting', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_STRING, + label='String setting', + default='', + description='Example string user setting', + placeholder='Example string', + ), + PluginAppSettingDef( + name='user_int_setting', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_INTEGER, + label='Integer setting', + default=0, + description='Example integer user setting', + placeholder=0, + widget_attrs={'class': 'text-success'}, + ), + PluginAppSettingDef( + name='user_str_setting_options', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_STRING, + label='String setting with options', + default='string1', + options=['string1', 'string2'], + description='Example string user setting with options', + ), + PluginAppSettingDef( + name='user_int_setting_options', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_INTEGER, + label='Integer setting with options', + default=0, + options=[0, 1], + description='Example integer user setting with options', + widget_attrs={'class': 'text-success'}, + ), + PluginAppSettingDef( + name='user_bool_setting', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_BOOLEAN, + label='Boolean setting', + default=False, + description='Example boolean user setting', + ), + PluginAppSettingDef( + name='user_json_setting', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_JSON, + label='JSON setting', + default={ + 'Example': 'Value', + 'list': [1, 2, 3, 4, 5], + 'level_6': False, + }, + description='Example JSON user setting', + widget_attrs={'class': 'text-danger'}, + ), + PluginAppSettingDef( + name='user_hidden_setting', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_STRING, + default='', + description='Example hidden user setting', + user_modifiable=False, + ), + PluginAppSettingDef( + name='project_user_str_setting', + scope=APP_SETTING_SCOPE_PROJECT_USER, + type=APP_SETTING_TYPE_STRING, + default='', + description='Example string project user setting', + ), + PluginAppSettingDef( + name='project_user_int_setting', + scope=APP_SETTING_SCOPE_PROJECT_USER, + type=APP_SETTING_TYPE_INTEGER, + default=0, + description='Example int project user setting', + ), + PluginAppSettingDef( + name='project_user_bool_setting', + scope=APP_SETTING_SCOPE_PROJECT_USER, + type=APP_SETTING_TYPE_BOOLEAN, + default=False, + description='Example bool project user setting', + ), + PluginAppSettingDef( + name='project_user_json_setting', + scope=APP_SETTING_SCOPE_PROJECT_USER, + type=APP_SETTING_TYPE_JSON, + default={ + 'Example': 'Value', + 'list': [1, 2, 3, 4, 5], + 'level_6': False, + }, + description='Example JSON project user setting', + ), + PluginAppSettingDef( + name='project_callable_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_STRING, + label='Callable project setting', + default=get_example_setting_default, + description='Example callable project setting', + ), + PluginAppSettingDef( + name='user_callable_setting', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_STRING, + label='Callable user setting', + default=get_example_setting_default, + description='Example callable user setting', + ), + PluginAppSettingDef( + name='project_user_callable_setting', + scope=APP_SETTING_SCOPE_PROJECT_USER, + type=APP_SETTING_TYPE_STRING, + default=get_example_setting_default, + description='Example callable project user setting', + ), + PluginAppSettingDef( + name='project_callable_setting_options', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_STRING, + label='Callable setting with options', + default=get_example_setting_default, + options=get_example_setting_options, + description='Example callable project setting with options', + ), + PluginAppSettingDef( + name='user_callable_setting_options', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_STRING, + label='Callable setting with options', + default=get_example_setting_default, + options=get_example_setting_options, + description='Example callable user setting with options', + ), + PluginAppSettingDef( + name='project_user_callable_setting_options', + scope=APP_SETTING_SCOPE_PROJECT_USER, + type=APP_SETTING_TYPE_STRING, + default=get_example_setting_default, + options=get_example_setting_options, + description='Example callable project user setting with options', + ), + PluginAppSettingDef( + name='category_bool_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + label='Category boolean setting', + default=False, + description='Example boolean project category setting', + project_types=[PROJECT_TYPE_CATEGORY], + ), + PluginAppSettingDef( + name='site_bool_setting', + scope=APP_SETTING_SCOPE_SITE, + type=APP_SETTING_TYPE_BOOLEAN, + label='Site boolean setting', + default=False, + description='Example boolean site setting', + ), +] + + class ProjectAppPlugin(ProjectModifyPluginMixin, ProjectAppPluginPoint): """Plugin for registering app with Projectroles""" @@ -57,249 +313,8 @@ class ProjectAppPlugin(ProjectModifyPluginMixin, ProjectAppPluginPoint): # Properties defined in ProjectAppPluginPoint ----------------------- - #: Project and user settings - # TODO: Unify naming - app_settings = { - 'project_str_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'String setting', - 'default': '', - 'description': 'Example string project setting', - 'placeholder': 'Example string', - 'user_modifiable': True, - }, - 'project_int_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_INTEGER, - 'label': 'Integer setting', - 'default': 0, - 'description': 'Example integer project setting', - 'user_modifiable': True, - 'placeholder': 0, - 'widget_attrs': {'class': 'text-success'}, - }, - 'project_str_setting_options': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'String setting with options', - 'default': 'string1', - 'description': 'Example string project setting with options', - 'options': ['string1', 'string2'], - 'user_modifiable': True, - }, - 'project_int_setting_options': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_INTEGER, - 'label': 'Integer setting with options', - 'default': 0, - 'description': 'Example integer project setting with options', - 'options': [0, 1], - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-success'}, - }, - 'project_bool_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Boolean setting', - 'default': False, - 'description': 'Example boolean project setting', - 'user_modifiable': True, - }, - 'project_global_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Global boolean setting', - 'default': False, - 'description': 'Example global boolean project setting', - 'user_modifiable': True, - 'global': True, - }, - 'project_json_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_JSON, - 'label': 'JSON setting', - 'default': { - 'Example': 'Value', - 'list': [1, 2, 3, 4, 5], - 'level_6': False, - }, - 'description': 'Example JSON project setting', - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-danger'}, - }, - 'project_hidden_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Hidden setting', - 'default': '', - 'description': 'Example hidden project setting', - 'user_modifiable': False, - }, - 'project_hidden_json_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_JSON, - 'label': 'Hidden JSON setting', - 'description': 'Example hidden JSON project setting', - 'user_modifiable': False, - }, - 'user_str_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'String setting', - 'default': '', - 'description': 'Example string user setting', - 'placeholder': 'Example string', - 'user_modifiable': True, - }, - 'user_int_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_INTEGER, - 'label': 'Integer setting', - 'default': 0, - 'description': 'Example integer user setting', - 'placeholder': 0, - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-success'}, - }, - 'user_str_setting_options': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'String setting with options', - 'default': 'string1', - 'options': ['string1', 'string2'], - 'description': 'Example string user setting with options', - 'user_modifiable': True, - }, - 'user_int_setting_options': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_INTEGER, - 'label': 'Integer setting with options', - 'default': 0, - 'options': [0, 1], - 'description': 'Example integer user setting with options', - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-success'}, - }, - 'user_bool_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Boolean setting', - 'default': False, - 'description': 'Example boolean user setting', - 'user_modifiable': True, - }, - 'user_json_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_JSON, - 'label': 'JSON setting', - 'default': { - 'Example': 'Value', - 'list': [1, 2, 3, 4, 5], - 'level_6': False, - }, - 'description': 'Example JSON user setting', - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-danger'}, - }, - 'user_hidden_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'default': '', - 'description': 'Example hidden user setting', - 'user_modifiable': False, - }, - 'project_user_str_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_STRING, - 'default': '', - 'description': 'Example string project user setting', - }, - 'project_user_int_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_INTEGER, - 'default': 0, - 'description': 'Example int project user setting', - }, - 'project_user_bool_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'description': 'Example bool project user setting', - }, - 'project_user_json_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_JSON, - 'default': { - 'Example': 'Value', - 'list': [1, 2, 3, 4, 5], - 'level_6': False, - }, - 'description': 'Example json project user setting', - }, - 'project_callable_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Callable project setting', - 'default': get_example_setting_default, - 'description': 'Example callable project setting', - }, - 'user_callable_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Callable user setting', - 'default': get_example_setting_default, - 'description': 'Example callable user setting', - }, - 'project_user_callable_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_STRING, - 'default': get_example_setting_default, - 'description': 'Example callable project user setting', - }, - 'project_callable_setting_options': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Callable setting with options', - 'default': get_example_setting_default, - 'options': get_example_setting_options, - 'description': 'Example callable project setting with options', - 'user_modifiable': True, - }, - 'user_callable_setting_options': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Callable setting with options', - 'default': get_example_setting_default, - 'options': get_example_setting_options, - 'description': 'Example callable user setting with options', - 'user_modifiable': True, - }, - 'project_user_callable_setting_options': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_STRING, - 'default': get_example_setting_default, - 'options': get_example_setting_options, - 'description': 'Example callable project user setting with options', - }, - 'category_bool_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Category boolean setting', - 'default': False, - 'description': 'Example boolean project category setting', - 'user_modifiable': True, - 'project_types': [PROJECT_TYPE_CATEGORY], - }, - 'site_bool_setting': { - 'scope': APP_SETTING_SCOPE_SITE, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Site boolean setting', - 'default': False, - 'description': 'Example boolean site setting', - 'user_modifiable': True, - }, - } + #: App setting definitions + app_settings = EXAMPLE_PROJECT_APP_SETTINGS #: Iconify icon icon = 'mdi:rocket-launch' diff --git a/filesfolders/plugins.py b/filesfolders/plugins.py index 7cb5d06d..b0dcfdec 100644 --- a/filesfolders/plugins.py +++ b/filesfolders/plugins.py @@ -5,6 +5,7 @@ from projectroles.models import SODAR_CONSTANTS from projectroles.plugins import ( ProjectAppPluginPoint, + PluginAppSettingDef, PluginObjectLink, PluginSearchResult, ) @@ -37,17 +38,17 @@ class ProjectAppPlugin(ProjectAppPluginPoint): # Properties defined in ProjectAppPluginPoint ----------------------- - #: App settings definition - app_settings = { - 'allow_public_links': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'label': 'Allow public links', - 'description': 'Allow generation of public links for files', - 'user_modifiable': True, - } - } + #: App setting definitions + app_settings = [ + PluginAppSettingDef( + name='allow_public_links', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + default=False, + label='Allow public links', + description='Allow generation of public links for files', + ) + ] #: Iconify icon icon = 'mdi:file' diff --git a/projectroles/app_settings.py b/projectroles/app_settings.py index 4943083b..5587f74c 100644 --- a/projectroles/app_settings.py +++ b/projectroles/app_settings.py @@ -5,8 +5,12 @@ from django.conf import settings -from projectroles.models import AppSetting, APP_SETTING_TYPES, SODAR_CONSTANTS -from projectroles.plugins import get_app_plugin, get_active_plugins +from projectroles.models import AppSetting, SODAR_CONSTANTS +from projectroles.plugins import ( + PluginAppSettingDef, + get_app_plugin, + get_active_plugins, +) from projectroles.utils import get_display_name @@ -31,12 +35,6 @@ # Local constants APP_SETTING_GLOBAL_DEFAULT = False -APP_SETTING_SCOPES = [ - APP_SETTING_SCOPE_PROJECT, - APP_SETTING_SCOPE_USER, - APP_SETTING_SCOPE_PROJECT_USER, - APP_SETTING_SCOPE_SITE, -] APP_SETTING_DEFAULT_VALUES = { APP_SETTING_TYPE_BOOLEAN: False, APP_SETTING_TYPE_INTEGER: 0, @@ -56,94 +54,85 @@ GLOBAL_USER_ERR_MSG = ( 'Overriding global user settings on target site not allowed' ) +DEF_DICT_DEPRECATE_MSG = ( + 'Defining app settings as dict is deprecated and will be removed in v1.2. ' + 'Provide definitions as a list of PluginAppSettingDef ' + 'objects (plugin={plugin_name})' +) # Define App Settings for projectroles app -PROJECTROLES_APP_SETTINGS = { - #: App settings definition - #: - #: Example :: - #: - #: 'example_setting': { - #: 'scope': 'PROJECT', # PROJECT/USER/PROJECT-USER/SITE - #: 'type': APP_SETTING_TYPE_STRING, # STRING/INTEGER/BOOLEAN/JSON - #: 'default': 'example', - #: 'label': 'Project setting', # Optional, defaults to name/key - #: 'placeholder': 'Enter example setting here', # Optional - #: 'description': 'Example project setting', # Optional - #: 'options': ['example', 'example2'], # Optional, only for - #: settings of type STRING or INTEGER - #: 'user_modifiable': True, # Optional, show/hide in forms - #: 'global': True, # Only allow editing on target sites if False - #: # (optional, default True) - #: 'project_types': [PROJECT_TYPE_PROJECT], # Optional, list may - #: contain PROJECT_TYPE_CATEGORY and/or PROJECT_TYPE_PROJECT - #: } - 'ip_restrict': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'label': 'IP restrict', - 'description': 'Restrict project access by an allowed IP list', - 'user_modifiable': True, - 'global': True, - }, - 'ip_allowlist': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_JSON, - 'default': [], - 'label': 'IP allow list', - 'description': 'List of allowed IPs for project access', - 'user_modifiable': True, - 'global': True, - }, - 'project_star': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'global': False, - 'project_types': [PROJECT_TYPE_PROJECT, PROJECT_TYPE_CATEGORY], - }, - 'notify_email_project': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': True, - 'label': 'Receive email for {} updates'.format( +PROJECTROLES_APP_SETTINGS = [ + PluginAppSettingDef( + name='ip_restrict', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + default=False, + label='IP restrict', + description='Restrict project access by an allowed IP list', + user_modifiable=True, + global_edit=True, + ), + PluginAppSettingDef( + name='ip_allowlist', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_JSON, + default=[], + label='IP allow list', + description='List of allowed IPs for project access', + user_modifiable=True, + global_edit=True, + ), + PluginAppSettingDef( + name='project_star', + scope=APP_SETTING_SCOPE_PROJECT_USER, + type=APP_SETTING_TYPE_BOOLEAN, + default=False, + global_edit=False, + project_types=[PROJECT_TYPE_PROJECT, PROJECT_TYPE_CATEGORY], + ), + PluginAppSettingDef( + name='notify_email_project', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_BOOLEAN, + default=True, + label='Receive email for {} updates'.format( get_display_name(PROJECT_TYPE_PROJECT) ), - 'description': ( + description=( 'Receive email notifications for {} or {} creation, updating, ' 'moving and archiving.'.format( get_display_name(PROJECT_TYPE_CATEGORY), get_display_name(PROJECT_TYPE_PROJECT), ) ), - 'user_modifiable': True, - 'global': True, - }, - 'notify_email_role': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': True, - 'label': 'Receive email for {} membership updates'.format( + user_modifiable=True, + global_edit=True, + ), + PluginAppSettingDef( + name='notify_email_role', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_BOOLEAN, + default=True, + label='Receive email for {} membership updates'.format( get_display_name(PROJECT_TYPE_PROJECT) ), - 'description': ( + description=( 'Receive email notifications for {} or {} membership updates and ' 'invitation activity.'.format( get_display_name(PROJECT_TYPE_CATEGORY), get_display_name(PROJECT_TYPE_PROJECT), ) ), - 'user_modifiable': True, - 'global': True, - }, -} + user_modifiable=True, + global_edit=True, + ), +] class AppSettingAPI: @classmethod - def _check_project_and_user(cls, scope, project, user): + def _validate_project_and_user(cls, scope, project, user): """ Ensure project and user parameters are set according to scope. @@ -157,53 +146,16 @@ def _check_project_and_user(cls, scope, project, user): 'user': user is not None, }: raise ValueError( - 'Project and/or user are set incorrect for setting ' - 'with {} scope'.format(scope) - ) - - @classmethod - def _check_scope(cls, scope): - """ - Ensure the validity of a scope definition. - - :param scope: String - :raise: ValueError if scope is not recognized - """ - if scope not in APP_SETTING_SCOPES: - raise ValueError('Invalid scope "{}"'.format(scope)) - - @classmethod - def _check_type(cls, setting_type): - """ - Ensure the validity of app setting type. - - :param setting_type: String - :raise: ValueError if type is not recognized - """ - if setting_type not in APP_SETTING_TYPES: - raise ValueError('Invalid setting type "{}"'.format(setting_type)) - - @classmethod - def _check_type_options(cls, setting_type, setting_options): - """ - Ensure setting_type is allowed to have options. - - :param setting_type: String - :param setting_options: List of options (Strings or Integers) - :raise: ValueError if type is not recognized - """ - if ( - setting_type - not in [APP_SETTING_TYPE_INTEGER, APP_SETTING_TYPE_STRING] - and setting_options - ): - raise ValueError( - 'Options are only allowed for settings of type INTEGER and ' - 'STRING' + 'Project and/or user invalid for setting with {} scope ' + '(project={}, user={})'.format( + scope, + project.get_log_title() if project else None, + user.username if user else None, + ) ) @classmethod - def _check_value_in_options( + def _validate_value_in_options( cls, setting_value, setting_options, project=None, user=None ): """ @@ -257,7 +209,7 @@ def _get_app_plugin(cls, plugin_name): @classmethod def _get_defs(cls, plugin=None, plugin_name=None): """ - Ensure valid argument values for a settings def query. + Get app setting definitions for a plugin. :param plugin: Plugin object or None :param plugin_name: Name of the app plugin (string or None) @@ -270,7 +222,31 @@ def _get_defs(cls, plugin=None, plugin_name=None): return cls.get_projectroles_defs() if not plugin: plugin = cls._get_app_plugin(plugin_name) - return plugin.app_settings + s_defs = plugin.app_settings + # TODO: Remove definition dict support in in v1.2 (#1532) + if isinstance(s_defs, dict): + logger.warning( + DEF_DICT_DEPRECATE_MSG.format(plugin_name=plugin.name) + ) + return { + k: PluginAppSettingDef( + name=k, + scope=v.get('scope'), + type=v.get('type'), + default=v.get('default'), + label=v.get('label'), + placeholder=v.get('placeholder'), + description=v.get('description'), + options=v.get('options'), + user_modifiable=v.get('user_modifiable', True), + global_edit=v.get('global', APP_SETTING_GLOBAL_DEFAULT), + project_types=v.get( + 'project_types', [PROJECT_TYPE_PROJECT] + ), + ) + for k, v in s_defs.items() + } + return {s.name: s for s in s_defs} @classmethod def _get_json_value(cls, value): @@ -339,45 +315,32 @@ def get_default( :raise: KeyError if nothing is found with setting_name """ if plugin_name == 'projectroles': - app_settings = cls.get_projectroles_defs() + s_defs = cls.get_projectroles_defs() else: - app_plugin = get_app_plugin(plugin_name) - if not app_plugin: - raise ValueError( - 'App plugin not found: "{}"'.format(plugin_name) - ) - app_settings = app_plugin.app_settings - if setting_name not in app_settings: + s_defs = cls._get_defs(plugin_name=plugin_name) + if setting_name not in s_defs: raise KeyError( 'Setting "{}" not found in app plugin "{}"'.format( setting_name, plugin_name ) ) - - if callable(app_settings[setting_name].get('default')): + s_def = s_defs[setting_name] + if callable(s_def.default): try: - callable_setting = app_settings[setting_name]['default'] - return callable_setting(project, user) + return s_def.default(project, user) except Exception: logger.error( 'Error in callable setting "{}" for plugin "{}"'.format( setting_name, plugin_name ) ) - return APP_SETTING_DEFAULT_VALUES[ - app_settings[setting_name]['type'] - ] - elif app_settings[setting_name]['type'] == APP_SETTING_TYPE_JSON: - json_default = app_settings[setting_name].get('default') - if not json_default: - if isinstance(json_default, dict): - return {} - elif isinstance(json_default, list): - return [] + return APP_SETTING_DEFAULT_VALUES[s_def.type] + elif s_def.type == APP_SETTING_TYPE_JSON: + if s_def.default is None: return {} if post_safe: - return json.dumps(app_settings[setting_name]['default']) - return app_settings[setting_name]['default'] + return json.dumps(s_def.default) + return s_def.default @classmethod def get( @@ -421,6 +384,7 @@ def get( return json.dumps(val) return val + # TODO: Remove, deprecate or refactor (see #1534) @classmethod def get_all(cls, project=None, user=None, post_safe=False): """ @@ -439,18 +403,18 @@ def get_all(cls, project=None, user=None, post_safe=False): ret = {} app_plugins = get_active_plugins() for plugin in app_plugins: - p_settings = cls.get_definitions( + p_defs = cls.get_definitions( APP_SETTING_SCOPE_PROJECT, plugin=plugin ) - for s_key in p_settings: + for s_key in p_defs: ret['settings.{}.{}'.format(plugin.name, s_key)] = cls.get( plugin.name, s_key, project, user, post_safe ) - p_settings = cls.get_definitions( + p_defs = cls.get_definitions( APP_SETTING_SCOPE_PROJECT, plugin_name='projectroles' ) - for s_key in p_settings: + for s_key in p_defs: ret['settings.{}.{}'.format('projectroles', s_key)] = cls.get( 'projectroles', s_key, post_safe ) @@ -467,13 +431,13 @@ def get_defaults(cls, scope, project=None, user=None, post_safe=False): :param post_safe: Whether POST safe values should be returned (bool) :return: Dict """ - cls._check_scope(scope) + PluginAppSettingDef.validate_scope(scope) ret = {} app_plugins = get_active_plugins() for plugin in app_plugins: - p_settings = cls.get_definitions(scope, plugin=plugin) - for s_key in p_settings: + p_defs = cls.get_definitions(scope, plugin=plugin) + for s_key in p_defs: ret['settings.{}.{}'.format(plugin.name, s_key)] = ( cls.get_default( plugin.name, @@ -484,8 +448,8 @@ def get_defaults(cls, scope, project=None, user=None, post_safe=False): ) ) - p_settings = cls.get_definitions(scope, plugin_name='projectroles') - for s_key in p_settings: + p_defs = cls.get_definitions(scope, plugin_name='projectroles') + for s_key in p_defs: ret['settings.{}.{}'.format('projectroles', s_key)] = ( cls.get_default( 'projectroles', @@ -524,24 +488,16 @@ def set( :raise: KeyError if setting name is not found in plugin specification """ s_def = cls.get_definition(name=setting_name, plugin_name=plugin_name) - cls._check_scope(s_def.get('scope', None)) - cls._check_project_and_user(s_def.get('scope', None), project, user) + cls._validate_project_and_user(s_def.scope, project, user) # Check project type - if ( - project - and not s_def.get('project_types', None) - and not project.type == PROJECT_TYPE_PROJECT - or project - and s_def.get('project_types', None) - and project.type not in s_def['project_types'] - ): + if project and project.type not in s_def.project_types: raise ValueError( 'Project type {} not allowed for setting {}'.format( project.type, setting_name ) ) # Prevent updating global setting on target site - if cls.get_global_value(s_def): + if s_def.global_edit: if project and project.is_remote(): raise ValueError(GLOBAL_PROJECT_ERR_MSG) if ( @@ -564,7 +520,7 @@ def set( cls.validate( setting.type, value, - s_def.get('options'), + s_def.options, project=project, user=user, ) @@ -579,11 +535,11 @@ def set( return True except AppSetting.DoesNotExist: # Create new - s_type = s_def['type'] + s_type = s_def.type if plugin_name == 'projectroles': app_plugin_model = None else: - app_plugin = get_app_plugin(plugin_name) + app_plugin = cls._get_app_plugin(plugin_name) app_plugin_model = app_plugin.get_model() if validate: v = ( @@ -594,15 +550,11 @@ def set( cls.validate( s_type, v, - s_def.get('options'), + s_def.options, project=project, user=user, ) - s_mod = ( - bool(s_def['user_modifiable']) - if 'user_modifiable' in s_def - else True - ) + s_mod = bool(s_def.user_modifiable) s_vals = { 'app_plugin': app_plugin_model, 'project': project, @@ -636,7 +588,7 @@ def is_set(cls, plugin_name, setting_name, project=None, user=None): :return: Boolean """ s_def = cls.get_definition(name=setting_name, plugin_name=plugin_name) - cls._check_project_and_user(s_def.get('scope', None), project, user) + cls._validate_project_and_user(s_def.scope, project, user) q_kwargs = {'name': setting_name, 'project': project, 'user': user} if not plugin_name == 'projectroles': q_kwargs['app_plugin__name'] = plugin_name @@ -656,11 +608,9 @@ def delete(cls, plugin_name, setting_name, project=None, user=None): :param user: User object to delete setting from (optional) :raise: ValueError with invalid project/user args """ - setting_def = cls.get_definition(setting_name, plugin_name=plugin_name) - if setting_def['scope'] != APP_SETTING_SCOPE_PROJECT_USER: - cls._check_project_and_user( - setting_def.get('scope', None), project, user - ) + s_def = cls.get_definition(setting_name, plugin_name=plugin_name) + if s_def.scope != APP_SETTING_SCOPE_PROJECT_USER: + cls._validate_project_and_user(s_def.scope, project, user) elif not project: raise ValueError( 'Project must be set for {} scope settings'.format( @@ -678,12 +628,10 @@ def delete(cls, plugin_name, setting_name, project=None, user=None): ) ) app_settings = AppSetting.objects.filter(**q_kwargs) - s_count = app_settings.count() + sc = app_settings.count() app_settings.delete() logger.debug( - 'Deleted {} app setting{}'.format( - s_count, 's' if s_count != 1 else '' - ) + 'Deleted {} app setting{}'.format(sc, 's' if sc != 1 else '') ) @classmethod @@ -701,19 +649,12 @@ def delete_by_scope( :param user: User object to delete setting from :raise: ValueError if scope, project or user are incorrect """ - if not scope: - raise ValueError('Scope must be set') - cls._check_scope(scope) - cls._check_project_and_user(scope, project, user) + PluginAppSettingDef.validate_scope(scope) + cls._validate_project_and_user(scope, project, user) for plugin_name, app_settings in cls.get_all_defs().items(): - for setting_name, setting_def in app_settings.items(): - if setting_def['scope'] == scope: - cls.delete( - plugin_name, - setting_name, - project=project, - user=user, - ) + for s_name, s_def in app_settings.items(): + if s_def.scope == scope: + cls.delete(plugin_name, s_name, project=project, user=user) @classmethod def validate( @@ -727,47 +668,22 @@ 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) :param user: User object (optional) :raise: ValueError if setting_type or setting_value is invalid """ - cls._check_type(setting_type) - cls._check_type_options(setting_type, setting_options) - cls._check_value_in_options( + PluginAppSettingDef.validate_type(setting_type) + cls._validate_value_in_options( setting_value, setting_options, project=project, user=user ) - - # Check 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 @@ -790,10 +706,7 @@ def get_definition(cls, name, plugin=None, plugin_name=None): plugin_name or plugin.name, name ) ) - ret = defs[name] - cls._check_type(ret['type']) - cls._check_type_options(ret['type'], ret.get('options')) - return ret + return defs[name] @classmethod def get_definitions( @@ -815,28 +728,13 @@ def get_definitions( :raise: ValueError if scope is invalid or if neither plugin_name nor plugin are set """ - cls._check_scope(scope) + PluginAppSettingDef.validate_scope(scope) defs = cls._get_defs(plugin, plugin_name) - ret = { + return { k: v for k, v in defs.items() - if ( - 'scope' in v - and v['scope'] == scope - and ( - not user_modifiable - or ( - 'user_modifiable' not in v - or v['user_modifiable'] is True - ) - ) - ) + if v.scope == scope and (not user_modifiable or v.user_modifiable) } - # Ensure type validity - for k, v in ret.items(): - cls._check_type(v['type']) - cls._check_type_options(v['type'], v.get('options')) - return ret @classmethod def get_projectroles_defs(cls): @@ -853,7 +751,7 @@ def get_projectroles_defs(cls): ) except AttributeError: app_settings = PROJECTROLES_APP_SETTINGS - return app_settings + return {s.name: s for s in app_settings} @classmethod def get_all_defs(cls): @@ -870,9 +768,10 @@ def get_all_defs(cls): + get_active_plugins('site_app') ) for p in plugins: - ret[p.name] = p.app_settings + ret[p.name] = cls._get_defs(p) return ret + # TODO: Remove (see #1533) @classmethod def get_global_value(cls, setting_def): """ @@ -882,7 +781,7 @@ def get_global_value(cls, setting_def): :param setting_def: Dict :return: Boolean """ - return setting_def.get('global', APP_SETTING_GLOBAL_DEFAULT) + return setting_def.global_edit @classmethod def compare_value(cls, obj, input_value): @@ -931,12 +830,9 @@ def get_example_setting_options(project=None, user=None): :param user: User object :return: List of tuples for ChoiceField """ - response = [ - ('N/A', 'No project or user for callable'), - 'Example string option', - ] + ret = [('N/A', 'No project or user for callable'), 'Example string option'] if project and user: - response.append( + ret.append( ( str(project.sodar_uuid), 'Project UUID {} by {}'.format( @@ -945,14 +841,14 @@ def get_example_setting_options(project=None, user=None): ) ) elif project: - response.append( + ret.append( ( str(project.sodar_uuid), 'Project UUID: {}'.format(project.sodar_uuid), ) ) elif user: - response.append( + ret.append( (str(user.sodar_uuid), 'User UUID: {}'.format(user.sodar_uuid)) ) - return response + return ret diff --git a/projectroles/checks.py b/projectroles/checks.py index c98ed831..077a2481 100644 --- a/projectroles/checks.py +++ b/projectroles/checks.py @@ -1,7 +1,10 @@ """Django checks for the projectroles app""" from django.conf import settings -from django.core.checks import Warning, register +from django.core.checks import Error, Warning, register + +from projectroles import app_settings +from projectroles.plugins import get_active_plugins # Local constants @@ -17,6 +20,11 @@ 'Set one or more of the following: {}'.format(', '.join(W001_SETTINGS)) ) W001 = Warning(W001_MSG, obj=settings, id='projectroles.W001') +E001_MSG = ( + 'Repeated app setting definition names found in plugins. Review your ' + 'plugins and ensure each app setting name is used only once within the ' + 'same plugin. Affected plugin(s): {plugin_names}' +) @register() @@ -25,7 +33,30 @@ def check_auth_methods(app_configs, **kwargs): Check for enabled authentication schemes. Raise error if no users other than superusers are able to log in with the current settings). """ - ret = [] if not any([getattr(settings, a, False) for a in W001_SETTINGS]): - ret.append(W001) - return ret + return [W001] + return [] + + +@register() +def check_app_setting_defs(app_configs, **kwargs): + """ + Check provided plugin app setting definitions to ensure the name of each + definition is unique within its app plugin. + """ + err_plugins = [] + for p in get_active_plugins(): + s_defs = p.app_settings + if isinstance(s_defs, list): + s_names = [d.name for d in s_defs] + if len(set(s_names)) != len(s_names) and p.name not in err_plugins: + err_plugins.append(p.name) + if err_plugins: + return [ + Error( + E001_MSG.format(plugin_names=', '.join(err_plugins)), + obj=app_settings, + id='projectroles.E001', + ) + ] + return [] diff --git a/projectroles/forms.py b/projectroles/forms.py index eafe0dfd..afe8cb81 100644 --- a/projectroles/forms.py +++ b/projectroles/forms.py @@ -387,33 +387,28 @@ def _init_remote_sites(self): required=False, ) - def _set_app_setting_field(self, plugin_name, s_field, s_key, s_val): + def _set_app_setting_field(self, plugin_name, s_field, s_def): """ Internal helper for setting app setting field, widget and value. :param plugin_name: App plugin name :param s_field: Form field name - :param s_key: Setting key - :param s_val: Setting value + :param s_def: PluginAppSettingDef object """ - s_widget_attrs = s_val.get('widget_attrs') or {} - s_project_types = s_val.get('project_types') or [PROJECT_TYPE_PROJECT] + s_widget_attrs = s_def.widget_attrs + s_project_types = s_def.project_types or [PROJECT_TYPE_PROJECT] s_widget_attrs['data-project-types'] = ','.join(s_project_types).lower() - if 'placeholder' in s_val: - s_widget_attrs['placeholder'] = s_val.get('placeholder') + if s_def.placeholder is not None: + s_widget_attrs['placeholder'] = s_def.placeholder setting_kwargs = { 'required': False, - 'label': s_val.get('label') or '{}.{}'.format(plugin_name, s_key), - 'help_text': s_val['description'], + 'label': s_def.label or '{}.{}'.format(plugin_name, s_def.name), + 'help_text': s_def.description, } # Option - if ( - s_val.get('options') - and callable(s_val['options']) - and self.instance.pk - ): - values = s_val['options'](project=self.instance) + if s_def.options and callable(s_def.options) and self.instance.pk: + values = s_def.options(project=self.instance) self.fields[s_field] = forms.ChoiceField( choices=[ ( @@ -425,12 +420,8 @@ def _set_app_setting_field(self, plugin_name, s_field, s_key, s_val): ], **setting_kwargs ) - elif ( - s_val.get('options') - and callable(s_val['options']) - and not self.instance.pk - ): - values = s_val['options'](project=None) + elif s_def.options and callable(s_def.options) and not self.instance.pk: + values = s_def.options(project=None) self.fields[s_field] = forms.ChoiceField( choices=[ ( @@ -442,31 +433,31 @@ def _set_app_setting_field(self, plugin_name, s_field, s_key, s_val): ], **setting_kwargs ) - elif s_val.get('options'): + elif s_def.options: self.fields[s_field] = forms.ChoiceField( choices=[ ( (int(option), int(option)) - if s_val['type'] == APP_SETTING_TYPE_INTEGER + if s_def.type == APP_SETTING_TYPE_INTEGER else (option, option) ) - for option in s_val['options'] + for option in s_def.options ], **setting_kwargs ) # Other types - elif s_val['type'] == APP_SETTING_TYPE_STRING: + elif s_def.type == APP_SETTING_TYPE_STRING: self.fields[s_field] = forms.CharField( widget=forms.TextInput(attrs=s_widget_attrs), **setting_kwargs ) - elif s_val['type'] == APP_SETTING_TYPE_INTEGER: + elif s_def.type == APP_SETTING_TYPE_INTEGER: self.fields[s_field] = forms.IntegerField( widget=forms.NumberInput(attrs=s_widget_attrs), **setting_kwargs ) - elif s_val['type'] == APP_SETTING_TYPE_BOOLEAN: + elif s_def.type == APP_SETTING_TYPE_BOOLEAN: self.fields[s_field] = forms.BooleanField(**setting_kwargs) # JSON - elif s_val['type'] == APP_SETTING_TYPE_JSON: + elif s_def.type == APP_SETTING_TYPE_JSON: # NOTE: Attrs MUST be supplied here (#404) if 'class' in s_widget_attrs: s_widget_attrs['class'] += ' sodar-json-input' @@ -482,25 +473,25 @@ def _set_app_setting_field(self, plugin_name, s_field, s_key, s_val): # Set initial value value = self.app_settings.get( plugin_name=plugin_name, - setting_name=s_key, + setting_name=s_def.name, project=self.instance if self.instance.pk else None, ) - if s_val['type'] == APP_SETTING_TYPE_JSON: + if s_def.type == APP_SETTING_TYPE_JSON: value = json.dumps(value) self.initial[s_field] = value - def _set_app_setting_notes(self, s_field, s_val, plugin): + def _set_app_setting_notes(self, s_field, s_def, plugin): """ Internal helper for setting app setting label notes. :param s_field: Form field name - :param s_val: Setting value + :param s_def: PluginAppSettingDef object :param plugin: Plugin object """ - if s_val.get('user_modifiable') is False: + if s_def.user_modifiable is False: self.fields[s_field].label += ' [HIDDEN]' self.fields[s_field].help_text += ' [HIDDEN FROM USERS]' - if self.app_settings.get_global_value(s_val): + if s_def.global_edit: if self.instance.is_remote(): self.fields[s_field].label += ' ' + SETTING_DISABLE_LABEL self.fields[s_field].help_text += ' ' + SETTING_SOURCE_ONLY_MSG @@ -527,22 +518,22 @@ def _init_app_settings(self): for plugin in self.app_plugins + [None]: # Projectroles has no plugin if plugin: plugin_name = plugin.name - p_settings = self.app_settings.get_definitions( + s_defs = self.app_settings.get_definitions( APP_SETTING_SCOPE_PROJECT, plugin=plugin, **self.p_kwargs ) else: plugin_name = APP_NAME - p_settings = self.app_settings.get_definitions( + s_defs = self.app_settings.get_definitions( APP_SETTING_SCOPE_PROJECT, plugin_name=plugin_name, **self.p_kwargs ) - for s_key, s_val in p_settings.items(): - s_field = 'settings.{}.{}'.format(plugin_name, s_key) + for s_def in s_defs.values(): + s_field = 'settings.{}.{}'.format(plugin_name, s_def.name) # Set field, widget and value - self._set_app_setting_field(plugin_name, s_field, s_key, s_val) + self._set_app_setting_field(plugin_name, s_field, s_def) # Set label notes - self._set_app_setting_notes(s_field, s_val, plugin) + self._set_app_setting_notes(s_field, s_def, plugin) @classmethod def _validate_app_settings( @@ -562,17 +553,17 @@ def _validate_app_settings( else: p_name = 'projectroles' def_kwarg = {'plugin_name': p_name} - p_defs = app_settings.get_definitions( + s_defs = app_settings.get_definitions( APP_SETTING_SCOPE_PROJECT, **{**p_kwargs, **def_kwarg} ) p_settings = {} - for s_key, s_val in p_defs.items(): - s_field = '.'.join(['settings', p_name, s_key]) - p_settings[s_key] = cleaned_data.get(s_field) + for s_def in s_defs.values(): + s_field = '.'.join(['settings', p_name, s_def.name]) + p_settings[s_def.name] = cleaned_data.get(s_field) - if s_val['type'] == APP_SETTING_TYPE_JSON: - if not p_settings[s_key]: + if s_def.type == APP_SETTING_TYPE_JSON: + if not p_settings[s_def.name]: cleaned_data[s_field] = '{}' try: cleaned_data[s_field] = json.loads( @@ -580,14 +571,14 @@ def _validate_app_settings( ) except json.JSONDecodeError as err: errors.append((s_field, 'Invalid JSON\n' + str(err))) - elif s_val['type'] == APP_SETTING_TYPE_INTEGER: + elif s_def.type == APP_SETTING_TYPE_INTEGER: # Convert integers from select fields cleaned_data[s_field] = int(cleaned_data[s_field]) if not app_settings.validate( - setting_type=s_val['type'], + setting_type=s_def.type, setting_value=cleaned_data.get(s_field), - setting_options=s_val.get('options'), + setting_options=s_def.options, project=instance, ): errors.append((s_field, 'Invalid value')) diff --git a/projectroles/management/commands/cleanappsettings.py b/projectroles/management/commands/cleanappsettings.py index 934ac97c..db4cc634 100644 --- a/projectroles/management/commands/cleanappsettings.py +++ b/projectroles/management/commands/cleanappsettings.py @@ -83,8 +83,8 @@ def handle(self, *args, **options): try: s_def = app_settings.get_definition(**def_kwargs) # Get allowed project types (if unset, default is PROJECT only) - if s_def['scope'] != APP_SETTING_SCOPE_USER: - p_types = s_def.get('project_types', [PROJECT_TYPE_PROJECT]) + if s_def.scope != APP_SETTING_SCOPE_USER: + p_types = s_def.project_types except ValueError: logger.info(self._get_log_msg(s, DEF_NOT_FOUND_MSG, check)) if not check: diff --git a/projectroles/migrations/0001_squashed_0032_alter_appsetting_value.py b/projectroles/migrations/0001_squashed_0032_alter_appsetting_value.py index 77b7bf79..caccb079 100644 --- a/projectroles/migrations/0001_squashed_0032_alter_appsetting_value.py +++ b/projectroles/migrations/0001_squashed_0032_alter_appsetting_value.py @@ -151,9 +151,7 @@ def delete_category_app_settings(apps, schema_editor): except ValueError: app_setting.delete() continue - if app_setting.project.type not in setting_def.get( - 'project_types', ['PROJECT'] - ): + if app_setting.project.type not in setting_def.project_types: # Delete app setting if it is not restricted to any project types app_setting.delete() diff --git a/projectroles/plugins.py b/projectroles/plugins.py index 687edce1..3093e3fd 100644 --- a/projectroles/plugins.py +++ b/projectroles/plugins.py @@ -1,15 +1,39 @@ """Plugin point definitions and plugin API for apps based on projectroles""" +import json + from django.conf import settings from djangoplugins.point import PluginPoint +from projectroles.models import APP_SETTING_TYPES, SODAR_CONSTANTS + + +# SODAR constants +PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] +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 costants +# Local constants PLUGIN_TYPES = { 'project_app': 'ProjectAppPluginPoint', 'backend': 'BackendPluginPoint', 'site_app': 'SiteAppPluginPoint', } +APP_SETTING_SCOPES = [ + APP_SETTING_SCOPE_PROJECT, + APP_SETTING_SCOPE_USER, + APP_SETTING_SCOPE_PROJECT_USER, + APP_SETTING_SCOPE_SITE, +] +APP_SETTING_OPTION_TYPES = [APP_SETTING_TYPE_INTEGER, APP_SETTING_TYPE_STRING] # From djangoplugins ENABLED = 0 @@ -631,6 +655,182 @@ def validate_form_app_settings(self, app_settings, user=None): # Data Classes ----------------------------------------------------------------- +class PluginAppSettingDef: + """ + Class representing an AppSetting definition. Expected to be used to define + app settings in the plugin app_settings variable. + """ + + def __init__( + self, + name, + scope, + type, + default=None, + label=None, + placeholder=None, + description=None, + options=None, + user_modifiable=True, + global_edit=False, + project_types=None, + widget_attrs=None, + ): + """ + Initialize PluginAppSettingDef. + + :param name: Setting name to be used internally (string) + :param scope: Setting scope, must correspond to one of + APP_SETTING_SCOPE_* (string) + :param type: Setting type, must correspond to one of APP_SETTING_TYPE_* + (string) + :param default: Default value, type depends on setting type. Can be a + callable. + :param label: Display label (string, optional, name is used as default) + :param placeholder: Placeholder value to be displayed in forms (string, + optional) + :param description: Detailed setting description (string, optional) + :param options: Limit value to given options. Can be callable (optional, + only for STRING or INTEGER types) + :param user_modifiable: Display in forms for user if True (optional, + default=True, only for PROJECT and USER scopes) + :param global_edit: Only allow editing on source site if True (optional, + default=False) + :param project_types: Allowed project types (optional, + default=[PROJECT_TYPE_PROJECT]) + :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) + # Set members + self.name = name + self.scope = scope + self.type = type + self.default = default + self.label = label + self.placeholder = placeholder + self.description = description + self.options = options or [] + self.user_modifiable = user_modifiable + self.global_edit = global_edit + self.project_types = project_types or [PROJECT_TYPE_PROJECT] + self.widget_attrs = widget_attrs or {} + + @classmethod + def validate_scope(cls, scope): + """ + Validate the app setting scope. + + :param scope: String + :raise: ValueError if scope is not recognized + """ + if scope not in APP_SETTING_SCOPES: + raise ValueError('Invalid scope "{}"'.format(scope)) + + @classmethod + def validate_type(cls, setting_type): + """ + Validate the app setting type. + + :param setting_type: String + :raise: ValueError if type is not recognized + """ + if setting_type not in APP_SETTING_TYPES: + raise ValueError('Invalid setting type "{}"'.format(setting_type)) + + @classmethod + def validate_type_options(cls, setting_type, setting_options): + """ + Validate existence of options against setting type. + + :param setting_type: String + :param setting_options: List of options (Strings or Integers) + :raise: ValueError if type is not recognized + """ + if ( + setting_type + not in [APP_SETTING_TYPE_INTEGER, APP_SETTING_TYPE_STRING] + and setting_options + ): + raise ValueError( + 'Options are only allowed for settings of type INTEGER and ' + '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: """ Class representing a hyperlink to an object used by the app. Expected to be diff --git a/projectroles/tests/test_app_settings.py b/projectroles/tests/test_app_settings.py index 9c48eeb2..27715123 100644 --- a/projectroles/tests/test_app_settings.py +++ b/projectroles/tests/test_app_settings.py @@ -4,13 +4,9 @@ from test_plus.test import TestCase -from projectroles.app_settings import ( - AppSettingAPI, - get_example_setting_default, - get_example_setting_options, -) +from projectroles.app_settings import AppSettingAPI from projectroles.models import Role, AppSetting, SODAR_CONSTANTS -from projectroles.plugins import get_app_plugin +from projectroles.plugins import PluginAppSettingDef, get_app_plugin from projectroles.tests.test_models import ( ProjectMixin, RoleAssignmentMixin, @@ -49,15 +45,6 @@ EXAMPLE_APP_NAME = 'example_project_app' INVALID_SETTING_VALUE = 'INVALID VALUE' INVALID_SETTING_MSG = 'INVALID_SETTING_VALUE detected' -APP_SETTINGS_TEST = { - 'project_star': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'local': True, # Deprecated - 'project_types': [PROJECT_TYPE_PROJECT, PROJECT_TYPE_CATEGORY], - }, -} class AppSettingInitMixin: @@ -66,6 +53,7 @@ class AppSettingInitMixin: def init_app_settings(self): """Init test app settings""" # TODO: Rename these to match the settings in example_project_app + # TODO: Only create these where it's necessary # Init test project settings self.project_str_setting = { 'plugin_name': EXAMPLE_APP_NAME, @@ -318,8 +306,10 @@ def test_get(self): def test_get_with_default(self): """Test get() with default value for existing setting""" - app_plugin = get_app_plugin(EXAMPLE_APP_NAME) - default_val = app_plugin.app_settings[EXISTING_SETTING]['default'] + s_defs = app_settings.get_definitions( + scope=APP_SETTING_SCOPE_PROJECT, plugin_name=EXAMPLE_APP_NAME + ) + default_val = s_defs[EXISTING_SETTING].default val = app_settings.get( plugin_name=EXAMPLE_APP_NAME, setting_name=EXISTING_SETTING, @@ -659,6 +649,7 @@ def test_get_definition_plugin(self): """Test get_definition() with plugin""" app_plugin = get_app_plugin(EXAMPLE_APP_NAME) expected = { + 'name': 'project_str_setting', 'scope': APP_SETTING_SCOPE_PROJECT, 'type': APP_SETTING_TYPE_STRING, 'label': 'String setting', @@ -666,15 +657,21 @@ def test_get_definition_plugin(self): 'description': 'Example string project setting', 'placeholder': 'Example string', 'user_modifiable': True, + 'global_edit': False, + 'options': [], + 'project_types': [PROJECT_TYPE_PROJECT], + 'widget_attrs': {}, } s_def = app_settings.get_definition( 'project_str_setting', plugin=app_plugin ) - self.assertEqual(s_def, expected) + self.assertIsInstance(s_def, PluginAppSettingDef) + self.assertEqual(s_def.__dict__, expected) - def test_get_definition_app_name(self): - """Test get_definition() with app name""" + def test_get_definition_plugin_name(self): + """Test get_definition() with plugin name""" expected = { + 'name': 'project_str_setting', 'scope': APP_SETTING_SCOPE_PROJECT, 'type': APP_SETTING_TYPE_STRING, 'label': 'String setting', @@ -682,15 +679,20 @@ def test_get_definition_app_name(self): 'description': 'Example string project setting', 'placeholder': 'Example string', 'user_modifiable': True, + 'global_edit': False, + 'options': [], + 'project_types': [PROJECT_TYPE_PROJECT], + 'widget_attrs': {}, } s_def = app_settings.get_definition( 'project_str_setting', plugin_name=EXAMPLE_APP_NAME ) - self.assertEqual(s_def, expected) + self.assertEqual(s_def.__dict__, expected) def test_get_definition_user(self): """Test get_definition() with user setting""" expected = { + 'name': 'user_str_setting', 'scope': APP_SETTING_SCOPE_USER, 'type': APP_SETTING_TYPE_STRING, 'label': 'String setting', @@ -698,11 +700,15 @@ def test_get_definition_user(self): 'description': 'Example string user setting', 'placeholder': 'Example string', 'user_modifiable': True, + 'global_edit': False, + 'options': [], + 'project_types': [PROJECT_TYPE_PROJECT], + 'widget_attrs': {}, } s_def = app_settings.get_definition( 'user_str_setting', plugin_name=EXAMPLE_APP_NAME ) - self.assertEqual(s_def, expected) + self.assertEqual(s_def.__dict__, expected) def test_get_definition_invalid(self): """Test get_definition() with innvalid input""" @@ -720,279 +726,47 @@ def test_get_definition_invalid(self): def test_get_definitions_project(self): """Test get_definitions() with PROJECT scope""" - expected = { - 'project_str_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'String setting', - 'default': '', - 'description': 'Example string project setting', - 'placeholder': 'Example string', - 'user_modifiable': True, - }, - 'project_int_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_INTEGER, - 'label': 'Integer setting', - 'default': 0, - 'description': 'Example integer project setting', - 'placeholder': 0, - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-success'}, - }, - 'project_str_setting_options': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'String setting with options', - 'default': 'string1', - 'options': ['string1', 'string2'], - 'description': 'Example string project setting with options', - 'user_modifiable': True, - }, - 'project_int_setting_options': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_INTEGER, - 'label': 'Integer setting with options', - 'default': 0, - 'options': [0, 1], - 'description': 'Example integer project setting with options', - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-success'}, - }, - 'project_bool_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Boolean setting', - 'default': False, - 'description': 'Example boolean project setting', - 'user_modifiable': True, - }, - 'project_global_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Global boolean setting', - 'default': False, - 'description': 'Example global boolean project setting', - 'user_modifiable': True, - 'global': True, - }, - 'project_json_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_JSON, - 'label': 'JSON setting', - 'default': { - 'Example': 'Value', - 'list': [1, 2, 3, 4, 5], - 'level_6': False, - }, - 'description': 'Example JSON project setting', - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-danger'}, - }, - 'project_hidden_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Hidden setting', - 'default': '', - 'description': 'Example hidden project setting', - 'user_modifiable': False, - }, - 'project_hidden_json_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_JSON, - 'label': 'Hidden JSON setting', - 'description': 'Example hidden JSON project setting', - 'user_modifiable': False, - }, - 'project_callable_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Callable project setting', - 'default': get_example_setting_default, - 'description': 'Example callable project setting', - }, - 'project_callable_setting_options': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Callable setting with options', - 'default': get_example_setting_default, - 'options': get_example_setting_options, - 'description': 'Example callable project setting with options', - 'user_modifiable': True, - }, - 'category_bool_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Category boolean setting', - 'default': False, - 'description': 'Example boolean project category setting', - 'user_modifiable': True, - 'project_types': [PROJECT_TYPE_CATEGORY], - }, - } defs = app_settings.get_definitions( APP_SETTING_SCOPE_PROJECT, plugin_name=EXAMPLE_APP_NAME ) - self.assertEqual(defs, expected) + self.assertIsInstance(defs, dict) + self.assertEqual(len(defs), 12) + self.assertTrue( + all([d.scope == APP_SETTING_SCOPE_PROJECT for d in defs.values()]) + ) def test_get_definitions_user(self): """Test get_definitions() with USER scope""" - expected = { - 'user_str_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'String setting', - 'default': '', - 'description': 'Example string user setting', - 'placeholder': 'Example string', - 'user_modifiable': True, - }, - 'user_int_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_INTEGER, - 'label': 'Integer setting', - 'default': 0, - 'description': 'Example integer user setting', - 'placeholder': 0, - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-success'}, - }, - 'user_str_setting_options': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'String setting with options', - 'default': 'string1', - 'options': ['string1', 'string2'], - 'description': 'Example string user setting with options', - 'user_modifiable': True, - }, - 'user_int_setting_options': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_INTEGER, - 'label': 'Integer setting with options', - 'default': 0, - 'options': [0, 1], - 'description': 'Example integer user setting with options', - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-success'}, - }, - 'user_bool_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Boolean setting', - 'default': False, - 'description': 'Example boolean user setting', - 'user_modifiable': True, - }, - 'user_json_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_JSON, - 'label': 'JSON setting', - 'default': { - 'Example': 'Value', - 'list': [1, 2, 3, 4, 5], - 'level_6': False, - }, - 'description': 'Example JSON user setting', - 'user_modifiable': True, - 'widget_attrs': {'class': 'text-danger'}, - }, - 'user_hidden_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'default': '', - 'description': 'Example hidden user setting', - 'user_modifiable': False, - }, - 'user_callable_setting': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Callable user setting', - 'default': get_example_setting_default, - 'description': 'Example callable user setting', - }, - 'user_callable_setting_options': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_STRING, - 'label': 'Callable setting with options', - 'default': get_example_setting_default, - 'options': get_example_setting_options, - 'description': 'Example callable user setting with options', - 'user_modifiable': True, - }, - } defs = app_settings.get_definitions( APP_SETTING_SCOPE_USER, plugin_name=EXAMPLE_APP_NAME ) - self.assertEqual(defs, expected) + self.assertEqual(len(defs), 9) + self.assertTrue( + all([d.scope == APP_SETTING_SCOPE_USER for d in defs.values()]) + ) def test_get_definitions_project_user(self): """Test get_definitions() with PROJECT_USER scope""" - expected = { - 'project_user_str_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_STRING, - 'default': '', - 'description': 'Example string project user setting', - }, - 'project_user_int_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_INTEGER, - 'default': 0, - 'description': 'Example int project user setting', - }, - 'project_user_bool_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'description': 'Example bool project user setting', - }, - 'project_user_json_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_JSON, - 'default': { - 'Example': 'Value', - 'list': [1, 2, 3, 4, 5], - 'level_6': False, - }, - 'description': 'Example json project user setting', - }, - 'project_user_callable_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_STRING, - 'default': get_example_setting_default, - 'description': 'Example callable project user setting', - }, - 'project_user_callable_setting_options': { - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_STRING, - 'default': get_example_setting_default, - 'options': get_example_setting_options, - 'description': 'Example callable project user setting with ' - 'options', - }, - } defs = app_settings.get_definitions( APP_SETTING_SCOPE_PROJECT_USER, plugin_name=EXAMPLE_APP_NAME ) - self.assertEqual(defs, expected) + self.assertEqual(len(defs), 6) + self.assertTrue( + all( + [ + d.scope == APP_SETTING_SCOPE_PROJECT_USER + for d in defs.values() + ] + ) + ) def test_get_definitions_site(self): """Test get_definitions() with SITE scope""" - expected = { - 'site_bool_setting': { - 'scope': APP_SETTING_SCOPE_SITE, - 'label': 'Site boolean setting', - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'description': 'Example boolean site setting', - 'user_modifiable': True, - } - } defs = app_settings.get_definitions( APP_SETTING_SCOPE_SITE, plugin_name=EXAMPLE_APP_NAME ) - self.assertEqual(defs, expected) + self.assertEqual(len(defs), 1) + self.assertTrue(list(defs.values())[0].scope, APP_SETTING_SCOPE_SITE) def test_get_definitions_modifiable(self): """Test get_definitions() with user_modifiable arg""" diff --git a/projectroles/tests/test_plugins.py b/projectroles/tests/test_plugins.py new file mode 100644 index 00000000..81f4b6b4 --- /dev/null +++ b/projectroles/tests/test_plugins.py @@ -0,0 +1,251 @@ +"""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, +} + + +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""" + + def callable_default(project, user): + return True + + 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""" + + def callable_options(project, user): + return [1, 2] + + 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], + ) diff --git a/projectroles/tests/test_views.py b/projectroles/tests/test_views.py index 29f5bb91..a47dfb17 100644 --- a/projectroles/tests/test_views.py +++ b/projectroles/tests/test_views.py @@ -48,6 +48,7 @@ CAT_DELIMITER, ) from projectroles.plugins import ( + PluginAppSettingDef, get_backend_api, get_active_plugins, ) @@ -139,32 +140,35 @@ UPDATED_HIDDEN_SETTING = 'Updated value' UPDATED_HIDDEN_JSON_SETTING = {'updated': 'value'} -APP_SETTINGS_TEST = { - 'test_setting': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'label': 'Test setting', - 'description': 'Test setting', - 'user_modifiable': True, - 'global': True, - }, - 'test_setting_local': { - 'scope': APP_SETTING_SCOPE_PROJECT, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'label': 'Test setting', - 'description': 'Test setting', - 'user_modifiable': True, - 'global': False, - }, - 'project_star': { # NOTE: We have to include this for view tests - 'scope': APP_SETTING_SCOPE_PROJECT_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'default': False, - 'global': True, - }, -} +APP_SETTINGS_TEST = [ + PluginAppSettingDef( + name='test_setting', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + default=False, + label='Test setting', + description='Test setting', + user_modifiable=True, + global_edit=True, + ), + PluginAppSettingDef( + name='test_setting_local', + scope=APP_SETTING_SCOPE_PROJECT, + type=APP_SETTING_TYPE_BOOLEAN, + default=False, + label='Test setting', + description='Test setting', + user_modifiable=True, + global_edit=False, + ), + PluginAppSettingDef( + name='project_star', + scope=APP_SETTING_SCOPE_PROJECT_USER, + type=APP_SETTING_TYPE_BOOLEAN, + default=False, + global_edit=True, + ), +] EX_PROJECT_UI_SETTINGS = [ 'project_str_setting', diff --git a/projectroles/views.py b/projectroles/views.py index 1fdbee3e..2189da0e 100644 --- a/projectroles/views.py +++ b/projectroles/views.py @@ -976,20 +976,15 @@ def _get_app_settings(data, instance, user): APP_SETTING_SCOPE_PROJECT, plugin_name=name, **p_kwargs ) - for s_key, s_val in p_settings.items(): - s_name = 'settings.{}.{}'.format(name, s_key) + for s_def in p_settings.values(): + s_name = 'settings.{}.{}'.format(name, s_def.name) s_data = data.get(s_name) - if ( - not s_val.get('project_types', None) - and not instance.type == PROJECT_TYPE_PROJECT - or s_val.get('project_types', None) - and instance.type not in s_val['project_types'] - ): + if instance.type not in s_def.project_types: continue if s_data is None and not instance: - s_data = app_settings.get_default(name, s_key) - if s_val['type'] == APP_SETTING_TYPE_JSON: + s_data = app_settings.get_default(name, s_def.name) + if s_def.type == APP_SETTING_TYPE_JSON: if s_data is None: s_data = {} project_settings[s_name] = json.dumps(s_data) @@ -1039,7 +1034,7 @@ def _get_project_update_data( s_name = k.split('.')[2] s_def = app_settings.get_definition(s_name, plugin_name=p_name) old_v = app_settings.get(p_name, s_name, project) - if s_def['type'] == APP_SETTING_TYPE_JSON: + if s_def.type == APP_SETTING_TYPE_JSON: v = json.loads(v) if old_v != v: extra_data[k] = v @@ -1104,7 +1099,7 @@ def _update_settings(cls, project, project_settings): s_def = app_settings.get_definition( setting_name, plugin_name=plugin_name ) - if app_settings.get_global_value(s_def): + if s_def.global_edit: continue app_settings.set( plugin_name=k.split('.')[1], @@ -1143,7 +1138,7 @@ def _create_timeline_event( p_name = k.split('.')[1] s_name = k.split('.')[2] s_def = app_settings.get_definition(s_name, plugin_name=p_name) - if s_def['type'] == APP_SETTING_TYPE_JSON: + if s_def.type == APP_SETTING_TYPE_JSON: v = json.loads(v) extra_data[k] = v diff --git a/projectroles/views_api.py b/projectroles/views_api.py index b9075fee..c8491081 100644 --- a/projectroles/views_api.py +++ b/projectroles/views_api.py @@ -861,7 +861,7 @@ def get_and_validate_def(cls, plugin_name, setting_name, allowed_scopes): ) except Exception as ex: raise serializers.ValidationError(ex) - if s_def['scope'] not in allowed_scopes: + if s_def.scope not in allowed_scopes: raise serializers.ValidationError('Invalid setting scope') return s_def @@ -885,12 +885,12 @@ def check_project_perms( """ Check permissions for project settings. - :param setting_def: Dict + :param setting_def: PluginAppSettingDef object :param project: Project object :param request_user: User object for requesting user :param setting_user: User object for the setting user or None """ - if setting_def['scope'] == APP_SETTING_SCOPE_PROJECT: + if setting_def.scope == APP_SETTING_SCOPE_PROJECT: if not request_user.has_perm( 'projectroles.update_project_settings', project ): @@ -898,7 +898,7 @@ def check_project_perms( 'User lacks permission to access PROJECT scope app ' 'settings in this project' ) - elif setting_def['scope'] == APP_SETTING_SCOPE_PROJECT_USER: + elif setting_def.scope == APP_SETTING_SCOPE_PROJECT_USER: if not setting_user: raise serializers.ValidationError( 'No user given for PROJECT_USER setting' @@ -1063,7 +1063,7 @@ def post(self, request, *args, **kwargs): setting_name, [APP_SETTING_SCOPE_PROJECT, APP_SETTING_SCOPE_PROJECT_USER], ) - if s_def.get('user_modifiable') is False: + if not s_def.user_modifiable: raise PermissionDenied(USER_MODIFIABLE_MSG) # Get value value = self.get_request_value(request) @@ -1090,7 +1090,7 @@ def post(self, request, *args, **kwargs): user=setting_user, ) # Call for additional actions for project creation/update in plugins - if s_def['scope'] == APP_SETTING_SCOPE_PROJECT and ( + if s_def.scope == APP_SETTING_SCOPE_PROJECT and ( settings, 'PROJECTROLES_ENABLE_MODIFY_API', False, @@ -1209,7 +1209,7 @@ def post(self, request, *args, **kwargs): s_def = self.get_and_validate_def( plugin_name, setting_name, [APP_SETTING_SCOPE_USER] ) - if s_def.get('user_modifiable') is False: + if not s_def.user_modifiable: raise PermissionDenied(USER_MODIFIABLE_MSG) value = self.get_request_value(request) diff --git a/userprofile/forms.py b/userprofile/forms.py index 83039c0b..530d6991 100644 --- a/userprofile/forms.py +++ b/userprofile/forms.py @@ -37,33 +37,32 @@ class UserSettingsForm(SODARForm): """Form for configuring user settings""" - def _set_app_setting_field(self, plugin_name, s_field, s_key, s_val): + def _set_app_setting_field(self, plugin_name, s_field, s_def): """ Set user app setting field, widget and value. :param plugin_name: App plugin name :param s_field: Form field name - :param s_key: Setting key - :param s_val: Setting value + :param s_def: PluginAppSettingDef object """ - s_widget_attrs = s_val.get('widget_attrs') or {} - if 'placeholder' in s_val: - s_widget_attrs['placeholder'] = s_val.get('placeholder') + s_widget_attrs = s_def.widget_attrs + if s_def.placeholder is not None: + s_widget_attrs['placeholder'] = s_def.placeholder setting_kwargs = { 'required': False, - 'label': s_val.get('label') or '{}.{}'.format(plugin_name, s_key), - 'help_text': s_val.get('description'), + 'label': s_def.label or '{}.{}'.format(plugin_name, s_def.name), + 'help_text': s_def.description, } # Disable global user settings if on target site if ( - app_settings.get_global_value(s_val) + s_def.global_edit and settings.PROJECTROLES_SITE_MODE == SITE_MODE_TARGET ): setting_kwargs['label'] += ' ' + SETTING_DISABLE_LABEL setting_kwargs['help_text'] += ' ' + SETTING_SOURCE_ONLY_MSG setting_kwargs['disabled'] = True - if s_val.get('options') and callable(s_val['options']): + if s_def.options and callable(s_def.options): self.fields[s_field] = forms.ChoiceField( choices=[ ( @@ -71,35 +70,35 @@ def _set_app_setting_field(self, plugin_name, s_field, s_key, s_val): if isinstance(value, tuple) else (str(value), str(value)) ) - for value in s_val['options'](user=self.user) + for value in s_def.options(user=self.user) ], **setting_kwargs, ) - elif s_val.get('options'): + elif s_def.options: self.fields[s_field] = forms.ChoiceField( choices=[ ( (int(option), int(option)) - if s_val['type'] == APP_SETTING_TYPE_INTEGER + if s_def.type == APP_SETTING_TYPE_INTEGER else (option, option) ) - for option in s_val['options'] + for option in s_def.options ], **setting_kwargs, ) - elif s_val['type'] == APP_SETTING_TYPE_STRING: + elif s_def.type == APP_SETTING_TYPE_STRING: self.fields[s_field] = forms.CharField( widget=forms.TextInput(attrs=s_widget_attrs), **setting_kwargs, ) - elif s_val['type'] == APP_SETTING_TYPE_INTEGER: + elif s_def.type == APP_SETTING_TYPE_INTEGER: self.fields[s_field] = forms.IntegerField( widget=forms.NumberInput(attrs=s_widget_attrs), **setting_kwargs, ) - elif s_val['type'] == APP_SETTING_TYPE_BOOLEAN: + elif s_def.type == APP_SETTING_TYPE_BOOLEAN: self.fields[s_field] = forms.BooleanField(**setting_kwargs) - elif s_val['type'] == APP_SETTING_TYPE_JSON: + elif s_def.type == APP_SETTING_TYPE_JSON: # NOTE: Attrs MUST be supplied here (#404) if 'class' in s_widget_attrs: s_widget_attrs['class'] += ' sodar-json-input' @@ -113,9 +112,9 @@ def _set_app_setting_field(self, plugin_name, s_field, s_key, s_val): # Modify initial value and attributes self.fields[s_field].widget.attrs.update(s_widget_attrs) value = app_settings.get( - plugin_name=plugin_name, setting_name=s_key, user=self.user + plugin_name=plugin_name, setting_name=s_def.name, user=self.user ) - if s_val['type'] == APP_SETTING_TYPE_JSON: + if s_def.type == APP_SETTING_TYPE_JSON: value = json.dumps(value) self.initial[s_field] = value @@ -129,20 +128,20 @@ def __init__(self, *args, **kwargs): for plugin in self.app_plugins + [None]: if plugin: - name = plugin.name - p_defs = app_settings.get_definitions( + plugin_name = plugin.name + s_defs = app_settings.get_definitions( APP_SETTING_SCOPE_USER, plugin=plugin, user_modifiable=True ) else: - name = 'projectroles' - p_defs = app_settings.get_definitions( + plugin_name = 'projectroles' + s_defs = app_settings.get_definitions( APP_SETTING_SCOPE_USER, - plugin_name=name, + plugin_name=plugin_name, user_modifiable=True, ) - for s_key, s_val in p_defs.items(): - s_field = 'settings.{}.{}'.format(name, s_key) - self._set_app_setting_field(name, s_field, s_key, s_val) + for s_def in s_defs.values(): + s_field = 'settings.{}.{}'.format(plugin_name, s_def.name) + self._set_app_setting_field(plugin_name, s_field, s_def) self.fields[s_field].label = self.get_app_setting_label( plugin, self.fields[s_field].label ) @@ -157,16 +156,16 @@ def clean(self): else: p_name = 'projectroles' p_kwargs['plugin_name'] = p_name - p_defs = app_settings.get_definitions( + s_defs = app_settings.get_definitions( APP_SETTING_SCOPE_USER, **p_kwargs ) p_settings = {} - for s_key, s_val in p_defs.items(): - s_field = '.'.join(['settings', p_name, s_key]) - p_settings[s_key] = self.cleaned_data.get(s_field) + for s_def in s_defs.values(): + s_field = '.'.join(['settings', p_name, s_def.name]) + p_settings[s_def.name] = self.cleaned_data.get(s_field) - if s_val['type'] == APP_SETTING_TYPE_JSON: + if s_def.type == APP_SETTING_TYPE_JSON: if not self.cleaned_data.get(s_field): self.cleaned_data[s_field] = '{}' try: @@ -175,14 +174,14 @@ def clean(self): ) except json.JSONDecodeError as err: self.add_error(s_field, 'Invalid JSON\n' + str(err)) - elif s_val['type'] == APP_SETTING_TYPE_INTEGER: + elif s_def.type == APP_SETTING_TYPE_INTEGER: # Convert integers from select fields self.cleaned_data[s_field] = int(self.cleaned_data[s_field]) if not app_settings.validate( - setting_type=s_val['type'], + setting_type=s_def.type, setting_value=self.cleaned_data.get(s_field), - setting_options=s_val.get('options'), + setting_options=s_def.options, user=self.user, ): self.add_error(s_field, 'Invalid value') diff --git a/userprofile/plugins.py b/userprofile/plugins.py index a43dc2d0..e351a18c 100644 --- a/userprofile/plugins.py +++ b/userprofile/plugins.py @@ -1,6 +1,6 @@ # Projectroles dependency from projectroles.models import SODAR_CONSTANTS -from projectroles.plugins import SiteAppPluginPoint +from projectroles.plugins import SiteAppPluginPoint, PluginAppSettingDef from userprofile.urls import urlpatterns @@ -34,15 +34,16 @@ class SiteAppPlugin(SiteAppPluginPoint): #: Required permission for displaying the app app_permission = 'userprofile.view_detail' - app_settings = { - 'enable_project_uuid_copy': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': APP_SETTING_TYPE_BOOLEAN, - 'label': 'Display project UUID copying link', - 'default': False, - 'user_modifiable': True, - } - } + #: App setting definitions + app_settings = [ + PluginAppSettingDef( + name='enable_project_uuid_copy', + scope=APP_SETTING_SCOPE_USER, + type=APP_SETTING_TYPE_BOOLEAN, + label='Display project UUID copying link', + default=False, + ) + ] def get_messages(self, user=None): """ diff --git a/userprofile/views.py b/userprofile/views.py index 3f95688f..559ab68c 100644 --- a/userprofile/views.py +++ b/userprofile/views.py @@ -71,21 +71,23 @@ def _get_user_settings(self): for plugin in plugins + [None]: if plugin: name = plugin.name - p_settings = app_settings.get_definitions( + s_defs = app_settings.get_definitions( APP_SETTING_SCOPE_USER, plugin=plugin, user_modifiable=True ) else: name = 'projectroles' - p_settings = app_settings.get_definitions( + s_defs = app_settings.get_definitions( APP_SETTING_SCOPE_USER, plugin_name=name, user_modifiable=True, ) - for k, v in p_settings.items(): + for s_def in s_defs.values(): yield { - 'label': v.get('label') or '{}.{}'.format(name, k), - 'value': app_settings.get(name, k, user=self.request.user), - 'description': v.get('description'), + 'label': s_def.label or '{}.{}'.format(name, s_def.name), + 'value': app_settings.get( + name, s_def.name, user=self.request.user + ), + 'description': s_def.description, } def get_context_data(self, **kwargs):