Skip to content

Commit

Permalink
Merge pull request #9 from datalad/reports
Browse files Browse the repository at this point in the history
Add an export-redcap-report command
  • Loading branch information
mslw committed Feb 6, 2023
2 parents e9cf23f + c5f0208 commit f62d02e
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 35 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The extension is in early development.

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

## Usage examples
Expand Down
6 changes: 6 additions & 0 deletions datalad_redcap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
# optional name of the command in the Python API
'export_redcap_form'
),
(
'datalad_redcap.export_report',
'ExportReport',
'export-redcap-report',
'export_redcap_report'
),
(
'datalad_redcap.query',
'Query',
Expand Down
39 changes: 5 additions & 34 deletions datalad_redcap/export_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@
)
from datalad_next.utils import CredentialManager

from .utils import update_credentials
from .utils import (
update_credentials,
check_ok_to_edit,
)

__docformat__ = "restructuredtext"
lgr = logging.getLogger("datalad.redcap.export_form")
Expand Down Expand Up @@ -139,7 +142,7 @@ def __call__(
res_outfile = resolve_path(outfile, ds=ds)

# refuse to operate if target file is outside the dataset or not clean
ok_to_edit, unlock = _check_ok_to_edit(res_outfile, ds)
ok_to_edit, unlock = check_ok_to_edit(res_outfile, ds)
if not ok_to_edit:
yield get_status_dict(
action="export_redcap_form",
Expand Down Expand Up @@ -203,38 +206,6 @@ def __call__(
)


def _check_ok_to_edit(filepath: Path, ds: Dataset) -> Tuple[bool, bool]:
"""Check if it's ok to write to a file, and if it needs unlocking
Only allows paths that are within the given dataset (not outside, not in
a subdatset) and lead either to existing clean files or nonexisting files.
Uses ds.repo.status.
"""
try:
st = ds.repo.status(paths=[filepath])
except ValueError:
# path outside the dataset
return False, False

if st == {}:
# path is fine, file doesn't exist
ok_to_edit = True
unlock = False
else:
st_fp = st[filepath] # need to unpack
if st_fp["type"] == "file" and st_fp["state"] == "clean":
ok_to_edit = True
unlock = False
elif st_fp["type"] == "symlink" and st_fp["state"] == "clean":
ok_to_edit = True
unlock = True
else:
# note: paths pointing into subdatasets have type=dataset
ok_to_edit = False
unlock = False
return ok_to_edit, unlock


def _write_commit_message(which_forms: List[str]) -> str:
"""Return a formatted commit message that includes form names"""
forms = ", ".join(which_forms)
Expand Down
183 changes: 183 additions & 0 deletions datalad_redcap/export_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
from pathlib import Path
from typing import Optional

from redcap.methods.reports import Reports

from datalad.distribution.dataset import (
require_dataset,
resolve_path,
)
from datalad.interface.common_opts import (
nosave_opt,
save_message_opt,
)

from datalad_next.commands import (
EnsureCommandParameterization,
ValidatedInterface,
Parameter,
build_doc,
datasetmethod,
eval_results,
get_status_dict,
)
from datalad_next.constraints import (
EnsureBool,
EnsurePath,
EnsureStr,
EnsureURL,
)
from datalad_next.constraints.dataset import (
DatasetParameter,
EnsureDataset,
)
from datalad_next.utils import CredentialManager

from .utils import (
update_credentials,
check_ok_to_edit,
)


@build_doc
class ExportReport(ValidatedInterface):
"""Export a report of the Project
This is an equivalent to exporting a custom report via the "My
Reports & Exports" page in REDCap's interface. A report must be
defined through the REDCap's interface, and the user needs to look
up its auto-generated report ID.
"""

_params_ = dict(
url=Parameter(
args=("url",),
doc="API URL to a REDCap server",
),
report=Parameter(
args=("report",),
doc="""the report ID number, provided next to the report name
on the report list page in REDCap UI""",
metavar="report_id",
),
outfile=Parameter(
args=("outfile",),
doc="file to write. Existing files will be overwritten.",
),
dataset=Parameter(
args=("-d", "--dataset"),
metavar="PATH",
doc="""the dataset in which the output file will be saved.
The `outfile` argument will be interpreted as being relative to
this dataset. If no dataset is given, it will be identified
based on the working directory.""",
),
credential=Parameter(
args=("--credential",),
metavar="name",
doc="""name of the credential providing a token to be used for
authorization. If a match for the name is found, it will
be used; otherwise the user will be prompted and the
credential will be saved. If the name is not provided, the
last-used credential matching the API url will be used if
present; otherwise the user will be prompted and the
credential will be saved under a default name.""",
),
message=save_message_opt,
save=nosave_opt,
)

_validator_ = EnsureCommandParameterization(
dict(
url=EnsureURL(required=["scheme", "netloc", "path"]),
report=EnsureStr(),
outfile=EnsurePath(),
dataset=EnsureDataset(installed=True, purpose="export redcap report"),
credential=EnsureStr(),
message=EnsureStr(),
save=EnsureBool(),
)
)

@staticmethod
@datasetmethod(name="export_redcap_report")
@eval_results
def __call__(
url: str,
report: str,
outfile: Path,
dataset: Optional[DatasetParameter] = None,
credential: Optional[str] = None,
message: Optional[str] = None,
save: bool = True,
):

# work with a dataset object
if dataset is None:
# https://github.com/datalad/datalad-next/issues/225
ds = require_dataset(None)
else:
ds = dataset.ds

# sort out the path in context of the dataset
res_outfile = resolve_path(outfile, ds=ds)

# refuse to operate if target file is outside the dataset or not clean
ok_to_edit, unlock = check_ok_to_edit(res_outfile, ds)
if not ok_to_edit:
yield get_status_dict(
action="export_redcap_report",
path=res_outfile,
status="error",
message=(
"Output file status is not clean or the file does not "
"belong directly to the reference dataset."
),
)
return

# determine a token
credman = CredentialManager(ds.config)
credname, credprops = credman.obtain(
name=credential,
prompt="A token is required to access the REDCap project API",
type_hint="token",
query_props={"realm": url},
expected_props=("secret",),
)

# create an api object
api = Reports(
url=url,
token=credprops["secret"],
)

# perform the api query
response = api.export_report(
report_id=report,
format_type="csv",
)

# query went well, store or update credentials
update_credentials(credman, credname, credprops)

# unlock the file if needed, and write contents
if unlock:
ds.unlock(res_outfile)
with open(res_outfile, "wt") as f:
f.write(response)

# save changes in the dataset
if save:
ds.save(
message=message if message is not None else "Export REDCap report",
path=res_outfile,
)

# yield successful result if we made it to here
yield get_status_dict(
action="export_redcap_report",
path=res_outfile,
status="ok",
)
36 changes: 36 additions & 0 deletions datalad_redcap/tests/test_export_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from unittest.mock import patch

from datalad.api import export_redcap_report
from datalad.distribution.dataset import Dataset
from datalad_next.tests.utils import (
assert_status,
eq_,
with_credential,
with_tempfile,
)

TEST_TOKEN = "WTJ3G8XWO9G8V1BB4K8N81KNGRPFJOVL" # needed to pass length assertion
CSV_CONTENT = "foo,bar,baz\nspam,spam,spam"
CREDNAME = "redcap"


@with_tempfile
@patch("datalad_redcap.export_report.Reports.export_report", return_value=CSV_CONTENT)
@with_credential(CREDNAME, type="token", secret=TEST_TOKEN)
def test_export_writes_file(ds_path=None, mocker=None):
ds = Dataset(ds_path).create(result_renderer="disabled")
fname = "report.csv"

res = export_redcap_report(
url="https://www.example.com/api/",
report="1234",
outfile=fname,
dataset=ds,
credential=CREDNAME,
)

# check that the command returned ok
assert_status("ok", res)

# check that the file was created and left in clean state
eq_(ds.status(fname, return_type="item-or-list").get("state"), "clean")
39 changes: 38 additions & 1 deletion datalad_redcap/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
"""Utility methods"""

import logging
from typing import Optional
from pathlib import Path
from typing import(
Optional,
Tuple,
)

from datalad.distribution.dataset import Dataset
from datalad_next.exceptions import CapturedException
from datalad_next.utils import CredentialManager

Expand Down Expand Up @@ -32,3 +37,35 @@ def update_credentials(
except Exception as e:
msg = ("Exception raised when storing credential %r %r: %s",)
lgr.warn(msg, credname, credprops, CapturedException(e))


def check_ok_to_edit(filepath: Path, ds: Dataset) -> Tuple[bool, bool]:
"""Check if it's ok to write to a file, and if it needs unlocking
Only allows paths that are within the given dataset (not outside, not in
a subdatset) and lead either to existing clean files or nonexisting files.
Uses ds.repo.status.
"""
try:
st = ds.repo.status(paths=[filepath])
except ValueError:
# path outside the dataset
return False, False

if st == {}:
# path is fine, file doesn't exist
ok_to_edit = True
unlock = False
else:
st_fp = st[filepath] # need to unpack
if st_fp["type"] == "file" and st_fp["state"] == "clean":
ok_to_edit = True
unlock = False
elif st_fp["type"] == "symlink" and st_fp["state"] == "clean":
ok_to_edit = True
unlock = True
else:
# note: paths pointing into subdatasets have type=dataset
ok_to_edit = False
unlock = False
return ok_to_edit, unlock
2 changes: 2 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ High-level API commands
:toctree: generated

export_redcap_form
export_redcap_report
redcap_query


Expand All @@ -39,6 +40,7 @@ Command line reference
:maxdepth: 1

generated/man/datalad-export-redcap-form
generated/man/datalad-export-redcap-report
generated/man/datalad-redcap-query.rst


Expand Down

0 comments on commit f62d02e

Please sign in to comment.