diff --git a/specimen/build.gradle b/specimen/build.gradle
new file mode 100644
index 00000000..c746886c
--- /dev/null
+++ b/specimen/build.gradle
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2021-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.
+ */
+import org.labkey.gradle.util.BuildUtils
+
+plugins {
+ id 'org.labkey.build.module'
+}
+
+dependencies {
+ BuildUtils.addLabKeyDependency(project: project, config: "implementation", depProjectPath: BuildUtils.getPlatformModuleProjectPath(project.gradle, "study"), depProjectConfig: "apiJarFile")
+ BuildUtils.addLabKeyDependency(project: project, config: "jspImplementation", depProjectPath: BuildUtils.getPlatformModuleProjectPath(project.gradle, "study"), depProjectConfig: "apiJarFile")
+
+ BuildUtils.addLabKeyDependency(project: project, config: "modules", depProjectPath: BuildUtils.getPlatformModuleProjectPath(project.gradle, "study"), depProjectConfig: "published", depExtension: "module")
+}
diff --git a/specimen/module.properties b/specimen/module.properties
new file mode 100644
index 00000000..b069d0b2
--- /dev/null
+++ b/specimen/module.properties
@@ -0,0 +1,8 @@
+ModuleClass: org.labkey.specimen.SpecimenModule
+Label: Legacy specimen tracking system
+Description: A system for tracking specimens as part of a study. \
+ This system has been deprecated, replaced by LabKey Sample Manager.
+URL: https://www.labkey.com/products-services/sample-management-software/
+License: Apache 2.0
+LicenseURL: http://www.apache.org/licenses/LICENSE-2.0
+ManageVersion: true
diff --git a/specimen/schemas/specimens.xsd b/specimen/schemas/specimens.xsd
new file mode 100644
index 00000000..416ad0ef
--- /dev/null
+++ b/specimen/schemas/specimens.xsd
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+ Provides information on the files that describe the specimens in the study.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Indicates the type of repository.
+ See Manage Specimens.
+
+
+
+
+
+ True if specimens can be requested in this repository.
+
+
+
+
+ True if specimens can be edited directly.
+
+
+
+
+
+
+
+ True if specimen requests are allowed from this location type.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/specimen/src/org/labkey/specimen/AmbiguousLocationException.java b/specimen/src/org/labkey/specimen/AmbiguousLocationException.java
new file mode 100644
index 00000000..acc9a1d1
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/AmbiguousLocationException.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+import org.labkey.api.data.Container;
+import org.labkey.api.specimen.location.LocationImpl;
+import org.labkey.api.specimen.location.LocationManager;
+
+import java.util.Collection;
+
+public class AmbiguousLocationException extends Exception
+{
+ private final Container _container;
+ private final Collection _possibleLocationIds;
+
+ private LocationImpl[] _possibleLocations = null;
+
+ public AmbiguousLocationException(Container container, Collection possibleLocationIds)
+ {
+ _container = container;
+ _possibleLocationIds = possibleLocationIds;
+ }
+
+ public Collection getPossibleLocationIds()
+ {
+ return _possibleLocationIds;
+ }
+
+ public LocationImpl[] getPossibleLocations()
+ {
+ if (_possibleLocations == null)
+ {
+ _possibleLocations = new LocationImpl[_possibleLocationIds.size()];
+ int idx = 0;
+
+ for (Integer id : _possibleLocationIds)
+ _possibleLocations[idx++] = LocationManager.get().getLocation(_container, id.intValue());
+ }
+ return _possibleLocations;
+ }
+}
diff --git a/specimen/src/org/labkey/specimen/RequestEventType.java b/specimen/src/org/labkey/specimen/RequestEventType.java
new file mode 100644
index 00000000..cca053f3
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/RequestEventType.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+public enum RequestEventType
+{
+ REQUEST_CREATED("Request Created"),
+ REQUEST_STATUS_CHANGED("Request Status Changed"),
+ REQUIREMENT_ADDED("Requirement Created"),
+ REQUIREMENT_REMOVED("Requirement Removed"),
+ REQUIREMENT_UPDATED("Requirement Updated"),
+ REQUEST_UPDATED("Request Updated"),
+ SPECIMEN_ADDED("Specimen Added"),
+ SPECIMEN_REMOVED("Specimen Removed"),
+ SPECIMEN_LIST_GENERATED("Specimen List Generated"),
+ COMMENT_ADDED("Comment/Attachment(s) Added"),
+ NOTIFICATION_SENT("Notification Sent");
+
+ private final String _displayText;
+
+ RequestEventType(String displayText)
+ {
+ _displayText = displayText;
+ }
+
+ public String getDisplayText()
+ {
+ return _displayText;
+ }
+}
diff --git a/specimen/src/org/labkey/specimen/RequestedSpecimens.java b/specimen/src/org/labkey/specimen/RequestedSpecimens.java
new file mode 100644
index 00000000..3920219c
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/RequestedSpecimens.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+import org.labkey.api.data.Container;
+import org.labkey.api.specimen.Vial;
+import org.labkey.api.specimen.location.LocationManager;
+import org.labkey.api.study.Location;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+
+public class RequestedSpecimens
+{
+ private final Collection _providingLocationIds;
+ private final List _vials;
+
+ private List _providingLocations;
+
+ public RequestedSpecimens(List vials, Collection providingLocationIds)
+ {
+ _vials = vials;
+ _providingLocationIds = providingLocationIds;
+ }
+
+ public RequestedSpecimens(List vials)
+ {
+ _vials = vials;
+ _providingLocationIds = new HashSet<>();
+ if (vials != null)
+ {
+ for (Vial vial : vials)
+ _providingLocationIds.add(vial.getCurrentLocation());
+ }
+ }
+
+ public List getProvidingLocations()
+ {
+ if (_providingLocations == null)
+ {
+ if (_vials == null || _vials.isEmpty())
+ _providingLocations = Collections.emptyList();
+ else
+ {
+ Container container = _vials.getFirst().getContainer();
+ _providingLocations = new ArrayList<>(_providingLocationIds.size());
+
+ for (Integer locationId : _providingLocationIds)
+ _providingLocations.add(LocationManager.get().getLocation(container, locationId.intValue()));
+ }
+ }
+ return _providingLocations;
+ }
+
+ public List getVials()
+ {
+ return _vials;
+ }
+}
diff --git a/specimen/src/org/labkey/specimen/SpecimenColumns.java b/specimen/src/org/labkey/specimen/SpecimenColumns.java
new file mode 100644
index 00000000..3e214989
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/SpecimenColumns.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+import org.labkey.specimen.importer.ImportTypes;
+import org.labkey.specimen.importer.ImportableColumn;
+import org.labkey.specimen.importer.SpecimenColumn;
+import org.labkey.specimen.importer.TargetTable;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+// Column definitions that are shared between specimen writers and importers
+public class SpecimenColumns
+{
+ public static final String GLOBAL_UNIQUE_ID_TSV_COL = "global_unique_specimen_id";
+
+ public static final String LAB_ID_TSV_COL = "lab_id";
+ public static final String SPEC_NUMBER_TSV_COL = "specimen_number";
+ public static final String EVENT_ID_COL = "record_id";
+ public static final String VISIT_COL = "visit_value";
+
+ // SpecimenEvent columns that form a psuedo-unique constraint
+ public static final SpecimenColumn GLOBAL_UNIQUE_ID, LAB_ID, SHIP_DATE, STORAGE_DATE, LAB_RECEIPT_DATE, DRAW_TIMESTAMP;
+ public static final SpecimenColumn VISIT_VALUE;
+
+ public static final List BASE_SPECIMEN_COLUMNS = List.of(
+ new SpecimenColumn(EVENT_ID_COL, "ExternalId", "BIGINT NOT NULL", TargetTable.SPECIMEN_EVENTS, true),
+ new SpecimenColumn("record_source", "RecordSource", "VARCHAR(20)", TargetTable.SPECIMEN_EVENTS),
+ GLOBAL_UNIQUE_ID = new SpecimenColumn(GLOBAL_UNIQUE_ID_TSV_COL, "GlobalUniqueId", "VARCHAR(50)", true, TargetTable.VIALS, true),
+ LAB_ID = new SpecimenColumn(LAB_ID_TSV_COL, "LabId", "INT", TargetTable.SPECIMEN_EVENTS, "Site", "ExternalId", "LEFT OUTER") {
+ @Override
+ public boolean isUnique() { return true; }
+ },
+ new SpecimenColumn("originating_location", "OriginatingLocationId", "INT", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS, "Site", "ExternalId", "LEFT OUTER"),
+ new SpecimenColumn("unique_specimen_id", "UniqueSpecimenId", "VARCHAR(50)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("ptid", "Ptid", "VARCHAR(32)", true, TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("parent_specimen_id", "ParentSpecimenId", "INT", TargetTable.SPECIMEN_EVENTS),
+ DRAW_TIMESTAMP = new SpecimenColumn("draw_timestamp", "DrawTimestamp", ImportTypes.DATETIME_TYPE, TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("sal_receipt_date", "SalReceiptDate", ImportTypes.DATETIME_TYPE, TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn(SPEC_NUMBER_TSV_COL, "SpecimenNumber", "VARCHAR(50)", true, TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("class_id", "ClassId", "VARCHAR(20)", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS),
+ VISIT_VALUE = new SpecimenColumn(VISIT_COL, "VisitValue", ImportTypes.NUMERIC_TYPE, TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("protocol_number", "ProtocolNumber", "VARCHAR(20)", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("visit_description", "VisitDescription", "VARCHAR(10)", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("other_specimen_id", "OtherSpecimenId", "VARCHAR(50)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("volume", "Volume", "FLOAT", TargetTable.VIALS_AND_SPECIMEN_EVENTS, "MAX"),
+ new SpecimenColumn("volume_units", "VolumeUnits", "VARCHAR(20)", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("stored", "Stored", "INT", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("storage_flag", "storageFlag", "INT", TargetTable.SPECIMEN_EVENTS),
+ STORAGE_DATE = new SpecimenColumn("storage_date", "StorageDate", ImportTypes.DATETIME_TYPE, TargetTable.SPECIMEN_EVENTS, true),
+ new SpecimenColumn("ship_flag", "ShipFlag", "INT", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("ship_batch_number", "ShipBatchNumber", "INT", TargetTable.SPECIMEN_EVENTS),
+ SHIP_DATE = new SpecimenColumn("ship_date", "ShipDate", ImportTypes.DATETIME_TYPE, TargetTable.SPECIMEN_EVENTS, true),
+ new SpecimenColumn("imported_batch_number", "ImportedBatchNumber", "INT", TargetTable.SPECIMEN_EVENTS),
+ LAB_RECEIPT_DATE = new SpecimenColumn("lab_receipt_date", "LabReceiptDate", ImportTypes.DATETIME_TYPE, TargetTable.SPECIMEN_EVENTS, true),
+ new SpecimenColumn("expected_time_value", "ExpectedTimeValue", "FLOAT", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("expected_time_unit", "ExpectedTimeUnit", "VARCHAR(15)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("group_protocol", "GroupProtocol", "INT", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("sub_additive_derivative", "SubAdditiveDerivative", "VARCHAR(50)", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("comments", "Comments", "VARCHAR(500)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("primary_specimen_type_id", "PrimaryTypeId", "INT", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS, "SpecimenPrimaryType", "ExternalId", "LEFT OUTER"),
+ new SpecimenColumn("derivative_type_id", "DerivativeTypeId", "INT", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS, "SpecimenDerivative", "ExternalId", "LEFT OUTER"),
+ new SpecimenColumn("derivative_type_id_2", "DerivativeTypeId2", "INT", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS, "SpecimenDerivative", "ExternalId", "LEFT OUTER"),
+ new SpecimenColumn("additive_type_id", "AdditiveTypeId", "INT", TargetTable.SPECIMENS_AND_SPECIMEN_EVENTS, "SpecimenAdditive", "ExternalId", "LEFT OUTER"),
+ new SpecimenColumn("specimen_condition", "SpecimenCondition", "VARCHAR(30)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("sample_number", "SampleNumber", "INT", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("x_sample_origin", "XSampleOrigin", "VARCHAR(50)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("external_location", "ExternalLocation", "VARCHAR(50)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("update_timestamp", "UpdateTimestamp", ImportTypes.DATETIME_TYPE, TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("requestable", "Requestable", ImportTypes.BOOLEAN_TYPE, TargetTable.VIALS),
+ new SpecimenColumn("shipped_from_lab", "ShippedFromLab", "VARCHAR(32)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("shipped_to_lab", "ShippedtoLab", "VARCHAR(32)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("frozen_time", "FrozenTime", ImportTypes.DURATION_TYPE, TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("primary_volume", "PrimaryVolume", "FLOAT", TargetTable.VIALS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("primary_volume_units", "PrimaryVolumeUnits", "VARCHAR(20)", TargetTable.VIALS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("processed_by_initials", "ProcessedByInitials", "VARCHAR(32)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("processing_date", "ProcessingDate", ImportTypes.DATETIME_TYPE, TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("processing_time", "ProcessingTime", ImportTypes.DURATION_TYPE, TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("quality_comments", "QualityComments", "VARCHAR(500)", TargetTable.SPECIMEN_EVENTS),
+ new SpecimenColumn("total_cell_count", "TotalCellCount", "FLOAT", TargetTable.VIALS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("tube_type", "TubeType", "VARCHAR(64)", TargetTable.VIALS_AND_SPECIMEN_EVENTS),
+ new SpecimenColumn("input_hash", "InputHash", ImportTypes.BINARY_TYPE, TargetTable.SPECIMEN_EVENTS) // Not pulled from file... maybe this should be a ComputedColumn
+ );
+
+ public static final Collection ADDITIVE_COLUMNS = List.of(
+ new ImportableColumn("additive_id", "ExternalId", "INT NOT NULL", true),
+ new ImportableColumn("ldms_additive_code", "LdmsAdditiveCode", "VARCHAR(30)"),
+ new ImportableColumn("labware_additive_code", "LabwareAdditiveCode", "VARCHAR(20)"),
+ new ImportableColumn("additive", "Additive", "VARCHAR(100)")
+ );
+
+ public static final Collection DERIVATIVE_COLUMNS = Arrays.asList(
+ new ImportableColumn("derivative_id", "ExternalId", "INT NOT NULL", true),
+ new ImportableColumn("ldms_derivative_code", "LdmsDerivativeCode", "VARCHAR(30)"),
+ new ImportableColumn("labware_derivative_code", "LabwareDerivativeCode", "VARCHAR(20)"),
+ new ImportableColumn("derivative", "Derivative", "VARCHAR(100)")
+ );
+
+ public static final Collection SITE_COLUMNS = List.of(
+ new ImportableColumn("lab_id", "ExternalId", "INT NOT NULL", true),
+ new ImportableColumn("ldms_lab_code", "LdmsLabCode", "INT"),
+ new ImportableColumn("labware_lab_code", "LabwareLabCode", "VARCHAR(20)", false, true),
+ new ImportableColumn("lab_name", "Label", "VARCHAR(200)", false, true),
+ new ImportableColumn("lab_upload_code", "LabUploadCode", "VARCHAR(10)"),
+ new ImportableColumn("is_sal", "Sal", ImportTypes.BOOLEAN_TYPE, Boolean.FALSE),
+ new ImportableColumn("is_repository", "Repository", ImportTypes.BOOLEAN_TYPE, Boolean.FALSE),
+ new ImportableColumn("is_clinic", "Clinic", ImportTypes.BOOLEAN_TYPE, Boolean.FALSE),
+ new ImportableColumn("is_endpoint", "Endpoint", ImportTypes.BOOLEAN_TYPE, Boolean.FALSE),
+ new ImportableColumn("street_address", "StreetAddress", "VARCHAR(200)", false, true),
+ new ImportableColumn("city", "City", "VARCHAR(200)", false, true),
+ new ImportableColumn("governing_district", "GoverningDistrict", "VARCHAR(200)", false, true),
+ new ImportableColumn("country", "Country", "VARCHAR(200)", false, true),
+ new ImportableColumn("postal_area", "PostalArea", "VARCHAR(50)", false, true),
+ new ImportableColumn("description", "Description", "VARCHAR(500)", false, true)
+ );
+
+ public static final Collection PRIMARYTYPE_COLUMNS = List.of(
+ new ImportableColumn("primary_type_id", "ExternalId", "INT NOT NULL", true),
+ new ImportableColumn("primary_type_ldms_code", "PrimaryTypeLdmsCode", "VARCHAR(5)"),
+ new ImportableColumn("primary_type_labware_code", "PrimaryTypeLabwareCode", "VARCHAR(5)"),
+ new ImportableColumn("primary_type", "PrimaryType", "VARCHAR(100)")
+ );
+}
diff --git a/specimen/src/org/labkey/specimen/SpecimenCommentAuditDomainKind.java b/specimen/src/org/labkey/specimen/SpecimenCommentAuditDomainKind.java
new file mode 100644
index 00000000..2964ad31
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/SpecimenCommentAuditDomainKind.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+import org.labkey.api.audit.query.AbstractAuditDomainKind;
+import org.labkey.api.exp.PropertyDescriptor;
+import org.labkey.api.exp.PropertyType;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+public class SpecimenCommentAuditDomainKind extends AbstractAuditDomainKind
+{
+ public static final String NAME = "SpecimenCommentAuditDomain";
+ public static final String SPECIMEN_COMMENT_EVENT = "SpecimenCommentEvent";
+ public static final String NAMESPACE_PREFIX = "Audit-" + NAME;
+ public static final String COLUMN_NAME_VIAL_ID = "VialId";
+
+ private final Set _fields;
+
+ public SpecimenCommentAuditDomainKind()
+ {
+ super(SPECIMEN_COMMENT_EVENT);
+
+ Set fields = new LinkedHashSet<>();
+ fields.add(createPropertyDescriptor(COLUMN_NAME_VIAL_ID, PropertyType.STRING));
+ _fields = Collections.unmodifiableSet(fields);
+ }
+
+ @Override
+ public Set getProperties()
+ {
+ return _fields;
+ }
+
+ @Override
+ protected String getNamespacePrefix()
+ {
+ return NAMESPACE_PREFIX;
+ }
+
+ @Override
+ public String getKindName()
+ {
+ return NAME;
+ }
+}
diff --git a/specimen/src/org/labkey/specimen/SpecimenCommentAuditEvent.java b/specimen/src/org/labkey/specimen/SpecimenCommentAuditEvent.java
new file mode 100644
index 00000000..b029df14
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/SpecimenCommentAuditEvent.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+import org.labkey.api.audit.AuditTypeEvent;
+import org.labkey.api.data.Container;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.labkey.specimen.SpecimenCommentAuditDomainKind.SPECIMEN_COMMENT_EVENT;
+
+public class SpecimenCommentAuditEvent extends AuditTypeEvent
+{
+ private String _vialId;
+
+ public SpecimenCommentAuditEvent()
+ {
+ super();
+ }
+
+ public SpecimenCommentAuditEvent(Container container, String comment)
+ {
+ super(SPECIMEN_COMMENT_EVENT, container, comment);
+ }
+
+ public String getVialId()
+ {
+ return _vialId;
+ }
+
+ public void setVialId(String vialId)
+ {
+ _vialId = vialId;
+ }
+
+ @Override
+ public Map getAuditLogMessageElements()
+ {
+ Map elements = new LinkedHashMap<>();
+ elements.put("vialId", getVialId());
+ elements.putAll(super.getAuditLogMessageElements());
+ return elements;
+ }
+}
diff --git a/specimen/src/org/labkey/specimen/SpecimenCommentAuditProvider.java b/specimen/src/org/labkey/specimen/SpecimenCommentAuditProvider.java
new file mode 100644
index 00000000..cb12be95
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/SpecimenCommentAuditProvider.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+import org.labkey.api.audit.AbstractAuditTypeProvider;
+import org.labkey.api.audit.AuditTypeEvent;
+import org.labkey.api.audit.AuditTypeProvider;
+import org.labkey.api.audit.query.DefaultAuditTypeTable;
+import org.labkey.api.data.ColumnInfo;
+import org.labkey.api.data.Container;
+import org.labkey.api.data.ContainerFilter;
+import org.labkey.api.data.ContainerManager;
+import org.labkey.api.data.DataColumn;
+import org.labkey.api.data.DisplayColumn;
+import org.labkey.api.data.DisplayColumnFactory;
+import org.labkey.api.data.MutableColumnInfo;
+import org.labkey.api.data.RenderContext;
+import org.labkey.api.data.TableInfo;
+import org.labkey.api.query.FieldKey;
+import org.labkey.api.query.UserSchema;
+import org.labkey.api.util.LinkBuilder;
+import org.labkey.api.view.ActionURL;
+import org.labkey.api.writer.HtmlWriter;
+import org.labkey.specimen.actions.SpecimenController;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.labkey.specimen.SpecimenCommentAuditDomainKind.COLUMN_NAME_VIAL_ID;
+import static org.labkey.specimen.SpecimenCommentAuditDomainKind.SPECIMEN_COMMENT_EVENT;
+
+public class SpecimenCommentAuditProvider extends AbstractAuditTypeProvider implements AuditTypeProvider
+{
+ private static final List defaultVisibleColumns = List.of(
+ FieldKey.fromParts(COLUMN_NAME_CREATED),
+ FieldKey.fromParts(COLUMN_NAME_CREATED_BY),
+ FieldKey.fromParts(COLUMN_NAME_IMPERSONATED_BY),
+ FieldKey.fromParts(COLUMN_NAME_PROJECT_ID),
+ FieldKey.fromParts(COLUMN_NAME_CONTAINER),
+ FieldKey.fromParts(COLUMN_NAME_VIAL_ID),
+ FieldKey.fromParts(COLUMN_NAME_COMMENT)
+ );
+
+ public SpecimenCommentAuditProvider()
+ {
+ super(new SpecimenCommentAuditDomainKind());
+ }
+
+ @Override
+ public String getEventName()
+ {
+ return SPECIMEN_COMMENT_EVENT;
+ }
+
+ @Override
+ public String getLabel()
+ {
+ return "Specimen Comments and QC";
+ }
+
+ @Override
+ public String getDescription()
+ {
+ return "Specimen Comments and QC";
+ }
+
+ @Override
+ public Class getEventClass()
+ {
+ return (Class)SpecimenCommentAuditEvent.class;
+ }
+
+ @Override
+ public TableInfo createTableInfo(UserSchema userSchema, ContainerFilter cf)
+ {
+ return new DefaultAuditTypeTable(this, createStorageTableInfo(), userSchema, cf, defaultVisibleColumns)
+ {
+ @Override
+ protected void initColumn(MutableColumnInfo col)
+ {
+ if (COLUMN_NAME_VIAL_ID.equalsIgnoreCase(col.getName()))
+ {
+ final ColumnInfo containerColumn = getColumn(FieldKey.fromParts(COLUMN_NAME_CONTAINER));
+ col.setLabel("Vial Id");
+
+ col.setDisplayColumnFactory(new DisplayColumnFactory()
+ {
+ @Override
+ public DisplayColumn createRenderer(final ColumnInfo colInfo)
+ {
+ return new DataColumn(colInfo)
+ {
+ @Override
+ public void addQueryColumns(Set columns)
+ {
+ columns.add(containerColumn);
+ super.addQueryColumns(columns);
+ }
+
+ @Override
+ public void renderGridCellContents(RenderContext ctx, HtmlWriter out)
+ {
+ Object containerId = containerColumn.getValue(ctx);
+ String globalUniqueId = (String) getValue(ctx);
+ if (globalUniqueId == null)
+ return;
+
+ Container container = ContainerManager.getForId(containerId.toString());
+ if (container == null)
+ {
+ out.write(globalUniqueId);
+ return;
+ }
+
+ ActionURL url = SpecimenController.getCommentURL(container, globalUniqueId);
+ out.write(LinkBuilder.simpleLink(globalUniqueId, url));
+ }
+ };
+ }
+ });
+ }
+ }
+ };
+ }
+
+
+ @Override
+ public List getDefaultVisibleColumns()
+ {
+ return defaultVisibleColumns;
+ }
+}
diff --git a/specimen/src/org/labkey/specimen/SpecimenManager.java b/specimen/src/org/labkey/specimen/SpecimenManager.java
new file mode 100644
index 00000000..8ef913b9
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/SpecimenManager.java
@@ -0,0 +1,689 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.labkey.api.audit.AuditLogService;
+import org.labkey.api.collections.LongArrayList;
+import org.labkey.api.data.ColumnInfo;
+import org.labkey.api.data.Container;
+import org.labkey.api.data.DbScope;
+import org.labkey.api.data.SQLFragment;
+import org.labkey.api.data.SimpleFilter;
+import org.labkey.api.data.Sort;
+import org.labkey.api.data.SqlExecutor;
+import org.labkey.api.data.SqlSelector;
+import org.labkey.api.data.Table;
+import org.labkey.api.data.TableInfo;
+import org.labkey.api.data.TableSelector;
+import org.labkey.api.module.Module;
+import org.labkey.api.module.ModuleHtmlView;
+import org.labkey.api.query.FieldKey;
+import org.labkey.api.query.QueryService;
+import org.labkey.api.query.UserSchema;
+import org.labkey.api.security.User;
+import org.labkey.api.specimen.SpecimenEvent;
+import org.labkey.api.specimen.SpecimenEventManager;
+import org.labkey.api.specimen.SpecimenQuerySchema;
+import org.labkey.api.specimen.SpecimenSchema;
+import org.labkey.api.specimen.Vial;
+import org.labkey.api.specimen.location.LocationImpl;
+import org.labkey.api.specimen.location.LocationManager;
+import org.labkey.api.specimen.model.SpecimenComment;
+import org.labkey.api.specimen.model.SpecimenTablesProvider;
+import org.labkey.specimen.model.SpecimenTypeSummary;
+import org.labkey.specimen.model.SpecimenTypeSummaryRow;
+import org.labkey.api.study.Cohort;
+import org.labkey.api.study.Study;
+import org.labkey.api.study.StudyService;
+import org.labkey.api.study.Visit;
+import org.labkey.api.util.DateUtil;
+import org.labkey.api.util.HtmlString;
+import org.labkey.api.util.Path;
+import org.labkey.api.view.NotFoundException;
+import org.labkey.api.view.ViewContext;
+import org.labkey.specimen.model.AdditiveType;
+import org.labkey.specimen.model.DerivativeType;
+import org.labkey.specimen.model.ExtendedSpecimenRequestView;
+import org.labkey.specimen.model.PrimaryType;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+public class SpecimenManager
+{
+ private final static SpecimenManager INSTANCE = new SpecimenManager();
+
+ private SpecimenManager()
+ {
+ }
+
+ public static SpecimenManager get()
+ {
+ return INSTANCE;
+ }
+
+ public List extends Visit> getVisitsWithSpecimens(Container container, User user, Cohort cohort)
+ {
+ Study study = StudyService.get().getStudy(container);
+ UserSchema schema = SpecimenQuerySchema.get(study, user);
+ TableInfo tinfo = schema.getTable(SpecimenTablesProvider.SIMPLE_SPECIMEN_TABLE_NAME);
+
+ FieldKey visitKey = FieldKey.fromParts("Visit");
+ Map colMap = QueryService.get().getColumns(tinfo, Collections.singleton(visitKey));
+ Collection cols = new ArrayList<>();
+ cols.add(colMap.get(visitKey));
+ Set unresolvedColumns = new HashSet<>();
+ cols = QueryService.get().ensureRequiredColumns(tinfo, cols, null, null, unresolvedColumns);
+ if (!unresolvedColumns.isEmpty())
+ throw new IllegalStateException("Unable to resolve column(s): " + unresolvedColumns);
+ // generate our select SQL:
+ SQLFragment specimenSql = Table.getSelectSQL(tinfo, cols, null, null);
+
+ SQLFragment visitIdSQL = new SQLFragment("SELECT DISTINCT Visit FROM (" + specimenSql.getSQL() + ") SimpleSpecimenQuery");
+ visitIdSQL.addAll(specimenSql.getParamsArray());
+
+ List visitIds = new SqlSelector(SpecimenSchema.get().getSchema(), visitIdSQL).getArrayList(Integer.class);
+
+ // Get shared visit study
+ Study visitStudy = StudyService.get().getStudyForVisits(study);
+
+ SimpleFilter filter = new SimpleFilter();
+ filter.addInClause(FieldKey.fromParts("RowId"), visitIds);
+ if (cohort != null)
+ filter.addWhereClause("CohortId IS NULL OR CohortId = ?", new Object[] { cohort.getRowId() });
+ return StudyService.get().getVisits(visitStudy, filter, new Sort("DisplayOrder,SequenceNumMin"));
+ }
+
+ public LocationImpl[] getSitesWithRequests(Container container)
+ {
+ TableInfo locationTableInfo = SpecimenSchema.get().getTableInfoLocation(container);
+ SQLFragment sql = new SQLFragment("SELECT * FROM ").append(locationTableInfo).append(" WHERE rowid IN\n" +
+ "(SELECT destinationsiteid FROM study.samplerequest WHERE container = ?)\n" +
+ "AND container = ? ORDER BY label").addAll(container, container);
+
+ return new SqlSelector(SpecimenSchema.get().getSchema(), sql).getArray(LocationImpl.class);
+ }
+
+ public Set getEnrollmentSitesWithRequests(Container container, User user)
+ {
+ UserSchema schema = SpecimenQuerySchema.get(StudyService.get().getStudy(container), user);
+ TableInfo tableInfoSpecimenDetail = schema.getTable(SpecimenTablesProvider.SPECIMEN_WRAP_TABLE_NAME);
+ if (null == tableInfoSpecimenDetail)
+ throw new IllegalStateException("SpecimenDetail table not found.");
+ String tableInfoAlias = "Specimen";
+ SQLFragment sql = new SQLFragment("SELECT Participant.EnrollmentSiteId FROM ");
+ sql.append(tableInfoSpecimenDetail.getFromSQL(tableInfoAlias)).append(", ")
+ .append("study.SampleRequestSpecimen AS RequestSpecimen, \n" +
+ "study.SampleRequest AS Request, study.SampleRequestStatus AS Status,\n" +
+ "study.Participant AS Participant\n" +
+ "WHERE Request.Container = Status.Container AND\n" +
+ "\tRequest.StatusId = Status.RowId AND\n" +
+ "\tRequestSpecimen.SampleRequestId = Request.RowId AND\n" +
+ "\tRequestSpecimen.Container = Request.Container AND\n" +
+ "\tSpecimen.Container = RequestSpecimen.Container AND\n" +
+ "\tSpecimen.GlobalUniqueId = RequestSpecimen.SpecimenGlobalUniqueId AND\n" +
+ "\tParticipant.EnrollmentSiteId IS NOT NULL AND\n" +
+ "\tParticipant.Container = Specimen.Container AND\n" +
+ "\tParticipant.ParticipantId = Specimen.Ptid AND\n" +
+ "\tStatus.SpecimensLocked = ? AND\n" +
+ "\tRequest.Container = ?");
+ sql.add(Boolean.TRUE);
+ sql.add(container);
+
+ return getSitesWithIdSql(container, "EnrollmentSiteId", sql);
+ }
+
+ public Set getEnrollmentSitesWithSpecimens(Container container, User user)
+ {
+ UserSchema schema = SpecimenQuerySchema.get(StudyService.get().getStudy(container), user);
+ TableInfo tableInfoSpecimenDetail = schema.getTable(SpecimenTablesProvider.SPECIMEN_WRAP_TABLE_NAME);
+ if (null == tableInfoSpecimenDetail)
+ throw new IllegalStateException("SpecimenDetail table not found.");
+ String tableInfoAlias = "Specimen";
+ SQLFragment sql = new SQLFragment("SELECT EnrollmentSiteId FROM ");
+ sql.append(tableInfoSpecimenDetail.getFromSQL(tableInfoAlias)).append(", study.Participant AS Participant\n" +
+ "WHERE Specimen.Ptid = Participant.ParticipantId AND\n" +
+ "\tParticipant.EnrollmentSiteId IS NOT NULL AND\n" +
+ "\tSpecimen.Container = Participant.Container AND\n" +
+ "\tSpecimen.Container = ?\n" +
+ "GROUP BY EnrollmentSiteId");
+ sql.add(container);
+
+ return getSitesWithIdSql(container, "EnrollmentSiteId", sql);
+ }
+
+ private Set getSitesWithIdSql(final Container container, final String idColumnName, SQLFragment sql)
+ {
+ final Set locations = new TreeSet<>((s1, s2) ->
+ {
+ if (s1 == null && s2 == null)
+ return 0;
+ if (s1 == null)
+ return -1;
+ if (s2 == null)
+ return 1;
+ return s1.getLabel().compareTo(s2.getLabel());
+ });
+
+ new SqlSelector(SpecimenSchema.get().getSchema(), sql).forEach(rs -> {
+ // try getObject first to see if we have a value for our row; getInt will coerce the null to
+ // zero, which could (theoretically) be a valid site ID.
+ if (rs.getObject(idColumnName) == null)
+ locations.add(null);
+ else
+ locations.add(LocationManager.get().getLocation(container, rs.getInt(idColumnName)));
+ });
+
+ return locations;
+ }
+
+ private static final int GET_COMMENT_BATCH_SIZE = 1000;
+
+ public Map getSpecimenComments(List vials)
+ {
+ if (vials == null || vials.isEmpty())
+ return Collections.emptyMap();
+
+ Container container = vials.getFirst().getContainer();
+ final Map result = new HashMap<>();
+ int offset = 0;
+
+ while (offset < vials.size())
+ {
+ final Map idToVial = new HashMap<>();
+
+ for (int current = offset; current < offset + GET_COMMENT_BATCH_SIZE && current < vials.size(); current++)
+ {
+ Vial vial = vials.get(current);
+ idToVial.put(vial.getGlobalUniqueId(), vial);
+ if (!container.equals(vial.getContainer()))
+ throw new IllegalArgumentException("All specimens must be from the same container");
+ }
+
+ SimpleFilter filter = SimpleFilter.createContainerFilter(container);
+ filter.addInClause(FieldKey.fromParts("GlobalUniqueId"), idToVial.keySet());
+
+ new TableSelector(SpecimenSchema.get().getTableInfoSpecimenComment(), filter, null).forEach(SpecimenComment.class, comment -> {
+ Vial vial = idToVial.get(comment.getGlobalUniqueId());
+ result.put(vial, comment);
+ });
+
+ offset += GET_COMMENT_BATCH_SIZE;
+ }
+
+ return result;
+ }
+
+ public SpecimenComment getSpecimenCommentForVial(Container container, String globalUniqueId)
+ {
+ SimpleFilter filter = SimpleFilter.createContainerFilter(container);
+ filter.addCondition(FieldKey.fromParts("GlobalUniqueId"), globalUniqueId);
+
+ return new TableSelector(SpecimenSchema.get().getTableInfoSpecimenComment(), filter, null).getObject(SpecimenComment.class);
+ }
+
+ public SpecimenComment getSpecimenCommentForVial(Vial vial)
+ {
+ return getSpecimenCommentForVial(vial.getContainer(), vial.getGlobalUniqueId());
+ }
+
+ public SpecimenComment setSpecimenComment(User user, Vial vial, String commentText, boolean qualityControlFlag, boolean qualityControlFlagForced)
+ {
+ TableInfo commentTable = SpecimenSchema.get().getTableInfoSpecimenComment();
+ DbScope scope = commentTable.getSchema().getScope();
+ SpecimenComment comment = getSpecimenCommentForVial(vial);
+ boolean clearComment = commentText == null && !qualityControlFlag && !qualityControlFlagForced;
+ try (DbScope.Transaction transaction = scope.ensureTransaction())
+ {
+ SpecimenComment result;
+ if (clearComment)
+ {
+ if (comment != null)
+ {
+ Table.delete(commentTable, comment.getRowId());
+ auditSpecimenComment(user, vial, comment.getComment(), null, comment.isQualityControlFlag(), false);
+ }
+ result = null;
+ }
+ else
+ {
+ if (comment != null)
+ {
+ String prevComment = comment.getComment();
+ boolean prevConflictState = comment.isQualityControlFlag();
+ comment.setComment(commentText);
+ comment.setQualityControlFlag(qualityControlFlag);
+ comment.setQualityControlFlagForced(qualityControlFlagForced);
+ comment.beforeUpdate(user);
+ result = Table.update(user, commentTable, comment, comment.getRowId());
+ auditSpecimenComment(user, vial, prevComment, result.getComment(), prevConflictState, result.isQualityControlFlag());
+ }
+ else
+ {
+ comment = new SpecimenComment();
+ comment.setGlobalUniqueId(vial.getGlobalUniqueId());
+ comment.setSpecimenHash(vial.getSpecimenHash());
+ comment.setComment(commentText);
+ comment.setQualityControlFlag(qualityControlFlag);
+ comment.setQualityControlFlagForced(qualityControlFlagForced);
+ comment.beforeInsert(user, vial.getContainer().getId());
+ result = Table.insert(user, commentTable, comment);
+ auditSpecimenComment(user, vial, null, result.getComment(), false, comment.isQualityControlFlag());
+ }
+ }
+ transaction.commit();
+ return result;
+ }
+ }
+
+ @Nullable
+ public ExtendedSpecimenRequestView getExtendedSpecimenRequestView(ViewContext context)
+ {
+ if (context == null || context.getContainer() == null)
+ return null;
+
+ Path path = ModuleHtmlView.getStandardPath("extendedrequest");
+
+ for (Module module : context.getContainer().getActiveModules())
+ {
+ if (ModuleHtmlView.exists(module, path))
+ {
+ ModuleHtmlView moduleView = ModuleHtmlView.get(module, path);
+ assert null != moduleView;
+ HtmlString html = moduleView.getHtml();
+ String s = ModuleHtmlView.replaceTokens(html.toString(), context);
+ return ExtendedSpecimenRequestView.createView(s);
+ }
+ }
+
+ return null;
+ }
+
+ private boolean safeComp(Object a, Object b)
+ {
+ if (a == null && b == null)
+ return true;
+ if (a == null || b == null)
+ return false;
+ return a.equals(b);
+ }
+
+ private void auditSpecimenComment(User user, Vial vial, String oldComment, String newComment, boolean prevConflictState, boolean newConflictState)
+ {
+ String verb = "updated";
+ if (oldComment == null)
+ verb = "added";
+ else if (newComment == null)
+ verb = "deleted";
+ String message = "";
+ if (!safeComp(oldComment, newComment))
+ {
+ message += "Comment " + verb + ".\n";
+ if (oldComment != null)
+ message += "Previous value: " + oldComment + "\n";
+ if (newComment != null)
+ message += "New value: " + newComment + "\n";
+ }
+
+ if (!safeComp(prevConflictState, newConflictState))
+ {
+ message = "QC alert flag changed.\n";
+ if (oldComment != null)
+ message += "Previous value: " + prevConflictState + "\n";
+ if (newComment != null)
+ message += "New value: " + newConflictState + "\n";
+ }
+
+ SpecimenCommentAuditEvent event = new SpecimenCommentAuditEvent(vial.getContainer(), message);
+ event.setVialId(vial.getGlobalUniqueId());
+
+ AuditLogService.get().addEvent(user, event);
+ }
+
+ public List getVials(Container container, User user, String participantId, Double visit)
+ {
+ SimpleFilter filter = SimpleFilter.createContainerFilter(container);
+ filter.addClause(new SimpleFilter.SQLClause("LOWER(ptid) = LOWER(?)", new Object[] {participantId}, FieldKey.fromParts("ptid")));
+ filter.addCondition(FieldKey.fromParts("VisitValue"), visit);
+ return getVials(container, user, filter);
+ }
+
+ public List getVials(Container container, User user, int[] vialsRowIds)
+ {
+ Set uniqueRowIds = new HashSet<>(vialsRowIds.length);
+ for (int vialRowId : vialsRowIds)
+ uniqueRowIds.add((long)vialRowId);
+ return getVials(container, user, uniqueRowIds);
+ }
+
+ public List getVials(Container container, User user, String[] globalUniqueIds) throws SpecimenRequestException
+ {
+ SimpleFilter filter = new SimpleFilter();
+ Set uniqueRowIds = new HashSet<>(globalUniqueIds.length);
+ Collections.addAll(uniqueRowIds, globalUniqueIds);
+ List ids = new ArrayList<>(uniqueRowIds);
+ filter.addInClause(FieldKey.fromParts("GlobalUniqueId"), ids);
+ List vials = getVials(container, user, filter);
+ if (vials == null || vials.size() != ids.size())
+ throw new SpecimenRequestException("Vial not found."); // an id has no matching specimen, let caller determine what to report
+ return vials;
+ }
+
+ public List getVials(Container container, User user, String participantId, Date date)
+ {
+ SimpleFilter filter = new SimpleFilter(new SimpleFilter.SQLClause("LOWER(ptid) = LOWER(?)", new Object[] {participantId}, FieldKey.fromParts("ptid")));
+ Calendar endCal = DateUtil.newCalendar(date.getTime());
+ endCal.add(Calendar.DATE, 1);
+ filter.addClause(new SimpleFilter.SQLClause("DrawTimestamp >= ? AND DrawTimestamp < ?", new Object[] {date, endCal.getTime()}));
+ return getVials(container, user, filter);
+ }
+
+ @Nullable
+ public AdditiveType getAdditiveType(Container c, int rowId)
+ {
+ List additiveTypes = getAdditiveTypes(c, new SimpleFilter(FieldKey.fromParts("RowId"), rowId));
+ if (!additiveTypes.isEmpty())
+ return additiveTypes.getFirst();
+ return null;
+ }
+
+ private List getAdditiveTypes(final Container container, @Nullable SimpleFilter filter)
+ {
+ final List additiveTypes = new ArrayList<>();
+ new TableSelector(SpecimenSchema.get().getTableInfoSpecimenAdditive(container), filter, null).
+ forEachMap(map -> additiveTypes.add(new AdditiveType(container, map)));
+ return additiveTypes;
+ }
+
+ public DerivativeType getDerivativeType(Container c, int rowId)
+ {
+ List derivativeTypes = getDerivativeTypes(c, new SimpleFilter(FieldKey.fromParts("RowId"), rowId));
+ if (!derivativeTypes.isEmpty())
+ return derivativeTypes.getFirst();
+ return null;
+ }
+
+ private List getDerivativeTypes(final Container container, @Nullable SimpleFilter filter)
+ {
+ final List derivativeTypes = new ArrayList<>();
+ new TableSelector(SpecimenSchema.get().getTableInfoSpecimenDerivative(container), filter, null).
+ forEachMap(map -> derivativeTypes.add(new DerivativeType(container, map)));
+ return derivativeTypes;
+ }
+
+ public boolean isSpecimensEmpty(Container container, User user)
+ {
+ TableSelector selector = getSpecimensSelector(container, user, null);
+ return !selector.exists();
+ }
+
+ public Vial getVial(Container container, User user, String globalUniqueId)
+ {
+ SimpleFilter filter = new SimpleFilter(new SimpleFilter.SQLClause("LOWER(GlobalUniqueId) = LOWER(?)", new Object[] { globalUniqueId }));
+ List matches = getVials(container, user, filter);
+ if (matches == null || matches.isEmpty())
+ return null;
+ if (matches.size() > 1)
+ {
+ // we apparently have two specimens with IDs that differ only in case; do a case-sensitive check
+ // here to find the right one:
+ for (Vial vial : matches)
+ {
+ if (vial.getGlobalUniqueId().equals(globalUniqueId))
+ return vial;
+ }
+ throw new IllegalStateException("Expected at least one vial to exactly match the specified global unique ID: " + globalUniqueId);
+ }
+ else
+ return matches.getFirst();
+ }
+
+ /** Looks for any specimens that have the given id as a globalUniqueId */
+ public PrimaryType getPrimaryType(Container c, int rowId)
+ {
+ List primaryTypes = getPrimaryTypes(c, new SimpleFilter(FieldKey.fromParts("RowId"), rowId), null);
+ if (!primaryTypes.isEmpty())
+ return primaryTypes.getFirst();
+ return null;
+ }
+
+ public List getRequestableVials(Container container, User user, Set vialRowIds)
+ {
+ SimpleFilter filter = SimpleFilter.createContainerFilter(container);
+ filter.addInClause(FieldKey.fromParts("RowId"), vialRowIds).addCondition(FieldKey.fromString("available"), true);
+ return getVials(container, user, filter);
+ }
+
+ public Map> getVialsForSpecimenHashes(Container container, User user, Collection hashes, boolean onlyAvailable)
+ {
+ SimpleFilter filter = SimpleFilter.createContainerFilter(container);
+ filter.addInClause(FieldKey.fromParts("SpecimenHash"), hashes);
+ if (onlyAvailable)
+ filter.addCondition(FieldKey.fromParts("Available"), true);
+ List vials = getVials(container, user, filter);
+ Map> map = new HashMap<>();
+ for (Vial vial : vials)
+ {
+ String hash = vial.getSpecimenHash();
+ List keyVials = map.computeIfAbsent(hash, k -> new ArrayList<>());
+ keyVials.add(vial);
+ }
+
+ return map;
+ }
+
+ public List getVials(Container container, User user, Set vialRowIds)
+ {
+ // Take a set to eliminate dups - issue 26940
+
+ SimpleFilter filter = SimpleFilter.createContainerFilter(container);
+ filter.addInClause(FieldKey.fromParts("RowId"), vialRowIds);
+ List vials = getVials(container, user, filter);
+ if (vials.size() != vialRowIds.size())
+ {
+ List unmatchedRowIds = new LongArrayList(vialRowIds);
+ for (Vial vial : vials)
+ {
+ unmatchedRowIds.remove(vial.getRowId());
+ }
+ throw new SpecimenRequestException("One or more specimen RowIds had no matching specimen: " + unmatchedRowIds);
+ }
+ return vials;
+ }
+
+ public List getVials(final Container container, final User user, SimpleFilter filter)
+ {
+ // TODO: LinkedList?
+ final List vials = new ArrayList<>();
+
+ getSpecimensSelector(container, user, filter)
+ .forEachMap(map -> vials.add(new Vial(container, map)));
+
+ return vials;
+ }
+
+ public TableSelector getSpecimensSelector(final Container container, final User user, SimpleFilter filter)
+ {
+ Study study = StudyService.get().getStudy(container);
+ if (study == null)
+ {
+ throw new NotFoundException("No study in container " + container.getPath());
+ }
+ UserSchema schema = SpecimenQuerySchema.get(study, user);
+ TableInfo specimenTable = schema.getTable(SpecimenTablesProvider.SPECIMEN_WRAP_TABLE_NAME);
+ return new TableSelector(specimenTable, filter, null);
+ }
+
+ public Vial getVial(Container container, User user, long rowId)
+ {
+ SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("RowId"), rowId);
+ List vials = getVials(container, user, filter);
+ if (vials.isEmpty())
+ return null;
+ return vials.getFirst();
+ }
+
+ public void deleteSpecimen(@NotNull Vial vial, boolean clearCaches)
+ {
+ Container container = vial.getContainer();
+ TableInfo tableInfoSpecimenEvent = SpecimenSchema.get().getTableInfoSpecimenEvent(container);
+ TableInfo tableInfoVial = SpecimenSchema.get().getTableInfoVial(container);
+ if (null == tableInfoSpecimenEvent || null == tableInfoVial)
+ return;
+
+ String tableInfoSpecimenEventSelectName = tableInfoSpecimenEvent.getSelectName();
+ String tableInfoVialSelectName = tableInfoVial.getSelectName();
+
+ SQLFragment sqlFragmentEvent = new SQLFragment("DELETE FROM ");
+ sqlFragmentEvent.append(tableInfoSpecimenEventSelectName).append(" WHERE VialId = ?");
+ sqlFragmentEvent.add(vial.getRowId());
+ new SqlExecutor(SpecimenSchema.get().getSchema()).execute(sqlFragmentEvent);
+
+ SQLFragment sqlFragment = new SQLFragment("DELETE FROM ");
+ sqlFragment.append(tableInfoVialSelectName).append(" WHERE RowId = ?");
+ sqlFragment.add(vial.getRowId());
+ new SqlExecutor(SpecimenSchema.get().getSchema()).execute(sqlFragment);
+
+ if (clearCaches)
+ {
+ SpecimenRequestManager.get().clearCaches(vial.getContainer());
+ }
+ }
+
+ public long getMaxExternalId(Container container)
+ {
+ TableInfo tableInfo = SpecimenSchema.get().getTableInfoSpecimenEvent(container);
+ SQLFragment sql = new SQLFragment("SELECT MAX(ExternalId) FROM ");
+ sql.append(tableInfo);
+ return new SqlSelector(tableInfo.getSchema(), sql).getArrayList(Long.class).getFirst();
+ }
+
+ public String getFirstProcessedByInitials(List dateOrderedEvents)
+ {
+ SpecimenEvent firstEvent = SpecimenEventManager.get().getFirstEvent(dateOrderedEvents);
+ return firstEvent != null ? firstEvent.getProcessedByInitials() : null;
+ }
+
+ public List getSpecimenEvents(List vials, boolean includeObsolete)
+ {
+ if (vials == null || vials.isEmpty())
+ return Collections.emptyList();
+ Collection vialIds = new HashSet<>();
+ Container container = null;
+ for (Vial vial : vials)
+ {
+ vialIds.add(vial.getRowId());
+ if (container == null)
+ container = vial.getContainer();
+ else if (!container.equals(vial.getContainer()))
+ throw new IllegalArgumentException("All specimens must be from the same container");
+ }
+ SimpleFilter filter = new SimpleFilter();
+ filter.addInClause(FieldKey.fromString("VialId"), vialIds);
+ if (!includeObsolete)
+ filter.addCondition(FieldKey.fromString("Obsolete"), false);
+ return SpecimenEventManager.get().getSpecimenEvents(container, filter);
+ }
+
+ public List getPrimaryTypes(Container c)
+ {
+ return getPrimaryTypes(c, null, new Sort("ExternalId"));
+ }
+
+ public List getPrimaryTypes(final Container container, @Nullable SimpleFilter filter, @Nullable Sort sort)
+ {
+ final List primaryTypes = new ArrayList<>();
+ new TableSelector(SpecimenSchema.get().getTableInfoSpecimenPrimaryType(container), filter, sort).
+ forEachMap(map -> primaryTypes.add(new PrimaryType(container, map)));
+ return primaryTypes;
+ }
+
+ public SpecimenTypeSummary getSpecimenTypeSummary(Container container, @NotNull User user)
+ {
+ UserSchema querySchema = SpecimenQuerySchema.get(StudyService.get().getStudy(container), user);
+ TableInfo tableInfoSpecimenWrap = querySchema.getTable(SpecimenTablesProvider.SPECIMEN_WRAP_TABLE_NAME);
+ if (null == tableInfoSpecimenWrap)
+ throw new IllegalStateException("SpecimenDetail table not found.");
+
+ TableInfo additiveTableInfo = querySchema.getTable(SpecimenTablesProvider.ADDITIVE_TYPE_TABLE_NAME);
+ TableInfo derivativeTableInfo = querySchema.getTable(SpecimenTablesProvider.DERIVATIVE_TYPE_TABLE_NAME);
+ TableInfo primaryTypeTableInfo = querySchema.getTable(SpecimenTablesProvider.PRIMARY_TYPE_TABLE_NAME);
+ String tableInfoSelectName = "SpecimenWrap";
+
+ // TODO: consider caching
+
+ Study study = StudyService.get().getStudy(container);
+ if (study == null)
+ return null;
+
+ SQLFragment specimenTypeSummarySQL = new SQLFragment("SELECT\n" +
+ "\tPrimaryType,\n" +
+ "\tPrimaryTypeId,\n" +
+ "\tDerivative,\n" +
+ "\tDerivativeTypeId,\n" +
+ "\tAdditive,\n" +
+ "\tAdditiveTypeId,\n" +
+ "\tSUM(VialCount) AS VialCount\n" +
+ "FROM (\n" +
+ "\tSELECT\n" +
+ "\tPT.PrimaryType AS PrimaryType,\n" +
+ "\tPrimaryTypeId,\n" +
+ "\tDT.Derivative AS Derivative,\n" +
+ "\tDerivativeTypeId,\n" +
+ "\tAT.Additive AS Additive,\n" +
+ "\tAdditiveTypeId,\n" +
+ "\tSpecimens.VialCount\n" +
+ "\tFROM\n");
+
+ specimenTypeSummarySQL.append("\t\t(SELECT ")
+ .append(tableInfoSpecimenWrap.getColumn("PrimaryTypeId").getValueSql(tableInfoSelectName)).append(",")
+ .append(tableInfoSpecimenWrap.getColumn("DerivativeTypeId").getValueSql(tableInfoSelectName)).append(",")
+ .append(tableInfoSpecimenWrap.getColumn("AdditiveTypeId").getValueSql(tableInfoSelectName)).append(",")
+ .append("\n\t\t\tSUM(").append(tableInfoSpecimenWrap.getColumn("VialCount").getValueSql(tableInfoSelectName))
+ .append(") AS VialCount\n")
+ .append("\n\t\tFROM ").append(tableInfoSpecimenWrap.getFromSQL(tableInfoSelectName)).append("\n")
+ .append("\t\tGROUP BY ")
+ .append(tableInfoSpecimenWrap.getColumn("PrimaryTypeId").getValueSql(tableInfoSelectName)).append(",")
+ .append(tableInfoSpecimenWrap.getColumn("DerivativeTypeId").getValueSql(tableInfoSelectName)).append(",")
+ .append(tableInfoSpecimenWrap.getColumn("AdditiveTypeId").getValueSql(tableInfoSelectName))
+ .append("\t\t\t) Specimens\n").append(
+ "\tLEFT OUTER JOIN ").append(primaryTypeTableInfo.getFromSQL("PT")).append( " ON\n" +
+ "\t\tPT.RowId = Specimens.PrimaryTypeId\n" +
+ "\tLEFT OUTER JOIN ").append(derivativeTableInfo.getFromSQL("DT")).append(" ON\n" +
+ "\t\tDT.RowId = Specimens.DerivativeTypeId\n" +
+ "\tLEFT OUTER JOIN ").append(additiveTableInfo.getFromSQL("AT")).append(" ON\n" +
+ "\t\tAT.RowId = Specimens.AdditiveTypeId\n" +
+ ") ContainerTotals\n" +
+ "GROUP BY PrimaryType, PrimaryTypeId, Derivative, DerivativeTypeId, Additive, AdditiveTypeId\n" +
+ "ORDER BY PrimaryType, Derivative, Additive"
+ );
+
+ SpecimenTypeSummaryRow[] rows = new SqlSelector(SpecimenSchema.get().getSchema(), specimenTypeSummarySQL).getArray(SpecimenTypeSummaryRow.class);
+
+ return new SpecimenTypeSummary(container, rows);
+ }
+}
diff --git a/specimen/src/org/labkey/specimen/SpecimenModule.java b/specimen/src/org/labkey/specimen/SpecimenModule.java
new file mode 100644
index 00000000..212e54d1
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/SpecimenModule.java
@@ -0,0 +1,378 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+import org.apache.commons.collections4.MultiSet;
+import org.apache.commons.collections4.multiset.HashMultiSet;
+import org.apache.commons.lang3.mutable.MutableInt;
+import org.apache.logging.log4j.Logger;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.labkey.api.attachments.AttachmentService;
+import org.labkey.api.audit.AuditLogService;
+import org.labkey.api.data.Container;
+import org.labkey.api.data.ContainerFilter;
+import org.labkey.api.data.ContainerManager;
+import org.labkey.api.data.SQLFragment;
+import org.labkey.api.data.SimpleFilter;
+import org.labkey.api.data.SqlSelector;
+import org.labkey.api.data.TableInfo;
+import org.labkey.api.data.TableSelector;
+import org.labkey.api.module.ModuleContext;
+import org.labkey.api.module.SpringModule;
+import org.labkey.api.pipeline.PipelineJob;
+import org.labkey.api.pipeline.PipelineJobException;
+import org.labkey.api.pipeline.PipelineService;
+import org.labkey.api.query.FieldKey;
+import org.labkey.api.query.QuerySettings;
+import org.labkey.api.query.QueryUpdateService;
+import org.labkey.api.query.QueryView;
+import org.labkey.api.query.UserSchema;
+import org.labkey.api.security.User;
+import org.labkey.api.security.permissions.Permission;
+import org.labkey.api.security.roles.RoleManager;
+import org.labkey.api.specimen.SpecimenMigrationService;
+import org.labkey.api.specimen.SpecimenQuerySchema;
+import org.labkey.api.specimen.SpecimenSchema;
+import org.labkey.api.specimen.SpecimensPage;
+import org.labkey.api.specimen.model.SpecimenTablesProvider;
+import org.labkey.api.study.SpecimenService;
+import org.labkey.api.study.Study;
+import org.labkey.api.study.StudyInternalService;
+import org.labkey.api.study.StudyService;
+import org.labkey.api.study.StudyUrls;
+import org.labkey.api.study.importer.SimpleStudyImportContext;
+import org.labkey.api.study.importer.SimpleStudyImporterRegistry;
+import org.labkey.api.study.writer.SimpleStudyWriterRegistry;
+import org.labkey.api.usageMetrics.UsageMetricsService;
+import org.labkey.api.util.LinkBuilder;
+import org.labkey.api.util.PageFlowUtil;
+import org.labkey.api.util.SafeToRender;
+import org.labkey.api.util.SystemMaintenance;
+import org.labkey.api.util.emailTemplate.EmailTemplateService;
+import org.labkey.api.util.logging.LogHelper;
+import org.labkey.api.view.ActionURL;
+import org.labkey.api.view.HttpView;
+import org.labkey.api.view.ViewContext;
+import org.labkey.api.view.WebPartFactory;
+import org.labkey.specimen.actions.SpecimenApiController;
+import org.labkey.specimen.actions.SpecimenController;
+import org.labkey.specimen.importer.AbstractSpecimenTask;
+import org.labkey.specimen.importer.QueryBasedSpecimenImportUploadTask;
+import org.labkey.specimen.importer.QueryBasedSpecimenTransform;
+import org.labkey.specimen.importer.RequestabilityManager;
+import org.labkey.specimen.importer.SpecimenImporter;
+import org.labkey.specimen.importer.SpecimenSchemaImporter;
+import org.labkey.specimen.importer.SpecimenSettingsImporter;
+import org.labkey.specimen.model.SpecimenRequestEventType;
+import org.labkey.specimen.pipeline.SpecimenPipeline;
+import org.labkey.specimen.query.SpecimenPivotByDerivativeType;
+import org.labkey.specimen.query.SpecimenPivotByPrimaryType;
+import org.labkey.specimen.query.SpecimenPivotByRequestingLocation;
+import org.labkey.specimen.query.SpecimenQueryView;
+import org.labkey.specimen.query.SpecimenUpdateService;
+import org.labkey.specimen.requirements.SpecimenRequestRequirementProvider;
+import org.labkey.specimen.security.permissions.EditSpecimenDataPermission;
+import org.labkey.specimen.security.permissions.ManageRequestSettingsPermission;
+import org.labkey.specimen.security.roles.SpecimenCoordinatorRole;
+import org.labkey.specimen.security.roles.SpecimenRequesterRole;
+import org.labkey.specimen.settings.RepositorySettings;
+import org.labkey.specimen.settings.SettingsManager;
+import org.labkey.specimen.view.ManageSpecimenView;
+import org.labkey.specimen.view.SpecimenReportWebPartFactory;
+import org.labkey.specimen.view.SpecimenRequestNotificationEmailTemplate;
+import org.labkey.specimen.view.SpecimenSearchWebPartFactory;
+import org.labkey.specimen.view.SpecimenToolsWebPartFactory;
+import org.labkey.specimen.view.SpecimenWebPartFactory;
+import org.labkey.specimen.writer.SpecimenArchiveWriter;
+import org.labkey.specimen.writer.SpecimenSettingsWriter;
+import org.labkey.specimen.writer.SpecimenWriter;
+import org.labkey.vfs.FileLike;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class SpecimenModule extends SpringModule
+{
+ private static final Logger LOG = LogHelper.getLogger(SpecimenModule.class, "Specimen metrics");
+ public static final String NAME = "Specimen";
+
+ @Override
+ public String getName()
+ {
+ return NAME;
+ }
+
+ @Override
+ @NotNull
+ protected Collection createWebPartFactories()
+ {
+ return List.of(
+ new SpecimenReportWebPartFactory(),
+ new SpecimenSearchWebPartFactory(HttpView.BODY),
+ new SpecimenToolsWebPartFactory(),
+ new SpecimenWebPartFactory()
+ );
+ }
+
+ @Override
+ public @Nullable Double getSchemaVersion()
+ {
+ return null;
+ }
+
+ @Override
+ public boolean hasScripts()
+ {
+ return false;
+ }
+
+ @Override
+ protected void init()
+ {
+ // Register early so these roles are available to Java code at upgrade time
+ RoleManager.registerRole(new SpecimenCoordinatorRole());
+ RoleManager.registerRole(new SpecimenRequesterRole());
+
+ AttachmentService.get().registerAttachmentParentType(SpecimenRequestEventType.get());
+
+ addController("specimen", SpecimenController.class, "study-samples");
+ addController("specimen-api", SpecimenApiController.class, "study-samples-api", "specimens-api");
+
+ // Register early -- some modules don't declare a runtime dependency on specimen module, but will use the
+ // service if it's available
+ SpecimenService.setInstance(new SpecimenServiceImpl());
+ EmailTemplateService.get().registerTemplate(SpecimenRequestNotificationEmailTemplate.class);
+
+ SpecimenMigrationService.setInstance(new SpecimenMigrationService()
+ {
+ @Override
+ public ActionURL getBeginURL(Container c)
+ {
+ return new ActionURL(SpecimenController.BeginAction.class, c);
+ }
+
+ @Override
+ public ActionURL getSelectedSpecimensURL(Container c)
+ {
+ return new ActionURL(SpecimenController.SelectedSpecimensAction.class, c);
+ }
+
+ @Override
+ public ActionURL getSpecimensURL(Container c)
+ {
+ return SpecimenController.getSpecimensURL(c);
+ }
+
+ @Override
+ public void importSpecimenArchive(@Nullable FileLike inputFile, PipelineJob job, SimpleStudyImportContext ctx, boolean merge, boolean syncParticipantVisit) throws PipelineJobException
+ {
+ AbstractSpecimenTask.doImport(inputFile, job, ctx, merge, syncParticipantVisit);
+ }
+
+ @Override
+ public void clearRequestCaches(Container c)
+ {
+ SpecimenRequestManager.get().clearCaches(c);
+ }
+
+ @Override
+ public @Nullable QueryUpdateService getSpecimenQueryUpdateService(Container c, TableInfo queryTable)
+ {
+ return SettingsManager.get().getRepositorySettings(c).isSpecimenDataEditable() ? new SpecimenUpdateService(queryTable) : null;
+ }
+
+ @Override
+ public QueryView getSpecimenQueryView(ViewContext context, QuerySettings settings)
+ {
+ return SpecimenQueryView.createView(context, settings, SpecimenQueryView.ViewType.VIALS);
+ }
+
+ @Override
+ public void setDefaultRequestabilityRules(Container container, User user)
+ {
+ RequestabilityManager.getInstance().setDefaultRules(container, user);
+ }
+
+ @Override
+ public boolean isEnableRequests(Container c)
+ {
+ return SettingsManager.get().getRepositorySettings(c).isEnableRequests();
+ }
+
+ @Override
+ public void addSpecimenPivotTableNames(Set names)
+ {
+ names.add(SpecimenPivotByPrimaryType.PIVOT_BY_PRIMARY_TYPE);
+ names.add(SpecimenPivotByDerivativeType.PIVOT_BY_DERIVATIVE_TYPE);
+ names.add(SpecimenPivotByRequestingLocation.PIVOT_BY_REQUESTING_LOCATION);
+ }
+
+ @Override
+ public @Nullable TableInfo getSpecimenPivotTable(UserSchema schema, String name, Study study, ContainerFilter cf)
+ {
+ if (SpecimenPivotByPrimaryType.PIVOT_BY_PRIMARY_TYPE.equalsIgnoreCase(name))
+ {
+ return new SpecimenPivotByPrimaryType(schema, study, cf);
+ }
+ if (SpecimenPivotByDerivativeType.PIVOT_BY_DERIVATIVE_TYPE.equalsIgnoreCase(name))
+ {
+ return new SpecimenPivotByDerivativeType(schema, study, cf);
+ }
+ if (SpecimenPivotByRequestingLocation.PIVOT_BY_REQUESTING_LOCATION.equalsIgnoreCase(name))
+ {
+ return new SpecimenPivotByRequestingLocation(schema, study, cf);
+ }
+ return null;
+ }
+
+ @Override
+ public @Nullable SafeToRender getSpecimenSettingsLink(Container c, User user)
+ {
+ // Must have ManageRequestSettingsPermission and requests enabled (otherwise non-admin will see a blank Manage Study page)
+ if (c.hasPermission(user, ManageRequestSettingsPermission.class) && isEnableRequests(c))
+ {
+ return LinkBuilder.labkeyLink(
+ "Manage Specimen Request Settings",
+ PageFlowUtil.urlProvider(StudyUrls.class).getManageStudyURL(c)
+ );
+ }
+
+ return null;
+ }
+
+ @Override
+ public @NotNull Class extends Permission> getEditSpecimenDataPermission()
+ {
+ return EditSpecimenDataPermission.class;
+ }
+ });
+
+ SystemMaintenance.addTask(new QueryBasedSpecimenImportUploadTask());
+ }
+
+ @Override
+ protected void startupAfterSpringConfig(ModuleContext moduleContext)
+ {
+ ContainerManager.addContainerListener(new SpecimenRequestContainerListener());
+
+ StudyService.get().registerStudyTabProvider(tabs -> tabs.add(new SpecimensPage("Specimen Data")));
+ AuditLogService.get().registerAuditType(new SpecimenCommentAuditProvider());
+ PipelineService.get().registerPipelineProvider(new SpecimenPipeline(this));
+ StudyInternalService.get().registerManageStudyViewFactory(ctx -> ctx.getContainer().hasActiveModuleByName("specimen") ? new ManageSpecimenView() : null);
+
+ SimpleStudyWriterRegistry.registerSimpleStudyWriterProvider(() -> List.of(
+ new SpecimenArchiveWriter(),
+ new SpecimenSettingsWriter()
+ ));
+
+ SimpleStudyImporterRegistry.registerSimpleStudyImporterProvider(() -> List.of(
+ new SpecimenSchemaImporter(),
+ new SpecimenSettingsImporter()
+ ));
+
+ SpecimenService specimenService = SpecimenService.get();
+ if (null != specimenService)
+ specimenService.registerSpecimenTransform(new QueryBasedSpecimenTransform());
+
+ UsageMetricsService svc = UsageMetricsService.get();
+ if (null != svc)
+ {
+ StudyService ss = StudyService.get();
+
+ if (null != ss)
+ {
+ svc.registerUsageMetrics(NAME, () -> {
+ // Collect and add specimen repository statistics: simple vs. advanced study count, event/vial/specimen count, count of studies with requests enabled, request count by status
+ MultiSet specimenMultiSet = new HashMultiSet<>();
+ MutableInt requestsEnabled = new MutableInt(0);
+ MutableInt hasLocations = new MutableInt(0);
+
+ ss.getAllStudies(ContainerManager.getRoot()).stream()
+ .map(study -> SpecimenQuerySchema.get(study, User.getAdminServiceUser()))
+ .forEach(schema -> {
+ RepositorySettings settings = SettingsManager.get().getRepositorySettings(schema.getContainer());
+
+ if (settings.isSimple())
+ {
+ specimenMultiSet.add("simple");
+ TableInfo simpleSpecimens = schema.getTable(SpecimenTablesProvider.SIMPLE_SPECIMEN_TABLE_NAME);
+ specimenMultiSet.add("simpleSpecimens", (int) new TableSelector(simpleSpecimens).getRowCount());
+ }
+ else
+ {
+ specimenMultiSet.add("advanced");
+ TableInfo events = schema.getTable(SpecimenTablesProvider.SPECIMEN_EVENT_TABLE_NAME);
+ TableInfo vials = schema.getTable(SpecimenTablesProvider.SPECIMEN_DETAIL_TABLE_NAME);
+ TableInfo specimens = schema.getTable(SpecimenTablesProvider.SPECIMEN_SUMMARY_TABLE_NAME);
+ specimenMultiSet.add("events", (int) new TableSelector(events).getRowCount());
+ specimenMultiSet.add("vials", (int) new TableSelector(vials).getRowCount());
+ specimenMultiSet.add("specimens", (int) new TableSelector(specimens).getRowCount());
+ }
+
+ if (settings.isEnableRequests())
+ requestsEnabled.increment();
+
+ TableInfo locations = schema.getTable(SpecimenQuerySchema.LOCATION_TABLE_NAME);
+ long locationCount = new TableSelector(locations).getRowCount();
+ specimenMultiSet.add("locations", (int) locationCount);
+ specimenMultiSet.add("locationsInUse", (int) new TableSelector(locations, new SimpleFilter(FieldKey.fromParts("In Use"), true), null).getRowCount());
+ if (locationCount > 0)
+ hasLocations.increment();
+
+ LOG.debug(specimenMultiSet.toString());
+ });
+
+ Map specimensMap = specimenMultiSet.uniqueSet().stream().collect(Collectors.toMap(s -> s, specimenMultiSet::getCount));
+ Map requestsMap = new SqlSelector(SpecimenSchema.get().getSchema(), new SQLFragment("SELECT Label, COUNT(*) FROM study.SampleRequest INNER JOIN study.SampleRequestStatus srs ON StatusId = srs.RowId GROUP BY Label")).getValueMap(String.class);
+ requestsMap.put("enabled", requestsEnabled);
+ specimensMap.put("requests", requestsMap);
+ specimensMap.put("hasLocations", hasLocations.intValue());
+
+ return specimensMap;
+ });
+ }
+ }
+ }
+
+ @Override
+ public @NotNull Set> getUnitTests()
+ {
+ return Set.of(
+ SpecimenWriter.TestCase.class
+ );
+ }
+
+ @Override
+ public @NotNull Collection getSchemaNames()
+ {
+ return Set.of();
+ }
+
+ @Override
+ public @NotNull Set> getIntegrationTests()
+ {
+ return Set.of(
+ SpecimenImporter.TestCase.class,
+ SpecimenRequestRequirementProvider.ContainerScopingTestCase.class,
+ SpecimenController.ContainerScopingTestCase.class
+ );
+ }
+}
\ No newline at end of file
diff --git a/specimen/src/org/labkey/specimen/SpecimenRequestContainerListener.java b/specimen/src/org/labkey/specimen/SpecimenRequestContainerListener.java
new file mode 100644
index 00000000..8f04c51e
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/SpecimenRequestContainerListener.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2022-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.specimen;
+
+import org.labkey.api.data.Container;
+import org.labkey.api.data.ContainerManager;
+import org.labkey.api.security.User;
+
+import java.beans.PropertyChangeEvent;
+
+public class SpecimenRequestContainerListener implements ContainerManager.ContainerListener
+{
+ @Override
+ public void containerCreated(Container c, User user)
+ {
+ SpecimenRequestManager.get().clearCaches(c);
+ }
+
+ @Override
+ public void containerDeleted(Container c, User user)
+ {
+ SpecimenRequestManager.get().clearCaches(c);
+ }
+
+ @Override
+ public void containerMoved(Container c, Container oldParent, User user)
+ {
+ SpecimenRequestManager.get().clearCaches(c);
+ }
+
+ @Override
+ public void propertyChange(PropertyChangeEvent evt)
+ {
+ SpecimenRequestManager.get().clearCaches((Container)evt.getSource());
+ }
+}
diff --git a/specimen/src/org/labkey/specimen/SpecimenRequestException.java b/specimen/src/org/labkey/specimen/SpecimenRequestException.java
new file mode 100644
index 00000000..ea05a1cb
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/SpecimenRequestException.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+public class SpecimenRequestException extends RuntimeException
+{
+ public SpecimenRequestException()
+ {
+ }
+
+ public SpecimenRequestException(String message)
+ {
+ super(message);
+ }
+}
diff --git a/specimen/src/org/labkey/specimen/SpecimenRequestManager.java b/specimen/src/org/labkey/specimen/SpecimenRequestManager.java
new file mode 100644
index 00000000..0e601503
--- /dev/null
+++ b/specimen/src/org/labkey/specimen/SpecimenRequestManager.java
@@ -0,0 +1,1290 @@
+/*
+ * Copyright (c) 2021-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.specimen;
+
+import org.apache.commons.collections4.comparators.ComparableComparator;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.labkey.api.action.SpringActionController;
+import org.labkey.api.attachments.AttachmentFile;
+import org.labkey.api.attachments.AttachmentService;
+import org.labkey.api.cache.BlockingCache;
+import org.labkey.api.cache.CacheLoader;
+import org.labkey.api.cache.CacheManager;
+import org.labkey.api.collections.IntHashMap;
+import org.labkey.api.collections.LongArrayList;
+import org.labkey.api.data.ColumnInfo;
+import org.labkey.api.data.Container;
+import org.labkey.api.data.DatabaseCache;
+import org.labkey.api.data.DbScope;
+import org.labkey.api.data.RuntimeSQLException;
+import org.labkey.api.data.SQLFragment;
+import org.labkey.api.data.SimpleFilter;
+import org.labkey.api.data.SqlExecutor;
+import org.labkey.api.data.SqlSelector;
+import org.labkey.api.data.Table;
+import org.labkey.api.data.TableInfo;
+import org.labkey.api.data.TableResultSet;
+import org.labkey.api.exp.Lsid;
+import org.labkey.api.exp.ObjectProperty;
+import org.labkey.api.exp.OntologyManager;
+import org.labkey.api.exp.PropertyType;
+import org.labkey.api.query.FieldKey;
+import org.labkey.api.query.QueryService;
+import org.labkey.api.query.UserSchema;
+import org.labkey.api.query.ValidationException;
+import org.labkey.api.security.User;
+import org.labkey.api.settings.AppProps;
+import org.labkey.api.specimen.SpecimenQuerySchema;
+import org.labkey.api.specimen.SpecimenSchema;
+import org.labkey.api.specimen.Vial;
+import org.labkey.api.specimen.importer.RollupHelper;
+import org.labkey.api.specimen.importer.RollupInstance;
+import org.labkey.api.specimen.importer.VialSpecimenRollup;
+import org.labkey.api.specimen.model.SpecimenTablesProvider;
+import org.labkey.api.study.QueryHelper;
+import org.labkey.api.study.QueryHelper.StudyCacheCollections;
+import org.labkey.api.study.SpecimenUrls;
+import org.labkey.api.study.Study;
+import org.labkey.api.study.StudyService;
+import org.labkey.api.study.StudyUtils;
+import org.labkey.api.util.GUID;
+import org.labkey.api.util.PageFlowUtil;
+import org.labkey.api.util.Pair;
+import org.labkey.api.util.ReentrantLockWithName;
+import org.labkey.api.view.ActionURL;
+import org.labkey.specimen.importer.RequestabilityManager;
+import org.labkey.specimen.model.SpecimenRequestEvent;
+import org.labkey.specimen.requirements.SpecimenRequest;
+import org.labkey.specimen.requirements.SpecimenRequestRequirement;
+import org.labkey.specimen.requirements.SpecimenRequestRequirementProvider;
+import org.labkey.specimen.security.permissions.ManageRequestsPermission;
+import org.labkey.specimen.security.permissions.RequestSpecimensPermission;
+import org.labkey.specimen.settings.SettingsManager;
+
+import java.io.IOException;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.locks.ReentrantLock;
+
+import static org.labkey.api.util.IntegerUtils.asLongElseNull;
+
+public class SpecimenRequestManager
+{
+ private static final SpecimenRequestManager INSTANCE = new SpecimenRequestManager();
+
+ private final QueryHelper> _requestEventHelper;
+ private final QueryHelper> _requestStatusHelper;
+ private final QueryHelper> _requestHelper;
+
+ public static SpecimenRequestManager get()
+ {
+ return INSTANCE;
+ }
+
+ private SpecimenRequestManager()
+ {
+ _requestEventHelper = new QueryHelper<>(() -> SpecimenSchema.get().getTableInfoSampleRequestEvent(), SpecimenRequestEvent.class);
+ _requestStatusHelper = new QueryHelper<>(() -> SpecimenSchema.get().getTableInfoSampleRequestStatus(), SpecimenRequestStatus.class, "SortOrder");
+ _requestHelper = new QueryHelper<>(() -> SpecimenSchema.get().getTableInfoSampleRequest(), SpecimenRequest.class, "-Created");
+
+ initGroupedValueAllowedColumnMap();
+ }
+
+ public List getRequestStatuses(Container c, User user)
+ {
+ List statuses = new ArrayList<>(_requestStatusHelper.getCollection(c));
+ // if the 'not-yet-submitted' status doesn't exist, create it here, with sort order -1,
+ // so it's always first.
+ if (statuses.isEmpty() || statuses.getFirst().getSortOrder() != -1)
+ {
+ SpecimenRequestStatus notYetSubmittedStatus = new SpecimenRequestStatus();
+ notYetSubmittedStatus.setContainer(c);
+ notYetSubmittedStatus.setFinalState(false);
+ notYetSubmittedStatus.setSpecimensLocked(true);
+ notYetSubmittedStatus.setLabel("Not Yet Submitted");
+ notYetSubmittedStatus.setSortOrder(-1);
+ try (var ignore = SpringActionController.ignoreSqlUpdates())
+ {
+ Table.insert(user, _requestStatusHelper.getTableInfo(), notYetSubmittedStatus);
+ _requestStatusHelper.clearCache(c);
+ }
+ statuses = new ArrayList<>(_requestStatusHelper.getCollection(c));
+ }
+ return statuses;
+ }
+
+ public SpecimenRequestStatus getRequestShoppingCartStatus(Container c, User user)
+ {
+ List statuses = getRequestStatuses(c, user);
+ if (statuses.getFirst().getSortOrder() != -1)
+ throw new IllegalStateException("Shopping cart status should be created automatically.");
+ return statuses.getFirst();
+ }
+
+ public SpecimenRequestStatus getInitialRequestStatus(Container c, User user, boolean nonCart)
+ {
+ List statuses = getRequestStatuses(c, user);
+ if (!nonCart && SettingsManager.get().isSpecimenShoppingCartEnabled(c))
+ return statuses.get(0);
+ else
+ return statuses.get(1);
+ }
+
+ public boolean hasEditRequestPermissions(User user, SpecimenRequest request)
+ {
+ if (request == null)
+ return false;
+ Container container = request.getContainer();
+ if (!container.hasPermission(user, RequestSpecimensPermission.class))
+ return false;
+ if (container.hasPermission(user, ManageRequestsPermission.class))
+ return true;
+
+ if (SettingsManager.get().isSpecimenShoppingCartEnabled(container))
+ {
+ SpecimenRequestStatus cartStatus = getRequestShoppingCartStatus(container, user);
+ if (cartStatus.getRowId() == request.getStatusId() && request.getCreatedBy() == user.getUserId())
+ return true;
+ }
+ return false;
+ }
+
+ public Set getRequestStatusIdsInUse(Container c)
+ {
+ Collection requests = _requestHelper.getCollection(c);
+ Set uniqueStatuses = new HashSet<>();
+ for (SpecimenRequest request : requests)
+ uniqueStatuses.add(request.getStatusId());
+ return uniqueStatuses;
+ }
+
+ public SpecimenRequestEvent getRequestEvent(Container c, int rowId)
+ {
+ return _requestEventHelper.get(c, rowId);
+ }
+
+ public static class SpecimenRequestInput
+ {
+ private final String _title;
+ private final String _helpText;
+ private final int _displayOrder;
+
+ private boolean _required;
+ private boolean _rememberSiteValue;
+ private boolean _multiLine;
+ private Map _locationToDefaultValue;
+
+ public SpecimenRequestInput(String title, String helpText, int displayOrder, boolean multiLine, boolean required, boolean rememberSiteValue)
+ {
+ _title = title;
+ _required = required;
+ _rememberSiteValue = rememberSiteValue;
+ _helpText = helpText;
+ _displayOrder = displayOrder;
+ _multiLine = multiLine;
+ }
+
+ public SpecimenRequestInput(String title, String helpText, int displayOrder)
+ {
+ this(title, helpText, displayOrder, false, false, false);
+ }
+
+ public String getHelpText()
+ {
+ return _helpText;
+ }
+
+ public boolean isRememberSiteValue()
+ {
+ return _rememberSiteValue;
+ }
+
+ public boolean isRequired()
+ {
+ return _required;
+ }
+
+ public String getTitle()
+ {
+ return _title;
+ }
+
+ public int getDisplayOrder()
+ {
+ return _displayOrder;
+ }
+
+ public boolean isMultiLine()
+ {
+ return _multiLine;
+ }
+
+ public void setMultiLine(boolean multiLine)
+ {
+ _multiLine = multiLine;
+ }
+
+ public void setRememberSiteValue(boolean rememberSiteValue)
+ {
+ _rememberSiteValue = rememberSiteValue;
+ }
+
+ public void setRequired(boolean required)
+ {
+ _required = required;
+ }
+
+ public Map getDefaultSiteValues(Container container) throws ValidationException
+ {
+ if (!isRememberSiteValue())
+ throw new UnsupportedOperationException("Only those inputs set to remember site values can be queried for a site default.");
+
+ if (_locationToDefaultValue != null)
+ return _locationToDefaultValue;
+ String defaultObjectLsid = getRequestInputDefaultObjectLsid(container);
+ String setItemLsid = ensureOntologyManagerSetItem(container, defaultObjectLsid, getTitle());
+ Map locationToValue = new IntHashMap<>();
+
+ Map defaultValueProperties = OntologyManager.getPropertyObjects(container, setItemLsid);
+ if (defaultValueProperties != null)
+ {
+ for (Map.Entry defaultValue : defaultValueProperties.entrySet())
+ {
+ String locationIdString = defaultValue.getKey().substring(defaultValue.getKey().lastIndexOf(".") + 1);
+ int locationId = Integer.parseInt(locationIdString);
+ locationToValue.put(locationId, defaultValue.getValue().getStringValue());
+ }
+ }
+ _locationToDefaultValue = locationToValue;
+ return _locationToDefaultValue;
+ }
+
+ public void setDefaultSiteValue(Container container, int locationId, String value) throws SQLException
+ {
+ try
+ {
+ assert locationId > 0 : "Invalid site id: " + locationId;
+ if (!isRememberSiteValue())
+ throw new UnsupportedOperationException("Only those inputs configured to remember site values can set a site default.");
+ _locationToDefaultValue = null;
+ String parentObjectLsid = getRequestInputDefaultObjectLsid(container);
+
+ String setItemLsid = ensureOntologyManagerSetItem(container, parentObjectLsid, getTitle());
+ String propertyId = parentObjectLsid + "." + locationId;
+ ObjectProperty defaultValueProperty = new ObjectProperty(setItemLsid, container, propertyId, value);
+ OntologyManager.deleteProperty(setItemLsid, propertyId, container, container);
+ OntologyManager.insertProperties(container, setItemLsid, defaultValueProperty);
+ }
+ catch (ValidationException e)
+ {
+ throw new SQLException(e.getMessage());
+ }
+ }
+ }
+
+ public SpecimenRequestInput[] getNewSpecimenRequestInputs(Container container) throws SQLException
+ {
+ return getNewSpecimenRequestInputs(container, true);
+ }
+
+ public SpecimenRequestInput[] getNewSpecimenRequestInputs(Container container, boolean createIfMissing) throws SQLException
+ {
+ String parentObjectLsid = getRequestInputObjectLsid(container);
+ Map resourceProperties = OntologyManager.getPropertyObjects(container, parentObjectLsid);
+ SpecimenRequestInput[] inputs = new SpecimenRequestInput[0];
+ if (resourceProperties == null || resourceProperties.isEmpty())
+ {
+ if (createIfMissing)
+ {
+ try (var ignore = SpringActionController.ignoreSqlUpdates())
+ {
+ inputs = new SpecimenRequestInput[] {
+ new SpecimenRequestInput("Assay Plan", "Please enter a description of or reference to the assay plan(s) that will be used for the requested specimens.", 0, true, true, false),
+ new SpecimenRequestInput("Shipping Information", "Please enter your shipping address along with any special instructions.", 1, true, true, true),
+ new SpecimenRequestInput("Comments", "Please enter any additional information regarding your request.", 2, true, false, false)
+ };
+ saveNewSpecimenRequestInputs(container, inputs);
+ }
+ }
+ return inputs;
+ }
+ else
+ {
+ inputs = new SpecimenRequestInput[resourceProperties.size()];
+ for (Map.Entry parentPropertyEntry : resourceProperties.entrySet())
+ {
+ String resourcePropertyLsid = parentPropertyEntry.getKey();
+ int displayOrder = Integer.parseInt(resourcePropertyLsid.substring(resourcePropertyLsid.lastIndexOf('.') + 1));
+
+ Map inputProperties = parentPropertyEntry.getValue().retrieveChildProperties();
+ String title = inputProperties.get(parentObjectLsid + ".Title").getStringValue();
+ String helpText = null;
+ if (inputProperties.get(parentObjectLsid + ".HelpText") != null)
+ helpText = inputProperties.get(parentObjectLsid + ".HelpText").getStringValue();
+ boolean rememberSiteValue = inputProperties.get(parentObjectLsid + ".RememberSiteValue").getFloatValue() == 1;
+ boolean required = inputProperties.get(parentObjectLsid + ".Required").getFloatValue() == 1;
+ boolean multiLine = inputProperties.get(parentObjectLsid + ".MultiLine").getFloatValue() == 1;
+ inputs[displayOrder] = new SpecimenRequestInput(title, helpText, displayOrder, multiLine, required, rememberSiteValue);
+ }
+ }
+ return inputs;
+ }
+
+ private static String getRequestInputObjectLsid(Container container)
+ {
+ return new Lsid(StudyService.SPECIMEN_NAMESPACE_PREFIX, "Folder-" + container.getRowId(), "RequestInput").toString();
+ }
+
+ private static String getRequestInputDefaultObjectLsid(Container container)
+ {
+ return new Lsid(StudyService.SPECIMEN_NAMESPACE_PREFIX, "Folder-" + container.getRowId(), "RequestInputDefault").toString();
+ }
+
+ private static String ensureOntologyManagerSetItem(Container container, String lsidBase, String uniqueItemId) throws ValidationException
+ {
+ try (var ignore = SpringActionController.ignoreSqlUpdates())
+ {
+ Long listParentObjectId = OntologyManager.ensureObject(container, lsidBase);
+ String listItemReferenceLsidPrefix = lsidBase + "#objectResource.";
+ String listItemObjectLsid = lsidBase + "#" + uniqueItemId;
+ String listItemPropertyReferenceLsid = listItemReferenceLsidPrefix + uniqueItemId;
+
+ // ensure the object that corresponds to a single list item:
+ OntologyManager.ensureObject(container, listItemObjectLsid, listParentObjectId);
+
+ // check to make sure that the list item is wired up to the top-level list object via a property:
+ Map properties = OntologyManager.getPropertyObjects(container, lsidBase);
+ if (!properties.containsKey(listItemPropertyReferenceLsid))
+ {
+ // create the resource property that links the parent object to the list item object:
+ ObjectProperty resourceProperty = new ObjectProperty(lsidBase, container,
+ listItemPropertyReferenceLsid, listItemObjectLsid, PropertyType.RESOURCE);
+ OntologyManager.insertProperties(container, lsidBase, resourceProperty);
+ }
+ return listItemObjectLsid;
+ }
+ }
+
+ public void saveNewSpecimenRequestInputs(Container container, SpecimenRequestInput[] inputs) throws SQLException
+ {
+ if (!requestInputsChanged(container, inputs))
+ return;
+
+ try {
+ String parentObjectLsid = getRequestInputObjectLsid(container);
+ String defaultValuesObjectLsid = getRequestInputDefaultObjectLsid(container);
+ OntologyManager.deleteOntologyObject(parentObjectLsid, container, true);
+ OntologyManager.deleteOntologyObject(defaultValuesObjectLsid, container, true);
+ for (int i = 0; i < inputs.length; i++)
+ {
+ SpecimenRequestInput input = inputs[i];
+ String setItemLsid = ensureOntologyManagerSetItem(container, parentObjectLsid, "" + i);
+ ObjectProperty[] props = new ObjectProperty[5];
+ props[0] = new ObjectProperty(setItemLsid, container, parentObjectLsid + ".HelpText", input.getHelpText());
+ props[1] = new ObjectProperty(setItemLsid, container, parentObjectLsid + ".Required", input.isRequired() ? 1 : 0);
+ props[2] = new ObjectProperty(setItemLsid, container, parentObjectLsid + ".RememberSiteValue", input.isRememberSiteValue() ? 1 : 0);
+ props[3] = new ObjectProperty(setItemLsid, container, parentObjectLsid + ".Title", input.getTitle());
+ props[4] = new ObjectProperty(setItemLsid, container, parentObjectLsid + ".MultiLine", input.isMultiLine() ? 1 : 0);
+ OntologyManager.insertProperties(container, setItemLsid, props);
+ }
+ }
+ catch (ValidationException ve)
+ {
+ throw new SQLException(ve.getMessage());
+ }
+ }
+
+ private boolean requestInputsChanged(Container container, SpecimenRequestInput[] newInputs) throws SQLException
+ {
+ SpecimenRequestInput[] oldInputs = getNewSpecimenRequestInputs(container, false);
+ if (oldInputs.length == 0)
+ return true;
+ else if (oldInputs.length != newInputs.length)
+ return true;
+ else
+ {
+ for (int i = 0; i < oldInputs.length; i++)
+ {
+ if (oldInputs[i].isMultiLine() != newInputs[i].isMultiLine() ||
+ oldInputs[i].isRememberSiteValue() != newInputs[i].isRememberSiteValue() ||
+ oldInputs[i].isRequired() != newInputs[i].isRequired() ||
+ !oldInputs[i].getTitle().equals(newInputs[i].getTitle()) ||
+ !getSafeString(oldInputs[i].getHelpText()).equals(getSafeString(newInputs[i].getHelpText())))
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private String getSafeString(String str)
+ {
+ return Objects.requireNonNullElse(str, "");
+ }
+
+ public void createRequestStatus(User user, SpecimenRequestStatus status)
+ {
+ _requestStatusHelper.create(user, status);
+ }
+
+ public void updateRequestStatus(User user, SpecimenRequestStatus status)
+ {
+ _requestStatusHelper.update(user, status);
+ }
+
+ public void deleteRequestStatus(SpecimenRequestStatus status)
+ {
+ _requestStatusHelper.delete(status);
+ }
+
+ public SpecimenRequestRequirement[] getRequestRequirements(SpecimenRequest request)
+ {
+ if (request == null)
+ return new SpecimenRequestRequirement[0];
+ return request.getRequirements();
+ }
+
+ public void deleteRequestRequirement(User user, SpecimenRequestRequirement requirement) throws AttachmentService.DuplicateFilenameException
+ {
+ deleteRequestRequirement(user, requirement, true);
+ }
+
+ public void deleteRequestRequirement(User user, SpecimenRequestRequirement requirement, boolean createEvent) throws AttachmentService.DuplicateFilenameException
+ {
+ if (createEvent)
+ createRequestEvent(user, requirement, RequestEventType.REQUIREMENT_REMOVED, requirement.getRequirementSummary(), null);
+ requirement.delete();
+ }
+
+ public void createRequestRequirement(User user, SpecimenRequestRequirement requirement, boolean createEvent, boolean force) throws AttachmentService.DuplicateFilenameException
+ {
+ SpecimenRequest request = getRequest(requirement.getContainer(), requirement.getRequestId());
+ SpecimenRequestRequirement newRequirement = SpecimenRequestRequirementProvider.get().createRequirement(user, request, requirement, force);
+ if (newRequirement != null && createEvent)
+ createRequestEvent(user, requirement, RequestEventType.REQUIREMENT_ADDED, requirement.getRequirementSummary(), null);
+ }
+
+ public void updateRequestRequirement(User user, SpecimenRequestRequirement requirement)
+ {
+ requirement.update(user);
+ }
+
+ public boolean isInFinalState(SpecimenRequest request)
+ {
+ return getRequestStatus(request.getContainer(), request.getStatusId()).isFinalState();
+ }
+
+ public SpecimenRequestStatus getRequestStatus(Container c, int rowId)
+ {
+ return _requestStatusHelper.get(c, rowId);
+ }
+
+ private SpecimenRequestEvent createRequestEvent(User user, SpecimenRequestEvent event)
+ {
+ return _requestEventHelper.create(user, event);
+ }
+
+ public SpecimenRequestEvent createRequestEvent(User user, SpecimenRequestRequirement requirement, RequestEventType type, String comments, List attachments) throws AttachmentService.DuplicateFilenameException
+ {
+ return createRequestEvent(user, requirement.getContainer(), requirement.getRequestId(), requirement.getRowId(), type, comments, attachments);
+ }
+
+ public SpecimenRequestEvent createRequestEvent(User user, SpecimenRequest request, RequestEventType type, String comments, List attachments) throws AttachmentService.DuplicateFilenameException
+ {
+ return createRequestEvent(user, request.getContainer(), request.getRowId(), -1, type, comments, attachments);
+ }
+
+ private SpecimenRequestEvent createRequestEvent(User user, Container container, int requestId, int requirementId, RequestEventType type, String comments, List attachments) throws AttachmentService.DuplicateFilenameException
+ {
+ SpecimenRequestEvent event = new SpecimenRequestEvent();
+ event.setEntryType(type.getDisplayText());
+ event.setComments(comments);
+ event.setRequestId(requestId);
+ event.setCreated(new Date(System.currentTimeMillis()));
+ if (requirementId >= 0)
+ event.setRequirementId(requirementId);
+ event.setContainer(container);
+ event.setEntityId(GUID.makeGUID());
+ event = createRequestEvent(user, event);
+ try
+ {
+ AttachmentService.get().addAttachments(event, attachments, user);
+ }
+ catch (AttachmentService.DuplicateFilenameException e)
+ {
+ // UI should (minimally) catch and display these errors nicely
+ throw e;
+ }
+ catch (IOException e)
+ {
+ // this is unexpected, and indicative of a larger system problem; we'll convert to a runtime
+ // exception, rather than requiring all event loggers to handle this unlikely scenario:
+ throw new RuntimeException(e);
+ }
+ return event;
+ }
+
+ public Collection getRequests(Container c, @Nullable User user)
+ {
+ return _requestHelper.getCollection(c).stream()
+ .filter(sr -> !sr.isHidden())
+ .filter(sr -> user == null || sr.getCreatedBy() == user.getUserId())
+ .toList();
+ }
+
+ public SpecimenRequest getRequest(Container c, int rowId)
+ {
+ return _requestHelper.get(c, rowId);
+ }
+
+ public SpecimenRequest createRequest(User user, SpecimenRequest request, boolean createEvent) throws AttachmentService.DuplicateFilenameException
+ {
+ request = _requestHelper.create(user, request);
+ if (createEvent)
+ createRequestEvent(user, request, RequestEventType.REQUEST_CREATED, request.getRequestDescription(), null);
+ return request;
+ }
+
+ public void updateRequest(User user, SpecimenRequest request) throws RequestabilityManager.InvalidRuleException
+ {
+ DbScope scope = SpecimenSchema.get().getScope();
+ try (DbScope.Transaction transaction = scope.ensureTransaction())
+ {
+ _requestHelper.update(user, request);
+
+ // update specimen states
+ List vials = request.getVials();
+ if (!vials.isEmpty())
+ {
+ SpecimenRequestStatus status = getRequestStatus(request.getContainer(), request.getStatusId());
+ updateSpecimenStatus(vials, user, status.isSpecimensLocked());
+ }
+ transaction.commit();
+ }
+ }
+
+ private static final ReentrantLock REQUEST_ADDITION_LOCK = new ReentrantLockWithName(SpecimenRequestManager.class, "REQUEST_ADDITION_LOCK");
+
+ public void createRequestSpecimenMapping(User user, SpecimenRequest request, List vials, boolean createEvents, boolean createRequirements)
+ throws RequestabilityManager.InvalidRuleException, AttachmentService.DuplicateFilenameException, SpecimenRequestException
+ {
+ if (vials == null || vials.isEmpty())
+ return;
+
+ DbScope scope = SpecimenSchema.get().getScope();
+ try (DbScope.Transaction transaction = scope.ensureTransaction(REQUEST_ADDITION_LOCK))
+ {
+ for (Vial vial : vials)
+ {
+ if (!request.getContainer().getId().equals(vial.getContainer().getId()))
+ throw new IllegalStateException("Mismatched containers.");
+
+ if (!vial.isAvailable())
+ throw new SpecimenRequestException("Vial unavailable.");
+ }
+
+ for (Vial vial : vials)
+ {
+ Map fields = new HashMap<>();
+ fields.put("Container", request.getContainer().getId());
+ fields.put("SampleRequestId", request.getRowId());
+ fields.put("SpecimenGlobalUniqueId", vial.getGlobalUniqueId());
+ Table.insert(user, SpecimenSchema.get().getTableInfoSampleRequestSpecimen(), fields);
+ if (createEvents)
+ SpecimenRequestManager.get().createRequestEvent(user, request, RequestEventType.SPECIMEN_ADDED, vial.getSpecimenDescription(), null);
+ }
+
+ if (createRequirements)
+ SpecimenRequestRequirementProvider.get().generateDefaultRequirements(user, request);
+
+ SpecimenRequestStatus status = getRequestStatus(request.getContainer(), request.getStatusId());
+ updateSpecimenStatus(vials, user, status.isSpecimensLocked());
+
+ transaction.commit();
+ }
+ }
+
+ /**
+ * Update the lockedInRequest and available field states for the set of specimens.
+ */
+ private void updateSpecimenStatus(List vials, User user, boolean lockedInRequest) throws RequestabilityManager.InvalidRuleException
+ {
+ for (Vial vial : vials)
+ {
+ vial.setLockedInRequest(lockedInRequest);
+ Table.update(user, SpecimenSchema.get().getTableInfoVial(vial.getContainer()), vial.getRowMap(), vial.getRowId());
+ }
+ updateRequestabilityAndCounts(vials, user);
+ if (!vials.isEmpty())
+ clearCaches(getContainer(vials));
+ }
+
+ private void updateRequestabilityAndCounts(List vials, User user) throws RequestabilityManager.InvalidRuleException
+ {
+ if (vials.isEmpty())
+ return;
+ Container container = getContainer(vials);
+
+ // update requestable flags before updating counts, since available count could change:
+ for (int start = 0; start < vials.size(); start += 1000)
+ {
+ List subset = vials.subList(start, start + Math.min(1000, vials.size() - start));
+ RequestabilityManager.getInstance().updateRequestability(container, user, subset);
+ }
+
+ for (int start = 0; start < vials.size(); start += 1000)
+ {
+ List subset = vials.subList(start, start + Math.min(1000, vials.size() - start));
+ updateVialCounts(container, user, subset);
+ }
+ }
+
+ private Container getContainer(List vials)
+ {
+ Container container = vials.getFirst().getContainer();
+ if (AppProps.getInstance().isDevMode())
+ {
+ for (int i = 1; i < vials.size(); i++)
+ {
+ if (!container.equals(vials.get(i).getContainer()))
+ throw new IllegalStateException("All specimens must be from the same container");
+ }
+ }
+ return container;
+ }
+
+ private static final String UPDATE_SPECIMEN_SETS =
+ " SET\n" +
+ " TotalVolume = VialCounts.TotalVolume,\n" +
+ " AvailableVolume = VialCounts.AvailableVolume,\n" +
+ " VialCount = VialCounts.VialCount,\n" +
+ " LockedInRequestCount = VialCounts.LockedInRequestCount,\n" +
+ " AtRepositoryCount = VialCounts.AtRepositoryCount,\n" +
+ " AvailableCount = VialCounts.AvailableCount,\n" +
+ " ExpectedAvailableCount = VialCounts.ExpectedAvailableCount";
+
+ private static final String UPDATE_SPECIMEN_SELECTS =
+ "\nFROM (\n" +
+ "\tSELECT SpecimenId,\n" +
+ "\t\tSUM(Volume) AS TotalVolume,\n" +
+ "\t\tSUM(CASE Available WHEN ? THEN Volume ELSE 0 END) AS AvailableVolume,\n" +
+ "\t\tCOUNT(GlobalUniqueId) AS VialCount,\n" +
+ "\t\tSUM(CASE LockedInRequest WHEN ? THEN 1 ELSE 0 END) AS LockedInRequestCount,\n" +
+ "\t\tSUM(CASE AtRepository WHEN ? THEN 1 ELSE 0 END) AS AtRepositoryCount,\n" +
+ "\t\tSUM(CASE Available WHEN ? THEN 1 ELSE 0 END) AS AvailableCount,\n" +
+ "\t\t(COUNT(GlobalUniqueId) - SUM(\n" +
+ "\t\tCASE\n" +
+ "\t\t\t(CASE LockedInRequest WHEN ? THEN 1 ELSE 0 END) -- Null is considered false for LockedInRequest\n" +
+ "\t\t\t| (CASE Requestable WHEN ? THEN 1 ELSE 0 END)-- Null is considered true for Requestable\n" +
+ "\t\t\tWHEN 1 THEN 1 ELSE 0 END)\n" +
+ "\t\t) AS ExpectedAvailableCount";
+
+ // "\tFROM ";
+
+ private void updateVialCounts(Container container, User user, List vials)
+ {
+ TableInfo tableInfoSpecimen = SpecimenSchema.get().getTableInfoSpecimen(container);
+ TableInfo tableInfoVial = SpecimenSchema.get().getTableInfoVial(container);
+
+ String tableInfoSpecimenSelectName = tableInfoSpecimen.getSelectName();
+ String tableInfoVialSelectName = tableInfoVial.getSelectName();
+ RollupHelper.RollupMap matchedRollups = RollupHelper.getVialToSpecimenRollups(container, user);
+
+ SQLFragment updateSql = new SQLFragment();
+ updateSql.append("UPDATE ").append(tableInfoSpecimenSelectName).append(UPDATE_SPECIMEN_SETS);
+ for (List> rollupList : matchedRollups.values())
+ for (Pair rollupItem : rollupList)
+ {
+ ColumnInfo column = tableInfoSpecimen.getColumn(rollupItem.first);
+ if (null == column)
+ throw new IllegalStateException("Expected Specimen table column to exist.");
+ var colSelectName = column.getSelectIdentifier();
+ updateSql.append(",\n ").appendIdentifier(colSelectName).append(" = VialCounts.").appendIdentifier(colSelectName);
+ }
+
+ updateSql.append(UPDATE_SPECIMEN_SELECTS);
+ updateSql.add(Boolean.TRUE); // AvailableVolume
+ updateSql.add(Boolean.TRUE); // LockedInRequestCount
+ updateSql.add(Boolean.TRUE); // AtRepositoryCount
+ updateSql.add(Boolean.TRUE); // AvailableCount
+ updateSql.add(Boolean.TRUE); // LockedInRequest case of ExpectedAvailableCount
+ updateSql.add(Boolean.FALSE); // Requestable case of ExpectedAvailableCount
+
+ for (Map.Entry>> entry : matchedRollups.entrySet())
+ {
+ ColumnInfo vialColumn = tableInfoVial.getColumn(entry.getKey());
+ if (null == vialColumn)
+ throw new IllegalStateException("Expected Vial table column to exist.");
+ var fromName = vialColumn.getSelectIdentifier();
+ for (RollupInstance rollupItem : entry.getValue())
+ {
+ VialSpecimenRollup rollup = rollupItem.second;
+ ColumnInfo column = tableInfoSpecimen.getColumn(rollupItem.first);
+ if (null == column)
+ throw new IllegalStateException("Expected Specimen table column to exist.");
+ var toName = column.getSelectIdentifier();
+
+ // TODO convert to SQLFragment
+ updateSql.append(",\n\t\t").append(rollup.getRollupSql(fromName.getId(), toName.getId()));
+ }
+ }
+
+ updateSql.append("\tFROM ").append(tableInfoVialSelectName).append("\n");
+
+ if (vials != null && !vials.isEmpty())
+ {
+ Set specimenIds = new HashSet<>();
+ for (Vial vial : vials)
+ specimenIds.add(vial.getSpecimenId());
+
+ updateSql.append("WHERE ")
+ .append(tableInfoVial.getColumn("SpecimenId").getValueSql(tableInfoVialSelectName))
+ .append(" IN (");
+ String sep = "";
+ for (Long id : specimenIds)
+ {
+ updateSql.append(sep).append("?");
+ updateSql.add(id);
+ sep = ", ";
+ }
+ updateSql.append(")\n");
+ }
+
+ updateSql.append("\tGROUP BY SpecimenId\n) VialCounts\nWHERE ")
+ .append(tableInfoSpecimen.getColumn("RowId").getValueSql(tableInfoSpecimenSelectName))
+ .append("= VialCounts.SpecimenId");
+ new SqlExecutor(SpecimenSchema.get().getSchema()).execute(updateSql);
+ }
+
+ public void updateVialCounts(Container container, User user)
+ {
+ updateVialCounts(container, user, null);
+ }
+
+ public void clearCaches(Container c)
+ {
+ _requestEventHelper.clearCache(c);
+ _requestHelper.clearCache(c);
+ _requestStatusHelper.clearCache(c);
+
+ clearGroupedValuesForColumn(c);
+ }
+
+ void clearRequestStatusHelper(Container c)
+ {
+ _requestStatusHelper.clearCache(c);
+ }
+
+ void clearRequestHelper(Container c)
+ {
+ _requestHelper.clearCache(c);
+ }
+
+ void clearRequestEventHelper(Container c)
+ {
+ _requestEventHelper.clearCache(c);
+ }
+
+ public static class GroupedValueColumnHelper
+ {
+ private final String _viewColumnName;
+ private final String _sqlColumnName;
+ private final String _urlFilterName;
+ private final String _joinColumnName;
+
+ public GroupedValueColumnHelper(String sqlColumnName, String viewColumnName, String urlFilterName, String joinColumnName)
+ {
+ _sqlColumnName = sqlColumnName;
+ _viewColumnName = viewColumnName;
+ _urlFilterName = urlFilterName;
+ _joinColumnName = joinColumnName;
+ }
+
+ public String getUrlFilterName()
+ {
+ return _urlFilterName;
+ }
+
+ public FieldKey getFieldKey()
+ {
+ // constructs FieldKey whether it needs join or not
+ if (null == _joinColumnName)
+ return FieldKey.fromString(_viewColumnName);
+ return FieldKey.fromParts(_sqlColumnName, _joinColumnName);
+ }
+ }
+
+ // Map "ViewColumnName" name to object with sql column name and url filter name
+ private final Map _groupedValueAllowedColumnMap = new HashMap<>();
+
+ private void initGroupedValueAllowedColumnMap()
+ { // sqlColumnName viewColumnName urlFilterName joinColumnName
+ _groupedValueAllowedColumnMap.put("Primary Type", new GroupedValueColumnHelper("PrimaryTypeId", "PrimaryType", "PrimaryType/Description", "PrimaryType"));
+ _groupedValueAllowedColumnMap.put("Derivative Type", new GroupedValueColumnHelper("DerivativeTypeId", "DerivativeType", "DerivativeType/Description", "Derivative"));
+ _groupedValueAllowedColumnMap.put("Additive Type", new GroupedValueColumnHelper("AdditiveTypeId", "AdditiveType", "AdditiveType/Description", "Additive"));
+ _groupedValueAllowedColumnMap.put("Derivative Type2", new GroupedValueColumnHelper("DerivativeTypeId2", "DerivativeType2", "DerivativeType2/Description", "Derivative"));
+ _groupedValueAllowedColumnMap.put("Sub Additive Derivative",new GroupedValueColumnHelper("SubAdditiveDerivative", "SubAdditiveDerivative", "SubAdditiveDerivative", null));
+ _groupedValueAllowedColumnMap.put("Clinic", new GroupedValueColumnHelper("originatinglocationid", "Clinic", "Clinic/Label", "Label"));
+ _groupedValueAllowedColumnMap.put("Processing Location", new GroupedValueColumnHelper("ProcessingLocation", "ProcessingLocation", "ProcessingLocation/Label", "Label"));
+ _groupedValueAllowedColumnMap.put("Protocol Number", new GroupedValueColumnHelper("ProtocolNumber", "ProtocolNumber", "ProtocolNumber", null));
+ _groupedValueAllowedColumnMap.put("Tube Type", new GroupedValueColumnHelper("TubeType", "TubeType", "TubeType", null));
+ _groupedValueAllowedColumnMap.put("Site Name", new GroupedValueColumnHelper("CurrentLocation", "SiteName", "SiteName/Label", "Label"));
+ _groupedValueAllowedColumnMap.put("Available", new GroupedValueColumnHelper("Available", "Available", "Available", null));
+ _groupedValueAllowedColumnMap.put("Freezer", new GroupedValueColumnHelper("Freezer", "Freezer", "Freezer", null));
+ _groupedValueAllowedColumnMap.put("Fr Container", new GroupedValueColumnHelper("Fr_Container", "Fr_Container", "Fr_Container", null));
+ _groupedValueAllowedColumnMap.put("Fr Position", new GroupedValueColumnHelper("Fr_Position", "Fr_Position", "Fr_Position", null));
+ _groupedValueAllowedColumnMap.put("Fr Level1", new GroupedValueColumnHelper("Fr_Level1", "Fr_Level1", "Fr_Level1", null));
+ _groupedValueAllowedColumnMap.put("Fr Level2", new GroupedValueColumnHelper("Fr_Level2", "Fr_Level2", "Fr_Level2", null));
+ }
+
+ public Map getGroupedValueAllowedMap()
+ {
+ return _groupedValueAllowedColumnMap;
+ }
+
+ public String[] getGroupedValueAllowedColumns()
+ {
+ Set keySet = _groupedValueAllowedColumnMap.keySet();
+ String[] allowedColumns = keySet.toArray(new String[0]);
+ Arrays.sort(allowedColumns, new ComparableComparator<>());
+ return allowedColumns;
+ }
+
+ private static class GroupedValueFilter
+ {
+ private String _viewColumnName;
+ private String _filterValueName;
+
+ public GroupedValueFilter()
+ {
+ }
+
+ public String getViewColumnName()
+ {
+ return _viewColumnName;
+ }
+
+ public String getFilterValueName()
+ {
+ return _filterValueName;
+ }
+
+ public void setFilterValueName(String filterValueName)
+ {
+ _filterValueName = filterValueName;
+ }
+
+ public void setViewColumnName(String viewColumnName)
+ {
+ _viewColumnName = viewColumnName;
+ }
+ }
+
+ private final BlockingCache>> GROUPED_VALUES_CACHE = DatabaseCache.get(SpecimenSchema.get().getScope(), 10, 8 * CacheManager.HOUR, "Specimen grouped values", new GroupedValuesCacheLoader());
+
+ private static class GroupedResults
+ {
+ public String viewName;
+ public String urlFilterName;
+ public String labelValue;
+ public long count;
+ public Map childGroupedResultsMap;
+ }
+
+ public void clearGroupedValuesForColumn(Container container)
+ {
+ GROUPED_VALUES_CACHE.remove(container);
+ }
+
+ @NotNull
+ public Map> getGroupedValuesForColumn(Container container, User user, ArrayList groupings)
+ {
+ return GROUPED_VALUES_CACHE.get(container, Pair.of(user, groupings));
+ }
+
+ private class GroupedValuesCacheLoader implements CacheLoader>>
+ {
+ @Override
+ public Map> load(@NotNull Container c, @Nullable Object argument)
+ {
+ Map> groupedValues = new HashMap<>();
+
+ // ColumnName and filter names are "QueryView" names; map them to actual table names before building query
+ Study study = StudyService.get().getStudy(c);
+ if (study != null)
+ {
+ @SuppressWarnings("unchecked") Pair> pair = (Pair>)argument;
+ assert null != pair;
+ User user = pair.first;
+ List groupings = pair.second;
+ QueryService queryService = QueryService.get();
+
+ for (String[] grouping : groupings)
+ {
+ List fieldKeys = new ArrayList<>();
+ for (String aGrouping : grouping)
+ {
+ if (!StringUtils.isNotBlank(aGrouping))
+ break; // Grouping may have null/blank entries for groupBys that are not chosen to be used
+ GroupedValueColumnHelper columnHelper = getGroupedValueAllowedMap().get(aGrouping);
+ FieldKey fieldKey = columnHelper.getFieldKey();
+ fieldKeys.add(fieldKey);
+ }
+
+ if (fieldKeys.isEmpty())
+ continue; // Nothing specified for grouping
+
+ // Basic SQL with joins
+ UserSchema schema = SpecimenQuerySchema.get(study, user);
+ TableInfo tableInfo = schema.getTable(SpecimenTablesProvider.SPECIMEN_WRAP_TABLE_NAME);
+ Map columnMap = queryService.getColumns(tableInfo, fieldKeys);
+
+ SQLFragment inner = queryService.getSelectBuilder(tableInfo).columns(columnMap.values()).buildSqlFragment();
+
+ // Insert COUNT
+ String sampleCountName = tableInfo.getSqlDialect().makeLegalIdentifier("SampleCount");
+ SQLFragment sql = new SQLFragment("SELECT COUNT(*) As " + sampleCountName);
+ for (var col : columnMap.values())
+ sql.append(",").appendIdentifier(col.getAlias());
+
+ sql.append("\nFROM (").append(inner).append(") AS details");
+
+ sql.append("\nGROUP BY ");
+ boolean firstGroupBy = true;
+ for (ColumnInfo columnInfo : columnMap.values())
+ {
+ if (!firstGroupBy)
+ sql.append(", ");
+ firstGroupBy = false;
+ sql.appendIdentifier(columnInfo.getAlias());
+ }
+
+ sql.append("\nORDER BY ");
+ boolean firstOrderBy = true;
+ for (ColumnInfo columnInfo : columnMap.values())
+ {
+ if (!firstOrderBy)
+ sql.append(", ");
+ firstOrderBy = false;
+ sql.appendIdentifier(columnInfo.getAlias());
+ }
+
+ SqlSelector selector = new SqlSelector(tableInfo.getSchema(), sql);
+
+ try (TableResultSet resultSet = selector.getResultSet())
+ {
+ if (null != resultSet)
+ {
+ // The result set is grouped by all levels together, so at the upper levels, we have to group ourselves
+ // Build a tree of GroupedResultsMaps, one level for each grouping level
+ //
+ Map groupedResultsMap = new HashMap<>();
+ while (resultSet.next())
+ {
+ Map rowMap = resultSet.getRowMap();
+ long count = 0;
+ Object countObject = rowMap.get(sampleCountName);
+ if (asLongElseNull(countObject) instanceof Long l)
+ count = l;
+
+ Map currentGroupedResultsMap = groupedResultsMap;
+
+ for (String s : grouping)
+ {
+ if (!StringUtils.isNotBlank(s))
+ break; // Grouping may have null entries for groupBys that are not chosen to be used
+
+ GroupedValueColumnHelper columnHelper = getGroupedValueAllowedMap().get(s);
+ ColumnInfo columnInfo = columnMap.get(columnHelper.getFieldKey());
+ Object value = columnInfo.getValue(rowMap);
+ String labelValue = (null != value) ? value.toString() : null;
+ GroupedResults groupedResults = currentGroupedResultsMap.get(labelValue);
+ if (null == groupedResults)
+ {
+ groupedResults = new GroupedResults();
+ groupedResults.viewName = s;
+ groupedResults.urlFilterName = columnHelper.getUrlFilterName();
+ groupedResults.labelValue = labelValue;
+ groupedResults.childGroupedResultsMap = new HashMap<>();
+ currentGroupedResultsMap.put(labelValue, groupedResults);
+ }
+ groupedResults.count += count;
+ currentGroupedResultsMap = groupedResults.childGroupedResultsMap;
+ }
+ }
+
+ Map groupedValue;
+ if (!groupedResultsMap.isEmpty())
+ {
+ groupedValue = buildGroupedValue(groupedResultsMap, c, new ArrayList<>());
+ }
+ else
+ {
+ groupedValue = new HashMap<>(2);
+ groupedValue.put("name", grouping[0]);
+ groupedValue.put("values", new ArrayList