Source code for projectroles.app_settings

"""Projectroles app settings API"""

import json
import logging

from django.conf import settings

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


logger = logging.getLogger(__name__)


# SODAR constants
PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT']
PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY']
SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE']
SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET']
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
APP_NAME = 'projectroles'
APP_SETTING_GLOBAL_DEFAULT = False
APP_SETTING_DEFAULT_VALUES = {
    APP_SETTING_TYPE_BOOLEAN: False,
    APP_SETTING_TYPE_INTEGER: 0,
    APP_SETTING_TYPE_JSON: {},
    APP_SETTING_TYPE_STRING: '',
}
APP_SETTING_SCOPE_ARGS = {
    APP_SETTING_SCOPE_PROJECT: {'project': True, 'user': False},
    APP_SETTING_SCOPE_USER: {'project': False, 'user': True},
    APP_SETTING_SCOPE_PROJECT_USER: {'project': True, 'user': True},
    APP_SETTING_SCOPE_SITE: {'project': False, 'user': False},
}
PROJECT_LIST_PAGE_OPTIONS = [10, 25, 50, 100, (-1, 'No pagination')]
DELETE_SCOPE_ERR_MSG = 'Argument "{arg}" must be set for {scope} scope setting'
GLOBAL_PROJECT_ERR_MSG = (
    'Overriding global settings for remote projects not allowed'
)
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})'
)
GET_ALL_DEPRECATE_MSG = (
    'The get_all() method has been deprecated in v1.1 and will be removed in '
    'v1.2. Use get_all_by_scope() instead'
)


# Define App Settings for projectroles app
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=(
            'Receive email notifications for {} or {} creation, updating, '
            'moving, archiving and deletion.'.format(
                get_display_name(PROJECT_TYPE_CATEGORY),
                get_display_name(PROJECT_TYPE_PROJECT),
            )
        ),
        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=(
            '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_edit=True,
    ),
    PluginAppSettingDef(
        name='site_read_only',
        scope=APP_SETTING_SCOPE_SITE,
        type=APP_SETTING_TYPE_BOOLEAN,
        default=False,
        label='Site read-only mode',
        description='Set site in read-only mode. Data altering operations will '
        'be prohibited. Mode must be explicitly unset to allow data '
        'modification.',
        user_modifiable=True,
        global_edit=False,
    ),
    PluginAppSettingDef(
        name='project_list_highlight',
        scope=APP_SETTING_SCOPE_USER,
        type=APP_SETTING_TYPE_BOOLEAN,
        default=False,
        label='Project list title highlight',
        description='Highlight project title in paths displayed in the project '
        'list.',
        user_modifiable=True,
        global_edit=True,
    ),
    PluginAppSettingDef(
        name='project_list_pagination',
        scope=APP_SETTING_SCOPE_USER,
        type=APP_SETTING_TYPE_INTEGER,
        default=10,
        label='Project list page size',
        description='Amount of projects per page in the project list.',
        options=PROJECT_LIST_PAGE_OPTIONS,
        user_modifiable=True,
        global_edit=True,
    ),
]


[docs] class AppSettingAPI: @classmethod def _validate_project_and_user(cls, scope, project, user): """ Ensure project and user parameters are set according to scope. :param scope: Scope of Setting (USER, PROJECT, PROJECT_USER, SITE) :param project: Project object :param user: User object :raise: ValueError if none or both objects exist """ if not APP_SETTING_SCOPE_ARGS[scope] == { 'project': project is not None, 'user': user is not None, }: raise ValueError( '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 _validate_value_in_options( cls, setting_value, setting_options, project=None, user=None ): """ Ensure setting_value is present in setting_options. :param setting_value: String :param setting_options: List of options :param project: Project object :param user: User object :raise: ValueError if type is not recognized """ if callable(setting_options): valid_options = [ val[0] if isinstance(val, tuple) else val for val in setting_options(project, user) ] if setting_value not in valid_options: raise ValueError( 'Choice "{}" not found in options ({})'.format( setting_value, ', '.join(map(str, valid_options)), ) ) elif setting_options: opts = [ o[0] if isinstance(o, tuple) else o for o in setting_options ] if setting_value not in opts: raise ValueError( 'Choice "{}" not found in options ({})'.format( setting_value, ', '.join(map(str, setting_options)) ) ) @classmethod def _get_app_plugin(cls, plugin_name): """ Return app plugin by name. :param plugin_name: Name of the app plugin (string) :return: App plugin object :raise: ValueError if plugin is not found with the name """ plugin = get_app_plugin(plugin_name) if not plugin: raise ValueError( 'Plugin not found with name "{}"'.format(plugin_name) ) return plugin @classmethod def _get_defs(cls, plugin=None, plugin_name=None): """ Get app setting definitions for a plugin. :param plugin: Plugin object or None :param plugin_name: Name of the app plugin (string or None) :return: Dict :raise: ValueError if args are not valid or plugin is not found """ if not plugin and not plugin_name: raise ValueError('Plugin object and name both unset') if plugin_name == APP_NAME: return cls.get_projectroles_defs() if not plugin: plugin = cls._get_app_plugin(plugin_name) 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): """ Return JSON value as dict regardless of input type :param value: Original value (string or dict) :raise: json.decoder.JSONDecodeError if string value is not valid JSON :raise: ValueError if value type is not recognized or if value is not valid JSON :return: dict """ if not value: return {} try: if isinstance(value, str): return json.loads(value) json.dumps(value) # Ensure this is valid return value except Exception: raise ValueError('Value is not valid JSON: {}'.format(value)) @classmethod def _log_set_debug( cls, action, plugin_name, setting_name, value, project, user ): """ Helper method for logging setting changes in set() method. :param action: Action string (string) :param plugin_name: App plugin name (string) :param setting_name: Setting name (string) :param value: Setting value (string) :param project: Project object :param user: User object """ extra_data = [] if project: extra_data.append('project={}'.format(project.sodar_uuid)) if user: extra_data.append('user={}'.format(user.username)) logger.debug( '{} app setting: {}.{} = "{}"{}'.format( action, plugin_name, setting_name, value, ' ({})'.format('; '.join(extra_data)) if extra_data else '', ) )
[docs] @classmethod def get_default( cls, plugin_name, setting_name, project=None, user=None, post_safe=False ): """ Get default setting value from an app plugin. :param plugin_name: App plugin name (string, equals "name" in plugin) :param setting_name: Setting name (string) :param project: Project object (optional) :param user: User object (optional) :param post_safe: Whether a POST safe value should be returned (bool) :return: Setting value (string, integer or boolean) :raise: ValueError if app plugin is not found :raise: KeyError if nothing is found with setting_name """ if plugin_name == APP_NAME: s_defs = cls.get_projectroles_defs() else: 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 ) ) s_def = s_defs[setting_name] if callable(s_def.default): try: 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[s_def.type] elif s_def.type == APP_SETTING_TYPE_JSON: if s_def.default is None: return {} if post_safe: return json.dumps(s_def.default) return s_def.default
[docs] @classmethod def get( cls, plugin_name, setting_name, project=None, user=None, post_safe=False ): """ Return app setting value for a project or a user. If not set, return default. :param plugin_name: App plugin name (string, equals "name" in plugin) :param setting_name: Setting name (string) :param project: Project object (optional) :param user: User object (optional) :param post_safe: Whether a POST safe value should be returned (bool) :return: String or None :raise: KeyError if nothing is found with setting_name """ if not user or user.is_authenticated: try: val = AppSetting.objects.get_setting_value( plugin_name, setting_name, project=project, user=user ) except AppSetting.DoesNotExist: val = cls.get_default( plugin_name, setting_name, project=project, user=user, post_safe=post_safe, ) else: # Anonymous user val = cls.get_default( plugin_name, setting_name, project=project, user=user, post_safe=post_safe, ) # Handle post_safe for dict values (JSON) if post_safe and isinstance(val, (dict, list)): return json.dumps(val) return val
[docs] @classmethod def get_all_by_scope(cls, scope, project=None, user=None, post_safe=False): """ Return all setting values by scope. If a value is not set, return the default. :param scope: String :param project: Project object (optional) :param user: User object (optional) :param post_safe: Whether POST safe values should be returned (bool) :return: Dict :raise: ValueError if scope or project and user args are invalid """ PluginAppSettingDef.validate_scope(scope) cls._validate_project_and_user(scope, project, user) ret = {} all_defs = cls.get_all_defs() for plugin_name, s_defs in all_defs.items(): for s_def in [d for d in s_defs.values() if d.scope == scope]: ret[f'settings.{plugin_name}.{s_def.name}'] = cls.get( plugin_name, s_def.name, project, user, post_safe ) return ret
# TODO: Remove in v1.2 (see #1538)
[docs] @classmethod def get_all(cls, project=None, user=None, post_safe=False): """ Return all setting values with the PROJECT scope. If a value is not found, return the default. This method is DEPRECATED and will be removed in v1.2. Please use get_all_by_scope() instead. :param project: Project object (optional) :param user: User object (NOTE: Not actually used) :param post_safe: Whether POST safe values should be returned (bool) :return: Dict :raise: ValueError if neither project nor user are set """ logger.warning(GET_ALL_DEPRECATE_MSG) return cls.get_all_by_scope( scope=APP_SETTING_SCOPE_PROJECT, project=project, user=None, post_safe=post_safe, )
[docs] @classmethod def get_defaults(cls, scope, project=None, user=None, post_safe=False): """ Get all default settings for a scope. :param scope: Setting scope (PROJECT, USER or PROJECT_USER) :param project: Project object (optional) :param user: User object (optional) :param post_safe: Whether POST safe values should be returned (bool) :return: Dict """ PluginAppSettingDef.validate_scope(scope) ret = {} app_plugins = get_active_plugins() for plugin in app_plugins: 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, s_key, project=project, user=user, post_safe=post_safe, ) ) p_defs = cls.get_definitions(scope, plugin_name=APP_NAME) for s_key in p_defs: ret['settings.{}.{}'.format(APP_NAME, s_key)] = cls.get_default( APP_NAME, s_key, project=project, user=user, post_safe=post_safe, ) return ret
[docs] @classmethod def set( cls, plugin_name, setting_name, value, project=None, user=None, validate=True, ): """ Set value of an existing project or user settings. Creates the object if not found. :param plugin_name: App plugin name (string, equals "name" in plugin) :param setting_name: Setting name (string) :param value: Value to be set :param project: Project object (optional) :param user: User object (optional) :param validate: Validate value (bool, default=True) :return: True if changed, False if not changed :raise: ValueError if validating and value is not accepted for setting type :raise: ValueError if neither project nor user are 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._validate_project_and_user(s_def.scope, project, user) # Check project type 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 s_def.global_edit: if project and project.is_remote(): raise ValueError(GLOBAL_PROJECT_ERR_MSG) if ( user and not project and settings.PROJECTROLES_SITE_MODE == SITE_MODE_TARGET ): raise ValueError(GLOBAL_USER_ERR_MSG) try: # Update existing setting q_kwargs = {'name': setting_name, 'project': project, 'user': user} if not plugin_name == APP_NAME: q_kwargs['app_plugin__name'] = plugin_name else: q_kwargs['app_plugin'] = None setting = AppSetting.objects.get(**q_kwargs) if cls.compare_value(setting, value): return False if validate: cls.validate( setting.type, value, s_def.options, project=project, user=user, ) if setting.type == APP_SETTING_TYPE_JSON: setting.value_json = cls._get_json_value(value) else: setting.value = value setting.save() cls._log_set_debug( 'Set', plugin_name, setting_name, value, project, user ) return True except AppSetting.DoesNotExist: # Create new s_type = s_def.type if plugin_name == APP_NAME: app_plugin_model = None else: app_plugin = cls._get_app_plugin(plugin_name) app_plugin_model = app_plugin.get_model() if validate: v = ( cls._get_json_value(value) if s_type == APP_SETTING_TYPE_JSON else value ) cls.validate( s_type, v, s_def.options, project=project, user=user, ) s_mod = bool(s_def.user_modifiable) s_vals = { 'app_plugin': app_plugin_model, 'project': project, 'user': user, 'name': setting_name, 'type': s_type, 'user_modifiable': s_mod, } if s_type == APP_SETTING_TYPE_JSON: s_vals['value_json'] = cls._get_json_value(value) else: s_vals['value'] = value AppSetting.objects.create(**s_vals) cls._log_set_debug( 'Create', plugin_name, setting_name, value, project, user ) return True
[docs] @classmethod def is_set(cls, plugin_name, setting_name, project=None, user=None): """ Return True if the setting has been set, instead of retrieving the default value from the definition. NOTE: Also returns True if the current set value equals the default. :param plugin_name: App plugin name (string, equals "name" in plugin) :param setting_name: Setting name (string) :param project: Project object (optional) :param user: User object (optional) :return: Boolean """ s_def = cls.get_definition(name=setting_name, plugin_name=plugin_name) cls._validate_project_and_user(s_def.scope, project, user) q_kwargs = {'name': setting_name, 'project': project, 'user': user} if not plugin_name == APP_NAME: q_kwargs['app_plugin__name'] = plugin_name else: q_kwargs['app_plugin'] = None return AppSetting.objects.filter(**q_kwargs).exists()
[docs] @classmethod def delete(cls, plugin_name, setting_name, project=None, user=None): """ Delete one or more app setting objects. In case of a PROJECT_USER setting, can be used to delete all settings related to project. :param plugin_name: App plugin name (string, equals "name" in plugin) :param setting_name: Setting name (string) :param project: Project object to delete setting from (optional) :param user: User object to delete setting from (optional) :raise: ValueError with invalid project/user args """ 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( APP_SETTING_SCOPE_PROJECT_USER ) ) q_kwargs = {'name': setting_name} if user: q_kwargs['user'] = user if project: q_kwargs['project'] = project logger.debug( 'Delete app setting: {}.{} ({})'.format( plugin_name, setting_name, '; '.join(q_kwargs) ) ) app_settings = AppSetting.objects.filter(**q_kwargs) sc = app_settings.count() app_settings.delete() logger.debug( 'Deleted {} app setting{}'.format(sc, 's' if sc != 1 else '') )
[docs] @classmethod def delete_by_scope( cls, scope, project=None, user=None, ): """ Delete all app settings within a given scope for a project and/or user. :param scope: Setting scope (string) :param project: Project object to delete setting from :param user: User object to delete setting from :raise: ValueError if scope, project or user are incorrect """ PluginAppSettingDef.validate_scope(scope) cls._validate_project_and_user(scope, project, user) for plugin_name, app_settings in cls.get_all_defs().items(): for s_name, s_def in app_settings.items(): if s_def.scope == scope: cls.delete(plugin_name, s_name, project=project, user=user)
[docs] @classmethod def validate( cls, setting_type, setting_value, setting_options, project=None, user=None, ): """ Validate setting value according to its 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 """ PluginAppSettingDef.validate_type(setting_type) cls._validate_value_in_options( setting_value, setting_options, project=project, user=user ) # Test callable value if callable(setting_value): setting_value(project, user) else: # Else validate normal value PluginAppSettingDef.validate_value(setting_type, setting_value) return True
[docs] @classmethod def get_definition(cls, name, plugin=None, plugin_name=None): """ Return definition for a single app setting, either based on an app name or the plugin object. :param name: Setting name :param plugin: Plugin object or None :param plugin_name: Name of the app plugin (string or None) :return: Dict :raise: ValueError if neither plugin_name nor plugin are set, or if setting is not found in plugin """ defs = cls._get_defs(plugin, plugin_name) if name not in defs: raise ValueError( 'App setting not found in plugin "{}" with name "{}"'.format( plugin_name or plugin.name, name ) ) return defs[name]
[docs] @classmethod def get_definitions( cls, scope=None, plugin=None, plugin_name=None, user_modifiable=False, ): """ Return app setting definitions from a plugin, optionally limited by scope. :param scope: String or None :param plugin: Plugin object or None :param plugin_name: App plugin name (string, equals "name" in plugin) :param user_modifiable: Only return non-superuser modifiable settings if True (boolean) :return: Dict :raise: ValueError if scope is invalid or if neither plugin_name nor plugin are set """ if scope: PluginAppSettingDef.validate_scope(scope) defs = cls._get_defs(plugin, plugin_name) return { k: v for k, v in defs.items() if (not scope or v.scope == scope) and (not user_modifiable or v.user_modifiable) }
[docs] @classmethod def get_projectroles_defs(cls): """ Return projectroles settings definitions. If it exists, get value from settings.PROJECTROLES_APP_SETTINGS_TEST for testing modifications. :return: Dict """ try: app_settings = ( settings.PROJECTROLES_APP_SETTINGS_TEST or PROJECTROLES_APP_SETTINGS ) except AttributeError: app_settings = PROJECTROLES_APP_SETTINGS return {s.name: s for s in app_settings}
[docs] @classmethod def get_all_defs(cls): """ Return app setting definitions for projectroles and all active app plugins in a dictionary with the app name as key. :return: Dict """ ret = {APP_NAME: cls.get_projectroles_defs()} plugins = ( [] + get_active_plugins('project_app') + get_active_plugins('site_app') ) for p in plugins: ret[p.name] = cls._get_defs(p) return ret
[docs] @classmethod def compare_value(cls, obj, input_value): """ Compare input value to value in an AppSetting object. Return True if values match, False if there is a mismatch. :param obj: AppSetting object :param input_value: Input value (string, int, bool or dict) :return: Bool """ if obj.type == APP_SETTING_TYPE_JSON: return ( not obj.value_json and not input_value ) or obj.value_json == cls._get_json_value(input_value) elif obj.type == APP_SETTING_TYPE_BOOLEAN: if isinstance(input_value, str): input_value = bool(int(input_value)) return bool(int(obj.value)) == input_value return obj.value == str(input_value)
def get_example_setting_default(project=None, user=None): """ Example method for callable default value retrieval for app settings. :param project: Project object :param user: User object :return: String with project and user info or 'No project' """ response = 'N/A' if project and user: response = '{}:{}'.format(project.title, user.username) elif project: response = str(project.sodar_uuid) elif user: response = str(user.sodar_uuid) return response def get_example_setting_options(project=None, user=None): """ Example method for callable option list retrieval for app settings. :param project: Project object :param user: User object :return: List of tuples for ChoiceField """ ret = [('N/A', 'No project or user for callable'), 'Example string option'] if project and user: ret.append( ( str(project.sodar_uuid), 'Project UUID {} by {}'.format( project.sodar_uuid, user.username ), ) ) elif project: ret.append( ( str(project.sodar_uuid), 'Project UUID: {}'.format(project.sodar_uuid), ) ) elif user: ret.append( (str(user.sodar_uuid), 'User UUID: {}'.format(user.sodar_uuid)) ) return ret