diff --git a/api/src/org/labkey/api/audit/SampleTimelineAuditEvent.java b/api/src/org/labkey/api/audit/SampleTimelineAuditEvent.java index b65ce37f2e7..3fd35532cca 100644 --- a/api/src/org/labkey/api/audit/SampleTimelineAuditEvent.java +++ b/api/src/org/labkey/api/audit/SampleTimelineAuditEvent.java @@ -20,6 +20,7 @@ import org.jetbrains.annotations.Nullable; import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.api.data.Container; +import org.labkey.api.exp.api.ExperimentService; import org.labkey.api.qc.DataState; import org.labkey.api.qc.SampleStatusService; import org.labkey.api.query.QueryService; @@ -224,46 +225,38 @@ public Map getAuditLogMessageElements() return elements; } - /** - * If the sample state changed, explicitly add in the Status Label value to the map so that it will render in the - * audit log timeline event even if the DataState row is later deleted. Also, remove the aliquot rollup calculated - * fields from the data. - */ @Override public void setOldRecordMap(String oldRecordMap, Container container) { - if (oldRecordMap != null) - { - Map row = new CaseInsensitiveHashMap<>(AbstractAuditTypeProvider.decodeFromDataMap(oldRecordMap)); - EXCLUDED_DETAIL_FIELDS.forEach(row::remove); - String label = getStatusLabel(row, container); - if (label != null) - { - row.put("samplestatelabel", label); - oldRecordMap = AbstractAuditTypeProvider.encodeForDataMap(row); - } - } - super.setOldRecordMap(oldRecordMap); + super.setOldRecordMap(withResolvedLabels(oldRecordMap, container)); } - /** - * If the sample state changed, explicitly add in the Status Label value to the map so that it will render in the - * audit log timeline event even if the DataState row is later deleted. Also, remove the aliquot rollup calculated - * fields from the data. - */ @Override public void setNewRecordMap(String newRecordMap, Container container) { - if (newRecordMap != null) - { - Map row = new CaseInsensitiveHashMap<>(AbstractAuditTypeProvider.decodeFromDataMap(newRecordMap)); - EXCLUDED_DETAIL_FIELDS.forEach(row::remove); - String label = getStatusLabel(row, container); - if (label != null) - row.put("samplestatelabel", label); - newRecordMap = AbstractAuditTypeProvider.encodeForDataMap(row); - } - super.setNewRecordMap(newRecordMap, container); + super.setNewRecordMap(withResolvedLabels(newRecordMap, container), container); + } + + /** + * If the sample state or color changed, explicitly add the resolved Status/Color label to the map so it renders in + * the audit log timeline event even if the DataState/DataColor row is later deleted. Also removes the aliquot rollup + * calculated fields from the data. + */ + private String withResolvedLabels(String recordMap, Container container) + { + if (recordMap == null) + return null; + + Map row = new CaseInsensitiveHashMap<>(AbstractAuditTypeProvider.decodeFromDataMap(recordMap)); + EXCLUDED_DETAIL_FIELDS.forEach(row::remove); + + String statusLabel = getStatusLabel(row, container); + row.put("samplestatelabel", statusLabel); + + String colorLabel = getColorLabel(row, container); + row.put("expmaterialcolorlabel", colorLabel); + + return AbstractAuditTypeProvider.encodeForDataMap(row); } private String getStatusLabel(Map row, Container container) @@ -276,4 +269,12 @@ private String getStatusLabel(Map row, Container container) } return null; } + + private String getColorLabel(Map row, Container container) + { + String value = row.get(ExpMaterialColor.name()); + if (!StringUtils.isBlank(value)) + return ExperimentService.get().getDataColorLabel(container, Long.parseLong(value)); + return null; + } } diff --git a/api/src/org/labkey/api/data/ContainerManager.java b/api/src/org/labkey/api/data/ContainerManager.java index 349f90b49ce..4624420b9f0 100644 --- a/api/src/org/labkey/api/data/ContainerManager.java +++ b/api/src/org/labkey/api/data/ContainerManager.java @@ -1953,7 +1953,10 @@ private static boolean delete(final Container c, User user, @Nullable String com ExperimentService experimentService = ExperimentService.get(); if (experimentService != null) + { experimentService.removeContainerDataTypeExclusions(c.getId()); + experimentService.removeContainerDataColorExclusions(c.getId()); + } // Issue 17015: After we've committed the transaction, be sure that we remove this container from the cache tx.addCommitTask(() -> diff --git a/api/src/org/labkey/api/exp/api/ExpMaterial.java b/api/src/org/labkey/api/exp/api/ExpMaterial.java index 26ac0c41810..76a61354fc7 100644 --- a/api/src/org/labkey/api/exp/api/ExpMaterial.java +++ b/api/src/org/labkey/api/exp/api/ExpMaterial.java @@ -85,6 +85,10 @@ public interface ExpMaterial extends ExpRunItem void setSampleStateId(Long stateId); + Long getSampleColorId(); + + void setSampleColorId(Long colorId); + Date getMaterialExpDate(); ActionURL detailsURL(Container container, boolean checkForOverride); diff --git a/api/src/org/labkey/api/exp/api/ExperimentService.java b/api/src/org/labkey/api/exp/api/ExperimentService.java index df052bbe503..179b7b9b93a 100644 --- a/api/src/org/labkey/api/exp/api/ExperimentService.java +++ b/api/src/org/labkey/api/exp/api/ExperimentService.java @@ -134,6 +134,8 @@ public interface ExperimentService extends ExperimentRunTypeSource String EXPERIMENTAL_FEATURE_ALLOW_ROW_ID_MERGE = "org.labkey.experiment.api.SampleTypeUpdateServiceDI#ALLOW_ROW_ID_SAMPLE_MERGE"; + String EXPERIMENTAL_SAMPLE_COLORS = "org.labkey.api.exp.api.ExperimentService#SAMPLE_COLORS"; + int SIMPLE_PROTOCOL_FIRST_STEP_SEQUENCE = 1; int SIMPLE_PROTOCOL_CORE_STEP_SEQUENCE = 10; int SIMPLE_PROTOCOL_EXTRA_STEP_SEQUENCE = 15; @@ -710,6 +712,8 @@ static void validateParentAlias(Map aliasMap, Set reserv SampleStatusTable createSampleStatusTable(ExpSchema expSchema, ContainerFilter cf); + TableInfo createDataColorTable(ExpSchema expSchema, ContainerFilter cf); + ExpUnreferencedSampleFilesTable createUnreferencedSampleFilesTable(ExpSchema expSchema, ContainerFilter cf); FilteredTable createFieldsTable(ExpSchema expSchema, ContainerFilter cf); @@ -1139,6 +1143,25 @@ List getExpProtocolsWithParameterValue( String getDisabledDataTypeAuditMsg(DataTypeForExclusion type, List ids, boolean isUpdate); + @NotNull Set getDataTypeExcludedColors(DataTypeForExclusion dataType, long dataTypeId); + + /** The data type rowIds (e.g. sample type rowIds) that currently exclude the given color. Inverse of {@link #getDataTypeExcludedColors}. */ + @NotNull Set getDataTypesExcludingColor(DataTypeForExclusion dataType, long colorRowId); + + @NotNull Set getActiveDataTypeColors(@NotNull Container container, DataTypeForExclusion dataType, long dataTypeId); + + @Nullable String getDataColorLabel(@NotNull Container container, long colorRowId); + + boolean ensureDataColorExclusions(long dataTypeId, DataTypeForExclusion dataType, @Nullable Collection disabledColorRowIds, @NotNull Container container, User user); + + @NotNull Set updateColorDataTypeExclusions(long colorRowId, DataTypeForExclusion dataType, @Nullable Collection newlyDisabledDataTypeIds, @Nullable Collection newlyEnabledDataTypeIds, @NotNull Container container, User user); + + void removeDataColorExclusionsForColor(long colorRowId); + + void removeDataColorExclusionsForDataType(long dataTypeId, DataTypeForExclusion dataType); + + void removeContainerDataColorExclusions(String containerId); + void registerRunInputsViewProvider(QueryViewProvider provider); void registerRunOutputsViewProvider(QueryViewProvider providers); diff --git a/api/src/org/labkey/api/exp/api/SampleTypeDomainKind.java b/api/src/org/labkey/api/exp/api/SampleTypeDomainKind.java index f6e3124ed11..2724d303d99 100644 --- a/api/src/org/labkey/api/exp/api/SampleTypeDomainKind.java +++ b/api/src/org/labkey/api/exp/api/SampleTypeDomainKind.java @@ -535,6 +535,8 @@ public Domain createDomain(GWTDomain domain, @Nullable Sa Map> aliases = null; List excludedContainerIds = null; List excludedDashboardContainerIds = null; + List excludedSampleColorIds = null; + if (arguments != null) { @@ -556,12 +558,13 @@ public Domain createDomain(GWTDomain domain, @Nullable Sa aliases = arguments.getImportAliases(); excludedContainerIds = arguments.getExcludedContainerIds(); excludedDashboardContainerIds = arguments.getExcludedDashboardContainerIds(); + excludedSampleColorIds = arguments.getDisabledSampleColorRowIds(); } ExpSampleType st; try { st = SampleTypeService.get().createSampleType(container, user, name, description, properties, indices, idCol1, idCol2, idCol3, parentCol, nameExpression, aliquotNameExpression, - templateInfo, aliases, labelColor, metricUnit, autoLinkTargetContainer, autoLinkCategory, category, domain.getDisabledSystemFields(), excludedContainerIds, excludedDashboardContainerIds, arguments != null ? arguments.getAuditRecordMap() : null); + templateInfo, aliases, labelColor, metricUnit, autoLinkTargetContainer, autoLinkCategory, category, domain.getDisabledSystemFields(), excludedContainerIds, excludedDashboardContainerIds, excludedSampleColorIds, arguments != null ? arguments.getAuditRecordMap() : null); } catch (ExperimentException e) { diff --git a/api/src/org/labkey/api/exp/api/SampleTypeDomainKindProperties.java b/api/src/org/labkey/api/exp/api/SampleTypeDomainKindProperties.java index 5ebe037728e..d80ab1c8a06 100644 --- a/api/src/org/labkey/api/exp/api/SampleTypeDomainKindProperties.java +++ b/api/src/org/labkey/api/exp/api/SampleTypeDomainKindProperties.java @@ -87,6 +87,7 @@ public SampleTypeDomainKindProperties(ExpSampleType st) private String category; private List excludedContainerIds; private List excludedDashboardContainerIds; + private List disabledSampleColorRowIds; //Ignored on import/save, use Domain.name & Domain.description instead private String name; @@ -285,6 +286,16 @@ public List getExcludedDashboardContainerIds() return excludedDashboardContainerIds; } + public List getDisabledSampleColorRowIds() + { + return disabledSampleColorRowIds; + } + + public void setDisabledSampleColorRowIds(List disabledSampleColorRowIds) + { + this.disabledSampleColorRowIds = disabledSampleColorRowIds; + } + public void setExcludedDashboardContainerIds(List excludedDashboardContainerIds) { this.excludedDashboardContainerIds = excludedDashboardContainerIds; diff --git a/api/src/org/labkey/api/exp/api/SampleTypeService.java b/api/src/org/labkey/api/exp/api/SampleTypeService.java index e4a0f7c992f..83f1190cddc 100644 --- a/api/src/org/labkey/api/exp/api/SampleTypeService.java +++ b/api/src/org/labkey/api/exp/api/SampleTypeService.java @@ -123,6 +123,8 @@ static void setInstance(SampleTypeService impl) Map getSampleTypesForRoles(Container container, ContainerFilter filter, ExpProtocol.ApplicationType type); + void auditSampleColorExclusion(Container container, long sampleTypeRowId, @Nullable String auditUserComment, User user); + /** * Create a new SampleType with the provided properties. * If a 'Name' property exists in the list, it will be used as the 'id' property of the SampleType. @@ -150,7 +152,7 @@ ExpSampleType createSampleType(Container container, User user, String name, Stri ExpSampleType createSampleType(Container c, User u, String name, String description, List properties, List indices, int idCol1, int idCol2, int idCol3, int parentCol, String nameExpression, String aliquotNameExpression, @Nullable TemplateInfo templateInfo, @Nullable Map> importAliases, @Nullable String labelColor, @Nullable String metricUnit, @Nullable Container autoLinkTargetContainer, @Nullable String autoLinkCategory, @Nullable String category, @Nullable List disabledSystemField, - @Nullable List excludedContainerIds, @Nullable List excludedDashboardContainerIds, @Nullable Map changeDetails) + @Nullable List excludedContainerIds, @Nullable List excludedDashboardContainerIds, @Nullable List excludedSampleColorIds, @Nullable Map changeDetails) throws ExperimentException; @NotNull diff --git a/api/src/org/labkey/api/exp/query/ExpMaterialTable.java b/api/src/org/labkey/api/exp/query/ExpMaterialTable.java index 9d00482ab25..66f1e1b3d68 100644 --- a/api/src/org/labkey/api/exp/query/ExpMaterialTable.java +++ b/api/src/org/labkey/api/exp/query/ExpMaterialTable.java @@ -66,6 +66,7 @@ enum Column RunId, // database table only RunApplication, RunApplicationOutput, + ExpMaterialColor, SampleSet, SampleState, SourceApplicationId, // database table only diff --git a/api/src/org/labkey/api/exp/query/ExpSchema.java b/api/src/org/labkey/api/exp/query/ExpSchema.java index 65b4a4c8805..e88cc46fe4d 100644 --- a/api/src/org/labkey/api/exp/query/ExpSchema.java +++ b/api/src/org/labkey/api/exp/query/ExpSchema.java @@ -245,6 +245,14 @@ public TableInfo createTable(ExpSchema expSchema, String queryName, ContainerFil return ExperimentService.get().createSampleStatusTable(expSchema, cf); } }, + DataColors + { + @Override + public TableInfo createTable(ExpSchema expSchema, String queryName, ContainerFilter cf) + { + return ExperimentService.get().createDataColorTable(expSchema, cf); + } + }, Fields { @Override diff --git a/api/src/org/labkey/api/query/AbstractQueryUpdateService.java b/api/src/org/labkey/api/query/AbstractQueryUpdateService.java index eef0416412b..31eaf3efbc3 100644 --- a/api/src/org/labkey/api/query/AbstractQueryUpdateService.java +++ b/api/src/org/labkey/api/query/AbstractQueryUpdateService.java @@ -596,7 +596,8 @@ protected DataIteratorBuilder _toDataIteratorBuilder(String debugName, List> _insertRowsUsingInsertRow(User user, Container container, List> rows, BatchValidationException errors, Map extraScriptContext) + protected List> _insertRowsUsingInsertRow(User user, Container container, List> rows, BatchValidationException errors, + @Nullable Map configParameters, Map extraScriptContext) throws DuplicateKeyException, BatchValidationException, QueryUpdateServiceException, SQLException { if (!hasInsertRowsPermission(user)) @@ -664,7 +665,7 @@ else if (SqlDialect.isTransactionException(sqlx) && errors.hasErrors()) if (hasTableScript) getQueryTable().fireBatchTrigger(container, user, TableInfo.TriggerType.INSERT, null, false, errors, extraScriptContext); - addAuditEvent(user, container, QueryService.AuditAction.INSERT, null, result, null, providedValues); + addAuditEvent(user, container, QueryService.AuditAction.INSERT, configParameters, result, null, providedValues); return result; } @@ -715,7 +716,7 @@ public List> insertRows(User user, Container container, List { try { - List> ret = _insertRowsUsingInsertRow(user, container, rows, errors, extraScriptContext); + List> ret = _insertRowsUsingInsertRow(user, container, rows, errors, configParameters, extraScriptContext); afterInsertUpdate(null==ret?0:ret.size(), errors); if (errors.hasErrors()) return null; diff --git a/experiment/resources/schemas/dbscripts/postgresql/exp-26.007-26.008.sql b/experiment/resources/schemas/dbscripts/postgresql/exp-26.007-26.008.sql new file mode 100644 index 00000000000..c1de3664b4e --- /dev/null +++ b/experiment/resources/schemas/dbscripts/postgresql/exp-26.007-26.008.sql @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ + +CREATE TABLE exp.DataColors +( + RowId SERIAL NOT NULL, + Container ENTITYID NOT NULL, + Label VARCHAR(64) NOT NULL, + Color VARCHAR(7) NOT NULL, + Archived BOOLEAN NOT NULL DEFAULT FALSE, + Created TIMESTAMP, + CreatedBy INT, + Modified TIMESTAMP, + ModifiedBy INT, + + CONSTRAINT PK_DataColors PRIMARY KEY (RowId), + CONSTRAINT FK_DataColors_Container FOREIGN KEY (Container) REFERENCES core.Containers (EntityId) +); + +CREATE UNIQUE INDEX UQ_DataColors_Label ON exp.DataColors (Container, LOWER(Label)); + +ALTER TABLE exp.Material ADD COLUMN ExpMaterialColor INT; +ALTER TABLE exp.Material ADD CONSTRAINT FK_Material_ExpMaterialColor FOREIGN KEY (ExpMaterialColor) REFERENCES exp.DataColors (RowId); +CREATE INDEX IX_Material_ExpMaterialColor ON exp.Material (ExpMaterialColor) WHERE ExpMaterialColor IS NOT NULL; + +CREATE TABLE exp.DataTypeColorExclusion +( + RowId SERIAL NOT NULL, + Container ENTITYID NOT NULL, + DataTypeRowId INT NOT NULL, + DataType VARCHAR(20) NOT NULL, + ColorRowId INT NOT NULL, + Created TIMESTAMP, + CreatedBy INT, + Modified TIMESTAMP, + ModifiedBy INT, + + CONSTRAINT PK_DataTypeColorExclusion PRIMARY KEY (RowId), + CONSTRAINT UQ_DataTypeColorExclusion UNIQUE (DataTypeRowId, DataType, ColorRowId), + CONSTRAINT FK_DataTypeColorExclusion_Container FOREIGN KEY (Container) REFERENCES core.Containers (EntityId), + CONSTRAINT FK_DataTypeColorExclusion_Color FOREIGN KEY (ColorRowId) REFERENCES exp.DataColors (RowId) +); diff --git a/experiment/resources/schemas/exp.xml b/experiment/resources/schemas/exp.xml index 0aa6faa310e..e0ca8b900b3 100644 --- a/experiment/resources/schemas/exp.xml +++ b/experiment/resources/schemas/exp.xml @@ -295,6 +295,9 @@ Represents the status of the sample + + The color assigned to this individual sample + Amount @@ -1047,6 +1050,7 @@ + @@ -1255,4 +1259,30 @@ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
diff --git a/experiment/src/org/labkey/experiment/ExpDataIterators.java b/experiment/src/org/labkey/experiment/ExpDataIterators.java index 841ce6bf8d2..d63d8503123 100644 --- a/experiment/src/org/labkey/experiment/ExpDataIterators.java +++ b/experiment/src/org/labkey/experiment/ExpDataIterators.java @@ -119,6 +119,8 @@ import org.labkey.experiment.api.ExpMaterialTableImpl; import org.labkey.experiment.api.ExpRunItemTableImpl; import org.labkey.experiment.api.ExpSampleTypeImpl; +import org.labkey.experiment.api.DataColor; +import org.labkey.experiment.api.DataColorManager; import org.labkey.experiment.api.ExperimentServiceImpl; import org.labkey.experiment.api.SampleTypeServiceImpl; import org.labkey.experiment.api.SampleTypeUpdateServiceDI; @@ -2446,6 +2448,9 @@ public DataIterator getDataIterator(DataIteratorContext context) // Add RootMaterialRowId if it does not exist dib = getRootMaterialRowIdBuilder(dib); + if (_dataTypeObject != null && !DataColorManager.getInstance().getAllProjectColors(_container).isEmpty()) + dib = new SampleColorCheckIteratorBuilder(dib, _container, _dataTypeObject.getRowId()); + if (isMergeOrUpdate) { dib = new SampleStatusCheckIteratorBuilder(dib, _container); @@ -3348,4 +3353,78 @@ public boolean next() throws BatchValidationException return true; } } + + public static class SampleColorCheckIteratorBuilder implements DataIteratorBuilder + { + private final DataIteratorBuilder _in; + private final Container _container; + private final long _sampleTypeRowId; + + public SampleColorCheckIteratorBuilder(@NotNull DataIteratorBuilder in, Container container, long sampleTypeRowId) + { + _in = in; + _container = container; + _sampleTypeRowId = sampleTypeRowId; + } + + @Override + public DataIterator getDataIterator(DataIteratorContext context) + { + DataIterator pre = _in.getDataIterator(context); + if (pre == null) + return null; // can happen if context has errors + + // Colors excluded (disabled) for this sample type; if none, there's nothing to reject. + Set excludedColors = ExperimentService.get().getDataTypeExcludedColors(ExperimentService.DataTypeForExclusion.SampleType, _sampleTypeRowId); + if (excludedColors.isEmpty()) + return pre; + + Integer colorCol = DataIteratorUtil.createColumnNameMap(pre).get(ExpMaterialColor.name()); + if (colorCol == null) + return pre; + + Map byRowId = DataColorManager.getInstance().getAllProjectColors(_container).stream() + .collect(Collectors.toMap(c -> (long) c.getRowId(), c -> c, (a, b) -> a)); + Map excludedColorLabels = new HashMap<>(); + for (Long rowId : excludedColors) + { + DataColor color = byRowId.get(rowId); + excludedColorLabels.put(rowId, color != null ? color.getLabel() : "rowId " + rowId); + } + + return LoggingDataIterator.wrap(new SampleColorCheckDataIterator(pre, context, excludedColorLabels, colorCol)); + } + } + + private static class SampleColorCheckDataIterator extends WrapperDataIterator + { + private final DataIteratorContext _context; + private final Map _excludedColorLabels; + private final int _colorCol; + + protected SampleColorCheckDataIterator(DataIterator di, DataIteratorContext context, Map excludedColorLabels, int colorCol) + { + super(di); + _context = context; + _excludedColorLabels = excludedColorLabels; + _colorCol = colorCol; + } + + @Override + public boolean next() throws BatchValidationException + { + boolean hasNext = super.next(); + if (!hasNext) + return false; + + if (_context.getErrors().hasErrors()) + return true; + + Long colorRowId = asLong(get(_colorCol)); + if (colorRowId != null && _excludedColorLabels.containsKey(colorRowId)) + _context.getErrors().addRowError(new ValidationException("The color '" + _excludedColorLabels.get(colorRowId) + "' is not valid for this sample type.")); + + return true; + } + } } diff --git a/experiment/src/org/labkey/experiment/ExperimentModule.java b/experiment/src/org/labkey/experiment/ExperimentModule.java index b81ced637c0..0226497abab 100644 --- a/experiment/src/org/labkey/experiment/ExperimentModule.java +++ b/experiment/src/org/labkey/experiment/ExperimentModule.java @@ -118,6 +118,8 @@ import org.labkey.api.webdav.WebdavService; import org.labkey.api.writer.ContainerUser; import org.labkey.experiment.api.DataClassDomainKind; +import org.labkey.experiment.api.DataColorManager; +import org.labkey.experiment.api.DataColorTable; import org.labkey.experiment.api.EdgeDiagnosticsTestCase; import org.labkey.experiment.api.ExpDataClassImpl; import org.labkey.experiment.api.ExpDataClassTableImpl; @@ -209,7 +211,7 @@ public String getName() @Override public Double getSchemaVersion() { - return 26.007; + return 26.008; } @Nullable @@ -295,6 +297,8 @@ protected void init() "Support for querying lineage of experiment objects", false, true); OptionalFeatureService.get().addExperimentalFeatureFlag(ExperimentService.EXPERIMENTAL_FEATURE_ALLOW_ROW_ID_MERGE, "Allow RowId to be accepted when merging samples or data class data", "If the incoming data includes a RowId column we will allow the column but ignore it's values.", false, true); + OptionalFeatureService.get().addExperimentalFeatureFlag(ExperimentService.EXPERIMENTAL_SAMPLE_COLORS, "Sample Colors", + "Enable assigning custom colors to individual samples, with an app-level color palette configurable per sample type.", false, true); RoleManager.registerPermission(new DesignVocabularyPermission(), true); RoleManager.registerRole(new SampleTypeDesignerRole()); @@ -553,6 +557,8 @@ protected void startupAfterSpringConfig(ModuleContext moduleContext) AuditLogService.get().registerAuditType(new SampleTypeAuditProvider()); AuditLogService.get().registerAuditType(new SampleTimelineAuditProvider()); + DataColorManager.getInstance().registerHandler(SampleTypeServiceImpl.get()); + FileContentService fileContentService = FileContentService.get(); if (null != fileContentService) { @@ -793,6 +799,27 @@ SELECT COUNT(DISTINCT DD.DomainURI) FROM results.put("sampleTypesWithVolumeTypeUnit", new SqlSelector(schema, "SELECT COUNT(*) from exp.materialSource WHERE category IS NULL AND metricunit IN ('L', 'mL', 'uL')").getObject(Long.class)); results.put("sampleTypesWithCountTypeUnit", new SqlSelector(schema, "SELECT COUNT(*) from exp.materialSource WHERE category IS NULL AND metricunit = ?", "unit").getObject(Long.class)); + Map sampleColorMetrics = new HashMap<>(); + Long colorCount = new SqlSelector(schema, "SELECT COUNT(*) FROM exp.datacolors").getObject(Long.class); + sampleColorMetrics.put("colorCount", colorCount); + if (colorCount > 0) + { + Long archivedColorCount = new SqlSelector(schema, new SQLFragment("SELECT COUNT(*) FROM exp.datacolors WHERE archived = " + schema.getSqlDialect().getBooleanTRUE())).getObject(Long.class); + sampleColorMetrics.put("archivedColorCount", archivedColorCount); + sampleColorMetrics.put("samplesWithColorCount", new SqlSelector(schema, "SELECT COUNT(*) FROM exp.material WHERE expmaterialcolor IS NOT NULL").getObject(Long.class)); + sampleColorMetrics.put("samplesWithArchivedColorCount", new SqlSelector(schema, new SQLFragment("SELECT COUNT(*) FROM exp.material m JOIN exp.datacolors dc ON m.expmaterialcolor = dc.rowid WHERE dc.archived = " + schema.getSqlDialect().getBooleanTRUE())).getObject(Long.class)); + + sampleColorMetrics.put("sampleTypesWithColorsEnabledCount", new SqlSelector(schema, new SQLFragment( + "SELECT COUNT(*) FROM exp.materialsource ms WHERE EXISTS (" + + "SELECT 1 FROM exp.datacolors dc WHERE dc.container = ms.container AND dc.archived = " + schema.getSqlDialect().getBooleanFALSE() + " AND NOT EXISTS (" + + "SELECT 1 FROM exp.datatypecolorexclusion e WHERE e.datatype = ? AND e.datatyperowid = ms.rowid AND e.colorrowid = dc.rowid))") + .add(ExperimentService.DataTypeForExclusion.SampleType.name())).getObject(Long.class)); + sampleColorMetrics.put("sampleTypesWithColorsDisabledCount", new SqlSelector(schema, new SQLFragment( + "SELECT COUNT(DISTINCT datatyperowid) FROM exp.datatypecolorexclusion WHERE datatype = ?") + .add(ExperimentService.DataTypeForExclusion.SampleType.name())).getObject(Long.class)); + } + results.put("sampleColors", sampleColorMetrics); + results.put("duplicateSampleMaterialNameCount", new SqlSelector(schema, "SELECT COUNT(*) as duplicateCount FROM " + "(SELECT name, cpastype FROM exp.material WHERE cpastype <> 'Material' GROUP BY name, cpastype HAVING COUNT(*) > 1) d").getObject(Long.class)); results.put("duplicateSpecimenMaterialNameCount", new SqlSelector(schema, "SELECT COUNT(*) as duplicateCount FROM " + @@ -1129,6 +1156,7 @@ public Collection getSummary(Container c) return Set.of( EdgeDiagnosticsTestCase.class, ExperimentController.ContainerScopingTestCase.class, + DataColorTable.TestCase.class, DomainImpl.TestCase.class, DomainPropertyImpl.TestCase.class, ExpDataTableImpl.TestCase.class, @@ -1197,6 +1225,7 @@ public JSONObject getPageContextJson(ContainerUser context) { JSONObject json = super.getPageContextJson(context); json.put(SAMPLE_FILES_TABLE, OptionalFeatureService.get().isFeatureEnabled(SAMPLE_FILES_TABLE)); + json.put("SampleColors", OptionalFeatureService.get().isFeatureEnabled(ExperimentService.EXPERIMENTAL_SAMPLE_COLORS)); return json; } } diff --git a/experiment/src/org/labkey/experiment/api/DataColor.java b/experiment/src/org/labkey/experiment/api/DataColor.java new file mode 100644 index 00000000000..02edbd45f17 --- /dev/null +++ b/experiment/src/org/labkey/experiment/api/DataColor.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.labkey.experiment.api; + +import org.labkey.api.data.Container; +import org.labkey.api.data.Entity; + +public class DataColor extends Entity +{ + private int _rowId; + private String _label; + private String _color; // hex value + private boolean _archived; + + public int getRowId() + { + return _rowId; + } + + public void setRowId(int rowId) + { + _rowId = rowId; + } + + public String getLabel() + { + return _label; + } + + public void setLabel(String label) + { + _label = label; + } + + public String getColor() + { + return _color; + } + + public void setColor(String color) + { + _color = color; + } + + public boolean isArchived() + { + return _archived; + } + + public void setArchived(boolean archived) + { + _archived = archived; + } + + public Container getContainer() + { + return lookupContainer(); + } +} diff --git a/experiment/src/org/labkey/experiment/api/DataColorManager.java b/experiment/src/org/labkey/experiment/api/DataColorManager.java new file mode 100644 index 00000000000..64abd476a97 --- /dev/null +++ b/experiment/src/org/labkey/experiment/api/DataColorManager.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.labkey.experiment.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.cache.Cache; +import org.labkey.api.cache.CacheManager; +import org.labkey.api.collections.LongHashMap; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Sort; +import org.labkey.api.data.TableSelector; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class DataColorManager +{ + /** Maximum number of data colors (active + archived) allowed per container. */ + public static final int MAX_DATA_COLORS = 200; + + private static final DataColorManager _instance = new DataColorManager(); + private static final Cache CACHE = CacheManager.getBlockingCache( + CacheManager.UNLIMITED, CacheManager.DAY, "Data colors", (c, argument) -> new DataColorCollections(c)); + private static final Map _handlers = new HashMap<>(); + + private static class DataColorCollections + { + private final List _colors; + private final Map _byRowId; + + private DataColorCollections(Container c) + { + List colors = new ArrayList<>(); + Map byRowId = new LongHashMap<>(); + + new TableSelector(ExperimentServiceImpl.get().getTinfoDataColors(), SimpleFilter.createContainerFilter(c), new Sort("Label")) + .forEach(DataColor.class, color -> { + colors.add(color); + byRowId.put((long) color.getRowId(), color); + }); + + _colors = Collections.unmodifiableList(colors); + _byRowId = Collections.unmodifiableMap(byRowId); + } + } + + private DataColorManager() {} + + public static DataColorManager getInstance() + { + return _instance; + } + + public interface DataColorHandler + { + String getHandlerType(); + + boolean isColorInUse(long colorRowId); + } + + public void registerHandler(DataColorHandler handler) + { + String type = handler.getHandlerType(); + if (_handlers.containsKey(type)) + throw new IllegalArgumentException("DataColorHandler '" + type + "' is already registered."); + _handlers.put(type, handler); + } + + public boolean isInUse(long colorRowId) + { + for (DataColorHandler handler : _handlers.values()) + { + if (handler.isColorInUse(colorRowId)) + return true; + } + return false; + } + + @NotNull + public List getColors(Container container) + { + return CACHE.get(container)._colors; + } + + @NotNull + public List getActiveColors(Container container) + { + return getColors(container).stream().filter(c -> !c.isArchived()).toList(); + } + + @NotNull + public List getAllProjectColors(Container container) + { + List colors = new ArrayList<>(getColors(container)); + if (!container.isProject() && container.getProject() != null) + colors.addAll(getColors(container.getProject())); + if (!container.equals(ContainerManager.getSharedContainer())) + colors.addAll(getColors(ContainerManager.getSharedContainer())); + return colors; + } + + @NotNull + public List getActiveProjectColors(Container container) + { + return getAllProjectColors(container).stream().filter(c -> !c.isArchived()).toList(); + } + + @Nullable + public DataColor getColorForRowId(Container container, Long rowId) + { + return rowId == null ? null : CACHE.get(container)._byRowId.get(rowId); + } + + public void clearCache(Container c) + { + CACHE.remove(c); + } +} diff --git a/experiment/src/org/labkey/experiment/api/DataColorTable.java b/experiment/src/org/labkey/experiment/api/DataColorTable.java new file mode 100644 index 00000000000..fc2f9bef425 --- /dev/null +++ b/experiment/src/org/labkey/experiment/api/DataColorTable.java @@ -0,0 +1,602 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.labkey.experiment.api; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.AuditTypeEvent; +import org.labkey.api.collections.CaseInsensitiveHashMap; +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.DbScope; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Sort; +import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.exp.api.ExpMaterial; +import org.labkey.api.exp.api.ExpSampleType; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.api.ExperimentService.DataTypeForExclusion; +import org.labkey.api.exp.api.SampleTypeService; +import org.labkey.api.exp.query.ExpSchema; +import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.DefaultQueryUpdateService; +import org.labkey.api.query.DuplicateKeyException; +import org.labkey.api.query.FilteredTable; +import org.labkey.api.query.InvalidKeyException; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.QueryUpdateServiceException; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.query.UserSchema; +import org.labkey.api.query.ValidationException; +import org.labkey.api.security.User; +import org.labkey.api.security.UserPrincipal; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.util.JunitUtil; +import org.labkey.api.util.TestContext; +import org.labkey.experiment.SampleTypeAuditProvider; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.labkey.api.util.IntegerUtils.asLong; + +public class DataColorTable extends FilteredTable +{ + public DataColorTable(ExpSchema schema, ContainerFilter cf) + { + super(ExperimentServiceImpl.get().getTinfoDataColors(), schema, cf); + setName(ExpSchema.TableType.DataColors.name()); + for (ColumnInfo baseColumn : _rootTable.getColumns()) + { + String name = baseColumn.getName(); + if ("Container".equalsIgnoreCase(name)) + continue; + var col = addWrapColumn(baseColumn); + if ("RowId".equalsIgnoreCase(name)) + col.setHidden(true); + } + } + + @Override + public boolean hasPermission(@NotNull UserPrincipal user, @NotNull Class perm) + { + return getContainer().hasPermission(user, perm == ReadPermission.class ? perm : AdminPermission.class); + } + + @Override + public @Nullable QueryUpdateService getUpdateService() + { + return new DataColorUpdateService(this); + } + + private static class DataColorUpdateService extends DefaultQueryUpdateService + { + private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$"); + private static final int MAX_LABEL_LENGTH = 64; // matches exp.DataColors.Label VARCHAR(64) + + public DataColorUpdateService(FilteredTable table) + { + super(table, table.getRealTable()); + } + + private boolean isBlankLabel(Map row, boolean allowMissing) + { + if (allowMissing && !row.containsKey("label")) + return false; + return StringUtils.isBlank((String) row.get("label")); + } + + private boolean isInvalidColor(Map row, boolean allowMissing) + { + if (allowMissing && !row.containsKey("color")) + return false; + String color = (String) row.get("color"); + return color == null || !COLOR_PATTERN.matcher(color).matches(); + } + + private boolean isDuplicateLabel(String label, Container container, int currentRowId) + { + for (DataColor color : DataColorManager.getInstance().getColors(container)) + { + if (color.getRowId() != currentRowId && color.getLabel().equalsIgnoreCase(label)) + return true; + } + return false; + } + + private long getColorCount(Container container) + { + SimpleFilter filter = SimpleFilter.createContainerFilter(container); + return new TableSelector(ExperimentServiceImpl.get().getTinfoDataColors(), filter, null).getRowCount(); + } + + // Shared insert/update validation. On insert the label and color are required and a duplicate label is always + // rejected; on update only the provided fields are validated, and the duplicate check runs (excluding the row + // itself) only when the label is being changed. The 200-color cap is insert-only and stays in insertRow. + private void validateColor(Map row, Container container, boolean isInsert) throws QueryUpdateServiceException + { + boolean allowMissing = !isInsert; + if (isBlankLabel(row, allowMissing)) + throw new QueryUpdateServiceException("Label cannot be blank."); + String label = (String) row.get("label"); + if (label != null && label.length() > MAX_LABEL_LENGTH) + throw new QueryUpdateServiceException("Label may not exceed " + MAX_LABEL_LENGTH + " characters."); + if (isInvalidColor(row, allowMissing)) + throw new QueryUpdateServiceException("Color must be a 6-digit hex value (e.g. #1a2b3c)."); + if (isInsert || row.containsKey("label")) + { + int currentRowId = isInsert ? -1 : (int) row.get("rowId"); + if (isDuplicateLabel(String.valueOf(row.get("label")), container, currentRowId)) + throw new QueryUpdateServiceException("Label '" + row.get("label") + "' is already in use."); + } + } + + @Override + protected Map insertRow(User user, Container container, Map row) throws DuplicateKeyException, ValidationException, QueryUpdateServiceException, SQLException + { + validateColor(row, container, true); + if (getColorCount(container) >= DataColorManager.MAX_DATA_COLORS) + throw new QueryUpdateServiceException("Cannot add more than " + DataColorManager.MAX_DATA_COLORS + " colors."); + + Map inserted; + try (DbScope.Transaction tx = ExperimentServiceImpl.getExpSchema().getScope().ensureTransaction()) + { + inserted = super.insertRow(user, container, row); + tx.addCommitTask(() -> DataColorManager.getInstance().clearCache(container), DbScope.CommitTaskOption.IMMEDIATE, DbScope.CommitTaskOption.POSTCOMMIT); + tx.commit(); + } + return inserted; + } + + @Override + protected Map updateRow(User user, Container container, Map row, @NotNull Map oldRow, boolean allowOwner, boolean retainCreation) throws InvalidKeyException, ValidationException, QueryUpdateServiceException, SQLException + { + validateColor(row, container, false); + + Map updated; + try (DbScope.Transaction tx = ExperimentServiceImpl.getExpSchema().getScope().ensureTransaction()) + { + updated = super.updateRow(user, container, row, oldRow, allowOwner, retainCreation); + tx.addCommitTask(() -> DataColorManager.getInstance().clearCache(container), DbScope.CommitTaskOption.IMMEDIATE, DbScope.CommitTaskOption.POSTCOMMIT); + tx.commit(); + } + return updated; + } + + @Override + protected Map deleteRow(User user, Container container, Map oldRowMap) throws InvalidKeyException, QueryUpdateServiceException, SQLException + { + long rowId = asLong(oldRowMap.get("rowId")); + if (DataColorManager.getInstance().isInUse(rowId)) + throw new QueryUpdateServiceException("This color can't be deleted because it is in use."); + + Map deleted; + try (DbScope.Transaction tx = ExperimentServiceImpl.getExpSchema().getScope().ensureTransaction()) + { + // Drop the per-data-type exclusion rows that reference this color BEFORE deleting the color itself: + // exp.DataTypeColorExclusion.ColorRowId has a (RESTRICT) FK to exp.DataColors, so the color row can't + // be removed while exclusion rows still point at it. + ExperimentServiceImpl.get().removeDataColorExclusionsForColor(rowId); + deleted = super.deleteRow(user, container, oldRowMap); + tx.addCommitTask(() -> DataColorManager.getInstance().clearCache(container), DbScope.CommitTaskOption.IMMEDIATE, DbScope.CommitTaskOption.POSTCOMMIT); + tx.commit(); + } + return deleted; + } + } + + /** + * Integration tests for the Custom Sample Colors server logic: the {@code exp.DataColors} QueryUpdateService + * validation (above), the {@code exp.DataTypeColorExclusion} service methods, create-path exclusion persistence + + * audit, and orphan cleanup on color / sample-type / container deletion. Registered in + * {@code ExperimentModule.getIntegrationTests()}. + */ + @SuppressWarnings("JUnitMalformedDeclaration") + public static class TestCase extends Assert + { + private Container _c; + private User _user; + + @Before + public void setUp() + { + JunitUtil.deleteTestContainer(); + _c = JunitUtil.getTestContainer(); + _user = TestContext.get().getUser(); + } + + @After + public void tearDown() + { + JunitUtil.deleteTestContainer(); + } + + // ---- helpers -------------------------------------------------------- + + private QueryUpdateService colorQus(Container c) + { + UserSchema schema = QueryService.get().getUserSchema(_user, c, ExpSchema.SCHEMA_NAME); + return schema.getTable(ExpSchema.TableType.DataColors.name()).getUpdateService(); + } + + private static Map colorRow(String label, String color, boolean archived) + { + Map row = new CaseInsensitiveHashMap<>(); + row.put("Label", label); + row.put("Color", color); + row.put("Archived", archived); + return row; + } + + private long insertColor(Container c, String label, String color, boolean archived) throws Exception + { + BatchValidationException errors = new BatchValidationException(); + List> inserted = colorQus(c).insertRows(_user, c, List.of(colorRow(label, color, archived)), errors, null, null); + if (errors.hasErrors()) + throw errors; + return ((Number) new CaseInsensitiveHashMap<>(inserted.get(0)).get("RowId")).longValue(); + } + + private long insertColor(String label, String color, boolean archived) throws Exception + { + return insertColor(_c, label, color, archived); + } + + private void deleteColor(long rowId) throws Exception + { + colorQus(_c).deleteRows(_user, _c, List.of(CaseInsensitiveHashMap.of("RowId", rowId)), null, null); + } + + private void assertColorInsertFails(Map row, String expectedFragment) + { + try + { + BatchValidationException errors = new BatchValidationException(); + colorQus(_c).insertRows(_user, _c, List.of(row), errors, null, null); + if (errors.hasErrors()) + throw errors; + fail("Expected color insert to be rejected: " + row); + } + catch (Exception e) + { + String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); + assertTrue("Unexpected error message: " + e.getMessage(), msg.contains(expectedFragment.toLowerCase())); + } + } + + private ExpSampleType createSampleType(String name) throws Exception + { + List props = new ArrayList<>(); + props.add(new GWTPropertyDescriptor("Name", "string")); + return SampleTypeService.get().createSampleType(_c, _user, name, null, props, Collections.emptyList(), -1, -1, -1, -1, null); + } + + private void insertSample(ExpSampleType st, String name) throws Exception + { + UserSchema schema = QueryService.get().getUserSchema(_user, _c, SchemaKey.fromParts("Samples")); + QueryUpdateService qus = schema.getTable(st.getName()).getUpdateService(); + BatchValidationException errors = new BatchValidationException(); + qus.insertRows(_user, _c, List.of(CaseInsensitiveHashMap.of("Name", name)), errors, null, null); + if (errors.hasErrors()) + throw errors; + } + + private static Map sampleRow(String name, Long colorRowId) + { + Map row = new CaseInsensitiveHashMap<>(); + row.put("Name", name); + row.put("ExpMaterialColor", colorRowId); + return row; + } + + private String saveSample(ExpSampleType st, Map row, boolean isUpdate) + { + try + { + UserSchema schema = QueryService.get().getUserSchema(_user, _c, SchemaKey.fromParts("Samples")); + QueryUpdateService qus = schema.getTable(st.getName()).getUpdateService(); + BatchValidationException errors = new BatchValidationException(); + if (isUpdate) + qus.updateRows(_user, _c, List.of(row), null, errors, null, null); + else + qus.insertRows(_user, _c, List.of(row), errors, null, null); + return errors.hasErrors() ? errors.getMessage() : null; + } + catch (Exception e) + { + return e.getMessage(); + } + } + + private long countInContainer(TableInfo table, String containerId) + { + SQLFragment sql = new SQLFragment("SELECT COUNT(*) FROM ").append(table).append(" WHERE Container = ?").add(containerId); + return new SqlSelector(ExperimentServiceImpl.getExpSchema(), sql).getObject(Long.class); + } + + // ---- DataColorTable QUS validation ---------------------------------- + + @Test + public void testColorValidation() throws Exception + { + long red = insertColor("Red", "#ff0000", false); + assertTrue(red > 0); + + assertColorInsertFails(colorRow("", "#00ff00", false), "blank"); // blank label + assertColorInsertFails(colorRow("BadHex", "red", false), "hex"); // not hex + assertColorInsertFails(colorRow("BadHex2", "#ff00", false), "hex"); // too short + assertColorInsertFails(colorRow("BadHex3", "#GGGGGG", false), "hex"); // non-hex chars + assertColorInsertFails(colorRow("RED", "#0000ff", false), "already in use"); // case-insensitive dup + } + + @Test + public void testColorCapEnforced() throws Exception + { + List> rows = new ArrayList<>(); + for (int i = 0; i <= DataColorManager.MAX_DATA_COLORS; i++) // MAX + 1 rows + rows.add(colorRow("Color" + i, String.format("#0000%02x", i % 256), false)); + + try + { + BatchValidationException errors = new BatchValidationException(); + colorQus(_c).insertRows(_user, _c, rows, errors, null, null); + if (errors.hasErrors()) + throw errors; + fail("Expected the " + DataColorManager.MAX_DATA_COLORS + "-color cap to be enforced"); + } + catch (Exception e) + { + String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); + assertTrue("Unexpected error message: " + e.getMessage(), msg.contains("more than")); + } + } + + @Test + public void testInUseDeleteGuard() throws Exception + { + long red = insertColor("InUse", "#123456", false); + ExpSampleType st = createSampleType("ColorInUseST"); + insertSample(st, "s1"); + + ExpMaterial m = st.getSample(_c, "s1"); + m.setSampleColorId(red); + m.save(_user); + assertTrue("color should be reported in use", DataColorManager.getInstance().isInUse(red)); + + try + { + deleteColor(red); + fail("Expected in-use color delete to be rejected"); + } + catch (Exception e) + { + String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); + assertTrue("Unexpected error message: " + e.getMessage(), msg.contains("in use")); + } + + // clear the reference and the delete should now succeed + m.setSampleColorId(null); + m.save(_user); + assertFalse(DataColorManager.getInstance().isInUse(red)); + deleteColor(red); + assertNull(DataColorManager.getInstance().getColorForRowId(_c, red)); + } + + // ---- sample import: color exclusion enforcement --------------------- + + @Test + public void testCannotInsertSampleWithExcludedColor() throws Exception + { + ExpSampleType st = createSampleType("ColorInsertST"); + long red = insertColor("Red", "#ff0000", false); + long blue = insertColor("Blue", "#0000ff", false); + // Blue is excluded for this sample type; Red is not. + ExperimentService.get().ensureDataColorExclusions(st.getRowId(), DataTypeForExclusion.SampleType, List.of(blue), _c, _user); + + // an allowed (non-excluded) color imports fine + assertNull("a non-excluded color should be insertable", saveSample(st, sampleRow("okSample", red), false)); + // a null color is always fine + assertNull("a sample with no color should be insertable", saveSample(st, sampleRow("noColor", null), false)); + + // an excluded color is rejected + String err = saveSample(st, sampleRow("badSample", blue), false); + assertNotNull("inserting an excluded color should fail", err); + assertTrue("Unexpected error: " + err, err.toLowerCase().contains("not valid")); + } + + @Test + public void testCannotUpdateSampleToExcludedColor() throws Exception + { + ExpSampleType st = createSampleType("ColorUpdateST"); + long red = insertColor("Red", "#ff0000", false); + long blue = insertColor("Blue", "#0000ff", false); + ExperimentService.get().ensureDataColorExclusions(st.getRowId(), DataTypeForExclusion.SampleType, List.of(blue), _c, _user); + + assertNull(saveSample(st, sampleRow("s1", red), false)); + long sampleRowId = st.getSample(_c, "s1").getRowId(); + + // updating to the excluded color is rejected + Map toExcluded = new CaseInsensitiveHashMap<>(); + toExcluded.put("RowId", sampleRowId); + toExcluded.put("ExpMaterialColor", blue); + String err = saveSample(st, toExcluded, true); + assertNotNull("updating to an excluded color should fail", err); + assertTrue("Unexpected error: " + err, err.toLowerCase().contains("not valid")); + + // updating to an allowed color succeeds + Map toAllowed = new CaseInsensitiveHashMap<>(); + toAllowed.put("RowId", sampleRowId); + toAllowed.put("ExpMaterialColor", red); + assertNull("updating to a non-excluded color should succeed", saveSample(st, toAllowed, true)); + } + + @Test + public void testArchivedNonExcludedColorCanBeImported() throws Exception + { + ExpSampleType st = createSampleType("ColorArchivedImportST"); + long blue = insertColor("Blue", "#0000ff", false); + long gray = insertColor("Gray", "#888888", true); // archived, but NOT excluded + // Exclude Blue so the import check is active for this type. + ExperimentService.get().ensureDataColorExclusions(st.getRowId(), DataTypeForExclusion.SampleType, List.of(blue), _c, _user); + + // Exclusion — not archived-ness — is what's enforced on import, so an archived non-excluded color is allowed. + assertNull("an archived non-excluded color should still be insertable", saveSample(st, sampleRow("s1", gray), false)); + } + + // ---- exclusion service methods -------------------------------------- + + @Test + public void testExclusionDeltaAndReads() throws Exception + { + ExperimentService svc = ExperimentService.get(); + ExpSampleType stA = createSampleType("ExclA"); + ExpSampleType stB = createSampleType("ExclB"); + long red = insertColor("Red", "#ff0000", false); + long blue = insertColor("Blue", "#0000ff", false); + long typeA = stA.getRowId(); + long typeB = stB.getRowId(); + + assertTrue(svc.getDataTypeExcludedColors(DataTypeForExclusion.SampleType, typeA).isEmpty()); + + // exclude red for stA + Set affected = svc.updateColorDataTypeExclusions(red, DataTypeForExclusion.SampleType, List.of(typeA), null, _c, _user); + assertEquals(Set.of(typeA), affected); + assertEquals(Set.of(red), svc.getDataTypeExcludedColors(DataTypeForExclusion.SampleType, typeA)); + assertEquals(Set.of(typeA), svc.getDataTypesExcludingColor(DataTypeForExclusion.SampleType, red)); + + // the exclusion row is stamped with the caller's container + String stampedContainer = new SqlSelector(ExperimentServiceImpl.getExpSchema(), + new SQLFragment("SELECT Container FROM ").append(ExperimentServiceImpl.get().getTinfoDataTypeColorExclusion()) + .append(" WHERE ColorRowId = ? AND DataTypeRowId = ?").add(red).add(typeA)).getObject(String.class); + assertEquals(_c.getId(), stampedContainer); + + // idempotent re-add is a no-op + assertTrue(svc.updateColorDataTypeExclusions(red, DataTypeForExclusion.SampleType, List.of(typeA), null, _c, _user).isEmpty()); + + // getActiveDataTypeColors reflects the exclusion for stA only + assertFalse(svc.getActiveDataTypeColors(_c, DataTypeForExclusion.SampleType, typeA).contains(red)); + assertTrue(svc.getActiveDataTypeColors(_c, DataTypeForExclusion.SampleType, typeB).contains(red)); + + // re-enable (delta remove) + affected = svc.updateColorDataTypeExclusions(red, DataTypeForExclusion.SampleType, null, List.of(typeA), _c, _user); + assertEquals(Set.of(typeA), affected); + assertTrue(svc.getDataTypeExcludedColors(DataTypeForExclusion.SampleType, typeA).isEmpty()); + + // ensureDataColorExclusions sets the full disabled set (create-time path) + assertTrue(svc.ensureDataColorExclusions(typeB, DataTypeForExclusion.SampleType, List.of(blue), _c, _user)); + assertEquals(Set.of(blue), svc.getDataTypeExcludedColors(DataTypeForExclusion.SampleType, typeB)); + // ...and is idempotent + assertFalse(svc.ensureDataColorExclusions(typeB, DataTypeForExclusion.SampleType, List.of(blue), _c, _user)); + } + + // ---- create-path persistence + audit -------------------------------- + + @Test + public void testCreateSampleTypeWithExclusionsAndAudit() throws Exception + { + long red = insertColor("Red", "#ff0000", false); + + List props = new ArrayList<>(); + props.add(new GWTPropertyDescriptor("Name", "string")); + ExpSampleType st = SampleTypeService.get().createSampleType(_c, _user, "CreatedWithColors", null, props, + Collections.emptyList(), -1, -1, -1, -1, + null, null, null, null, null, null, // nameExpression..metricUnit + null, null, null, null, // autoLink..disabledSystemField + null, null, List.of((int) red), null); // excludedContainerIds, excludedDashboardContainerIds, excludedSampleColorIds, changeDetails + + assertEquals(Set.of(red), ExperimentService.get().getDataTypeExcludedColors(DataTypeForExclusion.SampleType, st.getRowId())); + assertColorAuditEventWritten(); + } + + @Test + public void testAuditSampleColorExclusion() throws Exception + { + ExpSampleType st = createSampleType("AuditST"); + long red = insertColor("Red", "#ff0000", false); + ExperimentService.get().updateColorDataTypeExclusions(red, DataTypeForExclusion.SampleType, List.of((long) st.getRowId()), null, _c, _user); + SampleTypeService.get().auditSampleColorExclusion(_c, st.getRowId(), "junit comment", _user); + assertColorAuditEventWritten(); + } + + private void assertColorAuditEventWritten() + { + List events = AuditLogService.get().getAuditEvents(_c, _user, SampleTypeAuditProvider.EVENT_TYPE, null, new Sort("-RowId")); + assertTrue("expected a sample-type audit event mentioning color exclusion", + events.stream().anyMatch(e -> e.getComment() != null && e.getComment().contains("Sample color exclusion"))); + } + + // ---- orphan cleanup ------------------------------------------------- + + @Test + public void testNoOrphanExclusionsOnColorDelete() throws Exception + { + ExpSampleType st = createSampleType("OrphanColorST"); + long red = insertColor("Red", "#ff0000", false); + ExperimentService.get().updateColorDataTypeExclusions(red, DataTypeForExclusion.SampleType, List.of((long) st.getRowId()), null, _c, _user); + assertFalse(ExperimentService.get().getDataTypesExcludingColor(DataTypeForExclusion.SampleType, red).isEmpty()); + + deleteColor(red); + assertTrue("exclusion rows for a deleted color must be removed", + ExperimentService.get().getDataTypesExcludingColor(DataTypeForExclusion.SampleType, red).isEmpty()); + } + + @Test + public void testNoOrphanExclusionsOnSampleTypeDelete() throws Exception + { + ExpSampleType st = createSampleType("OrphanTypeST"); + long red = insertColor("Red", "#ff0000", false); + long typeRowId = st.getRowId(); + ExperimentService.get().updateColorDataTypeExclusions(red, DataTypeForExclusion.SampleType, List.of(typeRowId), null, _c, _user); + assertFalse(ExperimentService.get().getDataTypeExcludedColors(DataTypeForExclusion.SampleType, typeRowId).isEmpty()); + + st.delete(_user, null); + assertTrue("exclusion rows for a deleted sample type must be removed", + ExperimentService.get().getDataTypeExcludedColors(DataTypeForExclusion.SampleType, typeRowId).isEmpty()); + } + + @Test + public void testNoOrphanColorsOrExclusionsOnContainerDelete() throws Exception + { + Container child = ContainerManager.createContainer(_c, "SampleColorsChild", _user); + String childId = child.getId(); + + long red = insertColor(child, "ChildRed", "#ff0000", false); + + ExpSampleType st = SampleTypeService.get().createSampleType(child, _user, "ChildST", null, + List.of(new GWTPropertyDescriptor("Name", "string")), Collections.emptyList(), -1, -1, -1, -1, null); + ExperimentService.get().updateColorDataTypeExclusions(red, DataTypeForExclusion.SampleType, List.of((long) st.getRowId()), null, child, _user); + + assertEquals(1L, countInContainer(ExperimentServiceImpl.get().getTinfoDataColors(), childId)); + assertEquals(1L, countInContainer(ExperimentServiceImpl.get().getTinfoDataTypeColorExclusion(), childId)); + + ContainerManager.delete(child, _user); + + assertEquals("no orphaned colors after container delete", 0L, countInContainer(ExperimentServiceImpl.get().getTinfoDataColors(), childId)); + assertEquals("no orphaned exclusions after container delete", 0L, countInContainer(ExperimentServiceImpl.get().getTinfoDataTypeColorExclusion(), childId)); + } + } + +} diff --git a/experiment/src/org/labkey/experiment/api/ExpMaterialImpl.java b/experiment/src/org/labkey/experiment/api/ExpMaterialImpl.java index 3799d5c8713..152dd004add 100644 --- a/experiment/src/org/labkey/experiment/api/ExpMaterialImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExpMaterialImpl.java @@ -191,6 +191,18 @@ public void setSampleStateId(Long stateId) _object.setSampleState(stateId); } + @Override + public Long getSampleColorId() + { + return _object.getExpMaterialColor(); + } + + @Override + public void setSampleColorId(Long colorId) + { + _object.setExpMaterialColor(colorId); + } + @Override public DataState getSampleState() { diff --git a/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java b/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java index 38f71e66222..176aec9b24e 100644 --- a/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExpMaterialTableImpl.java @@ -118,6 +118,7 @@ import org.labkey.api.security.permissions.Permission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.settings.OptionalFeatureService; import org.labkey.api.test.TestWhen; import org.labkey.api.util.ContextListener; import org.labkey.api.util.GUID; @@ -597,6 +598,20 @@ protected ContainerFilter getLookupContainerFilter() { return createPropertiesColumn(alias); } + case ExpMaterialColor -> + { + boolean colorsEnabled = colorsEnabled(getContainer()); + var ret = wrapColumn(alias, _rootTable.getColumn(column.name())); + ret.setLabel("Sample Color"); + ret.setHidden(!colorsEnabled); + ret.setShownInDetailsView(colorsEnabled); + ret.setShownInInsertView(colorsEnabled); + ret.setShownInUpdateView(colorsEnabled); + ret.setRemapMissingBehavior(SimpleTranslator.RemapMissingBehavior.Error); + ret.setFk(new QueryForeignKey.Builder(getUserSchema(), getMaterialBaseFieldLookupContainerFilter()) + .schema(getExpSchema()).table(ExpSchema.TableType.DataColors).display("Label")); + return ret; + } case SampleState -> { boolean statusEnabled = isStatusEnabled(getContainer()); @@ -607,7 +622,7 @@ protected ContainerFilter getLookupContainerFilter() ret.setShownInInsertView(statusEnabled); ret.setShownInUpdateView(statusEnabled); ret.setRemapMissingBehavior(SimpleTranslator.RemapMissingBehavior.Error); - ret.setFk(new QueryForeignKey.Builder(getUserSchema(), getSampleStatusLookupContainerFilter()) + ret.setFk(new QueryForeignKey.Builder(getUserSchema(), getMaterialBaseFieldLookupContainerFilter()) .schema(getExpSchema()).table(ExpSchema.TableType.SampleStatus).display("Label")); return ret; } @@ -757,6 +772,15 @@ private static boolean isStatusEnabled(Container c) return SampleStatusService.get().supportsSampleStatus() && !SampleStatusService.get().getAllProjectStates(c).isEmpty(); } + private boolean colorsEnabled(Container c) + { + if (!OptionalFeatureService.get().isFeatureEnabled(ExperimentService.EXPERIMENTAL_SAMPLE_COLORS)) + return false; + if (_ss != null) + return !ExperimentService.get().getActiveDataTypeColors(c, ExperimentService.DataTypeForExclusion.SampleType, _ss.getRowId()).isEmpty(); + return !DataColorManager.getInstance().getActiveProjectColors(c).isEmpty(); + } + private Unit getSampleTypeUnit() { Unit typeUnit = null; @@ -862,6 +886,9 @@ protected void populateColumns() addColumn(SampleState); if (isStatusEnabled(getContainer())) defaultCols.add(SampleState.fieldKey()); + addColumn(ExpMaterialColor); + if (colorsEnabled(getContainer())) + defaultCols.add(ExpMaterialColor.fieldKey()); // TODO is this a real Domain??? if (st != null && !"urn:lsid:labkey.com:SampleSource:Default".equals(st.getDomain().getTypeURI())) @@ -985,7 +1012,7 @@ protected void populateColumns() addColumn(lineageLookup); } - private ContainerFilter getSampleStatusLookupContainerFilter() + private ContainerFilter getMaterialBaseFieldLookupContainerFilter() { // The default lookup container filter is Current. However, we want to have the default be CurrentPlusProjectAndShared // for the sample status lookup since in the app project context we want to share status definitions across diff --git a/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java b/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java index 5e97c39ce6e..bb407f07100 100644 --- a/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java +++ b/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java @@ -291,7 +291,6 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; import java.util.function.Supplier; -import java.util.function.UnaryOperator; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -1665,6 +1664,12 @@ public SampleStatusTable createSampleStatusTable(ExpSchema expSchema, ContainerF return new SampleStatusTable(expSchema, containerFilter); } + @Override + public TableInfo createDataColorTable(ExpSchema expSchema, ContainerFilter containerFilter) + { + return new DataColorTable(expSchema, containerFilter); + } + @Override public ExpUnreferencedSampleFilesTable createUnreferencedSampleFilesTable(ExpSchema expSchema, ContainerFilter cf) { @@ -3979,6 +3984,16 @@ public TableInfo getTinfoDataTypeExclusion() return getExpSchema().getTable("DataTypeExclusion"); } + public TableInfo getTinfoDataColors() + { + return getExpSchema().getTable("DataColors"); + } + + public TableInfo getTinfoDataTypeColorExclusion() + { + return getExpSchema().getTable("DataTypeColorExclusion"); + } + /** * return the object of any known experiment type that is identified with the LSID * @@ -9108,6 +9123,159 @@ public String getDisabledDataTypeAuditMsg(DataTypeForExclusion type, List return builder.toString(); } + @Override + public @NotNull Set getDataTypeExcludedColors(DataTypeForExclusion dataType, long dataTypeId) + { + SQLFragment sql = new SQLFragment("SELECT ColorRowId FROM ") + .append(getTinfoDataTypeColorExclusion()) + .append(" WHERE DataTypeRowId = ? AND DataType = ?") + .add(dataTypeId).add(dataType.name()); + return new HashSet<>(new SqlSelector(getExpSchema(), sql).getArrayList(Long.class)); + } + + @Override + public @Nullable String getDataColorLabel(@NotNull Container container, long colorRowId) + { + return DataColorManager.getInstance().getAllProjectColors(container).stream() + .filter(c -> c.getRowId() == colorRowId) + .map(DataColor::getLabel) + .findFirst() + .orElse(null); + } + + @Override + public @NotNull Set getActiveDataTypeColors(@NotNull Container container, DataTypeForExclusion dataType, long dataTypeId) + { + Set disabled = getDataTypeExcludedColors(dataType, dataTypeId); + return DataColorManager.getInstance().getActiveProjectColors(container).stream() + .map(c -> (long) c.getRowId()) + .filter(rowId -> !disabled.contains(rowId)) + .collect(toSet()); + } + + // Applies a reconciled set of exclusion changes to exp.DataTypeColorExclusion in one transaction: one key column is + // held fixed (fixedColumn = fixedValue), the other varies. Rows in toAdd are inserted; rows in toRemove are deleted. + // Shared by ensureDataColorExclusions (fixes DataTypeRowId, varies ColorRowId) and updateColorDataTypeExclusions + // (fixes ColorRowId, varies DataTypeRowId). The column names are code constants, not caller input. + private void applyExclusionChanges(String fixedColumn, long fixedValue, String varyingColumn, Set toAdd, Set toRemove, DataTypeForExclusion dataType, Container container, User user) + { + try (DbScope.Transaction tx = getExpSchema().getScope().ensureTransaction()) + { + for (Long id : toAdd) + { + Map fields = new HashMap<>(); + fields.put("Container", container.getId()); + fields.put("DataType", dataType.name()); + fields.put(fixedColumn, fixedValue); + fields.put(varyingColumn, id); + Table.insert(user, getTinfoDataTypeColorExclusion(), fields); + } + if (!toRemove.isEmpty()) + { + SQLFragment sql = new SQLFragment("DELETE FROM ") + .append(getTinfoDataTypeColorExclusion()) + .append(" WHERE ").append(fixedColumn).append(" = ? AND DataType = ?") + .add(fixedValue).add(dataType.name()) + .append(" AND ").append(varyingColumn).append(" "); + sql.appendInClause(toRemove, getExpSchema().getSqlDialect()); + new SqlExecutor(getExpSchema()).execute(sql); + } + tx.commit(); + } + } + + @Override + public boolean ensureDataColorExclusions(long dataTypeId, DataTypeForExclusion dataType, @Nullable Collection disabledColorRowIds, @NotNull Container container, User user) + { + if (disabledColorRowIds == null) + return false; + + Set previous = getDataTypeExcludedColors(dataType, dataTypeId); + Set updated = new HashSet<>(disabledColorRowIds); + + Set toAdd = new HashSet<>(updated); + toAdd.removeAll(previous); + + Set toRemove = new HashSet<>(previous); + toRemove.removeAll(updated); + + if (toAdd.isEmpty() && toRemove.isEmpty()) + return false; + + // Fix the sample type; vary the colors being disabled/re-enabled for it. + applyExclusionChanges("DataTypeRowId", dataTypeId, "ColorRowId", toAdd, toRemove, dataType, container, user); + return true; + } + + @Override + public @NotNull Set getDataTypesExcludingColor(DataTypeForExclusion dataType, long colorRowId) + { + SQLFragment sql = new SQLFragment("SELECT DataTypeRowId FROM ") + .append(getTinfoDataTypeColorExclusion()) + .append(" WHERE ColorRowId = ? AND DataType = ?") + .add(colorRowId).add(dataType.name()); + return new HashSet<>(new SqlSelector(getExpSchema(), sql).getArrayList(Long.class)); + } + + @Override + public @NotNull Set updateColorDataTypeExclusions(long colorRowId, DataTypeForExclusion dataType, @Nullable Collection newlyDisabledDataTypeIds, @Nullable Collection newlyEnabledDataTypeIds, @NotNull Container container, User user) + { + Set toAdd = newlyDisabledDataTypeIds == null ? new HashSet<>() : new HashSet<>(newlyDisabledDataTypeIds); + Set toRemove = newlyEnabledDataTypeIds == null ? new HashSet<>() : new HashSet<>(newlyEnabledDataTypeIds); + toRemove.removeAll(toAdd); + + if (toAdd.isEmpty() && toRemove.isEmpty()) + return Set.of(); + + Set existing = getDataTypesExcludingColor(dataType, colorRowId); + toAdd.removeAll(existing); + toRemove.retainAll(existing); + + if (toAdd.isEmpty() && toRemove.isEmpty()) + return Set.of(); + + // Fix the color; vary the sample types it's disabled/re-enabled for. + applyExclusionChanges("ColorRowId", colorRowId, "DataTypeRowId", toAdd, toRemove, dataType, container, user); + + Set affected = new HashSet<>(toAdd); + affected.addAll(toRemove); + return affected; + } + + @Override + public void removeDataColorExclusionsForColor(long colorRowId) + { + SQLFragment sql = new SQLFragment("DELETE FROM ") + .append(getTinfoDataTypeColorExclusion()) + .append(" WHERE ColorRowId = ?").add(colorRowId); + new SqlExecutor(getExpSchema()).execute(sql); + } + + @Override + public void removeDataColorExclusionsForDataType(long dataTypeId, DataTypeForExclusion dataType) + { + SQLFragment sql = new SQLFragment("DELETE FROM ") + .append(getTinfoDataTypeColorExclusion()) + .append(" WHERE DataTypeRowId = ? AND DataType = ?") + .add(dataTypeId).add(dataType.name()); + new SqlExecutor(getExpSchema()).execute(sql); + } + + @Override + public void removeContainerDataColorExclusions(String containerId) + { + SqlExecutor executor = new SqlExecutor(getExpSchema()); + SQLFragment delExclusions = new SQLFragment("DELETE FROM ") + .append(getTinfoDataTypeColorExclusion()) + .append(" WHERE Container = ?").add(containerId); + executor.execute(delExclusions); + + SQLFragment delColors = new SQLFragment("DELETE FROM ") + .append(getTinfoDataColors()) + .append(" WHERE Container = ?").add(containerId); + executor.execute(delColors); + } + @Override public void addObjectLegacyName(long objectId, String objectType, String legacyName, User user) { diff --git a/experiment/src/org/labkey/experiment/api/Material.java b/experiment/src/org/labkey/experiment/api/Material.java index 1d0be05427d..8a4f1a15af0 100644 --- a/experiment/src/org/labkey/experiment/api/Material.java +++ b/experiment/src/org/labkey/experiment/api/Material.java @@ -35,6 +35,7 @@ public class Material extends RunItem private Long rootMaterialRowId; private String aliquotedFromLSID; private Long sampleState; + private Long expMaterialColor; private Date materialExpDate; private Double storedAmount; @@ -112,6 +113,16 @@ public void setSampleState(Long sampleState) this.sampleState = sampleState; } + public Long getExpMaterialColor() + { + return expMaterialColor; + } + + public void setExpMaterialColor(Long expMaterialColor) + { + this.expMaterialColor = expMaterialColor; + } + public Integer getAliquotCount() { return aliquotCount; diff --git a/experiment/src/org/labkey/experiment/api/SampleTypeServiceImpl.java b/experiment/src/org/labkey/experiment/api/SampleTypeServiceImpl.java index f69d0948abe..1ae70f5b04c 100644 --- a/experiment/src/org/labkey/experiment/api/SampleTypeServiceImpl.java +++ b/experiment/src/org/labkey/experiment/api/SampleTypeServiceImpl.java @@ -175,7 +175,7 @@ import static org.labkey.api.exp.query.ExpMaterialTable.Column.Units; -public class SampleTypeServiceImpl extends AbstractAuditHandler implements SampleTypeService +public class SampleTypeServiceImpl extends AbstractAuditHandler implements SampleTypeService, DataColorManager.DataColorHandler { public static final String SAMPLE_COUNT_SEQ_NAME = "org.labkey.api.exp.api.ExpMaterial:sampleCount"; public static final String ROOT_SAMPLE_COUNT_SEQ_NAME = "org.labkey.api.exp.api.ExpMaterial:rootSampleCount"; @@ -203,6 +203,19 @@ public static SampleTypeServiceImpl get() return (SampleTypeServiceImpl) SampleTypeService.get(); } + @Override + public String getHandlerType() + { + return "SampleColorMaterial"; + } + + @Override + public boolean isColorInUse(long colorRowId) + { + SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("ExpMaterialColor"), colorRowId); + return new TableSelector(ExperimentServiceImpl.get().getTinfoMaterial(), filter, null).exists(); + } + private static final Logger LOG = LogHelper.getLogger(SampleTypeServiceImpl.class, "Info about sample type operations"); /** SampleType LSID -> Container cache */ @@ -686,6 +699,7 @@ public void deleteSampleType(long rowId, Container c, User user, @Nullable Strin ExperimentService.get().removeDataTypeExclusion(Collections.singleton(rowId), ExperimentService.DataTypeForExclusion.SampleType); ExperimentService.get().removeDataTypeExclusion(Collections.singleton(rowId), ExperimentService.DataTypeForExclusion.DashboardSampleType); + ExperimentService.get().removeDataColorExclusionsForDataType(rowId, ExperimentService.DataTypeForExclusion.SampleType); transaction.addCommitTask(() -> clearMaterialSourceCache(c), DbScope.CommitTaskOption.IMMEDIATE, POSTCOMMIT, POSTROLLBACK); transaction.commit(); @@ -757,7 +771,7 @@ public ExpSampleTypeImpl createSampleType(Container c, User u, String name, Stri public ExpSampleTypeImpl createSampleType(Container c, User u, String name, String description, List properties, List indices, int idCol1, int idCol2, int idCol3, int parentCol, String nameExpression, String aliquotNameExpression, @Nullable TemplateInfo templateInfo, @Nullable Map> importAliases, @Nullable String labelColor, @Nullable String metricUnit) throws ExperimentException { - return createSampleType(c, u, name, description, properties, indices, idCol1, idCol2, idCol3, parentCol, nameExpression, aliquotNameExpression, templateInfo, importAliases, labelColor, metricUnit, null, null, null, null, null, null, null); + return createSampleType(c, u, name, description, properties, indices, idCol1, idCol2, idCol3, parentCol, nameExpression, aliquotNameExpression, templateInfo, importAliases, labelColor, metricUnit, null, null, null, null, null, null, null, null); } @NotNull @@ -765,7 +779,7 @@ public ExpSampleTypeImpl createSampleType(Container c, User u, String name, Stri public ExpSampleTypeImpl createSampleType(Container c, User u, String name, String description, List properties, List indices, int idCol1, int idCol2, int idCol3, int parentCol, String nameExpression, String aliquotNameExpression, @Nullable TemplateInfo templateInfo, @Nullable Map> importAliases, @Nullable String labelColor, @Nullable String metricUnit, @Nullable Container autoLinkTargetContainer, @Nullable String autoLinkCategory, @Nullable String category, @Nullable List disabledSystemField, - @Nullable List excludedContainerIds, @Nullable List excludedDashboardContainerIds, @Nullable Map changeDetails) + @Nullable List excludedContainerIds, @Nullable List excludedDashboardContainerIds, @Nullable List excludedSampleColorIds, @Nullable Map changeDetails) throws ExperimentException { validateSampleTypeName(c, u, name, false); @@ -954,6 +968,13 @@ public ExpSampleTypeImpl createSampleType(Container c, User u, String name, Stri ExperimentService.get().ensureDataTypeContainerExclusions(ExperimentService.DataTypeForExclusion.DashboardSampleType, excludedDashboardContainerIds, st.getRowId(), u); else ExperimentService.get().ensureDataTypeContainerExclusionsNonAdmin(ExperimentService.DataTypeForExclusion.DashboardSampleType, st.getRowId(), c, u); + if (excludedSampleColorIds != null && !excludedSampleColorIds.isEmpty()) + { + List disabledColorRowIds = excludedSampleColorIds.stream().map(Integer::longValue).toList(); + boolean hasColorChange = ExperimentService.get().ensureDataColorExclusions(st.getRowId(), ExperimentService.DataTypeForExclusion.SampleType, disabledColorRowIds, c, u); + if (hasColorChange) + auditSampleColorExclusion(c, st, null, u); + } transaction.addCommitTask(() -> clearMaterialSourceCache(c), DbScope.CommitTaskOption.IMMEDIATE, POSTCOMMIT, POSTROLLBACK); transaction.addCommitTask(() -> indexSampleType(SampleTypeService.get().getSampleType(domain.getTypeURI()), SearchService.get().defaultTask().getQueue(c, SearchService.PRIORITY.modified)), POSTCOMMIT); @@ -1200,6 +1221,13 @@ public ValidationException updateSampleType(GWTDomain disabledColorRowIds = options.getDisabledSampleColorRowIds().stream().map(Integer::longValue).toList(); + boolean hasChange = ExperimentService.get().ensureDataColorExclusions(st.getRowId(), ExperimentService.DataTypeForExclusion.SampleType, disabledColorRowIds, container, user); + if (hasChange) + auditSampleColorExclusion(container, st, auditUserComment, user); + } errors = DomainUtil.updateDomainDescriptor(original, update, container, user, hasNameChange, changeDetails.toString(), auditUserComment, oldProps, newProps); @@ -1224,6 +1252,21 @@ public ValidationException updateSampleType(GWTDomain disabled = ExperimentService.get().getDataTypeExcludedColors(ExperimentService.DataTypeForExclusion.SampleType, sampleType.getRowId()); + String msg = "Sample color exclusion was updated for sample type (rowId " + sampleType.getRowId() + "). " + + (disabled.isEmpty() ? "All colors enabled." : "Excluded color rowIds: " + StringUtils.join(disabled, ", ") + "."); + addSampleTypeAuditEvent(user, container, sampleType, msg, auditUserComment, "update colors"); + } + + public String getCommentDetailed(QueryService.AuditAction action, boolean isUpdate) { String comment = SampleTimelineAuditEvent.SampleTimelineEventType.getActionCommentDetailed(action, isUpdate); diff --git a/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java b/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java index 39b5dabde21..bf0f993ea08 100644 --- a/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java +++ b/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java @@ -185,6 +185,8 @@ public class SampleTypeUpdateServiceDI extends DefaultQueryUpdateService SAMPLE_ALT_IMPORT_NAME_COLS.put("Expiration Date", "MaterialExpDate"); SAMPLE_ALT_IMPORT_NAME_COLS.put("Entered Storage", "Stored"); SAMPLE_ALT_IMPORT_NAME_COLS.put("EnteredStorage", "Stored"); + SAMPLE_ALT_IMPORT_NAME_COLS.put("SampleColor", "ExpMaterialColor"); + SAMPLE_ALT_IMPORT_NAME_COLS.put("Sample Color", "ExpMaterialColor"); } public enum Options @@ -1309,6 +1311,8 @@ public DataIterator getDataIterator(DataIteratorContext context) continue; if (isExpMaterialColumn(SampleState, name)) continue; + if (isExpMaterialColumn(ExpMaterialColor, name)) + continue; if (isExpMaterialColumn(MaterialExpDate, name)) continue; if (isExpMaterialColumn(StoredAmount, name))