Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add instrument methods #282

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand All @@ -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)
```
1 change: 1 addition & 0 deletions redcap/methods/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ def _return_data(
"event",
"exportFieldNames",
"formEventMapping",
"instrument",
"log",
"metadata",
"participantList",
Expand Down
161 changes: 149 additions & 12 deletions redcap/methods/instruments.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -20,6 +11,104 @@
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': 'demo', 'instrument_label': 'Demographics'}]
"""
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,
)

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

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")
# there's no format field in this call
payload["action"] = "export"
if record:
payload["record"] = record
if event:
payload["event"] = event
if instrument:
payload["instrument"] = instrument
if repeat_instance:
payload["repeat_instance"] = str(repeat_instance)
if all_records:
payload["allRecords"] = str(all_records == "True")
patking02 marked this conversation as resolved.
Show resolved Hide resolved
if compact_display:
payload["compactDisplay"] = str(compact_display == "True")
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

def export_instrument_event_mappings(
self,
format_type: Literal["json", "csv", "xml", "df"] = "json",
Expand Down Expand Up @@ -62,3 +151,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
50 changes: 50 additions & 0 deletions tests/integration/test_long_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,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_events()
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
20 changes: 19 additions & 1 deletion tests/integration/test_simple_project.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
events = simple_project.export_events()
assert len(events) == 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()
Expand Down Expand Up @@ -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()
56 changes: 53 additions & 3 deletions tests/unit/callback_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading