From 9b32536b05b99b6e3f63ea7e1a5d6de0ee85cd8b Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Wed, 2 Sep 2026 17:30:26 -0600 Subject: [PATCH 1/8] Require the initial location on the birth and arrival forms --- nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js | 3 ++- nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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..9b1f377 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 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..20b969b 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 From 513658cd1046810e0a41ce86948d5f57c38e9e49 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 3 Sep 2026 17:07:36 -0600 Subject: [PATCH 2/8] Record a generation for animals entered on the birth and arrival forms Generation lives on demographics beside the social code: arrivals start at 0, and births take the dam's generation plus one, falling back to 1 when she has none. The new column has to be imported into an existing study before the forms will accept it, and the birth test now sets a location, which became required in the previous commit. --- nbri_ehr/resources/queries/study/arrival.js | 11 +++++ nbri_ehr/resources/queries/study/birth.js | 21 ++++++++ .../queries/study/demographics.query.xml | 3 ++ .../queries/study/demographics/.qview.xml | 1 + .../study/datasets/datasets_metadata.xml | 3 ++ .../web/nbri_ehr/model/sources/Arrival.js | 15 ++++++ .../web/nbri_ehr/model/sources/Birth.js | 11 +++++ .../window/StartWithConceptionWindow.js | 39 ++++++++++----- .../section/NBRIArrivalFormSection.java | 6 ++- .../section/NBRIBirthFormSection.java | 1 + .../nbri_ehr/query/NBRI_EHRTriggerHelper.java | 12 +++++ .../tests.nbri_ehr/NBRI_EHRTest.java | 49 ++++++++++++++++--- 12 files changed, 149 insertions(+), 23 deletions(-) diff --git a/nbri_ehr/resources/queries/study/arrival.js b/nbri_ehr/resources/queries/study/arrival.js index d374944..2afbe46 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'); } + // the arrival form seeds this to 0, so a blank one means an insert that bypassed the form + if (!row.rearrival && !helper.isETL() && 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 : row['Id/demographics/generation']; if (row.QCStateLabel) { row.qcstate = helper.getJavaHelper().getQCStateForLabel(row.QCStateLabel).getRowId(); diff --git a/nbri_ehr/resources/queries/study/birth.js b/nbri_ehr/resources/queries/study/birth.js index 6531bc9..8a93d60 100644 --- a/nbri_ehr/resources/queries/study/birth.js +++ b/nbri_ehr/resources/queries/study/birth.js @@ -13,6 +13,11 @@ var idsToSync = []; // 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) @@ -146,6 +151,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 : row['Id/demographics/generation'], taskid: row.taskid, remark: row.remark, QCStateLabel: row.QCStateLabel, @@ -161,6 +167,16 @@ 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) { + EHR.Server.Utils.addError(scriptErrors, 'Id/demographics/generation', 'No generation is recorded for dam ' + (obj.dam || '(none)') + ', so this birth was recorded as generation 1', '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); @@ -198,6 +214,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/demographics.query.xml b/nbri_ehr/resources/queries/study/demographics.query.xml index 0323b4a..6ca4511 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/referenceStudy/study/datasets/datasets_metadata.xml b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml index eed9f6a..e0de176 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 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 9b1f377..26a10c4 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js @@ -56,6 +56,21 @@ EHR.model.DataModelManager.registerMetadata('Arrival', { width: 200 } }, + // an arriving animal founds its own line, 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 20b969b..a961152 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Birth.js @@ -146,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/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/query/NBRI_EHRTriggerHelper.java b/nbri_ehr/src/org/labkey/nbri_ehr/query/NBRI_EHRTriggerHelper.java index b1ed437..f0e0874 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,18 @@ 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"); + 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"); 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..ae62811 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"); @@ -803,6 +812,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); @@ -821,6 +832,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"); @@ -859,6 +875,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"); @@ -931,6 +949,8 @@ public void testDuplicateConceptionRejected() throws Exception log("Entering two births that both claim the first conception"); startWithConception(births, firstConcept, 1); + 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); startWithConception(births, firstConcept, 2); fillBirthRow(births, 2, secondAnimal, now.minusDays(1), socialCode, animalGroup); @@ -976,6 +996,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)"; @@ -983,8 +1006,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)); @@ -1028,6 +1051,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")); @@ -2321,12 +2346,13 @@ 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. private void fillBirthRow(Ext4GridRef births, int rowIdx, String animalId, LocalDateTime birthDate, String socialCode, String animalGroup) { births.setGridCellJS(rowIdx, "date", birthDate.format(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT_STRING))); births.setGridCell(rowIdx, "Id", animalId); + births.setGridCell(rowIdx, "cage", CAGE_IN_R2); births.setGridCell(rowIdx, "Id/demographics/gender", "Female"); births.setGridCell(rowIdx, "Id/demographics/socialCode", socialCode); births.setGridCell(rowIdx, "project", "795644"); @@ -2336,10 +2362,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)); @@ -2351,7 +2383,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; From 1ebb651cbda68b99945e83e1efb124b43e1b7927 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 3 Sep 2026 19:43:04 -0600 Subject: [PATCH 3/8] Disable retired death types and drop the death disposition field Death types that are no longer valid for new entry stay in the lookup as disabled so the historical rows that use them still resolve. Removing disposition from the deaths form and dataset leaves death_reason unused, so it is deregistered too. --- 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 - .../queries/study/deathNotification.sql | 3 +-- .../resources/queries/study/deaths.query.xml | 9 --------- .../resources/queries/study/deaths/.qview.xml | 1 - .../study/datasets/datasets_metadata.xml | 3 --- nbri_ehr/resources/views/necropsy.html | 2 +- .../web/nbri_ehr/model/sources/Death.js | 8 +++----- .../nbri_ehr/history/DeathDataSource.java | 12 +----------- .../nbri_ehr/query/NBRI_EHRTriggerHelper.java | 7 ++----- .../tests.nbri_ehr/NBRI_EHRTest.java | 4 ++-- 15 files changed, 19 insertions(+), 58 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/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 e28e2f0..1cb8b02 100644 --- a/nbri_ehr/resources/queries/study/deaths.query.xml +++ b/nbri_ehr/resources/queries/study/deaths.query.xml @@ -16,15 +16,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/referenceStudy/study/datasets/datasets_metadata.xml b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml index e0de176..378705d 100644 --- a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml +++ b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml @@ -564,9 +564,6 @@ http://cpas.labkey.com/Study#VisitDate http://cpas.labkey.com/Study#VisitDate - - varchar - double 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/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/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 f0e0874..edc07f4 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 @@ -596,11 +596,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) { @@ -608,7 +607,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")); } }); @@ -621,8 +619,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 ae62811..23ff48b 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 @@ -1644,7 +1644,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"); @@ -1840,7 +1840,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", From b8899ca554efea31acda878d81bf4c6648038884 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 3 Sep 2026 19:44:16 -0600 Subject: [PATCH 4/8] Add delivery mode and require conception Id on pregnancy outcomes Delivery mode reuses the pregnancy dataset's unused type column and the delivery_mode lookup that study.birth already points at. --- nbri_ehr/resources/queries/study/pregnancy.query.xml | 10 ++++++++++ .../study/datasets/datasets_metadata.xml | 1 - .../web/nbri_ehr/model/sources/Pregnancy.js | 12 +++++++++--- .../org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java | 3 +++ 4 files changed, 22 insertions(+), 4 deletions(-) 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 378705d..b036dfa 100644 --- a/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml +++ b/nbri_ehr/resources/referenceStudy/study/datasets/datasets_metadata.xml @@ -1367,7 +1367,6 @@ http://cpas.labkey.com/Study#VisitDate
- Type varchar 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/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 23ff48b..733b5c5 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 @@ -1089,6 +1089,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"); @@ -1106,6 +1107,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(); @@ -1114,6 +1116,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(); From 92a9c174394ee75289dd0250840582fcef34b1dc Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 4 Sep 2026 13:18:29 -0600 Subject: [PATCH 5/8] Clarify the generation comment on the arrival form --- nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 26a10c4..af420c2 100644 --- a/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js +++ b/nbri_ehr/resources/web/nbri_ehr/model/sources/Arrival.js @@ -56,7 +56,7 @@ EHR.model.DataModelManager.registerMetadata('Arrival', { width: 200 } }, - // an arriving animal founds its own line, so it starts at generation 0 + // an arriving animal establishes its own lineage, so it starts at generation 0 'Id/demographics/generation': { allowBlank: false, nullable: false, From 32bc6b8b1d8e361ae06a60f45c3d850f58f546ad Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Fri, 4 Sep 2026 14:04:32 -0600 Subject: [PATCH 6/8] Fix generation handling on the arrival and birth triggers The arrival trigger required a generation on every upsert and never propagated a corrected one to demographics; both now follow the scoping and the update clause the neighboring fields already use. Also coerces the value before comparing it, names the real cause when a birth has no dam at all, reports a missing generation column directly, and gives each birth test row its own cage. --- nbri_ehr/resources/queries/study/arrival.js | 10 ++++++++-- nbri_ehr/resources/queries/study/birth.js | 7 +++++-- .../nbri_ehr/query/NBRI_EHRTriggerHelper.java | 3 +++ .../tests.nbri_ehr/NBRI_EHRTest.java | 13 +++++++------ 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/nbri_ehr/resources/queries/study/arrival.js b/nbri_ehr/resources/queries/study/arrival.js index 2afbe46..13228f6 100644 --- a/nbri_ehr/resources/queries/study/arrival.js +++ b/nbri_ehr/resources/queries/study/arrival.js @@ -52,7 +52,7 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even } // the arrival form seeds this to 0, so a blank one means an insert that bypassed the form - if (!row.rearrival && !helper.isETL() && isBlankGeneration(row['Id/demographics/generation'])) { + if (!row.rearrival && !helper.isETL() && !helper.isGeneratedByServer() && helper.getEvent() == 'insert' && isBlankGeneration(row['Id/demographics/generation'])) { EHR.Server.Utils.addError(scriptErrors, 'Id/demographics/generation', 'Generation is required', 'ERROR'); } @@ -79,7 +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 : row['Id/demographics/generation']; + 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(); @@ -163,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 8a93d60..fec63c3 100644 --- a/nbri_ehr/resources/queries/study/birth.js +++ b/nbri_ehr/resources/queries/study/birth.js @@ -151,7 +151,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 : row['Id/demographics/generation'], + generation: isBlankGeneration(row['Id/demographics/generation']) ? null : parseInt(row['Id/demographics/generation'], 10), taskid: row.taskid, remark: row.remark, QCStateLabel: row.QCStateLabel, @@ -171,7 +171,10 @@ EHR.Server.TriggerManager.registerHandlerForQuery(EHR.Server.TriggerManager.Even if (isBlankGeneration(obj.generation)) { var damGeneration = obj.dam ? triggerHelper.getGeneration(obj.dam) : null; if (damGeneration === null) { - EHR.Server.Utils.addError(scriptErrors, 'Id/demographics/generation', 'No generation is recorded for dam ' + (obj.dam || '(none)') + ', so this birth was recorded as generation 1', 'WARN'); + 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; 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 edc07f4..6f2cce2 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 @@ -307,6 +307,9 @@ public boolean animalIdExists(String id) 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); 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 733b5c5..781f5e9 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 @@ -951,9 +951,9 @@ public void testDuplicateConceptionRejected() throws Exception startWithConception(births, firstConcept, 1); 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); + 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 @@ -1020,7 +1020,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 @@ -2349,13 +2349,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. - // Generation is not set here: the conception supplies it from the dam. + // 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_IN_R2); + births.setGridCell(rowIdx, "cage", cage); births.setGridCell(rowIdx, "Id/demographics/gender", "Female"); births.setGridCell(rowIdx, "Id/demographics/socialCode", socialCode); births.setGridCell(rowIdx, "project", "795644"); From 543d29231a022535a80b6d85c59384cd98ee959c Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Sat, 5 Sep 2026 05:30:58 -0600 Subject: [PATCH 7/8] Scope the arrival generation requirement to form entry A study import carries no generation column, so the guard rejected every row of the sample-data arrival dataset and failed the folder import in test setup. --- nbri_ehr/resources/queries/study/arrival.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nbri_ehr/resources/queries/study/arrival.js b/nbri_ehr/resources/queries/study/arrival.js index 13228f6..9ca4acf 100644 --- a/nbri_ehr/resources/queries/study/arrival.js +++ b/nbri_ehr/resources/queries/study/arrival.js @@ -51,8 +51,8 @@ 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'); } - // the arrival form seeds this to 0, so a blank one means an insert that bypassed the form - if (!row.rearrival && !helper.isETL() && !helper.isGeneratedByServer() && helper.getEvent() == 'insert' && isBlankGeneration(row['Id/demographics/generation'])) { + // 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'); } From f16d045c1383c937c42522d6384f928d1e7b62b3 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Sat, 5 Sep 2026 06:59:17 -0600 Subject: [PATCH 8/8] Drop the removed disposition field from the death form test The Disposition combo and the deaths.reason column are both gone on this branch, so testDeathNecropsyForm timed out selecting the combo and would then have inserted a column that no longer exists. --- .../src/org.labkey.test/tests.nbri_ehr/NBRI_EHRTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 781f5e9..a241bb4 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 @@ -1689,7 +1689,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"))); @@ -1699,8 +1698,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,