Skip to content

Commit f62d02e

Browse files
authored
Merge pull request #9 from datalad/reports
Add an export-redcap-report command
2 parents e9cf23f + c5f0208 commit f62d02e

File tree

7 files changed

+271
-35
lines changed

7 files changed

+271
-35
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The extension is in early development.
1111

1212
## Commands
1313
- `export-redcap-form`: Export records from selected forms (instruments)
14+
- `export-redcap-report`: Export a report that was defined in a project
1415
- `redcap-query`: Show names of available forms (instruments)
1516

1617
## Usage examples

datalad_redcap/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
# optional name of the command in the Python API
2424
'export_redcap_form'
2525
),
26+
(
27+
'datalad_redcap.export_report',
28+
'ExportReport',
29+
'export-redcap-report',
30+
'export_redcap_report'
31+
),
2632
(
2733
'datalad_redcap.query',
2834
'Query',

datalad_redcap/export_form.py

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@
4242
)
4343
from datalad_next.utils import CredentialManager
4444

45-
from .utils import update_credentials
45+
from .utils import (
46+
update_credentials,
47+
check_ok_to_edit,
48+
)
4649

4750
__docformat__ = "restructuredtext"
4851
lgr = logging.getLogger("datalad.redcap.export_form")
@@ -139,7 +142,7 @@ def __call__(
139142
res_outfile = resolve_path(outfile, ds=ds)
140143

141144
# refuse to operate if target file is outside the dataset or not clean
142-
ok_to_edit, unlock = _check_ok_to_edit(res_outfile, ds)
145+
ok_to_edit, unlock = check_ok_to_edit(res_outfile, ds)
143146
if not ok_to_edit:
144147
yield get_status_dict(
145148
action="export_redcap_form",
@@ -203,38 +206,6 @@ def __call__(
203206
)
204207

205208

206-
def _check_ok_to_edit(filepath: Path, ds: Dataset) -> Tuple[bool, bool]:
207-
"""Check if it's ok to write to a file, and if it needs unlocking
208-
209-
Only allows paths that are within the given dataset (not outside, not in
210-
a subdatset) and lead either to existing clean files or nonexisting files.
211-
Uses ds.repo.status.
212-
"""
213-
try:
214-
st = ds.repo.status(paths=[filepath])
215-
except ValueError:
216-
# path outside the dataset
217-
return False, False
218-
219-
if st == {}:
220-
# path is fine, file doesn't exist
221-
ok_to_edit = True
222-
unlock = False
223-
else:
224-
st_fp = st[filepath] # need to unpack
225-
if st_fp["type"] == "file" and st_fp["state"] == "clean":
226-
ok_to_edit = True
227-
unlock = False
228-
elif st_fp["type"] == "symlink" and st_fp["state"] == "clean":
229-
ok_to_edit = True
230-
unlock = True
231-
else:
232-
# note: paths pointing into subdatasets have type=dataset
233-
ok_to_edit = False
234-
unlock = False
235-
return ok_to_edit, unlock
236-
237-
238209
def _write_commit_message(which_forms: List[str]) -> str:
239210
"""Return a formatted commit message that includes form names"""
240211
forms = ", ".join(which_forms)

datalad_redcap/export_report.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
from pathlib import Path
2+
from typing import Optional
3+
4+
from redcap.methods.reports import Reports
5+
6+
from datalad.distribution.dataset import (
7+
require_dataset,
8+
resolve_path,
9+
)
10+
from datalad.interface.common_opts import (
11+
nosave_opt,
12+
save_message_opt,
13+
)
14+
15+
from datalad_next.commands import (
16+
EnsureCommandParameterization,
17+
ValidatedInterface,
18+
Parameter,
19+
build_doc,
20+
datasetmethod,
21+
eval_results,
22+
get_status_dict,
23+
)
24+
from datalad_next.constraints import (
25+
EnsureBool,
26+
EnsurePath,
27+
EnsureStr,
28+
EnsureURL,
29+
)
30+
from datalad_next.constraints.dataset import (
31+
DatasetParameter,
32+
EnsureDataset,
33+
)
34+
from datalad_next.utils import CredentialManager
35+
36+
from .utils import (
37+
update_credentials,
38+
check_ok_to_edit,
39+
)
40+
41+
42+
@build_doc
43+
class ExportReport(ValidatedInterface):
44+
"""Export a report of the Project
45+
46+
This is an equivalent to exporting a custom report via the "My
47+
Reports & Exports" page in REDCap's interface. A report must be
48+
defined through the REDCap's interface, and the user needs to look
49+
up its auto-generated report ID.
50+
51+
"""
52+
53+
_params_ = dict(
54+
url=Parameter(
55+
args=("url",),
56+
doc="API URL to a REDCap server",
57+
),
58+
report=Parameter(
59+
args=("report",),
60+
doc="""the report ID number, provided next to the report name
61+
on the report list page in REDCap UI""",
62+
metavar="report_id",
63+
),
64+
outfile=Parameter(
65+
args=("outfile",),
66+
doc="file to write. Existing files will be overwritten.",
67+
),
68+
dataset=Parameter(
69+
args=("-d", "--dataset"),
70+
metavar="PATH",
71+
doc="""the dataset in which the output file will be saved.
72+
The `outfile` argument will be interpreted as being relative to
73+
this dataset. If no dataset is given, it will be identified
74+
based on the working directory.""",
75+
),
76+
credential=Parameter(
77+
args=("--credential",),
78+
metavar="name",
79+
doc="""name of the credential providing a token to be used for
80+
authorization. If a match for the name is found, it will
81+
be used; otherwise the user will be prompted and the
82+
credential will be saved. If the name is not provided, the
83+
last-used credential matching the API url will be used if
84+
present; otherwise the user will be prompted and the
85+
credential will be saved under a default name.""",
86+
),
87+
message=save_message_opt,
88+
save=nosave_opt,
89+
)
90+
91+
_validator_ = EnsureCommandParameterization(
92+
dict(
93+
url=EnsureURL(required=["scheme", "netloc", "path"]),
94+
report=EnsureStr(),
95+
outfile=EnsurePath(),
96+
dataset=EnsureDataset(installed=True, purpose="export redcap report"),
97+
credential=EnsureStr(),
98+
message=EnsureStr(),
99+
save=EnsureBool(),
100+
)
101+
)
102+
103+
@staticmethod
104+
@datasetmethod(name="export_redcap_report")
105+
@eval_results
106+
def __call__(
107+
url: str,
108+
report: str,
109+
outfile: Path,
110+
dataset: Optional[DatasetParameter] = None,
111+
credential: Optional[str] = None,
112+
message: Optional[str] = None,
113+
save: bool = True,
114+
):
115+
116+
# work with a dataset object
117+
if dataset is None:
118+
# https://github.com/datalad/datalad-next/issues/225
119+
ds = require_dataset(None)
120+
else:
121+
ds = dataset.ds
122+
123+
# sort out the path in context of the dataset
124+
res_outfile = resolve_path(outfile, ds=ds)
125+
126+
# refuse to operate if target file is outside the dataset or not clean
127+
ok_to_edit, unlock = check_ok_to_edit(res_outfile, ds)
128+
if not ok_to_edit:
129+
yield get_status_dict(
130+
action="export_redcap_report",
131+
path=res_outfile,
132+
status="error",
133+
message=(
134+
"Output file status is not clean or the file does not "
135+
"belong directly to the reference dataset."
136+
),
137+
)
138+
return
139+
140+
# determine a token
141+
credman = CredentialManager(ds.config)
142+
credname, credprops = credman.obtain(
143+
name=credential,
144+
prompt="A token is required to access the REDCap project API",
145+
type_hint="token",
146+
query_props={"realm": url},
147+
expected_props=("secret",),
148+
)
149+
150+
# create an api object
151+
api = Reports(
152+
url=url,
153+
token=credprops["secret"],
154+
)
155+
156+
# perform the api query
157+
response = api.export_report(
158+
report_id=report,
159+
format_type="csv",
160+
)
161+
162+
# query went well, store or update credentials
163+
update_credentials(credman, credname, credprops)
164+
165+
# unlock the file if needed, and write contents
166+
if unlock:
167+
ds.unlock(res_outfile)
168+
with open(res_outfile, "wt") as f:
169+
f.write(response)
170+
171+
# save changes in the dataset
172+
if save:
173+
ds.save(
174+
message=message if message is not None else "Export REDCap report",
175+
path=res_outfile,
176+
)
177+
178+
# yield successful result if we made it to here
179+
yield get_status_dict(
180+
action="export_redcap_report",
181+
path=res_outfile,
182+
status="ok",
183+
)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from unittest.mock import patch
2+
3+
from datalad.api import export_redcap_report
4+
from datalad.distribution.dataset import Dataset
5+
from datalad_next.tests.utils import (
6+
assert_status,
7+
eq_,
8+
with_credential,
9+
with_tempfile,
10+
)
11+
12+
TEST_TOKEN = "WTJ3G8XWO9G8V1BB4K8N81KNGRPFJOVL" # needed to pass length assertion
13+
CSV_CONTENT = "foo,bar,baz\nspam,spam,spam"
14+
CREDNAME = "redcap"
15+
16+
17+
@with_tempfile
18+
@patch("datalad_redcap.export_report.Reports.export_report", return_value=CSV_CONTENT)
19+
@with_credential(CREDNAME, type="token", secret=TEST_TOKEN)
20+
def test_export_writes_file(ds_path=None, mocker=None):
21+
ds = Dataset(ds_path).create(result_renderer="disabled")
22+
fname = "report.csv"
23+
24+
res = export_redcap_report(
25+
url="https://www.example.com/api/",
26+
report="1234",
27+
outfile=fname,
28+
dataset=ds,
29+
credential=CREDNAME,
30+
)
31+
32+
# check that the command returned ok
33+
assert_status("ok", res)
34+
35+
# check that the file was created and left in clean state
36+
eq_(ds.status(fname, return_type="item-or-list").get("state"), "clean")

datalad_redcap/utils.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
"""Utility methods"""
22

33
import logging
4-
from typing import Optional
4+
from pathlib import Path
5+
from typing import(
6+
Optional,
7+
Tuple,
8+
)
59

10+
from datalad.distribution.dataset import Dataset
611
from datalad_next.exceptions import CapturedException
712
from datalad_next.utils import CredentialManager
813

@@ -32,3 +37,35 @@ def update_credentials(
3237
except Exception as e:
3338
msg = ("Exception raised when storing credential %r %r: %s",)
3439
lgr.warn(msg, credname, credprops, CapturedException(e))
40+
41+
42+
def check_ok_to_edit(filepath: Path, ds: Dataset) -> Tuple[bool, bool]:
43+
"""Check if it's ok to write to a file, and if it needs unlocking
44+
45+
Only allows paths that are within the given dataset (not outside, not in
46+
a subdatset) and lead either to existing clean files or nonexisting files.
47+
Uses ds.repo.status.
48+
"""
49+
try:
50+
st = ds.repo.status(paths=[filepath])
51+
except ValueError:
52+
# path outside the dataset
53+
return False, False
54+
55+
if st == {}:
56+
# path is fine, file doesn't exist
57+
ok_to_edit = True
58+
unlock = False
59+
else:
60+
st_fp = st[filepath] # need to unpack
61+
if st_fp["type"] == "file" and st_fp["state"] == "clean":
62+
ok_to_edit = True
63+
unlock = False
64+
elif st_fp["type"] == "symlink" and st_fp["state"] == "clean":
65+
ok_to_edit = True
66+
unlock = True
67+
else:
68+
# note: paths pointing into subdatasets have type=dataset
69+
ok_to_edit = False
70+
unlock = False
71+
return ok_to_edit, unlock

docs/source/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ High-level API commands
2929
:toctree: generated
3030

3131
export_redcap_form
32+
export_redcap_report
3233
redcap_query
3334

3435

@@ -39,6 +40,7 @@ Command line reference
3940
:maxdepth: 1
4041

4142
generated/man/datalad-export-redcap-form
43+
generated/man/datalad-export-redcap-report
4244
generated/man/datalad-redcap-query.rst
4345

4446

0 commit comments

Comments
 (0)