diff --git a/nirc_ehr/resources/data/editable_lookups.tsv b/nirc_ehr/resources/data/editable_lookups.tsv index 8e2bf6df..90cc8af6 100644 --- a/nirc_ehr/resources/data/editable_lookups.tsv +++ b/nirc_ehr/resources/data/editable_lookups.tsv @@ -89,7 +89,7 @@ ehr_lookups observation_areas Clinical Observation Areas Clinical observation ar ehr_lookups obstetric_observations Clinical Obstetric Observations Obstetric Observations Fixed Values. ehr_lookups ocular_observations Clinical Ocular Observations Ocular Observations Fixed Values. ehr_lookups ocular_problem Clinical Ocular Problem Clinical observation fixed values. -ehr_lookups pairing_formation_types Pairing Formation Type value +ehr_lookups pairing_formation_types Behavior Pairing Formation Type Pairing formation values. ehr_lookups pairing_goal Behavior Pairing Goal Used in pairing dataset. ehr_lookups pairing_reason Behavior Introduction Reason Used in pairing dataset. ehr_lookups pairing_observation Behavior Interaction Summary Used in pairing dataset. diff --git a/nirc_ehr/resources/queries/study/aliases.sql b/nirc_ehr/resources/queries/study/aliases.sql index 4f517b41..829e8384 100644 --- a/nirc_ehr/resources/queries/study/aliases.sql +++ b/nirc_ehr/resources/queries/study/aliases.sql @@ -16,4 +16,3 @@ UNION SELECT Id, Alias as alias FROM study.alias where Id.demographics.calculated_status != 'Alive - In Progress' -' \ No newline at end of file diff --git a/nirc_ehr/resources/queries/study/treatmentSchedule.query.xml b/nirc_ehr/resources/queries/study/treatmentSchedule.query.xml index 9ff56692..91608544 100644 --- a/nirc_ehr/resources/queries/study/treatmentSchedule.query.xml +++ b/nirc_ehr/resources/queries/study/treatmentSchedule.query.xml @@ -2,6 +2,7 @@ + Treatment Schedule /EHR/treatmentDetails.view?key=${lsid} primaryKey diff --git a/nirc_ehr/resources/queries/study/treatmentSchedule.sql b/nirc_ehr/resources/queries/study/treatmentSchedule.sql index c8e64ae4..12d1eaf6 100644 --- a/nirc_ehr/resources/queries/study/treatmentSchedule.sql +++ b/nirc_ehr/resources/queries/study/treatmentSchedule.sql @@ -38,7 +38,6 @@ JOIN( timestampdiff('SQL_TSI_DAY', cast(t1.dateOnly AS timestamp), dr.dateOnly) + 1 AS daysElapsed, t1.enddate, t1.code, - t1.treatmentRecord, t1.volume, t1.vol_units, t1.concentration, diff --git a/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java b/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java index 83b753b3..31cb9df5 100644 --- a/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java +++ b/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java @@ -36,6 +36,8 @@ import org.labkey.api.ehr.security.EHRDataEntryPermission; import org.labkey.api.ehr.security.EHRVeterinarianPermission; import org.labkey.api.ehr.table.FixedWidthDisplayColumn; +import org.labkey.api.ehr.table.TreatmentLinkConfig; +import org.labkey.api.ehr.table.TreatmentLinkDisplayColumnFactory; import org.labkey.api.exp.api.StorageProvisioner; import org.labkey.api.exp.property.Domain; import org.labkey.api.gwt.client.FacetingBehaviorType; @@ -67,6 +69,10 @@ public class NIRC_EHRCustomizer extends AbstractTableCustomizer { + private static final TreatmentLinkConfig RECORD_TREATMENT = TreatmentLinkConfig.builder() + .formTypes("Behavior", "Behavioral Rounds", "Bulk Behavior Entry") + .build(); + public UserSchema getEHRUserSchema(AbstractTableInfo ds, String name) { Container ehrContainer = EHRService.get().getEHRStudyContainer(ds.getUserSchema().getContainer()); @@ -131,6 +137,11 @@ public void customize(TableInfo table) customizeTreatmentOrder(ti); } + if (matches(ti, "study", "treatmentSchedule")) + { + customizeTreatmentSchedule(ti); + } + if (matches(ti, "study", "prc_order")) { customizeProcedureOrder(ti); @@ -896,15 +907,17 @@ private void ensureSortColumn(AbstractTableInfo ti, ColumnInfo baseColumn) private void customizeHousingTable(AbstractTableInfo ti) { + // ehr_lookups.cage is unique on (Container, Location), so the cage subqueries below must be container-scoped; + // a second EHR folder defining the same location would otherwise make them return multiple rows. + Container lookupContainer = EHRService.get().getEHRStudyContainer(ti.getUserSchema().getContainer()); + if (lookupContainer == null) + lookupContainer = ti.getUserSchema().getContainer(); // as DefaultEHRCustomizer does + if (ti.getColumn("room") == null && ti.getColumn("cage") != null) { - UserSchema us = getUserSchema(ti, "ehr_lookups"); - if (us != null) - { - SQLFragment roomSql = new SQLFragment("(SELECT room FROM ehr_lookups.cage WHERE location = " + ExprColumn.STR_TABLE_ALIAS + ".cage)"); - ExprColumn roomCol = new ExprColumn(ti, "room", roomSql, JdbcType.VARCHAR, ti.getColumn("cage")); - ti.addColumn(roomCol); - } + SQLFragment roomSql = new SQLFragment("(SELECT room FROM ehr_lookups.cage WHERE Container = ? AND location = " + ExprColumn.STR_TABLE_ALIAS + ".cage)", lookupContainer); + ExprColumn roomCol = new ExprColumn(ti, "room", roomSql, JdbcType.VARCHAR, ti.getColumn("cage")); + ti.addColumn(roomCol); ensureSortColumn(ti, ti.getColumn("room")); } @@ -913,8 +926,8 @@ private void customizeHousingTable(AbstractTableInfo ti) TableInfo realTable = getRealTable(ti); if (realTable != null && realTable.getColumn("participantid") != null && realTable.getColumn("date") != null && realTable.getColumn("enddate") != null) { - SQLFragment roomSql = new SQLFragment(realTable.getSqlDialect().getDateDiff(Calendar.DATE, "{fn curdate()}", "COALESCE((SELECT max(h2.enddate) as d FROM " + realTable.getSelectName() + " h2 LEFT JOIN ehr_lookups.cage cg ON h2.cage = cg.location " + - "WHERE h2.enddate IS NOT NULL AND h2.enddate <= " + ExprColumn.STR_TABLE_ALIAS + ".date AND h2.participantid = " + ExprColumn.STR_TABLE_ALIAS + ".participantid AND cg.room != (SELECT room FROM ehr_lookups.cage WHERE location = " + ExprColumn.STR_TABLE_ALIAS + ".cage)), " + ExprColumn.STR_TABLE_ALIAS + ".date)")); + SQLFragment roomSql = new SQLFragment(realTable.getSqlDialect().getDateDiff(Calendar.DATE, "{fn curdate()}", "COALESCE((SELECT max(h2.enddate) as d FROM " + realTable.getSelectName() + " h2 LEFT JOIN ehr_lookups.cage cg ON h2.cage = cg.location AND cg.Container = ? " + + "WHERE h2.enddate IS NOT NULL AND h2.enddate <= " + ExprColumn.STR_TABLE_ALIAS + ".date AND h2.participantid = " + ExprColumn.STR_TABLE_ALIAS + ".participantid AND cg.room != (SELECT room FROM ehr_lookups.cage WHERE Container = ? AND location = " + ExprColumn.STR_TABLE_ALIAS + ".cage)), " + ExprColumn.STR_TABLE_ALIAS + ".date)"), lookupContainer, lookupContainer); ExprColumn roomCol = new ExprColumn(ti, "daysInRoom", roomSql, JdbcType.INTEGER, realTable.getColumn("participantid"), realTable.getColumn("date"), realTable.getColumn("enddate")); roomCol.setLabel("Days In Room"); ti.addColumn(roomCol); @@ -1065,88 +1078,18 @@ private void customizeTreatmentOrder(AbstractTableInfo ti) { WrappedColumn col = new WrappedColumn(ti.getColumn("objectid"), "treatmentRecord"); col.setLabel("Record Treatment"); - col.setDisplayColumnFactory(new DisplayColumnFactory() { - - @Override - public DisplayColumn createRenderer(final ColumnInfo colInfo) - { - return new DataColumn(colInfo){ - - @Override - public void renderGridCellContents(RenderContext ctx, HtmlWriter out) - { - String objectid = (String)getBoundColumn().getValue(ctx); - Date date = (Date)ctx.get("date"); - String caseid = (String)ctx.get("caseid"); - String category = (String)ctx.get("category"); - ActionURL url = new ActionURL("ehr", "dataEntryForm", ti.getUserSchema().getContainer()); - if (!ti.getUserSchema().getContainer().hasPermission(ti.getUserSchema().getUser(), EHRClinicalEntryPermission.class)) - return; - - if (category.equals("Behavior")) - { - if (caseid != null) - { - url.addParameter("formType", "Behavioral Rounds"); - url.addParameter("caseid", caseid); - } - else - { - url.addParameter("formType", "Bulk Behavior Entry"); - } - } - else - { - if (caseid != null) - { - url.addParameter("formType", "Clinical Rounds"); - url.addParameter("caseid", caseid); - } - else - { - url.addParameter("formType", "medicationTreatment"); - } - } - - url.addParameter("treatmentid", objectid); - url.addParameter("scheduledDate", date.toString()); - - String returnUrl = new ActionURL("ehr", "animalHistory", ti.getUserSchema().getContainer()) + "#inputType:none&showReport:0&activeReport:clinMedicationSchedule"; - url.addParameter("returnUrl", returnUrl); - - out.write(LinkBuilder.labkeyLink("Record Treatment", url).target("_blank")); - } - - @Override - public void addQueryFieldKeys(Set keys) - { - super.addQueryFieldKeys(keys); - keys.add(getBoundColumn().getFieldKey()); - keys.add(FieldKey.fromString("date")); - keys.add(FieldKey.fromString("caseid")); - keys.add(FieldKey.fromString("category")); - } - - @Override - public boolean isSortable() - { - return false; - } - - @Override - public boolean isFilterable() - { - return false; - } + col.setDisplayColumnFactory(TreatmentLinkDisplayColumnFactory.forOrder(RECORD_TREATMENT)); + ti.addColumn(col); + } + } - @Override - public boolean isEditable() - { - return false; - } - }; - } - }); + private void customizeTreatmentSchedule(AbstractTableInfo ti) + { + if (ti.getColumn("treatmentRecord") == null && ti.getColumn("objectid") != null) + { + WrappedColumn col = new WrappedColumn(ti.getColumn("objectid"), "treatmentRecord"); + col.setLabel("Record Treatment"); + col.setDisplayColumnFactory(TreatmentLinkDisplayColumnFactory.forSchedule(RECORD_TREATMENT)); ti.addColumn(col); } } diff --git a/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java b/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java index 6bf96be4..4cf09864 100644 --- a/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java +++ b/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java @@ -68,6 +68,9 @@ import java.io.BufferedReader; import java.io.File; import java.io.IOException; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; @@ -1572,6 +1575,129 @@ public void testBehavioralCases() Assert.assertEquals("Case was not closed", 1, activeCase.getDataRowCount()); } + // Verifies the URL rendered by the treatment link display column on both tables that carry it. The category to + // form type routing, the treatmentid parameter name and the return report are all configuration declared in + // NIRC_EHRCustomizer, so a typo there is otherwise invisible until a user clicks the link. The presence of + // scheduledDate is what separates the two tables: the schedule's date is the slot being recorded, while a + // treatment order's date is the order's start date, so passing it would make every recording against an order + // look like the first one. + @Test + public void testTreatmentRecordLinks() throws Exception + { + String animalId = "TRTLINK1"; + String behaviorCaseId = UUID.randomUUID().toString(); + String clinicalCaseId = UUID.randomUUID().toString(); + + // objectids are supplied rather than server-generated so each rendered link can be matched back to the order + // it came from without depending on grid row order. + String behaviorWithCase = UUID.randomUUID().toString(); + String behaviorNoCase = UUID.randomUUID().toString(); + String clinicalWithCase = UUID.randomUUID().toString(); + String surgicalNoCase = UUID.randomUUID().toString(); + + String orderStart = LocalDateTime.now().minusDays(1).format(_dateFormat); + String today = LocalDateTime.now().format(_dateFormat); + + goToEHRFolder(); + + log("Creating a live animal with one active treatment order per routing case being verified"); + getApiHelper().deleteAllRecords("study", "treatment_order", new Filter("Id", animalId)); + getApiHelper().deleteAllRecords("study", "demographics", new Filter("Id", animalId)); + + String[] demographicsFields = {"Id", "Species", "Birth", "Gender", "date", "calculated_status", "objectid", "performedby"}; + Object[][] demographicsData = {{animalId, "Rhesus", (new Date()).toString(), getMale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004}}; + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), getApiHelper().prepareInsertCommand("study", "demographics", "lsid", demographicsFields, demographicsData), getExtraContext()); + + // SID yields exactly one scheduled slot per order per day, at the 8:00 AM hourofday in + // treatment_frequency_times, so each order below contributes exactly one row to the schedule. + // Surgical is included because it has no routing of its own: it must fall through to the same forms as + // Clinical, proving the fallback is not keyed to the Clinical category. + String[] orderFields = {"Id", "date", "code", "frequency", "route", "category", "caseid", FIELD_QCSTATELABEL, FIELD_OBJECTID, FIELD_LSID, "_recordid", "performedby"}; + Object[][] orderData = { + {animalId, orderStart, "NIRC-001", "SID", "IV", "Behavior", behaviorCaseId, EHRQCState.COMPLETED.label, behaviorWithCase, null, "recordID1", 1004}, + {animalId, orderStart, "NIRC-001", "SID", "IV", "Behavior", null, EHRQCState.COMPLETED.label, behaviorNoCase, null, "recordID2", 1004}, + {animalId, orderStart, "NIRC-001", "SID", "IV", "Clinical", clinicalCaseId, EHRQCState.COMPLETED.label, clinicalWithCase, null, "recordID3", 1004}, + {animalId, orderStart, "NIRC-001", "SID", "IV", "Surgical", null, EHRQCState.COMPLETED.label, surgicalNoCase, null, "recordID4", 1004} + }; + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), getApiHelper().prepareInsertCommand("study", "treatment_order", "lsid", orderFields, orderData), getExtraContext()); + + log("Verifying the treatment order links, which must not pass a scheduled date"); + beginAt(String.format("%s/query-executeQuery.view?schemaName=study&query.queryName=treatment_order&query.columns=objectid,Id,category,caseid,treatmentRecord&query.Id~eq=%s", + getContainerPath(), animalId)); + DataRegionTable orderTable = new DataRegionTable("query", this); + assertEquals("Incorrect number of treatment orders", 4, orderTable.getDataRowCount()); + + Map> orderLinks = readTreatmentLinkParams(orderTable); + verifyTreatmentLink(orderLinks, behaviorWithCase, "a Behavior order with a case", "Behavioral Rounds", behaviorCaseId, null); + verifyTreatmentLink(orderLinks, behaviorNoCase, "a Behavior order with no case", "Bulk Behavior Entry", null, null); + verifyTreatmentLink(orderLinks, clinicalWithCase, "a Clinical order with a case", "Clinical Rounds", clinicalCaseId, null); + verifyTreatmentLink(orderLinks, surgicalNoCase, "a Surgical order with no case", "medicationTreatment", null, null); + + log("Verifying the treatment schedule links, which must pass the slot's own date as the scheduled date"); + beginAt(String.format("%s/query-executeQuery.view?schemaName=study&query.queryName=treatmentSchedule&query.columns=objectid,Id,category,caseid,date,treatmentRecord&query.Id~eq=%s&query.param.StartDate=%s", + getContainerPath(), animalId, today)); + DataRegionTable scheduleTable = new DataRegionTable("query", this); + assertEquals("Incorrect number of scheduled slots", 4, scheduleTable.getDataRowCount()); + + String expectedScheduledDate = today + " 08:00"; + Map> scheduleLinks = readTreatmentLinkParams(scheduleTable); + verifyTreatmentLink(scheduleLinks, behaviorWithCase, "a Behavior slot with a case", "Behavioral Rounds", behaviorCaseId, expectedScheduledDate); + verifyTreatmentLink(scheduleLinks, behaviorNoCase, "a Behavior slot with no case", "Bulk Behavior Entry", null, expectedScheduledDate); + verifyTreatmentLink(scheduleLinks, clinicalWithCase, "a Clinical slot with a case", "Clinical Rounds", clinicalCaseId, expectedScheduledDate); + verifyTreatmentLink(scheduleLinks, surgicalNoCase, "a Surgical slot with no case", "medicationTreatment", null, expectedScheduledDate); + + checker().screenShotIfNewError("treatmentRecordLinks"); + + // The URL assertions above cannot tell a correct form type name from a plausible misspelling, so open the one + // routing no other test reaches: a Behavior order with no case, which goes to the bulk entry form. + log("Verifying the Behavior no-case link opens the bulk entry form"); + scheduleTable.link(scheduleRowForOrder(scheduleTable, behaviorNoCase), "treatmentRecord").click(); + switchToWindow(1); + waitForText("Bulk Behavior Entry"); + waitForText(animalId); + switchToMainWindow(); + } + + // Maps the URL parameters of each rendered treatmentRecord link, keyed by the objectid of the row it was + // rendered from. + private Map> readTreatmentLinkParams(DataRegionTable table) + { + Map> byOrderId = new HashMap<>(); + for (int row = 0; row < table.getDataRowCount(); row++) + { + String href = table.link(row, "treatmentRecord").getAttribute("href"); + Assert.assertNotNull("Treatment link in row " + row + " has no href", href); + Map params = new HashMap<>(); + WebTestHelper.parseUrlQueryString(URI.create(href).getRawQuery()) + .forEach((key, value) -> params.put(key, value == null ? null : URLDecoder.decode(value, StandardCharsets.UTF_8))); + byOrderId.put(table.getDataAsText(row, "objectid"), params); + } + return byOrderId; + } + + private int scheduleRowForOrder(DataRegionTable table, String objectid) + { + int row = table.getColumnDataAsText("objectid").indexOf(objectid); + Assert.assertNotEquals("No schedule row for treatment order " + objectid, -1, row); + return row; + } + + // expectedCaseId and expectedScheduledDate are null when the parameter must be absent entirely. An empty value is + // a failure, not a pass: the link is meant to omit the parameter rather than send it blank. + private void verifyTreatmentLink(Map> linksByOrderId, String objectid, String scenario, + String expectedFormType, @Nullable String expectedCaseId, @Nullable String expectedScheduledDate) + { + Map params = linksByOrderId.get(objectid); + Assert.assertNotNull("No treatment link rendered for " + scenario, params); + + checker().verifyEquals("Incorrect formType for " + scenario, expectedFormType, params.get("formType")); + checker().verifyEquals("Incorrect caseid for " + scenario, expectedCaseId, params.get("caseid")); + checker().verifyEquals("Incorrect scheduledDate for " + scenario, expectedScheduledDate, params.get("scheduledDate")); + checker().verifyEquals("Incorrect treatmentid for " + scenario, objectid, params.get("treatmentid")); + checker().verifyTrue("returnUrl should return to the medication schedule report for " + scenario + ": " + params.get("returnUrl"), + params.get("returnUrl") != null && params.get("returnUrl").endsWith("activeReport:clinMedicationSchedule")); + } + private int countLines(File file) throws Exception { try (BufferedReader reader = Readers.getReader(file))