diff --git a/api/pom.xml b/api/pom.xml
index 14a78f7..651d576 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -33,6 +33,13 @@
provided
+
+ org.openmrs.module
+ o3forms-omod
+ ${o3FormsVersion}
+ provided
+
+
org.apache.xmlgraphics
fop
@@ -115,6 +122,31 @@
+
+
+ org.openmrs.module
+ webservices.rest-omod-common
+ ${openmrsWebRestVersion}
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
+
+
@@ -154,5 +186,17 @@
false
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ com.fasterxml.jackson.datatype:jackson-datatype-jsr310
+
+
+
+
diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/PatientDocumentsActivator.java b/api/src/main/java/org/openmrs/module/patientdocuments/PatientDocumentsActivator.java
index 5dda567..4d270c7 100644
--- a/api/src/main/java/org/openmrs/module/patientdocuments/PatientDocumentsActivator.java
+++ b/api/src/main/java/org/openmrs/module/patientdocuments/PatientDocumentsActivator.java
@@ -12,6 +12,7 @@
import static org.openmrs.module.patientdocuments.common.PatientDocumentsConstants.MODULE_NAME;
import org.openmrs.module.BaseModuleActivator;
+import org.openmrs.module.patientdocuments.reports.EncounterPdfReportManager;
import org.openmrs.module.patientdocuments.reports.PatientIdStickerReportManager;
import org.openmrs.module.reporting.report.manager.ReportManagerUtil;
import org.slf4j.Logger;
@@ -42,6 +43,17 @@ public void started() {
catch (Exception e) {
log.error("Failed to set up report '{}'", patientIdStickerReportName, e);
}
+
+ EncounterPdfReportManager encounterReportManager = new EncounterPdfReportManager();
+ String encounterPdfReportName = encounterReportManager.getName();
+
+ log.info("Setting up report: {} ...", encounterPdfReportName);
+ try {
+ ReportManagerUtil.setupReport(encounterReportManager);
+ log.info("Successfully set up report: {}", encounterPdfReportName);
+ } catch (Exception e) {
+ log.error("Failed to set up report '{}'", encounterPdfReportName, e);
+ }
}
/**
diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/common/Helper.java b/api/src/main/java/org/openmrs/module/patientdocuments/common/Helper.java
index df2b099..bfda735 100644
--- a/api/src/main/java/org/openmrs/module/patientdocuments/common/Helper.java
+++ b/api/src/main/java/org/openmrs/module/patientdocuments/common/Helper.java
@@ -9,28 +9,17 @@
*/
package org.openmrs.module.patientdocuments.common;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
-
-import org.apache.commons.io.IOUtils;
import org.openmrs.util.OpenmrsClassLoader;
+import java.io.InputStream;
+
public class Helper {
-
- /**
- * Given a location on the classpath, return the contents of this resource as a String
- */
- public static String getStringFromResource(String resourceName) {
- InputStream is = null;
+
+ public static InputStream getInputStreamByResource(String resourceName) {
try {
- is = OpenmrsClassLoader.getInstance().getResourceAsStream(resourceName);
- return IOUtils.toString(is, StandardCharsets.UTF_8.name());
- }
- catch (Exception e) {
+ return OpenmrsClassLoader.getInstance().getResourceAsStream(resourceName);
+ } catch (Exception e) {
throw new IllegalArgumentException("Unable to load resource: " + resourceName, e);
}
- finally {
- IOUtils.closeQuietly(is);
- }
}
}
diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsConstants.java b/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsConstants.java
index 2ad9878..fcd36cc 100644
--- a/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsConstants.java
+++ b/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsConstants.java
@@ -31,4 +31,18 @@ public class PatientDocumentsConstants {
* The path to the style sheet for Patient History reports.
*/
public static final String PATIENT_ID_STICKER_XSL_PATH = "patientIdStickerFopStylesheet.xsl";
+
+ public static final String DEFAULT_ENCOUNTER_FORM_XSL_PATH = "defaultEncounterFormFopStylesheet.xsl";
+
+ public static final String ENCOUNTER_PRINTING_HEADER_PREFIX = "report.encounterPrinting.header.";
+
+ public static final String ENCOUNTER_PRINTING_FOOTER_PREFIX = "report.encounterPrinting.footer.";
+
+ public static final String ENCOUNTER_PRINTING_STYLESHEET_KEY = "report.encounterPrinting.stylesheet";
+
+ public static final String ENCOUNTER_PRINTING_LOGO_PATH_KEY = "report.encounterPrinting.logopath";
+
+ public static final String NO_DATA_RECORDED_PLACEHOLDER = "No data recorded";
+
+ public static final String MISSING_VALUE_PLACEHOLDER = "-";
}
diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsPrivilegeConstants.java b/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsPrivilegeConstants.java
index 46ff4f0..51244c3 100644
--- a/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsPrivilegeConstants.java
+++ b/api/src/main/java/org/openmrs/module/patientdocuments/common/PatientDocumentsPrivilegeConstants.java
@@ -15,5 +15,7 @@
public class PatientDocumentsPrivilegeConstants {
public static final String VIEW_PATIENT_ID_STICKER = "App: Can generate a Patient Identity Sticker";
-
+
+ public static final String PRINT_ENCOUNTER_FORMS_PRIVILEGE = "App: Print encounter forms";
+
}
diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java
new file mode 100644
index 0000000..21a1052
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPdfReportRenderer.java
@@ -0,0 +1,122 @@
+/**
+ * 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.renderer;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.fop.apps.FOUserAgent;
+import org.apache.fop.apps.Fop;
+import org.apache.fop.apps.FopFactory;
+import org.apache.fop.apps.MimeConstants;
+import org.openmrs.Encounter;
+import org.openmrs.annotation.Handler;
+import org.openmrs.api.EncounterService;
+import org.openmrs.api.context.Context;
+import org.openmrs.module.initializer.api.InitializerService;
+import org.openmrs.module.patientdocuments.common.Helper;
+import org.openmrs.module.patientdocuments.common.PatientDocumentsConstants;
+import org.openmrs.module.patientdocuments.reports.EncounterPdfReportManager;
+import org.openmrs.module.reporting.report.ReportData;
+import org.openmrs.module.reporting.report.ReportRequest;
+import org.openmrs.module.reporting.report.renderer.RenderingException;
+import org.openmrs.module.reporting.report.renderer.ReportDesignRenderer;
+import org.springframework.stereotype.Component;
+
+import javax.xml.transform.Result;
+import javax.xml.transform.Source;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.sax.SAXResult;
+import javax.xml.transform.stream.StreamSource;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.StringReader;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+@Component
+@Handler
+public class EncounterPdfReportRenderer extends ReportDesignRenderer {
+
+ @Override
+ public String getFilename(ReportRequest request) {
+ return "EncountersReport.pdf";
+ }
+
+ @Override
+ public String getRenderedContentType(ReportRequest request) {
+ return "application/pdf";
+ }
+
+ @Override
+ public void render(ReportData results, String argument, OutputStream out) throws RenderingException {
+ try {
+ String encounterUuidsParam = (String) getReportParam(results, EncounterPdfReportManager.ENCOUNTER_UUIDS_PARAM);
+ if (StringUtils.isBlank(encounterUuidsParam)) {
+ throw new RenderingException("No encounter UUIDs provided");
+ }
+
+ List encounters = collectEncounters(encounterUuidsParam);
+ Locale reportLocale = (Locale) getReportParam(results, EncounterPdfReportManager.ENCOUNTER_LOCALE_PARAM);
+ EncounterPrintingContext printingContext = new EncounterPrintingContext(encounters, reportLocale);
+ String encountersXml = new EncounterXmlBuilder().build(printingContext);
+ transformXmlToPdf(encountersXml, out);
+ } catch (Exception e) {
+ throw new RenderingException("Error generating PDF: " + e.getMessage(), e);
+ }
+ }
+
+ private Object getReportParam(ReportData data, String paramName) {
+ return data.getContext().getParameterValue(paramName);
+ }
+
+ private List collectEncounters(String encounterUuids) {
+ EncounterService encounterService = Context.getEncounterService();
+ String[] uuids = encounterUuids.split(",");
+ List encounters = new ArrayList<>();
+ for (String uuid : uuids) {
+ Encounter encounter = encounterService.getEncounterByUuid(uuid.trim());
+ if (encounter != null) {
+ encounters.add(encounter);
+ }
+ }
+
+ return encounters;
+ }
+
+ private void transformXmlToPdf(String xmlData, OutputStream outStream) throws Exception {
+ FopFactory fopFactory = FopFactory.newInstance(new URI("."));
+ FOUserAgent foUserAgent = fopFactory.newFOUserAgent();
+ Fop fop = fopFactory.newFop(MimeConstants.MIME_PDF, foUserAgent, outStream);
+
+ String stylesheetName = getStylesheetName();
+ try (InputStream xslStream = Helper.getInputStreamByResource(stylesheetName)) {
+ if (xslStream == null) {
+ throw new FileNotFoundException("Stylesheet not found at " + stylesheetName);
+ }
+
+ TransformerFactory factory = TransformerFactory.newInstance();
+ Transformer transformer = factory.newTransformer(new StreamSource(xslStream));
+ Source src = new StreamSource(new StringReader(xmlData));
+ Result res = new SAXResult(fop.getDefaultHandler());
+ transformer.transform(src, res);
+ }
+ }
+
+ private String getStylesheetName() {
+ String stylesheetName = Context.getService(InitializerService.class).getValueFromKey(PatientDocumentsConstants.ENCOUNTER_PRINTING_STYLESHEET_KEY);
+ if (StringUtils.isBlank(stylesheetName)) {
+ stylesheetName = PatientDocumentsConstants.DEFAULT_ENCOUNTER_FORM_XSL_PATH;
+ }
+ return stylesheetName;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPrintingContext.java b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPrintingContext.java
new file mode 100644
index 0000000..9adb31f
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterPrintingContext.java
@@ -0,0 +1,43 @@
+/**
+ * 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.renderer;
+
+import org.openmrs.Encounter;
+
+import java.util.List;
+import java.util.Locale;
+
+public class EncounterPrintingContext {
+ private List encounters;
+
+ private Locale locale;
+
+ public EncounterPrintingContext(List encounters, Locale locale) {
+ this.encounters = encounters;
+ this.locale = locale;
+ }
+
+ public List getEncounters() {
+ return encounters;
+ }
+
+ public void setEncounters(List encounters) {
+ this.encounters = encounters;
+ }
+
+ public Locale getLocale() {
+ return locale;
+ }
+
+ public void setLocale(Locale locale) {
+ this.locale = locale;
+ }
+}
+
diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java
new file mode 100644
index 0000000..c8374bd
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/patientdocuments/renderer/EncounterXmlBuilder.java
@@ -0,0 +1,592 @@
+/**
+ * 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.renderer;
+
+import org.apache.commons.lang.StringEscapeUtils;
+import org.apache.commons.lang.StringUtils;
+import org.openmrs.Concept;
+import org.openmrs.ConceptName;
+import org.openmrs.Encounter;
+import org.openmrs.Obs;
+import org.openmrs.Patient;
+import org.openmrs.PatientIdentifier;
+import org.openmrs.PersonAttribute;
+import org.openmrs.User;
+import org.openmrs.Visit;
+import org.openmrs.VisitAttribute;
+import org.openmrs.api.ConceptService;
+import org.openmrs.api.context.Context;
+import org.openmrs.messagesource.MessageSourceService;
+import org.openmrs.module.initializer.api.InitializerService;
+import org.openmrs.module.o3forms.api.O3FormsService;
+import org.openmrs.module.patientdocuments.common.PatientDocumentsConstants;
+import org.openmrs.module.webservices.rest.SimpleObject;
+import org.openmrs.util.OpenmrsUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+
+public class EncounterXmlBuilder {
+
+ private static final String QUESTION_OPTIONS_SECTION = "questionOptions";
+
+ private static final String QUESTIONS_SECTION = "questions";
+
+ private static final String LABEL_FIELD = "label";
+
+ private InitializerService initializerService;
+
+ private static final Logger log = LoggerFactory.getLogger(EncounterXmlBuilder.class);
+
+ private InitializerService getInitializerService() {
+ if (initializerService == null) {
+ initializerService = Context.getService(InitializerService.class);
+ }
+ return initializerService;
+ }
+
+ private String getLogoContent() {
+ String logoPath = getInitializerService().getValueFromKey(PatientDocumentsConstants.ENCOUNTER_PRINTING_LOGO_PATH_KEY);
+ if (StringUtils.isBlank(logoPath)) {
+ return null;
+ }
+
+ File logoFile = resolveSecureLogoPath(logoPath);
+ if (logoFile == null || !logoFile.exists() || !logoFile.canRead() || !logoFile.isFile()) {
+ return null;
+ }
+
+ try {
+ byte[] logoBytes = OpenmrsUtil.getFileAsBytes(logoFile);
+ if (logoBytes != null && logoBytes.length > 0) {
+ return "data:image/png;base64," + Base64.getEncoder().encodeToString(logoBytes);
+ }
+ } catch (IOException e) {
+ log.warn("Unable to read logo file");
+ }
+
+ return null;
+ }
+
+ private File resolveSecureLogoPath(String logoUrlPath) {
+ if (StringUtils.isBlank(logoUrlPath)) {
+ return null;
+ }
+
+ final File appDataDir = OpenmrsUtil.getApplicationDataDirectoryAsFile();
+ try {
+ final Path appDataPath = appDataDir.toPath().toRealPath();
+ final Path logoPath = Paths.get(logoUrlPath);
+
+ if (logoPath.isAbsolute()) {
+ return null;
+ }
+
+ final Path logoAbsolutePath = logoPath.toAbsolutePath();
+ final Path logoNormalizedPath = logoAbsolutePath.normalize();
+
+ if (!logoAbsolutePath.equals(logoNormalizedPath)) {
+ return null;
+ }
+
+ final Path resolvedLogoPath = appDataPath.resolve(logoUrlPath).normalize();
+ final Path resolvedLogoRealPath = resolvedLogoPath.toRealPath();
+
+ if (!resolvedLogoRealPath.startsWith(appDataPath)) {
+ return null;
+ }
+
+ return resolvedLogoRealPath.toFile();
+ } catch (IllegalArgumentException | IOException e) {
+ return null;
+ }
+ }
+
+ public String build(EncounterPrintingContext printingContext) {
+ StringBuilder xml = new StringBuilder();
+ xml.append("");
+ xml.append("");
+
+ O3FormsService o3FormsService = Context.getService(O3FormsService.class);
+ User user = Context.getAuthenticatedUser();
+ for (Encounter encounter : printingContext.getEncounters()) {
+ if (encounter.getForm() != null) {
+ xml.append(renderSingleEncounter(encounter, printingContext.getLocale(), o3FormsService, user));
+ }
+ }
+
+ xml.append("");
+
+ return xml.toString();
+ }
+
+ private String renderSingleEncounter(Encounter encounter, Locale locale, O3FormsService o3FormsService, User user) {
+ StringBuilder xml = new StringBuilder();
+ xml.append("");
+
+ xml.append(buildHeaderContent(encounter, locale));
+ xml.append(buildMainContent(encounter, locale, o3FormsService));
+ xml.append(buildFooterContent(user, locale));
+
+ xml.append("");
+ return xml.toString();
+ }
+
+ private String buildHeaderContent(Encounter encounter, Locale locale) {
+ StringBuilder xml = new StringBuilder();
+ Visit visit = encounter.getVisit();
+ Patient patient = encounter.getPatient();
+
+ String logoContent = getLogoContent();
+ if (logoContent != null) {
+ xml.append("").append(escape(logoContent)).append("");
+ }
+
+ if (isHeaderFieldEnabled("patientName")) {
+ xml.append("")
+ .append(escape(patient.getPersonName().getFullName()))
+ .append("");
+ }
+
+ if (isHeaderFieldEnabled("formName")) {
+ xml.append("true");
+ }
+
+ if (isHeaderFieldEnabled("location")) {
+ String locationName = encounter.getLocation() != null ? encounter.getLocation().getName()
+ : PatientDocumentsConstants.MISSING_VALUE_PLACEHOLDER;
+ xml.append("")
+ .append(escape(locationName))
+ .append("");
+ }
+
+ if (isHeaderFieldEnabled("encounterDate")) {
+ xml.append("")
+ .append(escape(OpenmrsUtil.getDateFormat(locale).format(encounter.getEncounterDatetime())))
+ .append("");
+ }
+
+ if (isHeaderFieldEnabled("visitStartDate")) {
+ String visitStartDate = (visit != null && visit.getStartDatetime() != null)
+ ? OpenmrsUtil.getDateFormat(locale).format(visit.getStartDatetime())
+ : PatientDocumentsConstants.MISSING_VALUE_PLACEHOLDER;
+ xml.append("")
+ .append(escape(visitStartDate))
+ .append("");
+ }
+
+ if (isHeaderFieldEnabled("visitEndDate")) {
+ String visitEndDate = (visit != null && visit.getStopDatetime() != null)
+ ? OpenmrsUtil.getDateFormat(locale).format(visit.getStopDatetime())
+ : PatientDocumentsConstants.MISSING_VALUE_PLACEHOLDER;
+ xml.append("")
+ .append(escape(visitEndDate))
+ .append("");
+ }
+
+ if (isHeaderFieldEnabled("visitType")) {
+ String visitTypeName = (visit != null && visit.getVisitType() != null)
+ ? visit.getVisitType().getName()
+ : PatientDocumentsConstants.MISSING_VALUE_PLACEHOLDER;
+ xml.append("")
+ .append(escape(visitTypeName))
+ .append("");
+ }
+
+ if (isHeaderFieldEnabled("patientIdentifiers")) {
+ xml.append(renderPatientIdentifiers(patient));
+ }
+
+ if (isHeaderFieldEnabled("personAttributes")) {
+ xml.append(renderPersonAttributes(patient));
+ }
+
+ if (isHeaderFieldEnabled("visitAttributes")) {
+ xml.append(renderVisitAttributes(visit));
+ }
+
+ return xml.toString();
+ }
+
+ private String buildFooterContent(User user, Locale locale) {
+ StringBuilder xml = new StringBuilder();
+
+ String userName = (user != null && user.getPersonName() != null) ? user.getPersonName().getFullName() : "System";
+ String systemId = (user != null && user.getSystemId() != null) ? user.getSystemId() : "Unknown";
+ String printTimestamp = OpenmrsUtil.getDateTimeFormat(locale).format(new Date());
+
+ String printedBy = String.format("Printed by %s (%s) at %s", userName, systemId, printTimestamp);
+ xml.append("").append(escape(printedBy)).append("");
+
+ String customFooterText = getInitializerService()
+ .getValueFromKey(PatientDocumentsConstants.ENCOUNTER_PRINTING_FOOTER_PREFIX + "customText");
+ if (StringUtils.isNotBlank(customFooterText)) {
+ xml.append("").append(escape(customFooterText)).append("");
+ }
+
+ return xml.toString();
+ }
+
+ private String buildMainContent(Encounter encounter, Locale locale, O3FormsService o3FormsService) {
+ StringBuilder xml = new StringBuilder();
+
+ xml.append("")
+ .append(escape(encounter.getForm().getName()))
+ .append("");
+
+ String formUuid = encounter.getForm().getUuid();
+ try {
+ SimpleObject schema = o3FormsService.compileFormSchema(formUuid);
+
+ Map> obsMap = buildObsMap(encounter);
+
+ xml.append("");
+ List");
+ } catch (Exception e) {
+ xml.append("Could not render form: ")
+ .append(escape(e.getMessage()))
+ .append("");
+ }
+
+ return xml.toString();
+ }
+
+ private Map> buildObsMap(Encounter encounter) {
+ Map> obsMap = new HashMap<>();
+ for (Obs obs : encounter.getAllObs()) {
+ String conceptUuid = obs.getConcept().getUuid();
+ obsMap.computeIfAbsent(conceptUuid, k -> new ArrayList<>()).add(obs);
+ }
+ return obsMap;
+ }
+
+ private String renderPage(Map page, Map> obsMap, Locale locale) {
+ StringBuilder xml = new StringBuilder();
+ String originalLabel = (String) page.getOrDefault(LABEL_FIELD, "Page");
+ String localizedLabel = getLocalizedLabel(originalLabel, null, locale);
+
+ xml.append("");
+
+ List");
+ return xml.toString();
+ }
+
+ private String renderSection(Map section, Map> obsMap, Locale locale) {
+ StringBuilder xml = new StringBuilder();
+ String originalLabel = (String) section.getOrDefault(LABEL_FIELD, "");
+ String localizedLabel = getLocalizedLabel(originalLabel, null, locale);
+
+ xml.append("");
+
+ List> questions = (List>) section.get(QUESTIONS_SECTION);
+ if (questions != null) {
+ for (Map question : questions) {
+ xml.append(renderQuestion(question, obsMap, locale));
+ }
+ }
+
+ xml.append("");
+ return xml.toString();
+ }
+
+ private String renderQuestion(Map question, Map> obsMap, Locale locale) {
+ String type = (String) question.get("type");
+
+ if ("markdown".equals(type) || "label".equals(type)) {
+ return renderMarkdownQuestion(question, locale);
+ }
+
+ if ("obsGroup".equals(type)) {
+ return renderObsGroupQuestion(question, obsMap, locale);
+ }
+
+ if ("obs".equals(type)) {
+ return renderObsQuestion(question, obsMap, locale);
+ }
+
+ return renderSubQuestions(question, obsMap, locale);
+ }
+
+ private String renderMarkdownQuestion(Map question, Locale locale) {
+ StringBuilder sb = new StringBuilder();
+ String text = formatValueAsText(question.get("value"));
+ sb.append("").append(escape(text)).append("");
+ return sb.toString();
+ }
+
+ private String renderObsGroupQuestion(Map question, Map> obsMap, Locale locale) {
+ StringBuilder sb = new StringBuilder();
+ String label = getQuestionLabel(question, locale);
+
+ if (StringUtils.isNotBlank(label)) {
+ sb.append("").append(escape(label)).append("");
+ }
+
+ sb.append(renderSubQuestions(question, obsMap, locale));
+ return sb.toString();
+ }
+
+ private String renderObsQuestion(Map question, Map> obsMap, Locale locale) {
+ StringBuilder sb = new StringBuilder();
+ String label = getQuestionLabel(question, locale);
+
+ sb.append("");
+ String obsValue = findObsValue(question, obsMap, locale);
+ sb.append(escape(obsValue));
+ sb.append("");
+
+ sb.append(renderSubQuestions(question, obsMap, locale));
+ return sb.toString();
+ }
+
+ private String renderSubQuestions(Map question, Map> obsMap, Locale locale) {
+ StringBuilder sb = new StringBuilder();
+ List> subQuestions = (List>) question.get(QUESTIONS_SECTION);
+ if (subQuestions != null) {
+ for (Map subQuestion : subQuestions) {
+ sb.append(renderQuestion(subQuestion, obsMap, locale));
+ }
+ }
+ return sb.toString();
+ }
+
+ private String getQuestionLabel(Map question, Locale locale) {
+ String originalLabel = (String) question.getOrDefault(LABEL_FIELD, "");
+ String conceptRef = extractConceptRef(question);
+ return getLocalizedLabel(originalLabel, conceptRef, locale);
+ }
+
+ private String extractConceptRef(Map question) {
+ if (!question.containsKey(QUESTION_OPTIONS_SECTION)) {
+ return null;
+ }
+ Map options = (Map) question.get(QUESTION_OPTIONS_SECTION);
+ return (String) options.get("concept");
+ }
+
+ private String formatValueAsText(Object value) {
+ if (value instanceof List) {
+ return String.join("\n", (List) value);
+ }
+ return value != null ? value.toString() : "";
+ }
+
+ private String findObsValue(Map question, Map> obsMap, Locale locale) {
+ String conceptRef = extractConceptRef(question);
+ if (conceptRef == null) {
+ return PatientDocumentsConstants.NO_DATA_RECORDED_PLACEHOLDER;
+ }
+
+ Concept concept = findConceptByRef(conceptRef);
+ if (concept == null || !obsMap.containsKey(concept.getUuid())) {
+ return PatientDocumentsConstants.NO_DATA_RECORDED_PLACEHOLDER;
+ }
+
+ List observations = obsMap.get(concept.getUuid());
+ if (observations.isEmpty()) {
+ return PatientDocumentsConstants.NO_DATA_RECORDED_PLACEHOLDER;
+ }
+
+ return observations.stream()
+ .map(obs -> getLocalizedObsValue(obs, locale))
+ .collect(Collectors.joining(", "));
+ }
+
+ private String escape(String input) {
+ return StringEscapeUtils.escapeXml(StringUtils.defaultString(input));
+ }
+
+ private Concept findConceptByRef(String conceptRef) {
+ Concept concept = getConceptService().getConceptByReference(conceptRef);
+ if (concept == null) {
+ concept = getConceptService().getConceptByUuid(conceptRef); // fallback for UUIDs that are non-UUID format
+ }
+
+ return concept;
+ }
+
+ private String getLocalizedObsValue(Obs obs, Locale locale) {
+ if (obs.getValueCoded() != null) {
+ ConceptName localizedName = obs.getValueCoded().getName(locale);
+ if (localizedName != null) {
+ return localizedName.getName();
+ } else {
+ return obs.getValueCoded().getDisplayString();
+ }
+ }
+ return obs.getValueAsString(locale);
+ }
+
+ private String getLocalizedLabel(String defaultLabel, String conceptRef, Locale locale) {
+ if (StringUtils.isNotBlank(conceptRef)) {
+ Concept concept = getConceptService().getConceptByReference(conceptRef);
+ if (concept != null) {
+ ConceptName localizedName = concept.getName(locale);
+ if (localizedName != null) {
+ return localizedName.getName();
+ }
+ }
+ }
+
+ if (StringUtils.isNotBlank(defaultLabel)) {
+ try {
+ String translated = getMessageSourceService().getMessage(defaultLabel, null, locale);
+ if (StringUtils.isNotBlank(translated) && !translated.equals(defaultLabel)) {
+ return translated;
+ }
+ } catch (Exception ignored) {
+ }
+ }
+
+ return defaultLabel;
+ }
+
+ private MessageSourceService getMessageSourceService() {
+ return Context.getMessageSourceService();
+ }
+
+ private ConceptService getConceptService() {
+ return Context.getConceptService();
+ }
+
+ private boolean isHeaderFieldEnabled(String fieldName) {
+ String configKey = PatientDocumentsConstants.ENCOUNTER_PRINTING_HEADER_PREFIX + fieldName;
+ Boolean enabled = getInitializerService().getBooleanFromKey(configKey);
+ return Boolean.TRUE.equals(enabled);
+ }
+
+ private String renderPatientIdentifiers(Patient patient) {
+ StringBuilder xml = new StringBuilder();
+ String configuredPatientIdentifierTypes = getInitializerService()
+ .getValueFromKey(PatientDocumentsConstants.ENCOUNTER_PRINTING_HEADER_PREFIX + "patientIdentifierTypes");
+ List identifierTypes = parseCommaSeparatedList(configuredPatientIdentifierTypes);
+
+ List identifiers = new ArrayList<>(patient.getIdentifiers());
+ boolean hasIdentifiers = false;
+
+ for (PatientIdentifier identifier : identifiers) {
+ String identifierTypeName = identifier.getIdentifierType().getName();
+ if (identifierTypes.isEmpty() || identifierTypes.contains(identifierTypeName)) {
+ if (!hasIdentifiers) {
+ xml.append("");
+ hasIdentifiers = true;
+ }
+ xml.append("")
+ .append(escape(identifier.getIdentifier()))
+ .append("");
+ }
+ }
+
+ if (hasIdentifiers) {
+ xml.append("");
+ }
+
+ return xml.toString();
+ }
+
+ private String renderPersonAttributes(Patient patient) {
+ StringBuilder xml = new StringBuilder();
+ String configuredPersonAttributeTypes = getInitializerService()
+ .getValueFromKey(PatientDocumentsConstants.ENCOUNTER_PRINTING_HEADER_PREFIX + "personAttributeTypes");
+ List attributeTypes = parseCommaSeparatedList(configuredPersonAttributeTypes);
+
+ List attributes = new ArrayList<>(patient.getAttributes());
+ boolean hasAttributes = false;
+
+ for (PersonAttribute attribute : attributes) {
+ String attributeTypeName = attribute.getAttributeType().getName();
+ if (attributeTypes.isEmpty() || attributeTypes.contains(attributeTypeName)) {
+ if (!hasAttributes) {
+ xml.append("");
+ hasAttributes = true;
+ }
+ xml.append("")
+ .append(escape(attribute.getValue() != null ? attribute.getValue() : ""))
+ .append("");
+ }
+ }
+
+ if (hasAttributes) {
+ xml.append("");
+ }
+
+ return xml.toString();
+ }
+
+ private String renderVisitAttributes(Visit visit) {
+ StringBuilder xml = new StringBuilder();
+ if (visit == null) {
+ return xml.toString();
+ }
+
+ String configuredVisitAttributeTypes = getInitializerService()
+ .getValueFromKey(PatientDocumentsConstants.ENCOUNTER_PRINTING_HEADER_PREFIX + "visitAttributeTypes");
+ List attributeTypes = parseCommaSeparatedList(configuredVisitAttributeTypes);
+
+ List attributes = new ArrayList<>(visit.getActiveAttributes());
+ boolean hasAttributes = false;
+
+ for (VisitAttribute attribute : attributes) {
+ String attributeTypeName = attribute.getAttributeType().getName();
+ if (attributeTypes.isEmpty() || attributeTypes.contains(attributeTypeName)) {
+ if (!hasAttributes) {
+ xml.append("");
+ hasAttributes = true;
+ }
+ xml.append("")
+ .append(escape(attribute.getValue() != null ? attribute.getValue().toString() : ""))
+ .append("");
+ }
+ }
+
+ if (hasAttributes) {
+ xml.append("");
+ }
+
+ return xml.toString();
+ }
+
+ private List parseCommaSeparatedList(String value) {
+ if (StringUtils.isBlank(value)) {
+ return new ArrayList<>();
+ }
+ return Arrays.stream(value.split(","))
+ .map(String::trim)
+ .filter(StringUtils::isNotBlank)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/reports/EncounterPdfReport.java b/api/src/main/java/org/openmrs/module/patientdocuments/reports/EncounterPdfReport.java
new file mode 100644
index 0000000..9cfeb6c
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/patientdocuments/reports/EncounterPdfReport.java
@@ -0,0 +1,82 @@
+/**
+ * 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.reports;
+
+import org.openmrs.api.context.Context;
+import org.openmrs.module.patientdocuments.renderer.EncounterPdfReportRenderer;
+import org.openmrs.module.reporting.evaluation.parameter.Mapped;
+import org.openmrs.module.reporting.report.ReportRequest;
+import org.openmrs.module.reporting.report.definition.ReportDefinition;
+import org.openmrs.module.reporting.report.definition.service.ReportDefinitionService;
+import org.openmrs.module.reporting.report.renderer.RenderingMode;
+import org.openmrs.module.reporting.report.service.ReportService;
+import org.openmrs.module.webservices.rest.SimpleObject;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Component
+public class EncounterPdfReport {
+
+ public SimpleObject triggerEncountersPrinting(List encounterUuids) {
+ ReportService reportService = Context.getService(ReportService.class);
+ ReportRequest reportRequest = buildReportRequest(encounterUuids, reportService);
+ reportRequest = reportService.queueReport(reportRequest);
+ reportService.processNextQueuedReports();
+
+ SimpleObject response = new SimpleObject();
+ response.put("uuid", reportRequest.getUuid());
+ response.put("requestUuid", reportRequest.getUuid());
+ response.put("status", reportRequest.getStatus().name());
+
+ return response;
+ }
+
+ private ReportRequest buildReportRequest(List encounterUuids, ReportService reportService) {
+ ReportDefinitionService reportDefinitionService = Context.getService(ReportDefinitionService.class);
+ ReportDefinition reportDef =
+ reportDefinitionService.getDefinitionByUuid(EncounterPdfReportManager.REPORT_DEFINITION_UUID);
+ if (reportDef == null) {
+ throw new RuntimeException("Report definition not found");
+ }
+
+ Map parameterMappings = new HashMap<>();
+ String joinedUuids = String.join(",", encounterUuids);
+ parameterMappings.put(EncounterPdfReportManager.ENCOUNTER_UUIDS_PARAM, joinedUuids);
+ parameterMappings.put(EncounterPdfReportManager.ENCOUNTER_LOCALE_PARAM, Context.getLocale());
+
+ Mapped mappedReportDef = new Mapped<>();
+ mappedReportDef.setParameterizable(reportDef);
+ mappedReportDef.setParameterMappings(parameterMappings);
+
+ ReportRequest reportRequest = new ReportRequest();
+ reportRequest.setReportDefinition(mappedReportDef);
+ reportRequest.setPriority(ReportRequest.Priority.NORMAL);
+
+ RenderingMode renderingMode = null;
+ for (RenderingMode mode : reportService.getRenderingModes(reportDef)) {
+ if (mode.getRenderer() instanceof EncounterPdfReportRenderer) {
+ renderingMode = mode;
+ break;
+ }
+ }
+
+ if (renderingMode == null) {
+ throw new IllegalStateException(
+ "No rendering mode configured for " + EncounterPdfReportRenderer.class.getName());
+ }
+
+ reportRequest.setRenderingMode(renderingMode);
+
+ return reportRequest;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/patientdocuments/reports/EncounterPdfReportManager.java b/api/src/main/java/org/openmrs/module/patientdocuments/reports/EncounterPdfReportManager.java
new file mode 100644
index 0000000..2b63601
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/patientdocuments/reports/EncounterPdfReportManager.java
@@ -0,0 +1,89 @@
+/**
+ * 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.reports;
+
+import org.openmrs.module.patientdocuments.renderer.EncounterPdfReportRenderer;
+import org.openmrs.module.reporting.evaluation.parameter.Parameter;
+import org.openmrs.module.reporting.report.ReportDesign;
+import org.openmrs.module.reporting.report.definition.ReportDefinition;
+import org.openmrs.module.reporting.report.manager.BaseReportManager;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+@Component
+public class EncounterPdfReportManager extends BaseReportManager {
+
+ public static final String REPORT_DESIGN_UUID = "1ce73eb4-10bb-11f1-a6da-0242ac1e0002";
+
+ public static final String REPORT_DESIGN_NAME = "Encounter PDF Design";
+
+ public static final String REPORT_DEFINITION_UUID = "0e89ec4c-10bb-11f1-a6da-0242ac1e0002";
+
+ public static final String REPORT_DEFINITION_NAME = "Generic Encounter PDF Report";
+
+ public static final String REPORT_DESCRIPTION = "Dynamically generates PDF printouts for one or more clinical encounters based on O3 forms.";
+
+ public static final String ENCOUNTER_UUIDS_PARAM = "encounterUuids";
+
+ public static final String ENCOUNTER_LOCALE_PARAM = "locale";
+
+ @Override
+ public String getUuid() {
+ return REPORT_DEFINITION_UUID;
+ }
+
+ @Override
+ public String getName() {
+ return REPORT_DEFINITION_NAME;
+ }
+
+ @Override
+ public String getDescription() {
+ return REPORT_DESCRIPTION;
+ }
+
+ @Override
+ public String getVersion() {
+ return "2.0";
+ }
+
+ @Override
+ public List getParameters() {
+ List params = new ArrayList<>();
+ params.add(new Parameter(ENCOUNTER_UUIDS_PARAM, "Encounter UUIDs (comma separated)", String.class));
+ params.add(new Parameter(ENCOUNTER_LOCALE_PARAM, "Locale", Locale.class));
+ return params;
+ }
+
+ @Override
+ public ReportDefinition constructReportDefinition() {
+ ReportDefinition rd = new ReportDefinition();
+ rd.setUuid(getUuid());
+ rd.setName(getName());
+ rd.setDescription(getDescription());
+ rd.setParameters(getParameters());
+ return rd;
+ }
+
+ @Override
+ public List constructReportDesigns(ReportDefinition reportDefinition) {
+ ReportDesign reportDesign = new ReportDesign();
+ reportDesign.setName(REPORT_DESIGN_NAME);
+ reportDesign.setUuid(REPORT_DESIGN_UUID);
+ reportDesign.setReportDefinition(reportDefinition);
+ reportDesign.setRendererType(EncounterPdfReportRenderer.class);
+
+ return Collections.singletonList(reportDesign);
+ }
+}
diff --git a/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl b/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl
new file mode 100644
index 0000000..6c40f64
--- /dev/null
+++ b/api/src/main/resources/defaultEncounterFormFopStylesheet.xsl
@@ -0,0 +1,268 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Patient Name:
+
+
+ Location:
+
+
+ Visit Start:
+
+
+
+ :
+
+
+
+ Form:
+
+
+
+
+ Encounter Date:
+
+
+ Visit End:
+
+
+ Visit Type:
+
+
+
+ :
+
+
+
+
+ :
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Patient Name:
+
+
+ Location:
+
+
+ Visit Start:
+
+
+
+ :
+
+
+
+ Form:
+
+
+
+
+ Visit Date:
+
+
+ Encounter Date:
+
+
+ Visit End:
+
+
+ Visit Type:
+
+
+
+ :
+
+
+
+
+ :
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No data recorded for this section.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/api/src/test/java/org/openmrs/module/patientdocuments/reports/EncounterPdfReportManagerTest.java b/api/src/test/java/org/openmrs/module/patientdocuments/reports/EncounterPdfReportManagerTest.java
new file mode 100644
index 0000000..e4c1ed5
--- /dev/null
+++ b/api/src/test/java/org/openmrs/module/patientdocuments/reports/EncounterPdfReportManagerTest.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.patientdocuments.reports;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import java.util.List;
+import java.util.Locale;
+
+import org.hamcrest.Matchers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.openmrs.module.patientdocuments.renderer.EncounterPdfReportRenderer;
+import org.openmrs.module.reporting.evaluation.parameter.Parameter;
+import org.openmrs.module.reporting.report.ReportDesign;
+import org.openmrs.module.reporting.report.definition.ReportDefinition;
+import org.openmrs.module.reporting.report.manager.ReportManagerUtil;
+import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest;
+
+public class EncounterPdfReportManagerTest extends BaseModuleContextSensitiveTest {
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ ReportManagerUtil.setupReport(new EncounterPdfReportManager());
+ }
+
+ private ReportDesign setupAndReturnReportDesign() {
+ EncounterPdfReportManager manager = new EncounterPdfReportManager();
+ ReportDefinition reportDefinition = manager.constructReportDefinition();
+ assertNotNull(reportDefinition);
+ assertEquals(EncounterPdfReportManager.REPORT_DEFINITION_NAME, reportDefinition.getName());
+ assertEquals(EncounterPdfReportManager.REPORT_DEFINITION_UUID, reportDefinition.getUuid());
+ assertNotNull(reportDefinition.getParameters());
+ assertThat(reportDefinition.getParameters().size(), Matchers.is(2));
+
+ List reportDesigns = manager.constructReportDesigns(reportDefinition);
+ assertNotNull(reportDesigns);
+ assertThat(reportDesigns.size(), org.hamcrest.Matchers.is(1));
+
+ return reportDesigns.get(0);
+ }
+
+ @Test
+ public void setupReport_shouldSetupEncounterPdfReport() throws Exception {
+ ReportDesign reportDesign = setupAndReturnReportDesign();
+ assertEquals(EncounterPdfReportManager.REPORT_DESIGN_UUID, reportDesign.getUuid());
+ assertEquals(EncounterPdfReportManager.REPORT_DESIGN_NAME, reportDesign.getName());
+ }
+
+ @Test
+ public void constructReportDefinition_shouldHaveCorrectParameters() throws Exception {
+ EncounterPdfReportManager manager = new EncounterPdfReportManager();
+ List parameters = manager.getParameters();
+
+ assertNotNull(parameters);
+ assertEquals(2, parameters.size());
+
+ boolean hasEncounterUuidsParam = false;
+ boolean hasLocaleParam = false;
+
+ for (Parameter param : parameters) {
+ if (EncounterPdfReportManager.ENCOUNTER_UUIDS_PARAM.equals(param.getName())) {
+ hasEncounterUuidsParam = true;
+ assertEquals(String.class, param.getType());
+ }
+ if (EncounterPdfReportManager.ENCOUNTER_LOCALE_PARAM.equals(param.getName())) {
+ hasLocaleParam = true;
+ assertEquals(Locale.class, param.getType());
+ }
+ }
+
+ assertThat("Should have encounterUuids parameter", hasEncounterUuidsParam, Matchers.is(true));
+ assertThat("Should have locale parameter", hasLocaleParam, Matchers.is(true));
+ }
+
+ @Test
+ public void constructReportDesigns_shouldUseEncounterPdfReportRenderer() throws Exception {
+ EncounterPdfReportManager manager = new EncounterPdfReportManager();
+ ReportDefinition reportDefinition = manager.constructReportDefinition();
+ List reportDesigns = manager.constructReportDesigns(reportDefinition);
+
+ assertNotNull(reportDesigns);
+ assertEquals(1, reportDesigns.size());
+
+ ReportDesign reportDesign = reportDesigns.get(0);
+ assertEquals(EncounterPdfReportRenderer.class, reportDesign.getRendererType());
+ }
+}
diff --git a/api/src/test/java/org/openmrs/module/patientdocuments/testconfig/WebServicesRestTestConfig.java b/api/src/test/java/org/openmrs/module/patientdocuments/testconfig/WebServicesRestTestConfig.java
new file mode 100644
index 0000000..dce01c0
--- /dev/null
+++ b/api/src/test/java/org/openmrs/module/patientdocuments/testconfig/WebServicesRestTestConfig.java
@@ -0,0 +1,22 @@
+package org.openmrs.module.patientdocuments.testconfig;
+
+import org.openmrs.module.webservices.rest.web.api.RestService;
+import org.openmrs.module.webservices.rest.web.api.RestHelperService;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import static org.mockito.Mockito.mock;
+
+@Configuration
+public class WebServicesRestTestConfig {
+
+ @Bean
+ public RestService restService() {
+ return mock(RestService.class);
+ }
+
+ @Bean(name = "restHelperService")
+ public RestHelperService restHelperService() {
+ return mock(RestHelperService.class);
+ }
+}
\ No newline at end of file
diff --git a/api/src/test/resources/TestingApplicationContext.xml b/api/src/test/resources/TestingApplicationContext.xml
index fdcb39a..4ec4f81 100644
--- a/api/src/test/resources/TestingApplicationContext.xml
+++ b/api/src/test/resources/TestingApplicationContext.xml
@@ -10,6 +10,14 @@
+
+
+
+
+
diff --git a/omod/src/test/java/org/openmrs/module/patientdocuments/web/rest/controller/EncounterDataPdfExportControllerTest.java b/omod/src/test/java/org/openmrs/module/patientdocuments/web/rest/controller/EncounterDataPdfExportControllerTest.java
new file mode 100644
index 0000000..306966f
--- /dev/null
+++ b/omod/src/test/java/org/openmrs/module/patientdocuments/web/rest/controller/EncounterDataPdfExportControllerTest.java
@@ -0,0 +1,71 @@
+/**
+ * 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.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+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 EncounterDataPdfExportControllerTest extends BaseModuleWebContextSensitiveTest {
+
+ @Autowired
+ private EncounterDataPdfExportController controller;
+
+ @Test
+ public void triggerEncounterPrinting_shouldReturnBadRequestForEmptyList() {
+ List emptyList = Collections.emptyList();
+
+ ResponseEntity response = controller.triggerEncounterPrinting(emptyList);
+
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ assertNotNull(response.getBody());
+ assertEquals("No encounter UUIDs provided", response.getBody().get("error"));
+ }
+
+ @Test
+ public void triggerEncounterPrinting_shouldReturnBadRequestForNullList() {
+ ResponseEntity response = controller.triggerEncounterPrinting(null);
+
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ assertNotNull(response.getBody());
+ assertEquals("No encounter UUIDs provided", response.getBody().get("error"));
+ }
+
+ @Test
+ public void getReportStatus_shouldReturn404ForInvalidUuid() {
+ String invalidUuid = "nonexistent-uuid-12345";
+
+ ResponseEntity response = controller.getReportStatus(invalidUuid);
+
+ assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
+ assertNotNull(response.getBody());
+ assertTrue(response.getBody().get("error").toString().contains("not found"));
+ }
+
+ @Test
+ public void downloadPdf_shouldReturnErrorForInvalidUuid() {
+ String invalidUuid = "nonexistent-uuid-12345";
+
+ ResponseEntity response = controller.downloadPdf(invalidUuid);
+
+ assertTrue(response.getStatusCode() == HttpStatus.BAD_REQUEST ||
+ response.getStatusCode() == HttpStatus.NOT_FOUND);
+ }
+}
diff --git a/pom.xml b/pom.xml
index 848eed7..48a6b70 100644
--- a/pom.xml
+++ b/pom.xml
@@ -56,6 +56,7 @@
2.9.0
1.3.11
2.50.0
+ 2.3.0
diff --git a/readme/EncounterPrinting.md b/readme/EncounterPrinting.md
new file mode 100644
index 0000000..17cf6a4
--- /dev/null
+++ b/readme/EncounterPrinting.md
@@ -0,0 +1,155 @@
+# Encounter Printing Configuration
+
+This configuration defines the settings for generating printable encounter forms (PDF reports). Each configuration property controls which header/footer fields are displayed on the report.
+
+## Configuration Fields
+
+### Header Fields
+These flags control which patient and visit information is displayed in the header of each page.
+
+| Key | Description |
+|-----|-------------|
+| `report.encounterPrinting.header.patientName` | Show patient name in header |
+| `report.encounterPrinting.header.location` | Show location in header |
+| `report.encounterPrinting.header.formName` | Show form name in header |
+| `report.encounterPrinting.header.encounterDate` | Show encounter date in header |
+| `report.encounterPrinting.header.visitStartDate` | Show visit start datetime in header |
+| `report.encounterPrinting.header.visitEndDate` | Show visit stop datetime in header |
+| `report.encounterPrinting.header.visitType` | Show visit type in header |
+
+**Note:** The form name (`formName`) is always displayed in the body of the report as the title, regardless of this configuration. Set `formName` to `true` to also display it in the header.
+
+**Note:** For fields that depend on Visit (visitStartDate, visitEndDate, visitType), if no visit exists or the value is not set, a "-" placeholder will be displayed.
+
+### Patient Identifiers
+Configuration for displaying patient identifiers in the header.
+
+| Key | Description |
+|-----|-------------|
+| `report.encounterPrinting.header.patientIdentifiers` | Enable patient identifiers display |
+| `report.encounterPrinting.header.patientIdentifierTypes` | Comma-separated identifier type **names** to display |
+
+### Person Attributes
+Configuration for displaying patient person attributes in the header.
+
+| Key | Description |
+|-----|-------------|
+| `report.encounterPrinting.header.personAttributes` | Enable person attributes display |
+| `report.encounterPrinting.header.personAttributeTypes` | Comma-separated person attribute type **names** to display |
+
+### Visit Attributes
+Configuration for displaying visit attributes in the header.
+
+| Key | Description |
+|-----|-------------|
+| `report.encounterPrinting.header.visitAttributes` | Enable visit attributes display |
+| `report.encounterPrinting.header.visitAttributeTypes` | Comma-separated visit attribute type **names** to display |
+
+### Footer
+Configuration for the footer section.
+
+| Key | Description |
+|-----|-------------|
+| `report.encounterPrinting.footer.customText` | Custom text to display below the "Printed by" statement |
+
+### Stylesheet
+Configuration for the XSL stylesheet used to render the PDF.
+
+| Key | Description |
+|-----|-------------|
+| `report.encounterPrinting.stylesheet` | XSL stylesheet filename to use for rendering (e.g., `defaultEncounterFormFopStylesheet.xsl`) |
+
+### Logo
+Configuration for logo element in the PDF.
+
+| Key | Description |
+|-------------------------------------|---------------------------------------------------------------------------|
+| `report.encounterPrinting.logopath` | Relative path to the logo image. If not configured, no logo is displayed. |
+
+**Note:** The logo path must be a relative path within the `OPENMRS_APPLICATION_DATA_DIRECTORY`. Path traversal is not allowed.
+
+## Example Configuration
+
+```json
+{
+ "report.encounterPrinting.header.patientName": "true",
+ "report.encounterPrinting.header.location": "true",
+ "report.encounterPrinting.header.formName": "true",
+ "report.encounterPrinting.header.encounterDate": "true",
+ "report.encounterPrinting.header.visitStartDate": "true",
+ "report.encounterPrinting.header.visitEndDate": "true",
+ "report.encounterPrinting.header.visitType": "true",
+ "report.encounterPrinting.header.patientIdentifiers": "true",
+ "report.encounterPrinting.header.patientIdentifierTypes": "OpenMRS ID",
+ "report.encounterPrinting.header.personAttributes": "true",
+ "report.encounterPrinting.header.personAttributeTypes": "Telephone Number,Health District",
+ "report.encounterPrinting.header.visitAttributes": "true",
+ "report.encounterPrinting.header.visitAttributeTypes": "Payment Method",
+ "report.encounterPrinting.footer.customText": "Organization Name",
+ "report.encounterPrinting.stylesheet": "defaultEncounterFormFopStylesheet.xsl",
+ "report.encounterPrinting.logopath": "branding/logo.png"
+}
+```
+
+## REST API
+
+### Endpoints
+
+The module exposes REST endpoints for printing encounter forms:
+
+#### 1. Trigger Print Job
+```
+POST /openmrs/ws/rest/v1/patientdocuments/encounters
+```
+
+**Request Body:** Array of encounter UUIDs
+```json
+["encounter-uuid-1", "encounter-uuid-2"]
+```
+
+**Response:**
+```json
+{
+ "uuid": "report-request-uuid",
+ "requestUuid": "report-request-uuid",
+ "status": "QUEUED"
+}
+```
+
+#### 2. Check Print Job Status
+```
+GET /openmrs/ws/rest/v1/patientdocuments/encounters/status/{requestUuid}
+```
+
+**Response:**
+```json
+{
+ "uuid": "report-request-uuid",
+ "status": "COMPLETED"
+}
+```
+
+#### 3. Download PDF
+```
+GET /openmrs/ws/rest/v1/patientdocuments/encounters/download/{requestUuid}
+```
+
+**Response:** PDF file download
+
+### Required Privilege
+
+To use the encounter printing API, users must have the following privilege:
+
+| Privilege | Description |
+|-----------|-------------|
+| `App: Print encounter forms` | Required to trigger print jobs and download PDFs |
+
+This privilege should be assigned to users or roles that need to print encounter forms.
+
+## Notes
+
+- All boolean values should be strings `"true"` or `"false"` to maintain compatibility with properties-based parsers.
+- For patient identifiers, person attributes, and visit attributes, use the **type names** (e.g., "Patient ID", "Telephone Number") not UUIDs.
+- The customFooterText appears below the "Printed by..." line on every page.
+- Header fields are displayed in a two-column table layout.
+- When no configuration is provided, no header fields will be displayed.