diff --git a/api/src/org/labkey/api/data/ContainerManager.java b/api/src/org/labkey/api/data/ContainerManager.java index 7d3c22a9729..12252b68af9 100644 --- a/api/src/org/labkey/api/data/ContainerManager.java +++ b/api/src/org/labkey/api/data/ContainerManager.java @@ -1952,7 +1952,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..3157a60fab3 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,23 @@ 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); + + 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..6d501eef379 --- /dev/null +++ b/experiment/resources/schemas/dbscripts/postgresql/exp-26.007-26.008.sql @@ -0,0 +1,40 @@ +/* + * 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 UQ_DataColors_Label UNIQUE (Container, 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 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) +); 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/ExperimentModule.java b/experiment/src/org/labkey/experiment/ExperimentModule.java index b81ced637c0..20695ea73e9 100644 --- a/experiment/src/org/labkey/experiment/ExperimentModule.java +++ b/experiment/src/org/labkey/experiment/ExperimentModule.java @@ -118,6 +118,7 @@ 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.EdgeDiagnosticsTestCase; import org.labkey.experiment.api.ExpDataClassImpl; import org.labkey.experiment.api.ExpDataClassTableImpl; @@ -209,7 +210,7 @@ public String getName() @Override public Double getSchemaVersion() { - return 26.007; + return 26.008; } @Nullable @@ -295,6 +296,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 +556,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 +798,24 @@ 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)); + } + 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 " + @@ -1197,6 +1220,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..47180d914ee --- /dev/null +++ b/experiment/src/org/labkey/experiment/api/DataColorManager.java @@ -0,0 +1,109 @@ +/* + * 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.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(Container container, 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(Container container, long colorRowId) + { + for (DataColorHandler handler : _handlers.values()) + { + if (handler.isColorInUse(container, 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(); + } + + @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..cac310e1881 --- /dev/null +++ b/experiment/src/org/labkey/experiment/api/DataColorTable.java @@ -0,0 +1,168 @@ +/* + * 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.labkey.api.data.ColumnInfo; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TableSelector; +import org.labkey.api.exp.query.ExpSchema; +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.QueryUpdateService; +import org.labkey.api.query.QueryUpdateServiceException; +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 java.sql.SQLException; +import java.util.Map; +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}$"); + + 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(); + } + + @Override + protected Map insertRow(User user, Container container, Map row) throws DuplicateKeyException, ValidationException, QueryUpdateServiceException, SQLException + { + if (isBlankLabel(row, false)) + throw new QueryUpdateServiceException("Label cannot be blank."); + if (isInvalidColor(row, false)) + throw new QueryUpdateServiceException("Color must be a 6-digit hex value (e.g. #1a2b3c)."); + if (isDuplicateLabel(String.valueOf(row.get("label")), container, -1)) + throw new QueryUpdateServiceException("Label '" + row.get("label") + "' is already in use."); + 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 + { + if (isBlankLabel(row, true)) + throw new QueryUpdateServiceException("Label cannot be blank."); + if (isInvalidColor(row, true)) + throw new QueryUpdateServiceException("Color must be a 6-digit hex value (e.g. #1a2b3c)."); + int rowId = (int) row.get("rowId"); + if (row.containsKey("label") && isDuplicateLabel(String.valueOf(row.get("label")), container, rowId)) + throw new QueryUpdateServiceException("Label '" + row.get("label") + "' is already in use."); + + 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(container, 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()) + { + deleted = super.deleteRow(user, container, oldRowMap); + // Also drop any per-data-type exclusion rows that reference this color. + ExperimentServiceImpl.get().removeDataColorExclusionsForColor(rowId); + tx.addCommitTask(() -> DataColorManager.getInstance().clearCache(container), DbScope.CommitTaskOption.IMMEDIATE, DbScope.CommitTaskOption.POSTCOMMIT); + tx.commit(); + } + return deleted; + } + } +} 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..87f2e99301e 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(), getSampleStatusLookupContainerFilter()) + .schema(getExpSchema()).table(ExpSchema.TableType.DataColors).display("Label")); + return ret; + } case SampleState -> { boolean statusEnabled = isStatusEnabled(getContainer()); @@ -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().getActiveColors(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())) diff --git a/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java b/experiment/src/org/labkey/experiment/api/ExperimentServiceImpl.java index 5e97c39ce6e..2b19b02d2f2 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,161 @@ 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 @NotNull Set getActiveDataTypeColors(@NotNull Container container, DataTypeForExclusion dataType, long dataTypeId) + { + Set disabled = getDataTypeExcludedColors(dataType, dataTypeId); + return DataColorManager.getInstance().getActiveColors(container).stream() + .map(c -> (long) c.getRowId()) + .filter(rowId -> !disabled.contains(rowId)) + .collect(toSet()); + } + + @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; + + try (DbScope.Transaction tx = getExpSchema().getScope().ensureTransaction()) + { + for (Long colorRowId : toAdd) + { + Map fields = new HashMap<>(); + fields.put("Container", container.getId()); + fields.put("DataTypeRowId", dataTypeId); + fields.put("DataType", dataType.name()); + fields.put("ColorRowId", colorRowId); + Table.insert(user, getTinfoDataTypeColorExclusion(), fields); + } + if (!toRemove.isEmpty()) + { + SQLFragment sql = new SQLFragment("DELETE FROM ") + .append(getTinfoDataTypeColorExclusion()) + .append(" WHERE DataTypeRowId = ? AND DataType = ?") + .add(dataTypeId).add(dataType.name()) + .append(" AND ColorRowId "); + sql.appendInClause(toRemove, getExpSchema().getSqlDialect()); + new SqlExecutor(getExpSchema()).execute(sql); + } + tx.commit(); + } + + 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(); + + try (DbScope.Transaction tx = getExpSchema().getScope().ensureTransaction()) + { + for (Long dataTypeId : toAdd) + { + Map fields = new HashMap<>(); + fields.put("Container", container.getId()); + fields.put("DataTypeRowId", dataTypeId); + fields.put("DataType", dataType.name()); + fields.put("ColorRowId", colorRowId); + Table.insert(user, getTinfoDataTypeColorExclusion(), fields); + } + if (!toRemove.isEmpty()) + { + SQLFragment sql = new SQLFragment("DELETE FROM ") + .append(getTinfoDataTypeColorExclusion()) + .append(" WHERE ColorRowId = ? AND DataType = ?") + .add(colorRowId).add(dataType.name()) + .append(" AND DataTypeRowId "); + sql.appendInClause(toRemove, getExpSchema().getSqlDialect()); + new SqlExecutor(getExpSchema()).execute(sql); + } + tx.commit(); + } + + 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..c13d2185b50 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(Container container, 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..ddecc483e2a 100644 --- a/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java +++ b/experiment/src/org/labkey/experiment/api/SampleTypeUpdateServiceDI.java @@ -1309,6 +1309,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))