diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00cf7fe..70866b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: poetry install -E data_science - name: Run doctests # Forks can't run doctests, requires super user token - if: github.actor == 'pwildenhain' + if: github.triggering_actor == 'pwildenhain' run: | poetry run pytest --doctest-only --doctest-plus - name: Run tests diff --git a/docs/quickstart.md b/docs/quickstart.md index f3dee47..ab8f79b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -35,7 +35,7 @@ Export a file ```python content, headers = project.export_file('1', 'file') -with open(headers['name'], 'w') as fobj: +with open(headers['name'], 'wb') as fobj: fobj.write(content) ``` @@ -50,3 +50,11 @@ except ValueError: # You screwed up and gave it a bad field name, etc pass ``` + +Export a PDF file of all instruments (blank) + +```python +content, _headers = project.export_pdf() +with open('all_instruments_blank.pdf', 'wb') as fobj: + fobj.write(content) +``` diff --git a/redcap/methods/base.py b/redcap/methods/base.py index afa9f4f..a3800be 100644 --- a/redcap/methods/base.py +++ b/redcap/methods/base.py @@ -341,6 +341,7 @@ def _return_data( "event", "exportFieldNames", "formEventMapping", + "instrument", "log", "metadata", "participantList", diff --git a/redcap/methods/instruments.py b/redcap/methods/instruments.py index b8af596..1a9e930 100644 --- a/redcap/methods/instruments.py +++ b/redcap/methods/instruments.py @@ -1,16 +1,7 @@ """REDCap API methods for Project instruments""" -from typing import ( - TYPE_CHECKING, - Any, - Dict, - List, - Literal, - Optional, - Union, - cast, -) - -from redcap.methods.base import Base +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, cast + +from redcap.methods.base import Base, FileMap from redcap.request import Json if TYPE_CHECKING: @@ -20,6 +11,116 @@ class Instruments(Base): """Responsible for all API methods under 'Instruments' in the API Playground""" + def export_instruments( + self, + format_type: Literal["json", "csv", "xml", "df"] = "json", + ): + """ + Export the Instruments of the Project + + Args: + format_type: + Response return format + + Returns: + Union[List[Dict[str, Any]], str, pandas.DataFrame]: List of Instruments + + Examples: + >>> proj.export_instruments() + [{'instrument_name': 'form_1', 'instrument_label': 'Form 1'}] + """ + payload = self._initialize_payload( + content="instrument", format_type=format_type + ) + return_type = self._lookup_return_type(format_type, request_type="export") + response = cast(Union[Json, str], self._call_api(payload, return_type)) + + return self._return_data( + response=response, + content="instrument", + format_type=format_type, + ) + + #### pylint: disable=too-many-locals + + def export_pdf( + self, + record: Optional[str] = None, + event: Optional[str] = None, + instrument: Optional[str] = None, + repeat_instance: Optional[int] = None, + all_records: Optional[bool] = None, + compact_display: Optional[bool] = None, + ) -> FileMap: + """ + Export PDF file of instruments, either as blank or with data + + Args: + record: Record ID + event: For longitudinal projects, the unique event name + instrument: Unique instrument name + repeat_instance: + (Only for projects with repeating instruments/events) + The repeat instance number of the repeating event (if longitudinal) + or the repeating instrument (if classic or longitudinal). + all_records: + If True, then all records will be exported as a single PDF file. + Note: If this is True, then record, event, and instrument parameters + are all ignored. + compact_display: + If True, then the PDF will be exported in compact display mode. + + Returns: + Content of the file and dictionary of useful metadata + + Examples: + >>> proj.export_pdf() + (b'%PDF-1.3\\n3 0 obj\\n..., {...}) + """ + # load up payload + payload = self._initialize_payload(content="pdf", return_format_type="json") + keys_to_add = ( + record, + event, + instrument, + repeat_instance, + all_records, + compact_display, + ) + str_keys = ( + "record", + "event", + "instrument", + "repeat_instance", + "allRecords", + "compactDisplay", + ) + for key, data in zip(str_keys, keys_to_add): + data = cast(str, data) + if data: + payload[key] = data + payload["action"] = "export" + + content, headers = cast( + FileMap, self._call_api(payload=payload, return_type="file_map") + ) + # REDCap adds some useful things in content-type + content_map = {} + if "content-type" in headers: + splat = [ + key_values.strip() for key_values in headers["content-type"].split(";") + ] + key_values = [ + (key_values.split("=")[0], key_values.split("=")[1].replace('"', "")) + for key_values in splat + if "=" in key_values + ] + content_map = dict(key_values) + + return content, content_map + + #### pylint: enable=too-many-locals + def export_instrument_event_mappings( self, format_type: Literal["json", "csv", "xml", "df"] = "json", @@ -62,3 +163,51 @@ def export_instrument_event_mappings( format_type=format_type, df_kwargs=df_kwargs, ) + + def import_instrument_event_mappings( + 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", + ): + # pylint: disable=line-too-long + """ + Import the project's instrument to event mapping + + Note: + This only works for longitudinal projects. + + 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, import_format + will be json-encoded + + Returns: + Union[int, str]: Number of instrument-event mappings imported + + Examples: + Import instrument-event mappings + >>> instrument_event_mappings = [{"arm_num": "1", "unique_event_name": "event_1_arm_1", "form": "form_1"}] + >>> proj.import_instrument_event_mappings(instrument_event_mappings) + 1 + """ + payload = self._initialize_import_payload( + to_import=to_import, + import_format=import_format, + return_format_type=return_format_type, + content="formEventMapping", + ) + payload["action"] = "import" + + return_type = self._lookup_return_type( + format_type=return_format_type, request_type="import" + ) + response = cast(Union[Json, str], self._call_api(payload, return_type)) + + return response diff --git a/tests/integration/test_long_project.py b/tests/integration/test_long_project.py index c8c97f6..adb041e 100644 --- a/tests/integration/test_long_project.py +++ b/tests/integration/test_long_project.py @@ -184,8 +184,12 @@ def test_arms_delete(long_project): @pytest.mark.integration def test_arms_import_override(long_project): - # Cache current events, so they can be restored for subsequent tests - current_events = long_project.export_events() + # Cache current events, so they can be restored for subsequent tests, because arms, events, + # and mappings are deleted when the 'override' parameter is used. + state_dict = { + "events": long_project.export_events(), + "form_event_map": long_project.export_instrument_event_mappings(), + } new_arms = [{"arm_num": 3, "name": "Drug C"}] response = long_project.import_arms(new_arms) @@ -206,9 +210,14 @@ def test_arms_import_override(long_project): with pytest.raises(RedcapError): response = long_project.export_arms() - response = long_project.import_events(current_events) + response = long_project.import_events(state_dict["events"]) assert response == 16 + response = long_project.import_instrument_event_mappings( + state_dict["form_event_map"] + ) + assert response == 44 + response = long_project.export_arms() assert len(response) == 2 @@ -248,3 +257,53 @@ def test_events_delete(long_project): response = long_project.export_events() assert len(response) == 16 + + +@pytest.mark.integration +def test_export_instruments(long_project): + response = long_project.export_instruments() + assert len(response) == 9 + + +@pytest.mark.integration +def test_export_pdf(long_project): + content, _ = long_project.export_pdf() + + assert isinstance(content, bytes) + + +@pytest.mark.integration +def test_fem_export(long_project): + response = long_project.export_instrument_event_mappings() + + assert len(response) == 44 + + +@pytest.mark.integration +def test_fem_import(long_project): + # Cache current instrument-event mappings, so they can be restored for subsequent tests + current_fem = long_project.export_instrument_event_mappings() + + instrument_event_mappings = [ + { + "arm_num": "1", + "unique_event_name": "enrollment_arm_1", + "form": "demographics", + } + ] + response = long_project.import_instrument_event_mappings(instrument_event_mappings) + assert response == 1 + + response = long_project.export_instrument_event_mappings() + assert len(response) == 1 + + fem_arm_nums = [fem["arm_num"] for fem in response] + fem_unique_event_names = [fem["unique_event_name"] for fem in response] + fem_forms = [fem["form"] for fem in response] + + assert fem_arm_nums == [1] + assert fem_unique_event_names == ["enrollment_arm_1"] + assert fem_forms == ["demographics"] + + response = long_project.import_instrument_event_mappings(current_fem) + assert response == 44 diff --git a/tests/integration/test_simple_project.py b/tests/integration/test_simple_project.py index 9395cc4..176f3f2 100644 --- a/tests/integration/test_simple_project.py +++ b/tests/integration/test_simple_project.py @@ -1,7 +1,6 @@ """Test suite for simple REDCap Project against real REDCap server""" # pylint: disable=missing-function-docstring import os - from io import StringIO import pandas as pd @@ -216,6 +215,19 @@ def test_export_field_names_df(simple_project): assert all(field_names.columns == ["choice_value", "export_field_name"]) +@pytest.mark.integration +def test_export_instruments(simple_project): + instruments = simple_project.export_instruments() + assert len(instruments) == 1 + + +@pytest.mark.integration +def test_export_pdf(simple_project): + content, _ = simple_project.export_pdf() + + assert isinstance(content, bytes) + + @pytest.mark.integration def test_export_and_import_metadata(simple_project): original_metadata = simple_project.export_metadata() @@ -279,3 +291,9 @@ def test_export_arms(simple_project): def test_export_events(simple_project): with pytest.raises(RedcapError): simple_project.export_events() + + +@pytest.mark.integration +def test_export_instrument_event_mapping(simple_project): + with pytest.raises(RedcapError): + simple_project.export_instrument_event_mappings() diff --git a/tests/unit/callback_utils.py b/tests/unit/callback_utils.py index d183351..3175a2a 100644 --- a/tests/unit/callback_utils.py +++ b/tests/unit/callback_utils.py @@ -210,15 +210,61 @@ def handle_export_field_names_request(**kwargs) -> Any: def handle_simple_project_form_event_mapping_request(**kwargs) -> Any: """Handle events export, used at project initialization""" headers = kwargs["headers"] - resp = {"error": "no events"} + resp = {"error": "You cannot export form/event mappings for classic projects"} + + return (400, headers, json.dumps(resp)) + + +def handle_simple_project_instruments_request(**kwargs) -> Any: + """Handle Instrument requests for simple project""" + headers = kwargs["headers"] + + # Instrument export (JSON only) + resp = [{"instrument_name": "form_1", "instrument_label": "Form 1"}] + + return (201, headers, json.dumps(resp)) + + +def handle_simple_project_pdf_request(**kwargs) -> Any: + """Handle PDF requests for simple project""" + headers = kwargs["headers"] + resp = {} + + return (201, headers, json.dumps(resp)) + + +def handle_long_project_instruments_request(**kwargs) -> Any: + """Handle Instrument requests for long project""" + headers = kwargs["headers"] + + # Instrument export (JSON only) + resp = [ + {"instrument_name": "form_1", "instrument_label": "Form 1"}, + {"instrument_name": "form_2", "instrument_label": "Form 2"}, + {"instrument_name": "form_3", "instrument_label": "Form 3"}, + ] + + return (201, headers, json.dumps(resp)) + + +def handle_long_project_pdf_request(**kwargs) -> Any: + """Handle PDF requests for long project""" + headers = kwargs["headers"] + resp = {} return (201, headers, json.dumps(resp)) def handle_long_project_form_event_mapping_request(**kwargs) -> Any: - """Give back list of events for long project""" + """Handle instrument-event mappings for long project""" headers = kwargs["headers"] - resp = [{"unique_event_name": "raw"}] + data = kwargs["data"] + # FEM import (JSON only) + if "data" in str(data): + resp = 1 + # FEM export (JSON only) + else: + resp = [{"arm_num": 1, "unique_event_name": "raw", "form": "form_1"}] return (201, headers, json.dumps(resp)) @@ -709,8 +755,10 @@ def get_simple_project_request_handler(request_type: str) -> Callable: "file": handle_simple_project_file_request, "formEventMapping": handle_simple_project_form_event_mapping_request, "generateNextRecordName": handle_generate_next_record_name_request, + "instrument": handle_simple_project_instruments_request, "log": handle_logging_request, "metadata": handle_simple_project_metadata_request, + "pdf": handle_simple_project_pdf_request, "project": handle_project_info_request, "record": handle_simple_project_records_request, "report": handle_simple_project_reports_request, @@ -731,9 +779,11 @@ def get_long_project_request_handler(request_type: str) -> Callable: "event": handle_long_project_events_request, "file": handle_long_project_file_request, "formEventMapping": handle_long_project_form_event_mapping_request, + "instrument": handle_long_project_instruments_request, "repeatingFormsEvents": handle_long_project_repeating_form_request, "metadata": handle_long_project_metadata_request, "participantList": handle_long_project_survey_participants_request, + "pdf": handle_long_project_pdf_request, "record": handle_long_project_records_request, "report": handle_long_project_reports_request, "version": handle_long_project_version_request, diff --git a/tests/unit/test_long_project.py b/tests/unit/test_long_project.py index c0932f6..c283336 100644 --- a/tests/unit/test_long_project.py +++ b/tests/unit/test_long_project.py @@ -81,6 +81,38 @@ def test_is_longitudinal(long_project): assert long_project.is_longitudinal +def test_instruments_export(long_project): + response = long_project.export_instruments() + + assert len(response) == 3 + + +def test_pdf_export(long_project): + content, _ = long_project.export_pdf() + + assert isinstance(content, bytes) + + +def test_pdf_export_specify(long_project): + content, _ = long_project.export_pdf( + record="1", event="raw", instrument="test", repeat_instance=1 + ) + + assert isinstance(content, bytes) + + +def test_pdf_export_all_records(long_project): + content, _ = long_project.export_pdf(all_records=True) + + assert isinstance(content, bytes) + + +def test_pdf_export_compact_display(long_project): + content, _ = long_project.export_pdf(compact_display=True) + + assert isinstance(content, bytes) + + def test_export_with_events(long_project): events = long_project.export_instrument_event_mappings() unique_event = events[0]["unique_event_name"] @@ -100,12 +132,23 @@ def test_fem_export(long_project): for arm in fem: assert isinstance(arm, dict) + assert len(fem) == 1 + def test_fem_export_stricly_enforces_format(long_project): with pytest.raises(ValueError): long_project.export_instrument_event_mappings(format_type="unsupported") +def test_fem_import(long_project): + instrument_event_mappings = [ + {"arm_num": "1", "unique_event_name": "event_1_arm_1", "form": "form_2"} + ] + res = long_project.import_instrument_event_mappings(instrument_event_mappings) + + assert res == 1 + + def test_export_to_df_gives_multi_index(long_project): long_dataframe = long_project.export_records(format_type="df", event_name="raw") diff --git a/tests/unit/test_simple_project.py b/tests/unit/test_simple_project.py index 01787c9..64af1bf 100644 --- a/tests/unit/test_simple_project.py +++ b/tests/unit/test_simple_project.py @@ -463,6 +463,18 @@ def test_export_records_strictly_enforces_format(simple_project): simple_project.export_records(format_type="unsupported") +def test_instruments_export(simple_project): + response = simple_project.export_instruments() + + assert len(response) == 1 + + +def test_pdf_export(simple_project): + content, _ = simple_project.export_pdf() + + assert isinstance(content, bytes) + + def test_fem_export_passes_filters_as_arrays(simple_project, mocker): mocked_api_call = mocker.patch.object( simple_project, "_call_api", return_value=None @@ -628,3 +640,8 @@ def test_arms_export_throws_exception(simple_project): def test_events_export_throws_exception(simple_project): with pytest.raises(RedcapError): simple_project.export_events() + + +def test_instrument_event_mapping_export_throws_exception(simple_project): + with pytest.raises(RedcapError): + simple_project.export_instrument_event_mappings()