diff --git a/README.md b/README.md index cdd5214..a6d8afa 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ Currently, these API calls are available: * Survey participant list * Users * User-DAG assignment +* User Roles +* User-Role assignment * Version ### Import @@ -65,6 +67,8 @@ Currently, these API calls are available: * Repeating instruments and events * Users * User-DAG assignment +* User Roles +* User-Role assignment ### Delete diff --git a/docs/api_reference/user_roles.md b/docs/api_reference/user_roles.md new file mode 100644 index 0000000..74f6b6c --- /dev/null +++ b/docs/api_reference/user_roles.md @@ -0,0 +1,5 @@ +# User Roles + +::: redcap.methods.user_roles + selection: + inherited_members: true diff --git a/mkdocs.yml b/mkdocs.yml index 212eb26..b0d5bfd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,7 @@ nav: - Reports: api_reference/reports.md - Surveys: api_reference/surveys.md - Users: api_reference/users.md + - User Roles: api_reference/user_roles.md - Version: api_reference/version.md theme: name: material diff --git a/redcap/methods/__init__.py b/redcap/methods/__init__.py index afec2fc..af928e0 100644 --- a/redcap/methods/__init__.py +++ b/redcap/methods/__init__.py @@ -12,4 +12,5 @@ import redcap.methods.reports import redcap.methods.surveys import redcap.methods.users +import redcap.methods.user_roles import redcap.methods.version diff --git a/redcap/methods/user_roles.py b/redcap/methods/user_roles.py new file mode 100644 index 0000000..7ba5520 --- /dev/null +++ b/redcap/methods/user_roles.py @@ -0,0 +1,241 @@ +"""REDCap API methods for Project user roles""" +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, overload + +from redcap.methods.base import Base, Json + +if TYPE_CHECKING: + import pandas as pd + + +class UserRoles(Base): + """Responsible for all API methods under 'Users Roles' in the API Playground""" + + @overload + def export_user_roles(self, format_type: Literal["json"], df_kwargs: None) -> Json: + ... + + @overload + def export_user_roles( + self, format_type: Literal["csv", "xml"], df_kwargs: None + ) -> str: + ... + + @overload + def export_user_roles( + self, format_type: Literal["df"], df_kwargs: Optional[Dict[str, Any]] + ) -> "pd.DataFrame": + ... + + def export_user_roles( + self, + format_type: Literal["json", "csv", "xml", "df"] = "json", + df_kwargs: Optional[Dict[str, Any]] = None, + ): + """ + Export the user roles of the Project + + Args: + format_type: + Response return format + df_kwargs: + Passed to `pandas.read_csv` to control construction of + returned DataFrame. By default, nothing + + Returns: + Union[List[Dict[str, Any]], str, pandas.DataFrame]: + List of user roles with assigned user rights + + Examples: + >>> proj.export_user_roles() + [{'unique_role_name': ..., 'role_label': 'Test role', 'design': '0', 'user_rights': '0', + 'data_access_groups': '0', 'data_export': '0', 'reports': '0', 'stats_and_charts': '0', + 'manage_survey_participants': '0', 'calendar': '0', 'data_import_tool': '0', + 'data_comparison_tool': '0', 'logging': '0', 'file_repository': '0', + 'data_quality_create': '0', 'data_quality_execute': '0', 'api_export': '0', + 'api_import': '0', 'mobile_app': '0', 'mobile_app_download_data': '0', + 'record_create': '0', 'record_rename': '0', 'record_delete': '0', + 'lock_records_customization': '0', 'lock_records': '0', 'lock_records_all_forms': '0', + 'forms': {'form_1': 2}}] + """ + payload = self._initialize_payload(content="userRole", format_type=format_type) + return_type = self._lookup_return_type(format_type, request_type="export") + response = self._call_api(payload, return_type) + + return self._return_data( + response=response, + content="userRole", + format_type=format_type, + df_kwargs=df_kwargs, + ) + + @overload + def import_user_roles( + self, + to_import: Union[str, List[Dict[str, Any]], "pd.DataFrame"], + return_format_type: Literal["json"], + import_format: Literal["json", "csv", "xml", "df"] = "json", + ) -> int: + ... + + @overload + def import_user_roles( + self, + to_import: Union[str, List[Dict[str, Any]], "pd.DataFrame"], + return_format_type: Literal["csv", "xml"], + import_format: Literal["json", "csv", "xml", "df"] = "json", + ) -> str: + ... + + def import_user_roles( + self, + to_import: Union[str, List[Dict[str, Any]], "pd.DataFrame"], + return_format_type: Literal["json", "csv", "xml"] = "json", + import_format: Literal["json", "csv", "xml", "df"] = "json", + ): + """ + Import user roles into the REDCap Project + + Args: + to_import: array of dicts, csv/xml string, `pandas.DataFrame` + Note: + If you pass a csv or xml string, you should use the + `import format` parameter appropriately. + return_format_type: + Response format. By default, response will be json-decoded. + import_format: + Format of incoming data. By default, to_import will be json-encoded + + Returns: + Union[int, str]: Number of user roles added or updated + + Examples: + >>> roles = proj.export_user_roles() + >>> proj.import_user_roles(roles) + 1 + """ + payload = self._initialize_import_payload( + to_import=to_import, + import_format=import_format, + return_format_type=return_format_type, + content="userRole", + ) + + return_type = self._lookup_return_type( + format_type=return_format_type, request_type="import" + ) + response = self._call_api(payload, return_type) + + return response + + @overload + def export_user_role_assignment( + self, format_type: Literal["json"], df_kwargs: None + ) -> Json: + ... + + @overload + def export_user_role_assignment( + self, format_type: Literal["csv", "xml"], df_kwargs: None + ) -> str: + ... + + @overload + def export_user_role_assignment( + self, format_type: Literal["df"], df_kwargs: Optional[Dict[str, Any]] + ) -> "pd.DataFrame": + ... + + def export_user_role_assignment( + self, + format_type: Literal["json", "csv", "xml", "df"] = "json", + df_kwargs: Optional[Dict[str, Any]] = None, + ): + """ + Export the User-Role assignments of the Project + + Args: + format_type: + Response return format + df_kwargs: + Passed to `pandas.read_csv` to control construction of + returned DataFrame. By default, nothing + + Returns: + Union[List[Dict[str, Any]], str, pandas.DataFrame]: + List of user-role assignments + + Examples: + >>> proj.export_user_role_assignment() + [{'username': ..., 'unique_role_name': ''}] + """ + payload = self._initialize_payload( + content="userRoleMapping", format_type=format_type + ) + return_type = self._lookup_return_type(format_type, request_type="export") + response = self._call_api(payload, return_type) + + return self._return_data( + response=response, + content="userRoleMapping", + format_type=format_type, + df_kwargs=df_kwargs, + ) + + @overload + def import_user_role_assignment( + self, + to_import: Union[str, List[Dict[str, Any]], "pd.DataFrame"], + return_format_type: Literal["json"], + import_format: Literal["json", "csv", "xml", "df"] = "json", + ) -> int: + ... + + @overload + def import_user_role_assignment( + self, + to_import: Union[str, List[Dict[str, Any]], "pd.DataFrame"], + return_format_type: Literal["csv", "xml"], + import_format: Literal["json", "csv", "xml", "df"] = "json", + ) -> str: + ... + + def import_user_role_assignment( + self, + to_import: Union[str, List[Dict[str, Any]], "pd.DataFrame"], + return_format_type: Literal["json", "csv", "xml"] = "json", + import_format: Literal["json", "csv", "xml", "df"] = "json", + ): + """ + Import User-Role assignments into the REDCap Project + + Args: + to_import: array of dicts, csv/xml string, `pandas.DataFrame` + Note: + If you pass a csv or xml string, you should use the + `import format` parameter appropriately. + return_format_type: + Response format. By default, response will be json-decoded. + import_format: + Format of incoming data. By default, to_import will be json-encoded + + Returns: + Union[int, str]: Number of user-role assignments added or updated + + Examples: + >>> user_role_assignments = proj.export_user_role_assignment() + >>> proj.import_user_role_assignment(user_role_assignments) + 1 + """ + payload = self._initialize_import_payload( + to_import=to_import, + import_format=import_format, + return_format_type=return_format_type, + content="userRoleMapping", + ) + + return_type = self._lookup_return_type( + format_type=return_format_type, request_type="import" + ) + response = self._call_api(payload, return_type) + + return response diff --git a/redcap/project.py b/redcap/project.py index 00a3501..49219e0 100755 --- a/redcap/project.py +++ b/redcap/project.py @@ -31,6 +31,7 @@ class Project( methods.reports.Reports, methods.surveys.Surveys, methods.users.Users, + methods.user_roles.UserRoles, methods.version.Version, ): """Main class for interacting with REDCap projects @@ -71,5 +72,10 @@ def redcap_version(self) -> Optional[semantic_version.Version]: try: return self._redcap_version except AttributeError: + # weird pylint bug on windows where it can't find Version.export_version() + # possible too many parents it's inheriting from? We also need to disable + # useless-supression since this is a windows only issue + # pylint: disable=no-member,useless-suppression self._redcap_version = self.export_version() + # pylint: enable=no-member,useless-suppression return self._redcap_version diff --git a/tests/data/doctest_project.xml b/tests/data/doctest_project.xml index 0af6a82..2de7f3e 100644 --- a/tests/data/doctest_project.xml +++ b/tests/data/doctest_project.xml @@ -38,6 +38,9 @@ <p>Thank you!</p>" offline_instructions="" acknowledgement="<p><strong>Thank you for taking the survey.</strong></p> <p>Have a nice day!</p>" stop_action_acknowledgement="" stop_action_delete_response="0" question_by_section="0" display_page_number="0" question_auto_numbering="1" survey_enabled="1" save_and_return="0" save_and_return_code_bypass="0" logo="" hide_title="0" view_results="0" min_responses_view_results="10" check_diversity_view_results="0" end_survey_redirect_url="" survey_expiration="" promis_skip_question="0" survey_auth_enabled_single="0" edit_completed_response="0" hide_back_button="0" show_required_field_text="1" confirmation_email_subject="" confirmation_email_content="" confirmation_email_from="" confirmation_email_from_display="" confirmation_email_attach_pdf="0" confirmation_email_attachment="" text_to_speech="0" text_to_speech_language="en" end_survey_redirect_next_survey="0" end_survey_redirect_next_survey_logic="" theme="" text_size="1" font_family="16" theme_text_buttons="" theme_bg_page="" theme_text_title="" theme_bg_title="" theme_text_sectionheader="" theme_bg_sectionheader="" theme_text_question="" theme_bg_question="" enhanced_choices="0" repeat_survey_enabled="0" repeat_survey_btn_text="" repeat_survey_btn_location="HIDDEN" response_limit="" response_limit_include_partials="1" response_limit_custom_text="<p>Thank you for your interest; however, the survey is closed because the maximum number of responses has been reached.</p>" survey_time_limit_days="" survey_time_limit_hours="" survey_time_limit_minutes="" email_participant_field="" end_of_survey_pdf_download="0" pdf_save_to_field="" pdf_save_to_event_id="" pdf_auto_archive="0" pdf_econsent_version="" pdf_econsent_type="" pdf_econsent_firstname_field="" pdf_econsent_firstname_event_id="" pdf_econsent_lastname_field="" pdf_econsent_lastname_event_id="" pdf_econsent_dob_field="" pdf_econsent_dob_event_id="" pdf_econsent_allow_edit="1" pdf_econsent_signature_field1="" pdf_econsent_signature_field2="" pdf_econsent_signature_field3="" pdf_econsent_signature_field4="" pdf_econsent_signature_field5=""/> + + + diff --git a/tests/data/test_simple_project.xml b/tests/data/test_simple_project.xml index d05142a..1cb8f52 100644 --- a/tests/data/test_simple_project.xml +++ b/tests/data/test_simple_project.xml @@ -30,6 +30,9 @@ + + + diff --git a/tests/integration/test_simple_project.py b/tests/integration/test_simple_project.py index d4ec068..a29ce67 100644 --- a/tests/integration/test_simple_project.py +++ b/tests/integration/test_simple_project.py @@ -112,6 +112,37 @@ def test_export_users(simple_project): assert users[0]["api_export"] == 1 +@pytest.mark.integration +def test_export_user_roles(simple_project): + user_roles = simple_project.export_user_roles() + assert len(user_roles) == 1 + assert user_roles[0]["role_label"] == "Example Role" + + +@pytest.mark.integration +def test_export_import_user_role_assignments(simple_project): + new_user = "pandeharris@gmail.com" + simple_project.import_users([{"username": new_user}]) + + example_role_name = simple_project.export_user_roles()[0]["unique_role_name"] + + res = simple_project.import_user_role_assignment( + [{"username": new_user, "unique_role_name": example_role_name}] + ) + assert res == 1 + + user_role_assignments = simple_project.export_user_role_assignment() + test_user_role_name = [ + user_role["unique_role_name"] + for user_role in user_role_assignments + if user_role["username"] == new_user + ][0] + assert test_user_role_name == example_role_name + # cleanup + res = simple_project.delete_users([new_user]) + assert res == 1 + + @pytest.mark.integration def test_export_dags(simple_project): dags = simple_project.export_dags(format_type="df") diff --git a/tests/unit/callback_utils.py b/tests/unit/callback_utils.py index f6691a5..2d38507 100644 --- a/tests/unit/callback_utils.py +++ b/tests/unit/callback_utils.py @@ -519,6 +519,45 @@ def handle_user_request(**kwargs) -> MockResponse: return (201, headers, resp) +def handle_user_role_request(**kwargs) -> MockResponse: + """Handle user role requests""" + headers = kwargs["headers"] + data = kwargs["data"] + # user import (JSON only) + if "data" in str(data): + resp = 1 + # user export (JSON only) + else: + resp = [ + { + "unique_role_name": "Role Name", + "role_label": "Test role", + "design": "0", + "user_rights": "0", + "data_access_groups": "0", + "data_export": "0", + "reports": "0", + "stats_and_charts": "0", + } + ] + + return (201, headers, json.dumps(resp)) + + +def handle_user_role_assignment_request(**kwargs) -> MockResponse: + """Handle user role mapping requests""" + headers = kwargs["headers"] + data = kwargs["data"] + # user import (JSON only) + if "data" in str(data): + resp = 1 + # user export (JSON only) + else: + resp = [{"username": "test user", "unique_role_name": "Role Name"}] + + return (201, headers, json.dumps(resp)) + + # pylint: disable=unused-argument def handle_simple_project_version_request(**kwargs) -> MockResponse: """Handle REDCap version request""" @@ -587,6 +626,8 @@ def get_simple_project_request_handler(request_type: str) -> Callable: "report": handle_simple_project_reports_request, "user": handle_user_request, "userDagMapping": handle_user_dag_assignment_request, + "userRole": handle_user_role_request, + "userRoleMapping": handle_user_role_assignment_request, "version": handle_simple_project_version_request, } diff --git a/tests/unit/test_simple_project.py b/tests/unit/test_simple_project.py index 3ee3ac1..f6c6034 100644 --- a/tests/unit/test_simple_project.py +++ b/tests/unit/test_simple_project.py @@ -215,6 +215,30 @@ def test_user_delete(simple_project): assert res == "1" +def test_user_role_export(simple_project): + res = simple_project.export_user_roles() + assert len(res) == 1 + + +def test_user_role_import(simple_project): + new_role = [{"role_label": "New role"}] + + res = simple_project.import_user_roles(new_role) + assert res == 1 + + +def test_export_user_role_assignment(simple_project): + res = simple_project.export_user_role_assignment() + assert len(res) == 1 + + +def test_import_user_role_assignment(simple_project): + new_role_assignment = [{"unique_role_name": "test role", "username": "test user"}] + + res = simple_project.import_user_role_assignment(new_role_assignment) + assert res == 1 + + def test_generate_next_record_name(simple_project): next_name = simple_project.generate_next_record_name()