From 55a38959a1c27e5d19c7e9b8e95e4a23c371fa38 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 28 Jul 2026 21:06:41 -0700 Subject: [PATCH 1/4] Scope cage lookups to the EHR study container in housing tables (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale Scope the housing cage lookups to a single container so housing grids, and the queries built on them, keep working on a server with more than one EHR folder. The cage lookup is keyed by container and location together, but the room and days-in-room columns matched on location alone, so a second EHR folder defining the same cage location makes those subqueries match more than one row and the query returns a database error instead of results. Production installations run a single EHR folder per server, so the effect is limited to test and development environments where several EHR folders coexist. ## Related Pull Requests - LabKey/nbriEHRModules, branch `26.7_fb_cage_container_scope` — the identical fix in the NBRI customizer. ## Changes - Scope every cage lookup behind the housing room and days-in-room columns to a single container, preferring the EHR study container and falling back to the current one when it is not configured. - Fix a null dereference that could occur while building the room sort field. --- .../nirc_ehr/table/NIRC_EHRCustomizer.java | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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..c7e86541 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 @@ -896,15 +896,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 +915,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); From 7fd852786bfb953ded7f093368c143f85407f484 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 28 Jul 2026 21:18:26 -0700 Subject: [PATCH 2/4] Fix Record Treatment link to pass schedule slot date as scheduledDate (#730) ## Rationale The Record Treatment link on study.treatment_order passed the order's start date as the scheduledDate URL parameter, so every treatment recorded through it carried the same scheduledDate regardless of which schedule slot was being recorded. The second recording against an order then tripped the duplicate-treatment trigger in study/drug.js ("A treatment has already been entered for this order for this date and time.") while the treatmentSchedule grid still showed the slot as unrecorded, since its status join compares the computed slot time against the stored scheduledDate. ## Related Pull Requests - https://github.com/LabKey/johnsHopkinsEHRModules/pull/667 (same fix for jhu_ehr) ## Changes - Extract the inline Record Treatment display column into TreatmentDisplayColumnFactory with an includeScheduledDate flag; emit scheduledDate only when set, ISO-formatted via DateUtil.formatIsoDateShortTime() instead of Date.toString(); add a null guard on category. - The treatment_order Record Treatment link no longer passes scheduledDate; new customizeTreatmentSchedule() adds a treatmentRecord link column to study.treatmentSchedule that passes the slot's date. - treatmentSchedule.sql: drop the t1.treatmentRecord passthrough column inherited from treatment_order. - treatmentSchedule.query.xml: apply the module customizer via javaCustomizer so the new column is added. --- nirc_ehr/resources/data/editable_lookups.tsv | 2 +- .../queries/study/treatmentSchedule.query.xml | 1 + .../queries/study/treatmentSchedule.sql | 1 - .../nirc_ehr/table/NIRC_EHRCustomizer.java | 97 +++---------- .../table/TreatmentDisplayColumnFactory.java | 130 ++++++++++++++++++ 5 files changed, 148 insertions(+), 83 deletions(-) create mode 100644 nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java 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/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 c7e86541..d2d5777b 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 @@ -131,6 +131,11 @@ public void customize(TableInfo table) customizeTreatmentOrder(ti); } + if (matches(ti, "study", "treatmentSchedule")) + { + customizeTreatmentSchedule(ti); + } + if (matches(ti, "study", "prc_order")) { customizeProcedureOrder(ti); @@ -1067,88 +1072,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(new TreatmentDisplayColumnFactory(false)); + 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(new TreatmentDisplayColumnFactory(true)); ti.addColumn(col); } } diff --git a/nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java b/nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java new file mode 100644 index 00000000..56ffb38b --- /dev/null +++ b/nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.nirc_ehr.table; + +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.DataColumn; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.DisplayColumnFactory; +import org.labkey.api.data.RenderContext; +import org.labkey.api.ehr.security.EHRClinicalEntryPermission; +import org.labkey.api.query.FieldKey; +import org.labkey.api.util.DateUtil; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.view.ActionURL; +import org.labkey.api.writer.HtmlWriter; + +import java.util.Date; +import java.util.Set; + +/** + * Display column factory for creating Record Treatment links. When includeScheduledDate is set, the row's date is + * passed as the scheduledDate URL parameter, so it should only be set on tables whose date column is the scheduled + * slot being recorded (e.g. treatmentSchedule), not the treatment order's start date. + */ +public class TreatmentDisplayColumnFactory implements DisplayColumnFactory +{ + private final boolean _includeScheduledDate; + + public TreatmentDisplayColumnFactory(boolean includeScheduledDate) + { + _includeScheduledDate = includeScheduledDate; + } + + @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", colInfo.getParentTable().getUserSchema().getContainer()); + if (!colInfo.getParentTable().getUserSchema().getContainer().hasPermission(colInfo.getParentTable().getUserSchema().getUser(), EHRClinicalEntryPermission.class)) + return; + + if (category == null) + 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); + if (_includeScheduledDate && date != null) + url.addParameter("scheduledDate", DateUtil.formatIsoDateShortTime(date)); + + String returnUrl = new ActionURL("ehr", "animalHistory", colInfo.getParentTable().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; + } + + @Override + public boolean isEditable() + { + return false; + } + }; + } +} From 44b1091304df75719dc0ba857ff053163f17fe9f Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 6 Aug 2026 04:32:01 -0700 Subject: [PATCH 3/4] Use shared treatment link display column factory (#736) ## Rationale Adopt the shared treatment link renderer so this module no longer carries its own copy. The renderer was duplicated across three centers, which meant a recent fix to how the scheduled date is passed had to be repeated here. This depends on the ehrModules change linked below and does not compile until that merges. ## Changes - Replaces the local treatment link display column with the shared one from ehrModules. - Declares this center's Behavior treatment routing as configuration; every other category takes the shared defaults, so the rendered links are unchanged. - Adds test coverage for the form each treatment category routes to, and for the scheduled date being passed from the treatment schedule but not from the treatment order. --- .../nirc_ehr/table/NIRC_EHRCustomizer.java | 10 +- .../table/TreatmentDisplayColumnFactory.java | 130 ------------------ .../tests.nirc_ehr/NIRC_EHRTest.java | 126 +++++++++++++++++ 3 files changed, 134 insertions(+), 132 deletions(-) delete mode 100644 nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java 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 d2d5777b..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()); @@ -1072,7 +1078,7 @@ private void customizeTreatmentOrder(AbstractTableInfo ti) { WrappedColumn col = new WrappedColumn(ti.getColumn("objectid"), "treatmentRecord"); col.setLabel("Record Treatment"); - col.setDisplayColumnFactory(new TreatmentDisplayColumnFactory(false)); + col.setDisplayColumnFactory(TreatmentLinkDisplayColumnFactory.forOrder(RECORD_TREATMENT)); ti.addColumn(col); } } @@ -1083,7 +1089,7 @@ private void customizeTreatmentSchedule(AbstractTableInfo ti) { WrappedColumn col = new WrappedColumn(ti.getColumn("objectid"), "treatmentRecord"); col.setLabel("Record Treatment"); - col.setDisplayColumnFactory(new TreatmentDisplayColumnFactory(true)); + col.setDisplayColumnFactory(TreatmentLinkDisplayColumnFactory.forSchedule(RECORD_TREATMENT)); ti.addColumn(col); } } diff --git a/nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java b/nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java deleted file mode 100644 index 56ffb38b..00000000 --- a/nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (c) 2026 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.nirc_ehr.table; - -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.DataColumn; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.DisplayColumnFactory; -import org.labkey.api.data.RenderContext; -import org.labkey.api.ehr.security.EHRClinicalEntryPermission; -import org.labkey.api.query.FieldKey; -import org.labkey.api.util.DateUtil; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.view.ActionURL; -import org.labkey.api.writer.HtmlWriter; - -import java.util.Date; -import java.util.Set; - -/** - * Display column factory for creating Record Treatment links. When includeScheduledDate is set, the row's date is - * passed as the scheduledDate URL parameter, so it should only be set on tables whose date column is the scheduled - * slot being recorded (e.g. treatmentSchedule), not the treatment order's start date. - */ -public class TreatmentDisplayColumnFactory implements DisplayColumnFactory -{ - private final boolean _includeScheduledDate; - - public TreatmentDisplayColumnFactory(boolean includeScheduledDate) - { - _includeScheduledDate = includeScheduledDate; - } - - @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", colInfo.getParentTable().getUserSchema().getContainer()); - if (!colInfo.getParentTable().getUserSchema().getContainer().hasPermission(colInfo.getParentTable().getUserSchema().getUser(), EHRClinicalEntryPermission.class)) - return; - - if (category == null) - 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); - if (_includeScheduledDate && date != null) - url.addParameter("scheduledDate", DateUtil.formatIsoDateShortTime(date)); - - String returnUrl = new ActionURL("ehr", "animalHistory", colInfo.getParentTable().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; - } - - @Override - public boolean isEditable() - { - return false; - } - }; - } -} 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)) From 86290dc429b6efbef1e35558d4f2772eaff8e64c Mon Sep 17 00:00:00 2001 From: Binal Patel Date: Thu, 6 Aug 2026 13:49:12 -0600 Subject: [PATCH 4/4] Remove stray quote from NIRC study.aliases query (#740) --- nirc_ehr/resources/queries/study/aliases.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/nirc_ehr/resources/queries/study/aliases.sql b/nirc_ehr/resources/queries/study/aliases.sql index 2652d6d0..904ff059 100644 --- a/nirc_ehr/resources/queries/study/aliases.sql +++ b/nirc_ehr/resources/queries/study/aliases.sql @@ -11,4 +11,3 @@ UNION SELECT Id, Alias as alias FROM study.alias where Id.demographics.calculated_status != 'Alive - In Progress' -' \ No newline at end of file