diff --git a/README.md b/README.md index 24e601e..eff3cf8 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,14 @@ The following functions are possible - Link an available other projects to each specific project - Override the project information - Override the component information +- Add affiliated institutions to the created project/component and to the forked project The impossible ones: - Update attributes for available projects **\* Notice** about the order of creating a project/component: - Create project (includes `category`, `title`, `description`, `public`, `tags`) +- Add affiliated institutions to the created project/component - Add license (as `node_license`) - Create components for the created project - Link to other projects (as `project_links`) diff --git a/grdmcli/__main__.py b/grdmcli/__main__.py index cb00a96..a047427 100644 --- a/grdmcli/__main__.py +++ b/grdmcli/__main__.py @@ -171,5 +171,5 @@ def main(): cli_parser.parse_args(_args) -if __name__ == '__main__': +if __name__ == '__main__': # pragma: no cover main() diff --git a/grdmcli/constants.py b/grdmcli/constants.py index e8a8b33..affc80b 100644 --- a/grdmcli/constants.py +++ b/grdmcli/constants.py @@ -21,10 +21,6 @@ PAGE_SIZE_SERVER = 100 MAX_THREADS_CALL_API = 10 -# Maximum data return from API -# (update it if the max range of return data per_page of server is change) -MAX_THREADS_CALL_API = 10 - MAX_PAGE_SIZE = 1000 PAGE_SIZE_QUERY_PARAM = 'page[size]' ORDERING_QUERY_PARAM = 'sort' diff --git a/grdmcli/grdm_client/__init__.py b/grdmcli/grdm_client/__init__.py index 700ab53..f214dd2 100644 --- a/grdmcli/grdm_client/__init__.py +++ b/grdmcli/grdm_client/__init__.py @@ -21,6 +21,7 @@ def __init__(self, **kwargs): from .users import ( _users_me, + _users_institutions, ) # For projects functions from .licenses import ( @@ -34,6 +35,8 @@ def __init__(self, **kwargs): _load_project, _fork_project, _create_project, + _prepare_institutions_relationship_data, + _add_node_institutions, _link_project_to_project, _add_project_pointers, _add_project_components, diff --git a/grdmcli/grdm_client/common.py b/grdmcli/grdm_client/common.py index c50a530..10d00d9 100644 --- a/grdmcli/grdm_client/common.py +++ b/grdmcli/grdm_client/common.py @@ -48,6 +48,7 @@ def __init__(self, **kwargs): self.user = None self.is_authenticated = False + self.affiliated_institutions = [] # Call initial methods before parse_args self._load_option_from_config_file() @@ -116,9 +117,21 @@ def _request(self, method, url, params=None, data=None, headers=None): error_msg = error.detail if hasattr(error, 'source'): error_msg = f'{error_msg}. The pointer is {error.source.pointer}' - except Exception: + except Exception as ex: error_msg = f'{_response.status_code} {_response.reason}' + # Keep parse context for troubleshooting unexpected API responses. + response_content = _response.content + if isinstance(response_content, (bytes, bytearray)): + response_content = response_content.decode('utf-8', errors='replace') + else: + response_content = str(response_content) + error_msg = ( + f'{error_msg}.' + f'\n - Failed to parse API error response: {type(ex).__name__}: {ex}' + f'\n - Response content: {response_content}' + ) + return None, error_msg return _response, None diff --git a/grdmcli/grdm_client/projects.py b/grdmcli/grdm_client/projects.py index 2fa9862..405180c 100644 --- a/grdmcli/grdm_client/projects.py +++ b/grdmcli/grdm_client/projects.py @@ -3,14 +3,12 @@ import logging import os import sys +from concurrent.futures import ThreadPoolExecutor from pprint import pprint # noqa from types import SimpleNamespace -from concurrent.futures import ThreadPoolExecutor -import re - -from .. import constants as const, utils from grdmcli.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND +from .. import constants as const, utils __all__ = [ '_get_template_schema_projects', @@ -20,6 +18,8 @@ '_fork_project', '_create_project', '_update_project', + '_prepare_institutions_relationship_data', + '_add_node_institutions', '_link_project_to_project', '_overwrite_node_link', '_update_project_component', @@ -38,6 +38,10 @@ logger = logging.getLogger(__name__) +MSG_E_INSTITUTIONS_INVALID = ( + 'Invalid affiliated institutions input. Expected a list of items with "id" and type "institutions".' +) + def _get_template_schema_projects(self): return os.path.abspath(os.path.join(os.path.dirname(here), const.TEMPLATE_SCHEMA_PROJECTS)) @@ -329,6 +333,70 @@ def _update_project(self, node_object, ignore_error=True, verbose=True): return project, json.loads(_content)['data'] +def _prepare_institutions_relationship_data(self, institutions, verbose=True): + """Build payload for adding institution relationships to a node. + + :param institutions: list of institution dicts with keys id and type + :param verbose: boolean + :return: dict payload + """ + if not institutions: + return {'data': []} + + if not isinstance(institutions, list): + logger.warning(MSG_E_INSTITUTIONS_INVALID) + sys.exit(MSG_E_INSTITUTIONS_INVALID) + + unique_ids = [] + for idx, institution in enumerate(institutions): + if not isinstance(institution, dict): + _error_message = f'{MSG_E_INSTITUTIONS_INVALID} Invalid item at index {idx}.' + logger.warning(_error_message) + sys.exit(_error_message) + + institution_id = institution.get('id') + institution_type = institution.get('type') + + if not institution_id or institution_type != 'institutions': + _error_message = f'{MSG_E_INSTITUTIONS_INVALID} Invalid item at index {idx}.' + logger.warning(_error_message) + sys.exit(_error_message) + + if institution_id not in unique_ids: + unique_ids.append(institution_id) + + data = { + 'data': [ + { + 'type': 'institutions', + 'id': institution_id + } + for institution_id in unique_ids + ] + } + + if verbose and data['data']: + logger.debug(f'Prepared institutions relationship data: {data}') + + return data + + +def _add_node_institutions( + self, node_id, institutions, verbose=True, +): + """Add affiliated institutions to a node.""" + _data = self._prepare_institutions_relationship_data(institutions, verbose=verbose) + if len(_data.get('data', [])) == 0: + return True + + _url = f'nodes/{node_id}/relationships/institutions/' + _response, _error_message = self._request('POST', _url, params={}, data=_data) + if _error_message: + logger.warning(f'Failed to add affiliated institutions to nodes/{node_id}/: {_error_message}') + sys.exit(_error_message) + return _response is not None + + def _link_project_to_project(self, node_id, pointer_id, ignore_error=True, verbose=True): """Add a link to another project into this project by project's GUID.\n @@ -408,12 +476,14 @@ def _add_project_pointers(self, project_links, project, verbose=True): continue # update output object - # can overwrite object by dictionary _project_links[_node_id_idx] = _ + # can overwrite object by dictionary _project_links[_node_id_idx] = linked project_links[_node_id_idx] = linked['id'] return project_links -def _add_project_components(self, children, project, verbose=True): +def _add_project_components( + self, children, project, verbose=True, affiliated_institutions=None, +): """Add component to project from list of children in template file :param children: object of component from template file @@ -434,12 +504,22 @@ def _add_project_components(self, children, project, verbose=True): logger.error("Project could not created") continue # update child node - component = self._update_project_component(_component_dict, verbose) + self._update_project_component( + _component_dict, + verbose, + affiliated_institutions=affiliated_institutions, + ) # create new children if ID NOT EXIST in input else: logger.info(f'JSONPOINTER ./children/{_component_idx}/') - component, _ = self._projects_add_component(project.id, _component_dict, ignore_error=True, verbose=verbose) + component, _ = self._projects_add_component( + project.id, + _component_dict, + ignore_error=True, + verbose=verbose, + affiliated_institutions=affiliated_institutions, + ) if component is None: # has error, update output object @@ -452,10 +532,17 @@ def _add_project_components(self, children, project, verbose=True): children[_component_idx]['type'] = component.type # handle create/update children of current child - self._overwrite_node_link_update_component(_component_dict, verbose) + self._overwrite_node_link_update_component( + _component_dict, + verbose, + affiliated_institutions=affiliated_institutions, + ) -def _projects_add_component(self, parent_id, node_object, ignore_error=True, verbose=True): +def _projects_add_component( + self, parent_id, node_object, ignore_error=True, verbose=True, + affiliated_institutions=None +): """Add a component into project by project's GUID, component's attributes such as title and category.\n In scope of method, call component as 'project' and its child as 'component'. @@ -502,6 +589,12 @@ def _projects_add_component(self, parent_id, node_object, ignore_error=True, ver if verbose: logger.debug(f'\'{project.id}\' - \'{project.attributes.title}\' [{project.type}][{project.attributes.category}]') + # Institution relation errors should stop CLI to prevent partial creation. + self._add_node_institutions( + project.id, affiliated_institutions, + verbose=verbose, + ) + # link a project to this node (parent_node_id = project.id) self._add_project_pointers(_project_links, project, verbose=verbose) @@ -510,7 +603,11 @@ def _projects_add_component(self, parent_id, node_object, ignore_error=True, ver node_object['project_links'] = [_pointer for _pointer in _project_links if _pointer is not None] # add Components to this node (parent_node_id = project.id) - self._add_project_components(_children, project, verbose=verbose) + self._add_project_components( + _children, project, + verbose=verbose, + affiliated_institutions=affiliated_institutions, + ) # Delete None from children if _children: @@ -519,8 +616,11 @@ def _projects_add_component(self, parent_id, node_object, ignore_error=True, ver return project, json.loads(_content)['data'] -def _create_or_update_project(self, projects, project_idx, verbose=True): - """Create new project or fork project or load project +def _create_or_update_project( + self, projects, project_idx, verbose=True, + affiliated_institutions=None +): + """Create new project or fork project or update project :param projects: list of project from template :param project_idx: integer of project index base on it order in project list @@ -551,6 +651,12 @@ def _create_or_update_project(self, projects, project_idx, verbose=True): # overwrite project self.projects_creation_output[project.id] = convert_namespace_to_dict(project) + + # Institution relation errors should stop CLI to prevent partial creation. + self._add_node_institutions( + project.id, affiliated_institutions, + verbose=verbose, + ) elif _id: logger.info(f'JSONPOINTER /projects/{project_idx}/id == {_id}') project, _ = self._load_project(_id, is_fake=const.IS_FAKE_LOAD_PROJECT, ignore_error=True, verbose=verbose) @@ -590,6 +696,12 @@ def _create_or_update_project(self, projects, project_idx, verbose=True): # add to output self.projects_creation_output[project.id] = convert_namespace_to_dict(project) + + # Institution relation errors should stop CLI to prevent partial creation. + self._add_node_institutions( + project.id, affiliated_institutions, + verbose=verbose + ) return project @@ -625,6 +737,10 @@ def projects_create(self): logger.info(f'Validate by the template of projects: {self.template_schema_projects}') utils.check_json_schema(self.template_schema_projects, _input_prj_dicts) + affiliated_institutions = self._users_institutions( + verbose=verbose, + ) + logger.info('Loop following the template of projects') _input_projects = _input_prj_dicts.get('projects', []) for _project_idx, _input_prj_dict in enumerate(_input_projects): @@ -633,7 +749,12 @@ def projects_create(self): _input_prj_link_ids = _input_prj_dict.get('project_links', None) # create new or fork project or update project - project = self._create_or_update_project(_input_projects, _project_idx, verbose) + project = self._create_or_update_project( + _input_projects, + _project_idx, + verbose, + affiliated_institutions=affiliated_institutions, + ) if project is None: # update output object and ignore it _input_projects[_project_idx] = None @@ -660,7 +781,10 @@ def projects_create(self): _filtered_input_children = _input_prj_dict['children'] # Create Components and lower level component - self._add_project_components(_filtered_input_children, project, verbose) + self._add_project_components( + _filtered_input_children, project, verbose, + affiliated_institutions=affiliated_institutions, + ) # Delete None from projects _input_prj_dicts['projects'] = [_prj for _prj in _input_projects if _prj is not None] @@ -1204,7 +1328,9 @@ def _overwrite_node_link(self, project, project_dict, verbose=True): self._add_project_pointers(_need_create_node_link_ids, project, verbose=verbose) -def _update_project_component(self, child_project_dict, verbose=True): +def _update_project_component( + self, child_project_dict, verbose=True, affiliated_institutions=None, +): """Update component (project children) :param child_project_dict: children dictionary @@ -1257,11 +1383,19 @@ def _update_project_component(self, child_project_dict, verbose=True): if len(_ip_children): child_project_dict['children'] = [_child for _child in _ip_children if _child is not None] _filtered_ip_children = child_project_dict['children'] - self._add_project_components(_filtered_ip_children, _node, verbose) + self._add_project_components( + _filtered_ip_children, + _node, + verbose, + affiliated_institutions=affiliated_institutions, + ) return _node -def _overwrite_node_link_update_component(self, _input_prj_dicts, verbose=True): +def _overwrite_node_link_update_component( + self, _input_prj_dicts, verbose=True, + affiliated_institutions=None, +): """Update component node link and update current component with the update of _input_prj_dicts :param _input_prj_dicts: list children want to update @@ -1276,7 +1410,10 @@ def _overwrite_node_link_update_component(self, _input_prj_dicts, verbose=True): _input_prj_links_id = _input_prj_dict.get('project_links', None) # create new or fork project or update project - project = self._create_or_update_project(_input_projects, _project_idx, verbose) + project = self._create_or_update_project( + _input_projects, _project_idx, verbose, + affiliated_institutions=affiliated_institutions, + ) if project is None: # update output object and ignore it _input_projects[_project_idx] = None @@ -1303,7 +1440,10 @@ def _overwrite_node_link_update_component(self, _input_prj_dicts, verbose=True): _filtered_ip_children = _input_prj_dict['children'] # Create Components and lower level component - self._add_project_components(_filtered_ip_children, project, verbose) + self._add_project_components( + _filtered_ip_children, project, verbose, + affiliated_institutions=affiliated_institutions, + ) def convert_namespace_to_dict(namespace): diff --git a/grdmcli/grdm_client/users.py b/grdmcli/grdm_client/users.py index 6e3d0c1..4e8a3d4 100644 --- a/grdmcli/grdm_client/users.py +++ b/grdmcli/grdm_client/users.py @@ -10,8 +10,10 @@ __all__ = [ '_users_me', + '_users_institutions', ] MSG_E001 = 'Missing currently logged-in user' +MSG_E002 = 'Invalid institutions response format. Expected "data" as a list of objects with "id" and "type".' logger = logging.getLogger(__name__) @@ -44,3 +46,40 @@ def _users_me(self, ignore_error=True, verbose=True): logger.info(f'You are logged in as \'{self.user.id}\'') if verbose: logger.debug(f'\'{self.user.id}\' - \'{self.user.attributes.full_name}\'') + + +def _users_institutions(self, verbose=True): + """Get institution relationships of the currently logged-in user. + + :param verbose: boolean + :return: list of institutions + """ + if not self.user: + logger.warning(MSG_E001) + sys.exit(MSG_E001) + + _url = f'users/{self.user.id}/relationships/institutions/' + _response, _error_message = self._request('GET', _url, params={}, data={}) + if _error_message: + logger.warning(_error_message) + sys.exit(_error_message) + + _content = _response.content + response_json = json.loads(_content) + institutions = response_json.get('data') + if not isinstance(institutions, list): + logger.warning(MSG_E002) + sys.exit(MSG_E002) + + for idx, institution in enumerate(institutions): + if not isinstance(institution, dict) or not institution.get('id') or not institution.get('type'): + _error_message = f'{MSG_E002} Invalid item at index {idx}.' + logger.warning(_error_message) + sys.exit(_error_message) + + self.affiliated_institutions = institutions + + if verbose and self.affiliated_institutions: + logger.debug(f'Found affiliated institutions. [{len(self.affiliated_institutions)}]') + + return self.affiliated_institutions diff --git a/tests/factories.py b/tests/factories.py index cd9138f..fe51129 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -172,19 +172,16 @@ def _check_config(self, verbose=True): def _users_me(self, ignore_error=True, verbose=True): pass - def _prepare_output_file(self): - pass - - def parse_api_response(self, method, url, params=None): + def _users_institutions(self, verbose=True): pass - - def get_all_data_from_api(self, url, params=None): + + def _prepare_output_file(self): pass def parse_api_response(self, method, url, params=None, ignore_error=False, is_target_node=False): pass - def get_all_data_from_api(self, url, params={}): + def get_all_data_from_api(self, url, params=None, ignore_error=False): pass @@ -205,7 +202,6 @@ def __init__(self): self.created_project_contributors = [] self.template_schema_contributors = 'path-to-schema' - # For projects functions def _fake_project_content_data(self, pk, verbose=True): @@ -229,19 +225,25 @@ def _add_project_pointers(self, project_links, project, verbose=True): def _create_project(self, node_object, ignore_error=True, verbose=True): pass + def _prepare_institutions_relationship_data(self, institutions, verbose=True): + return {'data': []} + + def _add_node_institutions(self, node_id, institutions, verbose=True): + return True + def _load_project(self, pk, is_fake=True, ignore_error=True, verbose=True): pass def _fork_project(self, node_object, ignore_error=True, verbose=True): pass - def _projects_add_component(self, parent_id, node_object, ignore_error=True, verbose=True): + def _projects_add_component(self, parent_id, node_object, ignore_error=True, verbose=True, affiliated_institutions=None): pass - def _add_project_components(self, children, project, verbose=True): + def _add_project_components(self, children, project, verbose=True, affiliated_institutions=None): pass - def _create_or_update_project(self, projects, project_idx): + def _create_or_update_project(self, projects, project_idx, verbose=True, affiliated_institutions=None): pass def _update_project(self, node_object, ignore_error=True, verbose=True): @@ -250,10 +252,10 @@ def _update_project(self, node_object, ignore_error=True, verbose=True): def _overwrite_node_link(self, project, project_dict, verbose=True): pass - def _update_project_component(self, project_dict, verbose=True): + def _update_project_component(self, project_dict, verbose=True, affiliated_institutions=None): pass - def _overwrite_node_link_update_component(self, _ip_projects_dict, verbose=True): + def _overwrite_node_link_update_component(self, _ip_projects_dict, verbose=True, affiliated_institutions=None): pass def _remapping_node(self, tree_root): diff --git a/tests/test_grdm_client/test_common.py b/tests/test_grdm_client/test_common.py index 46b166d..3c17d95 100644 --- a/tests/test_grdm_client/test_common.py +++ b/tests/test_grdm_client/test_common.py @@ -91,7 +91,25 @@ def test_request__error_exception(self, mock_get, common_cli): mock_get.return_value = resp actual1, actual2 = CommonCLI._request(common_cli, 'GET', self.url) assert actual1 is None - assert actual2 == f'{resp.status_code} {resp.reason}' + assert actual2.startswith(f'{resp.status_code} {resp.reason}.') + assert 'Failed to parse API error response' in actual2 + assert 'Response content:' in actual2 + + @mock.patch('requests.request') + def test_request__error_exception_contains_parse_context(self, mock_get, common_cli): + resp = requests.Response() + resp.reason = 'bad request' + resp.status_code = 400 + resp._content = b'{"errors": invalid-json}' + mock_get.return_value = resp + + actual1, actual2 = CommonCLI._request(common_cli, 'GET', self.url) + + assert actual1 is None + assert actual2.startswith('400 bad request.') + assert 'Failed to parse API error response' in actual2 + assert 'JSONDecodeError' in actual2 + assert 'Response content: {"errors": invalid-json}' in actual2 @mock.patch('requests.request') def test_request__error_source(self, mock_get, common_cli): diff --git a/tests/test_grdm_client/test_projects.py b/tests/test_grdm_client/test_projects.py index 58dce20..41f5a6c 100644 --- a/tests/test_grdm_client/test_projects.py +++ b/tests/test_grdm_client/test_projects.py @@ -17,6 +17,8 @@ _fork_project, _create_project, _link_project_to_project, + _prepare_institutions_relationship_data, + _add_node_institutions, _add_project_pointers, _add_project_components, _projects_add_component, @@ -31,11 +33,8 @@ projects_get_list, projects_get, get_all_linked_node, - convert_contributor_with_template_get_cli, call_api_user_nodes, - convert_namespace_to_dict ) -from pathvalidate import ValidationError from tests.factories import GRDMClientFactory from tests.utils import * @@ -1627,7 +1626,7 @@ def test_create_or_update_project__case_create_project(caplog, grdm_client): def test_create_or_update_project__case_fork_project_none(caplog, grdm_client): - _projects = (projects.get('projects', [])).copy() + _projects = copy.deepcopy(projects.get('projects', [])) _fork_id = _projects[3].get('fork_id') with mock.patch.object(grdm_client, '_fork_project', return_value=(None, None)): actual = _create_or_update_project(grdm_client, _projects, 3) @@ -1638,7 +1637,7 @@ def test_create_or_update_project__case_fork_project_none(caplog, grdm_client): def test_create_or_update_project__case_fork_project(caplog, grdm_client): - _projects = projects.get('projects', []) + _projects = copy.deepcopy(projects.get('projects', [])) _fork_id = _projects[3].get('fork_id') with mock.patch.object(grdm_client, '_fork_project', return_value=(fork_project_obj.data, None)): actual = _create_or_update_project(grdm_client, _projects, 3) @@ -1650,6 +1649,247 @@ def test_create_or_update_project__case_fork_project(caplog, grdm_client): assert _projects[3]['type'] == fork_project_obj.data.type +def test_create_or_update_project__fork_calls_add_node_institutions(grdm_client): + _projects = copy.deepcopy(projects.get('projects', [])) + institutions = [SimpleNamespace(id='inst01')] + with ( + mock.patch.object(grdm_client, '_fork_project', return_value=(fork_project_obj.data, None)), + mock.patch.object(grdm_client, '_add_node_institutions') as mocked_add_institutions, + ): + _create_or_update_project( + grdm_client, _projects, 3, + affiliated_institutions=institutions, + ) + + mocked_add_institutions.assert_called_once_with( + fork_project_obj.data.id, institutions, + verbose=True, + ) + + +def test_create_or_update_project__update_does_not_add_node_institutions(grdm_client): + test_projects = [{'id': 'node01'}] + institutions = [SimpleNamespace(id='inst01')] + project = SimpleNamespace(id='node01', type='nodes') + + with ( + mock.patch.object(grdm_client, '_load_project', return_value=(project, None)), + mock.patch.object(grdm_client, '_update_project', return_value=(project, None)), + mock.patch.object(grdm_client, '_add_node_institutions') as mocked_add_institutions, + ): + _create_or_update_project( + grdm_client, test_projects, 0, + affiliated_institutions=institutions, + ) + + mocked_add_institutions.assert_not_called() + + +def test_prepare_institutions_relationship_data__success(grdm_client): + institutions = [ + {'id': 'inst01', 'type': 'institutions'}, + {'id': 'inst02', 'type': 'institutions'}, + {'id': 'inst03', 'type': 'institutions'}, + {'id': 'inst01', 'type': 'institutions'}, + ] + actual = _prepare_institutions_relationship_data(grdm_client, institutions, verbose=False) + assert actual == { + 'data': [ + {'type': 'institutions', 'id': 'inst01'}, + {'type': 'institutions', 'id': 'inst02'}, + {'type': 'institutions', 'id': 'inst03'}, + ] + } + + +def test_prepare_institutions_relationship_data__invalid_top_level_type_sys_exit(grdm_client, caplog): + with pytest.raises(SystemExit) as ex_info: + _prepare_institutions_relationship_data(grdm_client, 'inst01', verbose=False) + + assert ex_info.value.code == 'Invalid affiliated institutions input. Expected a list of items with "id" and type "institutions".' + assert caplog.records[0].levelname == warning_level_log + + +@pytest.mark.parametrize('institutions', [ + [{'id': 'inst01', 'type': 'institutions'}, 'inst02'], + [{'type': 'institutions'}], + [{'id': 'inst01', 'type': 'organization'}], +]) +def test_prepare_institutions_relationship_data__invalid_item_sys_exit(institutions, grdm_client, caplog): + with pytest.raises(SystemExit) as ex_info: + _prepare_institutions_relationship_data(grdm_client, institutions, verbose=False) + + assert ex_info.value.code.startswith( + 'Invalid affiliated institutions input. Expected a list of items with "id" and type "institutions".' + ) + assert caplog.records[0].levelname == warning_level_log + + +def test_prepare_institutions_relationship_data__empty(grdm_client): + actual = _prepare_institutions_relationship_data(grdm_client, [], verbose=True) + assert actual == {'data': []} + + +def test_prepare_institutions_relationship_data__verbose_log(grdm_client, caplog): + institutions = [{'id': 'inst01', 'type': 'institutions'}] + actual = _prepare_institutions_relationship_data(grdm_client, institutions, verbose=True) + assert actual == {'data': [{'type': 'institutions', 'id': 'inst01'}]} + assert caplog.records[0].levelname == debug_level_log + assert caplog.records[0].message == "Prepared institutions relationship data: {'data': [{'type': 'institutions', 'id': 'inst01'}]}" + + +def test_add_node_institutions__request_error_sys_exit(grdm_client, caplog): + with mock.patch.object(grdm_client, '_request', return_value=(None, 'error')), \ + mock.patch.object( + grdm_client, '_prepare_institutions_relationship_data', + return_value={'data': [{'type': 'institutions', 'id': 'inst01'}]}, + ): + with pytest.raises(SystemExit) as ex_info: + _add_node_institutions( + grdm_client, 'node01', + [{'id': 'inst01', 'type': 'institutions'}], + ) + assert ex_info.value.code == 'error' + assert caplog.records[0].levelname == warning_level_log + assert caplog.records[0].message == 'Failed to add affiliated institutions to nodes/node01/: error' + + +def test_add_node_institutions__empty_payload_returns_true(grdm_client): + with ( + mock.patch.object(grdm_client, '_prepare_institutions_relationship_data', return_value={'data': []}), + mock.patch.object(grdm_client, '_request') as mocked_request, + ): + actual = _add_node_institutions(grdm_client, 'node01', []) + assert actual is True + mocked_request.assert_not_called() + + +def test_add_node_institutions__request_returns_none_without_error_returns_false(grdm_client): + with mock.patch.object(grdm_client, '_request', return_value=(None, None)), \ + mock.patch.object( + grdm_client, '_prepare_institutions_relationship_data', + return_value={'data': [{'type': 'institutions', 'id': 'inst01'}]}, + ): + actual = _add_node_institutions( + grdm_client, 'node01', + [{'id': 'inst01', 'type': 'institutions'}], + ) + assert actual is False + + +def test_create_or_update_project__create_add_node_institutions_error_sys_exit(grdm_client): + _projects = copy.deepcopy(projects.get('projects', [])) + _projects[0].pop('id', None) + _projects[0].pop('fork_id', None) + with ( + mock.patch.object(grdm_client, '_create_project', return_value=(new_project_obj.data, None)), + mock.patch.object(grdm_client, '_add_node_institutions', side_effect=SystemExit('error')), + ): + with pytest.raises(SystemExit) as ex_info: + _create_or_update_project( + grdm_client, _projects, 0, + affiliated_institutions=[{'id': 'inst01', 'type': 'institutions'}], + ) + assert ex_info.value.code == 'error' + + +def test_create_or_update_project__fork_add_node_institutions_error_sys_exit(grdm_client): + _projects = copy.deepcopy(projects.get('projects', [])) + with ( + mock.patch.object(grdm_client, '_fork_project', return_value=(fork_project_obj.data, None)), + mock.patch.object(grdm_client, '_add_node_institutions', side_effect=SystemExit('error')), + ): + with pytest.raises(SystemExit) as ex_info: + _create_or_update_project( + grdm_client, _projects, 3, + affiliated_institutions=[{'id': 'inst01', 'type': 'institutions'}], + ) + assert ex_info.value.code == 'error' + + +def test_add_node_institutions__request_success_returns_true(grdm_client): + with mock.patch.object(grdm_client, '_request', return_value=(requests.Response(), None)), \ + mock.patch.object( + grdm_client, '_prepare_institutions_relationship_data', + return_value={'data': [{'type': 'institutions', 'id': 'inst01'}]}, + ): + actual = _add_node_institutions( + grdm_client, 'node01', + [{'id': 'inst01', 'type': 'institutions'}], + ) + assert actual is True + + +def test_create_or_update_project__create_calls_add_node_institutions(grdm_client): + _projects = copy.deepcopy(projects.get('projects', [])) + _projects[0].pop('id', None) + _projects[0].pop('fork_id', None) + institutions = [{'id': 'inst01', 'type': 'institutions'}] + with ( + mock.patch.object(grdm_client, '_create_project', return_value=(new_project_obj.data, None)), + mock.patch.object(grdm_client, '_add_node_institutions') as mocked_add_institutions + ): + _create_or_update_project( + grdm_client, _projects, 0, + affiliated_institutions=institutions + ) + mocked_add_institutions.assert_called_once_with( + new_project_obj.data.id, institutions, + verbose=True + ) + + +def test_projects_add_component__calls_add_node_institutions(grdm_client): + resp = requests.Response() + resp._content = new_project_str + _project = projects['projects'][2] + institutions = [{'id': 'inst01', 'type': 'institutions'}] + + with ( + mock.patch('tests.factories.GRDMClientFactory._prepare_project_data', return_value=True), + mock.patch.object(grdm_client, '_request', return_value=(resp, None)), + mock.patch.object(grdm_client, '_add_node_institutions') as mocked_add_institutions + ): + _projects_add_component( + grdm_client, + 'nid11', + _project, + verbose=False, + affiliated_institutions=institutions, + ) + + mocked_add_institutions.assert_called_once_with( + 'ezcuj', institutions, + verbose=False + ) + + +def test_projects_add_component__add_node_institutions_error_sys_exit_and_stop_downstream(grdm_client): + resp = requests.Response() + resp._content = new_project_str + _project = projects['projects'][2] + + with ( + mock.patch('tests.factories.GRDMClientFactory._prepare_project_data', return_value=True), + mock.patch.object(grdm_client, '_request', return_value=(resp, None)), + mock.patch.object(grdm_client, '_add_node_institutions', side_effect=SystemExit('error')), + mock.patch.object(grdm_client, '_add_project_pointers') as mocked_add_pointers, + mock.patch.object(grdm_client, '_add_project_components') as mocked_add_components, + ): + with pytest.raises(SystemExit) as ex_info: + _projects_add_component( + grdm_client, + 'nid11', + _project, + verbose=False, + affiliated_institutions=[{'id': 'inst01', 'type': 'institutions'}], + ) + + assert ex_info.value.code == 'error' + mocked_add_pointers.assert_not_called() + mocked_add_components.assert_not_called() + + def test_add_project_components__children_exist_id_ignored(grdm_client, caplog): _children = projects['projects'][0]['children'] children = [_children[1]] @@ -1660,6 +1900,29 @@ def test_add_project_components__children_exist_id_ignored(grdm_client, caplog): assert caplog.records[0].message == f'Project could not created' +def test_add_project_components__existing_child_passes_affiliated_institutions(grdm_client): + children = [{'id': 'child01'}] + institutions = [{'id': 'inst01', 'type': 'institutions'}] + with ( + mock.patch.object(grdm_client, 'get_all_data_from_api', return_value=[SimpleNamespace(id='child01')]), + mock.patch.object(grdm_client, '_update_project_component', return_value=SimpleNamespace(id='child01')) as mocked_update, + mock.patch.object(grdm_client, '_overwrite_node_link_update_component'), + ): + _add_project_components( + grdm_client, + children, + SimpleNamespace(id='parent01'), + verbose=False, + affiliated_institutions=institutions, + ) + + mocked_update.assert_called_once_with( + children[0], + False, + affiliated_institutions=institutions, + ) + + def test_add_project_components__add_components_is_none(grdm_client, caplog): _children = (projects['projects'][2]['children']).copy() children = [_children[0]] @@ -1687,6 +1950,43 @@ def test_add_project_components__add_components_not_have_child_id(grdm_client, c assert children[0]['type'] == component.type +def test_add_project_components__new_child_passes_affiliated_institutions(grdm_client): + children = [{ + 'category': 'analysis', + 'title': 'new child', + 'project_links': ['node01'], + }] + project = SimpleNamespace(id='parent01') + component = SimpleNamespace(id='child01', type='nodes') + institutions = [{'id': 'inst01', 'type': 'institutions'}] + + with ( + mock.patch.object(grdm_client, 'get_all_data_from_api', return_value=[]), + mock.patch.object(grdm_client, '_projects_add_component', return_value=(component, None)) as mocked_add_component, + mock.patch.object(grdm_client, '_overwrite_node_link_update_component') as mocked_overwrite, + ): + _add_project_components( + grdm_client, + children, + project, + verbose=False, + affiliated_institutions=institutions, + ) + + mocked_add_component.assert_called_once_with( + project.id, + children[0], + ignore_error=True, + verbose=False, + affiliated_institutions=institutions, + ) + mocked_overwrite.assert_called_once_with( + children[0], + False, + affiliated_institutions=institutions, + ) + + def test_add_project_components__add_components_success(grdm_client, caplog): _project = link_project_obj.data _children = projects['projects'][4]['children'] @@ -1833,6 +2133,55 @@ def test_projects_create__verbose_true(mocker, grdm_client, caplog): assert ex_info.value.args[0] == 0 +@mock.patch('sys.exit') +def test_projects_create__passes_affiliated_institutions_to_downstream(mocker, grdm_client): + institutions = [{'id': 'inst01', 'type': 'institutions'}] + input_projects = { + 'projects': [ + { + 'title': 'project01', + 'category': 'project', + 'children': [ + { + 'title': 'component01', + 'category': 'analysis', + } + ], + } + ] + } + created_project = SimpleNamespace(id='node01') + + mocker.patch('grdmcli.utils.check_json_schema') + mocker.patch('os.path.exists', side_effect=[True, True]) + with ( + mock.patch('grdmcli.utils.read_json_file', return_value=input_projects), + mock.patch.object(grdm_client, '_users_institutions', return_value=institutions) as mocked_users_institutions, + mock.patch.object(grdm_client, '_create_or_update_project', return_value=created_project) as mocked_create_or_update, + mock.patch.object(grdm_client, '_add_project_components') as mocked_add_components, + ): + projects_create(grdm_client) + + mocked_users_institutions.assert_called_once_with(verbose=False) + assert mocked_create_or_update.call_args.kwargs.get('affiliated_institutions') == institutions + assert mocked_add_components.call_args.kwargs.get('affiliated_institutions') == institutions + + +def test_projects_create__users_institutions_error_sys_exit_and_stop_downstream(grdm_client): + with ( + mock.patch('grdmcli.utils.check_json_schema'), + mock.patch('os.path.exists', side_effect=[True, True]), + mock.patch('grdmcli.utils.read_json_file', return_value={'projects': [{'title': 'project01', 'category': 'project'}]}), + mock.patch.object(grdm_client, '_users_institutions', side_effect=SystemExit('error')), + mock.patch.object(grdm_client, '_create_or_update_project') as mocked_create_or_update, + ): + with pytest.raises(SystemExit) as ex_info: + projects_create(grdm_client) + + assert ex_info.value.code == 'error' + mocked_create_or_update.assert_not_called() + + @mock.patch('sys.exit') @mock.patch('grdmcli.utils.write_json_file') def test_projects_create__case_create_or_update_project_none(mocker, grdm_client, caplog): @@ -2013,6 +2362,7 @@ def mock_get_cli__request(url, params = {}): data = requests.Response() data._content = json.dumps(contributors) return json.loads(data.content, object_hook=lambda d: SimpleNamespace(**d)).data + return None # Get cli parse_api_response @@ -2548,6 +2898,53 @@ def test_update_project_component__success(grdm_client): _update_project_component(grdm_client, has_child_link_project) +def test_update_project_component__children_pass_affiliated_institutions(grdm_client): + node_str = """{ + "data": { + "id": "qsqf2", + "relationships": { + "id": "ezcuj", + "parent": { + "data": { + "id": "f221h" + } + } + } + } + }""" + resp = requests.Response() + resp._content = node_str + child_project_dict = { + 'id': 'child01', + 'children': [ + { + 'category': 'analysis', + 'title': 'new child', + } + ] + } + institutions = [SimpleNamespace(id='inst01')] + + with ( + mock.patch.object(grdm_client, '_request', side_effect=[(None, None), (resp, None)]), + mock.patch.object(grdm_client, '_prepare_project_data'), + mock.patch.object(grdm_client, '_add_project_components') as mocked_add_components, + ): + _update_project_component( + grdm_client, + child_project_dict, + verbose=False, + affiliated_institutions=institutions, + ) + + mocked_add_components.assert_called_once_with( + child_project_dict['children'], + mock.ANY, + False, + affiliated_institutions=institutions, + ) + + def test_overwrite_node_link_update_component__successful(grdm_client, caplog): override_prjs = copy.deepcopy(prj_has_children_prj_link) override_prjs['projects'][0]['id'] = '123d' @@ -2587,6 +2984,36 @@ def test_overwrite_node_link_update_component__project_not_none_has_id(grdm_clie _overwrite_node_link_update_component(grdm_client, override_prjs, True) +def test_overwrite_node_link_update_component__passes_affiliated_institutions(grdm_client): + override_prjs = copy.deepcopy(prj_has_children_prj_link) + override_prjs['projects'][0]['id'] = '123d' + institutions = [SimpleNamespace(id='inst01')] + project = SimpleNamespace(id='123d') + + with ( + mock.patch.object(grdm_client, '_create_or_update_project', return_value=project) as mocked_create_or_update, + mock.patch.object(grdm_client, '_overwrite_node_link'), + mock.patch.object(grdm_client, '_add_project_components') as mocked_add_components, + ): + _overwrite_node_link_update_component( + grdm_client, + override_prjs, + False, + affiliated_institutions=institutions, + ) + + assert mocked_create_or_update.call_count == len(override_prjs['projects']) + for _call in mocked_create_or_update.call_args_list: + assert _call.kwargs.get('affiliated_institutions') == institutions + + mocked_add_components.assert_called_once_with( + override_prjs['projects'][0]['children'], + project, + False, + affiliated_institutions=institutions, + ) + + def test_remapping_node(grdm_client): pr1 = { "id": "id1", @@ -2655,5 +3082,3 @@ def test_convert_node_to_create_schema__has_license(grdm_client): grdm_client.licenses.append(json.loads(json.dumps(license), object_hook=lambda d: SimpleNamespace(**d))) with mock.patch.object(grdm_client, 'get_all_data_from_api', return_value=[_projects_obj]): _convert_node_to_create_schema(grdm_client, node_dict_has_license) - - diff --git a/tests/test_grdm_client/test_user.py b/tests/test_grdm_client/test_user.py index e911ce4..cea9268 100644 --- a/tests/test_grdm_client/test_user.py +++ b/tests/test_grdm_client/test_user.py @@ -7,7 +7,8 @@ import requests from grdmcli.grdm_client.users import ( - _users_me + _users_me, + _users_institutions, ) from tests.factories import GRDMClientFactory from tests.utils import * @@ -168,3 +169,65 @@ def test_users_me__send_request_success_and_verbose_true(caplog, grdm_client): assert caplog.records[1].message == f'You are logged in as \'{grdm_client.user.id}\'' assert caplog.records[2].levelname == debug_level_log assert caplog.records[2].message == f'\'{grdm_client.user.id}\' - \'{grdm_client.user.attributes.full_name}\'' + + +def test_users_institutions__missing_user_sys_exit( + caplog, grdm_client): + grdm_client.user = None + with pytest.raises(SystemExit) as ex_info: + _users_institutions(grdm_client) + assert ex_info.value.code == 'Missing currently logged-in user' + assert caplog.records[0].levelname == warning_level_log + assert caplog.records[0].message == 'Missing currently logged-in user' + + +def test_users_institutions__request_error_sys_exit( + caplog, grdm_client): + _error_message = 'error' + with mock.patch.object(grdm_client, '_request', return_value=(None, _error_message)): + with pytest.raises(SystemExit) as ex_info: + _users_institutions(grdm_client) + assert ex_info.value.code == _error_message + assert caplog.records[0].levelname == warning_level_log + assert caplog.records[0].message == _error_message + + +def test_users_institutions__request_success(caplog, grdm_client): + resp = requests.Response() + resp._content = _users_me_institutions_str + with mock.patch.object(grdm_client, '_request', return_value=(resp, None)): + actual = _users_institutions(grdm_client, verbose=True) + + assert len(actual) == 1 + assert actual[0]['id'] == 'csic' + assert actual[0]['type'] == 'institutions' + assert grdm_client.affiliated_institutions == actual + assert caplog.records[0].levelname == debug_level_log + assert caplog.records[0].message == 'Found affiliated institutions. [1]' + + +def test_users_institutions__invalid_response_data_not_list_sys_exit(caplog, grdm_client): + resp = requests.Response() + resp._content = '{"data": {"id": "csic", "type": "institutions"}}'.encode('utf-8') + with mock.patch.object(grdm_client, '_request', return_value=(resp, None)): + with pytest.raises(SystemExit) as ex_info: + _users_institutions(grdm_client) + + assert ex_info.value.code == 'Invalid institutions response format. Expected "data" as a list of objects with "id" and "type".' + assert caplog.records[0].levelname == warning_level_log + + +@pytest.mark.parametrize('data_payload', [ + '{"data": [{"id": "csic"}]}', + '{"data": [{"type": "institutions"}]}', + '{"data": ["csic"]}', +]) +def test_users_institutions__invalid_response_item_sys_exit(data_payload, caplog, grdm_client): + resp = requests.Response() + resp._content = data_payload.encode('utf-8') + with mock.patch.object(grdm_client, '_request', return_value=(resp, None)): + with pytest.raises(SystemExit) as ex_info: + _users_institutions(grdm_client) + + assert ex_info.value.code.startswith('Invalid institutions response format. Expected "data" as a list of objects with "id" and "type".') + assert caplog.records[0].levelname == warning_level_log