From 75647674c810ab046d66668982e50104d30a8ec0 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Tue, 28 Oct 2025 20:32:11 -0700 Subject: [PATCH 01/12] Checkpoint --- resources/views/scheduleInstrument.html | 253 ++++++++++++++---- .../labkey/targetedms/TargetedMSManager.java | 5 + .../labkey/targetedms/TargetedMSSchema.java | 39 ++- .../query/InstrumentScheduleTable.java | 73 +++++ .../query/InstrumentUsagePaymentTrigger.java | 86 ++++++ .../query/SimpleTargetedMSTable.java | 32 +++ 6 files changed, 408 insertions(+), 80 deletions(-) create mode 100644 src/org/labkey/targetedms/query/InstrumentScheduleTable.java create mode 100644 src/org/labkey/targetedms/query/InstrumentUsagePaymentTrigger.java create mode 100644 src/org/labkey/targetedms/query/SimpleTargetedMSTable.java diff --git a/resources/views/scheduleInstrument.html b/resources/views/scheduleInstrument.html index 1526f1333..9ceacdfce 100644 --- a/resources/views/scheduleInstrument.html +++ b/resources/views/scheduleInstrument.html @@ -13,12 +13,14 @@ - +
- - [Add Payment Method] + +
[Add payment method]
+
+ @@ -337,26 +339,147 @@

Time Scheduled

} }); + function validatePaymentMethodPercentsAndShow() { + const errorEl = document.getElementById('payment-method-error'); + const scheduleError = document.getElementById('schedule-save-error'); + const selects = document.querySelectorAll('#paymentMethodDropDown select'); + const percents = document.querySelectorAll('.paymentMethodPercent'); + + // Default: clear + let message = ''; + let total = 0; + let invalid = false; + + // Only validate when there are percent inputs (i.e., multiple methods in use UI-wise) + if (percents.length > 0 && selects.length > 0) { + for (let i = 0; i < selects.length; i++) { + const val = percents[i] ? percents[i].value : (selects.length === 1 ? '100' : '0'); + const num = parseFloat(val); + if (isNaN(num) || num < 0 || num > 100) { + invalid = true; + } + total += isNaN(num) ? 0 : num; + } + + if (invalid) { + message = 'Each payment percentage must be a number between 0 and 100.'; + } + else if (percents.length > 1 && total !== 100) { + message = 'When multiple payment methods are used, the percentages must add up to 100% (current total: ' + total + '%).'; + } + } + + if (errorEl) errorEl.textContent = message; + if (scheduleError) scheduleError.textContent = message; // mirror below modal as well + + return { valid: !message, total: total }; + } + + const validationCallback = validatePaymentMethodPercentsAndShow; + + function updatePercentInputsVisibility() { + const container = document.getElementById('paymentMethodDropDown'); + if (!container) return; + + const selects = container.querySelectorAll('select'); + const groups = container.querySelectorAll('.paymentPercentGroup'); + const inputs = container.querySelectorAll('.paymentMethodPercent'); + const rowCount = selects.length; + + // Show when multiple rows, hide when single + const show = rowCount > 1; + + // If only one row remains, reset its percent back to 100 before hiding + if (!show && inputs.length > 0) { + inputs[0].value = '100'; + } + + groups.forEach(function (grp) { + grp.style.display = show ? '' : 'none'; + }); + + // When first transitioning to multi-row, ensure sensible defaults + if (show && inputs.length > 0) { + // If first value is empty, default to 100 + if (inputs[0].value === '') inputs[0].value = '100'; + // For others, if empty, default to 0 + for (let i = 1; i < inputs.length; i++) { + if (inputs[i].value === '') inputs[i].value = '0'; + } + } + + // Clear the top message when hiding + if (!show) { + const topErr = document.getElementById('payment-method-error'); + if (topErr) topErr.textContent = ''; + } + } + LABKEY.Query.selectRows({ schemaName: 'targetedms', - queryName: 'paymentMethod', - sort: 'name', - columns: 'Id,UWBudgetNumber,worktag, budgetExpirationDate, name', + queryName: 'projectPaymentMethod', + sort: 'paymentMethod/name', + columns: 'paymentMethod,paymentMethod/name,paymentMethod/UWBudgetNumber,paymentMethod/worktag,paymentMethod/budgetExpirationDate', + filterArray: [ + LABKEY.Filter.create('project', project, LABKEY.Filter.Types.EQUAL) + ], scope: this, success: function (result) { - let rows = result.rows; - paymentMethodsData = rows; + let rows = result.rows || []; + // Normalize to the same shape used elsewhere in this file + paymentMethodsData = rows.map(function(r) { + return { + Id: r.paymentMethod, + name: r['paymentMethod/name'], + UWBudgetNumber: r['paymentMethod/UWBudgetNumber'], + worktag: r['paymentMethod/worktag'], + budgetExpirationDate: r['paymentMethod/budgetExpirationDate'] + }; + }); let paymentMethodDropDown = ''; - - paymentMethodDropDown += ' '; - paymentMethodDropDown += ''; + // Always render a percent input for the first row; visibility will be toggled based on row count + paymentMethodDropDown += ' '; jQuery('#paymentMethodDropDown').html(paymentMethodDropDown); + // Attach live validation listeners to the first row percent input + const firstPercent = document.querySelector('#paymentMethodDropDown .paymentMethodPercent'); + if (firstPercent) { + firstPercent.addEventListener('input', validationCallback); + firstPercent.addEventListener('change', validationCallback); + } + + // Toggle visibility based on how many rows are present + updatePercentInputsVisibility(); + // Run validation once to clear or show any messages + validatePaymentMethodPercentsAndShow(); + + // Hide or show the Add Payment Method control based on count + const addBtn = document.getElementById('addPaymentMethod'); + if (addBtn) { + if (paymentMethodsData.length <= 1) { + addBtn.style.display = 'none'; + } else { + addBtn.style.display = ''; + } + } + + // If there are no payment methods, disable Save and inform the user + const saveBtn = document.getElementById('save-event'); + const scheduleError = document.getElementById('schedule-save-error'); + if (paymentMethodsData.length === 0) { + if (saveBtn) saveBtn.setAttribute('disabled', 'disabled'); + if (scheduleError) scheduleError.textContent = 'No payment methods are configured for this project. Please contact an administrator.'; + } + else { + if (saveBtn) saveBtn.removeAttribute('disabled'); + if (scheduleError) scheduleError.textContent = ''; + } + } }); @@ -460,16 +583,40 @@

Time Scheduled

// get the paymentMethod and percentPayment from the html fields paymentMethods = []; let paymentMethodDropDowns = document.querySelectorAll('#paymentMethodDropDown select'); - let percentPaymentFields = document.querySelectorAll('#paymentMethodPercent'); + let percentPaymentFields = document.querySelectorAll('.paymentMethodPercent'); + + // Validation: ensure numeric values and totals add to 100 when multiple entries + let percentTotal = 0; + let invalidPercent = false; for (let i = 0; i < paymentMethodDropDowns.length; i++) { let paymentMethodId = paymentMethodDropDowns[i].value; - let paymentMethodPercent = percentPaymentFields[i].value; + let percentVal = percentPaymentFields[i] ? percentPaymentFields[i].value : (paymentMethodDropDowns.length === 1 ? '100' : '0'); + let percentNum = parseFloat(percentVal); + if (percentPaymentFields.length > 0) { + // only validate when inputs are present (multiple methods) + if (isNaN(percentNum) || percentNum < 0 || percentNum > 100) { + invalidPercent = true; + } + percentTotal += isNaN(percentNum) ? 0 : percentNum; + } paymentMethods.push({ paymentMethod: paymentMethodId, - percentPayment: paymentMethodPercent + percentPayment: percentVal }); } + const scheduleError = document.getElementById('schedule-save-error'); + if (invalidPercent) { + if (scheduleError) scheduleError.textContent = 'Each payment percentage must be a number between 0 and 100.'; + return; + } + if (percentPaymentFields.length > 1 && percentTotal !== 100) { + if (scheduleError) scheduleError.textContent = 'When multiple payment methods are used, the percentages must add up to 100% (current total: ' + percentTotal + '%).'; + return; + } else if (scheduleError) { + scheduleError.textContent = ''; + } + fetchInstrumentCosts(eventToSave.instrument, eventToSave.startTime, eventToSave.endTime, true); // TODO: Make sure user has not exceeded instrument time quota @@ -538,40 +685,27 @@

Time Scheduled

function saveEvent() { - let instrumentScheduleCommand = { - command: eventToSave.id ? 'update' : 'insert', - schemaName: 'targetedms', - queryName: 'instrumentSchedule', - rows: [ - eventToSave - ] - }; - let instrumentUsagePaymentRows = []; if (paymentMethods.length > 0) { for (let i = 0; i < paymentMethods.length; i++) { instrumentUsagePaymentRows.push({ - instrumentScheduleId: eventToSave.id, paymentMethod: paymentMethods[i].paymentMethod, percentPayment: paymentMethods[i].percentPayment }); } } - let instrumentUsagePaymentCommand = { + eventToSave.usagePayments = instrumentUsagePaymentRows; + + let instrumentScheduleCommand = { command: eventToSave.id ? 'update' : 'insert', schemaName: 'targetedms', - queryName: 'instrumentUsagePayment', - rows: instrumentUsagePaymentRows + queryName: 'instrumentSchedule', + rows: [ + eventToSave + ] }; - - let commandsToExecute = [instrumentScheduleCommand]; - if (eventToSave.id) { - commandsToExecute.push(instrumentUsagePaymentCommand); - } - - LABKEY.Query.saveRows({ commands: [instrumentScheduleCommand], success: function (response) { @@ -589,16 +723,6 @@

Time Scheduled

for (let i = 0; i < instrumentUsagePaymentRows.length; i++) { instrumentUsagePaymentRows[i].instrumentScheduleId = instrumentScheduleId; } - - LABKEY.Query.saveRows({ - commands: [instrumentUsagePaymentCommand], - success: function () { - // Do nothing - }, - failure: function (errorInfo) { - $('#schedule-save-error').text('Error saving. ' + (errorInfo.exception ? errorInfo.exception : '')); - } - }); } }, failure: function (errorInfo) { @@ -694,7 +818,7 @@

Time Scheduled

queryName: 'instrumentSchedule', columns: 'Id,startTime,endTime,name,notes,instrument/color,instrument/Id,project/Id, project/Title', filterArray: [ - LABKEY.Filter.create('instrument', instrument ? instrument : $('#instrumentDropDown').val()), + LABKEY.Filter.create('instrument', currentInstrument), ], success: function (data) { let rows = data.rows; @@ -737,36 +861,51 @@

Time Scheduled

$('#event-modal').modal('hide'); }); - }); - let paymentMethodsData; - let paymentMethodCount = 1; - document.addEventListener('DOMContentLoaded', function () { + let paymentMethodsData; + let paymentMethodCount = 1; const addPaymentMethodEl = document.getElementById('addPaymentMethod'); addPaymentMethodEl.addEventListener('click', function () { paymentMethodCount++; let id = 'paymentMethodDropDown' + paymentMethodCount; - let paymentMethodDropDown = ''; for (let i = 0; i < paymentMethodsData.length; i++) { paymentMethodDropDown += ''; } paymentMethodDropDown += ''; - paymentMethodDropDown += ' '; - paymentMethodDropDown += ''; + // Always render a percent input group; visibility toggled by updatePercentInputsVisibility + paymentMethodDropDown += ' '; - // TODO: add delete icon paymentMethodDropDown += ' ' + '[Remove]'; let logElt = document.createElement('div'); logElt.innerHTML = paymentMethodDropDown; - document.querySelector('#paymentMethodDropDown').appendChild(logElt); + const container = document.querySelector('#paymentMethodDropDown'); + container.appendChild(logElt); + + // Attach live validation listeners to the newly added input + const newInput = logElt.querySelector('.paymentMethodPercent'); + if (newInput) { + newInput.addEventListener('input', validatePaymentMethodPercentsAndShow); + newInput.addEventListener('change', validatePaymentMethodPercentsAndShow); + } + + // Ensure percent inputs are shown only when multiple rows are present + updatePercentInputsVisibility(); + + // Re-run validation after adding a row + validatePaymentMethodPercentsAndShow(); }); document.addEventListener('click', function (event) { if (event.target.id === 'removePaymentMethod') { event.preventDefault(); event.target.parentElement.remove(); + // Update visibility after removal + updatePercentInputsVisibility(); + // Re-validate after removal to update the message and totals + validatePaymentMethodPercentsAndShow(); } }); @@ -781,7 +920,5 @@

Time Scheduled

}); document.getElementById('instrument-rates').innerHTML = LABKEY.Utils.textLink({text: 'View the current rates for instruments', href: LABKEY.ActionURL.buildURL('query', 'executeQuery', LABKEY.ActionURL.getContainer(), {schemaName: 'targetedms', 'query.queryName': 'InstrumentRate'})}); - }); - \ No newline at end of file diff --git a/src/org/labkey/targetedms/TargetedMSManager.java b/src/org/labkey/targetedms/TargetedMSManager.java index 46ef668b3..7af575ee6 100644 --- a/src/org/labkey/targetedms/TargetedMSManager.java +++ b/src/org/labkey/targetedms/TargetedMSManager.java @@ -689,6 +689,11 @@ public static TableInfo getTableInfoInstrumentSchedule() return getSchema().getTable(TargetedMSSchema.TABLE_INSTRUMENT_SCHEDULE); } + public static TableInfo getTableInfoInstrumentUsagePayment() + { + return getSchema().getTable(TargetedMSSchema.TABLE_INSTRUMENT_USAGE_PAYMENT); + } + /** @return rowId for pipeline job that will perform the import asynchronously */ public static Long addRunToQueue(ViewBackgroundInfo info, final Path path) throws XarFormatException, PipelineValidationException diff --git a/src/org/labkey/targetedms/TargetedMSSchema.java b/src/org/labkey/targetedms/TargetedMSSchema.java index 748540ab4..ac0e68e9e 100644 --- a/src/org/labkey/targetedms/TargetedMSSchema.java +++ b/src/org/labkey/targetedms/TargetedMSSchema.java @@ -17,6 +17,7 @@ package org.labkey.targetedms; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.json.JSONObject; @@ -50,7 +51,6 @@ import org.labkey.api.gwt.client.AuditBehaviorType; import org.labkey.api.module.Module; import org.labkey.api.query.CustomView; -import org.labkey.api.query.DefaultQueryUpdateService; import org.labkey.api.query.DefaultSchema; import org.labkey.api.query.DetailsURL; import org.labkey.api.query.ExprColumn; @@ -62,15 +62,12 @@ import org.labkey.api.query.QuerySchema; import org.labkey.api.query.QueryService; import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryUpdateService; import org.labkey.api.query.QueryView; import org.labkey.api.query.SchemaKey; import org.labkey.api.query.SimpleUserSchema; import org.labkey.api.query.UserIdQueryForeignKey; import org.labkey.api.query.UserSchema; import org.labkey.api.security.User; -import org.labkey.api.security.UserPrincipal; -import org.labkey.api.security.permissions.Permission; import org.labkey.api.targetedms.RepresentativeDataState; import org.labkey.api.targetedms.RunRepresentativeDataState; import org.labkey.api.util.ContainerContext; @@ -87,6 +84,8 @@ import org.labkey.targetedms.parser.Chromatogram; import org.labkey.targetedms.parser.ChromatogramBinaryFormat; import org.labkey.targetedms.parser.SkylineBinaryParser; +import org.labkey.targetedms.query.InstrumentScheduleTable; +import org.labkey.targetedms.query.InstrumentUsagePaymentTrigger; import org.labkey.targetedms.query.AnnotatedTargetedMSTable; import org.labkey.targetedms.query.CalibrationCurveTable; import org.labkey.targetedms.query.DocTransitionsTableInfo; @@ -111,6 +110,7 @@ import org.labkey.targetedms.query.QCTraceMetricValuesTable; import org.labkey.targetedms.query.RepresentativeStateDisplayColumn; import org.labkey.targetedms.query.SampleFileTable; +import org.labkey.targetedms.query.SimpleTargetedMSTable; import org.labkey.targetedms.query.SkylineAuditTable; import org.labkey.targetedms.query.TargetedMSCrosstabView; import org.labkey.targetedms.query.TargetedMSForeignKey; @@ -1602,36 +1602,31 @@ public DisplayColumn createRenderer(ColumnInfo colInfo) { return new QCEmailNotificationsTable(this, cf); } + + if (TABLE_INSTRUMENT_SCHEDULE.equalsIgnoreCase(name)) + { + return new InstrumentScheduleTable(this, cf); + } + if (TABLE_MS_PROJECT.equalsIgnoreCase(name) || TABLE_PROJECT_RESEARCHER.equalsIgnoreCase(name) || TABLE_MS_INSTRUMENT.equalsIgnoreCase(name) || TABLE_PAYMENT_METHOD.equalsIgnoreCase(name) || TABLE_PROJECT_PAYMENT_METHOD.equalsIgnoreCase(name) || - TABLE_INSTRUMENT_SCHEDULE.equalsIgnoreCase(name) || TABLE_RATE_TYPE.equalsIgnoreCase(name) || TABLE_INSTRUMENT_RATE.equalsIgnoreCase(name) || TABLE_INSTRUMENT_USAGE_PAYMENT.equalsIgnoreCase(name) || TABLE_INSTRUMENT_NICKNAME.equalsIgnoreCase(name)) { - var result = new FilteredTable<>(getSchema().getTable(name), this, cf) - { - @Override - public boolean hasPermission(@NotNull UserPrincipal user, @NotNull Class perm) - { - return getContainer().hasPermission(user, perm); - } - - @Override @NotNull - public QueryUpdateService getUpdateService() - { - return new DefaultQueryUpdateService(this, getRealTable()); - } - }; - result.wrapAllColumns(true); + var result = new SimpleTargetedMSTable(name, this, cf); if (TABLE_INSTRUMENT_NICKNAME.equalsIgnoreCase(name)) { result.setAuditBehavior(AuditBehaviorType.DETAILED); } + if (TABLE_INSTRUMENT_USAGE_PAYMENT.equalsIgnoreCase(name)) + { + result.addTriggerFactory((c, table, extraContext) -> List.of(new InstrumentUsagePaymentTrigger("InstrumentScheduleId"))); + } TargetedMSTable.fixupLookups(result); return result; } @@ -1862,12 +1857,12 @@ public QueryDefinition getQueryDef(@NotNull String queryName) { QueryDefinition result = super.getQueryDef(queryName); - if (result == null && StringUtils.startsWithIgnoreCase(queryName, QUERY_PTM_PERCENTS_PREFIX)) + if (result == null && Strings.CI.startsWith(queryName, QUERY_PTM_PERCENTS_PREFIX)) { result = createRunScopedPTMQuery(QUERY_PTM_PERCENTS_PREFIX, "PTMPercents", queryName); } - if (result == null && StringUtils.startsWithIgnoreCase(queryName, QUERY_PTM_PERCENTS_GROUPED_PREFIX)) + if (result == null && Strings.CI.startsWith(queryName, QUERY_PTM_PERCENTS_GROUPED_PREFIX)) { result = createRunScopedPTMQuery(QUERY_PTM_PERCENTS_GROUPED_PREFIX, "PTMPercentsGrouped", queryName); } diff --git a/src/org/labkey/targetedms/query/InstrumentScheduleTable.java b/src/org/labkey/targetedms/query/InstrumentScheduleTable.java new file mode 100644 index 000000000..9eb6ef354 --- /dev/null +++ b/src/org/labkey/targetedms/query/InstrumentScheduleTable.java @@ -0,0 +1,73 @@ +package org.labkey.targetedms.query; + +import org.jetbrains.annotations.NotNull; +import org.json.JSONArray; +import org.json.JSONObject; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.DefaultQueryUpdateService; +import org.labkey.api.query.DuplicateKeyException; +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.targetedms.TargetedMSSchema; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class InstrumentScheduleTable extends SimpleTargetedMSTable +{ + public InstrumentScheduleTable(TargetedMSSchema schema, ContainerFilter cf) + { + super(TargetedMSSchema.TABLE_INSTRUMENT_SCHEDULE, schema, cf); + addTriggerFactory((c, table, extraContext) -> List.of(new InstrumentUsagePaymentTrigger("Id"))); + } + + @Override + public @NotNull QueryUpdateService getUpdateService() + { + return new DefaultQueryUpdateService(this, getRealTable()) + { + @Override + protected Map insertRow(User user, Container container, Map row) throws DuplicateKeyException, ValidationException, QueryUpdateServiceException, SQLException + { + Map result = super.insertRow(user, container, row); + Object paymentInfo = row.get("UsagePayments"); + if (paymentInfo instanceof JSONArray a) + { + List> paymentRows = new ArrayList<>(); + for (Object o : a) + { + if (o instanceof JSONObject jsonObject) + { + Map rowMap = jsonObject.toMap(); + rowMap.put("InstrumentScheduleId", result.get("Id")); + paymentRows.add(rowMap); + } + } + + TableInfo paymentTable = getUserSchema().getTableOrThrow(TargetedMSSchema.TABLE_INSTRUMENT_USAGE_PAYMENT); + BatchValidationException errors = new BatchValidationException(); + try + { + paymentTable.getUpdateService().insertRows(getUserSchema().getUser(), getUserSchema().getContainer(), paymentRows, errors, null, null); + } + catch (BatchValidationException e) + { + throw e.getLastRowError(); + } + if (errors.hasErrors()) + { + throw errors.getLastRowError(); + } + } + return result; + } + }; + } +} diff --git a/src/org/labkey/targetedms/query/InstrumentUsagePaymentTrigger.java b/src/org/labkey/targetedms/query/InstrumentUsagePaymentTrigger.java new file mode 100644 index 000000000..8c08da313 --- /dev/null +++ b/src/org/labkey/targetedms/query/InstrumentUsagePaymentTrigger.java @@ -0,0 +1,86 @@ +package org.labkey.targetedms.query; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.ApiUsageException; +import org.labkey.api.data.Container; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SqlSelector; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.triggers.Trigger; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.ValidationException; +import org.labkey.api.security.User; +import org.labkey.targetedms.TargetedMSManager; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Ensures that all edits to instrument usage payments add up to 100%. + */ +public class InstrumentUsagePaymentTrigger implements Trigger +{ + private final Set _schedulesToCheck = new HashSet<>(); + private final String _instrumentScheduleColumnName; + + public InstrumentUsagePaymentTrigger(String instrumentScheduleColumnName) + { + _instrumentScheduleColumnName = instrumentScheduleColumnName; + } + + @Override + public void afterUpdate(TableInfo table, Container c, User user, @Nullable Map newRow, @Nullable Map oldRow, ValidationException errors, Map extraContext) throws ValidationException + { + trackChange(newRow); + } + + private void trackChange(Map row) throws ValidationException + { + Number number = (Number) row.get(_instrumentScheduleColumnName); + if (number == null) + { + throw new ValidationException(_instrumentScheduleColumnName + " cannot be null"); + } + _schedulesToCheck.add(number.intValue()); + } + + @Override + public void afterInsert(TableInfo table, Container c, User user, @Nullable Map newRow, ValidationException errors, Map extraContext, @Nullable Map existingRecord) throws ValidationException + { + trackChange(newRow); + } + + @Override + public void beforeDelete(TableInfo table, Container c, User user, @Nullable Map oldRow, ValidationException errors, Map extraContext) throws ValidationException + { + trackChange(oldRow); + } + + @Override + public void complete(TableInfo table, Container c, User user, TableInfo.TriggerType event, BatchValidationException errors, Map extraContext) + { + table.getSchema().getScope().addCommitTask(() -> + { + SQLFragment sql = new SQLFragment("SELECT * FROM (\n"); + sql.append("SELECT COUNT(*) AS C, s.Id, SUM(PercentPayment) AS PercentTotal FROM "); + sql.append(TargetedMSManager.getTableInfoInstrumentSchedule(), "s"); + sql.append(" LEFT OUTER JOIN "); + sql.append(TargetedMSManager.getTableInfoInstrumentUsagePayment(), "iup"); + sql.append(" ON s.Id = iup.InstrumentScheduleId\n"); + sql.append(" GROUP BY s.Id\n"); + sql.append(") x\n"); + sql.append(" WHERE (PercentTotal != 100 OR PercentTotal IS NULL) AND Id "); + sql.appendInClause(_schedulesToCheck, table.getSchema().getSqlDialect()); + + Collection> rows = new SqlSelector(TargetedMSManager.getSchema(), sql).getMapCollection(); + + if (!rows.isEmpty()) + { + throw new ApiUsageException("Instrument usage payments do not add up to 100%"); + } + }, DbScope.CommitTaskOption.PRECOMMIT); + } +} diff --git a/src/org/labkey/targetedms/query/SimpleTargetedMSTable.java b/src/org/labkey/targetedms/query/SimpleTargetedMSTable.java new file mode 100644 index 000000000..79309419e --- /dev/null +++ b/src/org/labkey/targetedms/query/SimpleTargetedMSTable.java @@ -0,0 +1,32 @@ +package org.labkey.targetedms.query; + +import org.jetbrains.annotations.NotNull; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.query.DefaultQueryUpdateService; +import org.labkey.api.query.FilteredTable; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.security.UserPrincipal; +import org.labkey.api.security.permissions.Permission; +import org.labkey.targetedms.TargetedMSSchema; + +public class SimpleTargetedMSTable extends FilteredTable +{ + public SimpleTargetedMSTable(String name, TargetedMSSchema schema, ContainerFilter cf) + { + super(TargetedMSSchema.getSchema().getTable(name), schema, cf); + wrapAllColumns(true); + } + + @Override + public boolean hasPermission(@NotNull UserPrincipal user, @NotNull Class perm) + { + return getContainer().hasPermission(user, perm); + } + + @Override + @NotNull + public QueryUpdateService getUpdateService() + { + return new DefaultQueryUpdateService(this, getRealTable()); + } +} From d3321c04d97050e36060aaeaa7ff1c454a8e116f Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Thu, 30 Oct 2025 09:30:04 -0700 Subject: [PATCH 02/12] Checkpoint - server-side validation, project-scoped permissions --- resources/views/scheduleInstrument.html | 324 ++++++++++-------- .../labkey/targetedms/TargetedMSSchema.java | 33 +- .../query/AdminSchedulingTable.java | 37 ++ .../InstrumentScheduleOverlapTrigger.java | 92 +++++ .../query/InstrumentScheduleTable.java | 7 +- .../query/OwnProjectSchedulingTable.java | 151 ++++++++ .../targetedms/query/TargetedMSTable.java | 4 +- 7 files changed, 492 insertions(+), 156 deletions(-) create mode 100644 src/org/labkey/targetedms/query/AdminSchedulingTable.java create mode 100644 src/org/labkey/targetedms/query/InstrumentScheduleOverlapTrigger.java create mode 100644 src/org/labkey/targetedms/query/OwnProjectSchedulingTable.java diff --git a/resources/views/scheduleInstrument.html b/resources/views/scheduleInstrument.html index 9ceacdfce..fad60226e 100644 --- a/resources/views/scheduleInstrument.html +++ b/resources/views/scheduleInstrument.html @@ -4,14 +4,14 @@ - - - - + + + +
@@ -46,14 +46,10 @@
-
-
- -
-
-
- -
+
+ + +
@@ -68,76 +64,40 @@
-
-
-
- - - - - - - -