diff --git a/resources/queries/targetedms/InstrumentBilling.sql b/resources/queries/targetedms/InstrumentBilling.sql index debe1ba72..d9cb727f5 100644 --- a/resources/queries/targetedms/InstrumentBilling.sql +++ b/resources/queries/targetedms/InstrumentBilling.sql @@ -28,8 +28,9 @@ SELECT TIMESTAMPDIFF('SQL_TSI_HOUR', StartTime, EndTime) * ir.Fee + ir.rateType.setupFee AS TotalCost, iup.PaymentMethod.UWBudgetNumber AS Payment_Method, iup.PaymentMethod.Name AS Payment_Method_Name, - iup.PercentPayment + iup.PercentPayment, + ((TIMESTAMPDIFF('SQL_TSI_HOUR', StartTime, EndTime) * Fee + ir.rateType.setupFee) * PercentPayment / 100) AS AmountBilled FROM targetedms.InstrumentSchedule i -LEFT OUTER JOIN targetedms.InstrumentRate ir ON i.Instrument = ir.Instrument -LEFT OUTER JOIN targetedms.InstrumentUsagePayment iup ON i.Id = iup.InstrumentScheduleId \ No newline at end of file +INNER JOIN targetedms.InstrumentRate ir ON i.Instrument = ir.Instrument +INNER JOIN targetedms.InstrumentUsagePayment iup ON i.Id = iup.InstrumentScheduleId \ No newline at end of file diff --git a/resources/queries/targetedms/InstrumentBillingByMonth.sql b/resources/queries/targetedms/InstrumentBillingByMonth.sql index 07021015c..eda019381 100644 --- a/resources/queries/targetedms/InstrumentBillingByMonth.sql +++ b/resources/queries/targetedms/InstrumentBillingByMonth.sql @@ -31,7 +31,8 @@ SELECT Payment_Method, Payment_Method_Name, PercentPayment, - ((HoursInRange * Fee + Setup_Cost) * PercentPayment / 100) AS AmountBilled + -- Only include the setup fee when the beginning of the reservation falls within the report's time window + ((HoursInRange * Fee + (CASE WHEN StartDate <= StartBillDate THEN 0 ELSE Setup_Cost END)) * PercentPayment / 100) AS AmountBilled FROM (SELECT diff --git a/resources/queries/targetedms/instrumentBilling.query.xml b/resources/queries/targetedms/instrumentBilling.query.xml index 736fbee64..93d083bfc 100644 --- a/resources/queries/targetedms/instrumentBilling.query.xml +++ b/resources/queries/targetedms/instrumentBilling.query.xml @@ -2,6 +2,7 @@ + Instrument Billing 0.0 @@ -9,6 +10,9 @@ $#,##0.00 + + $#,##0.00 +
diff --git a/resources/queries/targetedms/instrumentBillingByMonth.query.xml b/resources/queries/targetedms/instrumentBillingByMonth.query.xml index 78f428b89..d0a052d6b 100644 --- a/resources/queries/targetedms/instrumentBillingByMonth.query.xml +++ b/resources/queries/targetedms/instrumentBillingByMonth.query.xml @@ -2,6 +2,7 @@ + Instrument Billing by Month 0.0 diff --git a/resources/queries/targetedms/userProjects.query.xml b/resources/queries/targetedms/userProjects.query.xml deleted file mode 100644 index 04a9b3ec4..000000000 --- a/resources/queries/targetedms/userProjects.query.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - -
- - -
-
-
- \ No newline at end of file diff --git a/resources/queries/targetedms/userProjects.sql b/resources/queries/targetedms/userProjects.sql deleted file mode 100644 index 3eacfeda2..000000000 --- a/resources/queries/targetedms/userProjects.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - p.id AS Id, - p.title AS Title, - p.submitDate AS SubmitDate, - p.collaborationStatus AS CollaborationStatus -FROM msProject p WHERE p.id IN (SELECT project FROM projectresearcher pr WHERE pr.researcher = USERID()) diff --git a/resources/schemas/dbscripts/postgresql/targetedms-25.006-25.007.sql b/resources/schemas/dbscripts/postgresql/targetedms-25.006-25.007.sql new file mode 100644 index 000000000..16d453688 --- /dev/null +++ b/resources/schemas/dbscripts/postgresql/targetedms-25.006-25.007.sql @@ -0,0 +1,7 @@ +-- Don't allow duplicate project/researcher mapping rows +DELETE FROM targetedms.projectresearcher WHERE Id NOT IN (SELECT MIN(Id) FROM targetedms.projectresearcher GROUP BY project, researcher); + +ALTER TABLE targetedms.projectResearcher + ADD CONSTRAINT UQ_projectResearcher_project_researcher UNIQUE (project, researcher); + +DROP INDEX targetedms.IDX_projectResearcher_Project; diff --git a/resources/views/instrumentSchedulingAdmin.html b/resources/views/instrumentSchedulingAdmin.html index af6892462..5ecac796f 100644 --- a/resources/views/instrumentSchedulingAdmin.html +++ b/resources/views/instrumentSchedulingAdmin.html @@ -12,6 +12,4 @@
Edit rate types (setup fee)
Edit rate list (assigns a setup fee and an hourly rate to instruments)
Edit project list
-
Edit project/researcher mapping (attaches users to projects)
Edit payment method list
-
Edit project/payment method mapping
\ No newline at end of file diff --git a/resources/views/msProject.html b/resources/views/msProject.html index b89ba8ae7..fd66b3447 100644 --- a/resources/views/msProject.html +++ b/resources/views/msProject.html @@ -7,8 +7,11 @@ renderTo: 'ms-project', frame: 'none', schemaName: 'targetedms', - queryName: 'userProjects', - containerFilter: LABKEY.Query.containerFilter.currentAndSubfolders + queryName: 'msProject', + containerFilter: LABKEY.Query.containerFilter.currentAndSubfolders, + filters: [ + LABKEY.Filter.create('ProjectMember', true) + ] }; // admins can see all projects, non-admins can only see projects they are a researcher on new LABKEY.QueryWebPart(config); diff --git a/resources/views/msProjectDetails.html b/resources/views/msProjectDetails.html index 401305d45..cfef9843e 100644 --- a/resources/views/msProjectDetails.html +++ b/resources/views/msProjectDetails.html @@ -1,12 +1,9 @@ -
-
-
-
+
+

-
-
-
-
+
+
+
\ No newline at end of file + \ No newline at end of file diff --git a/resources/views/scheduleInstrument.view.xml b/resources/views/scheduleInstrument.view.xml index 66bbcc043..5cd52d2e0 100644 --- a/resources/views/scheduleInstrument.view.xml +++ b/resources/views/scheduleInstrument.view.xml @@ -1,8 +1,8 @@ - + + - \ 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/TargetedMSModule.java b/src/org/labkey/targetedms/TargetedMSModule.java index c0b7bd85d..0a087b69c 100644 --- a/src/org/labkey/targetedms/TargetedMSModule.java +++ b/src/org/labkey/targetedms/TargetedMSModule.java @@ -231,7 +231,7 @@ public String getName() @Override public Double getSchemaVersion() { - return 25.006; + return 25.007; } @Override diff --git a/src/org/labkey/targetedms/TargetedMSSchema.java b/src/org/labkey/targetedms/TargetedMSSchema.java index 748540ab4..db2ef2cb7 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,13 @@ 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.security.permissions.AdminPermission; import org.labkey.api.targetedms.RepresentativeDataState; import org.labkey.api.targetedms.RunRepresentativeDataState; import org.labkey.api.util.ContainerContext; @@ -87,6 +85,9 @@ import org.labkey.targetedms.parser.Chromatogram; import org.labkey.targetedms.parser.ChromatogramBinaryFormat; import org.labkey.targetedms.parser.SkylineBinaryParser; +import org.labkey.targetedms.query.AdminSchedulingTable; +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; @@ -97,12 +98,14 @@ import org.labkey.targetedms.query.MoleculePrecursorTableInfo; import org.labkey.targetedms.query.MoleculeTableInfo; import org.labkey.targetedms.query.MoleculeTransitionsTableInfo; +import org.labkey.targetedms.query.OwnProjectSchedulingTable; import org.labkey.targetedms.query.PTMPercentsGroupedCustomizer; import org.labkey.targetedms.query.PeptideIsotopeModificationTableInfo; import org.labkey.targetedms.query.PeptideStructuralModificationTableInfo; import org.labkey.targetedms.query.PeptideTableInfo; import org.labkey.targetedms.query.PrecursorChromInfoTable; import org.labkey.targetedms.query.PrecursorTableInfo; +import org.labkey.targetedms.query.ProjectAddUserTrigger; import org.labkey.targetedms.query.QCAnnotationTable; import org.labkey.targetedms.query.QCAnnotationTypeTable; import org.labkey.targetedms.query.QCEnabledMetricsTable; @@ -111,6 +114,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; @@ -1598,41 +1602,78 @@ public DisplayColumn createRenderer(ColumnInfo colInfo) return new SimpleUserSchema.SimpleTable<>(this, getSchema().getTable(TABLE_PEPTIDE_MOLECULE_PRECURSOR_EXCLUSION), cf).init(); } - if(TABLE_QC_EMAIL_NOTIFICATIONS.equalsIgnoreCase(name)) + if (TABLE_QC_EMAIL_NOTIFICATIONS.equalsIgnoreCase(name)) { return new QCEmailNotificationsTable(this, cf); } - if (TABLE_MS_PROJECT.equalsIgnoreCase(name) || - TABLE_PROJECT_RESEARCHER.equalsIgnoreCase(name) || - TABLE_MS_INSTRUMENT.equalsIgnoreCase(name) || + + if (TABLE_INSTRUMENT_NICKNAME.equalsIgnoreCase(name)) + { + var result = new SimpleTargetedMSTable(name, this, cf); + result.setAuditBehavior(AuditBehaviorType.DETAILED); + return result; + } + + if (TABLE_INSTRUMENT_SCHEDULE.equalsIgnoreCase(name)) + { + return new InstrumentScheduleTable(this, cf); + } + + if (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_RATE.equalsIgnoreCase(name)) + { + return new AdminSchedulingTable(name, this, cf); + } + + if (TABLE_MS_PROJECT.equalsIgnoreCase(name) || + TABLE_PROJECT_RESEARCHER.equalsIgnoreCase(name) || TABLE_INSTRUMENT_USAGE_PAYMENT.equalsIgnoreCase(name) || - TABLE_INSTRUMENT_NICKNAME.equalsIgnoreCase(name)) + TABLE_PROJECT_PAYMENT_METHOD.equalsIgnoreCase(name)) { - var result = new FilteredTable<>(getSchema().getTable(name), this, cf) + var result = new OwnProjectSchedulingTable(name, this, cf, !TABLE_MS_PROJECT.equalsIgnoreCase(name)); + if (TABLE_INSTRUMENT_USAGE_PAYMENT.equalsIgnoreCase(name)) { - @Override - public boolean hasPermission(@NotNull UserPrincipal user, @NotNull Class perm) - { - return getContainer().hasPermission(user, perm); - } + result.addTriggerFactory((c, table, extraContext) -> List.of(new InstrumentUsagePaymentTrigger("InstrumentScheduleId"))); + } + TargetedMSTable.fixupLookups(result); - @Override @NotNull - public QueryUpdateService getUpdateService() + if (TABLE_MS_PROJECT.equalsIgnoreCase(name)) + { + SQLFragment projectMemberSql = new SQLFragment("EXISTS (SELECT Project FROM "); + projectMemberSql.append(TargetedMSManager.getTableInfoProjectResearcher(), "pr"); + projectMemberSql.append(" WHERE pr.researcher = ? AND pr.project = "); + projectMemberSql.add(getUser().getUserId()); + projectMemberSql.append(ExprColumn.STR_TABLE_ALIAS).append(".Id)"); + + ExprColumn projectMemberCol = new ExprColumn(result, "ProjectMember", projectMemberSql, JdbcType.BOOLEAN); + result.addColumn(projectMemberCol); + + // For non-admins, completely hide projects they're not associated with + if (!getContainer().hasPermission(getUser(), AdminPermission.class)) { - return new DefaultQueryUpdateService(this, getRealTable()); + result.addCondition(projectMemberSql, FieldKey.fromParts("Id")); } - }; - result.wrapAllColumns(true); - if (TABLE_INSTRUMENT_NICKNAME.equalsIgnoreCase(name)) + result.addTriggerFactory((c, table, extraContext) -> List.of(new ProjectAddUserTrigger())); + } + else if (TABLE_INSTRUMENT_USAGE_PAYMENT.equalsIgnoreCase(name)) { - result.setAuditBehavior(AuditBehaviorType.DETAILED); + // For non-admins, completely hide payment data for projects they're not associated with + if (!getContainer().hasPermission(getUser(), AdminPermission.class)) + { + SQLFragment projectMemberSql = new SQLFragment("InstrumentScheduleId IN (SELECT i.Id FROM "); + projectMemberSql.append(TargetedMSManager.getTableInfoInstrumentSchedule(), "i"); + projectMemberSql.append(" INNER JOIN "); + projectMemberSql.append(TargetedMSManager.getTableInfoProjectResearcher(), "pr"); + projectMemberSql.append(" ON i.Project = pr.Project AND pr.researcher = ?)"); + projectMemberSql.add(getUser().getUserId()); + result.addCondition(projectMemberSql, FieldKey.fromParts("Project")); + } + } - TargetedMSTable.fixupLookups(result); + + return result; } @@ -1862,12 +1903,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/AdminSchedulingTable.java b/src/org/labkey/targetedms/query/AdminSchedulingTable.java new file mode 100644 index 000000000..814f2431d --- /dev/null +++ b/src/org/labkey/targetedms/query/AdminSchedulingTable.java @@ -0,0 +1,37 @@ +package org.labkey.targetedms.query; + +import org.jetbrains.annotations.NotNull; +import org.labkey.api.data.ContainerFilter; +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.targetedms.TargetedMSSchema; + +/** Tables that only folder admins or higher should be able to modify */ +public class AdminSchedulingTable extends SimpleTargetedMSTable +{ + public AdminSchedulingTable(String name, TargetedMSSchema schema, ContainerFilter cf) + { + super(name, schema, cf); + } + + @Override + public boolean hasPermission(@NotNull UserPrincipal user, @NotNull Class perm) + { + // Admins can do it all + if (getUserSchema().getContainer().hasPermission(user, AdminPermission.class)) + { + return true; + } + + // Anyone else with folder permissions can read + if (perm.equals(ReadPermission.class)) + { + return super.hasPermission(user, perm); + } + + // but nothing else + return false; + } +} diff --git a/src/org/labkey/targetedms/query/InstrumentScheduleOverlapTrigger.java b/src/org/labkey/targetedms/query/InstrumentScheduleOverlapTrigger.java new file mode 100644 index 000000000..becfe99d9 --- /dev/null +++ b/src/org/labkey/targetedms/query/InstrumentScheduleOverlapTrigger.java @@ -0,0 +1,95 @@ +package org.labkey.targetedms.query; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +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.ValidationException; +import org.labkey.api.security.User; +import org.labkey.targetedms.TargetedMSManager; + +import java.sql.Timestamp; +import java.util.Date; +import java.util.Map; + +/** + * Prevents overlapping instrument schedule entries for the same instrument and that the instrument is active. + */ +public class InstrumentScheduleOverlapTrigger implements Trigger +{ + @Override + public void beforeInsert(TableInfo table, Container c, User user, Map newRow, ValidationException errors, Map extraContext) throws ValidationException + { + validateNoOverlap(newRow, null); + Number instrument = (Number) newRow.get("Instrument"); + SqlSelector selector = new SqlSelector(TargetedMSManager.getSchema(), "SELECT Id FROM " + TargetedMSManager.getTableInfoMSInstrument() + " WHERE Id = ? AND Active = ?", instrument, true); + if (!selector.exists()) + { + throw new ValidationException("Instrument does not exist or is not active"); + } + } + + @Override + public void beforeUpdate(TableInfo table, Container c, User user, @Nullable Map newRow, @Nullable Map oldRow, ValidationException errors, Map extraContext) throws ValidationException + { + // Use the Id from either newRow or oldRow to exclude the current record + Integer id = getId(newRow); + if (id == null) + id = getId(oldRow); + validateNoOverlap(newRow, id); + } + + private void validateNoOverlap(Map row, @Nullable Integer excludeId) throws ValidationException + { + Number instrument = (Number) row.get("Instrument"); + Date start = (Date) row.get("StartTime"); + Date end = (Date) row.get("EndTime"); + + if (instrument == null) + { + throw new ValidationException("Instrument is required"); + } + if (start == null || end == null) + { + throw new ValidationException("StartTime and EndTime are required"); + } + if (!start.before(end)) + { + throw new ValidationException("StartTime must be before EndTime"); + } + + // Overlap condition: existing.start < new.end AND existing.end > new.start + SQLFragment sql = new SQLFragment(); + sql.append("SELECT s.Id FROM "); + sql.append(TargetedMSManager.getTableInfoInstrumentSchedule(), "s"); + sql.append(" WHERE s.Instrument = ? AND s.StartTime < ? AND s.EndTime > ?"); + sql.add(instrument.intValue()); + sql.add(new Timestamp(end.getTime())); + sql.add(new Timestamp(start.getTime())); + + if (excludeId != null) + { + sql.append(" AND s.Id != ?"); + sql.add(excludeId); + } + + boolean overlapExists = new SqlSelector(TargetedMSManager.getSchema(), sql).exists(); + if (overlapExists) + { + throw new ValidationException("Instrument schedule overlaps with an existing reservation for this instrument"); + } + } + + @Nullable + private Integer getId(@Nullable Map row) + { + if (row == null) + return null; + Object o = row.get("Id"); + if (o instanceof Number n) + return n.intValue(); + return null; + } +} diff --git a/src/org/labkey/targetedms/query/InstrumentScheduleTable.java b/src/org/labkey/targetedms/query/InstrumentScheduleTable.java new file mode 100644 index 000000000..c55e3a76f --- /dev/null +++ b/src/org/labkey/targetedms/query/InstrumentScheduleTable.java @@ -0,0 +1,76 @@ +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 OwnProjectSchedulingTable +{ + public InstrumentScheduleTable(TargetedMSSchema schema, ContainerFilter cf) + { + super(TargetedMSSchema.TABLE_INSTRUMENT_SCHEDULE, schema, cf, true); + addTriggerFactory((c, table, extraContext) -> List.of( + new InstrumentUsagePaymentTrigger("Id"), + new InstrumentScheduleOverlapTrigger() + )); + } + + @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..9a58eb81c --- /dev/null +++ b/src/org/labkey/targetedms/query/InstrumentUsagePaymentTrigger.java @@ -0,0 +1,106 @@ +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%"); + } + + for (int scheduleId : _schedulesToCheck) + { + SQLFragment wrongProjectSql = new SQLFragment("SELECT * FROM "); + wrongProjectSql.append(TargetedMSManager.getTableInfoInstrumentUsagePayment(), "iup"); + wrongProjectSql.append(" INNER JOIN "); + wrongProjectSql.append(TargetedMSManager.getTableInfoInstrumentSchedule(), "s"); + wrongProjectSql.append(" ON iup.InstrumentScheduleId = s.Id\n"); + wrongProjectSql.append(" WHERE iup.InstrumentScheduleId = ? AND iup.PaymentMethod NOT IN (SELECT PaymentMethod FROM "); + wrongProjectSql.add(scheduleId); + wrongProjectSql.append(TargetedMSManager.getTableInfoProjectPaymentMethod(), "ppm"); + wrongProjectSql.append(" WHERE ppm.Project = s.Project)"); + + if (new SqlSelector(TargetedMSManager.getSchema(), wrongProjectSql).exists()) + { + throw new ApiUsageException("Instrument usage payments are not using a payment method that is configured for the project."); + } + } + + + }, DbScope.CommitTaskOption.PRECOMMIT); + } +} diff --git a/src/org/labkey/targetedms/query/OwnProjectSchedulingTable.java b/src/org/labkey/targetedms/query/OwnProjectSchedulingTable.java new file mode 100644 index 000000000..28ba0a57a --- /dev/null +++ b/src/org/labkey/targetedms/query/OwnProjectSchedulingTable.java @@ -0,0 +1,164 @@ +package org.labkey.targetedms.query; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +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.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.DeletePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.targetedms.TargetedMSManager; +import org.labkey.targetedms.TargetedMSSchema; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class OwnProjectSchedulingTable extends SimpleTargetedMSTable +{ + private final boolean _allowCollaboratorsToInsert; + + public OwnProjectSchedulingTable(String name, TargetedMSSchema schema, ContainerFilter cf, boolean allowCollaboratorsToInsert) + { + super(name, schema, cf); + _allowCollaboratorsToInsert = allowCollaboratorsToInsert; + addTriggerFactory((c, table, extraContext) -> List.of(new OwnProjectTrigger())); + } + + @Override + public boolean hasPermission(@NotNull UserPrincipal user, @NotNull Class perm) + { + if (isAdmin()) + { + return true; + } + + // Collaborators shouldn't be able to insert new projects but should be able to edit ones they're attached to + if (perm.equals(InsertPermission.class) && !_allowCollaboratorsToInsert) + { + return isLabMember(); + } + + // Let lab members and collaborators past the initial check for insert/update/delete permission and enforce + // row-level permissions in the UpdateService + return (perm.equals(InsertPermission.class) || perm.equals(UpdatePermission.class) || + perm.equals(DeletePermission.class) || perm.equals(ReadPermission.class)) + && (isLabMember() || isExternalCollaborator()); + } + + private boolean isAdmin() + { + return getUserSchema().getContainer().hasPermission(getUserSchema().getUser(), AdminPermission.class); + } + + private boolean isLabMember() + { + // Check for key permissions for editors + return getUserSchema().getContainer().hasPermission(getUserSchema().getUser(), InsertPermission.class) && + getUserSchema().getContainer().hasPermission(getUserSchema().getUser(), UpdatePermission.class) && + getUserSchema().getContainer().hasPermission(getUserSchema().getUser(), DeletePermission.class); + } + + private boolean isExternalCollaborator() + { + // Check for key permissions submitters + return getUserSchema().getContainer().hasPermission(getUserSchema().getUser(), InsertPermission.class); + } + + private class OwnProjectTrigger implements Trigger + { + private Set _projectIds; + + private void validateProject(Integer projectId) throws ValidationException + { + // Admins can make changes to any project. Others need to be members + if (!getUserSchema().getContainer().hasPermission(getUserSchema().getUser(), AdminPermission.class)) + { + if (_projectIds == null) + { + _projectIds = new HashSet<>(new SqlSelector(getSchema(), + new SQLFragment("SELECT Project FROM "). + append(TargetedMSManager.getTableInfoProjectResearcher(), "pr"). + append(" WHERE Researcher = ?"). + add(getUserSchema().getUser().getUserId())).getArrayList(Integer.class)); + } + if (projectId == null || !_projectIds.contains(projectId)) + { + throw new ValidationException("User is not a member of the project"); + } + } + } + + @Nullable + private Integer getInteger(@Nullable Map row, String key) + { + if (row == null) + return null; + Object o = row.get(key); + if (o instanceof Number n) + return n.intValue(); + return null; + } + + + @Override + public void beforeInsert(TableInfo table, Container c, User user, @Nullable Map newRow, ValidationException errors, Map extraContext) throws ValidationException + { + checkRowLevelPermission(newRow, true); + } + + private void checkRowLevelPermission(@Nullable Map newRow, boolean insert) throws ValidationException + { + String tableName = OwnProjectSchedulingTable.this.getName(); + if (TargetedMSSchema.TABLE_MS_PROJECT.equalsIgnoreCase(tableName)) + { + if (!insert) + { + validateProject(getInteger(newRow, "id")); + } + } + if (TargetedMSSchema.TABLE_INSTRUMENT_SCHEDULE.equalsIgnoreCase(tableName) || + TargetedMSSchema.TABLE_PROJECT_RESEARCHER.equalsIgnoreCase(tableName)) + { + validateProject(getInteger(newRow, "project")); + + } + if (TargetedMSSchema.TABLE_INSTRUMENT_USAGE_PAYMENT.equalsIgnoreCase(tableName)) + { + Integer scheduleId = getInteger(newRow, "InstrumentScheduleId"); + if (scheduleId != null) // A null will violate the non-null constraint, so we don't need to report an error + { + Integer project = new SqlSelector(getSchema(), + new SQLFragment("SELECT Project FROM "). + append(TargetedMSManager.getTableInfoInstrumentSchedule()). + append(" WHERE Id = ?").add(scheduleId)).getObject(Integer.class); + validateProject(project); + } + } + } + + @Override + public void beforeUpdate(TableInfo table, Container c, User user, @Nullable Map newRow, @Nullable Map oldRow, ValidationException errors, Map extraContext) throws ValidationException + { + checkRowLevelPermission(oldRow, false); + checkRowLevelPermission(newRow, false); + } + + @Override + public void beforeDelete(TableInfo table, Container c, User user, @Nullable Map oldRow, ValidationException errors, Map extraContext) throws ValidationException + { + checkRowLevelPermission(oldRow, false); + } + } +} diff --git a/src/org/labkey/targetedms/query/ProjectAddUserTrigger.java b/src/org/labkey/targetedms/query/ProjectAddUserTrigger.java new file mode 100644 index 000000000..7e0e7c41a --- /dev/null +++ b/src/org/labkey/targetedms/query/ProjectAddUserTrigger.java @@ -0,0 +1,31 @@ +package org.labkey.targetedms.query; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.Container; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.triggers.Trigger; +import org.labkey.api.query.ValidationException; +import org.labkey.api.security.User; +import org.labkey.targetedms.TargetedMSManager; + +import java.util.HashMap; +import java.util.Map; + +public class ProjectAddUserTrigger implements Trigger +{ + + @Override + public void afterInsert(TableInfo table, Container c, + User user, @Nullable Map newRow, + ValidationException errors, Map extraContext, @Nullable Map existingRecord) + { + // Add the creating user to the membership list + Map membership = new HashMap<>(); + membership.put("Project", newRow.get("Id")); + membership.put("Researcher", user.getUserId()); + membership.put("Container", c.getId()); + + Table.insert(user, TargetedMSManager.getTableInfoProjectResearcher(), membership); + } +} diff --git a/src/org/labkey/targetedms/query/SimpleTargetedMSTable.java b/src/org/labkey/targetedms/query/SimpleTargetedMSTable.java new file mode 100644 index 000000000..50bc9e16f --- /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.QueryUpdateService; +import org.labkey.api.query.SimpleUserSchema; +import org.labkey.api.security.UserPrincipal; +import org.labkey.api.security.permissions.Permission; +import org.labkey.targetedms.TargetedMSSchema; + +public class SimpleTargetedMSTable extends SimpleUserSchema.SimpleTable +{ + public SimpleTargetedMSTable(String name, TargetedMSSchema schema, ContainerFilter cf) + { + super(schema, TargetedMSSchema.getSchema().getTable(name), 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()); + } +} diff --git a/src/org/labkey/targetedms/query/TargetedMSTable.java b/src/org/labkey/targetedms/query/TargetedMSTable.java index d1d9845d6..271bed733 100644 --- a/src/org/labkey/targetedms/query/TargetedMSTable.java +++ b/src/org/labkey/targetedms/query/TargetedMSTable.java @@ -81,7 +81,7 @@ public static void fixupLookups(FilteredTable table) for (var columnInfo : table.getMutableColumns()) { ForeignKey fk = columnInfo.getFk(); - if (fk != null && TargetedMSSchema.SCHEMA_NAME.equalsIgnoreCase(fk.getLookupSchemaName())) + if (fk != null && TargetedMSSchema.SCHEMA_KEY.equals(fk.getLookupSchemaKey())) { columnInfo.setFk(new QueryForeignKey(table.getUserSchema(), table.getContainerFilter(), table.getUserSchema(), null, fk.getLookupTableName(), fk.getLookupColumnName(), fk.getLookupDisplayName())); } @@ -144,7 +144,7 @@ public boolean hasPermission(@NotNull UserPrincipal user, @NotNull Class> paymentMethods = paymentMethodInsert.execute(createDefaultConnection(), getProjectName()).getRows(); InsertRowsCommand projectPaymentMethodInsert = new InsertRowsCommand("targetedms", "projectPaymentMethod"); projectPaymentMethodInsert.setRows(Arrays.asList( Map.of("PaymentMethod", paymentMethods.get(0).get("Id"), "Project", projects.get(0).get("Id")), + Map.of("PaymentMethod", paymentMethods.get(0).get("Id"), "Project", projects.get(1).get("Id")), Map.of("PaymentMethod", paymentMethods.get(1).get("Id"), "Project", projects.get(1).get("Id")) )); List> projectPaymentMethods = projectPaymentMethodInsert.execute(createDefaultConnection(), getProjectName()).getRows(); - int currentUserId = new WhoAmICommand().execute(createDefaultConnection(), getProjectName()).getUserId().intValue(); - InsertRowsCommand projectResearcherInsert = new InsertRowsCommand("targetedms", "projectResearcher"); projectResearcherInsert.setRows(Arrays.asList( - Map.of("Project", projects.get(0).get("Id"), "Researcher", schedulerUser1Id), - Map.of("Project", projects.get(1).get("Id"), "Researcher", schedulerUser1Id), - Map.of("Project", projects.get(1).get("Id"), "Researcher", schedulerUser2Id), - Map.of("Project", projects.get(0).get("Id"), "Researcher", currentUserId), - Map.of("Project", projects.get(1).get("Id"), "Researcher", currentUserId) + Map.of("Project", projects.get(0).get("Id"), "Researcher", labMemberUserId), + Map.of("Project", projects.get(1).get("Id"), "Researcher", labMemberUserId), + Map.of("Project", projects.get(1).get("Id"), "Researcher", collaboratorUserId) )); List> projectResearchers = projectResearcherInsert.execute(createDefaultConnection(), getProjectName()).getRows(); @@ -125,8 +144,19 @@ private void doInit() throws IOException, CommandException List> instrumentRates = instrumentRateInsert.execute(createDefaultConnection(), getProjectName()).getRows(); } + @Override + protected void doCleanup(boolean afterTest) throws TestTimeoutException + { + // these tests use the UIContainerHelper for project creation, but we can use the APIContainerHelper for deletion + APIContainerHelper apiContainerHelper = new APIContainerHelper(this); + apiContainerHelper.deleteProject(getProjectName(), afterTest); + + _userHelper.deleteUsers(false, LAB_MEMBER_USER); + _userHelper.deleteUsers(false, EXTERNAL_COLLABORATOR_USER); + } + @Test - public void testSchedule() + public void testSchedule() throws IOException, CommandException { goToProjectHome(); clickAndWait(Locator.linkWithText("Your project list")); @@ -134,38 +164,123 @@ public void testSchedule() clickAndWait(Locator.linkWithText(PROJECT_1)); waitAndClickAndWait(Locator.linkWithText("Schedule instrument time")); - String yearMonth = Calendar.getInstance().get(Calendar.YEAR) + "-"; + assertTrue("Wrong instrument list", waitFor(() -> Arrays.asList(INSTRUMENT_1, INSTRUMENT_2).equals(getSelectOptions(INSTRUMENT_DROP_DOWN)), 5_000)); + assertTrue("Wrong payment method list", waitFor(() -> Arrays.asList(PAYMENT_METHOD_1).equals(getSelectOptions(PAYMENT_METHOD_DROP_DOWNS)), 5_000)); + int month = (Calendar.getInstance().get(Calendar.MONTH) + 1); - if (month < 10) - { - yearMonth += yearMonth; - } - yearMonth += month; + String yearMonth = Calendar.getInstance().get(Calendar.YEAR) + "-" + (month < 10 ? "0" + month : "" + month); - scheduleInstrument(yearMonth + "-02"); - scheduleInstrument(yearMonth + "-03"); + scheduleInstrument(yearMonth + "-02", false); + scheduleInstrument(yearMonth + "-03", false, () -> + { + String originalStart = getFormElement(START_DATE_TIME_FIELD.findElement(getDriver())); + String originalEnd = getFormElement(END_DATE_TIME_FIELD.findElement(getDriver())); + // Try scheduling over the first reservation and verify it is blocked + setFormElement(START_DATE_TIME_FIELD.findElement(getDriver()), originalStart.replace("-03T", "-02T")); + setFormElement(END_DATE_TIME_FIELD.findElement(getDriver()), originalEnd.replace("-03T", "-02T")); + waitAndClick(Locator.button("Save")); + waitForText("Error saving. Instrument schedule overlaps with an existing reservation for this instrument"); + setFormElement(START_DATE_TIME_FIELD.findElement(getDriver()), originalStart); + waitForText("End date must be after start date."); + setFormElement(END_DATE_TIME_FIELD.findElement(getDriver()), originalEnd); + }); scheduleInstrument(yearMonth + "-03", true); - scheduleInstrument(yearMonth + "-03"); + scheduleInstrument(yearMonth + "-03", false); assertProjectEventCounts(2, 0); - sleep(1000); - doAndWaitForPageToLoad(() -> selectOptionByText(Locator.id("projectDropDown"), PROJECT_2)); + doAndWaitForPageToLoad(() -> selectOptionByText(PROJECT_DROP_DOWN, PROJECT_2)); - scheduleInstrument(yearMonth + "-04"); + scheduleInstrument(yearMonth + "-04", false); assertProjectEventCounts(1, 2); - scheduleInstrument(yearMonth + "-05"); + scheduleInstrument(yearMonth + "-05", false); assertProjectEventCounts(2, 2); + doAndWaitForPageToLoad(() -> selectOptionByText(INSTRUMENT_DROP_DOWN, INSTRUMENT_2)); sleep(1000); - doAndWaitForPageToLoad(() -> selectOptionByText(Locator.id("instrumentDropDown"), INSTRUMENT_2)); - scheduleInstrument(yearMonth + "-06"); + click(Locator.id("addPaymentMethod")); + List percentInputs = getDriver().findElements(PAYMENT_METHOD_PERCENTS); + assertEquals("Wrong number of payment method percents", 2, percentInputs.size()); + List methodInputs = getDriver().findElements(PAYMENT_METHOD_DROP_DOWNS); + assertEquals("Wrong number of payment method dropdowns", 2, methodInputs.size()); + + // Duplicate payment methods + selectOptionByText(methodInputs.get(0), PAYMENT_METHOD_2); + assertTextPresent("The same payment method cannot be selected more than once."); + selectOptionByText(methodInputs.get(0), PAYMENT_METHOD_1); + + // Bogus payment percentages + setFormElement(percentInputs.get(1), "0"); + assertTextPresent("Each payment percentage must be a number between 0 and 100."); + setFormElement(percentInputs.get(1), "60"); + assertTextPresent("When multiple payment methods are used, the percentages must add up to 100% (current total: 110%)."); + setFormElement(percentInputs.get(0), "40"); + + scheduleInstrument(yearMonth + "-06", false, () -> { + // Make it a two-day reservation + String originalEnd = getFormElement(END_DATE_TIME_FIELD.findElement(getDriver())); + setFormElement(END_DATE_TIME_FIELD.findElement(getDriver()), originalEnd.replace("-06T", "-07T")); + }); assertProjectEventCounts(1, 0); - goToDashboard(); + impersonate(LAB_MEMBER_USER); + assertEquals("Wrong number of projects for " + LAB_MEMBER_USER, + 2, + new SelectRowsCommand("targetedms", "msProject").execute(createDefaultConnection(), getProjectName()).getRows().size()); + + stopImpersonating(); + impersonate(EXTERNAL_COLLABORATOR_USER); + List> projects = new SelectRowsCommand("targetedms", "msProject").execute(createDefaultConnection(), getProjectName()).getRows(); + assertEquals("Wrong number of projects for " + EXTERNAL_COLLABORATOR_USER, 1, projects.size()); + int project2Id = (Integer) projects.get(0).get("Id"); + SelectRowsCommand instrumentSelect = new SelectRowsCommand("targetedms", "msInstrument"); + instrumentSelect.setFilters(Arrays.asList(new Filter("Name", INSTRUMENT_1))); + List> instruments = instrumentSelect.execute(createDefaultConnection(), getProjectName()).getRows(); + assertEquals("Wrong number of instruments", 1, instruments.size()); + int instrument1Id = (Integer) instruments.get(0).get("Id"); + + SelectRowsCommand paymentMethodSelect = new SelectRowsCommand("targetedms", "paymentMethod"); + paymentMethodSelect.setFilters(Arrays.asList(new Filter("Name", PAYMENT_METHOD_1))); + List> paymentMethods = paymentMethodSelect.execute(createDefaultConnection(), getProjectName()).getRows(); + assertEquals("Wrong number of paymentMethods", 1, paymentMethods.size()); + int paymentMethod1Id = (Integer) paymentMethods.get(0).get("Id"); + + int project1Id = project2Id - 1; // Assume sequential auto-incrementing ids + int inactiveInstrumentId = instrument1Id + 2; + int paymentMethod3Id = paymentMethod1Id + 2; + + attemptScheduleInsertExpectingFailure( + Map.of("Project", project1Id, "Instrument", instrument1Id), + "User is not a member of the project"); + attemptScheduleInsertExpectingFailure( + Map.of("Project", project2Id, "Instrument", instrument1Id), + "StartTime and EndTime are required"); + Calendar start = Calendar.getInstance(); + start.add(Calendar.MONTH, 1); + Calendar end = Calendar.getInstance(); + end.add(Calendar.MONTH, 1); + end.add(Calendar.DATE, 1); + Date startDate = new Date(start.getTimeInMillis()); + Date endDate = new Date(end.getTimeInMillis()); + attemptScheduleInsertExpectingFailure( + Map.of("Project", project2Id, "Instrument", instrument1Id, "StartTime", endDate, "EndTime", startDate), + "StartTime must be before EndTime"); + attemptScheduleInsertExpectingFailure( + Map.of("Project", project2Id, "Instrument", instrument1Id, "StartTime", startDate, "EndTime", endDate), + "Instrument usage payments do not add up to 100%"); + attemptScheduleInsertExpectingFailure( + Map.of("Project", project2Id, "Instrument", instrument1Id, "StartTime", startDate, "EndTime", endDate, "UsagePayments", Arrays.asList(Map.of("PaymentMethod", paymentMethod3Id, "PercentPayment", 100))), + "Instrument usage payments are not using a payment method that is configured for the project."); + attemptScheduleInsertExpectingFailure( + Map.of("Project", project2Id, "Instrument", inactiveInstrumentId, "StartTime", startDate, "EndTime", endDate, "UsagePayments", Arrays.asList(Map.of("PaymentMethod", paymentMethod1Id, "PercentPayment", 100))), + "Instrument does not exist or is not active"); + + stopImpersonating(); + + goToProjectHome(); waitAndClickAndWait(Locator.linkWithText("All instrument calendar view")); - assertTextPresent(INSTRUMENT_1, INSTRUMENT_2, INACTIVE_INSTRUMENT); + waitForText(INSTRUMENT_1, INSTRUMENT_2, INACTIVE_INSTRUMENT); assertProjectEventCounts(5, 0); @@ -174,42 +289,113 @@ public void testSchedule() goToDashboard(); waitAndClickAndWait(Locator.linkWithText("Instrument billing report")); - assertTextPresent("$950.00", 4); - assertTextPresent("$1,040.00", 1); - - // Future test cases: - // Split payment across multiple methods - // Schedule for hours within a day instead of 24-hour periods - // Check billing for individual months, including reservations that span month boundaries with start/end dates - // Ensure that overlapping reservations are rejected - // Ensure that reservations cannot be made for inactive instruments + assertTextPresent("$950.00", 8); + // Two rows, one for each of the two payment methods + assertTextPresent("$3,680.00", 2); + assertTextPresent(PAYMENT_METHOD_1, 5); + assertTextPresent(PAYMENT_METHOD_2, 1); + // Verify the 40/60 split + assertTextPresent("$1,472.00", "$2,208.00"); + + goToDashboard(); + clickAndWait(Locator.linkWithText("Monthly instrument billing report")); + // Choose a date that splits a reservation into two parts + setFormElement(Locator.name("query.param.StartBillDate"), yearMonth + "-07"); + clickButton("Submit"); + // Only some hours should be in the range for this billing report + assertTextPresent("17.0", 2); + assertTextPresent("$748.00", "$1,122.00"); } - private void assertProjectEventCounts(int expectedActiveCount, int expectedOtherCount) + private void attemptScheduleInsertExpectingFailure(Map row, String expected) throws IOException { - Locator activeLocator = Locator.byClass("activeProjectEvent"); - Locator otherLocator = Locator.byClass("otherProjectEvent"); - if (expectedActiveCount > 0) + InsertRowsCommand scheduleInsert = new InsertRowsCommand("targetedms", "instrumentSchedule"); + scheduleInsert.setRows(Arrays.asList(row)); + failInsert(scheduleInsert, expected); + } + + @Test + public void testSetupInsertPermissions() throws IOException, CommandException + { + // Validate that a collaborator can't add a project themselves + int adminId = getCurrentUserId(); + impersonate(EXTERNAL_COLLABORATOR_USER); + InsertRowsCommand projectInsert = new InsertRowsCommand("targetedms", "msProject"); + projectInsert.setRows(Arrays.asList( + Map.of("Affiliation", "External", "Title", "External", "SubmitDate", "1/1/2025", "CollaborationWith", "Mike", "ScientificQuestion", "Why are collaborators so great?", "abstract", "c") + )); + + failInsert(projectInsert, null); + + // Insert as a lab member + stopImpersonating(); + impersonate(LAB_MEMBER_USER); + Map project = projectInsert.execute(createDefaultConnection(), getProjectName()).getRows().get(0); + int projectId = (Integer) project.get("Id"); + + // Collaborator isn't part of the project, so they shouldn't be able to add a researcher + stopImpersonating(); + impersonate(EXTERNAL_COLLABORATOR_USER); + int collaboratorId = getCurrentUserId(); + InsertRowsCommand researcherInsert = new InsertRowsCommand("targetedms", "projectResearcher"); + researcherInsert.setRows(Arrays.asList( + Map.of("Project", projectId, "Researcher", collaboratorId) + )); + failInsert(researcherInsert, null); + + // Add the collaborator + stopImpersonating(); + impersonate(LAB_MEMBER_USER); + researcherInsert.execute(createDefaultConnection(), getProjectName()); + + // Now the collaborator should be able to add another researcher + stopImpersonating(); + impersonate(EXTERNAL_COLLABORATOR_USER); + researcherInsert.setRows(Arrays.asList( + Map.of("Project", projectId, "Researcher", adminId) + )); + researcherInsert.execute(createDefaultConnection(), getProjectName()); + + + } + + private void failInsert(InsertRowsCommand insert, String expectedMessage) throws IOException + { + try { - waitForElementToBeVisible(activeLocator); + insert.execute(createDefaultConnection(), getProjectName()); + fail("Shouldn't have permissions"); } - if (expectedOtherCount > 0) + catch (CommandException e) { - waitForElementToBeVisible(otherLocator); + if (expectedMessage != null) + { + assertEquals(expectedMessage, e.getMessage()); + } } + } + + + + private void assertProjectEventCounts(int expectedActiveCount, int expectedOtherCount) + { + Locator activeLocator = Locator.byClass("activeProjectEvent"); + Locator otherLocator = Locator.byClass("otherProjectEvent"); + waitFor(() -> expectedActiveCount == getElementCount(activeLocator) && expectedOtherCount == getElementCount(otherLocator), 5_000); assertElementPresent(activeLocator, expectedActiveCount); assertElementPresent(otherLocator, expectedOtherCount); } - private void scheduleInstrument(String yearMonthDay) + private void scheduleInstrument(String yearMonthDay, boolean delete) { - scheduleInstrument(yearMonthDay, false); + scheduleInstrument(yearMonthDay, delete, () -> {}); } - private void scheduleInstrument(String yearMonthDay, boolean delete) + private void scheduleInstrument(String yearMonthDay, boolean delete, @NotNull Runnable extraSteps) { waitAndClick(Locator.tagWithAttribute("td", "data-date", yearMonthDay)); waitForText("Add Instrument Time"); + extraSteps.run(); if (delete) { waitAndClick(Locator.button("Delete")); @@ -220,18 +406,7 @@ private void scheduleInstrument(String yearMonthDay, boolean delete) setFormElement(EVENT_NAME_FIELD.findElement(getDriver()), "A name!"); setFormElement(EVENT_NOTE_FIELD.findElement(getDriver()), "A note!"); waitAndClick(Locator.button("Save")); - waitAndClick(Locator.button("Yes")); } } - @Override - protected void doCleanup(boolean afterTest) throws TestTimeoutException - { - // these tests use the UIContainerHelper for project creation, but we can use the APIContainerHelper for deletion - APIContainerHelper apiContainerHelper = new APIContainerHelper(this); - apiContainerHelper.deleteProject(getProjectName(), afterTest); - - _userHelper.deleteUsers(false, SCHEDULER_USER_1); - _userHelper.deleteUsers(false, SCHEDULER_USER_2); - } } diff --git a/webapp/TargetedMS/instrumentCalendar.lib.xml b/webapp/TargetedMS/instrumentCalendar.lib.xml index 1b217ed90..733ad30b2 100644 --- a/webapp/TargetedMS/instrumentCalendar.lib.xml +++ b/webapp/TargetedMS/instrumentCalendar.lib.xml @@ -1,5 +1,6 @@