diff --git a/modules/redcap/CONFIGURATION.md b/modules/redcap/CONFIGURATION.md index 559b174bdd0..40ea03d9743 100644 --- a/modules/redcap/CONFIGURATION.md +++ b/modules/redcap/CONFIGURATION.md @@ -20,6 +20,11 @@ The configuration is of the following form: visit_1 arm_1 event_1 + + My Site + My Project + My Cohort + @@ -28,6 +33,8 @@ The configuration is of the following form: ## Detailed description +### General configuration + The configuration nodes are the following: - `redcap` (required): Root node of the LORIS REDCap configuration. - `instance` (required, multiple allowed): The list of instance entries to synchronize with LORIS. @@ -36,15 +43,27 @@ In an `instance` entry, the configuration parameters are the following: - `redcap-url` (required): The URL of the REDCap instance. This is also the URL of the REDCap instance API without the `api` suffix. - `project` (required, multiple allowed): The list of project entries in this REDCap instance to synchronize with LORIS. +### Project configuration + In a `project` entry, the configuration parameters are the following: - `redcap-project-id` (required): The REDCap project ID of the REDCap project described by this entry. - `redcap-api-token` (required): The REDCap API token used by LORIS to retrieve REDCap data for this project. - `prefix-instrument-variable` (optional): Whether or not the instrument field variable names are prefixed by their instrument name in REDCap. The two options are `true` or `false`. If not present, `false` is used. - `redcap-participant-id` (optional): The type of REDCap participant identifier used to map the REDCap participants with the LORIS candidates. The two options are `record_id` and `survey_participant_id`. If not present, `record_id` is used. - `candidate-id` (optional): The type of LORIS candidate identifier used to map the REDCap participants with the LORIS candidates. The two options are `candid` and `pscid`. If not present, `pscid` is used. -- `visit` (optional, multiple allowed): The list of visit entries that describe how REDCap arms and events are mapped to LORIS visits. If not present, the REDCap arms are ignored and the REDCap event names are matched to LORIS visit labels with the same name. +- `visit` (optional, multiple allowed): A list of visit mappings that describe how REDCap arms and events are mapped to LORIS visits. If not present, the REDCap arms are ignored and the REDCap event names are matched to LORIS visit labels with the same name. + +### Visit mapping configuration In a `visit` entry, the configuration parameters are the following: - `visit-label` (required): The LORIS visit label of the visit to which to attach the instrument responses that match this entry. - `redcap-arm-name` (optional): The REDCap arm name that the instrument responses must match to be attached to this visit. If not present, the arm name is ignored when filtering instrument responses. - `redcap-event-name` (optional): The REDCap event name that the instrument responses must match to be attached to this visit. If not present, the event name is ignored when filtering instrument responses. +- `create-session` (optional): The information needed to create the LORIS session for the candidate and visit associated with a REDCap event if it does not already exist. + +### Session creation configuration + +In a `create-session` entry, the configuration parameters are the following: +- `site-name`: The name of the LORIS site for which to create the session if it does not already exist. +- `project-name`: The name of the LORIS project for which to create the session visit if it does not already exist. +- `cohort-name`: The name of the LORIS cohort for which to create the session visit if it does not already exist. diff --git a/modules/redcap/php/client/models/records/redcaprecord.class.inc b/modules/redcap/php/client/models/records/redcaprecord.class.inc index 2ee331ce7f9..5e066910fa0 100644 --- a/modules/redcap/php/client/models/records/redcaprecord.class.inc +++ b/modules/redcap/php/client/models/records/redcaprecord.class.inc @@ -12,6 +12,8 @@ namespace LORIS\redcap\client\models\records; +use LORIS\redcap\client\RedcapProps; + /** * This represents a redcap record. * @@ -23,6 +25,20 @@ namespace LORIS\redcap\client\models\records; */ class RedcapRecord implements IRedcapRecord { + /** + * The date at which the record was completed if it was completed using a + * survey. + */ + public readonly ?\DateTimeImmutable $datetime; + + /** + * The record completion status. + * 0 = incomplete / partial survey response + * 1 = unverified + * 2 = complete + */ + public readonly int $complete; + private string $_form_name; private array $_props; @@ -37,6 +53,22 @@ class RedcapRecord implements IRedcapRecord { $this->_form_name = $form_name; $this->_props = $props; + + $props = new RedcapProps('record', $props); + + $datetime_string = $props->getStringNullable("{$form_name}_timestamp"); + if ($datetime_string !== null) { + try { + $datetime = new \DateTimeImmutable($datetime_string); + } catch (\DateMalformedStringException) { + $datetime = null; + } + } else { + $datetime = null; + } + + $this->datetime = $datetime; + $this->complete = $props->getInt("{$form_name}_complete"); } /** diff --git a/modules/redcap/php/config/redcapconfigcreatesession.class.inc b/modules/redcap/php/config/redcapconfigcreatesession.class.inc new file mode 100644 index 00000000000..afcd0783dd6 --- /dev/null +++ b/modules/redcap/php/config/redcapconfigcreatesession.class.inc @@ -0,0 +1,62 @@ +site_name = $site_name; + $this->project_name = $project_name; + $this->cohort_name = $cohort_name; + } +} diff --git a/modules/redcap/php/config/redcapconfigparser.class.inc b/modules/redcap/php/config/redcapconfigparser.class.inc index 052db07703a..a06a31c2aa0 100644 --- a/modules/redcap/php/config/redcapconfigparser.class.inc +++ b/modules/redcap/php/config/redcapconfigparser.class.inc @@ -366,11 +366,60 @@ class RedcapConfigParser $redcap_arm_name = $visit_node['redcap-arm-name'] ?? null; $redcap_event_name = $visit_node['redcap-event-name'] ?? null; + $create_session = $this->_parseCreateSession($visit_node); return new RedcapConfigVisit( $visit_label, $redcap_arm_name, $redcap_event_name, + $create_session, + ); + } + + /** + * Parse a REDCap session creation configuration from a REDCap session creation + * configuration XML node. + * + * @param array $visit_node The REDCap configuration visit mapping XML node. + * + * @return ?RedcapConfigCreateSession The REDCap session creation configuration. + */ + private function _parseCreateSession( + array $visit_node, + ): ?RedcapConfigCreateSession { + $create_session_node = $visit_node['create-session'] ?? null; + if (empty($create_session_node)) { + return null; + } + + $site_name = $create_session_node['site-name'] ?? null; + if (empty($site_name)) { + throw $this->_exception( + "no site name in session creation configuration, missing node" + . " 'site-name'." + ); + } + + $project_name = $create_session_node['project-name'] ?? null; + if (empty($project_name)) { + throw $this->_exception( + "no project name in session creation configuration, missing node" + . " 'project-name'." + ); + } + + $cohort_name = $create_session_node['cohort-name'] ?? null; + if (empty($cohort_name)) { + throw $this->_exception( + "no cohort name in session creation configuration, missing node" + . " 'cohort-name'." + ); + } + + return new RedcapConfigCreateSession( + $site_name, + $project_name, + $cohort_name, ); } diff --git a/modules/redcap/php/config/redcapconfigvisit.class.inc b/modules/redcap/php/config/redcapconfigvisit.class.inc index 44c53e026c4..1930d750ae1 100644 --- a/modules/redcap/php/config/redcapconfigvisit.class.inc +++ b/modules/redcap/php/config/redcapconfigvisit.class.inc @@ -30,33 +30,45 @@ class RedcapConfigVisit public readonly string $visit_label; /** - * The REDCap arm name of the mapping, or `NULL`. + * The REDCap arm name of the mapping, if any. * * @var ?string */ public readonly ?string $redcap_arm_name; /** - * The REDCap event name of the mapping, or `NULL`. + * The REDCap event name of the mapping, if any. * * @var ?string */ public readonly ?string $redcap_event_name; + /** + * The LORIS automatic session creation configuration, if any. + * + * @var ?RedcapConfigCreateSession + */ + public ?RedcapConfigCreateSession $create_session; + /** * Constructor. * - * @param string $visit_label The LORIS visit label. - * @param ?string $redcap_arm_name The REDCap arm name. - * @param ?string $redcap_event_name The REDCap event name. + * @param string $visit_label The LORIS visit label. + * @param ?string $redcap_arm_name The REDCap arm name. + * @param ?string $redcap_event_name The REDCap event name. + * @param ?RedcapConfigCreateSession $create_session The LORIS automatic + * session creation + * configuration. */ public function __construct( string $visit_label, ?string $redcap_arm_name, ?string $redcap_event_name, + ?RedcapConfigCreateSession $create_session, ) { $this->visit_label = $visit_label; $this->redcap_arm_name = $redcap_arm_name; $this->redcap_event_name = $redcap_event_name; + $this->create_session = $create_session; } } diff --git a/modules/redcap/php/redcapmapper.class.inc b/modules/redcap/php/redcapmapper.class.inc index fe2eb1cfd60..b9e023fd361 100644 --- a/modules/redcap/php/redcapmapper.class.inc +++ b/modules/redcap/php/redcapmapper.class.inc @@ -12,12 +12,17 @@ namespace LORIS\redcap; +use \LORIS\LorisInstance; use \LORIS\redcap\RedcapQueries; use \LORIS\redcap\config\RedcapConfig; use \LORIS\redcap\config\RedcapConfigLorisId; use \LORIS\redcap\config\RedcapConfigRedcapId; +use \LORIS\redcap\config\RedcapConfigVisit; use \LORIS\redcap\client\RedcapHttpClient; use \LORIS\redcap\client\models\RedcapEvent; +use \LORIS\redcap\client\models\records\RedcapRecord; +use \Candidate; +use \TimePoint; /** * Mapping methods to match REDCap and LORIS identifiers in the REDCap module. @@ -32,6 +37,13 @@ use \LORIS\redcap\client\models\RedcapEvent; */ class RedcapMapper { + /** + * THe LORIS instance. + * + * @var LorisInstance + */ + private LorisInstance $_loris; + /** * The REDCap HTTP client. * @@ -56,18 +68,58 @@ class RedcapMapper /** * Constructor. * + * @param LorisInstance $loris The LORIS instance. * @param RedcapHttpClient $redcap_client The REDCap HTTP client. * @param RedcapConfig $config The REDCap module configuration. - * @param RedcapQueries $queries The REDCap module database queries. */ public function __construct( + LorisInstance $loris, RedcapHttpClient $redcap_client, RedcapConfig $config, - RedcapQueries $queries, ) { + $this->_loris = $loris; $this->_redcap_client = $redcap_client; $this->_config = $config; - $this->_queries = $queries; + $this->_queries = new RedcapQueries($loris); + } + + /** + * Get the visit label of the session associated with a REDCap record. If the + * session does not already exist, this function either creates it if automatic + * session creation is enabled for that visit in the REDCap module + * configuration, or throws an error if it is not. + * + * @param RedcapRecord $redcap_record The REDCap record. + * @param string $unique_event_name The unique event name associated with + * that REDCap record. + * @param \Candidate $candidate The LORIS candidate associated with + * that REDCap record. + * + * @return string The visit label of the relevant session. + */ + public function getVisitLabel( + RedcapRecord $redcap_record, + string $unique_event_name, + \Candidate $candidate, + ): string { + $visit_config = $this->getVisitConfig($unique_event_name); + + // If no visit mappings are defined in the configuration, use the REDCap + // unique event name as the visit label directly. + if ($visit_config === null) { + return $unique_event_name; + } + + $session = $this->checkOrCreateSession($candidate, $visit_config); + + $this->checkOrStartSessionStage( + $candidate, + $session, + $visit_config, + $redcap_record, + ); + + return $visit_config->visit_label; } /** @@ -76,11 +128,11 @@ class RedcapMapper * * @param string $unique_event_name The REDCap unique event name. * - * @return ?string The LORIS visit label associated with the REDCap + * @return ?RedcapConfigVisit The LORIS visit label associated with the REDCap * notification, or `null` if no corresponding visit label is * found. */ - public function getVisitLabel(string $unique_event_name): ?string + public function getVisitConfig(string $unique_event_name): ?RedcapConfigVisit { // Get the list of all the REDCap events for this REDCap project. $redcap_events = $this->_redcap_client->getEvents(); @@ -104,7 +156,7 @@ class RedcapMapper // If there are no visit mappings in the REDCap module configuration, simply // use the REDCap event name as the LORIS vist name. if ($this->_config->visits === null) { - return $redcap_event->name; + return null; } $event_name = $redcap_event->name; @@ -147,7 +199,196 @@ class RedcapMapper } // Return the LORIS visit label associated with the matching visit mapping. - return reset($visit_mappings)->visit_label; + $visit_config = reset($visit_mappings); + return $visit_config ? $visit_config : null; + } + + /** + * Check that a session exists or create it using the REDCap module automatic + * session creation configuration. Throw an exception if the session does not + * exist and automatic session creation is not enabled for that visit. + * + * @param \Candidate $candidate The LORIS candidate associated with + * that REDCap record. + * @param RedcapConfigVisit $visit_config The REDCap module visit configuration + * associated with that REDCap record. + * + * @return TimePoint + */ + public function checkOrCreateSession( + \Candidate $candidate, + RedcapConfigVisit $visit_config, + ): TimePoint { + // Get the ID of the session associated with the candidate and visit label. + $session_id = array_search( + $visit_config->visit_label, + $candidate->getListOfVisitLabels() + ); + + // If the session already exists, no need to create it. + if ($session_id) { + return TimePoint::singleton(new \SessionID(strval($session_id)));; + } + + // Throw an exception if the session does not exist and there is no + // session creation configuration for that visit, hence skipping the + // notification. + if ($visit_config->create_session === null) { + $psc_id = $candidate->getPSCID(); + $visit_label = $visit_config->visit_label; + throw new \LorisException( + "[redcap] No session found for candidate '$psc_id' and visit label" + . " '$visit_label', skipping notification." + ); + } + + // Get the LORIS site, project, and cohort from the database using the + // information provided by the session creation configuration. + + $site_name = $visit_config->create_session->site_name; + $site_id = array_search($site_name, \Utility::getSiteList()); + + $project_name = $visit_config->create_session->project_name; + $project_id = array_search($project_name, \Utility::getProjectList()); + + $cohort_name = $visit_config->create_session->cohort_name; + $cohort_id = array_search($cohort_name, \Utility::getCohortList()); + + // Throw an exception if any of the site, project, or cohort could not be + // obtained from the database, which means that the information present in + // the session creation configuration was incorrect. + + if (!$site_id) { + throw new \LorisException( + "[redcap] Error: No LORIS site found for site name '$site_name'," + . " the REDCap module configuration is incorrect." + ); + } + + if (!$project_id) { + throw new \LorisException( + "[redcap] Error: No LORIS project found for site name" + . " '$project_name', the REDCap module configuration is incorrect." + ); + } + + if (!$cohort_id) { + throw new \LorisException( + "[redcap] Error: No LORIS cohort found for site name" + . " '$cohort_name', the REDCap module configuration is incorrect." + ); + } + + // Since the REDCap module is called by the REDCap API or from a script, + // there is usually no logged in user. As such, manually set the user to be + // the REDCap issue assignee to create the session. + + $user = $this->_queries->getRedcapIssueAssignee(); + + $state = \State::singleton(); + $state->setUsername($user->getUsername()); + $_SESSION['State'] =& $state; + + // Create the session that corresponds to this mapping. + + // TODO: Return directly once `TimePoint::createNew` has been modified to + // return the session. + TimePoint::createNew( + $candidate, + $cohort_id, + $visit_config->visit_label, + \Site::singleton(new \CenterID(strval($site_id))), + \Project::getProjectFromID(new \ProjectID(strval($project_id))), + ); + + // Get the session again now that it been created. + + $session_id = array_search( + $visit_config->visit_label, + $candidate->getListOfVisitLabels() + ); + + return TimePoint::singleton(new \SessionID(strval($session_id))); + } + + /** + * Start the session if it is not already started and the automatic session + * creation is enabled. + * + * @param Candidate $candidate The LORIS candidate associated with + * the REDCap record. + * @param TimePoint $session The LORIS session associated with the + * REDCap record. + * @param RedcapConfigVisit $visit_config The REDCap module visit configuration + * associated with that REDCap record. + * @param RedcapRecord $redcap_record The REDCap record. + * + * @return void + */ + function checkOrStartSessionStage( + Candidate $candidate, + TimePoint $session, + RedcapConfigVisit $visit_config, + RedcapRecord $redcap_record, + ) { + // If the session is not startable, do not start it. + if ($session->getCurrentStage() !== 'Not Started') { + return; + } + + // Throw an exception if the automatic session creation is not enabled for + // that visit, hence skipping the notification. + if ($visit_config->create_session === null) { + $psc_id = $candidate->getPSCID(); + $visit_label = $visit_config->visit_label; + throw new \LorisException( + "[redcap] Session for candidate '$psc_id' and visit label" + . " '$visit_label' is not started, skipping notification." + ); + } + + // Start the session next stage, so that it can receive instrument data. + + $new_stage = $session->getNextStage(); + $session->startStage($new_stage); + + // Use the record completion date if it is present, or fall back to the + // current date otherwise. + // TODO: Factorize the "get record date" operation. + if ($redcap_record->datetime !== null) { + $datetime = $redcap_record->datetime; + } else { + $datetime = new \DateTimeImmutable(); + } + + $session->setData( + [ + "Date_{$new_stage}" => $datetime->format('Y-m-d'), + ] + ); + + // Add the test batteries to the session. + + $battery = new \NDB_BVL_Battery; + + $first_visit_label = $candidate->getFirstVisit(); + if ($first_visit_label == $session->getVisitLabel()) { + $first_visit = true; + } else { + $first_visit = false; + } + + $battery->selectBattery($session->getSessionID()); + + // add instruments to the time point (lower case stage) + $battery->createBattery( + $this->_loris, + $session->getCohortID(), + $new_stage, + $session->getVisitLabel(), + $session->getCenterID(), + $first_visit, + ); } /** diff --git a/modules/redcap/php/redcapnotificationhandler.class.inc b/modules/redcap/php/redcapnotificationhandler.class.inc index 2accb25883e..cda25d7cf27 100644 --- a/modules/redcap/php/redcapnotificationhandler.class.inc +++ b/modules/redcap/php/redcapnotificationhandler.class.inc @@ -99,7 +99,7 @@ class RedcapNotificationHandler RedcapConfig $config, ) { $queries = new RedcapQueries($loris); - $mapper = new RedcapMapper($redcap_client, $config, $queries); + $mapper = new RedcapMapper($loris, $redcap_client, $config); $this->_loris = $loris; $this->_redcap_client = $redcap_client; @@ -158,15 +158,21 @@ class RedcapNotificationHandler $psc_id = $candidate->getPSCID(); - $visit_label = $this->_mapper->getVisitLabel( - $this->_redcap_notif->unique_event_name, - ); + // The visit label is populated using record information. + $visit_label = null; // Track which instruments are updated and not updated. $instruments_not_updated = []; - // + // Import each REDCap record into LORIS. foreach ($records as $record) { + // Get the LORIS visit label associated with the REDCap record. + $visit_label = $this->_mapper->getVisitLabel( + $record, + $this->_redcap_notif->unique_event_name, + $candidate, + ); + // if repeating instrument, contains the repeat index $instrument_name = $record->getInstrumentName();