Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SystemTask> {

@Override
protected List<BaseLineExporter<SystemTask>> 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<SystemTask> getAllInstances() {
return Context.getService(TasksService.class).getAllSystemTasks(true);
}

@Override
public Collection<? extends OpenmrsObject> 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);
Comment on lines +58 to +64

@wikumChamith wikumChamith Sep 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dkayiwa, @ibacher, we might need a decision on the Initializer side here. Initializer's providerroles domain and the system tasks "default assignee role" column both resolve provider roles through providermanagement only, so exports of core 2.8+ provider roles can't be imported yet.

There's a PR by @mseaton that adds core ProviderRole support, but it was paused: mekomsolutions/openmrs-module-initializer#304 (tracking issue: mekomsolutions/openmrs-module-initializer#303).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep it as it is. Exporting the core role uuid is the only value that will still be right once #303/#304 lands, and dropping the column now would quietly lose the assignee from every config exported in the meantime. The README paragraph you added is the right place for the caveat, and none of this blocks the PR.

The follow-up that actually closes the gap is #304, so it is worth nudging @mseaton on it separately rather than working around it here.

}
}
Original file line number Diff line number Diff line change
@@ -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<SystemTask> {

@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());
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retired system tasks come out as uuid + void/retire only, and that row cannot be imported onto a server that does not already have the task, so this needs fixing before merge.

Initializer's shouldFill is !(voidOrRetire && instance.getId() != null), and SystemTask.getId() is a real Integer, so on a fresh target SystemTasksLineProcessor.fill does run. It just has no name or title to read, and tasks_systemtask declares both NOT NULL. I exported one live task and one retired task, then fed the CSV back through SystemTasksCsvParser in a context-sensitive test: the live row imports, the retired row fails with PropertyValueException: not-null property references a null or transient value : org.openmrs.module.tasks.SystemTask.name. A file that happens to hold only retired rows has no name column at all, and there line.getName(true) throws on the missing header instead.

Merged as is, replaying an exported configuration onto a server that doesn't already carry these tasks loses every retired one. Initializer records the line as failed and carries on, so the task never arrives.

IdentifierSourceLineExporter ran into this and re-dispatches to export, which is what I'd do here. I re-ran the round trip with the override in place and both directions work: on a fresh target the retired row is created and retired, and on a target that already has the row shouldFill is false, so the extra columns are ignored.

Suggested change
@Override
protected void writeRetiredDiscriminators(SystemTask instance, ExportLine line) {
export(instance, line);
}

SystemTaskLineExporterTest.retiredTaskEmitsUuidAndFlagOnly asserts the current shape, so it flips with this (it was the only test that failed). Dropping retired tasks from getAllInstances() the way CohortTypeDomainExporter does would also stop the failure, but it throws away the retirement, and unlike cohort types nothing forces that here.

@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;
}
}
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing exercises getAllInstances() or an actual export-then-reimport, and both are reachable from this class, so they would be worth adding. Not blocking.

Context.getService(TasksService.class) does resolve in a context-sensitive test: tasks-api ships a moduleApplicationContext.xml, and at provided scope it lands on the test classpath, so the service bean is registered. SystemTask is an annotated @Entity under org.openmrs.**, so rows seed through sessionFactory and exporter.getAllInstances() can be asserted the way CohortTypeDomainExporterIntegrationTest does for cohort types. SystemTasksCsvParser's constructor is public too, so new SystemTasksCsvParser(Context.getService(TasksService.class), new SystemTasksLineProcessor()) plus setInputStream / getLines / process replays the exported CSV and reports what Initializer rejected. That round trip is the only thing that shows the file is loadable at all; the unit tests only check the shape of a line.


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<? extends OpenmrsObject> 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<SystemTask> instances = exporter.getAllInstances();

assertEquals(2, instances.size());
Set<String> 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<String[]> 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;
}
}
Loading