diff --git a/README.md b/README.md index 8056c18..1b429a3 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,17 @@ Currently supported domains: cannot resolve them: its lookups exclude retired rows, so re-importing one fails on the name/uuid constraints), and datatype config is not exported (Initializer has no column for it); requires the cohort module (3.5+) +* System tasks (name, title, description, priority, default assignee role, rationale) — the + default assignee is written as the provider role's uuid and returned as a cross-domain + dependency, but provider roles themselves are not yet exported: Initializer's `providerroles` + domain still targets the providermanagement module's provider roles, while core 2.8+ has its own + (see [Initializer #303](https://github.com/mekomsolutions/openmrs-module-initializer/issues/303)), + so until that lands Initializer resolves the assignee column through the providermanagement + module only: when that module is absent the assignee is dropped with a warning, and when it is + present a providermanagement provider role with the same uuid must already exist on the importing + server or the row fails to import (core's own `provider_role` table is not consulted); a task + whose assignee role no longer exists on the exporting server is exported without the assignee + column, with a warning; requires the tasks module (1.0+) Domains contributed by other modules (supportable, but depend on the module being present; not yet covered): @@ -114,7 +125,6 @@ not yet covered): * Appointment scheduling (specialities, service definitions, service types) * Queues * Data filter mappings -* System Tasks Non-exportable Initializer domains (Liquibase, JSON key-values, OCL, Dispositions) are out of scope. diff --git a/api/src/main/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskDomainExporter.java b/api/src/main/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskDomainExporter.java new file mode 100644 index 0000000..6fd4a52 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskDomainExporter.java @@ -0,0 +1,66 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.domain.systemtask; + +import org.openmrs.OpenmrsObject; +import org.openmrs.ProviderRole; +import org.openmrs.annotation.OpenmrsProfile; +import org.openmrs.api.context.Context; +import org.openmrs.module.initializer.Domain; +import org.openmrs.module.metadataexport.export.BaseLineExporter; +import org.openmrs.module.metadataexport.export.CsvDomainExporter; +import org.openmrs.module.tasks.SystemTask; +import org.openmrs.module.tasks.api.TasksService; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +@Component +@OpenmrsProfile(modules = "tasks:1.0.0 - 9.*") +public class SystemTaskDomainExporter extends CsvDomainExporter { + + @Override + protected List> chain() { + return Collections.singletonList(new SystemTaskLineExporter()); + } + + @Override + protected String fileName() { + return "systemTasks.csv"; + } + + @Override + public Domain getDomain() { + return Domain.SYSTEM_TASKS; + } + + @Override + public boolean handles(OpenmrsObject instance) { + return instance instanceof SystemTask; + } + + @Override + public Collection getAllInstances() { + return Context.getService(TasksService.class).getAllSystemTasks(true); + } + + @Override + public Collection getDependencies(SystemTask instance) { + // We currently don't have a ProviderRoleExporter in the module. This is because Initializer still only + // supports the ProviderRole from the providermanagement module, whereas on core 2.8+ provider roles have + // moved into core. Until an exporter exists, Selector drops the role returned here because no registered + // domain owns it. Keeping this here for future sake: it is returned anyway so the closure starts working + // the moment a provider roles domain is added. + ProviderRole role = SystemTaskLineExporter.resolveAssignee(instance); + return role == null ? Collections.emptyList() : Collections.singletonList(role); + } +} diff --git a/api/src/main/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskLineExporter.java b/api/src/main/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskLineExporter.java new file mode 100644 index 0000000..797d621 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskLineExporter.java @@ -0,0 +1,57 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.domain.systemtask; + +import lombok.extern.slf4j.Slf4j; +import org.openmrs.ProviderRole; +import org.openmrs.api.context.Context; +import org.openmrs.module.initializer.api.BaseLineProcessor; +import org.openmrs.module.initializer.api.systemtasks.SystemTasksLineProcessor; +import org.openmrs.module.metadataexport.export.ExportLine; +import org.openmrs.module.metadataexport.export.MetadataLineExporter; +import org.openmrs.module.tasks.SystemTask; + +@Slf4j +public class SystemTaskLineExporter extends MetadataLineExporter { + + @Override + public void export(SystemTask instance, ExportLine line) { + line.put(BaseLineProcessor.HEADER_NAME, instance.getName()); + line.put(SystemTasksLineProcessor.HEADER_TITLE, instance.getTitle()); + line.put(BaseLineProcessor.HEADER_DESC, instance.getDescription()); + line.put(SystemTasksLineProcessor.HEADER_RATIONALE, instance.getRationale()); + + line.put(SystemTasksLineProcessor.HEADER_PRIORITY, instance.getPriority()); + + ProviderRole providerRole = resolveAssignee(instance); + if (providerRole != null) { + line.put(SystemTasksLineProcessor.HEADER_DEFAULT_ASSIGNEE_ROLE, providerRole.getUuid()); + } + } + + @Override + protected void writeRetiredDiscriminators(SystemTask instance, ExportLine line) { + export(instance, line); + } + + static ProviderRole resolveAssignee(SystemTask task) { + Integer providerRoleId = task.getDefaultAssigneeProviderRoleId(); + if (providerRoleId == null) { + return null; + } + ProviderRole providerRole = Context.getProviderService().getProviderRole(providerRoleId); + if (providerRole == null) { + log.warn("System Tasks: skipping default assignee role of system task {} — provider role id {} does not" + + " exist, so the task will import unassigned", + task.getUuid(), providerRoleId); + } + return providerRole; + } +} diff --git a/api/src/test/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskDomainExporterIntegrationTest.java b/api/src/test/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskDomainExporterIntegrationTest.java new file mode 100644 index 0000000..f997362 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskDomainExporterIntegrationTest.java @@ -0,0 +1,223 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.domain.systemtask; + +import org.hibernate.SessionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openmrs.OpenmrsObject; +import org.openmrs.ProviderRole; +import org.openmrs.api.context.Context; +import org.openmrs.module.initializer.Domain; +import org.openmrs.module.initializer.api.CsvFailingLines; +import org.openmrs.module.initializer.api.systemtasks.SystemTasksCsvParser; +import org.openmrs.module.initializer.api.systemtasks.SystemTasksLineProcessor; +import org.openmrs.module.metadataexport.export.ExportContext; +import org.openmrs.module.metadataexport.export.ExportLine; +import org.openmrs.module.tasks.Priority; +import org.openmrs.module.tasks.SystemTask; +import org.openmrs.module.tasks.api.TasksService; +import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SystemTaskDomainExporterIntegrationTest extends BaseModuleContextSensitiveTest { + + private static final String ROLE_UUID = "6a9d7e9a-2f3b-4c1d-9e8f-1a2b3c4d5e6f"; + + private static final String LIVE_UUID = "c1d8a345-3f10-11e4-adec-0800271c1b75"; + + private static final String RETIRED_UUID = "439559c2-a3a4-4a25-b4b2-1a0299e287ee"; + + private final SystemTaskDomainExporter exporter = new SystemTaskDomainExporter(); + + private ProviderRole nurse; + + @BeforeEach + void seedProviderRole() { + nurse = new ProviderRole(); + nurse.setUuid(ROLE_UUID); + nurse.setName("Nurse"); + nurse.setDescription("Ward nursing staff"); + SessionFactory sessionFactory = Context.getRegisteredComponent("sessionFactory", SessionFactory.class); + sessionFactory.getCurrentSession().saveOrUpdate(nurse); + sessionFactory.getCurrentSession().flush(); + } + + @Test + void getDependencies_pullsInTheAssigneeProviderRole() { + SystemTask task = taskAssignedTo(nurse.getProviderRoleId()); + + Collection dependencies = exporter.getDependencies(task); + + assertEquals(1, dependencies.size()); + assertEquals(ROLE_UUID, dependencies.iterator().next().getUuid()); + } + + @Test + void getDependencies_ignoresAnUnknownAssignee() { + SystemTask task = taskAssignedTo(Integer.MAX_VALUE); + + assertTrue(exporter.getDependencies(task).isEmpty()); + } + + @Test + void lineExporter_writesTheAssigneeAsAUuid() { + SystemTask task = taskAssignedTo(nurse.getProviderRoleId()); + + ExportLine line = new ExportLine(); + new SystemTaskLineExporter().writeLine(task, line); + + assertEquals(ROLE_UUID, line.get("default assignee role")); + } + + @Test + void lineExporter_omitsAnUnknownAssignee() { + SystemTask task = taskAssignedTo(Integer.MAX_VALUE); + + ExportLine line = new ExportLine(); + new SystemTaskLineExporter().writeLine(task, line); + + assertEquals("vital-check", line.get("name")); + assertNull(line.get("default assignee role")); + } + + @Test + void getAllInstances_includesRetiredTasks() { + seedOneLiveAndOneRetiredTask(); + + Collection instances = exporter.getAllInstances(); + + assertEquals(2, instances.size()); + Set uuids = instances.stream().map(SystemTask::getUuid).collect(Collectors.toSet()); + assertEquals(new HashSet<>(Arrays.asList(LIVE_UUID, RETIRED_UUID)), uuids, + "retired tasks are exported too, so their retirement replays on the target"); + SystemTask retired = instances.stream().filter(t -> RETIRED_UUID.equals(t.getUuid())).findFirst().get(); + assertTrue(retired.getRetired(), "the retired row must come back still flagged as retired"); + } + + @Test + void getAllInstances_isEmptyWhenNoTasksExist() { + assertTrue(exporter.getAllInstances().isEmpty(), "the standard test dataset seeds no system tasks"); + } + + @Test + void export_thenReimportOntoAFreshTarget(@TempDir File outDir) throws Exception { + seedOneLiveAndOneRetiredTask(); + exporter.export(exporter.getAllInstances(), new ExportContext(outDir)); + purgeAllSystemTasks(); + assertTrue(tasksService().getAllSystemTasks(true).isEmpty(), "the target must start without the tasks"); + + CsvFailingLines failed = replayThroughInitializer(outDir); + + assertTrue(failed.getFailingLines().isEmpty(), describe(failed)); + SystemTask live = tasksService().getSystemTaskByUuid(LIVE_UUID); + assertEquals("vital-check", live.getName()); + assertEquals("Daily Vital Check", live.getTitle()); + assertEquals("Check patient vitals every day", live.getDescription()); + assertEquals("Routine monitoring required", live.getRationale()); + assertEquals(Priority.HIGH, live.getPriority()); + assertFalse(live.getRetired()); + SystemTask retired = tasksService().getSystemTaskByUuid(RETIRED_UUID); + assertEquals("discontinued", retired.getName(), "Iniz bootstraps and fills the retired row, so name must travel"); + assertEquals("Discontinued Task", retired.getTitle()); + assertTrue(retired.getRetired(), "the retirement itself must replay on the target"); + } + + @Test + void export_thenReimportOntoATargetThatAlreadyHasTheTasks(@TempDir File outDir) throws Exception { + seedOneLiveAndOneRetiredTask(); + exporter.export(exporter.getAllInstances(), new ExportContext(outDir)); + + CsvFailingLines failed = replayThroughInitializer(outDir); + + assertTrue(failed.getFailingLines().isEmpty(), describe(failed)); + assertEquals(2, tasksService().getAllSystemTasks(true).size(), "existing rows are matched by uuid, not duplicated"); + assertFalse(tasksService().getSystemTaskByUuid(LIVE_UUID).getRetired()); + assertTrue(tasksService().getSystemTaskByUuid(RETIRED_UUID).getRetired()); + } + + private void seedOneLiveAndOneRetiredTask() { + SystemTask live = taskAssignedTo(nurse.getProviderRoleId()); + live.setUuid(LIVE_UUID); + live.setDescription("Check patient vitals every day"); + live.setRationale("Routine monitoring required"); + tasksService().saveSystemTask(live); + + SystemTask retired = new SystemTask(); + retired.setUuid(RETIRED_UUID); + retired.setName("discontinued"); + retired.setTitle("Discontinued Task"); + retired.setPriority(Priority.MEDIUM); + tasksService().saveSystemTask(retired); + tasksService().retireSystemTask(retired, "No longer needed"); + Context.flushSession(); + } + + private void purgeAllSystemTasks() { + SessionFactory sessionFactory = Context.getRegisteredComponent("sessionFactory", SessionFactory.class); + for (SystemTask task : tasksService().getAllSystemTasks(true)) { + sessionFactory.getCurrentSession().delete(task); + } + sessionFactory.getCurrentSession().flush(); + } + + /** + * Feeds the exported file back through Iniz's own parser, the only thing that shows the file is + * loadable rather than merely well-shaped. + */ + private static CsvFailingLines replayThroughInitializer(File outDir) throws Exception { + File csv = outDir.toPath().resolve(Paths.get("configuration", Domain.SYSTEM_TASKS.getName(), "systemTasks.csv")) + .toFile(); + assertTrue(csv.exists(), "expected " + csv); + SystemTasksCsvParser parser = new SystemTasksCsvParser(Context.getService(TasksService.class), + new SystemTasksLineProcessor()); + try (InputStream in = new FileInputStream(csv)) { + parser.setInputStream(in); + List lines = parser.getLines(); + assertEquals(2, lines.size(), "both seeded tasks must be in the file"); + return parser.process(lines); + } + } + + private static String describe(CsvFailingLines failed) { + return failed.getErrorDetails().stream().map(d -> d.getCsvLine().prettyPrint() + " -> " + d.getException()) + .collect(Collectors.joining("\n", "Iniz rejected exported lines:\n", "")); + } + + private static TasksService tasksService() { + return Context.getService(TasksService.class); + } + + private static SystemTask taskAssignedTo(Integer providerRoleId) { + SystemTask task = new SystemTask(); + task.setUuid("550e8400-e29b-41d4-a716-446655440001"); + task.setName("vital-check"); + task.setTitle("Daily Vital Check"); + task.setPriority(Priority.HIGH); + task.setDefaultAssigneeProviderRoleId(providerRoleId); + return task; + } +} diff --git a/api/src/test/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskDomainExporterTest.java b/api/src/test/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskDomainExporterTest.java new file mode 100644 index 0000000..c3d50e6 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskDomainExporterTest.java @@ -0,0 +1,49 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.domain.systemtask; + +import org.junit.jupiter.api.Test; +import org.openmrs.ProviderRole; +import org.openmrs.module.initializer.Domain; +import org.openmrs.module.tasks.SystemTask; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SystemTaskDomainExporterTest { + + private final SystemTaskDomainExporter exporter = new SystemTaskDomainExporter(); + + @Test + void ownsTheSystemTasksDomain() { + assertEquals(Domain.SYSTEM_TASKS, exporter.getDomain()); + } + + @Test + void writesASingleSystemTasksFile() { + assertEquals("systemTasks.csv", exporter.fileName()); + } + + @Test + void handlesOnlySystemTasks() { + assertTrue(exporter.handles(new SystemTask())); + assertFalse(exporter.handles(new ProviderRole())); + } + + @Test + void noAssigneeMeansNoDependencies() { + SystemTask task = new SystemTask(); + task.setName("vital-check"); + task.setTitle("Daily Vital Check"); + + assertTrue(exporter.getDependencies(task).isEmpty()); + } +} diff --git a/api/src/test/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskLineExporterTest.java b/api/src/test/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskLineExporterTest.java new file mode 100644 index 0000000..de44a7b --- /dev/null +++ b/api/src/test/java/org/openmrs/module/metadataexport/domain/systemtask/SystemTaskLineExporterTest.java @@ -0,0 +1,97 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under + * the terms of the Healthcare Disclaimer located at http://openmrs.org/license. + * + * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS + * graphic logo is a trademark of OpenMRS Inc. + */ +package org.openmrs.module.metadataexport.domain.systemtask; + +import org.junit.jupiter.api.Test; +import org.openmrs.module.metadataexport.export.ExportLine; +import org.openmrs.module.tasks.Priority; +import org.openmrs.module.tasks.SystemTask; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class SystemTaskLineExporterTest { + + @Test + void exportsAllColumnsOfALiveTask() { + SystemTask task = new SystemTask(); + task.setUuid("550e8400-e29b-41d4-a716-446655440001"); + task.setName("vital-check"); + task.setTitle("Daily Vital Check"); + task.setDescription("Check patient vitals every day"); + task.setRationale("Routine monitoring required"); + task.setPriority(Priority.HIGH); + + ExportLine line = new ExportLine(); + new SystemTaskLineExporter().writeLine(task, line); + + assertEquals("550e8400-e29b-41d4-a716-446655440001", line.get("uuid")); + assertEquals("vital-check", line.get("name")); + assertEquals("Daily Vital Check", line.get("title")); + assertEquals("Check patient vitals every day", line.get("description")); + assertEquals("Routine monitoring required", line.get("rationale")); + assertEquals("HIGH", line.get("priority"), "priority is written as the enum constant Initializer parses"); + assertNull(line.get("void/retire")); + } + + @Test + void omitsOptionalColumnsWhenUnset() { + SystemTask task = new SystemTask(); + task.setUuid("550e8400-e29b-41d4-a716-446655440002"); + task.setName("follow-up"); + task.setTitle("Follow-up Appointment"); + + ExportLine line = new ExportLine(); + new SystemTaskLineExporter().writeLine(task, line); + + assertEquals("follow-up", line.get("name")); + assertEquals("Follow-up Appointment", line.get("title")); + assertNull(line.get("description"), "empty description is not written as a column"); + assertNull(line.get("rationale"), "empty rationale is not written as a column"); + assertNull(line.get("priority"), "unset priority is not written as a column"); + assertNull(line.get("default assignee role"), "no assignee means no assignee column"); + } + + @Test + void noAssigneeDoesNotTouchTheProviderService() { + SystemTask task = new SystemTask(); + task.setUuid("550e8400-e29b-41d4-a716-446655440003"); + task.setName("lab-review"); + task.setTitle("Lab Order Review"); + task.setPriority(Priority.LOW); + task.setDefaultAssigneeProviderRoleId(null); + + ExportLine line = new ExportLine(); + new SystemTaskLineExporter().writeLine(task, line); + + assertEquals("LOW", line.get("priority")); + assertNull(line.get("default assignee role")); + } + + @Test + void retiredTaskEmitsFullRowPlusFlag() { + SystemTask task = new SystemTask(); + task.setUuid("439559c2-a3a4-4a25-b4b2-1a0299e287ee"); + task.setName("discontinued"); + task.setTitle("Discontinued Task"); + task.setPriority(Priority.MEDIUM); + task.setRetired(true); + + ExportLine line = new ExportLine(); + new SystemTaskLineExporter().writeLine(task, line); + + assertEquals("439559c2-a3a4-4a25-b4b2-1a0299e287ee", line.get("uuid")); + assertEquals("true", line.get("void/retire")); + assertEquals("discontinued", line.get("name"), + "Iniz bootstraps retired rows with unknown uuids and name is NOT NULL, so they carry the full row"); + assertEquals("Discontinued Task", line.get("title"), "title is NOT NULL on the target too"); + assertEquals("MEDIUM", line.get("priority")); + } +} diff --git a/omod/src/main/resources/config.xml b/omod/src/main/resources/config.xml index 1fd972e..abbccc2 100644 --- a/omod/src/main/resources/config.xml +++ b/omod/src/main/resources/config.xml @@ -42,6 +42,7 @@ org.openmrs.module.metadatasharing org.openmrs.module.addresshierarchy org.openmrs.module.cohort + org.openmrs.module.tasks diff --git a/pom.xml b/pom.xml index 1573975..baa7e08 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,7 @@ 1.2.2 2.17.0 3.5.0 + 1.0.0 @@ -200,5 +201,12 @@ ${cohortModuleVersion} provided + + + org.openmrs.module + tasks-api + ${tasksVersion} + provided +