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()