diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/CHANGELOG.md b/interface/modules/custom_modules/oe-module-ehi-exporter/CHANGELOG.md index 84abd25f18a..6a98cee685e 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/CHANGELOG.md +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/CHANGELOG.md @@ -1 +1,3 @@ +# 1.0.1 +Implemented asynchronous progress status and progress updates for batch exports # 1.0.0 Initial release of the EHI exporter diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/info.txt b/interface/modules/custom_modules/oe-module-ehi-exporter/info.txt index 47da6ac505c..fa20e8b4ea1 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/info.txt +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/info.txt @@ -1 +1 @@ -Electronic Health Information Exporter v1.0.0 +Electronic Health Information Exporter v1.0.1 diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/public/assets/js/ehi-exporter.js b/interface/modules/custom_modules/oe-module-ehi-exporter/public/assets/js/ehi-exporter.js new file mode 100644 index 00000000000..7f979a6973d --- /dev/null +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/public/assets/js/ehi-exporter.js @@ -0,0 +1,254 @@ +(function(window, oeExporter) { + + class ExporterState { + taskIds = []; + currentTaskIndex = 0; + ajaxUrl = ""; + csrfToken = ""; + + currentTaskPollingInterval = 0; + currentTaskPollingTimeout = 5000; + + startExport() { + this.currentTaskIndex = -1; + this.runNextExport(); + + } + runNextExport() { + this.currentTaskIndex++; + if (this.currentTaskIndex < this.taskIds.length) { + let callBack = function() { + this.startExportRequestForTask(this.taskIds[this.currentTaskIndex]); + }; + // just give a way to break promise callback chain + setTimeout(callBack.bind(this), 100); + } else { + // if we've finished everything... then we should clear the polling interval + this.clearPollingForExportStatus(); + } + } + + showErrorCardForTaskId(taskId, errorMessage='') { + // hide the processing div template node + let processingTask = document.querySelector(".template-task-processing[data-task-id='" + taskId + "']"); + if (!processingTask) { + console.error("Could not find processing task for task id: " + taskId); + return; + } + processingTask.classList.add("d-none"); + + // grab the error div template node + let errorTaskTemplate = document.querySelector(".template-task-failed"); + let errorTask = errorTaskTemplate.cloneNode(true); + + // populate the error div template node with the task id, the patient pids + errorTask.querySelector(".taskId").innerText = taskId; + errorTask.dataset['taskId'] = taskId; + // show the error div template node + if (errorMessage) { + errorTask.querySelector(".errorMessage").innerText = errorMessage; + } + // TODO: @adunsulag need to handle what happens when they retry the export and we need to move on to the next + // possible export. Should we disable all of the buttons until the export has processed everything... + errorTask.querySelector(".btn-retry-export-task").addEventListener("click", () => { + errorTask.remove(); + this.startExportRequestForTask(taskId); + }); + errorTask.classList.remove("d-none"); + processingTask.insertAdjacentElement("afterend", errorTask); + processingTask.remove(); // remove the processing node at the end since we don't need it. + } + + startExportRequestForTask(taskId) { + // hide the queued div template node + this.showProcessingCardForTaskId(taskId, {taskId: taskId}); + // send off the ajax request to start the export + let formParams = new FormData(); + formParams.set("taskId", taskId); + formParams.set("submit", "Start Export"); + formParams.set("action", "startExport"); + formParams.set("_token", this.csrfToken); + window.top.restoreSession(); // make sure the session is populated before we send off an ajax request + let resultPromise = window.fetch(this.ajaxUrl, { + method: 'POST', + body: new URLSearchParams(formParams) + }); + let exporterState = this; + resultPromise.then(response => { + if (response.ok) { + return response.json(); + } else { + throw new Error('Failed to receive response from server'); + } + }) + .then(data => { + if (data.status == 'failed') { + this.showErrorCardForTaskId(taskId, data.error_message); + // move onto the next task in the queue + return exporterState.runNextExport(); + } else { + this.showSuccessCardForTaskId(taskId, data); + return exporterState.runNextExport(); + } + }) + .catch(error => { + console.log(error); + this.showErrorCardForTaskId(taskId, error.message); + return exporterState.runNextExport(); + }); + // TODO: @adunsulag start the polling for the export status + this.startPollingForExportStatus(taskId); + } + + startPollingForExportStatus(taskId) { + if (this.currentTaskPollingInterval > 0) { + this.clearPollingForExportStatus(); + } + this.currentTaskPollingInterval = setInterval(this.pollForExportStatus.bind(this), this.currentTaskPollingTimeout, taskId); + } + clearPollingForExportStatus() { + clearInterval(this.currentTaskPollingInterval); + } + pollForExportStatus(taskId) { + let formParams = new FormData(); + formParams.set("taskId", taskId); + formParams.set("submit", "Get Status"); + formParams.set("action", "statusUpdate"); + formParams.set("_token", this.csrfToken); + window.top.restoreSession(); // make sure the session is populated before we send off an ajax request + let resultPromise = window.fetch(this.ajaxUrl, { + method: 'POST', + body: new URLSearchParams(formParams) + }); + let exporterState = this; + resultPromise.then(response => { + if (response.ok) { + return response.json(); + } else { + throw new Error('Failed to receive response from server'); + } + }) + .then(data => { + if (data.status == 'failed') { + this.showErrorCardForTaskId(taskId, data.error_message); + } else if (data.status == 'completed') { + this.showSuccessCardForTaskId(taskId, data); + } else { + this.showProcessingCardForTaskId(taskId, data); + } + }) + .catch(error => { + this.showErrorCardForTaskId(taskId, error.message); + console.log(error); + }); + } + + showProcessingCardForTaskId(taskId, data) { + let queuedTask = document.querySelector(".template-task-queued[data-task-id='" + taskId + "']"); + queuedTask.classList.add("d-none"); + + // if there are any existing processing tasks we need to remove them + let processingTasks = document.querySelectorAll(".template-task-processing[data-task-id='" + taskId + "']"); + processingTasks.forEach(function (task) { + task.remove(); + }); + + // grab the processing div template node + let processingTaskTemplate = document.querySelector(".template-task-processing"); + let processingTask = processingTaskTemplate.cloneNode(true); + + // populate the processing div template node with the task id, the patient pids + processingTask.querySelector(".taskId").innerText = taskId; + processingTask.dataset['taskId'] = taskId; + processingTask.querySelector(".patientPids").innerText = queuedTask.querySelector(".patientPids").innerText; + // show the processing div template node + if (data.exportedResult) { + this.populateCardWithResultData(processingTask, taskId, data); + } + processingTask.classList.remove("d-none"); + queuedTask.insertAdjacentElement("afterend", processingTask); + } + + populateCardWithResultData(cardNode, taskId, data) { + // .exportedTablesList needs to be looped on the data.exportedResult table + let totalTablesExported = 0; + let totalRecordsExported = 0; + if (data.exportedResult) { + if (data.exportedResult.exportedTables) { + let tableNames = Object.keys(data.exportedResult.exportedTables); + totalTablesExported = tableNames.length; + let itemTemplate = cardNode.querySelector(".exportedTableListItem"); + let templateParent = itemTemplate.parentNode; + for (let i = 0; i < totalTablesExported; i++) { + let tableItem = data.exportedResult.exportedTables[tableNames[i]]; + let exportedTableListItem = itemTemplate.cloneNode(true); + exportedTableListItem.classList.remove("d-none"); + exportedTableListItem.querySelector(".exportedTableName").innerText = tableItem.tableName + ".csv"; + exportedTableListItem.querySelector(".exportedTableCount").innerText = tableItem.count; + totalRecordsExported += tableItem.count; + templateParent.appendChild(exportedTableListItem); + } + } + if (data.exportedResult.exportedDocumentCount >= 0) { + cardNode.querySelector(".documentsExportedCount").innerText = data.exportedResult.exportedDocumentCount; + } + } + cardNode.querySelector(".total-tables-exported").innerText = totalTablesExported; + cardNode.querySelector(".total-records-exported").innerText = totalRecordsExported; + + if (data.includePatientDocuments) { + cardNode.querySelector(".documentsExportedSection").classList.remove("d-none"); + } + } + + showSuccessCardForTaskId(taskId, data) { + let processingTask = document.querySelector(".template-task-processing[data-task-id='" + taskId + "']"); + processingTask.classList.add("d-none"); + + // grab the error div template node + let successTemplate = document.querySelector(".template-result-success"); + let successTask = successTemplate.cloneNode(true); + + successTask.querySelector(".taskId").innerText = taskId; + successTask.dataset['taskId'] = taskId; + + // .download-link .download-link-name need to be populated + successTask.querySelector(".download-link-name").innerText = data.downloadName; + successTask.querySelector(".download-link").href = data.downloadLink; + successTask.querySelector(".download-link").addEventListener('click', function() { + window.top.restoreSession(); // make sure the session is populated before the download starts + }); + // .hash-algo-title, .hash-text need to be populated + successTask.querySelector(".hash-algo-title").innerText = data.hashAlgoTitle; + successTask.querySelector(".hash-text").innerText = data.hash; + this.populateCardWithResultData(successTask, taskId, data); + successTask.classList.remove("d-none"); + processingTask.insertAdjacentElement("afterend", successTask); + processingTask.remove(); // remove the processing node at the end since we don't need it. + } + } + let exporterState; + + function displayExportStartDialog(dialogId) { + let container = document.getElementById(dialogId); + let modal = new bootstrap.Modal(container, {keyboard: false, focus: true, backdrop: 'static'}); + modal.show(); + } + + oeExporter.displayExportStartDialog = displayExportStartDialog; + oeExporter.startTaskExports = function (ajaxUrl, csrfToken) { + let queuedTasks = document.querySelectorAll(".template-task-queued[data-task-id]"); + let queuedTaskIds = []; + queuedTasks.forEach(function (task) { + queuedTaskIds.push(+task.dataset.taskId); + }); + if (queuedTaskIds.length > 0) { + exporterState = new ExporterState(); + exporterState.ajaxUrl = ajaxUrl; + exporterState.csrfToken = csrfToken; + exporterState.taskIds = queuedTaskIds; + exporterState.startExport(); + } + }; + window.oeExporter = oeExporter; +})(window, window.oeExporter || window.top.oeExporter || {}); diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/public/index.php b/interface/modules/custom_modules/oe-module-ehi-exporter/public/index.php index cf3d7f82b35..0d2e5b64b38 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/public/index.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/public/index.php @@ -27,6 +27,7 @@ $defaultZipSize = 500; // size in MB $memoryLimitUpdated = false; $errorMessage = ""; +$twig = $bootstrap->getTwig(); if (isset($_POST['submit'])) { try { if (!CsrfUtils::verifyCsrfToken($_POST['_token'] ?? '')) { @@ -36,26 +37,68 @@ $pid = intval($_POST['pid'] ?? 0); $includeDocuments = intval($_POST['include_documents'] ?? 0) === 1; $fileSizeLimit = intval($_POST['file_size_limit'] ?? 500); - if ($pid > 0) { - $result = $exporter->exportPatient($pid, $includeDocuments, $fileSizeLimit); - } else { - $result = $exporter->exportAll($includeDocuments, $fileSizeLimit); + if ($_POST['action'] == 'createExport') { + if ($pid > 0) { + $job = $exporter->createExportPatientJob($pid, $includeDocuments, $fileSizeLimit); +// $result = $exporter->exportPatient($pid, $includeDocuments, $fileSizeLimit); + } else { + $job = $exporter->createExportPatientPopulationJob($includeDocuments, $fileSizeLimit); +// $result = $exporter->exportAll($includeDocuments, $fileSizeLimit); + } + echo $twig->render( + Bootstrap::MODULE_NAME . DIRECTORY_SEPARATOR . 'ehi-exporter-tasks.html.twig', + [ + 'result' => $result + ,'job' => $job + , 'assetPath' => $bootstrap->getAssetPath() + ,'postUrl' => $GLOBALS['webroot'] . Bootstrap::MODULE_INSTALLATION_PATH . '/' + . Bootstrap::MODULE_NAME . '/public/index.php' + ] + ); + // TODO: @adunsulag we really should move all of this into a controller to be cleaner, but we are time crunched here. + } else if ($_POST['action'] == 'startExport') { + try { + $taskId = intval($_POST['taskId'] ?? 0); + $task = $exporter->runExportTask($taskId); + echo json_encode($task->getJSON()); + } catch (\Exception $exception) { + $errorMessage = $exception->getMessage(); + $bootstrap->getLogger()->errorLogCaller($errorMessage, ['trace' => $exception->getTraceAsString()]); + echo json_encode(['status' => 'failed', 'error_message' => $errorMessage, 'taskId' => $taskId]); + } + exit; + } else if ($_POST['action'] == 'statusUpdate') { + try { + $taskId = intval($_POST['taskId'] ?? 0); + $task = $exporter->getExportTaskForStatusUpdate($taskId); + // will already have the encoded progress results in the task + echo json_encode($task->getJSON()); + } catch (\Exception $exception) { + $errorMessage = $exception->getMessage(); + $bootstrap->getLogger()->errorLogCaller($errorMessage, ['trace' => $exception->getTraceAsString()]); + echo json_encode(['status' => 'failed', 'error_message' => $errorMessage, 'taskId' => $taskId]); + } + exit; } } catch (\Exception $exception) { $errorMessage = $exception->getMessage(); $bootstrap->getLogger()->errorLogCaller($errorMessage, ['trace' => $exception->getTraceAsString()]); } +} else { + $exportSizeSettings = $exporter->getExportSizeSettings($defaultZipSize); + + echo $twig->render( + Bootstrap::MODULE_NAME . DIRECTORY_SEPARATOR . 'ehi-exporter.html.twig', + [ + 'result' => $result + , 'exportSizeSettings' => $exportSizeSettings + , 'memoryLimitUpdated' => $memoryLimitUpdated + // TODO: @adunsulag add most recent exports here. + , 'errorMessage' => $errorMessage + , 'postAction' => $_SERVER['PHP_SELF'] + , 'site_addr_oath' => trim($GLOBALS['site_addr_oath'] ?? '') + , 'assetPath' => $bootstrap->getAssetPath() + ] + ); } -$exportSizeSettings = $exporter->getExportSizeSettings($defaultZipSize); -$twig = $bootstrap->getTwig(); -echo $twig->render( - Bootstrap::MODULE_NAME . DIRECTORY_SEPARATOR . 'ehi-exporter.html.twig', - [ - 'result' => $result, 'exportSizeSettings' => $exportSizeSettings, 'memoryLimitUpdated' => $memoryLimitUpdated - // TODO: @adunsulag add most recent exports here. - ,'errorMessage' => $errorMessage - ,'postAction' => $_SERVER['PHP_SELF'] - ,'site_addr_oath' => trim($GLOBALS['site_addr_oath'] ?? '') - ] -); exit; diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Bootstrap.php b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Bootstrap.php index 5cf9f7c4536..5b5a7877e0d 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Bootstrap.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Bootstrap.php @@ -99,6 +99,11 @@ public static function instantiate(EventDispatcher $eventDispatcher, Kernel $ker return self::$instance; } + public function getAssetPath() + { + return $GLOBALS['webroot'] . self::MODULE_INSTALLATION_PATH . $this->moduleDirectoryName . "/public/assets/"; + } + public function getLogger() { return new SystemLogger(); diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/EhiExportJobTask.php b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/EhiExportJobTask.php index 12869fb9c06..dc7c11e6e77 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/EhiExportJobTask.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/EhiExportJobTask.php @@ -96,4 +96,29 @@ public function hasPatientIds() { return !empty($this->pids); } + + public function getJSON() + { + $data = [ + 'status' => $this->status + , 'taskId' => $this->ehi_task_id + , 'includePatientDocuments' => false + ]; + if (isset($this->ehiExportJob)) { + $data['includePatientDocuments'] = $this->ehiExportJob->include_patient_documents; + } + if (isset($this->exportedResult)) { + // so we can update progress on the client side + $data['exportedResult'] = $this->exportedResult; + } + if ($this->status == 'completed') { + $data['hashAlgoTitle'] = $this->document->get_hash_algo_title(); + $data['hash'] = $this->document->get_hash(); + $data['downloadLink'] = $this->exportedResult->downloadLink; + $data['downloadName'] = $this->document->get_name(); + } else if ($this->status == 'failed') { + $data['errorMessage'] = $this->error_message; + } + return $data; + } } diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportResult.php b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportResult.php index 3bca5cec07e..a64b7988403 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportResult.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportResult.php @@ -24,4 +24,17 @@ class ExportResult public $exportedTables; public $exportedDocumentCount = 0; + + public function fromJSON(array $exportedResult) + { + $this->downloadLink = $exportedResult['downloadLink'] ?? ''; + $this->exportedDocumentCount = $exportedResult['exportedDocumentCount'] ?? 0; + if (is_array($exportedResult['exportedTables'] ?? [])) { + foreach ($exportedResult['exportedTables'] as $exportedTable) { + $exportTableResult = new ExportTableResult(); + $exportTableResult->fromJSON($exportedTable); + $this->exportedTables[] = $exportTableResult; + } + } + } } diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportState.php b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportState.php index 1122f14015c..39c4db5b680 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportState.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportState.php @@ -51,6 +51,12 @@ class ExportState */ private string $tempDir; + private \SimpleXMLElement $metaNode; + + private ExportKeyDefinitionFilterer $keyFilterer; + + private EhiExportJobTask $jobTask; + public function __construct(SystemLogger $logger, \SimpleXMLElement $tableNode, \SimpleXMLElement $metaNode, EhiExportJobTask $jobTask) { $this->rootNode = $tableNode; diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportTableResult.php b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportTableResult.php index 5c41fe12110..ab3272223b3 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportTableResult.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Models/ExportTableResult.php @@ -17,4 +17,10 @@ class ExportTableResult { public $count; public $tableName; + + public function fromJSON(array $exportedTable) + { + $this->tableName = $exportedTable['tableName'] ?? ''; + $this->count = $exportedTable['count'] ?? 0; + } } diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExportJobService.php b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExportJobService.php index 7153f8462ab..590a6415a3d 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExportJobService.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExportJobService.php @@ -17,6 +17,7 @@ use OpenEMR\Modules\EhiExporter\Models\EhiExportJob; use OpenEMR\Modules\EhiExporter\Models\EhiExportJobTask; use OpenEMR\Services\BaseService; +use OpenEMR\Validators\ProcessingResult; class EhiExportJobService extends BaseService { @@ -64,8 +65,10 @@ public function insert(EhiExportJob $job) public function update(EhiExportJob $job) { $sql = "UPDATE " . self::TABLE_NAME . " SET `status`= ?" . ($job->isCompleted() ? ",`completion_date`= NOW() " : ""); + $sql .= " WHERE `ehi_export_job_id` = ? "; $bind = [ $job->getStatus() + ,$job->getId() ]; QueryUtils::startTransaction(); try { @@ -78,4 +81,33 @@ public function update(EhiExportJob $job) } return $job; } + + public function getJobById(?int $ehi_export_job_id, $loadPatients = false): ?EhiExportJob + { + $ehiExportJob = null; + $processingResult = $this->search(['ehi_export_job_id' => $ehi_export_job_id]); + if ($processingResult->hasData()) { + $record = ProcessingResult::extractDataArray($processingResult)[0]; + $ehiExportJob = new EhiExportJob(); + $ehiExportJob->setId($record['ehi_export_job_id']); + $ehiExportJob->setStatus($record['status']); + $ehiExportJob->setDocumentLimitSize($record['document_limit_size']); + $ehiExportJob->include_patient_documents = $record['include_patient_documents'] == 1; + $ehiExportJob->user_id = $record['user_id']; + $ehiExportJob->uuid = $record['uuid']; + + // now we need to grab all of the patient ids here + if ($loadPatients) { + $patientPids = $this->getPatientPidsForJobId($ehiExportJob->getId()); + $ehiExportJob->addPatientIdList($patientPids); + } + } + return $ehiExportJob; + } + + private function getPatientPidsForJobId(?int $jobId) + { + return QueryUtils::fetchTableColumn("SELECT pid FROM " . self::TABLE_NAME_PATIENT_JOIN_TABLE + . " WHERE ehi_export_job_id = ?", 'pid', [$jobId]); + } } diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExportJobTaskService.php b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExportJobTaskService.php index 0d529b04053..1f82432f446 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExportJobTaskService.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExportJobTaskService.php @@ -15,7 +15,9 @@ use OpenEMR\Common\Database\QueryUtils; use OpenEMR\Modules\EhiExporter\Models\EhiExportJobTask; +use OpenEMR\Modules\EhiExporter\Models\ExportResult; use OpenEMR\Services\BaseService; +use OpenEMR\Validators\ProcessingResult; class EhiExportJobTaskService extends BaseService { @@ -27,6 +29,36 @@ public function __construct() parent::__construct(self::TABLE_NAME); } + public function getTaskFromId(int $taskId, bool $loadPatients = true): ?EhiExportJobTask + { + $ehiExportJobTask = null; + $processingResult = $this->search(['ehi_task_id' => $taskId]); + if ($processingResult->hasData()) { + $taskRecord = ProcessingResult::extractDataArray($processingResult)[0]; + $ehiExportJobTask = new EhiExportJobTask(); + $ehiExportJobTask->ehi_task_id = $taskRecord['ehi_task_id']; + $ehiExportJobTask->ehi_export_job_id = $taskRecord['ehi_export_job_id']; + $ehiExportJobTask->export_document_id = $taskRecord['export_document_id']; + $ehiExportJobTask->error_message = $taskRecord['error_message']; + $ehiExportJobTask->setStatus($taskRecord['status']); + if (isset($ehiExportJobTask->export_document_id)) { + $ehiExportJobTask->document = new \Document($ehiExportJobTask->export_document_id); + } + + if (isset($taskRecord['exported_result'])) { + $exportedResult = json_decode($taskRecord['exported_result'], true); + $exportResult = new ExportResult(); + $exportResult->fromJSON($exportedResult); + $ehiExportJobTask->exportedResult = $exportResult; + } + if ($loadPatients) { + $patientPids = $this->getPatientPidsForJobTaskId($ehiExportJobTask->getId()); + $ehiExportJobTask->addPatientIdList($patientPids); + } + } + return $ehiExportJobTask; + } + public function insert(EhiExportJobTask $task) { $sql = "INSERT INTO " . self::TABLE_NAME . " (`ehi_export_job_id`, `status`) " @@ -78,6 +110,15 @@ public function update(EhiExportJobTask $task) } else { $sql .= ",error_message= NULL "; } + if (isset($task->exportedResult)) { + $sql .= ",exported_result= ? "; + $bind[] = json_encode($task->exportedResult); + } else { + $sql .= ",exported_result= NULL "; + } + + $sql .= " WHERE ehi_task_id = ? "; + $bind[] = $task->getId(); QueryUtils::startTransaction(); try { @@ -90,4 +131,10 @@ public function update(EhiExportJobTask $task) } return $task; } + + private function getPatientPidsForJobTaskId(?int $taskId) + { + return QueryUtils::fetchTableColumn("SELECT pid FROM " . self::TABLE_NAME_PATIENT_JOIN_TABLE + . " WHERE ehi_task_id = ?", 'pid', [$taskId]); + } } diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExporter.php b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExporter.php index 972c286527e..a2a1a199c6f 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExporter.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/src/Services/EhiExporter.php @@ -64,6 +64,8 @@ class EhiExporter private SystemLogger $logger; private EhiExportJobTaskService $taskService; private CryptoGen $cryptoGen; + private EhiExportJobService $jobService; + public function __construct(private $modulePublicDir, private $modulePublicUrl, private $xmlConfigPath, private Environment $twig) { $this->logger = new SystemLogger(); @@ -73,6 +75,50 @@ public function __construct(private $modulePublicDir, private $modulePublicUrl, $this->cryptoGen = new CryptoGen(); } + + public function createExportPatientJob(int $pid, bool $includePatientDocuments, int $defaultZipSize) + { + $patientPids = [$pid]; + $job = null; + try { + $job = $this->createJobForRequest($patientPids, $includePatientDocuments, $defaultZipSize); + } catch (\Exception $exception) { + if ($job !== null) { + $job->setStatus("failed"); + try { + $this->jobService->update($job); + } catch (\Exception $exception) { + $this->logger->errorLogCaller("Failed to mark job as failed ", [$exception->getMessage()]); + return $job; + } + } + throw $exception; + } + return $job; + } + + public function createExportPatientPopulationJob(bool $includePatientDocuments, int $defaultZipSize): EhiExportJob + { + $job = null; + try { + $sql = "SELECT pid FROM patient_data"; // We do everything here + $patientPids = QueryUtils::fetchTableColumn($sql, 'pid', []); + $job = $this->createJobForRequest($patientPids, $includePatientDocuments, $defaultZipSize); + } catch (\Exception $exception) { + if ($job !== null) { + $job->setStatus("failed"); + try { + $this->jobService->update($job); + } catch (\Exception $exception) { + $this->logger->errorLogCaller("Failed to mark job as failed ", [$exception->getMessage()]); + return $job; + } + } + throw $exception; + } + return $job; + } + public function exportPatient(int $pid, bool $includePatientDocuments, $defaultZipSize) { $patientPids = [$pid]; @@ -138,6 +184,16 @@ private function createJobForRequest(array &$patientPids, bool $includePatientDo $job->addPatientIdList($patientPids); $job->setDocumentLimitSize($defaultZipSize * 1024 * 1024); // set our max size in bytes $updatedJob = $this->jobService->insert($job); + + // now create the job tasks + $jobTasks = $this->createExportTasksFromJob($job); + if (empty($jobTasks)) { + $job->setStatus("failed"); // no tasks to process, we mark as failed. + } else { + foreach ($jobTasks as $jobTask) { + $job->addJobTask($jobTask); + } + } return $updatedJob; } @@ -356,6 +412,8 @@ private function exportBreadthAlgorithm(EhiExportJobTask $jobTask): EhiExportJob // write out the csv file $this->writeCsvFile($jobTask, $records, $tableDefinition->table, $exportState->getTempSysDir(), $tableDefinition->getColumnNames()); $exportState->addExportResultTable($tableDefinition->table, count($records)); + $jobTask->exportedResult = $exportState->getExportResult(); + $this->taskService->update($jobTask); // for progress updates $tableDefinition->setHasNewData(false); if (!empty($keyDefinitions)) { foreach ($keyDefinitions['keys'] as $keyDefinition) { @@ -723,4 +781,33 @@ private function createExportTasksFromJobWithoutDocuments(EhiExportJob $job, arr } return $tasks; } + + public function runExportTask(int $taskId): EhiExportJobTask + { + $task = $this->taskService->getTaskFromId($taskId); + if (empty($task)) { + throw new \InvalidArgumentException("Invalid task id"); + } + $job = $this->jobService->getJobById($task->ehi_export_job_id); + if (empty($job)) { + throw new \InvalidArgumentException("Invalid job id. This should never happen and indicates there is a system error"); + } + $task->ehiExportJob = $job; + if ($task->getStatus() == 'completed') { + // if the task is already complete we are just going to return it. + return $task; + } + $task->setStatus('processing'); + $updatedTask = $this->taskService->update($task); + return $this->processJobTask($updatedTask); + } + + public function getExportTaskForStatusUpdate(int $taskId) + { + $task = $this->taskService->getTaskFromId($taskId); + if (empty($task)) { + throw new \InvalidArgumentException("Invalid task id"); + } + return $task; + } } diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/src/TableDefinitions/ExportTableDefinition.php b/interface/modules/custom_modules/oe-module-ehi-exporter/src/TableDefinitions/ExportTableDefinition.php index 2b34559e7ca..9553f35ff50 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/src/TableDefinitions/ExportTableDefinition.php +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/src/TableDefinitions/ExportTableDefinition.php @@ -38,6 +38,8 @@ class ExportTableDefinition */ private array $tableColumnNames; + private array $primaryKeys; + public function __construct(?string $table = null, array $pks = []) { $this->table = $table; diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/table.sql b/interface/modules/custom_modules/oe-module-ehi-exporter/table.sql index 2037d1191f0..3e15f8f402a 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/table.sql +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/table.sql @@ -40,6 +40,7 @@ CREATE TABLE `ehi_export_job_tasks`( `completion_date` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending=task export has not started, processing=task export in progress, failed=error occurred in the task, completed=export task completed without errors', `error_message` TEXT COMMENT 'The error that occurred in the export process, only populated if status=failed', + `exported_result` TEXT COMMENT 'The JSON encoded result of the export process, populated during processing for status updates', PRIMARY KEY(`ehi_task_id`), CONSTRAINT `FK_task_ehi_export_job_id` FOREIGN KEY (`ehi_export_job_id`) REFERENCES `ehi_export_job`(`ehi_export_job_id`) ON DELETE RESTRICT ON UPDATE RESTRICT, CONSTRAINT `FK_task_ehi_export_document_id` FOREIGN KEY (`export_document_id`) REFERENCES `documents`(`id`) ON DELETE RESTRICT ON UPDATE RESTRICT @@ -62,3 +63,7 @@ INSERT INTO categories(`id`,`name`, `value`, `parent`, `lft`, `rght`, `aco_spec` UPDATE categories SET rght = rght + 2 WHERE name = 'Categories'; UPDATE categories_seq SET id = (select MAX(id) from categories); #EndIf + +#IfMissingColumn ehi_export_job_tasks exported_result +ALTER TABLE ehi_export_job_tasks ADD COLUMN `exported_result` TEXT COMMENT 'The JSON encoded result of the export process, populated during processing for status updates'; +#Endif diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/templates/oe-module-ehi-exporter/ehi-exporter-tasks.html.twig b/interface/modules/custom_modules/oe-module-ehi-exporter/templates/oe-module-ehi-exporter/ehi-exporter-tasks.html.twig new file mode 100644 index 00000000000..8370d844d9c --- /dev/null +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/templates/oe-module-ehi-exporter/ehi-exporter-tasks.html.twig @@ -0,0 +1,103 @@ +{# +# Handles the asynchronous task processing of the EHI Export +# +# @package OpenEMR +# @link http://www.open-emr.org +# +# @author Stephen Nielson + + {{ "Electronic Health Information Export"|xlt }} + {{ setupHeader() }} +{# #} + + + +

{{ "Electronic Health Information Export"|xlt }} {{ "Go Back"|xlt }}

+
+
+
+

{{ "Export Task:"|xlt }} - {{ "Failed"|xlt }}

+
+
+ +
+ {{ "Check system logs as export failed to process"|xlt }} +
+
+
+
+
+
+

{{ "Export Task:"|xlt }} - {{ "Processing"|xlt }}

+
+
+

{{ "Export Task is being processed"|xlt }}

+
{{ "Total Tables Exported:"|xlt }} 0
+
{{ "Total Records Exported:"|xlt }} 0
+
{{ "Total Patient Documents Exported:"|xlt }} 0
+
+ + {{ "Expand for Export Zip Details"|xlt }} + +

{{ "Patient Records Being Exported"|xlt }}

+
+

{{ "Records Exported"|xlt }}

+
    +
  1. :
  2. +
+
+
+
+
+
+

{{ "Export Task:"|xlt }} - {{ "Completed"|xlt }}

+
+
+

- {{ "Download"|xlt }}

+
:
+
{{ "Total Tables Exported:"|xlt }}
+
{{ "Total Records Exported:"|xlt }}
+
{{ "Total Patient Documents Exported:"|xlt }}
+
+ + {{ "Expand for Export Zip Details"|xlt }} + +

{{ "Records Exported"|xlt }}

+
    +
  1. :
  2. +
+
+
+
+ {% for task in job.getJobTasks() %} +
+
+

{{ "Export Task:"|xlt }} {{ task.getId()|text }} - {{ "Pending"|xlt }}

+
+
+

{{ "Patients to be exported:"|xlt }} {{ task.getPatientIds()|length|text }}

+

{{ "Export Task has been queued and will start shortly"|xlt }}

+
+ + {{ "Expand for Export Zip Details"|xlt }} + +

{{ "Patient Pids To Be Exported"|xlt }}

+
{{ task.getPatientIds()|json_encode|text }}
+
+
+
+ {% endfor %} + + + diff --git a/interface/modules/custom_modules/oe-module-ehi-exporter/templates/oe-module-ehi-exporter/ehi-exporter.html.twig b/interface/modules/custom_modules/oe-module-ehi-exporter/templates/oe-module-ehi-exporter/ehi-exporter.html.twig index 4656f302d1a..24f9b64cd72 100644 --- a/interface/modules/custom_modules/oe-module-ehi-exporter/templates/oe-module-ehi-exporter/ehi-exporter.html.twig +++ b/interface/modules/custom_modules/oe-module-ehi-exporter/templates/oe-module-ehi-exporter/ehi-exporter.html.twig @@ -13,6 +13,7 @@ {{ "Electronic Health Information Export"|xlt }} {{ setupHeader() }} + {% if result %} @@ -120,8 +121,40 @@

{{ "The export process will attempt to set the ram memory usage to unlimited in order to process the export."|xlt}} {{ "If this setting is disabled in your environment you will need to manually set your php.ini memory_limit setting."|xlt}}

{{ csrfToken() }} + + {% endif %} +