diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/api/section/FacilityHeaderSection.java b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/FacilityHeaderSection.java index 20babbf..fb6a784 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/api/section/FacilityHeaderSection.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/FacilityHeaderSection.java @@ -37,6 +37,12 @@ public boolean isEnabled() { return true; } + /** Always on (see isEnabled), so config UIs must not offer a toggle. */ + @Override + public boolean isToggleable() { + return false; + } + @Override protected FacilityInfo gatherData(Visit visit) { if (visit.getLocation() == null) { diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/api/section/FooterSection.java b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/FooterSection.java index 4620f87..8837ef2 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/api/section/FooterSection.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/FooterSection.java @@ -44,6 +44,12 @@ public boolean isEnabled() { return true; } + /** Always on (see isEnabled), so config UIs must not offer a toggle. */ + @Override + public boolean isToggleable() { + return false; + } + @Override protected FooterInfo gatherData(Visit visit) { User currentUser = Context.getAuthenticatedUser(); diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/api/section/PatientInfoSection.java b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/PatientInfoSection.java index 5086117..a519bef 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/api/section/PatientInfoSection.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/PatientInfoSection.java @@ -41,6 +41,12 @@ public boolean isEnabled() { return true; } + /** Always on (see isEnabled), so config UIs must not offer a toggle. */ + @Override + public boolean isToggleable() { + return false; + } + @Override protected PatientVisitInfo gatherData(Visit visit) { Patient patient = visit.getPatient(); diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/api/section/VisitSummarySection.java b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/VisitSummarySection.java index 5a0932c..8fb1992 100644 --- a/api/src/main/java/org/openmrs/module/patientdocuments/api/section/VisitSummarySection.java +++ b/api/src/main/java/org/openmrs/module/patientdocuments/api/section/VisitSummarySection.java @@ -10,6 +10,8 @@ package org.openmrs.module.patientdocuments.api.section; import org.openmrs.Visit; +import org.openmrs.api.context.Context; +import org.openmrs.module.patientdocuments.common.PatientDocumentsConstants; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -19,6 +21,8 @@ * renderXml() on each enabled section sorted by getOrder(). * Implementing directly (without TypedSection) is valid for sections that don't * fit the gather-then-render flow. + * Config UIs list the same beans via getLabel(), isEnabled(), getOrder() and + * isToggleable() — sections need no extra registration to appear there. */ public interface VisitSummarySection { @@ -44,6 +48,34 @@ default int getOrder() { return Integer.MAX_VALUE; } + /** + * Human-readable name for this section, localized for the current user locale. + * Resolves the same heading message key the PDF rendering uses + * (patientdocuments.visitSummary.section.<key>.heading), falling back to a + * humanized form of getSectionKey() when no message is defined — so downstream + * sections get a usable label without shipping message bundles. + */ + default String getLabel() { + String key = PatientDocumentsConstants.MODULE_ARTIFACT_ID + "." + PatientDocumentsConstants.VISIT_SUMMARY_ID + + ".section." + getSectionKey() + ".heading"; + String fallback = humanizeSectionKey(getSectionKey()); + try { + return Context.getMessageSourceService().getMessage(key, null, fallback, Context.getLocale()); + } + catch (Exception e) { + return fallback; + } + } + + /** + * Whether administrators may switch this section off via configuration. + * Sections that must appear on every PDF (facility identity, patient identity, + * audit footer) return false so config UIs can disable their toggle. + */ + default boolean isToggleable() { + return true; + } + /** * Build this section's XML elements into the document. * The Visit gives access to both the visit and its patient via visit.getPatient(). @@ -51,4 +83,24 @@ default int getOrder() { * TypedSection renders a section-error element on failure for clinical safety visibility. */ void renderXml(Document doc, Element root, Visit visit); + + /** + * Splits a camelCase section key into capitalized words, e.g. "labResults" becomes + * "Lab Results". Used as the getLabel() fallback when no heading message exists. + */ + static String humanizeSectionKey(String sectionKey) { + if (sectionKey == null || sectionKey.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + sb.append(Character.toUpperCase(sectionKey.charAt(0))); + for (int i = 1; i < sectionKey.length(); i++) { + char c = sectionKey.charAt(i); + if (Character.isUpperCase(c)) { + sb.append(' '); + } + sb.append(c); + } + return sb.toString(); + } } diff --git a/api/src/main/resources/messages.properties b/api/src/main/resources/messages.properties index 9665a8d..bf5bead 100644 --- a/api/src/main/resources/messages.properties +++ b/api/src/main/resources/messages.properties @@ -90,7 +90,11 @@ ${project.parent.artifactId}.visitSummary.section.labResults.flag.abnormal=Abnor ${project.parent.artifactId}.visitSummary.section.labResults.flag.critically_abnormal=Critically Abnormal ${project.parent.artifactId}.visitSummary.section.labResults.unknown=Unknown +# Visit summary - Facility Header section +${project.parent.artifactId}.visitSummary.section.facilityHeader.heading=Facility Header + # Visit summary - Footer section +${project.parent.artifactId}.visitSummary.section.footer.heading=Footer ${project.parent.artifactId}.visitSummary.section.footer.lbl.printedBy=Printed by: ${project.parent.artifactId}.visitSummary.section.footer.lbl.systemId=System ID: diff --git a/api/src/main/resources/messages_ar.properties b/api/src/main/resources/messages_ar.properties index 48e80b6..f2d70fc 100644 --- a/api/src/main/resources/messages_ar.properties +++ b/api/src/main/resources/messages_ar.properties @@ -95,7 +95,11 @@ ${project.parent.artifactId}.visitSummary.section.labResults.flag.abnormal=Abnor ${project.parent.artifactId}.visitSummary.section.labResults.flag.critically_abnormal=Critically Abnormal ${project.parent.artifactId}.visitSummary.section.labResults.unknown=Unknown +# Visit summary - Facility Header section +${project.parent.artifactId}.visitSummary.section.facilityHeader.heading=Facility Header + # Visit summary - Footer section (English placeholders pending native-speaker translation) +${project.parent.artifactId}.visitSummary.section.footer.heading=Footer ${project.parent.artifactId}.visitSummary.section.footer.lbl.printedBy=Printed by: ${project.parent.artifactId}.visitSummary.section.footer.lbl.systemId=System ID: diff --git a/api/src/main/resources/messages_fr.properties b/api/src/main/resources/messages_fr.properties index a700d84..ffabaaa 100644 --- a/api/src/main/resources/messages_fr.properties +++ b/api/src/main/resources/messages_fr.properties @@ -88,7 +88,11 @@ ${project.parent.artifactId}.visitSummary.section.labResults.flag.abnormal=Abnor ${project.parent.artifactId}.visitSummary.section.labResults.flag.critically_abnormal=Critically Abnormal ${project.parent.artifactId}.visitSummary.section.labResults.unknown=Unknown +# Visit summary - Facility Header section +${project.parent.artifactId}.visitSummary.section.facilityHeader.heading=Facility Header + # Visit summary - Footer section (English placeholders pending native-speaker translation) +${project.parent.artifactId}.visitSummary.section.footer.heading=Footer ${project.parent.artifactId}.visitSummary.section.footer.lbl.printedBy=Printed by: ${project.parent.artifactId}.visitSummary.section.footer.lbl.systemId=System ID: diff --git a/api/src/test/java/org/openmrs/module/patientdocuments/api/section/VisitSummarySectionDefaultsTest.java b/api/src/test/java/org/openmrs/module/patientdocuments/api/section/VisitSummarySectionDefaultsTest.java new file mode 100644 index 0000000..73e3125 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/patientdocuments/api/section/VisitSummarySectionDefaultsTest.java @@ -0,0 +1,76 @@ +/** + * 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.patientdocuments.api.section; + +import java.util.Locale; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openmrs.Visit; +import org.openmrs.api.context.Context; +import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +/** + * Tests for the VisitSummarySection default methods (getLabel, isToggleable), + * exercised through an anonymous implementation the way a downstream module + * would provide one — no changes in this module, no message bundle. + */ +public class VisitSummarySectionDefaultsTest extends BaseModuleContextSensitiveTest { + + private VisitSummarySection sectionWithKey(String sectionKey) { + return new VisitSummarySection() { + + @Override + public String getSectionKey() { + return sectionKey; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public void renderXml(Document doc, Element root, Visit visit) { + // no-op: these tests exercise getLabel()/isToggleable() only, never rendering + } + }; + } + + @BeforeEach + public void setUp() { + Context.setLocale(Locale.ENGLISH); + } + + @Test + public void getLabel_shouldResolveHeadingMessageForKnownSectionKey() { + Assertions.assertEquals("Vital Signs", sectionWithKey("vitals").getLabel()); + } + + @Test + public void getLabel_shouldHumanizeSectionKeyWhenNoHeadingMessageExists() { + Assertions.assertEquals("Referral Notes", sectionWithKey("referralNotes").getLabel()); + } + + @Test + public void isToggleable_shouldDefaultToTrue() { + Assertions.assertTrue(sectionWithKey("referralNotes").isToggleable()); + } + + @Test + public void humanizeSectionKey_shouldHandleSingleWordAndEmptyKeys() { + Assertions.assertEquals("Vitals", VisitSummarySection.humanizeSectionKey("vitals")); + Assertions.assertEquals("", VisitSummarySection.humanizeSectionKey("")); + Assertions.assertEquals("", VisitSummarySection.humanizeSectionKey(null)); + } +} diff --git a/omod/src/main/java/org/openmrs/module/patientdocuments/web/rest/controller/VisitSummarySectionsController.java b/omod/src/main/java/org/openmrs/module/patientdocuments/web/rest/controller/VisitSummarySectionsController.java new file mode 100644 index 0000000..d4f8bd5 --- /dev/null +++ b/omod/src/main/java/org/openmrs/module/patientdocuments/web/rest/controller/VisitSummarySectionsController.java @@ -0,0 +1,88 @@ +/** + * 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.patientdocuments.web.rest.controller; + +import static org.openmrs.module.patientdocuments.common.PatientDocumentsConstants.MODULE_ARTIFACT_ID; +import static org.openmrs.module.patientdocuments.common.PatientDocumentsConstants.VISIT_SUMMARY_ID; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.openmrs.api.APIAuthenticationException; +import org.openmrs.api.context.Context; +import org.openmrs.api.context.ContextAuthenticationException; +import org.openmrs.module.patientdocuments.api.section.VisitSummarySection; +import org.openmrs.module.webservices.rest.SimpleObject; +import org.openmrs.module.webservices.rest.web.RestConstants; +import org.openmrs.module.webservices.rest.web.v1_0.controller.BaseRestController; +import org.openmrs.util.PrivilegeConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +/** + * REST controller that lists the registered visit summary sections with their + * effective configuration, for admin/config UIs. + *

+ * Accessible at: {@code GET /rest/v1/patientdocuments/visitSummary/sections} + *

+ * Returns the standard REST collection shape — a {@code results} array of + * {@code {sectionKey, label, enabled, order, toggleable}} objects sorted by + * effective order, the same order the PDF renderer applies. + */ +@Controller +@RequestMapping(value = "/rest/" + RestConstants.VERSION_1 + "/" + MODULE_ARTIFACT_ID + "/" + VISIT_SUMMARY_ID + + "/sections") +public class VisitSummarySectionsController extends BaseRestController { + + private static final Logger logger = LoggerFactory.getLogger(VisitSummarySectionsController.class); + + @Autowired(required = false) + private List sections; + + @RequestMapping(method = RequestMethod.GET) + public ResponseEntity getSections() { + try { + Context.requirePrivilege(PrivilegeConstants.GET_GLOBAL_PROPERTIES); + + List results = new ArrayList<>(); + if (sections != null) { + List ordered = new ArrayList<>(sections); + ordered.sort(Comparator.comparingInt(VisitSummarySection::getOrder)); + for (VisitSummarySection section : ordered) { + SimpleObject entry = new SimpleObject(); + entry.add("sectionKey", section.getSectionKey()); + entry.add("label", section.getLabel()); + entry.add("enabled", section.isEnabled()); + entry.add("order", section.getOrder()); + entry.add("toggleable", section.isToggleable()); + results.add(entry); + } + } + return new ResponseEntity<>(new SimpleObject().add("results", results), HttpStatus.OK); + } + catch (APIAuthenticationException | ContextAuthenticationException e) { + logger.warn("Privilege check failed for visit summary sections request: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).contentType(MediaType.TEXT_PLAIN).body("Access denied"); + } + catch (Exception e) { + logger.error("An error occurred while processing the request", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).contentType(MediaType.TEXT_PLAIN) + .body("Error listing visit summary sections"); + } + } +} diff --git a/omod/src/test/java/org/openmrs/module/patientdocuments/web/rest/controller/VisitSummarySectionsControllerTest.java b/omod/src/test/java/org/openmrs/module/patientdocuments/web/rest/controller/VisitSummarySectionsControllerTest.java new file mode 100644 index 0000000..42ae212 --- /dev/null +++ b/omod/src/test/java/org/openmrs/module/patientdocuments/web/rest/controller/VisitSummarySectionsControllerTest.java @@ -0,0 +1,160 @@ +/** + * 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.patientdocuments.web.rest.controller; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openmrs.GlobalProperty; +import org.openmrs.api.context.Context; +import org.openmrs.module.patientdocuments.api.section.VisitSummarySection; +import org.openmrs.module.webservices.rest.SimpleObject; +import org.openmrs.web.test.jupiter.BaseModuleWebContextSensitiveTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class VisitSummarySectionsControllerTest extends BaseModuleWebContextSensitiveTest { + + private static final String VITALS_ENABLED_GP = "report.visitSummary.section.vitals.enabled"; + + private static final String VITALS_ORDER_GP = "report.visitSummary.section.vitals.order"; + + @Autowired + private VisitSummarySectionsController controller; + + @Autowired + private List registeredSections; + + @BeforeEach + public void setUp() { + Context.setLocale(Locale.ENGLISH); + // ConfigUtil caches GP values in-memory but does not participate in test + // transaction rollbacks. Explicitly save the defaults before each test so the + // cache reflects the correct baseline regardless of what a prior test wrote. + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty(VITALS_ENABLED_GP, "true")); + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty(VITALS_ORDER_GP, "300")); + } + + @SuppressWarnings("unchecked") + private List getSectionsBody() { + ResponseEntity response = controller.getSections(); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue("Body must be a SimpleObject wrapper", response.getBody() instanceof SimpleObject); + Object results = ((SimpleObject) response.getBody()).get("results"); + assertNotNull("Body must contain a results array", results); + return (List) results; + } + + private SimpleObject findByKey(List body, String sectionKey) { + for (SimpleObject entry : body) { + if (sectionKey.equals(entry.get("sectionKey"))) { + return entry; + } + } + throw new AssertionError("No entry with sectionKey " + sectionKey); + } + + @Test + public void getSections_shouldReturnEveryRegisteredSectionBean() { + List body = getSectionsBody(); + + assertEquals(registeredSections.size(), body.size()); + + Set expectedKeys = new HashSet<>(); + for (VisitSummarySection section : registeredSections) { + expectedKeys.add(section.getSectionKey()); + } + Set returnedKeys = new HashSet<>(); + for (SimpleObject entry : body) { + returnedKeys.add((String) entry.get("sectionKey")); + } + assertEquals(expectedKeys, returnedKeys); + } + + @Test + public void getSections_shouldReturnAllContractFieldsSortedByEffectiveOrder() { + List body = getSectionsBody(); + + int previousOrder = Integer.MIN_VALUE; + for (SimpleObject entry : body) { + assertNotNull(entry.get("sectionKey")); + assertNotNull(entry.get("label")); + assertNotNull(entry.get("enabled")); + assertNotNull(entry.get("order")); + assertNotNull(entry.get("toggleable")); + + int order = (Integer) entry.get("order"); + assertTrue("Sections must be sorted by order", order >= previousOrder); + previousOrder = order; + } + } + + @Test + public void getSections_shouldReflectGlobalPropertyOverrides() { + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty(VITALS_ENABLED_GP, "false")); + Context.getAdministrationService().saveGlobalProperty(new GlobalProperty(VITALS_ORDER_GP, "5")); + + List body = getSectionsBody(); + SimpleObject vitals = findByKey(body, "vitals"); + + assertEquals(false, vitals.get("enabled")); + assertEquals(5, (int) (Integer) vitals.get("order")); + assertEquals("Overridden order must re-sort the list", "vitals", body.get(0).get("sectionKey")); + } + + @Test + public void getSections_shouldMarkAlwaysOnSectionsNotToggleable() { + Set alwaysOnKeys = new HashSet<>(); + alwaysOnKeys.add("facilityHeader"); + alwaysOnKeys.add("patientInfo"); + alwaysOnKeys.add("footer"); + + for (SimpleObject entry : getSectionsBody()) { + String key = (String) entry.get("sectionKey"); + if (alwaysOnKeys.contains(key)) { + assertEquals("Section " + key + " must not be toggleable", false, entry.get("toggleable")); + assertEquals("Section " + key + " must be enabled", true, entry.get("enabled")); + } else { + assertEquals("Section " + key + " must be toggleable", true, entry.get("toggleable")); + } + } + } + + @Test + public void getSections_shouldResolveLabelsFromMessageSource() { + List body = getSectionsBody(); + + assertEquals("Vital Signs", findByKey(body, "vitals").get("label")); + assertEquals("Facility Header", findByKey(body, "facilityHeader").get("label")); + assertEquals("Footer", findByKey(body, "footer").get("label")); + } + + @Test + public void getSections_shouldReturnForbiddenWithoutGetGlobalPropertiesPrivilege() { + Context.logout(); + try { + ResponseEntity response = controller.getSections(); + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + } + finally { + authenticate(); + } + } +}