From 58853748d06bfd0319f86e94d7f742906955ce10 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Wed, 2 Sep 2026 03:15:22 -0700 Subject: [PATCH 1/4] Derive observation type from its category and fix cagemate matching (#29) ## Rationale Two independent fixes, to observation entry and to cagemate matching. The Observations form let a user pick any observation type but stored every entry as Clinical, so behavior observations recorded there were filed as clinical and dropped out of the behavior views; the form cannot know the right value up front because it depends on which type the user picks for each row, so the type is now derived on save from that type's category. The behavior forms had the same mismatch from the other direction: their Daily Observations shortcut bypassed the type-filtered picker and wrote clinical-category observations labeled as behavior. Separately, the cagemates query treated a housing row with no cage as though it were a location, so every animal whose location had not resolved was reported as a cagemate of every other. Rows already saved with a mismatched observation type need a one-time data fix; this change only affects new entries. ## Changes - The Observations form no longer defaults an observation's type. The trigger script derives it from the selected observation type's category, while every other form continues to set the type explicitly, including scheduled entries that inherit it from their order. - The Daily Observations shortcut is now opt-in per form section rather than always present, so it appears only on the clinical forms. - Cagemate matching now keys off active housing rows with a known cage. --- .../queries/study/clinical_observations.js | 7 ++ .../queries/study/demographicsCagemates.sql | 6 +- .../nbri_ehr/buttons/clinicalObsGridButton.js | 59 ------------- .../web/nbri_ehr/model/sources/ObsDefaults.js | 7 ++ .../form/NBRIBehaviorRoundsFormType.java | 2 +- .../form/NBRIBehavioralCasesFormType.java | 2 +- .../form/NBRIBulkBehaviorFormType.java | 4 +- .../form/NBRIBulkClinicalFormType.java | 2 +- .../dataentry/form/NBRICasesFormType.java | 2 +- .../NBRIClinicalObservationsFormType.java | 2 +- .../form/NBRIClinicalRoundsFormType.java | 2 +- .../NBRIClinicalObservationsFormSection.java | 18 ++-- .../NBRIObservationOrdersFormSection.java | 1 - .../nbri_ehr/query/NBRI_EHRTriggerHelper.java | 21 +++++ .../tests.nbri_ehr/NBRI_EHRTest.java | 85 +++++++++++++++++++ 15 files changed, 139 insertions(+), 81 deletions(-) delete mode 100644 nbri_ehr/resources/web/nbri_ehr/buttons/clinicalObsGridButton.js diff --git a/nbri_ehr/resources/queries/study/clinical_observations.js b/nbri_ehr/resources/queries/study/clinical_observations.js index 9e54948..ddafdc5 100644 --- a/nbri_ehr/resources/queries/study/clinical_observations.js +++ b/nbri_ehr/resources/queries/study/clinical_observations.js @@ -45,6 +45,13 @@ function onUpsert(helper, scriptErrors, row, oldRow) { EHR.Server.Utils.addError(scriptErrors, 'remark', "You selected 'Yes' for " + row.category + ", please explain in the Remark", "WARN"); } + // Always derive the type from the observation type's category rather than trusting the incoming value. + // The Observations form leaves it blank because it offers every type; the other forms set it explicitly, + // but their type pickers are filtered to the categories that agree with the value they set, so deriving + // here gives them the same answer. Deriving unconditionally also re-derives when a re-opened draft or a + // saved template carries a type left over from a different category. + row.type = triggerHelper.getObservationTypeCategory(row.category) === 'Behavior' ? 'Behavior' : 'Clinical'; + // Handle scheduled observations if (!helper.isValidateOnly() && row.scheduledDate) { var qc; diff --git a/nbri_ehr/resources/queries/study/demographicsCagemates.sql b/nbri_ehr/resources/queries/study/demographicsCagemates.sql index 0450dc2..4be95d9 100644 --- a/nbri_ehr/resources/queries/study/demographicsCagemates.sql +++ b/nbri_ehr/resources/queries/study/demographicsCagemates.sql @@ -25,10 +25,12 @@ JOIN study.housing h2 -- cagemates. Room is not consulted, since it is derived from this same id and so can never distinguish two rows. ON (h.cage = h2.cage AND h2.Id.demographics.calculated_status = 'Alive' - AND h2.enddateTimeCoalesced >= now() + AND h2.isActive = true AND h2.qcstate.publicdata = true) -WHERE h.enddateTimeCoalesced >= now() +-- a null location never resolved, so the row gets no cagemates rather than grouping with every other unresolved row +WHERE h.cage IS NOT NULL +AND h.isActive = true AND h.qcstate.publicdata = true GROUP BY h.id, h.cage diff --git a/nbri_ehr/resources/web/nbri_ehr/buttons/clinicalObsGridButton.js b/nbri_ehr/resources/web/nbri_ehr/buttons/clinicalObsGridButton.js deleted file mode 100644 index be6c858..0000000 --- a/nbri_ehr/resources/web/nbri_ehr/buttons/clinicalObsGridButton.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2026 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -EHR.DataEntryUtils.registerGridButton('NBRI_AUTO_POPULATE_DAILY_OBS', function(config){ - return Ext4.Object.merge({ - text: 'Auto Populate Clinical Obs', - xtype: 'button', - hidden: true, - listeners: { - render: function(btn){ - const id = LABKEY.ActionURL.getParameter('id'); - const caseid = LABKEY.ActionURL.getParameter('caseid'); - const scheduledDate = LABKEY.ActionURL.getParameter('scheduledDate'); - const scheduled = id && caseid && scheduledDate; - - LABKEY.Query.selectRows({ - schemaName: 'ehr', - queryName: 'observation_types', - ignoreFilter: true, - success: function (results) { - var grid = btn.up('gridpanel'); - if (grid?.store?.data?.getCount() === 0) { - if (results?.rows?.length > 0) { - for (var i = 0; i < results.rows.length; i++) { - var row = results.rows[i]; - if (row.value === 'Verified Id?' || row.value === 'Stool' || row.value === 'Activity' || - row.value === 'Appetite' || row.value === 'Hydration' || row.value === 'BCS') { - - var newRecord = grid.store.createModel({}); - newRecord.set({ - category: row.value, - }); - - if (scheduled) { - newRecord.set('Id', id); - newRecord.set('caseid', caseid); - newRecord.set('scheduledDate', scheduledDate); - } - grid.store.add(newRecord); - } - } - - if (scheduled) { - this.addEvents('animalchange'); - this.enableBubble('animalchange'); - this.fireEvent('animalchange', id); - grid.fireEvent('panelDataChange'); - } - } - } - }, - scope: this - }); - } - } - }, config); -}); \ No newline at end of file diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/ObsDefaults.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/ObsDefaults.js index bd9f4ce..3569d57 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/ObsDefaults.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/ObsDefaults.js @@ -6,6 +6,13 @@ EHR.model.DataModelManager.registerMetadata('ObsDefaults', { byQuery: { 'study.clinical_observations': { + // This form offers every observation type, so it can't know the observation's type up front. + // Clearing the default inherited from ClinicalDefaults lets the trigger script derive it + // from the selected type's category. + type: { + hidden: true, + defaultValue: null + }, category: { lookup: { columns: 'value,description', diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehaviorRoundsFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehaviorRoundsFormType.java index b85e71a..e969398 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehaviorRoundsFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehaviorRoundsFormType.java @@ -46,7 +46,7 @@ public NBRIBehaviorRoundsFormType(DataEntryFormContext ctx, Module owner) new NBRIAnimalDetailsFormSection(), new NBRICaseTemplateFormSection("Case Template", "Case Template", "nbri_ehr-casetemplatepanel", Arrays.asList(ClientDependency.supplierFromPath("nbri_ehr/panel/CaseTemplatePanel.js"))), new NBRICasesFormPanelSection("Behavior Case", ctx, true), - new NBRIClinicalObservationsFormSection(true, "cases"), + new NBRIClinicalObservationsFormSection(null, true, "cases"), new NBRITreatmentGivenFormSection(true, "cases") )); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehavioralCasesFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehavioralCasesFormType.java index 3bcc93a..d0bc2c5 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehavioralCasesFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBehavioralCasesFormType.java @@ -50,7 +50,7 @@ public NBRIBehavioralCasesFormType(DataEntryFormContext ctx, Module owner) new NBRICaseTemplateFormSection("Case Template", "Case Template", "nbri_ehr-casetemplatepanel", Arrays.asList(ClientDependency.supplierFromPath("nbri_ehr/panel/CaseTemplatePanel.js"))), new NBRICasesFormPanelSection("Behavior Case", ctx, true), new NBRIClinicalRemarksFormPanelSection(true, "cases", "Behavior Assessment", ctx, true), - new NBRIClinicalObservationsFormSection(true, "cases"), + new NBRIClinicalObservationsFormSection(null, true, "cases"), new NBRIObservationOrdersFormSection(null, true, "cases"), new NBRITreatmentGivenFormSection(true, "cases"), new NBRITreatmentOrderFormSection(true, "cases") diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkBehaviorFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkBehaviorFormType.java index 35c87c0..0603876 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkBehaviorFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkBehaviorFormType.java @@ -45,8 +45,8 @@ public NBRIBulkBehaviorFormType(DataEntryFormContext ctx, Module owner) new NBRIClinicalRemarksFormSection("Behavior Assessment", ctx.getContainer().hasPermission(ctx.getUser(), NBRIEHRVetTechPermission.class), ctx.getContainer().hasPermission(ctx.getUser(), EHRVeterinarianPermission.class), ctx.getContainer().hasPermission(ctx.getUser(), AdminPermission.class)), - new NBRIClinicalObservationsFormSection(false, null), - new NBRIObservationOrdersFormSection("NBRI_DAILY_CLINICAL_OBS_ORDERS", false, null), + new NBRIClinicalObservationsFormSection(null, false, null), + new NBRIObservationOrdersFormSection(null, false, null), new NBRITreatmentGivenFormSection(), new NBRITreatmentOrderFormSection() )); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkClinicalFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkClinicalFormType.java index a4317ff..e53b02e 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkClinicalFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIBulkClinicalFormType.java @@ -51,7 +51,7 @@ public NBRIBulkClinicalFormType(DataEntryFormContext ctx, Module owner) ctx.getContainer().hasPermission(ctx.getUser(), EHRVeterinarianPermission.class), ctx.getContainer().hasPermission(ctx.getUser(), AdminPermission.class)), new NBRIWeightFormSection(true, true), - new NBRIClinicalObservationsFormSection(false, null), + new NBRIClinicalObservationsFormSection("NBRI_DAILY_CLINICAL_OBS", false, null), new NBRIObservationOrdersFormSection("NBRI_DAILY_CLINICAL_OBS_ORDERS", false, null), new NBRIProcedureFormSection(), new NBRIProcedureOrderFormSection(), diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRICasesFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRICasesFormType.java index 04c3292..5360f51 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRICasesFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRICasesFormType.java @@ -58,7 +58,7 @@ public NBRICasesFormType(DataEntryFormContext ctx, Module owner) new NBRICasesFormPanelSection("Clinical Case", ctx, false), new NBRIClinicalRemarksFormPanelSection(true, "cases", "Clinical Remarks", ctx, false), new NBRIWeightFormSection(true, false, true, "cases"), - new NBRIClinicalObservationsFormSection(true, "cases"), + new NBRIClinicalObservationsFormSection("NBRI_DAILY_CLINICAL_OBS", true, "cases"), new NBRIObservationOrdersFormSection(null, true, "cases"), new NBRIProcedureFormSection(true, "cases"), new NBRIProcedureOrderFormSection(true, "cases"), diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalObservationsFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalObservationsFormType.java index 9a2094f..609f46c 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalObservationsFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalObservationsFormType.java @@ -37,7 +37,7 @@ public NBRIClinicalObservationsFormType(DataEntryFormContext ctx, Module owner) super(ctx, owner, NAME, NAME, "Clinical", Arrays.asList( new NBRITaskFormSection(), new NBRIAnimalDetailsFormSection(), - new NBRIClinicalObservationsFormSection(false, false), + new NBRIClinicalObservationsFormSection("NBRI_DAILY_CLINICAL_OBS", false), new NBRIWeightFormSection(true, true) )); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalRoundsFormType.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalRoundsFormType.java index 2864256..b3ac23b 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalRoundsFormType.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/form/NBRIClinicalRoundsFormType.java @@ -50,7 +50,7 @@ public NBRIClinicalRoundsFormType(DataEntryFormContext ctx, Module owner) new NBRICaseTemplateFormSection("Case Template", "Case Template", "nbri_ehr-casetemplatepanel", Arrays.asList(ClientDependency.supplierFromPath("nbri_ehr/panel/CaseTemplatePanel.js"))), new NBRICasesFormPanelSection("Clinical Case", ctx, false), new NBRIWeightFormSection(true, false, true, "cases"), - new NBRIClinicalObservationsFormSection(true, "cases"), + new NBRIClinicalObservationsFormSection("NBRI_DAILY_CLINICAL_OBS", true, "cases"), new NBRIProcedureFormSection(true, "cases"), new NBRITreatmentGivenFormSection(true, "cases"), new NBRIVitalsFormSection(true, "cases"), diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIClinicalObservationsFormSection.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIClinicalObservationsFormSection.java index b435c34..a9b95fb 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIClinicalObservationsFormSection.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIClinicalObservationsFormSection.java @@ -22,24 +22,23 @@ public class NBRIClinicalObservationsFormSection extends BaseFormSection { public static final String LABEL = "Observations"; - private boolean _autoPopulateDailyObs; + private final String _dailyObsOption; - public NBRIClinicalObservationsFormSection(boolean autoPopulateDailyObs, boolean initCollapsed) + public NBRIClinicalObservationsFormSection(String dailyObsOption, boolean initCollapsed) { super("study", "clinical_observations", LABEL, "ehr-clinicalobservationgridpanel", true, initCollapsed, true); - _autoPopulateDailyObs = autoPopulateDailyObs; + _dailyObsOption = dailyObsOption; addClientDependency(ClientDependency.supplierFromPath("ehr/plugin/ClinicalObservationsCellEditing.js")); addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/data/ClinicalObservationClientStore.js")); addClientDependency(ClientDependency.supplierFromPath("ehr/grid/ClinicalObservationGridPanel.js")); - addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/buttons/clinicalObsGridButton.js")); addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/buttons/addClinicalObsButton.js")); setClientStoreClass("NBRI_EHR.data.ClinicalObservationsClientStore"); } - public NBRIClinicalObservationsFormSection(boolean isChild, String parentQueryName) + public NBRIClinicalObservationsFormSection(String dailyObsOption, boolean isChild, String parentQueryName) { - this(false, true); + this(dailyObsOption, true); if (isChild && null != parentQueryName) { @@ -57,12 +56,9 @@ public List getTbarButtons() { List defaults = super.getTbarButtons(); - if (_autoPopulateDailyObs) + if (_dailyObsOption != null) { - defaults.add("NBRI_AUTO_POPULATE_DAILY_OBS"); - } - else { - defaults.add("NBRI_DAILY_CLINICAL_OBS"); + defaults.add(_dailyObsOption); } return defaults; diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIObservationOrdersFormSection.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIObservationOrdersFormSection.java index 9a2171e..82de7de 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIObservationOrdersFormSection.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIObservationOrdersFormSection.java @@ -32,7 +32,6 @@ public NBRIObservationOrdersFormSection(String dailyObsOption, boolean initColla _dailyObsOption = dailyObsOption; addClientDependency(ClientDependency.supplierFromPath("ehr/plugin/ClinicalObservationsCellEditing.js")); addClientDependency(ClientDependency.supplierFromPath("ehr/grid/ClinicalObservationGridPanel.js")); - addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/buttons/clinicalObsGridButton.js")); addClientDependency(ClientDependency.supplierFromPath("nbri_ehr/buttons/addClinicalObsButton.js")); setClientStoreClass("NBRI_EHR.data.ObsOrdersClientStore"); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java b/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java index d88b672..b1ed437 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java @@ -80,6 +80,7 @@ public class NBRI_EHRTriggerHelper private User _user; private static final Logger _log = LogManager.getLogger(NBRI_EHRTriggerHelper.class); private final Map _cachedDrugFormulary = new HashMap<>(); + private final Map _cachedObservationTypeCategories = new HashMap<>(); // Maps an originating observation order's taskid to the task its scheduled observations are grouped under, // for the duration of a single save batch (the same helper instance is reused across rows in the batch). @@ -924,6 +925,26 @@ public void ensureDailyClinicalObservationOrders(String id, String caseid, final } } + /** + * Returns the category of an observation type from ehr.observation_types, or null when the type has no + * category or is not found. Cached for the life of the save batch. + */ + public String getObservationTypeCategory(String observationType) + { + if (observationType == null) + return null; + + if (!_cachedObservationTypeCategories.containsKey(observationType)) + { + TableInfo ti = getTableInfo("ehr", "observation_types"); + SimpleFilter filter = new SimpleFilter(FieldKey.fromString("value"), observationType); + List categories = new TableSelector(ti, Collections.singleton("category"), filter, null).getArrayList(String.class); + _cachedObservationTypeCategories.put(observationType, categories.isEmpty() ? null : categories.get(0)); + } + + return _cachedObservationTypeCategories.get(observationType); + } + // This helper function propagates clinical observations through clinical cases public Map handleScheduledObservations(Map row, String qcstate, String orderTasks) throws SQLException, BatchValidationException, QueryUpdateServiceException, DuplicateKeyException { diff --git a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java index 9fbbae2..b93f92e 100644 --- a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java +++ b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java @@ -111,6 +111,9 @@ public class NBRI_EHRTest extends AbstractGenericEHRTest implements PostgresOnly // Dedicated animal for testScheduledObservationTaskGrouping; provisioned (alive, housed, assigned) in // createTestSubjects so the clinical case form raises no warnings that would keep the validation banner up. private static final String taskGroupAnimalId = "TESTGRP9090"; + // Dedicated animal for testObservationTypeDerivedFromCategory; provisioned the same way so the Observations + // form can be submitted final in one step. + private static final String obsTypeAnimalId = "TESTOBSTYPE9191"; // Rooms are keyed by building and name, so every room fixture needs a building to hang off of. // 'buildings' derives its key from the description, and 'SPF' is one of the areas seeded with the ehr_lookups schema. @@ -575,6 +578,32 @@ protected void createTestSubjects() throws Exception getApiHelper().deleteAllRecords("study", "Assignment", new Filter("Id", taskGroupAnimalId)); getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + // Fully provision the observation-type test animal for the same reason. + log("Creating observation type test subject"); + fields = new String[]{"Id", "Species", "Birth", "Gender", "date", "calculated_status", "objectid", "performedby"}; + data = new Object[][]{ + {obsTypeAnimalId, "MMU", (new Date()).toString(), getMale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004} + }; + insertCommand = getApiHelper().prepareInsertCommand("study", "demographics", "lsid", fields, data); + getApiHelper().deleteAllRecords("study", "demographics", new Filter("Id", obsTypeAnimalId)); + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + + fields = new String[]{"Id", "date", "enddate", "room", "cage", "performedby"}; + data = new Object[][]{ + {obsTypeAnimalId, pastDate1, null, getRooms()[0], CAGE_IN_R1, 1004} + }; + insertCommand = getApiHelper().prepareInsertCommand("study", "Housing", "lsid", fields, data); + getApiHelper().deleteAllRecords("study", "Housing", new Filter("Id", obsTypeAnimalId)); + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + + fields = new String[]{"Id", "date", "enddate", "project", "performedby"}; + data = new Object[][]{ + {obsTypeAnimalId, pastDate1, null, PROJECTS[0], 1004} + }; + insertCommand = getApiHelper().prepareInsertCommand("study", "Assignment", "lsid", fields, data); + getApiHelper().deleteAllRecords("study", "Assignment", new Filter("Id", obsTypeAnimalId)); + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), insertCommand, getExtraContext()); + primeCaches(); } @@ -1315,7 +1344,13 @@ public void testScheduledObservationTaskGrouping() Map entriesPerCategory = new HashMap<>(); for (Map row : getClinicalObservations(animalId)) + { entriesPerCategory.merge(String.valueOf(row.get("category")), 1, Integer::sum); + // A scheduled observation takes its type from the originating order, which the daily clinical + // observation orders create as Clinical. + Assert.assertEquals("Scheduled observation for category " + row.get("category") + " should be Clinical", + "Clinical", String.valueOf(row.get("type"))); + } Assert.assertEquals("Expected the six daily observation categories", NBRI_DAILY_OBS_VALUES.size(), entriesPerCategory.size()); entriesPerCategory.forEach((category, count) -> Assert.assertEquals("Expected two entries (one per matching order) for category " + category, Integer.valueOf(2), count)); @@ -1407,6 +1442,56 @@ private int countObservationsForTask(String taskId) return executeSelectRowCommand("study", "clinical_observations", ContainerFilter.Current, "/" + getContainerPath(), List.of(new Filter("taskid", taskId))).getRowCount().intValue(); } + // Two ehr.observation_types values on either side of the derivation: the first has no category, the second + // is categorized as Behavior. Both use a free-text Observation/Score editor, so neither depends on an + // ehr_lookups value list being populated. + private static final String UNCATEGORIZED_OBS_TYPE = "Mass"; + private static final String BEHAVIOR_OBS_TYPE = "General Behavior Observation"; + + @Test + public void testObservationTypeDerivedFromCategory() + { + String animalId = obsTypeAnimalId; + + // The Observations form offers every observation type, so it cannot set the observation's type up + // front; the trigger script derives it from the selected type's category. A type categorized as + // Behavior must be stored as a Behavior observation and everything else as Clinical, otherwise the + // entry drops out of the behavior views (study.behaviorObservations filters on type = 'Behavior'). + log("Entering an uncategorized and a Behavior-categorized observation type on the Observations form"); + gotoEnterData(); + waitAndClickAndWait(Locator.linkWithText("Observations")); + + Ext4GridRef observations = _helper.getExt4GridForFormSection("Observations"); + addObservationRow(observations, animalId, UNCATEGORIZED_OBS_TYPE, "3 cm mass on left arm"); + addObservationRow(observations, animalId, BEHAVIOR_OBS_TYPE, "Pacing observed"); + submitForm("Submit Final", "Finalize"); + + Map typeByCategory = new HashMap<>(); + for (Map row : getClinicalObservations(animalId)) + typeByCategory.put(String.valueOf(row.get("category")), String.valueOf(row.get("type"))); + + Assert.assertEquals("Expected exactly the two entered observations for " + animalId, + Set.of(UNCATEGORIZED_OBS_TYPE, BEHAVIOR_OBS_TYPE), typeByCategory.keySet()); + Assert.assertEquals("An uncategorized observation type should be stored as a Clinical observation", + "Clinical", typeByCategory.get(UNCATEGORIZED_OBS_TYPE)); + Assert.assertEquals("A Behavior-categorized observation type should be stored as a Behavior observation", + "Behavior", typeByCategory.get(BEHAVIOR_OBS_TYPE)); + } + + // Appends a row to an Observations grid and fills in the fields the trigger script needs to accept it: an + // animal, an observation type (the grid's "category"), and an Observation/Score plus remark, since an entry + // with neither raises a WARN that would disable Submit Final. The row index is read back from the grid + // rather than assumed, so this works whether or not the form starts with rows of its own. + private void addObservationRow(Ext4GridRef observations, String animalId, String category, String observation) + { + _helper.addRecordToGrid(observations); + int row = observations.getRowCount(); + observations.setGridCell(row, "Id", animalId); + observations.setGridCell(row, "category", category); + observations.setGridCell(row, "observation", observation); + observations.setGridCellJS(row, "remark", "remark for " + category); + } + @Test public void testObservationBulkEdit() { From c90f52b8dee02540207a4f0c11ab179dbd68f055 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 4 Sep 2026 12:17:14 -0700 Subject: [PATCH 2/4] Fix death notification timing, event time-of-day, and the Assign To dropdown (#33) ## Rationale This branch corrects three defects in the death and birth/arrival entry forms: the death notification never fired for a death entered alongside its necropsy, birth and arrival events were stored at midnight regardless of the time entered, and reopening the Submit For Review window rendered a second Assign To dropdown. The notification and the procedure-order update were gated on the record landing on 'Request: Pending', which only happens by way of 'Submit Death'. A death entered together with its necropsy goes straight to 'Review Required' or 'Completed' and so notified nobody. This widens the trigger to any first save that leaves draft, which means an API or bulk load inserting deaths already at 'Completed' will now send one notification per animal. ## Related Pull Requests - Notify and complete procedure orders on the first save that leaves draft, rather than only when the record lands on 'Request: Pending'. - Ignore deletes in the death trigger, which previously read a deleted row as a draft leaving draft. - Keep the time of day on birth and arrival events and on the assignment, protocol assignment, housing, and group membership records derived from them. - Show that time of day on the birth, arrival, death, necropsy and derived-record dates, which the study framework otherwise renders as date-only, so the entry forms offer a time editor. - Give the Assign To combo a window-scoped identity and discard the Submit For Review window on cancel. --- .../study/animal_group_members.query.xml | 2 ++ .../resources/queries/study/arrival.query.xml | 1 + .../queries/study/assignment.query.xml | 4 ++- nbri_ehr/resources/queries/study/birth.js | 1 - .../resources/queries/study/birth.query.xml | 1 + nbri_ehr/resources/queries/study/deaths.js | 33 +++++++++++++------ .../resources/queries/study/deaths.query.xml | 1 + .../queries/study/demographics.query.xml | 4 +-- .../resources/queries/study/housing.query.xml | 2 ++ .../queries/study/necropsy.query.xml | 1 + .../study/protocolAssignment.query.xml | 8 +++-- nbri_ehr/resources/scripts/nbri_triggers.js | 4 ++- .../nbri_ehr/buttons/deathNecropsyButtons.js | 6 ++-- 13 files changed, 49 insertions(+), 19 deletions(-) diff --git a/nbri_ehr/resources/queries/study/animal_group_members.query.xml b/nbri_ehr/resources/queries/study/animal_group_members.query.xml index 09a5157..3f4678e 100644 --- a/nbri_ehr/resources/queries/study/animal_group_members.query.xml +++ b/nbri_ehr/resources/queries/study/animal_group_members.query.xml @@ -7,9 +7,11 @@ Date Added + DateTime Date Removed + DateTime false diff --git a/nbri_ehr/resources/queries/study/arrival.query.xml b/nbri_ehr/resources/queries/study/arrival.query.xml index 84e30cc..eb3af07 100644 --- a/nbri_ehr/resources/queries/study/arrival.query.xml +++ b/nbri_ehr/resources/queries/study/arrival.query.xml @@ -11,6 +11,7 @@ Arrival Date + DateTime Arrival Type diff --git a/nbri_ehr/resources/queries/study/assignment.query.xml b/nbri_ehr/resources/queries/study/assignment.query.xml index df55a2e..6360ef8 100644 --- a/nbri_ehr/resources/queries/study/assignment.query.xml +++ b/nbri_ehr/resources/queries/study/assignment.query.xml @@ -7,7 +7,9 @@ DateTime - + + DateTime + ehr diff --git a/nbri_ehr/resources/queries/study/birth.js b/nbri_ehr/resources/queries/study/birth.js index 6531bc9..2c06b1b 100644 --- a/nbri_ehr/resources/queries/study/birth.js +++ b/nbri_ehr/resources/queries/study/birth.js @@ -43,7 +43,6 @@ function onInit(event, helper){ skipHousingCheck: true, announceAllModifiedParticipants: true, allowDatesInDistantPast: true, - removeTimeFromDate: true, skipAssignmentCheck: true, }); diff --git a/nbri_ehr/resources/queries/study/birth.query.xml b/nbri_ehr/resources/queries/study/birth.query.xml index f424994..fc7174f 100644 --- a/nbri_ehr/resources/queries/study/birth.query.xml +++ b/nbri_ehr/resources/queries/study/birth.query.xml @@ -11,6 +11,7 @@ Birth Date + DateTime Conception Id diff --git a/nbri_ehr/resources/queries/study/deaths.js b/nbri_ehr/resources/queries/study/deaths.js index fc9b3e9..87dd9ec 100644 --- a/nbri_ehr/resources/queries/study/deaths.js +++ b/nbri_ehr/resources/queries/study/deaths.js @@ -10,6 +10,10 @@ var idMap = {}; var deathIdMap = {}; var idsToSync = []; +// QC states that mean a death has been declared. Leaving draft for anything else -- 'Delete Requested', a denied +// request -- is not a declaration, so it must not notify or close out the animal's procedure orders. +var NOTIFY_STATES = ['REQUEST: PENDING', 'REVIEW REQUIRED', 'COMPLETED']; + function onInit(event, helper){ // the script scope can outlive a single save, so never inherit ids from a prior one @@ -185,24 +189,33 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even idsToSync = []; } + // A delete arrives here as the deleted row with a null oldRow, which otherwise reads as a draft leaving draft. + if (event === 'delete') + return; + var rows = helper.getRows() || []; + var idsToComplete = []; for (var i = 0; i < rows.length; i++) { var row = rows[i].row; var oldRow = rows[i].oldRow; - // Notification will get sent when: - // 1) a brand-new row saved directly as 'Request: Pending' (i.e., when a user clicks 'Submit Death'), or - // 2) a draft death record moving from 'In Progress' to 'Request: Pending'. - if (!helper.isETL() && - row && row.Id && - row.QCStateLabel && - row.QCStateLabel.toUpperCase() === 'REQUEST: PENDING' && - (!oldRow || !oldRow.QCStateLabel || oldRow.QCStateLabel.toUpperCase() === 'IN PROGRESS')) { + if (helper.isETL() || !row || !row.Id || !row.QCStateLabel) + continue; + + // Notify once, on the first save that leaves draft: 'Submit Death' lands on 'Request: Pending', but a death entered alongside its necropsy goes straight to 'Review Required' or 'Completed'. + var wasDraft = !oldRow || !oldRow.QCStateLabel || oldRow.QCStateLabel.toUpperCase() === 'IN PROGRESS'; + if (wasDraft && NOTIFY_STATES.indexOf(row.QCStateLabel.toUpperCase()) > -1) { console.log("Sending NBRI Death Notification") triggerHelper.sendDeathNotification(row.Id); - console.log("Updating Procedure Orders to Completed for Animal: " + row.Id + "") - triggerHelper.updateProcedureOrdersToCompleted([row.Id]); + if (idsToComplete.indexOf(row.Id) === -1) + idsToComplete.push(row.Id); } } + + // One pass for the whole save: the helper filters and updates in bulk, so a call per row multiplies round trips. + if (idsToComplete.length) { + console.log("Updating Procedure Orders to Completed for: " + idsToComplete.join(', ')) + triggerHelper.updateProcedureOrdersToCompleted(idsToComplete); + } }); \ No newline at end of file diff --git a/nbri_ehr/resources/queries/study/deaths.query.xml b/nbri_ehr/resources/queries/study/deaths.query.xml index e28e2f0..f66b06d 100644 --- a/nbri_ehr/resources/queries/study/deaths.query.xml +++ b/nbri_ehr/resources/queries/study/deaths.query.xml @@ -6,6 +6,7 @@ Death Date + DateTime Death Type diff --git a/nbri_ehr/resources/queries/study/demographics.query.xml b/nbri_ehr/resources/queries/study/demographics.query.xml index 0323b4a..6e6f2cc 100644 --- a/nbri_ehr/resources/queries/study/demographics.query.xml +++ b/nbri_ehr/resources/queries/study/demographics.query.xml @@ -13,7 +13,7 @@ Species - Date + DateTime Birth /query/executeQuery.view? schemaName=study& @@ -22,7 +22,7 @@ - Date + DateTime Death /query/executeQuery.view? schemaName=study& diff --git a/nbri_ehr/resources/queries/study/housing.query.xml b/nbri_ehr/resources/queries/study/housing.query.xml index e1a21a3..9c8a81f 100644 --- a/nbri_ehr/resources/queries/study/housing.query.xml +++ b/nbri_ehr/resources/queries/study/housing.query.xml @@ -6,6 +6,7 @@ In Date + DateTime true @@ -13,6 +14,7 @@ true true Out Date + DateTime Location diff --git a/nbri_ehr/resources/queries/study/necropsy.query.xml b/nbri_ehr/resources/queries/study/necropsy.query.xml index 35290a8..f8054a1 100644 --- a/nbri_ehr/resources/queries/study/necropsy.query.xml +++ b/nbri_ehr/resources/queries/study/necropsy.query.xml @@ -5,6 +5,7 @@ Exam Date + DateTime Category diff --git a/nbri_ehr/resources/queries/study/protocolAssignment.query.xml b/nbri_ehr/resources/queries/study/protocolAssignment.query.xml index d95eb07..ccd46a8 100644 --- a/nbri_ehr/resources/queries/study/protocolAssignment.query.xml +++ b/nbri_ehr/resources/queries/study/protocolAssignment.query.xml @@ -4,8 +4,12 @@ - - + + DateTime + + + DateTime + true diff --git a/nbri_ehr/resources/scripts/nbri_triggers.js b/nbri_ehr/resources/scripts/nbri_triggers.js index 320c63f..2c806fe 100644 --- a/nbri_ehr/resources/scripts/nbri_triggers.js +++ b/nbri_ehr/resources/scripts/nbri_triggers.js @@ -58,7 +58,9 @@ exports.init = function (EHR) { // group memberships are routinely backdated, so historical dates must not raise a warning helper.setScriptOptions({ requiresStatusRecalc: false, - allowDatesInDistantPast: true + allowDatesInDistantPast: true, + // Overrides the shared animal_group_members script, which sets this true. + removeTimeFromDate: false }); }); diff --git a/nbri_ehr/resources/web/nbri_ehr/buttons/deathNecropsyButtons.js b/nbri_ehr/resources/web/nbri_ehr/buttons/deathNecropsyButtons.js index efee481..b07a7a5 100644 --- a/nbri_ehr/resources/web/nbri_ehr/buttons/deathNecropsyButtons.js +++ b/nbri_ehr/resources/web/nbri_ehr/buttons/deathNecropsyButtons.js @@ -101,7 +101,7 @@ Ext4.define('NBRI_EHR.window.DeathNecropsySubmitForReviewWindow', { text: 'Cancel', scope: this, handler: function(btn){ - btn.up('window').hide(); + btn.up('window').close(); } }], items: [{ @@ -127,8 +127,10 @@ Ext4.define('NBRI_EHR.window.DeathNecropsySubmitForReviewWindow', { value: this.getDefaultRecipient(), displayField: 'DisplayName', valueField: 'UserId', + // No global 'id' here: a reopened window would adopt the previous window's element and render a second combo. itemId: 'assignedTo', - id: 'assignedTo', + // Ext derives the input's name from the component id when 'name' is absent, so set it explicitly rather than leaning on the id. + name: 'assignedTo', anyMatch: true, caseSensitive: false, }] From 1f1e76e622d81afc81ab1563768ab534ebce098b Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 4 Sep 2026 14:02:54 -0700 Subject: [PATCH 3/4] Add a Pregnant field to the animal snapshot panels (#34) ## Rationale Adds a Pregnant field to the animal snapshot so staff can see at a glance whether a female is carrying an open conception, without opening the conception records to check. A conception is open until a birth or a pregnancy outcome claims it, which no existing column expressed, so the field is backed by a new derived flag on the conception record. That flag is also useful on its own and is surfaced on the conceptions-by-dam report. ## Changes - Conception records carry a derived active flag marking the ones no birth or pregnancy outcome has claimed, and it appears on the conceptions-by-dam report alongside the outcome. - The snapshot and Animal Details panels show a Pregnant field listing each open conception as a link to its record, or "No" when there are none. - The dam's cached demographics are refreshed whenever a conception is entered, re-pointed or removed, and whenever a birth closes or reopens one. Previously only a pregnancy outcome refreshed her, so the field could show a stale value indefinitely. - The outcome report no longer counts birth or pregnancy records whose status is explicitly non-public. - Removed the Prev Id field from the snapshot panel. - Test coverage for the field and the flag across the birth, conception and pregnancy flows. --- .../resources/queries/nbri_ehr/Conception.js | 46 ++++++++++++- .../queries/nbri_ehr/Conception.query.xml | 1 + .../queries/nbri_ehr/ConceptionsByDam.sql | 12 ++-- .../queries/study/activeConceptions.sql | 11 ++++ nbri_ehr/resources/queries/study/birth.js | 33 ++++++++++ nbri_ehr/resources/queries/study/pregnancy.js | 42 +++++++++++- .../web/nbri_ehr/panel/AnimalDetailsPanel.js | 3 + .../web/nbri_ehr/panel/SnapshotPanel.js | 36 +++++++++-- .../org/labkey/nbri_ehr/NBRI_EHRModule.java | 2 + ...ActiveConceptionsDemographicsProvider.java | 59 +++++++++++++++++ .../nbri_ehr/query/NBRI_EHRTriggerHelper.java | 12 ++++ .../nbri_ehr/table/NBRI_EHRCustomizer.java | 64 +++++++++++++++++++ .../tests.nbri_ehr/NBRI_EHRTest.java | 39 +++++++++++ 13 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 nbri_ehr/resources/queries/study/activeConceptions.sql create mode 100644 nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java diff --git a/nbri_ehr/resources/queries/nbri_ehr/Conception.js b/nbri_ehr/resources/queries/nbri_ehr/Conception.js index 72d57b2..9dd9a9c 100644 --- a/nbri_ehr/resources/queries/nbri_ehr/Conception.js +++ b/nbri_ehr/resources/queries/nbri_ehr/Conception.js @@ -1 +1,45 @@ -require("ehr/triggers").initScript(this); \ No newline at end of file +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +require("ehr/triggers").initScript(this); + +var triggerHelper = new org.labkey.nbri_ehr.query.NBRI_EHRTriggerHelper(LABKEY.Security.currentUser.id, LABKEY.Security.currentContainer.id); + +// the shared trigger collects modified participants from row.Id, which this table does not have, so announce the dams +// here or their cached activeConceptions keeps a stale Pregnant value +var damsModified = []; + +function addDam(dam) { + if (dam && damsModified.indexOf(dam) === -1) { + damsModified.push(dam); + } +} + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.INIT, 'nbri_ehr', 'Conception', function(event, helper){ + // the script scope can outlive a single save, so never inherit dams from a prior one + damsModified = []; +}); + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_UPSERT, 'nbri_ehr', 'Conception', function(helper, scriptErrors, row, oldRow) { + if (helper.isValidateOnly()) + return; + + addDam(row.Dam); + + // a re-pointed conception frees the dam it used to belong to + addDam(oldRow ? oldRow.Dam : null); +}); + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_DELETE, 'nbri_ehr', 'Conception', function(helper, scriptErrors, row) { + // the row LabKey passes for a delete can carry keys only, and the record is still readable at this point + addDam(row.Dam || triggerHelper.getConceptionDam(row.ConceptId)); +}); + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.COMPLETE, 'nbri_ehr', 'Conception', function(event, errors, helper){ + if (damsModified.length) { + triggerHelper.reportDataChange('nbri_ehr', 'Conception', damsModified); + damsModified = []; + } +}); diff --git a/nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml b/nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml index 32e2b50..df0d1ca 100644 --- a/nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml +++ b/nbri_ehr/resources/queries/nbri_ehr/Conception.query.xml @@ -2,6 +2,7 @@
+ Conception Records diff --git a/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql b/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql index 68b07b4..599a1ed 100644 --- a/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql +++ b/nbri_ehr/resources/queries/nbri_ehr/ConceptionsByDam.sql @@ -9,17 +9,19 @@ SELECT c.ConceptDate, c.Estimated, c.Sire, + c.isActive, CASE + WHEN c.isActive = true THEN 'Unknown' WHEN b.conceptId IS NOT NULL THEN 'Live Birth' - WHEN po.conceptId IS NOT NULL THEN COALESCE(po.result, 'Unknown') - ELSE 'Unknown' + ELSE COALESCE(po.result, 'Unknown') END AS conceptionOutcome, b.offspring, c.Remark, c.QCState AS qcstate FROM Conception c --- a conception yields at most one birth; the aggregate only guards against duplicates the birth trigger warns about but does not block -LEFT JOIN (SELECT b.conceptId, MAX(b.Id) AS offspring FROM study.birth b WHERE b.conceptId IS NOT NULL GROUP BY b.conceptId) b +-- Both joins match isActive: a record claims its conception unless its QC state is explicitly non-public, so a null state counts as public. +-- The birth trigger blocks a duplicate conceptId, but ETL imports skip that check, so the aggregate guards against one. +LEFT JOIN (SELECT b.conceptId, MAX(b.Id) AS offspring FROM study.birth b WHERE b.conceptId IS NOT NULL AND (b.qcstate IS NULL OR b.qcstate.publicdata = true) GROUP BY b.conceptId) b ON b.conceptId = c.ConceptId -LEFT JOIN (SELECT p.conceptId, MAX(p.result.title) AS result FROM study.pregnancy p WHERE p.conceptId IS NOT NULL GROUP BY p.conceptId) po +LEFT JOIN (SELECT p.conceptId, MAX(p.result.title) AS result FROM study.pregnancy p WHERE p.conceptId IS NOT NULL AND (p.qcstate IS NULL OR p.qcstate.publicdata = true) GROUP BY p.conceptId) po ON po.conceptId = c.ConceptId diff --git a/nbri_ehr/resources/queries/study/activeConceptions.sql b/nbri_ehr/resources/queries/study/activeConceptions.sql new file mode 100644 index 0000000..3fd6f1f --- /dev/null +++ b/nbri_ehr/resources/queries/study/activeConceptions.sql @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +SELECT + c.Dam AS Id, + c.ConceptId, + c.ConceptDate +FROM nbri_ehr.Conception c +WHERE c.isActive = true AND c.Dam IS NOT NULL diff --git a/nbri_ehr/resources/queries/study/birth.js b/nbri_ehr/resources/queries/study/birth.js index 2c06b1b..9e4c390 100644 --- a/nbri_ehr/resources/queries/study/birth.js +++ b/nbri_ehr/resources/queries/study/birth.js @@ -9,6 +9,10 @@ EHR.Server.Utils = require("ehr/utils").EHR.Server.Utils; var triggerHelper = new org.labkey.nbri_ehr.query.NBRI_EHRTriggerHelper(LABKEY.Security.currentUser.id, LABKEY.Security.currentContainer.id); var idsToSync = []; +// dams whose conception this save closes. The birth row announces the newborn, so the dam's cached Pregnant value +// would otherwise keep listing a conception that is no longer open. +var damsToSync = []; + // conception ids claimed by the rows of this save that have already been validated. Rows entered together are not in // study.birth yet when each one is checked, so this is the only way the one-birth-per-conception rule can see them. var conceptIdsInSave = []; @@ -34,6 +38,17 @@ function createAssignment(scriptErrors, dataset, fieldName, value, row) { } } +// resolves the dam of a conception so the birth can announce her; the conception carries the dam, the birth row does not +function addConceptionDam(conceptId) { + if (!conceptId) + return; + + var dam = triggerHelper.getConceptionDam(conceptId); + if (dam && damsToSync.indexOf(dam) === -1) { + damsToSync.push(dam); + } +} + function onInit(event, helper){ helper.setScriptOptions({ allowAnyId: true, @@ -49,6 +64,7 @@ function onInit(event, helper){ // the script scope can outlive a single save, so never inherit ids from a prior one idsToSync = []; conceptIdsInSave = []; + damsToSync = []; helper.decodeExtraContextProperty('birthsInTransaction'); } @@ -64,6 +80,16 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even } idsToSync = []; } + + if (damsToSync.length) { + triggerHelper.reportDataChange('nbri_ehr', 'Conception', damsToSync); + damsToSync = []; + } +}); + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_DELETE, 'study', 'birth', function(helper, scriptErrors, row) { + // deleting the birth reopens its conception + addConceptionDam(row.conceptId); }); EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_UPSERT, 'study', 'birth', function(helper, scriptErrors, row, oldRow) { @@ -95,6 +121,13 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even } } + if (!helper.isETL() && !helper.isValidateOnly()) { + addConceptionDam(row.conceptId); + + // a re-pointed birth reopens the conception it used to claim + addConceptionDam(oldRow ? oldRow.conceptId : null); + } + if (!helper.isETL()) { if (row.QCStateLabel) { diff --git a/nbri_ehr/resources/queries/study/pregnancy.js b/nbri_ehr/resources/queries/study/pregnancy.js index ab4dc7b..c989278 100644 --- a/nbri_ehr/resources/queries/study/pregnancy.js +++ b/nbri_ehr/resources/queries/study/pregnancy.js @@ -8,6 +8,26 @@ EHR.Server.Utils = require("ehr/utils").EHR.Server.Utils; var triggerHelper = new org.labkey.nbri_ehr.query.NBRI_EHRTriggerHelper(LABKEY.Security.currentUser.id, LABKEY.Security.currentContainer.id); +// dams whose conception this save claims or frees. The outcome announces its own Id, which is the dam only when the +// record was entered against her, so resolve the dam from the conception instead of trusting row.Id. +var damsToSync = []; + +// resolves the dam of a conception so the outcome can announce her; the conception carries the dam, the outcome row does not +function addConceptionDam(conceptId) { + if (!conceptId) + return; + + var dam = triggerHelper.getConceptionDam(conceptId); + if (dam && damsToSync.indexOf(dam) === -1) { + damsToSync.push(dam); + } +} + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.INIT, 'study', 'pregnancy', function(event, helper){ + // the script scope can outlive a single save, so never inherit dams from a prior one + damsToSync = []; +}); + EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_UPSERT, 'study', 'pregnancy', function(helper, scriptErrors, row, oldRow) { if (!helper.isETL() && row.conceptId) { if (triggerHelper.totalRecords('nbri_ehr', 'Conception', 'ConceptId', row.conceptId) === 0) { @@ -24,6 +44,26 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even EHR.Server.Utils.addError(scriptErrors, 'conceptId', 'This conception Id is already used by a birth record', 'INFO'); } } + + // validation never reaches COMPLETE, so resolving dams during it is a wasted query per row + if (!helper.isETL() && !helper.isValidateOnly()) { + addConceptionDam(row.conceptId); + + // a re-pointed or cleared outcome reopens the conception it used to claim + addConceptionDam(oldRow ? oldRow.conceptId : null); + } +}); + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.BEFORE_DELETE, 'study', 'pregnancy', function(helper, scriptErrors, row) { + // deleting the outcome reopens its conception + addConceptionDam(row.conceptId); +}); + +EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.COMPLETE, 'study', 'pregnancy', function(event, errors, helper){ + if (damsToSync.length) { + triggerHelper.reportDataChange('nbri_ehr', 'Conception', damsToSync); + damsToSync = []; + } }); EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Events.ON_BECOME_PUBLIC, 'study', 'pregnancy', function(scriptErrors, helper, row, oldRow) { @@ -36,4 +76,4 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even } triggerHelper.sendPregnancyOutcomeNotification(row.Id, outcomeRec); } -}); \ No newline at end of file +}); diff --git a/nbri_ehr/resources/web/nbri_ehr/panel/AnimalDetailsPanel.js b/nbri_ehr/resources/web/nbri_ehr/panel/AnimalDetailsPanel.js index e22d7e5..36ef139 100644 --- a/nbri_ehr/resources/web/nbri_ehr/panel/AnimalDetailsPanel.js +++ b/nbri_ehr/resources/web/nbri_ehr/panel/AnimalDetailsPanel.js @@ -138,6 +138,9 @@ Ext4.define('NBRI_EHR.panel.AnimalDetailsPanel', { },{ fieldLabel: 'Cagemates', name: 'cagemates' + },{ + fieldLabel: 'Pregnant', + name: 'pregnant' },{ fieldLabel: 'Weight', name: 'weights' diff --git a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js index 0ee4d92..9bcccb7 100644 --- a/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js +++ b/nbri_ehr/resources/web/nbri_ehr/panel/SnapshotPanel.js @@ -85,10 +85,6 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { xtype: 'displayfield', fieldLabel: 'Source', name: 'source' - },{ - xtype: 'displayfield', - fieldLabel: 'Prev Id', - name: 'prev_id' }] },{ xtype: 'container', @@ -133,6 +129,10 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { xtype: 'displayfield', fieldLabel: 'Last TB', name: 'lastTB' + },{ + xtype: 'displayfield', + fieldLabel: 'Pregnant', + name: 'pregnant' },{ xtype: 'displayfield', fieldLabel: 'Weights', @@ -391,4 +391,32 @@ Ext4.define('NBRI_EHR.panel.SnapshotPanel', { toSet['parents'] = 'No data'; } }, + + appendDataResults: function(toSet, results, id){ + this.callParent(arguments); + this.appendPregnancy(toSet, results); + }, + + appendPregnancy: function(toSet, results){ + var records = results ? results.getData()['activeConceptions'] : null; + // getEHRContext returns null when the study container property is unset; fall back to the current container + var ctx = EHR.Utils.getEHRContext() || {}; + var values = []; + + if (Ext4.isArray(records)){ + Ext4.each(records, function(record){ + var conceptId = record['ConceptId']; + if (conceptId){ + var url = LABKEY.ActionURL.buildURL('query', 'executeQuery', ctx['EHRStudyContainer'], { + schemaName: 'nbri_ehr', + 'query.queryName': 'Conception', + 'query.ConceptId~eq': conceptId + }); + values.push('' + LABKEY.Utils.encodeHtml(conceptId) + ''); + } + }, this); + } + + toSet['pregnant'] = values.length ? values.join('
') : 'No'; + }, }); \ No newline at end of file diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java b/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java index 0b87214..06c078e 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/NBRI_EHRModule.java @@ -47,6 +47,7 @@ import org.labkey.nbri_ehr.dataentry.form.*; import org.labkey.nbri_ehr.demographics.ActiveAssignmentsDemographicsProvider; import org.labkey.nbri_ehr.demographics.ActiveCasesDemographicsProvider; +import org.labkey.nbri_ehr.demographics.ActiveConceptionsDemographicsProvider; import org.labkey.nbri_ehr.demographics.ActiveFlagsDemographicsProvider; import org.labkey.nbri_ehr.demographics.ActiveTreatmentsDemographicsProvider; import org.labkey.nbri_ehr.demographics.CagematesDemographicsProvider; @@ -135,6 +136,7 @@ protected void doStartupAfterSpringConfig(ModuleContext moduleContext) ehrService.registerDemographicsProvider(new ActiveTreatmentsDemographicsProvider(this)); ehrService.registerDemographicsProvider(new SourceDemographicsProvider(this)); ehrService.registerDemographicsProvider(new NecropsyStatusDemographicsProvider(this)); + ehrService.registerDemographicsProvider(new ActiveConceptionsDemographicsProvider(this)); EHRService.get().registerHistoryDataSource(new AnimalGroupsDataSource(this)); EHRService.get().registerHistoryDataSource(new AnimalGroupsEndDataSource(this)); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java new file mode 100644 index 0000000..b27b347 --- /dev/null +++ b/nbri_ehr/src/org/labkey/nbri_ehr/demographics/ActiveConceptionsDemographicsProvider.java @@ -0,0 +1,59 @@ +/* + * 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.nbri_ehr.demographics; + +import org.labkey.api.data.Sort; +import org.labkey.api.ehr.demographics.AbstractListDemographicsProvider; +import org.labkey.api.module.Module; +import org.labkey.api.query.FieldKey; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +public class ActiveConceptionsDemographicsProvider extends AbstractListDemographicsProvider +{ + public ActiveConceptionsDemographicsProvider(Module module) + { + super(module, "study", "activeConceptions", "activeConceptions"); + // isActive already excludes non-public conceptions, and the query exposes no QCState column for the inherited filter to use + _supportsQCState = false; + } + + @Override + public boolean requiresRecalc(String schema, String query) + { + return ("study".equalsIgnoreCase(schema) && ("birth".equalsIgnoreCase(query) || "pregnancy".equalsIgnoreCase(query))) || + ("nbri_ehr".equalsIgnoreCase(schema) && "Conception".equalsIgnoreCase(query)); + } + + @Override + protected Collection getFieldKeys() + { + Set keys = new HashSet<>(); + keys.add(FieldKey.fromString("Id")); + keys.add(FieldKey.fromString("ConceptId")); + keys.add(FieldKey.fromString("ConceptDate")); + + return keys; + } + + @Override + protected Sort getSort() + { + return new Sort("-ConceptDate"); + } +} diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java b/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java index b1ed437..d5c01d8 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java @@ -829,6 +829,18 @@ public long totalRecords(String schemaName, String queryName, String columnName, return ts.getRowCount(); } + // The Conception table has no Id column, so its trigger cannot announce a modified participant on its own + public String getConceptionDam(String conceptId) + { + if (conceptId == null) + return null; + + TableInfo ti = getTableInfo("nbri_ehr", "Conception"); + SimpleFilter filter = new SimpleFilter(FieldKey.fromString("ConceptId"), conceptId); + + return new TableSelector(ti, Collections.singleton("Dam"), filter, null).getObject(String.class); + } + public boolean canCloseCase() { return _container.hasPermission(_user, EHRVeterinarianPermission.class); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java b/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java index 7f4406b..ca8a01a 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/table/NBRI_EHRCustomizer.java @@ -62,9 +62,11 @@ import org.labkey.nbri_ehr.dataentry.form.NBRIClinicalObservationsFormType; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.HashSet; +import java.util.List; import java.util.Set; public class NBRI_EHRCustomizer extends AbstractTableCustomizer @@ -644,6 +646,10 @@ public void doTableSpecificCustomizations(AbstractTableInfo ti) { addIsActiveForProject(ti, EHRService.EndingOption.activeAfterMidnightTonight); } + if (matches(ti, "nbri_ehr", "Conception")) + { + addIsActiveForConception(ti); + } if (matches(ti, "study", "protocolAssignment")) { EHRService.get().addIsActiveCol(ti, false, EHRService.EndingOption.activeAfterMidnightTonight, EHRService.EndingOption.activeAfterMidnightTonight); @@ -685,6 +691,64 @@ private void addIsActiveForProject(AbstractTableInfo ti, EHRService.EndingOption } } + private void addIsActiveForConception(AbstractTableInfo ti) + { + String name = "isActive"; + // both columns back the expression below, so neither may be missing + if (ti.getColumn(name) != null || ti.getColumn("conceptid") == null || ti.getColumn("qcstate") == null) + return; + + UserSchema us = ti.getUserSchema(); + Container ehrContainer = us == null ? null : EHRService.get().getEHRStudyContainer(us.getContainer()); + if (ehrContainer == null) + return; + + String birthTable = getDatasetStorageTableName(ehrContainer, "birth"); + String pregnancyTable = getDatasetStorageTableName(ehrContainer, "pregnancy"); + if (birthTable == null || pregnancyTable == null) + return; + + String alias = ExprColumn.STR_TABLE_ALIAS; + String isFalse = ti.getSqlDialect().getBooleanFALSE(); + + // ConceptId is globally unique, so the subqueries need no container filter + SQLFragment sql = new SQLFragment("(CASE WHEN (" + + isPublicSql(alias, isFalse) + + " AND NOT EXISTS (SELECT 1 FROM studydataset." + birthTable + " b WHERE b.conceptid = " + alias + ".conceptid AND " + isPublicSql("b", isFalse) + ")" + + " AND NOT EXISTS (SELECT 1 FROM studydataset." + pregnancyTable + " p WHERE p.conceptid = " + alias + ".conceptid AND " + isPublicSql("p", isFalse) + ")" + + ") THEN " + ti.getSqlDialect().getBooleanTRUE() + + " ELSE " + isFalse + + " END)"); + + ExprColumn col = new ExprColumn(ti, name, sql, JdbcType.BOOLEAN, ti.getColumn("conceptid"), ti.getColumn("qcstate")); + col.setLabel("Is Active?"); + col.setDescription("No birth or pregnancy outcome record has claimed this conception Id."); + ti.addColumn(col); + + // Customizers run after the query XML column reorder, so listing isActive there does nothing and it lands last + List visible = new ArrayList<>(ti.getDefaultVisibleColumns()); + visible.remove(col.getFieldKey()); + int sireIndex = visible.indexOf(FieldKey.fromParts("Sire")); + visible.add(sireIndex < 0 ? visible.size() : sireIndex + 1, col.getFieldKey()); + ti.setDefaultVisibleColumns(visible); + } + + // A null QCState means none was assigned, which LabKey treats as visible, so only an explicitly non-public state hides a row + private String isPublicSql(String tableAlias, String isFalse) + { + return "NOT EXISTS (SELECT 1 FROM core.datastates ds WHERE ds.rowid = " + tableAlias + ".qcstate AND ds.publicdata = " + isFalse + ")"; + } + + private String getDatasetStorageTableName(Container c, String datasetName) + { + StudyService studyService = StudyService.get(); + if (studyService == null) + return null; + + Dataset dataset = studyService.getDataset(c, studyService.getDatasetIdByName(c, datasetName)); + return dataset != null && dataset.getDomain() != null ? dataset.getDomain().getStorageTableName() : null; + } + public void doSharedCustomization(AbstractTableInfo ti) { for (var col : ti.getMutableColumns()) diff --git a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java index b93f92e..f1619ec 100644 --- a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java +++ b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java @@ -788,6 +788,9 @@ public void testBirthForm() throws Exception conception.addRow(Map.of("ConceptId", conceptId, "ConceptDate", now.minusDays(160), "Dam", damId, "Sire", sireId)); conception.execute(getApiHelper().getConnection(), getContainerPath()); + log("Verifying the dam's Animal Details reports the open conception before the birth"); + assertEquals("Animal Details did not report the open conception", conceptId, getSnapshotFieldValue(damId, "Pregnant")); + gotoEnterData(); waitAndClickAndWait(Locator.linkWithText("Birth")); lockForm(); @@ -895,6 +898,11 @@ public void testBirthForm() throws Exception Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(damId), report.getRowDataAsText(0, "Id")); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList("Live Birth"), report.getRowDataAsText(0, "conceptionOutcome")); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(bornAnimal), report.getRowDataAsText(0, "offspring")); + Assert.assertEquals("A conception claimed by a birth should not be active", + Arrays.asList("false"), report.getRowDataAsText(0, "isActive")); + + log("Verifying the birth cleared the dam's pregnancy"); + assertEquals("Animal Details still reports a conception a birth has closed", "No", getSnapshotFieldValue(damId, "Pregnant")); } @Test @@ -1096,6 +1104,13 @@ public void testPregnancyForm() throws IOException, CommandException report.setFilter("ConceptId", "Equals", conceptId); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(animalId), report.getRowDataAsText(0, "Id")); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(result), report.getRowDataAsText(0, "conceptionOutcome")); + Assert.assertEquals("A conception claimed by a pregnancy outcome should not be active", + Arrays.asList("false"), report.getRowDataAsText(0, "isActive")); + + log("Verifying the pregnancy outcome cleared the dam's pregnancy"); + // this dam carries other conceptions from sibling tests, so assert only that this one is gone + Assert.assertFalse("Animal Details still reports a conception a pregnancy outcome has closed", + getSnapshotFieldValue(animalId, "Pregnant").contains(conceptId)); } @Test @@ -1140,6 +1155,30 @@ public void testConceptionForm() report.setFilter("ConceptId", "Equals", conceptId); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList(damId), report.getRowDataAsText(0, "Id")); Assert.assertEquals("Invalid ConceptionsByDam row", Arrays.asList("Unknown"), report.getRowDataAsText(0, "conceptionOutcome")); + Assert.assertEquals("A conception with no birth or pregnancy outcome should be active", + Arrays.asList("true"), report.getRowDataAsText(0, "isActive")); + + log("Verifying the dam's Animal Details links to the open conception"); + // this dam carries other conceptions from sibling tests, so assert only that this one is listed + Assert.assertTrue("Animal Details did not report the open conception", + getSnapshotFieldValue(damId, "Pregnant").contains(conceptId)); + } + + /** + * Reads one field from the Animal Details snapshot panel, which renders Ext4 displayfields rather than a grid, so + * there is no page object to read through. The value arrives from the demographics cache after the page settles, + * so an empty field means not-yet-loaded rather than no value. + */ + private String getSnapshotFieldValue(String animalId, String fieldLabel) + { + ParticipantViewPage.beginAt(this, animalId); + Locator field = Locator.xpath("//*[contains(@class,'x4-form-item')][.//label[starts-with(normalize-space(.),'" + + fieldLabel + "')]]//div[contains(@class,'x4-form-display-field')]"); + waitForElement(field); + waitFor(() -> !field.findElement(getDriver()).getText().trim().isEmpty(), + "Animal Details did not populate the " + fieldLabel + " field", WAIT_FOR_JAVASCRIPT); + + return field.findElement(getDriver()).getText().trim(); } @Test From 5e37878494b1bc317d21824f339a78241a511972 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Sat, 5 Sep 2026 07:15:02 -0700 Subject: [PATCH 4/4] Update the birth, arrival, death, and pregnancy data entry forms (#35) ## Rationale Bring the birth, arrival, death, and pregnancy entry forms in line with the data that is actually captured for new animals and pregnancy outcomes. Four gaps drove the work: animals were being created with neither a location nor a generation, the death type list still offered values that are no longer valid for new entry alongside a disposition field that is not used, and pregnancy outcomes recorded no delivery mode and could be saved without a link back to a conception. ## Changes - Require the initial location on the birth and arrival forms. - Record a generation on demographics for animals entered through those forms: arrivals start at zero and births derive theirs from the dam. - Disable the death types that are no longer valid for new entry, leaving them in the lookup so historical rows still resolve. - Drop the disposition field from the deaths form and dataset, and deregister the death reason lookup that it left unused. - Add a delivery mode to pregnancy outcomes and require a conception Id. - Extend the module's Selenium coverage for the new required fields and the generation behavior. - Note for deployment: the new generation column ships with the reference study, so an existing study needs it imported before the forms will accept it. --- nbri_ehr/resources/data/death_reason.tsv | 6 -- nbri_ehr/resources/data/death_type.tsv | 18 ++--- nbri_ehr/resources/data/editable_lookups.tsv | 1 - nbri_ehr/resources/data/lookup_sets.tsv | 1 - nbri_ehr/resources/data/lookupsManifest.tsv | 1 - .../resources/data/lookupsManifestTest.tsv | 1 - nbri_ehr/resources/queries/study/arrival.js | 17 +++++ nbri_ehr/resources/queries/study/birth.js | 24 +++++++ .../queries/study/deathNotification.sql | 3 +- .../resources/queries/study/deaths.query.xml | 9 --- .../resources/queries/study/deaths/.qview.xml | 1 - .../queries/study/demographics.query.xml | 3 + .../queries/study/demographics/.qview.xml | 1 + .../queries/study/pregnancy.query.xml | 10 +++ .../study/datasets/datasets_metadata.xml | 7 +- nbri_ehr/resources/views/necropsy.html | 2 +- .../web/nbri_ehr/model/sources/Arrival.js | 18 ++++- .../web/nbri_ehr/model/sources/Birth.js | 14 +++- .../web/nbri_ehr/model/sources/Death.js | 8 +-- .../web/nbri_ehr/model/sources/Pregnancy.js | 12 +++- .../window/StartWithConceptionWindow.js | 39 +++++++---- .../section/NBRIArrivalFormSection.java | 6 +- .../section/NBRIBirthFormSection.java | 1 + .../nbri_ehr/history/DeathDataSource.java | 12 +--- .../nbri_ehr/query/NBRI_EHRTriggerHelper.java | 22 ++++-- .../tests.nbri_ehr/NBRI_EHRTest.java | 70 ++++++++++++++----- 26 files changed, 213 insertions(+), 94 deletions(-) delete mode 100644 nbri_ehr/resources/data/death_reason.tsv diff --git a/nbri_ehr/resources/data/death_reason.tsv b/nbri_ehr/resources/data/death_reason.tsv deleted file mode 100644 index 0b39f78..0000000 --- a/nbri_ehr/resources/data/death_reason.tsv +++ /dev/null @@ -1,6 +0,0 @@ -value title -1 Euthanasia (clinical) -2 Euthaniasia (project) -3 Morbid -4 Natural Death -5 Program Management \ No newline at end of file diff --git a/nbri_ehr/resources/data/death_type.tsv b/nbri_ehr/resources/data/death_type.tsv index 700a88d..61853c9 100644 --- a/nbri_ehr/resources/data/death_type.tsv +++ b/nbri_ehr/resources/data/death_type.tsv @@ -1,14 +1,14 @@ -value title sort_order -A Experimental 1 +value title sort_order date_disabled +A Experimental 1 2026-09-03 D Spontaneous/Normal 2 -F Fetal 3 -FD Fetal Death 4 -FL Fetal Live 5 -FN Fetal found at necropsy 6 -FX Fetal experimental 7 +F Fetal 3 2026-09-03 +FD Fetal Death 4 2026-09-03 +FL Fetal Live 5 2026-09-03 +FN Fetal found at necropsy 6 2026-09-03 +FX Fetal experimental 7 2026-09-03 K Cull (scheduled) 8 M Medical cull (non-scheduled) 9 -ND Non-vaginal (C-section) dead 10 -NT Not pregnant at assessment 11 +ND Non-vaginal (C-section) dead 10 2026-09-03 +NT Not pregnant at assessment 11 2026-09-03 S Cull 12 X Experimental 13 \ No newline at end of file diff --git a/nbri_ehr/resources/data/editable_lookups.tsv b/nbri_ehr/resources/data/editable_lookups.tsv index c12dbd0..0d76eba 100644 --- a/nbri_ehr/resources/data/editable_lookups.tsv +++ b/nbri_ehr/resources/data/editable_lookups.tsv @@ -40,7 +40,6 @@ ehr_lookups country Colony Management Country ehr_lookups country_category Colony Management Country Category ehr_lookups daily_enrich_codes Behavior Daily enrichment codes. ehr_lookups data_category Clinical Data Categories Used in datasets. -ehr_lookups death_reason Colony Management Death Reason ehr_lookups death_type Colony Management Death Type Death type codes. ehr_lookups delivery_mode Colony Management Delivery Mode ehr_lookups delivery_state Colony Management Delivery State diff --git a/nbri_ehr/resources/data/lookup_sets.tsv b/nbri_ehr/resources/data/lookup_sets.tsv index 6cd8cba..c265799 100644 --- a/nbri_ehr/resources/data/lookup_sets.tsv +++ b/nbri_ehr/resources/data/lookup_sets.tsv @@ -31,7 +31,6 @@ country Country value title country_category Country Category value title daily_enrich_codes Daily Enrichment Codes value data_category Data Category Field Values value -death_reason Death Reason value death_type Death Type value title delivery_mode Delivery Mode value title delivery_state Delivery State value title diff --git a/nbri_ehr/resources/data/lookupsManifest.tsv b/nbri_ehr/resources/data/lookupsManifest.tsv index 19aa5a4..cd41275 100644 --- a/nbri_ehr/resources/data/lookupsManifest.tsv +++ b/nbri_ehr/resources/data/lookupsManifest.tsv @@ -35,7 +35,6 @@ country country_category daily_enrich_codes data_category -death_reason death_type delivery_mode delivery_state diff --git a/nbri_ehr/resources/data/lookupsManifestTest.tsv b/nbri_ehr/resources/data/lookupsManifestTest.tsv index 8d028e6..7f1db9d 100644 --- a/nbri_ehr/resources/data/lookupsManifestTest.tsv +++ b/nbri_ehr/resources/data/lookupsManifestTest.tsv @@ -35,7 +35,6 @@ country country_category daily_enrich_codes data_category -death_reason death_type delivery_mode delivery_state diff --git a/nbri_ehr/resources/queries/study/arrival.js b/nbri_ehr/resources/queries/study/arrival.js index d374944..9ca4acf 100644 --- a/nbri_ehr/resources/queries/study/arrival.js +++ b/nbri_ehr/resources/queries/study/arrival.js @@ -8,6 +8,11 @@ require("ehr/triggers").initScript(this); var triggerHelper = new org.labkey.nbri_ehr.query.NBRI_EHRTriggerHelper(LABKEY.Security.currentUser.id, LABKEY.Security.currentContainer.id); var idsToSync = []; +// generation 0 is a real value, so emptiness cannot be tested by truthiness the way the other demographics fields test it +function isBlankGeneration(value) { + return value === null || value === undefined || value === ''; +} + // opens one assignment record against the animal being entered; each dataset carries the assignment under its own field function createAssignment(scriptErrors, dataset, fieldName, value, row) { if (!value) @@ -46,6 +51,11 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even EHR.Server.Utils.addError(scriptErrors, 'Id', 'Animal Id ' + row.Id + ' is already in use. Please use a different Id.', 'ERROR'); } + // only a form entry can be held to a generation: the form seeds it to 0, while a study import has no such column to carry + if (!row.rearrival && !helper.isETL() && helper.isEHRDataEntry() && helper.getEvent() == 'insert' && isBlankGeneration(row['Id/demographics/generation'])) { + EHR.Server.Utils.addError(scriptErrors, 'Id/demographics/generation', 'Generation is required', 'ERROR'); + } + if (row.eventDate) { row.date = row.eventDate; } @@ -69,6 +79,7 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even row.gender = row['Id/demographics/gender'] || null; row.geographic_origin = row['Id/demographics/geographic_origin'] || null; row.socialCode = row['Id/demographics/socialCode'] || null; + row.generation = isBlankGeneration(row['Id/demographics/generation']) ? null : parseInt(row['Id/demographics/generation'], 10); if (row.QCStateLabel) { row.qcstate = helper.getJavaHelper().getQCStateForLabel(row.QCStateLabel).getRowId(); @@ -152,6 +163,12 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even hasUpdates = true; } + if (!isBlankGeneration(row.generation) && row.generation !== data.generation) + { + obj.generation = row.generation; + hasUpdates = true; + } + if (row.sire && row.sire !== data.sire) { obj.sire = row.sire; diff --git a/nbri_ehr/resources/queries/study/birth.js b/nbri_ehr/resources/queries/study/birth.js index 9e4c390..fa4f20a 100644 --- a/nbri_ehr/resources/queries/study/birth.js +++ b/nbri_ehr/resources/queries/study/birth.js @@ -17,6 +17,11 @@ var damsToSync = []; // study.birth yet when each one is checked, so this is the only way the one-birth-per-conception rule can see them. var conceptIdsInSave = []; +// generation 0 is a real value, so emptiness cannot be tested by truthiness the way the other demographics fields test it +function isBlankGeneration(value) { + return value === null || value === undefined || value === ''; +} + // opens one assignment record against the animal being entered; each dataset carries the assignment under its own field function createAssignment(scriptErrors, dataset, fieldName, value, row) { if (!value) @@ -178,6 +183,7 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even birth: row.date || null, gender: row['Id/demographics/gender'] || null, socialCode: row['Id/demographics/socialCode'] || null, + generation: isBlankGeneration(row['Id/demographics/generation']) ? null : parseInt(row['Id/demographics/generation'], 10), taskid: row.taskid, remark: row.remark, QCStateLabel: row.QCStateLabel, @@ -193,6 +199,19 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even obj.species = helper.getJavaHelper().getSpecies(obj.dam); } + // the conception window fills this in, so a blank one means a save that bypassed the form + if (isBlankGeneration(obj.generation)) { + var damGeneration = obj.dam ? triggerHelper.getGeneration(obj.dam) : null; + if (damGeneration === null) { + var generationWarning = obj.dam + ? 'No generation is recorded for dam ' + obj.dam + ', so this birth was recorded as generation 1' + : 'This birth record has no dam, so it was recorded as generation 1'; + EHR.Server.Utils.addError(scriptErrors, 'Id/demographics/generation', generationWarning, 'WARN'); + } + + obj.generation = (damGeneration === null ? 0 : damGeneration) + 1; + } + if (!oldRow) { //if not already present, we insert into demographics helper.getJavaHelper().createDemographicsRecord(row.Id, obj, extraDemographicsFieldMappings); @@ -230,6 +249,11 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even hasUpdates = true; } + if (!isBlankGeneration(obj.generation) && obj.generation !== data.generation) { + record.generation = obj.generation; + hasUpdates = true; + } + if (obj.performedby && obj.performedby !== data.performedby) { record.performedby = obj.performedby; hasUpdates = true; diff --git a/nbri_ehr/resources/queries/study/deathNotification.sql b/nbri_ehr/resources/queries/study/deathNotification.sql index 86f8800..51f5906 100644 --- a/nbri_ehr/resources/queries/study/deathNotification.sql +++ b/nbri_ehr/resources/queries/study/deathNotification.sql @@ -8,6 +8,5 @@ SELECT Id, date, taskid, - performedBy.DisplayName AS performedBy, - reason.title AS reason + performedBy.DisplayName AS performedBy FROM study.deaths \ No newline at end of file diff --git a/nbri_ehr/resources/queries/study/deaths.query.xml b/nbri_ehr/resources/queries/study/deaths.query.xml index f66b06d..94fd06a 100644 --- a/nbri_ehr/resources/queries/study/deaths.query.xml +++ b/nbri_ehr/resources/queries/study/deaths.query.xml @@ -17,15 +17,6 @@ title
- - Disposition - - ehr_lookups - death_reason - value - title - - Death Weight (kg) diff --git a/nbri_ehr/resources/queries/study/deaths/.qview.xml b/nbri_ehr/resources/queries/study/deaths/.qview.xml index 56eecec..eb7c293 100644 --- a/nbri_ehr/resources/queries/study/deaths/.qview.xml +++ b/nbri_ehr/resources/queries/study/deaths/.qview.xml @@ -7,7 +7,6 @@ - diff --git a/nbri_ehr/resources/queries/study/demographics.query.xml b/nbri_ehr/resources/queries/study/demographics.query.xml index 6e6f2cc..dd6db15 100644 --- a/nbri_ehr/resources/queries/study/demographics.query.xml +++ b/nbri_ehr/resources/queries/study/demographics.query.xml @@ -100,6 +100,9 @@ title + + Generation + CITES diff --git a/nbri_ehr/resources/queries/study/demographics/.qview.xml b/nbri_ehr/resources/queries/study/demographics/.qview.xml index 5f80b01..1fcb392 100644 --- a/nbri_ehr/resources/queries/study/demographics/.qview.xml +++ b/nbri_ehr/resources/queries/study/demographics/.qview.xml @@ -13,6 +13,7 @@ + diff --git a/nbri_ehr/resources/queries/study/pregnancy.query.xml b/nbri_ehr/resources/queries/study/pregnancy.query.xml index a97139a..5ee2a9f 100644 --- a/nbri_ehr/resources/queries/study/pregnancy.query.xml +++ b/nbri_ehr/resources/queries/study/pregnancy.query.xml @@ -29,6 +29,16 @@ ConceptId + + Delivery Mode + false + + ehr_lookups + delivery_mode + value + title + +
diff --git a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml index eed9f6a..b036dfa 100644 --- a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml +++ b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml @@ -509,6 +509,9 @@ varchar + + integer + varchar @@ -561,9 +564,6 @@ http://cpas.labkey.com/Study#VisitDate http://cpas.labkey.com/Study#VisitDate
- - varchar - double @@ -1367,7 +1367,6 @@ http://cpas.labkey.com/Study#VisitDate
- Type varchar diff --git a/nbri_ehr/resources/views/necropsy.html b/nbri_ehr/resources/views/necropsy.html index 9952555..7e7c544 100644 --- a/nbri_ehr/resources/views/necropsy.html +++ b/nbri_ehr/resources/views/necropsy.html @@ -62,7 +62,7 @@ schemaName: 'study', queryName: 'deaths', filterArray: filterArray, - columns: 'Id,Id/demographics/species,Id/demographics/gender,date,reason,deathWeight,Id/lastProtocol/protocol,Id/lastProject/project', + columns: 'Id,Id/demographics/species,Id/demographics/gender,date,deathWeight,Id/lastProtocol/protocol,Id/lastProject/project', }, title: 'Death', renderTo: 'animalDeath', diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js index 9883d37..af420c2 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js @@ -20,7 +20,8 @@ EHR.model.DataModelManager.registerMetadata('Arrival', { byQuery: { 'study.arrival': { 'cage': { - // allowBlank: false, + allowBlank: false, + nullable: false, columnConfig: { fixed: true, width: 200 @@ -55,6 +56,21 @@ EHR.model.DataModelManager.registerMetadata('Arrival', { width: 200 } }, + // an arriving animal establishes its own lineage, so it starts at generation 0 + 'Id/demographics/generation': { + allowBlank: false, + nullable: false, + getInitialValue: function(v) { + return Ext4.isEmpty(v) ? 0 : v; + }, + editorConfig: { + minValue: 0 + }, + columnConfig: { + fixed: true, + width: 120 + } + }, // an animal joins the colony already assigned to a project, a protocol and a group; the trigger script // opens the matching assignment record for each one project: { diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js index bed0ab7..a961152 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js @@ -59,7 +59,8 @@ EHR.model.DataModelManager.registerMetadata('Birth', { } }, 'cage': { - // allowBlank: false, + allowBlank: false, + nullable: false, columnConfig: { fixed: true, width: 200 @@ -145,6 +146,17 @@ EHR.model.DataModelManager.registerMetadata('Birth', { columnConfig: { width: 200 } + }, + // derived from the dam by the conception window, but left editable so it can be corrected by hand + 'Id/demographics/generation': { + allowBlank: false, + nullable: false, + editorConfig: { + minValue: 0 + }, + columnConfig: { + width: 120 + } } } } diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/Death.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/Death.js index 84eb22a..39b7865 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Death.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Death.js @@ -36,11 +36,9 @@ EHR.model.DataModelManager.registerMetadata('Death', { nullable: false, columnConfig: { width: 160 - } - }, - reason: { - columnConfig: { - width: 160 + }, + lookup: { + filterArray: [LABKEY.Filter.create('date_disabled', null, LABKEY.Filter.Types.ISBLANK)] } }, remark: { diff --git a/nbri_ehr/resources/web/nbri_ehr/model/sources/Pregnancy.js b/nbri_ehr/resources/web/nbri_ehr/model/sources/Pregnancy.js index c22335d..39df64e 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Pregnancy.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Pregnancy.js @@ -12,9 +12,6 @@ EHR.model.DataModelManager.registerMetadata('Pregnancy', { project: { hidden: true, }, - type: { - hidden: true, - }, diagnosis: { hidden: true, }, @@ -26,9 +23,18 @@ EHR.model.DataModelManager.registerMetadata('Pregnancy', { nullable: false, }, conceptId: { + allowBlank: false, + nullable: false, columnConfig: { width: 150 } + }, + // shares the delivery_mode lookup with study.birth, but is optional here: an outcome can be recorded + // before the delivery mode is known + type: { + columnConfig: { + width: 200 + } } }, diff --git a/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js b/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js index 9c33be5..3557aa7 100644 --- a/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js +++ b/nbri_ehr/resources/web/nbri_ehr/window/StartWithConceptionWindow.js @@ -89,8 +89,8 @@ Ext4.define('NBRI_EHR.window.StartWithConceptionWindow', { var sire = record.get('Sire'); btn.disable(); - this.getSpecies(dam, function(species, speciesError){ - this.applyConception(conceptId, dam, sire, species); + this.getDamAttributes(dam, function(damAttributes, speciesError){ + this.applyConception(conceptId, dam, sire, damAttributes.species, damAttributes.generation); btn.enable(); this.close(); @@ -102,47 +102,60 @@ Ext4.define('NBRI_EHR.window.StartWithConceptionWindow', { }, this); }, - // the species of the offspring is inferred from the dam of the conception. When it cannot be determined the - // callback receives a message explaining why, rather than a null that is indistinguishable from an unset field. - getSpecies: function(dam, callback, scope){ + // the species and generation of the offspring are both inferred from the dam of the conception. A species that + // cannot be determined comes back with a message explaining why, rather than a null that is indistinguishable from + // an unset field; a dam with no generation is not an error and simply leaves the offspring at generation 1. + getDamAttributes: function(dam, callback, scope){ if (!dam){ - callback.call(scope, null, 'The conception record has no dam, so the species could not be determined.'); + callback.call(scope, {species: null, generation: this.nextGeneration(null)}, + 'The conception record has no dam, so the species could not be determined.'); return; } LABKEY.Query.selectRows({ schemaName: 'study', queryName: 'demographics', - columns: 'Id,species', + columns: 'Id,species,generation', filterArray: [LABKEY.Filter.create('Id', dam, LABKEY.Filter.Types.EQUAL)], scope: this, success: function(results){ var rows = (results && results.rows) || []; if (!rows.length){ - callback.call(scope, null, 'No demographics record was found for dam ' + dam + '.'); + callback.call(scope, {species: null, generation: this.nextGeneration(null)}, + 'No demographics record was found for dam ' + dam + '.'); return; } + var generation = this.nextGeneration(rows[0].generation); + if (!rows[0].species){ - callback.call(scope, null, 'No species is recorded on the demographics record for dam ' + dam + '.'); + callback.call(scope, {species: null, generation: generation}, + 'No species is recorded on the demographics record for dam ' + dam + '.'); return; } - callback.call(scope, rows[0].species); + callback.call(scope, {species: rows[0].species, generation: generation}); }, failure: function(error){ console.error(error); - callback.call(scope, null, 'Unable to look up the species of dam ' + dam + ': ' + ((error && error.exception) || 'the query failed') + '.'); + callback.call(scope, {species: null, generation: this.nextGeneration(null)}, + 'Unable to look up the species of dam ' + dam + ': ' + ((error && error.exception) || 'the query failed') + '.'); } }); }, - applyConception: function(conceptId, dam, sire, species){ + // a founder dam carries no generation of her own, so her offspring start the count at 1 + nextGeneration: function(damGeneration){ + return (damGeneration == null ? 0 : damGeneration) + 1; + }, + + applyConception: function(conceptId, dam, sire, species, generation){ var values = { conceptId: conceptId, 'Id/demographics/dam': dam, 'Id/demographics/sire': sire, - 'Id/demographics/species': species + 'Id/demographics/species': species, + 'Id/demographics/generation': generation }; // only the fields the conception owns are written, so anything already entered on the row survives diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIArrivalFormSection.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIArrivalFormSection.java index 5707656..46d0204 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIArrivalFormSection.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIArrivalFormSection.java @@ -55,8 +55,10 @@ protected List getFieldKeys(TableInfo ti) keys.add(indexOf(keys, "project") + 1, FieldKey.fromString("Id/demographics/geographic_origin")); - // the social code sits beside Initial Location - keys.add(indexOf(keys, "cage") + 1, FieldKey.fromString("Id/demographics/socialCode")); + // the social code and generation sit beside Initial Location + keys.addAll(indexOf(keys, "cage") + 1, List.of( + FieldKey.fromString("Id/demographics/socialCode"), + FieldKey.fromString("Id/demographics/generation"))); return keys; } diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIBirthFormSection.java b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIBirthFormSection.java index 8088c16..12907ca 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIBirthFormSection.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/dataentry/section/NBRIBirthFormSection.java @@ -38,6 +38,7 @@ public class NBRIBirthFormSection extends NewAnimalFormSection FieldKey.fromString("Id/demographics/sire"), FieldKey.fromString("cage"), FieldKey.fromString("Id/demographics/socialCode"), + FieldKey.fromString("Id/demographics/generation"), FieldKey.fromString("project"), FieldKey.fromString("birthProtocol"), FieldKey.fromString("groupId"), diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/history/DeathDataSource.java b/nbri_ehr/src/org/labkey/nbri_ehr/history/DeathDataSource.java index 1e00fcd..ae3fc77 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/history/DeathDataSource.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/history/DeathDataSource.java @@ -35,7 +35,7 @@ public DeathDataSource(Module module) @Override protected Set getColumnNames() { - return PageFlowUtil.set("Id", "date", "reason/title", "remark"); + return PageFlowUtil.set("Id", "date", "remark"); } @Override @@ -43,16 +43,6 @@ protected String getHtml(Container c, Results rs, boolean redacted) throws SQLEx { StringBuilder sb = new StringBuilder(); - if(rs.hasColumn(FieldKey.fromString("reason/title")) && rs.getObject(FieldKey.fromString("reason/title")) != null) - { - sb.append(safeAppend(rs, "Disposition", "reason/title")); - } - else - { - sb.append("Disposition: Unknown"); - sb.append("\n"); - } - if(rs.hasColumn(FieldKey.fromString("remark")) && rs.getObject(FieldKey.fromString("remark")) != null) sb.append(safeAppend(rs, "Remark", "remark")); diff --git a/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java b/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java index d5c01d8..30724df 100644 --- a/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java +++ b/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java @@ -300,6 +300,21 @@ public boolean animalIdExists(String id) return ts.exists(); } + /** + * Null both when the animal has no demographics record and when it has one carrying no generation; birth.js treats + * the two the same, so the caller never needs to tell them apart. + */ + public Integer getGeneration(String id) + { + TableInfo ti = getTableInfo("study", "demographics"); + if (null == ti.getColumn("generation")) + throw new IllegalStateException("The demographics dataset has no 'generation' column. Import the reference study to add it."); + + TableSelector ts = new TableSelector(ti, PageFlowUtil.set("generation"), new SimpleFilter(FieldKey.fromString("Id"), id), null); + + return ts.getObject(Integer.class); + } + public boolean birthExists(String id) { TableInfo ti = getTableInfo("study", "birth"); @@ -584,11 +599,10 @@ public void sendDeathNotification(final String animalId) //get death info TableInfo deaths = getTableInfo("study", "deathNotification"); - TableSelector deathsTs = new TableSelector(deaths, PageFlowUtil.set("Id", "date", "taskid", "performedBy", "reason"), new SimpleFilter(FieldKey.fromString("Id"), animalId), null); + TableSelector deathsTs = new TableSelector(deaths, PageFlowUtil.set("Id", "date", "taskid", "performedBy"), new SimpleFilter(FieldKey.fromString("Id"), animalId), null); final Mutable deathDate = new MutableObject<>(); final Mutable taskId = new MutableObject<>(); final Mutable performedBy = new MutableObject<>(); - final Mutable disposition = new MutableObject<>(); deathsTs.forEach(rs -> { if (rs.getString("date") != null) { @@ -596,7 +610,6 @@ public void sendDeathNotification(final String animalId) deathDate.setValue(date); taskId.setValue(rs.getString("taskid")); performedBy.setValue(rs.getString("performedBy")); - disposition.setValue(rs.getString("reason")); } }); @@ -609,8 +622,7 @@ public void sendDeathNotification(final String animalId) return; } html.append("Animal '").append(PageFlowUtil.filter(animalId)).append("' has been declared dead on '").append(_dateFormat.format(deathDate.get())).append("'.
"); - html.append("Performed By: ").append(PageFlowUtil.filter(performedBy.get())).append("
"); - html.append("Disposition: ").append(PageFlowUtil.filter(disposition.get())).append("

"); + html.append("Performed By: ").append(PageFlowUtil.filter(performedBy.get())).append("

"); //append animal details appendAnimalDetails(html, animalId, container); diff --git a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java index f1619ec..9861c69 100644 --- a/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java +++ b/nbri_ehr/test/src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java @@ -696,6 +696,12 @@ public void testArrivalForm() throws IOException, CommandException waitForFormError("The field: Social Code is required"); arrivals.setGridCell(1, "Id/demographics/socialCode", socialCode); + log("Verifying Generation is seeded to 0 and is required"); + assertEquals("An arriving animal should start at generation 0", "0", String.valueOf(arrivals.getFieldValue(1, "Id/demographics/generation"))); + arrivals.setGridCellJS(1, "Id/demographics/generation", null); + waitForFormError("The field: Generation is required"); + arrivals.setGridCellJS(1, "Id/demographics/generation", 0); + // the animal's opening project, protocol and group are entered on the arrival row itself; the trigger script // opens the matching assignment record for each one arrivals.setGridCell(1, "project", "640991"); @@ -754,6 +760,8 @@ public void testArrivalForm() throws IOException, CommandException table.setFilter("Id", "Equals", arrivedAnimal); Assert.assertEquals("Social code entered on the arrival form did not reach demographics", Arrays.asList(socialCode), table.getRowDataAsText(0, "socialCode")); + Assert.assertEquals("Generation seeded by the arrival form did not reach demographics", + Arrays.asList("0"), table.getRowDataAsText(0, "generation")); log("Verifying the birth date reached demographics and agrees with the birth record"); String arrivalBirthDay = now.minusDays(7).format(_dateFormat); @@ -774,6 +782,7 @@ public void testBirthForm() throws Exception String damSpecies = "Brown-Tufted Capuchin"; String conceptId = "TESTCONCEPT1"; String breedingType = "Time-Mated"; + int damGeneration = 2; // demographics.socialCode holds an ehr_lookups.social_code code; the grids display its title String socialCode = "Mother-rearing (for indoors)"; // the group is an ehr_lookups.breeding_type code, carried to animal_group_members; the grids display its title @@ -781,7 +790,7 @@ public void testBirthForm() throws Exception LocalDateTime now = LocalDateTime.now(); log("Creating the dam and sire of the conception"); - createBreedingPair(damId, sireId, damSpeciesCode); + createBreedingPair(damId, sireId, damSpeciesCode, damGeneration); log("Creating conception record"); InsertRowsCommand conception = new InsertRowsCommand("nbri_ehr", "Conception"); @@ -806,6 +815,8 @@ public void testBirthForm() throws Exception assertEquals("Dam was not copied from the conception", damId, births.getFieldValue(1, "Id/demographics/dam")); assertEquals("Sire was not copied from the conception", sireId, births.getFieldValue(1, "Id/demographics/sire")); assertEquals("Species was not copied from the dam of the conception", damSpeciesCode, births.getFieldValue(1, "Id/demographics/species")); + assertEquals("Generation was not derived from the dam of the conception", String.valueOf(damGeneration + 1), + String.valueOf(births.getFieldValue(1, "Id/demographics/generation"))); log("Verifying Conception Id is required"); births.setGridCellJS(1, "conceptId", null); @@ -824,6 +835,11 @@ public void testBirthForm() throws Exception waitForFormError("The field: Social Code is required"); births.setGridCell(1, "Id/demographics/socialCode", socialCode); + log("Verifying Generation is required"); + births.setGridCellJS(1, "Id/demographics/generation", null); + waitForFormError("The field: Generation is required"); + births.setGridCellJS(1, "Id/demographics/generation", damGeneration + 1); + // the animal's opening project, protocol and group are entered on the birth row itself; the trigger script // opens the matching assignment record for each one births.setGridCell(1, "project", "795644"); @@ -862,6 +878,8 @@ public void testBirthForm() throws Exception Assert.assertEquals("Invalid demographics record", Arrays.asList(damSpecies), table.getRowDataAsText(0, "species")); Assert.assertEquals("Social code entered on the birth form did not reach demographics", Arrays.asList(socialCode), table.getRowDataAsText(0, "socialCode")); + Assert.assertEquals("Generation derived from the dam did not reach demographics", + Arrays.asList(String.valueOf(damGeneration + 1)), table.getRowDataAsText(0, "generation")); goToSchemaBrowser(); table = viewQueryData("study", "assignment"); @@ -939,9 +957,11 @@ public void testDuplicateConceptionRejected() throws Exception log("Entering two births that both claim the first conception"); startWithConception(births, firstConcept, 1); - fillBirthRow(births, 1, firstAnimal, now.minusDays(1), socialCode, animalGroup); + assertEquals("A dam with no generation of her own should leave the birth at generation 1", "1", + String.valueOf(births.getFieldValue(1, "Id/demographics/generation"))); + fillBirthRow(births, 1, firstAnimal, now.minusDays(1), socialCode, animalGroup, CAGE_IN_R2); startWithConception(births, firstConcept, 2); - fillBirthRow(births, 2, secondAnimal, now.minusDays(1), socialCode, animalGroup); + fillBirthRow(births, 2, secondAnimal, now.minusDays(1), socialCode, animalGroup, CAGE_IN_R3); // Live validation only sends the row that just changed, so the rows of one form entry first reach the // server together on submit. A rule that compares them therefore reports by refusing the save rather than @@ -984,6 +1004,9 @@ public void testConceptionPickedFromGridCell() throws Exception // different species on each pair, so the copy from the newly picked dam is visible String firstSpeciesCode = "CAP"; String secondSpeciesCode = "MMU"; + // different generations on each dam, so the re-derivation from the newly picked dam is visible + int firstDamGeneration = 2; + int secondDamGeneration = 5; String firstConcept = "TESTCONCEPT6"; String secondConcept = "TESTCONCEPT7"; String socialCode = "Mother-rearing (for indoors)"; @@ -991,8 +1014,8 @@ public void testConceptionPickedFromGridCell() throws Exception LocalDateTime now = LocalDateTime.now(); log("Creating a breeding pair and a conception for each"); - createBreedingPair(firstDam, firstSire, firstSpeciesCode); - createBreedingPair(secondDam, secondSire, secondSpeciesCode); + createBreedingPair(firstDam, firstSire, firstSpeciesCode, firstDamGeneration); + createBreedingPair(secondDam, secondSire, secondSpeciesCode, secondDamGeneration); InsertRowsCommand conceptions = new InsertRowsCommand("nbri_ehr", "Conception"); conceptions.addRow(Map.of("ConceptId", firstConcept, "ConceptDate", now.minusDays(200), "Dam", firstDam, "Sire", firstSire)); @@ -1005,7 +1028,7 @@ public void testConceptionPickedFromGridCell() throws Exception Ext4GridRef births = _helper.getExt4GridForFormSection("Births"); startWithConception(births, firstConcept, 1); - fillBirthRow(births, 1, bornAnimal, now.minusDays(1), socialCode, animalGroup); + fillBirthRow(births, 1, bornAnimal, now.minusDays(1), socialCode, animalGroup, CAGE_IN_R1); births.setGridCell(1, "breedingType", "Time-Mated"); // the codes behind these lookups are not spelled out in the test, so remember what the row carries and @@ -1036,6 +1059,8 @@ public void testConceptionPickedFromGridCell() throws Exception assertEquals("Dam was not replaced from the picked conception", secondDam, births.getFieldValue(1, "Id/demographics/dam")); assertEquals("Sire was not replaced from the picked conception", secondSire, births.getFieldValue(1, "Id/demographics/sire")); assertEquals("Species was not replaced from the dam of the picked conception", secondSpeciesCode, births.getFieldValue(1, "Id/demographics/species")); + assertEquals("Generation was not re-derived from the dam of the picked conception", String.valueOf(secondDamGeneration + 1), + String.valueOf(births.getFieldValue(1, "Id/demographics/generation"))); log("Verifying nothing else on the row was touched"); assertEquals("Animal Id should have been left alone", bornAnimal, births.getFieldValue(1, "Id")); @@ -1072,6 +1097,7 @@ public void testPregnancyForm() throws IOException, CommandException String conceptId = "TESTCONCEPT2"; // a non-live outcome, so ConceptionsByDam reports it rather than falling through to 'Live Birth' String result = "Fetal Death"; + String deliveryMode = "Vaginal"; LocalDateTime now = LocalDateTime.now(); log("Creating conception record"); @@ -1089,6 +1115,7 @@ public void testPregnancyForm() throws IOException, CommandException outcomes.setGridCell(1, "Id", animalId); outcomes.setGridCell(1, "result", result); outcomes.setGridCell(1, "conceptId", conceptId); + outcomes.setGridCell(1, "type", deliveryMode); submitForm("Submit Final", "Finalize"); goToSchemaBrowser(); @@ -1097,6 +1124,7 @@ public void testPregnancyForm() throws IOException, CommandException Assert.assertEquals("Invalid Pregnancy Outcome record", Arrays.asList(animalId), table.getRowDataAsText(0, "Id")); Assert.assertEquals("Invalid Pregnancy Outcome record", Arrays.asList(result), table.getRowDataAsText(0, "result")); Assert.assertEquals("Invalid Pregnancy Outcome record", Arrays.asList(conceptId), table.getRowDataAsText(0, "conceptId")); + Assert.assertEquals("Invalid Pregnancy Outcome record", Arrays.asList(deliveryMode), table.getRowDataAsText(0, "type")); log("Verifying conception outcome in ConceptionsByDam"); goToSchemaBrowser(); @@ -1658,7 +1686,7 @@ public void createSubjectsForDeathForm() throws IOException, CommandException log("Marking an animal dead"); InsertRowsCommand deaths = new InsertRowsCommand("study", "deaths"); - deaths.addRow(Map.of("Id", deadAnimalId, "date", LocalDateTime.now().minusDays(10), "reason", "4", "performedby", 1004)); + deaths.addRow(Map.of("Id", deadAnimalId, "date", LocalDateTime.now().minusDays(10), "performedby", 1004)); deaths.execute(getApiHelper().getConnection(), getContainerPath()); log("Marking an animal departed"); @@ -1700,7 +1728,6 @@ public void testDeathNecropsyForm() throws IOException, CommandException setFormElement(Locator.name("Id"), aliveAnimalId); _ext4Helper.selectComboBoxItem("Death Type:", "Spontaneous/Normal"); - _ext4Helper.selectComboBoxItem("Disposition:", "Euthaniasia (project)"); waitForElement(Locator.name("deathWeight")); setFormElement(Locator.name("deathWeight"), "23"); Assert.assertFalse(isElementPresent(Locator.linkWithText("Submit Necropsy for Review"))); @@ -1710,8 +1737,8 @@ public void testDeathNecropsyForm() throws IOException, CommandException log("Verify a second death insert is rejected with a validation error, not a unique constraint violation"); SimplePostCommand duplicateDeath = getApiHelper().prepareInsertCommand("study", "deaths", "lsid", - new String[]{"Id", "date", "reason", "performedby"}, - new Object[][]{{aliveAnimalId, LocalDateTime.now(), "4", 1004}}); + new String[]{"Id", "date", "performedby"}, + new Object[][]{{aliveAnimalId, LocalDateTime.now(), 1004}}); CommandException duplicateError = getApiHelper().doSaveRowsExpectingError(DATA_ADMIN.getEmail(), duplicateDeath, getExtraContext()); Map> duplicateErrors = getApiHelper().extractErrors(duplicateError.getProperties()); Assert.assertTrue("Expected duplicate death validation error, got: " + duplicateErrors, @@ -1854,7 +1881,7 @@ public void testDeathDeleteRestoresDepartedStatus() throws Exception // the death has to be recorded before the departure: the deaths trigger rejects an animal that has shipped log("Recording the death"); InsertRowsCommand deaths = new InsertRowsCommand("study", "deaths"); - deaths.addRow(Map.of("Id", animalId, "date", now.minusDays(10), "reason", "4", "QCStateLabel", "Completed", "performedby", 1004)); + deaths.addRow(Map.of("Id", animalId, "date", now.minusDays(10), "QCStateLabel", "Completed", "performedby", 1004)); deaths.execute(getApiHelper().getConnection(), getContainerPath()); assertEquals("Demographics death date does not match the death record", @@ -2360,12 +2387,14 @@ private void startWithConception(Ext4GridRef births, String conceptId, int expec } // Fills in everything a birth row needs beyond what the conception supplies, so the form can be submitted. - // Birth Location is left blank on purpose: it is optional, and skipping it keeps housing out of these tests. + // Generation is not set here: the conception supplies it from the dam. Each row takes its own cage so that a + // co-housing or capacity rule can never be what fails these tests. private void fillBirthRow(Ext4GridRef births, int rowIdx, String animalId, LocalDateTime birthDate, - String socialCode, String animalGroup) + String socialCode, String animalGroup, String cage) { births.setGridCellJS(rowIdx, "date", birthDate.format(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT_STRING))); births.setGridCell(rowIdx, "Id", animalId); + births.setGridCell(rowIdx, "cage", cage); births.setGridCell(rowIdx, "Id/demographics/gender", "Female"); births.setGridCell(rowIdx, "Id/demographics/socialCode", socialCode); births.setGridCell(rowIdx, "project", "795644"); @@ -2375,10 +2404,16 @@ private void fillBirthRow(Ext4GridRef births, int rowIdx, String animalId, Local private void createBreedingPair(String damId, String sireId, String species) throws Exception { - String[] fields = new String[]{"Id", "Species", "Birth", "Gender", "date", "calculated_status", "objectid", "performedby"}; + createBreedingPair(damId, sireId, species, null); + } + + // A null damGeneration leaves the dam with no generation of her own, which is what makes her offspring generation 1. + private void createBreedingPair(String damId, String sireId, String species, Integer damGeneration) throws Exception + { + String[] fields = new String[]{"Id", "Species", "Birth", "Gender", "date", "calculated_status", "objectid", "performedby", "generation"}; Object[][] data = new Object[][]{ - {damId, species, (new Date()).toString(), getFemale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004}, - {sireId, species, (new Date()).toString(), getMale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004} + {damId, species, (new Date()).toString(), getFemale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004, damGeneration}, + {sireId, species, (new Date()).toString(), getMale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004, null} }; SimplePostCommand insertCommand = getApiHelper().prepareInsertCommand("study", "demographics", "lsid", fields, data); getApiHelper().deleteAllRecords("study", "demographics", new Filter("Id", damId + ";" + sireId, Filter.Operator.IN)); @@ -2390,7 +2425,8 @@ private void createBreedingPair(String damId, String sireId, String species) thr private void verifyBirthColumnOrder(Ext4GridRef births) { List expectedOrder = List.of("Id", "date", "conceptId", "Id/demographics/species", "Id/demographics/gender", - "Id/demographics/dam", "Id/demographics/sire", "cage", "Id/demographics/socialCode", "project", + "Id/demographics/dam", "Id/demographics/sire", "cage", "Id/demographics/socialCode", + "Id/demographics/generation", "project", "birthProtocol", "groupId", "type", "breedingType", "remark", "performedby"); int previousIdx = 0;