diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/pom.xml b/amps/ags/rm-automation/rm-automation-community-rest-api/pom.xml
index 2744b881fe..33870f8051 100644
--- a/amps/ags/rm-automation/rm-automation-community-rest-api/pom.xml
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/pom.xml
@@ -84,6 +84,12 @@
okhttp
test
+
+ org.awaitility
+ awaitility
+ ${dependency.awaitility.version}
+ test
+
org.apache.commons
commons-collections4
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/BulkBodyCancel.java b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/BulkBodyCancel.java
new file mode 100644
index 0000000000..c280255fd3
--- /dev/null
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/BulkBodyCancel.java
@@ -0,0 +1,41 @@
+/*-
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rest.rm.community.model.hold;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Builder
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class BulkBodyCancel
+{
+ private String reason;
+}
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkOperation.java b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkOperation.java
new file mode 100644
index 0000000000..288163d9cc
--- /dev/null
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkOperation.java
@@ -0,0 +1,59 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rest.rm.community.model.hold;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.alfresco.rest.search.RestRequestQueryModel;
+import org.alfresco.utility.model.TestModel;
+
+/**
+ * POJO for hold bulk request
+ *
+ * @author Damian Ujma
+ */
+@Builder
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class HoldBulkOperation extends TestModel
+{
+ public enum HoldBulkOperationType
+ {
+ ADD
+ }
+
+ @JsonProperty(required = true)
+ private RestRequestQueryModel query;
+ @JsonProperty(required = true)
+ private HoldBulkOperationType op;
+
+}
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkOperationEntry.java b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkOperationEntry.java
new file mode 100644
index 0000000000..2709cff151
--- /dev/null
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkOperationEntry.java
@@ -0,0 +1,50 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rest.rm.community.model.hold;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * POJO for hold bulk request entry
+ *
+ * @author Damian Ujma
+ */
+@Builder
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class HoldBulkOperationEntry
+{
+ private String bulkStatusId;
+
+ private long totalItems;
+}
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkStatus.java b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkStatus.java
new file mode 100644
index 0000000000..c8d1f8e8a2
--- /dev/null
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkStatus.java
@@ -0,0 +1,67 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rest.rm.community.model.hold;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.alfresco.utility.model.TestModel;
+
+/**
+ * POJO for hold bulk request
+ *
+ * @author Damian Ujma
+ */
+@Builder
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class HoldBulkStatus extends TestModel
+{
+ private String bulkStatusId;
+
+ private String startTime;
+
+ private String endTime;
+
+ private long processedItems;
+
+ private long errorsCount;
+
+ private long totalItems;
+
+ private String lastError;
+
+ private String status;
+
+ private boolean isCancelled;
+
+ private String cancellationReason;
+
+ private HoldBulkOperation holdBulkOperation;
+}
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkStatusCollection.java b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkStatusCollection.java
new file mode 100644
index 0000000000..b6f71b611a
--- /dev/null
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkStatusCollection.java
@@ -0,0 +1,38 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rest.rm.community.model.hold;
+
+import org.alfresco.rest.core.RestModels;
+
+/**
+ * Handle collection of {@link HoldBulkStatusEntry}
+ *
+ * @author Damian Ujma
+ */
+public class HoldBulkStatusCollection extends RestModels
+{
+}
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkStatusEntry.java b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkStatusEntry.java
new file mode 100644
index 0000000000..68291352cc
--- /dev/null
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldBulkStatusEntry.java
@@ -0,0 +1,46 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rest.rm.community.model.hold;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.alfresco.rest.core.RestModels;
+
+@Builder
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+@AllArgsConstructor
+public class HoldBulkStatusEntry extends RestModels
+{
+ private HoldBulkStatus entry;
+}
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldChildEntry.java b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldChildEntry.java
index e8a65dea78..6b3cc4b36e 100644
--- a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldChildEntry.java
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/model/hold/HoldChildEntry.java
@@ -48,5 +48,5 @@ import org.alfresco.rest.core.RestModels;
public class HoldChildEntry extends RestModels
{
@JsonProperty
- private HoldChildEntry entry;
+ private HoldChild entry;
}
\ No newline at end of file
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/requests/gscore/api/HoldsAPI.java b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/requests/gscore/api/HoldsAPI.java
index c06403290e..10bf6f3128 100644
--- a/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/requests/gscore/api/HoldsAPI.java
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/main/java/org/alfresco/rest/rm/community/requests/gscore/api/HoldsAPI.java
@@ -38,7 +38,12 @@ import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.HttpMethod.PUT;
import org.alfresco.rest.core.RMRestWrapper;
+import org.alfresco.rest.rm.community.model.hold.BulkBodyCancel;
import org.alfresco.rest.rm.community.model.hold.Hold;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkOperation;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkOperationEntry;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkStatus;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkStatusCollection;
import org.alfresco.rest.rm.community.model.hold.HoldChild;
import org.alfresco.rest.rm.community.model.hold.HoldChildCollection;
import org.alfresco.rest.rm.community.model.hold.HoldDeletionReason;
@@ -287,4 +292,155 @@ public class HoldsAPI extends RMModelRequest
{
deleteHoldChild(holdId, holdChildId, EMPTY);
}
+
+ /**
+ * Starts a bulk process for a hold.
+ *
+ * @param holdBulkOperation The bulk operation details
+ * @param hold The identifier of a hold
+ * @param parameters The URL parameters to add
+ * @return The {@link HoldBulkOperationEntry} for the started bulk process
+ * @throws RuntimeException for the following cases:
+ *
+ * - {@code hold} or {@code holdBulkOperation} is invalid
+ * - authentication fails
+ * - current user does not have permission to start a bulk process for {@code hold}
+ * - {@code hold} does not exist
+ *
+ */
+ public HoldBulkOperationEntry startBulkProcess(HoldBulkOperation holdBulkOperation, String hold, String parameters)
+ {
+ mandatoryObject("holdBulkOperation", holdBulkOperation);
+ mandatoryString("hold", hold);
+
+ return getRmRestWrapper().processModel(HoldBulkOperationEntry.class, requestWithBody(
+ POST,
+ toJson(holdBulkOperation),
+ "holds/{hold}/bulk",
+ hold,
+ parameters
+ ));
+ }
+
+ /**
+ * See {@link #startBulkProcess(HoldBulkOperation, String, String)}
+ */
+ public HoldBulkOperationEntry startBulkProcess(HoldBulkOperation holdBulkOperation, String hold)
+ {
+ return startBulkProcess(holdBulkOperation, hold, EMPTY);
+ }
+
+ /**
+ * Gets the status of a bulk process for a hold.
+ *
+ * @param holdId The identifier of a hold
+ * @param holdBulkStatusId The identifier of a bulk status operation
+ * @param parameters The URL parameters to add
+ * @return The {@link HoldBulkStatus} for the given {@code holdId} and {@code holdBulkStatusId}
+ * @throws RuntimeException for the following cases:
+ *
+ * - {@code holdId} or {@code holdBulkStatusId} is invalid
+ * - authentication fails
+ * - current user does not have permission to get the bulk status for {@code holdId}
+ * - {@code holdId} or {@code holdBulkStatusId} does not exist
+ *
+ */
+ public HoldBulkStatus getBulkStatus(String holdId, String holdBulkStatusId, String parameters)
+ {
+ mandatoryString("holdId", holdId);
+ mandatoryString("holdBulkStatusId", holdBulkStatusId);
+
+ return getRmRestWrapper().processModel(HoldBulkStatus.class, simpleRequest(
+ GET,
+ "holds/{holdId}/bulk-statuses/{holdBulkStatusId}",
+ holdId,
+ holdBulkStatusId,
+ parameters
+ ));
+ }
+
+ /**
+ * See {@link #getBulkStatus(String, String, String)}
+ */
+ public HoldBulkStatus getBulkStatus(String holdId, String holdBulkStatusId)
+ {
+ return getBulkStatus(holdId, holdBulkStatusId, EMPTY);
+ }
+
+ /**
+ * Gets the statuses of all bulk processes for a hold.
+ *
+ * @param holdId The identifier of a hold
+ * @param parameters The URL parameters to add
+ * @return The {@link HoldBulkStatusCollection} for the given {@code holdId}
+ * @throws RuntimeException for the following cases:
+ *
+ * - {@code holdId} is invalid
+ * - authentication fails
+ * - current user does not have permission to get the bulk statuses for {@code holdId}
+ * - {@code holdId} does not exist
+ *
+ */
+ public HoldBulkStatusCollection getBulkStatuses(String holdId, String parameters)
+ {
+ mandatoryString("holdId", holdId);
+
+ return getRmRestWrapper().processModels(HoldBulkStatusCollection.class, simpleRequest(
+ GET,
+ "holds/{holdId}/bulk-statuses",
+ holdId,
+ parameters
+ ));
+ }
+
+ /**
+ * See {@link #getBulkStatuses(String, String)}
+ */
+ public HoldBulkStatusCollection getBulkStatuses(String holdId)
+ {
+ return getBulkStatuses(holdId, EMPTY);
+ }
+
+ /**
+ * Cancels a bulk operation for a hold.
+ *
+ * @param holdId The identifier of a hold
+ * @param bulkStatusId The identifier of a bulk status operation
+ * @param bulkBodyCancel The bulk body cancel model
+ * @param parameters The URL parameters to add
+ * @throws RuntimeException for the following cases:
+ *
+ * - {@code holdId}, {@code bulkStatusId} or {@code bulkBodyCancel} is invalid
+ * - authentication fails
+ * - current user does not have permission to cancel the bulk operation for {@code bulkStatusId}
+ * - {@code holdId} or {@code bulkStatusId} does not exist
+ *
+ */
+ public void cancelBulkOperation(String holdId, String bulkStatusId, BulkBodyCancel bulkBodyCancel, String parameters)
+ {
+ mandatoryString("holdId", holdId);
+ mandatoryString("bulkStatusId", bulkStatusId);
+ mandatoryObject("bulkBodyCancel", bulkBodyCancel);
+
+ getRmRestWrapper().processEmptyModel(requestWithBody(
+ POST,
+ toJson(bulkBodyCancel),
+ "holds/{holdId}/bulk-statuses/{bulkStatusId}/cancel",
+ holdId,
+ bulkStatusId,
+ parameters
+ ));
+ }
+
+ /**
+ * See {@link #cancelBulkOperation(String, String, BulkBodyCancel, String)}
+ */
+ public void cancelBulkOperation(String holdId, String bulkStatusId, BulkBodyCancel bulkBodyCancel)
+ {
+ mandatoryString("holdId", holdId);
+ mandatoryString("bulkStatusId", bulkStatusId);
+ mandatoryObject("bulkBodyCancel", bulkBodyCancel);
+
+ cancelBulkOperation(holdId, bulkStatusId, bulkBodyCancel, EMPTY);
+ }
}
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/test/java/org/alfresco/rest/rm/community/hold/AddToHoldsBulkV1Tests.java b/amps/ags/rm-automation/rm-automation-community-rest-api/src/test/java/org/alfresco/rest/rm/community/hold/AddToHoldsBulkV1Tests.java
new file mode 100644
index 0000000000..f7b3a7fb85
--- /dev/null
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/test/java/org/alfresco/rest/rm/community/hold/AddToHoldsBulkV1Tests.java
@@ -0,0 +1,614 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rest.rm.community.hold;
+
+import static org.alfresco.rest.rm.community.base.TestData.HOLD_DESCRIPTION;
+import static org.alfresco.rest.rm.community.base.TestData.HOLD_REASON;
+import static org.alfresco.rest.rm.community.model.fileplancomponents.FilePlanComponentAlias.FILE_PLAN_ALIAS;
+import static org.alfresco.rest.rm.community.model.user.UserPermissions.PERMISSION_FILING;
+import static org.alfresco.rest.rm.community.model.user.UserPermissions.PERMISSION_READ_RECORDS;
+import static org.alfresco.rest.rm.community.util.CommonTestUtils.generateTestPrefix;
+import static org.alfresco.utility.report.log.Step.STEP;
+import static org.awaitility.Awaitility.await;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.springframework.http.HttpStatus.ACCEPTED;
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+import static org.springframework.http.HttpStatus.FORBIDDEN;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+import static org.springframework.http.HttpStatus.OK;
+import static org.springframework.http.HttpStatus.UNAUTHORIZED;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+
+import org.alfresco.dataprep.CMISUtil;
+import org.alfresco.dataprep.ContentActions;
+import org.alfresco.rest.rm.community.base.BaseRMRestTest;
+import org.alfresco.rest.rm.community.model.hold.BulkBodyCancel;
+import org.alfresco.rest.rm.community.model.hold.Hold;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkOperation;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkOperation.HoldBulkOperationType;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkOperationEntry;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkStatus;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkStatusCollection;
+import org.alfresco.rest.rm.community.model.hold.HoldBulkStatusEntry;
+import org.alfresco.rest.rm.community.model.hold.HoldChild;
+import org.alfresco.rest.rm.community.model.hold.HoldChildEntry;
+import org.alfresco.rest.rm.community.model.user.UserRoles;
+import org.alfresco.rest.search.RestRequestQueryModel;
+import org.alfresco.rest.search.SearchRequest;
+import org.alfresco.rest.v0.service.RoleService;
+import org.alfresco.utility.constants.UserRole;
+import org.alfresco.utility.model.FileModel;
+import org.alfresco.utility.model.FolderModel;
+import org.alfresco.utility.model.UserModel;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * API tests for adding items to holds via the bulk process
+ */
+public class AddToHoldsBulkV1Tests extends BaseRMRestTest
+{
+ private static final String ACCESS_DENIED_ERROR_MESSAGE = "Access Denied. You do not have the appropriate " +
+ "permissions to perform this operation.";
+ private static final int NUMBER_OF_FILES = 5;
+ private final List addedFiles = new ArrayList<>();
+ private final List users = new ArrayList<>();
+ private final List holds = new ArrayList<>();
+ private Hold hold;
+ private Hold hold2;
+ private Hold hold3;
+ private FolderModel rootFolder;
+ private HoldBulkOperation holdBulkOperation;
+ @Autowired
+ private RoleService roleService;
+ @Autowired
+ private ContentActions contentActions;
+
+ @BeforeClass(alwaysRun = true)
+ public void preconditionForAddContentToHold()
+ {
+ STEP("Create a hold.");
+ hold = getRestAPIFactory().getFilePlansAPI(getAdminUser()).createHold(
+ Hold.builder().name("HOLD" + generateTestPrefix(AddToHoldsV1Tests.class)).description(HOLD_DESCRIPTION)
+ .reason(HOLD_REASON).build(), FILE_PLAN_ALIAS);
+ holds.add(hold);
+
+ STEP("Create test files.");
+ testSite = dataSite.usingAdmin().createPublicRandomSite();
+
+ rootFolder = dataContent.usingAdmin().usingSite(testSite).createFolder();
+ FolderModel folder1 = dataContent.usingAdmin().usingResource(rootFolder).createFolder();
+ FolderModel folder2 = dataContent.usingAdmin().usingResource(folder1).createFolder();
+
+ // Add files to subfolders in the site
+ for (int i = 0; i < NUMBER_OF_FILES; i++)
+ {
+ FileModel documentHeld = dataContent.usingAdmin()
+ .usingResource(i % 2 == 0 ? folder1 : folder2)
+ .createContent(CMISUtil.DocumentType.TEXT_PLAIN);
+ addedFiles.add(documentHeld);
+ }
+
+ RestRequestQueryModel queryReq = getContentFromSiteQuery(testSite.getId());
+ SearchRequest searchRequest = new SearchRequest();
+ searchRequest.setQuery(queryReq);
+
+ STEP("Wait until all files are searchable.");
+ await().atMost(30, TimeUnit.SECONDS)
+ .until(() -> getRestAPIFactory().getSearchAPI(null).search(searchRequest).getPagination()
+ .getTotalItems() == NUMBER_OF_FILES);
+
+ holdBulkOperation = HoldBulkOperation.builder()
+ .query(queryReq)
+ .op(HoldBulkOperationType.ADD).build();
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from a site to a hold using the bulk API
+ * Then the content is added to the hold and the status of the bulk operation is DONE
+ */
+ @Test
+ public void addContentFromTestSiteToHoldUsingBulkAPI()
+ {
+ UserModel userAddHoldPermission = roleService.createUserWithSiteRoleRMRoleAndPermission(testSite,
+ UserRole.SiteCollaborator, hold.getId(), UserRoles.ROLE_RM_MANAGER, PERMISSION_FILING);
+ users.add(userAddHoldPermission);
+
+ STEP("Add content from the site to the hold using the bulk API.");
+ HoldBulkOperationEntry bulkOperationEntry = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .startBulkProcess(holdBulkOperation, hold.getId());
+
+ // Verify the status code
+ assertStatusCode(ACCEPTED);
+ assertEquals(NUMBER_OF_FILES, bulkOperationEntry.getTotalItems());
+
+ STEP("Wait until all files are added to the hold.");
+ await().atMost(20, TimeUnit.SECONDS).until(
+ () -> getRestAPIFactory().getHoldsAPI(getAdminUser()).getChildren(hold.getId()).getEntries().size()
+ == NUMBER_OF_FILES);
+ List holdChildrenNodeRefs = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getChildren(hold.getId()).getEntries().stream().map(HoldChildEntry::getEntry).map(
+ HoldChild::getId).toList();
+ assertEquals(addedFiles.stream().map(FileModel::getNodeRefWithoutVersion).sorted().toList(),
+ holdChildrenNodeRefs.stream().sorted().toList());
+
+ STEP("Check the bulk status.");
+ HoldBulkStatus holdBulkStatus = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getBulkStatus(hold.getId(), bulkOperationEntry.getBulkStatusId());
+ assertBulkProcessStatus(holdBulkStatus, NUMBER_OF_FILES, 0, null, holdBulkOperation);
+
+ STEP("Check the bulk statuses.");
+ HoldBulkStatusCollection holdBulkStatusCollection = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getBulkStatuses(hold.getId());
+ assertEquals(Arrays.asList(holdBulkStatus),
+ holdBulkStatusCollection.getEntries().stream().map(HoldBulkStatusEntry::getEntry).toList());
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from a folder and all subfolders to a hold using the bulk API
+ * Then the content is added to the hold and the status of the bulk operation is DONE
+ */
+ @Test
+ public void addContentFromFolderAndAllSubfoldersToHoldUsingBulkAPI()
+ {
+ hold3 = getRestAPIFactory().getFilePlansAPI(getAdminUser()).createHold(
+ Hold.builder().name("HOLD" + generateTestPrefix(AddToHoldsV1Tests.class)).description(HOLD_DESCRIPTION)
+ .reason(HOLD_REASON).build(), FILE_PLAN_ALIAS);
+ holds.add(hold3);
+
+ UserModel userAddHoldPermission = roleService.createUserWithSiteRoleRMRoleAndPermission(testSite,
+ UserRole.SiteCollaborator, hold3.getId(), UserRoles.ROLE_RM_MANAGER, PERMISSION_FILING);
+ users.add(userAddHoldPermission);
+
+ STEP("Add content from the site to the hold using the bulk API.");
+ // Get content from folder and all subfolders of the root folder
+ HoldBulkOperation bulkOperation = HoldBulkOperation.builder()
+ .query(getContentFromFolderAndAllSubfoldersQuery(rootFolder.getNodeRefWithoutVersion()))
+ .op(HoldBulkOperationType.ADD).build();
+ HoldBulkOperationEntry bulkOperationEntry = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .startBulkProcess(bulkOperation, hold3.getId());
+
+ // Verify the status code
+ assertStatusCode(ACCEPTED);
+ assertEquals(NUMBER_OF_FILES, bulkOperationEntry.getTotalItems());
+
+ STEP("Wait until all files are added to the hold.");
+ await().atMost(20, TimeUnit.SECONDS).until(
+ () -> getRestAPIFactory().getHoldsAPI(getAdminUser()).getChildren(hold3.getId()).getEntries().size()
+ == NUMBER_OF_FILES);
+ List holdChildrenNodeRefs = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getChildren(hold3.getId()).getEntries().stream().map(HoldChildEntry::getEntry).map(
+ HoldChild::getId).toList();
+ assertEquals(addedFiles.stream().map(FileModel::getNodeRefWithoutVersion).sorted().toList(),
+ holdChildrenNodeRefs.stream().sorted().toList());
+
+ STEP("Check the bulk status.");
+ HoldBulkStatus holdBulkStatus = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getBulkStatus(hold3.getId(), bulkOperationEntry.getBulkStatusId());
+ assertBulkProcessStatus(holdBulkStatus, NUMBER_OF_FILES, 0, null, bulkOperation);
+
+ STEP("Check the bulk statuses.");
+ HoldBulkStatusCollection holdBulkStatusCollection = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getBulkStatuses(hold3.getId());
+ assertEquals(List.of(holdBulkStatus),
+ holdBulkStatusCollection.getEntries().stream().map(HoldBulkStatusEntry::getEntry).toList());
+ }
+
+ /**
+ * Given a user without the add to hold capability
+ * When the user adds content from a site to a hold using the bulk API
+ * Then the user receives access denied error
+ */
+ @Test
+ public void testBulkProcessWithUserWithoutAddToHoldCapability()
+ {
+ UserModel userWithoutAddToHoldCapability = roleService.createUserWithSiteRoleRMRoleAndPermission(testSite,
+ UserRole
+ .SiteCollaborator,
+ hold.getId(), UserRoles.ROLE_RM_POWER_USER, PERMISSION_FILING);
+ users.add(userWithoutAddToHoldCapability);
+
+ STEP("Add content from the site to the hold using the bulk API.");
+ getRestAPIFactory().getHoldsAPI(userWithoutAddToHoldCapability)
+ .startBulkProcess(holdBulkOperation, hold.getId());
+
+ STEP("Verify the response status code and the error message.");
+ assertStatusCode(FORBIDDEN);
+ getRestAPIFactory().getRmRestWrapper().assertLastError().containsSummary(ACCESS_DENIED_ERROR_MESSAGE);
+ }
+
+ /**
+ * Given a user without the filing permission on a hold
+ * When the user adds content from a site to a hold using the bulk API
+ * Then the user receives access denied error
+ */
+ @Test
+ public void testBulkProcessWithUserWithoutFilingPermissionOnAHold()
+ {
+ // User without filing permission on a hold
+ UserModel userWithoutPermission = roleService.createUserWithSiteRoleRMRoleAndPermission(testSite,
+ UserRole.SiteCollaborator, hold.getId(), UserRoles.ROLE_RM_MANAGER, PERMISSION_READ_RECORDS);
+ users.add(userWithoutPermission);
+
+ STEP("Add content from the site to the hold using the bulk API.");
+ getRestAPIFactory().getHoldsAPI(userWithoutPermission)
+ .startBulkProcess(holdBulkOperation, hold.getId());
+
+ STEP("Verify the response status code and the error message.");
+ assertStatusCode(FORBIDDEN);
+ getRestAPIFactory().getRmRestWrapper().assertLastError().containsSummary(ACCESS_DENIED_ERROR_MESSAGE);
+
+ }
+
+ /**
+ * Given a user without the write permission on all the content
+ * When the user adds content from a site to a hold using the bulk API
+ * Then all processed items are marked as errors and the last error message contains access denied error
+ */
+ @Test
+ public void testBulkProcessWithUserWithoutWritePermissionOnTheContent()
+ {
+ // User without write permission on the content
+ UserModel userWithoutPermission = roleService.createUserWithSiteRoleRMRoleAndPermission(
+ testSite, UserRole.SiteConsumer,
+ hold.getId(), UserRoles.ROLE_RM_MANAGER, PERMISSION_FILING);
+ users.add(userWithoutPermission);
+
+ // Wait until permissions are reverted
+ SearchRequest searchRequest = new SearchRequest();
+ searchRequest.setQuery(holdBulkOperation.getQuery());
+ await().atMost(30, TimeUnit.SECONDS)
+ .until(() -> getRestAPIFactory().getSearchAPI(userWithoutPermission).search(searchRequest).getPagination()
+ .getTotalItems() == NUMBER_OF_FILES);
+
+ STEP("Add content from the site to the hold using the bulk API.");
+ HoldBulkOperationEntry bulkOperationEntry = getRestAPIFactory().getHoldsAPI(
+ userWithoutPermission).startBulkProcess(holdBulkOperation, hold.getId());
+
+ STEP("Verify the response.");
+ assertStatusCode(ACCEPTED);
+
+ await().atMost(20, TimeUnit.SECONDS).until(() ->
+ Objects.equals(getRestAPIFactory().getHoldsAPI(userWithoutPermission)
+ .getBulkStatus(hold.getId(), bulkOperationEntry.getBulkStatusId()).getStatus(), "DONE"));
+
+ HoldBulkStatus holdBulkStatus = getRestAPIFactory().getHoldsAPI(userWithoutPermission)
+ .getBulkStatus(hold.getId(), bulkOperationEntry.getBulkStatusId());
+ assertBulkProcessStatus(holdBulkStatus, NUMBER_OF_FILES, NUMBER_OF_FILES, ACCESS_DENIED_ERROR_MESSAGE,
+ holdBulkOperation);
+ }
+
+ /**
+ * Given a user without the write permission on one file
+ * When the user adds content from a site to a hold using the bulk API
+ * Then all processed items are added to the hold except the one that the user does not have write permission
+ * And the status of the bulk operation is DONE, contains the error message and the number of errors is 1
+ */
+ @Test
+ public void testBulkProcessWithUserWithoutWritePermissionOnOneFile()
+ {
+ hold2 = getRestAPIFactory().getFilePlansAPI(getAdminUser()).createHold(
+ Hold.builder().name("HOLD" + generateTestPrefix(AddToHoldsV1Tests.class)).description(HOLD_DESCRIPTION)
+ .reason(HOLD_REASON).build(), FILE_PLAN_ALIAS);
+ holds.add(hold2);
+
+ UserModel userAddHoldPermission = roleService.createUserWithSiteRoleRMRoleAndPermission(testSite,
+ UserRole.SiteCollaborator, hold2.getId(), UserRoles.ROLE_RM_MANAGER, PERMISSION_FILING);
+ users.add(userAddHoldPermission);
+
+ contentActions.setPermissionForUser(getAdminUser().getUsername(), getAdminUser().getPassword(),
+ testSite.getId(), addedFiles.get(0).getName(), userAddHoldPermission.getUsername(),
+ UserRole.SiteConsumer.getRoleId(), false);
+
+ STEP("Add content from the site to the hold using the bulk API.");
+ HoldBulkOperationEntry bulkOperationEntry = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .startBulkProcess(holdBulkOperation, hold2.getId());
+
+ // Verify the status code
+ assertStatusCode(ACCEPTED);
+ assertEquals(NUMBER_OF_FILES, bulkOperationEntry.getTotalItems());
+
+ STEP("Wait until all files are added to the hold.");
+ await().atMost(30, TimeUnit.SECONDS).until(
+ () -> getRestAPIFactory().getHoldsAPI(getAdminUser()).getChildren(hold2.getId()).getEntries().size()
+ == NUMBER_OF_FILES - 1);
+ await().atMost(30, TimeUnit.SECONDS).until(
+ () -> getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getBulkStatus(hold2.getId(), bulkOperationEntry.getBulkStatusId()).getProcessedItems()
+ == NUMBER_OF_FILES);
+ List holdChildrenNodeRefs = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getChildren(hold2.getId()).getEntries().stream().map(HoldChildEntry::getEntry).map(
+ HoldChild::getId).toList();
+ assertEquals(addedFiles.stream().skip(1).map(FileModel::getNodeRefWithoutVersion).sorted().toList(),
+ holdChildrenNodeRefs.stream().sorted().toList());
+
+ STEP("Check the bulk status.");
+ HoldBulkStatus holdBulkStatus = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getBulkStatus(hold2.getId(), bulkOperationEntry.getBulkStatusId());
+ assertBulkProcessStatus(holdBulkStatus, NUMBER_OF_FILES, 1, ACCESS_DENIED_ERROR_MESSAGE, holdBulkOperation);
+
+ STEP("Check the bulk statuses.");
+ HoldBulkStatusCollection holdBulkStatusCollection = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .getBulkStatuses(hold2.getId());
+ assertEquals(List.of(holdBulkStatus),
+ holdBulkStatusCollection.getEntries().stream().map(HoldBulkStatusEntry::getEntry).toList());
+
+ // Revert the permissions
+ contentActions.setPermissionForUser(getAdminUser().getUsername(), getAdminUser().getPassword(),
+ testSite.getId(), addedFiles.get(0).getName(), userAddHoldPermission.getUsername(),
+ UserRole.SiteCollaborator.getRoleId(), true);
+ }
+
+ /**
+ * Given an unauthenticated user
+ * When the user adds content from a site to a hold using the bulk API
+ * Then the user receives unauthorized error
+ */
+ @Test
+ public void testBulkProcessAsUnauthenticatedUser()
+ {
+ STEP("Start bulk process as unauthenticated user");
+ getRestAPIFactory().getHoldsAPI(new UserModel(getAdminUser().getUsername(), "wrongPassword"))
+ .startBulkProcess(holdBulkOperation, hold.getId());
+
+ STEP("Verify the response status code.");
+ assertStatusCode(UNAUTHORIZED);
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from a site to a hold using the bulk API
+ * And the hold does not exist
+ * Then the user receives not found error
+ */
+ @Test
+ public void testBulkProcessForNonExistentHold()
+ {
+ STEP("Start bulk process for non existent hold");
+ getRestAPIFactory().getHoldsAPI(getAdminUser()).startBulkProcess(holdBulkOperation, "nonExistentHoldId");
+
+ STEP("Verify the response status code.");
+ assertStatusCode(NOT_FOUND);
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from a site to a hold using the bulk API
+ * and the bulk operation is invalid
+ * Then the user receives bad request error
+ */
+ @Test
+ public void testGetBulkStatusesForInvalidOperation()
+ {
+ STEP("Start bulk process for non existent hold");
+
+ HoldBulkOperation invalidHoldBulkOperation = HoldBulkOperation.builder().op(null)
+ .query(holdBulkOperation.getQuery()).build();
+ getRestAPIFactory().getHoldsAPI(getAdminUser()).startBulkProcess(invalidHoldBulkOperation, hold.getId());
+
+ STEP("Verify the response status code.");
+ assertStatusCode(BAD_REQUEST);
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from a site to a hold using the bulk API
+ * And the hold does not exist
+ * Then the user receives not found error
+ */
+ @Test
+ public void testGetBulkStatusForNonExistentHold()
+ {
+ STEP("Start bulk process for non existent hold");
+ getRestAPIFactory().getHoldsAPI(getAdminUser()).getBulkStatus("nonExistentHoldId", "nonExistenBulkStatusId");
+
+ STEP("Verify the response status code.");
+ assertStatusCode(NOT_FOUND);
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from a site to a hold using the bulk API
+ * And the bulk status does not exist
+ * Then the user receives not found error
+ */
+ @Test
+ public void testGetBulkStatusForNonExistentBulkStatus()
+ {
+ STEP("Start bulk process for non bulk status");
+ getRestAPIFactory().getHoldsAPI(getAdminUser()).getBulkStatus(hold.getId(), "nonExistenBulkStatusId");
+
+ STEP("Verify the response status code.");
+ assertStatusCode(NOT_FOUND);
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from a site to a hold using the bulk API
+ * And the hold does not exist
+ * Then the user receives not found error
+ */
+ @Test
+ public void testGetBulkStatusesForNonExistentHold()
+ {
+ STEP("Start bulk process for non existent hold");
+ getRestAPIFactory().getHoldsAPI(getAdminUser()).getBulkStatuses("nonExistentHoldId");
+
+ STEP("Verify the response status code.");
+ assertStatusCode(NOT_FOUND);
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from all sites to a hold using the bulk API to exceed the limit (30 items)
+ * Then the user receives bad request error
+ */
+ @Test
+ public void testExceedingBulkOperationLimit()
+ {
+ RestRequestQueryModel queryReq = new RestRequestQueryModel();
+ queryReq.setQuery("TYPE:content");
+ queryReq.setLanguage("afts");
+
+ HoldBulkOperation exceedLimitOp = HoldBulkOperation.builder()
+ .query(queryReq)
+ .op(HoldBulkOperationType.ADD).build();
+
+ STEP("Start bulk process to exceed the limit");
+ getRestAPIFactory().getHoldsAPI(getAdminUser()).startBulkProcess(exceedLimitOp, hold.getId());
+
+ STEP("Verify the response status code.");
+ assertStatusCode(BAD_REQUEST);
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from a site to a hold using the bulk API
+ * And then the user cancels the bulk operation
+ * Then the user receives OK status code
+ */
+ @Test
+ public void testBulkProcessCancellationWithAllowedUser()
+ {
+ Hold hold4 = getRestAPIFactory().getFilePlansAPI(getAdminUser()).createHold(
+ Hold.builder().name("HOLD" + generateTestPrefix(AddToHoldsV1Tests.class)).description(HOLD_DESCRIPTION)
+ .reason(HOLD_REASON).build(), FILE_PLAN_ALIAS);
+ holds.add(hold4);
+
+ UserModel userAddHoldPermission = roleService.createUserWithSiteRoleRMRoleAndPermission(testSite,
+ UserRole.SiteCollaborator, hold4.getId(), UserRoles.ROLE_RM_MANAGER, PERMISSION_FILING);
+ users.add(userAddHoldPermission);
+
+ STEP("Add content from the site to the hold using the bulk API.");
+ HoldBulkOperationEntry bulkOperationEntry = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .startBulkProcess(holdBulkOperation, hold4.getId());
+
+ // Verify the status code
+ assertStatusCode(ACCEPTED);
+ assertEquals(NUMBER_OF_FILES, bulkOperationEntry.getTotalItems());
+
+ STEP("Cancel the bulk operation.");
+ getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .cancelBulkOperation(hold4.getId(), bulkOperationEntry.getBulkStatusId(), new BulkBodyCancel());
+
+ // Verify the status code
+ assertStatusCode(OK);
+ }
+
+ /**
+ * Given a user with the add to hold capability and hold filing permission
+ * When the user adds content from a site to a hold using the bulk API
+ * And a 2nd user without the add to hold capability cancels the bulk operation
+ * Then the 2nd user receives access denied error
+ */
+ @Test
+ public void testBulkProcessCancellationWithUserWithoutAddToHoldCapability()
+ {
+ Hold hold5 = getRestAPIFactory().getFilePlansAPI(getAdminUser()).createHold(
+ Hold.builder().name("HOLD" + generateTestPrefix(AddToHoldsV1Tests.class)).description(HOLD_DESCRIPTION)
+ .reason(HOLD_REASON).build(), FILE_PLAN_ALIAS);
+ holds.add(hold5);
+
+ UserModel userAddHoldPermission = roleService.createUserWithSiteRoleRMRoleAndPermission(testSite,
+ UserRole.SiteCollaborator, hold5.getId(), UserRoles.ROLE_RM_MANAGER, PERMISSION_FILING);
+ users.add(userAddHoldPermission);
+
+ STEP("Add content from the site to the hold using the bulk API.");
+ HoldBulkOperationEntry bulkOperationEntry = getRestAPIFactory().getHoldsAPI(userAddHoldPermission)
+ .startBulkProcess(holdBulkOperation, hold5.getId());
+
+ // Verify the status code
+ assertStatusCode(ACCEPTED);
+ assertEquals(NUMBER_OF_FILES, bulkOperationEntry.getTotalItems());
+
+ UserModel userWithoutAddToHoldCapability = roleService.createUserWithSiteRoleRMRoleAndPermission(testSite,
+ UserRole
+ .SiteCollaborator,
+ hold5.getId(), UserRoles.ROLE_RM_POWER_USER, PERMISSION_FILING);
+ users.add(userWithoutAddToHoldCapability);
+
+ STEP("Cancel the bulk operation.");
+ getRestAPIFactory().getHoldsAPI(userWithoutAddToHoldCapability)
+ .cancelBulkOperation(hold5.getId(), bulkOperationEntry.getBulkStatusId(), new BulkBodyCancel());
+
+ STEP("Verify the response status code and the error message.");
+ assertStatusCode(FORBIDDEN);
+ getRestAPIFactory().getRmRestWrapper().assertLastError().containsSummary(ACCESS_DENIED_ERROR_MESSAGE);
+ }
+
+ private void assertBulkProcessStatus(HoldBulkStatus holdBulkStatus, long expectedProcessedItems,
+ int expectedErrorsCount, String expectedErrorMessage, HoldBulkOperation holdBulkOperation)
+ {
+ assertEquals("DONE", holdBulkStatus.getStatus());
+ assertEquals(expectedProcessedItems, holdBulkStatus.getTotalItems());
+ assertEquals(expectedProcessedItems, holdBulkStatus.getProcessedItems());
+ assertEquals(expectedErrorsCount, holdBulkStatus.getErrorsCount());
+ assertEquals(holdBulkStatus.getHoldBulkOperation(), holdBulkOperation);
+ assertNotNull(holdBulkStatus.getStartTime());
+ assertNotNull(holdBulkStatus.getEndTime());
+
+ if (expectedErrorMessage != null)
+ {
+ assertTrue(holdBulkStatus.getLastError().contains(expectedErrorMessage));
+ }
+ }
+
+ private RestRequestQueryModel getContentFromSiteQuery(String siteId)
+ {
+ RestRequestQueryModel queryReq = new RestRequestQueryModel();
+ queryReq.setQuery("SITE:\"" + siteId + "\" and TYPE:content");
+ queryReq.setLanguage("afts");
+ return queryReq;
+ }
+
+ private RestRequestQueryModel getContentFromFolderAndAllSubfoldersQuery(String folderId)
+ {
+ RestRequestQueryModel queryReq = new RestRequestQueryModel();
+ queryReq.setQuery("ANCESTOR:\"workspace://SpacesStore/" + folderId + "\" and TYPE:content");
+ queryReq.setLanguage("afts");
+ return queryReq;
+ }
+
+ @AfterClass(alwaysRun = true)
+ public void cleanupAddToHoldsBulkV1Tests()
+ {
+ dataSite.usingAdmin().deleteSite(testSite);
+ users.forEach(user -> getDataUser().usingAdmin().deleteUser(user));
+ holds.forEach(hold -> getRestAPIFactory().getHoldsAPI(getAdminUser()).deleteHold(hold.getId()));
+ }
+}
\ No newline at end of file
diff --git a/amps/ags/rm-automation/rm-automation-community-rest-api/src/test/resources/default.properties b/amps/ags/rm-automation/rm-automation-community-rest-api/src/test/resources/default.properties
index 60d58a5b73..0586dd982d 100644
--- a/amps/ags/rm-automation/rm-automation-community-rest-api/src/test/resources/default.properties
+++ b/amps/ags/rm-automation/rm-automation-community-rest-api/src/test/resources/default.properties
@@ -45,9 +45,9 @@ serverHealth.showTenants=false
# testManagement.username=
# testManagement.apiKey=
# testManagement.project=
+# testManagement.testRun=
# testManagement.includeOnlyTestCasesExecuted=true #if you want to include in your run ONLY the test cases that you run, then set this value to false
-# testManagement.rateLimitInSeconds=1 #is the default rate limit after what minimum time, should we upload the next request. http://docs.gurock.com/testrail-api2/introduction #Rate Limit
+# testManagement.rateLimitInSeconds=1 #is the default rate limit after what minimum time, should we upload the next request. http://docs.gurock.com/testrail-api2/introduction #Rate Limit
# testManagement.suiteId=23 (the id of the Master suite)
# ------------------------------------------------------
testManagement.enabled=false
@@ -72,7 +72,7 @@ reports.path=./target/reports
#
# MySQL:
# db.url = jdbc:mysql://${alfresco.server}:3306/alfresco
-#
+#
# PostgreSQL:
# db.url = jdbc:postgresql://:3306/alfresco
#
diff --git a/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/alfresco-global.properties b/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/alfresco-global.properties
index 0b0936856c..13f938dc27 100644
--- a/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/alfresco-global.properties
+++ b/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/alfresco-global.properties
@@ -139,3 +139,26 @@ content.metadata.async.extract.6.enabled=false
# Max number of entries returned in Record search view
rm.recordSearch.maxItems=500
+
+#
+# Hold bulk
+#
+# The number of worker threads.
+rm.hold.bulk.threadCount=2
+# The maximum number of total items to process in a single bulk operation.
+rm.hold.bulk.maxItems=1000
+# The number of entries to be fetched from the Search Service as a next set of work object to process.
+rm.hold.bulk.batchSize=100
+# The number of entries to process before reporting progress.
+rm.hold.bulk.logging.interval=100
+# The number of entries we process at a time in a transaction.
+rm.hold.bulk.itemsPerTransaction=1
+# The maximum number of bulk requests we can process in parallel.
+rm.hold.bulk.maxParallelRequests=10
+
+cache.bulkHoldStatusCache.cluster.type=fully-distributed
+cache.bulkHoldStatusCache.timeToLiveSeconds=2592000
+cache.bulkHoldRegistryCache.cluster.type=fully-distributed
+cache.bulkHoldRegistryCache.timeToLiveSeconds=2592000
+cache.bulkCancellationsCache.cluster.type=fully-distributed
+cache.bulkCancellationsCache.timeToLiveSeconds=2592000
\ No newline at end of file
diff --git a/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/module-context.xml b/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/module-context.xml
index 4ccba8a2a0..70ddb82712 100644
--- a/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/module-context.xml
+++ b/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/module-context.xml
@@ -89,6 +89,9 @@
+
+
+
diff --git a/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/rm-bulk-context.xml b/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/rm-bulk-context.xml
new file mode 100644
index 0000000000..c8c5dc3678
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/rm-bulk-context.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${rm.hold.bulk.threadCount}
+
+
+ ${rm.hold.bulk.batchSize}
+
+
+ ${rm.hold.bulk.maxItems}
+
+
+ ${rm.hold.bulk.logging.interval}
+
+
+ ${rm.hold.bulk.itemsPerTransaction}
+
+
+ ${rm.hold.bulk.maxParallelRequests}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/rm-public-rest-context.xml b/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/rm-public-rest-context.xml
index f969a458ea..f1f225c569 100644
--- a/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/rm-public-rest-context.xml
+++ b/amps/ags/rm-community/rm-community-repo/config/alfresco/module/org_alfresco_module_rm/rm-public-rest-context.xml
@@ -83,6 +83,14 @@
+
+
+
+
+
+
+
+
diff --git a/amps/ags/rm-community/rm-community-repo/docker-compose.yml b/amps/ags/rm-community/rm-community-repo/docker-compose.yml
index 2b3b10a747..1d128c8634 100644
--- a/amps/ags/rm-community/rm-community-repo/docker-compose.yml
+++ b/amps/ags/rm-community/rm-community-repo/docker-compose.yml
@@ -41,6 +41,8 @@ services:
-Daos.baseUrlOverwrite=http://localhost:8080/alfresco/aos
-Dmessaging.broker.url=\"failover:(tcp://activemq:61616)?timeout=3000&jms.useCompression=true\"
-DlocalTransform.core-aio.url=http://transform-core-aio:8090/
+ -Drm.hold.bulk.maxItems=5
+ -Drm.hold.bulk.batchSize=2
"
ports:
- 8080:8080
diff --git a/amps/ags/rm-community/rm-community-repo/pom.xml b/amps/ags/rm-community/rm-community-repo/pom.xml
index 2a61e434d1..d0e6b9d12a 100644
--- a/amps/ags/rm-community/rm-community-repo/pom.xml
+++ b/amps/ags/rm-community/rm-community-repo/pom.xml
@@ -155,6 +155,12 @@
lombok
provided
+
+ org.awaitility
+ awaitility
+ ${dependency.awaitility.version}
+ test
+
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkBaseService.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkBaseService.java
new file mode 100644
index 0000000000..aeedc1f67c
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkBaseService.java
@@ -0,0 +1,265 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk;
+
+import static java.util.concurrent.Executors.newFixedThreadPool;
+
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.alfresco.repo.batch.BatchProcessWorkProvider;
+import org.alfresco.repo.batch.BatchProcessor;
+import org.alfresco.repo.batch.BatchProcessor.BatchProcessWorker;
+import org.alfresco.rest.api.search.impl.SearchMapper;
+import org.alfresco.rest.api.search.model.Query;
+import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException;
+import org.alfresco.service.ServiceRegistry;
+import org.alfresco.service.cmr.repository.NodeRef;
+import org.alfresco.service.cmr.search.ResultSet;
+import org.alfresco.service.cmr.search.SearchParameters;
+import org.alfresco.service.cmr.search.SearchService;
+import org.alfresco.service.transaction.TransactionService;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.beans.factory.InitializingBean;
+
+/**
+ * A base class for executing bulk operations on nodes based on search query results
+ */
+public abstract class BulkBaseService implements InitializingBean
+{
+ private static final Log LOG = LogFactory.getLog(BulkBaseService.class);
+ protected ExecutorService executorService;
+ protected ServiceRegistry serviceRegistry;
+ protected SearchService searchService;
+ protected TransactionService transactionService;
+ protected SearchMapper searchMapper;
+ protected BulkMonitor bulkMonitor;
+
+ protected int threadCount;
+ protected int batchSize;
+ protected int itemsPerTransaction;
+ protected int maxItems;
+ protected int loggingInterval;
+ protected int maxParallelRequests;
+
+ @Override
+ public void afterPropertiesSet() throws Exception
+ {
+ this.searchService = serviceRegistry.getSearchService();
+ this.executorService = newFixedThreadPool(maxParallelRequests);
+ }
+
+ /**
+ * Execute bulk operation on node based on the search query results
+ *
+ * @param nodeRef node reference
+ * @param bulkOperation bulk operation
+ * @return bulk status
+ */
+ public T execute(NodeRef nodeRef, BulkOperation bulkOperation)
+ {
+ checkPermissions(nodeRef, bulkOperation);
+
+ ResultSet resultSet = getTotalItems(bulkOperation.searchQuery(), maxItems);
+ if (maxItems < resultSet.getNumberFound() || resultSet.hasMore())
+ {
+ throw new InvalidArgumentException("Too many items to process. Please refine your query.");
+ }
+ long totalItems = resultSet.getNumberFound();
+ // Generate a random process id
+ String processId = UUID.randomUUID().toString();
+
+ T initBulkStatus = getInitBulkStatus(processId, totalItems);
+ bulkMonitor.updateBulkStatus(initBulkStatus);
+ bulkMonitor.registerProcess(nodeRef, processId, bulkOperation);
+
+ BulkProgress bulkProgress = new BulkProgress(totalItems, processId, new AtomicBoolean(false),
+ new AtomicInteger(0));
+ BatchProcessWorker batchProcessWorker = getWorkerProvider(nodeRef, bulkOperation, bulkProgress);
+ BulkStatusUpdater bulkStatusUpdater = getBulkStatusUpdater();
+
+ BatchProcessor batchProcessor = new BatchProcessor<>(
+ processId,
+ transactionService.getRetryingTransactionHelper(),
+ getWorkProvider(bulkOperation, bulkStatusUpdater, bulkProgress),
+ threadCount,
+ itemsPerTransaction,
+ bulkStatusUpdater,
+ LOG,
+ loggingInterval);
+
+ runAsyncBatchProcessor(batchProcessor, batchProcessWorker, bulkStatusUpdater);
+ return initBulkStatus;
+ }
+
+ /**
+ * Run batch processor
+ */
+ protected void runAsyncBatchProcessor(BatchProcessor batchProcessor,
+ BatchProcessWorker batchProcessWorker, BulkStatusUpdater bulkStatusUpdater)
+ {
+ Runnable backgroundLogic = () -> {
+ try
+ {
+ if (LOG.isDebugEnabled())
+ {
+ LOG.debug("Started processing batch with name: " + batchProcessor.getProcessName());
+ }
+ batchProcessor.processLong(batchProcessWorker, true);
+ if (LOG.isDebugEnabled())
+ {
+ LOG.debug("Processing batch with name: " + batchProcessor.getProcessName() + " completed");
+ }
+ }
+ catch (Exception exception)
+ {
+ LOG.error("Error processing batch with name: " + batchProcessor.getProcessName(), exception);
+ }
+ finally
+ {
+ bulkStatusUpdater.update();
+ }
+ };
+
+ executorService.submit(backgroundLogic);
+ }
+
+ /**
+ * Get initial bulk status
+ *
+ * @param processId process id
+ * @param totalItems total items
+ * @return bulk status
+ */
+ protected abstract T getInitBulkStatus(String processId, long totalItems);
+
+ /**
+ * Get bulk status updater
+ *
+ * @return bulk status updater
+ */
+ protected abstract BulkStatusUpdater getBulkStatusUpdater();
+
+ /**
+ * Get work provider
+ *
+ * @param bulkOperation bulk operation
+ * @param bulkStatusUpdater bulk status updater
+ * @param bulkProgress bulk progress
+ * @return work provider
+ */
+ protected abstract BatchProcessWorkProvider getWorkProvider(BulkOperation bulkOperation,
+ BulkStatusUpdater bulkStatusUpdater, BulkProgress bulkProgress);
+
+ /**
+ * Get worker provider
+ *
+ * @param nodeRef node reference
+ * @param bulkOperation bulk operation
+ * @param bulkProgress bulk progress
+ * @return worker provider
+ */
+ protected abstract BatchProcessWorker getWorkerProvider(NodeRef nodeRef, BulkOperation bulkOperation,
+ BulkProgress bulkProgress);
+
+ /**
+ * Check permissions
+ *
+ * @param nodeRef node reference
+ * @param bulkOperation bulk operation
+ */
+ protected abstract void checkPermissions(NodeRef nodeRef, BulkOperation bulkOperation);
+
+ protected ResultSet getTotalItems(Query searchQuery, int skipCount)
+ {
+ SearchParameters searchParams = new SearchParameters();
+ searchMapper.setDefaults(searchParams);
+ searchMapper.fromQuery(searchParams, searchQuery);
+ searchParams.setSkipCount(skipCount);
+ searchParams.setMaxItems(1);
+ searchParams.setLimit(1);
+ return searchService.query(searchParams);
+ }
+
+ public void setServiceRegistry(ServiceRegistry serviceRegistry)
+ {
+ this.serviceRegistry = serviceRegistry;
+ }
+
+ public void setSearchService(SearchService searchService)
+ {
+ this.searchService = searchService;
+ }
+
+ public void setTransactionService(TransactionService transactionService)
+ {
+ this.transactionService = transactionService;
+ }
+
+ public void setSearchMapper(SearchMapper searchMapper)
+ {
+ this.searchMapper = searchMapper;
+ }
+
+ public void setBulkMonitor(BulkMonitor bulkMonitor)
+ {
+ this.bulkMonitor = bulkMonitor;
+ }
+
+ public void setThreadCount(int threadCount)
+ {
+ this.threadCount = threadCount;
+ }
+
+ public void setBatchSize(int batchSize)
+ {
+ this.batchSize = batchSize;
+ }
+
+ public void setMaxItems(int maxItems)
+ {
+ this.maxItems = maxItems;
+ }
+
+ public void setLoggingInterval(int loggingInterval)
+ {
+ this.loggingInterval = loggingInterval;
+ }
+
+ public void setItemsPerTransaction(int itemsPerTransaction)
+ {
+ this.itemsPerTransaction = itemsPerTransaction;
+ }
+
+ public void setMaxParallelRequests(int maxParallelRequests)
+ {
+ this.maxParallelRequests = maxParallelRequests;
+ }
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkCancellationRequest.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkCancellationRequest.java
new file mode 100644
index 0000000000..21f9c4a680
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkCancellationRequest.java
@@ -0,0 +1,34 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk;
+
+/**
+ * An immutable POJO to represent a bulk cancellation request
+ */
+public record BulkCancellationRequest(String reason)
+{
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkMonitor.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkMonitor.java
new file mode 100644
index 0000000000..cb5ce3e099
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkMonitor.java
@@ -0,0 +1,83 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk;
+
+import org.alfresco.service.cmr.repository.NodeRef;
+
+/**
+ * An interface for monitoring the progress of a bulk operation
+ */
+public interface BulkMonitor
+{
+ /**
+ * Update the bulk status
+ *
+ * @param bulkStatus the bulk status
+ */
+ void updateBulkStatus(T bulkStatus);
+
+ /**
+ * Register a process
+ *
+ * @param nodeRef the node reference
+ * @param processId the process id
+ * @param bulkOperation the bulk operation
+ */
+ void registerProcess(NodeRef nodeRef, String processId, BulkOperation bulkOperation);
+
+ /**
+ * Get the bulk status
+ *
+ * @param bulkStatusId the bulk status id
+ * @return the bulk status
+ */
+ T getBulkStatus(String bulkStatusId);
+
+ /**
+ * Cancel a bulk operation
+ *
+ * @param bulkStatusId
+ * @param bulkCancellationRequest
+ */
+ void cancelBulkOperation(String bulkStatusId, BulkCancellationRequest bulkCancellationRequest);
+
+ /**
+ * Check if a bulk operation is cancelled
+ *
+ * @param bulkStatusId
+ * @return true if the bulk operation is cancelled
+ */
+ boolean isCancelled(String bulkStatusId);
+
+ /**
+ * Get the bulk cancellation request
+ *
+ * @param bulkStatusId
+ * @return cancellation reason
+ */
+ BulkCancellationRequest getBulkCancellationRequest(String bulkStatusId);
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkOperation.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkOperation.java
new file mode 100644
index 0000000000..f5483220b3
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkOperation.java
@@ -0,0 +1,46 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk;
+
+import java.io.Serializable;
+
+import org.alfresco.rest.api.search.model.Query;
+
+/**
+ * An immutable POJO to represent a bulk operation
+ */
+public record BulkOperation(Query searchQuery, String operationType) implements Serializable
+{
+ public BulkOperation
+ {
+ if (operationType == null || searchQuery == null)
+ {
+ throw new IllegalArgumentException("Operation type and search query must not be null");
+ }
+ }
+}
+
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkProgress.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkProgress.java
new file mode 100644
index 0000000000..3ee04df8ff
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkProgress.java
@@ -0,0 +1,37 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * An immutable POJO to represent the progress of a bulk operation
+ */
+public record BulkProgress(long totalItems, String processId, AtomicBoolean cancelled, AtomicInteger currentNodeNumber)
+{
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkStatusUpdater.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkStatusUpdater.java
new file mode 100644
index 0000000000..9dcc75a769
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/BulkStatusUpdater.java
@@ -0,0 +1,40 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk;
+
+import org.springframework.context.ApplicationEventPublisher;
+
+/**
+ * An interface for updating the status of a bulk operation
+ */
+public interface BulkStatusUpdater extends ApplicationEventPublisher
+{
+ /**
+ * Update the bulk status
+ */
+ void update();
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/DefaultHoldBulkMonitor.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/DefaultHoldBulkMonitor.java
new file mode 100644
index 0000000000..e02392a6c0
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/DefaultHoldBulkMonitor.java
@@ -0,0 +1,165 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk.hold;
+
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Function;
+
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkCancellationRequest;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkOperation;
+import org.alfresco.repo.cache.SimpleCache;
+import org.alfresco.service.cmr.repository.NodeRef;
+import org.alfresco.util.Pair;
+import org.springframework.context.ApplicationEvent;
+import org.springframework.extensions.surf.util.AbstractLifecycleBean;
+
+/**
+ * Default hold bulk monitor implementation
+ */
+public class DefaultHoldBulkMonitor extends AbstractLifecycleBean implements HoldBulkMonitor
+{
+ protected SimpleCache holdProgressCache;
+ protected SimpleCache bulkCancellationsCache;
+ protected SimpleCache, HoldBulkProcessDetails> holdProcessRegistry;
+
+ @Override
+ public void updateBulkStatus(HoldBulkStatus holdBulkStatus)
+ {
+ holdProgressCache.put(holdBulkStatus.bulkStatusId(), holdBulkStatus);
+ }
+
+ @Override
+ public void registerProcess(NodeRef holdRef, String processId, BulkOperation bulkOperation)
+ {
+ if (holdRef != null && processId != null)
+ {
+ holdProcessRegistry.put(new Pair<>(holdRef.getId(), processId),
+ new HoldBulkProcessDetails(processId, getCurrentInstanceDetails(), bulkOperation));
+ }
+ }
+
+ @Override
+ public HoldBulkStatus getBulkStatus(String bulkStatusId)
+ {
+ return holdProgressCache.get(bulkStatusId);
+ }
+
+ @Override
+ public void cancelBulkOperation(String bulkStatusId, BulkCancellationRequest bulkCancellationRequest)
+ {
+ bulkCancellationsCache.put(bulkStatusId, bulkCancellationRequest);
+ }
+
+ @Override
+ public boolean isCancelled(String bulkStatusId)
+ {
+ return bulkCancellationsCache.contains(bulkStatusId);
+ }
+
+ @Override
+ public BulkCancellationRequest getBulkCancellationRequest(String bulkStatusId)
+ {
+ return bulkCancellationsCache.get(bulkStatusId);
+ }
+
+ @Override
+ public List getBulkStatusesWithProcessDetails(String holdId)
+ {
+ return holdProcessRegistry.getKeys().stream()
+ .filter(holdIdAndBulkStatusId -> holdId.equals(holdIdAndBulkStatusId.getFirst()))
+ .map(holdIdAndBulkStatusId -> holdProcessRegistry.get(holdIdAndBulkStatusId))
+ .filter(Objects::nonNull)
+ .map(createHoldBulkStatusAndProcessDetails())
+ .filter(statusAndProcess -> Objects.nonNull(statusAndProcess.holdBulkStatus()))
+ .sorted(sortBulkStatuses())
+ .toList();
+ }
+
+ @Override
+ public HoldBulkStatusAndProcessDetails getBulkStatusWithProcessDetails(String holdId, String bulkStatusId)
+ {
+ return Optional.ofNullable(holdProcessRegistry.get(new Pair<>(holdId, bulkStatusId)))
+ .map(createHoldBulkStatusAndProcessDetails())
+ .filter(statusAndProcess -> Objects.nonNull(statusAndProcess.holdBulkStatus()))
+ .orElse(null);
+ }
+
+ protected String getCurrentInstanceDetails()
+ {
+ return null;
+ }
+
+ protected Function createHoldBulkStatusAndProcessDetails()
+ {
+ return bulkProcessDetails -> new HoldBulkStatusAndProcessDetails(
+ getBulkStatus(bulkProcessDetails.bulkStatusId()), bulkProcessDetails);
+ }
+
+ protected static Comparator sortBulkStatuses()
+ {
+ return Comparator.comparing(
+ statusAndProcess -> statusAndProcess.holdBulkStatus().endTime(),
+ Comparator.nullsLast(Comparator.naturalOrder()))
+ .thenComparing(statusAndProcess -> statusAndProcess.holdBulkStatus().startTime(),
+ Comparator.nullsLast(Comparator.naturalOrder()))
+ .reversed();
+ }
+
+ public void setHoldProgressCache(
+ SimpleCache holdProgressCache)
+ {
+ this.holdProgressCache = holdProgressCache;
+ }
+
+ public void setHoldProcessRegistry(
+ SimpleCache, HoldBulkProcessDetails> holdProcessRegistry)
+ {
+ this.holdProcessRegistry = holdProcessRegistry;
+ }
+
+ public void setBulkCancellationsCache(
+ SimpleCache bulkCancellationsCache)
+ {
+ this.bulkCancellationsCache = bulkCancellationsCache;
+ }
+
+ @Override
+ protected void onBootstrap(ApplicationEvent applicationEvent)
+ {
+ // NOOP
+ }
+
+ @Override
+ protected void onShutdown(ApplicationEvent applicationEvent)
+ {
+ // NOOP
+ }
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkMonitor.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkMonitor.java
new file mode 100644
index 0000000000..b7b094c3f9
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkMonitor.java
@@ -0,0 +1,54 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk.hold;
+
+import java.util.List;
+
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkMonitor;
+
+/**
+ * An interface for monitoring the progress of a bulk hold operation
+ */
+public interface HoldBulkMonitor extends BulkMonitor
+{
+ /**
+ * Get the bulk statuses with process details for a hold
+ *
+ * @param holdId the hold id
+ * @return the bulk statuses with process details
+ */
+ List getBulkStatusesWithProcessDetails(String holdId);
+
+ /**
+ * Get the bulk status with process details
+ *
+ * @param holdId the hold id
+ * @param bulkStatusId the bulk status id
+ * @return the bulk status with process details
+ */
+ HoldBulkStatusAndProcessDetails getBulkStatusWithProcessDetails(String holdId, String bulkStatusId);
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkProcessDetails.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkProcessDetails.java
new file mode 100644
index 0000000000..f173e547cc
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkProcessDetails.java
@@ -0,0 +1,38 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk.hold;
+
+import java.io.Serializable;
+
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkOperation;
+
+/**
+ * A simple immutable POJO to hold the details of a bulk process
+ */
+public record HoldBulkProcessDetails(String bulkStatusId, String creatorInstance, BulkOperation bulkOperation) implements Serializable
+{
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkService.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkService.java
new file mode 100644
index 0000000000..c69c1453ad
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkService.java
@@ -0,0 +1,55 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk.hold;
+
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkCancellationRequest;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkOperation;
+import org.alfresco.service.cmr.repository.NodeRef;
+
+/**
+ * Interface defining a hold bulk service.
+ */
+public interface HoldBulkService
+{
+ /**
+ * Initiates a bulk operation on a hold.
+ *
+ * @param holdRef The hold reference
+ * @param bulkOperation The bulk operation
+ * @return The initial status of the bulk operation
+ */
+ HoldBulkStatus execute(NodeRef holdRef, BulkOperation bulkOperation);
+
+ /**
+ * Cancels a bulk operation.
+ *
+ * @param holdRef The hold reference
+ * @param bulkStatusId The bulk status id
+ * @param bulkCancellationRequest The bulk cancellation request
+ */
+ void cancelBulkOperation(NodeRef holdRef, String bulkStatusId, BulkCancellationRequest bulkCancellationRequest);
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkServiceImpl.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkServiceImpl.java
new file mode 100644
index 0000000000..82df562e41
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkServiceImpl.java
@@ -0,0 +1,286 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk.hold;
+
+import static org.alfresco.model.ContentModel.PROP_NAME;
+import static org.alfresco.rm.rest.api.model.HoldBulkOperationType.ADD;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Locale;
+import java.util.Optional;
+
+import org.alfresco.model.ContentModel;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkBaseService;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkCancellationRequest;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkOperation;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkProgress;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkStatusUpdater;
+import org.alfresco.module.org_alfresco_module_rm.capability.CapabilityService;
+import org.alfresco.module.org_alfresco_module_rm.capability.RMPermissionModel;
+import org.alfresco.module.org_alfresco_module_rm.hold.HoldService;
+import org.alfresco.repo.batch.BatchProcessWorkProvider;
+import org.alfresco.repo.batch.BatchProcessor.BatchProcessWorker;
+import org.alfresco.repo.security.authentication.AuthenticationUtil;
+import org.alfresco.repo.security.permissions.AccessDeniedException;
+import org.alfresco.rest.api.search.model.Query;
+import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException;
+import org.alfresco.rm.rest.api.model.HoldBulkOperationType;
+import org.alfresco.service.cmr.repository.NodeRef;
+import org.alfresco.service.cmr.repository.NodeService;
+import org.alfresco.service.cmr.search.ResultSet;
+import org.alfresco.service.cmr.search.SearchParameters;
+import org.alfresco.service.cmr.security.AccessStatus;
+import org.alfresco.service.cmr.security.PermissionService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.extensions.surf.util.I18NUtil;
+
+/**
+ * Implementation of the {@link HoldBulkService} interface.
+ */
+@SuppressWarnings("PMD.PreserveStackTrace")
+public class HoldBulkServiceImpl extends BulkBaseService implements HoldBulkService
+{
+ private static final Logger LOGGER = LoggerFactory.getLogger(HoldBulkServiceImpl.class);
+ private static final String MSG_ERR_ACCESS_DENIED = "permissions.err_access_denied";
+
+ private HoldService holdService;
+ private CapabilityService capabilityService;
+ private PermissionService permissionService;
+ private NodeService nodeService;
+
+ @Override
+ protected HoldBulkStatus getInitBulkStatus(String processId, long totalItems)
+ {
+ return new HoldBulkStatus(processId, null, null, 0, 0, totalItems, null, false, null);
+ }
+
+ @Override
+ protected BulkStatusUpdater getBulkStatusUpdater()
+ {
+ return new HoldBulkStatusUpdater((HoldBulkMonitor) bulkMonitor);
+ }
+
+ @Override
+ protected BatchProcessWorkProvider getWorkProvider(BulkOperation bulkOperation,
+ BulkStatusUpdater bulkStatusUpdater, BulkProgress bulkProgress)
+ {
+ return new AddToHoldWorkerProvider(bulkOperation, bulkStatusUpdater, bulkProgress,
+ (HoldBulkMonitor) bulkMonitor);
+ }
+
+ @Override
+ protected BatchProcessWorker getWorkerProvider(NodeRef nodeRef, BulkOperation bulkOperation,
+ BulkProgress bulkProgress)
+ {
+ try
+ {
+ HoldBulkOperationType holdBulkOperationType = HoldBulkOperationType.valueOf(bulkOperation.operationType()
+ .toUpperCase(Locale.ENGLISH));
+ return switch (holdBulkOperationType)
+ {
+ case ADD -> new AddToHoldWorkerBatch(nodeRef, bulkProgress);
+ };
+ }
+ catch (IllegalArgumentException e)
+ {
+ String errorMsg = "Unsupported action type when starting the bulk process: ";
+ if (LOGGER.isDebugEnabled())
+ {
+ LOGGER.debug("{} {}", errorMsg, bulkOperation.operationType(), e);
+ }
+ throw new InvalidArgumentException(errorMsg + bulkOperation.operationType());
+ }
+ }
+
+ @Override
+ protected void checkPermissions(NodeRef holdRef, BulkOperation bulkOperation)
+ {
+ if (!holdService.isHold(holdRef))
+ {
+ final String holdName = (String) nodeService.getProperty(holdRef, PROP_NAME);
+ throw new InvalidArgumentException(I18NUtil.getMessage("rm.hold.not-hold", holdName), null);
+ }
+ if (ADD.name().equals(bulkOperation.operationType()) && (!AccessStatus.ALLOWED.equals(
+ capabilityService.getCapabilityAccessState(holdRef, RMPermissionModel.ADD_TO_HOLD)) ||
+ permissionService.hasPermission(holdRef, RMPermissionModel.FILING) == AccessStatus.DENIED))
+ {
+ throw new AccessDeniedException(I18NUtil.getMessage(MSG_ERR_ACCESS_DENIED));
+ }
+
+ }
+
+ @Override
+ public void cancelBulkOperation(NodeRef holdRef, String bulkStatusId, BulkCancellationRequest cancellationRequest)
+ {
+ if (bulkMonitor instanceof HoldBulkMonitor holdBulkMonitor)
+ {
+ HoldBulkStatusAndProcessDetails statusAndProcessDetails = holdBulkMonitor.getBulkStatusWithProcessDetails(
+ holdRef.getId(), bulkStatusId);
+
+ Optional.ofNullable(statusAndProcessDetails).map(HoldBulkStatusAndProcessDetails::holdBulkProcessDetails)
+ .map(HoldBulkProcessDetails::bulkOperation).ifPresent(bulkOperation -> {
+ checkPermissions(holdRef, bulkOperation);
+ holdBulkMonitor.cancelBulkOperation(bulkStatusId, cancellationRequest);
+ });
+ }
+ }
+
+ private class AddToHoldWorkerBatch implements BatchProcessWorker
+ {
+ private final NodeRef holdRef;
+ private final String currentUser;
+ private final BulkProgress bulkProgress;
+
+ public AddToHoldWorkerBatch(NodeRef holdRef, BulkProgress bulkProgress)
+ {
+ this.holdRef = holdRef;
+ this.bulkProgress = bulkProgress;
+ currentUser = AuthenticationUtil.getFullyAuthenticatedUser();
+ }
+
+ @Override
+ public String getIdentifier(NodeRef entry)
+ {
+ return entry.getId();
+ }
+
+ @Override
+ public void beforeProcess()
+ {
+ AuthenticationUtil.pushAuthentication();
+ }
+
+ @Override
+ public void process(NodeRef entry) throws Throwable
+ {
+ if (!bulkProgress.cancelled().get())
+ {
+ AuthenticationUtil.setFullyAuthenticatedUser(currentUser);
+ holdService.addToHold(holdRef, entry);
+ }
+ }
+
+ @Override
+ public void afterProcess()
+ {
+ AuthenticationUtil.popAuthentication();
+ }
+ }
+
+ private class AddToHoldWorkerProvider implements BatchProcessWorkProvider
+ {
+ private final HoldBulkMonitor holdBulkMonitor;
+ private final Query searchQuery;
+ private final String currentUser;
+ private final BulkProgress bulkProgress;
+ private final BulkStatusUpdater bulkStatusUpdater;
+
+ public AddToHoldWorkerProvider(BulkOperation bulkOperation,
+ BulkStatusUpdater bulkStatusUpdater, BulkProgress bulkProgress, HoldBulkMonitor holdBulkMonitor)
+ {
+ this.searchQuery = bulkOperation.searchQuery();
+ this.bulkProgress = bulkProgress;
+ this.bulkStatusUpdater = bulkStatusUpdater;
+ this.holdBulkMonitor = holdBulkMonitor;
+ currentUser = AuthenticationUtil.getFullyAuthenticatedUser();
+ }
+
+ @Override
+ public int getTotalEstimatedWorkSize()
+ {
+ return (int) bulkProgress.totalItems();
+ }
+
+ @Override
+ public long getTotalEstimatedWorkSizeLong()
+ {
+ return bulkProgress.totalItems();
+ }
+
+ @Override
+ public Collection getNextWork()
+ {
+ AuthenticationUtil.pushAuthentication();
+ AuthenticationUtil.setFullyAuthenticatedUser(currentUser);
+ if (holdBulkMonitor.isCancelled(bulkProgress.processId()))
+ {
+ bulkProgress.cancelled().set(true);
+ return Collections.emptyList();
+ }
+ SearchParameters searchParams = getNextPageParameters();
+ ResultSet result = searchService.query(searchParams);
+ if (result.getNodeRefs().isEmpty())
+ {
+ return Collections.emptyList();
+ }
+ AuthenticationUtil.popAuthentication();
+ if (LOGGER.isDebugEnabled())
+ {
+ LOGGER.debug("Processing the next work for the batch processor, skipCount={}, size={}",
+ searchParams.getSkipCount(), result.getNumberFound());
+ }
+ bulkProgress.currentNodeNumber().addAndGet(batchSize);
+ bulkStatusUpdater.update();
+ return result.getNodeRefs();
+ }
+
+ private SearchParameters getNextPageParameters()
+ {
+ SearchParameters searchParams = new SearchParameters();
+ searchMapper.setDefaults(searchParams);
+ searchMapper.fromQuery(searchParams, searchQuery);
+ searchParams.setSkipCount(bulkProgress.currentNodeNumber().get());
+ searchParams.setMaxItems(batchSize);
+ searchParams.setLimit(batchSize);
+ searchParams.addSort("@" + ContentModel.PROP_CREATED, true);
+ return searchParams;
+ }
+
+ }
+
+ public void setHoldService(HoldService holdService)
+ {
+ this.holdService = holdService;
+ }
+
+ public void setCapabilityService(CapabilityService capabilityService)
+ {
+ this.capabilityService = capabilityService;
+ }
+
+ public void setPermissionService(PermissionService permissionService)
+ {
+ this.permissionService = permissionService;
+ }
+
+ public void setNodeService(NodeService nodeService)
+ {
+ this.nodeService = nodeService;
+ }
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkStatus.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkStatus.java
new file mode 100644
index 0000000000..222d289635
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkStatus.java
@@ -0,0 +1,78 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk.hold;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * An immutable POJO that contains the status of a hold bulk operation
+ */
+public record HoldBulkStatus(String bulkStatusId, Date startTime, Date endTime, long processedItems, long errorsCount,
+ long totalItems, String lastError, boolean isCancelled, String cancellationReason)
+ implements Serializable
+{
+ public enum Status
+ {
+ PENDING("PENDING"),
+ IN_PROGRESS("IN PROGRESS"),
+ DONE("DONE"),
+ CANCELLED("CANCELLED");
+
+ private final String value;
+
+ Status(String value)
+ {
+ this.value = value;
+ }
+
+ public String getValue()
+ {
+ return value;
+ }
+ }
+
+ public String getStatus()
+ {
+ if (isCancelled)
+ {
+ return Status.CANCELLED.getValue();
+ }
+ else if (startTime == null && endTime == null)
+ {
+ return Status.PENDING.getValue();
+ }
+ else if (startTime != null && endTime == null)
+ {
+ return Status.IN_PROGRESS.getValue();
+ }
+ else
+ {
+ return Status.DONE.getValue();
+ }
+ }
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkStatusAndProcessDetails.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkStatusAndProcessDetails.java
new file mode 100644
index 0000000000..aa646e24be
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkStatusAndProcessDetails.java
@@ -0,0 +1,35 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk.hold;
+
+/**
+ * An immutable POJO that contains the status of a hold bulk operation and the details of the process
+ */
+public record HoldBulkStatusAndProcessDetails(HoldBulkStatus holdBulkStatus,
+ HoldBulkProcessDetails holdBulkProcessDetails)
+{
+}
\ No newline at end of file
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkStatusUpdater.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkStatusUpdater.java
new file mode 100644
index 0000000000..909e8d59a1
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkStatusUpdater.java
@@ -0,0 +1,77 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk.hold;
+
+import java.util.Optional;
+
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkCancellationRequest;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkStatusUpdater;
+import org.alfresco.repo.batch.BatchMonitor;
+import org.alfresco.repo.batch.BatchMonitorEvent;
+
+/**
+ * An implementation of {@link BulkStatusUpdater} for the hold bulk operation
+ */
+public class HoldBulkStatusUpdater implements BulkStatusUpdater
+{
+ private final Runnable task;
+ private BatchMonitor batchMonitor;
+
+ public HoldBulkStatusUpdater(HoldBulkMonitor holdBulkMonitor)
+ {
+ this.task = () -> holdBulkMonitor.updateBulkStatus(
+ new HoldBulkStatus(batchMonitor.getProcessName(),
+ batchMonitor.getStartTime(),
+ batchMonitor.getEndTime(),
+ batchMonitor.getSuccessfullyProcessedEntriesLong() + batchMonitor.getTotalErrorsLong(),
+ batchMonitor.getTotalErrorsLong(),
+ batchMonitor.getTotalResultsLong(),
+ batchMonitor.getLastError(),
+ holdBulkMonitor.isCancelled(batchMonitor.getProcessName()),
+ Optional.ofNullable(holdBulkMonitor.getBulkCancellationRequest(batchMonitor.getProcessName()))
+ .map(BulkCancellationRequest::reason)
+ .orElse(null)));
+ }
+
+ @Override
+ public void update()
+ {
+ if (task != null && batchMonitor != null)
+ {
+ task.run();
+ }
+ }
+
+ @Override
+ public void publishEvent(Object event)
+ {
+ if (event instanceof BatchMonitorEvent batchMonitorEvent)
+ {
+ batchMonitor = batchMonitorEvent.getBatchMonitor();
+ }
+ }
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkUtils.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkUtils.java
new file mode 100644
index 0000000000..9d4a0aed9b
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/module/org_alfresco_module_rm/bulk/hold/HoldBulkUtils.java
@@ -0,0 +1,69 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk.hold;
+
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkOperation;
+import org.alfresco.rest.api.search.model.Query;
+import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException;
+import org.alfresco.rm.rest.api.model.HoldBulkOperation;
+import org.alfresco.rm.rest.api.model.HoldBulkOperationType;
+import org.alfresco.rm.rest.api.model.HoldBulkStatusEntry;
+
+/**
+ * Utility class for hold bulk operations
+ */
+@SuppressWarnings("PMD.PreserveStackTrace")
+public final class HoldBulkUtils
+{
+ private HoldBulkUtils()
+ {
+ }
+
+ public static HoldBulkStatusEntry toHoldBulkStatusEntry(
+ HoldBulkStatusAndProcessDetails holdBulkStatusAndProcessDetails)
+ {
+ HoldBulkStatus bulkStatus = holdBulkStatusAndProcessDetails.holdBulkStatus();
+ BulkOperation bulkOperation = holdBulkStatusAndProcessDetails.holdBulkProcessDetails().bulkOperation();
+
+ try
+ {
+ HoldBulkOperation holdBulkOperation = new HoldBulkOperation(
+ new Query(bulkOperation.searchQuery().getLanguage(),
+ bulkOperation.searchQuery().getQuery(), bulkOperation.searchQuery().getUserQuery()),
+ HoldBulkOperationType.valueOf(bulkOperation.operationType()));
+ return new HoldBulkStatusEntry(bulkStatus.bulkStatusId(), bulkStatus.startTime(),
+ bulkStatus.endTime(), bulkStatus.processedItems(), bulkStatus.errorsCount(),
+ bulkStatus.totalItems(), bulkStatus.lastError(), bulkStatus.getStatus(),
+ bulkStatus.cancellationReason(), holdBulkOperation);
+ }
+ catch (IllegalArgumentException e)
+ {
+ String errorMsg = "Unsupported action type in the bulk operation: ";
+ throw new InvalidArgumentException(errorMsg + bulkOperation.operationType());
+ }
+ }
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/holds/HoldsBulkStatusesRelation.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/holds/HoldsBulkStatusesRelation.java
new file mode 100644
index 0000000000..8f21bf2ee9
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/holds/HoldsBulkStatusesRelation.java
@@ -0,0 +1,163 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rm.rest.api.holds;
+
+import static org.alfresco.module.org_alfresco_module_rm.util.RMParameterCheck.checkNotBlank;
+import static org.alfresco.util.ParameterCheck.mandatory;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import jakarta.servlet.http.HttpServletResponse;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkCancellationRequest;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkMonitor;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkService;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkStatusAndProcessDetails;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkUtils;
+import org.alfresco.module.org_alfresco_module_rm.model.RecordsManagementModel;
+import org.alfresco.rest.framework.Operation;
+import org.alfresco.rest.framework.WebApiDescription;
+import org.alfresco.rest.framework.core.exceptions.EntityNotFoundException;
+import org.alfresco.rest.framework.core.exceptions.NotFoundException;
+import org.alfresco.rest.framework.core.exceptions.PermissionDeniedException;
+import org.alfresco.rest.framework.core.exceptions.RelationshipResourceNotFoundException;
+import org.alfresco.rest.framework.resource.RelationshipResource;
+import org.alfresco.rest.framework.resource.actions.interfaces.RelationshipResourceAction;
+import org.alfresco.rest.framework.resource.parameters.CollectionWithPagingInfo;
+import org.alfresco.rest.framework.resource.parameters.Parameters;
+import org.alfresco.rest.framework.webscripts.WithResponse;
+import org.alfresco.rm.rest.api.impl.FilePlanComponentsApiUtils;
+import org.alfresco.rm.rest.api.model.BulkCancellationEntry;
+import org.alfresco.rm.rest.api.model.HoldBulkStatusEntry;
+import org.alfresco.service.cmr.repository.NodeRef;
+import org.alfresco.service.cmr.security.AccessStatus;
+import org.alfresco.service.cmr.security.PermissionService;
+import org.springframework.extensions.surf.util.I18NUtil;
+
+@RelationshipResource(name = "bulk-statuses", entityResource = HoldsEntityResource.class, title = "Bulk statuses of a hold")
+public class HoldsBulkStatusesRelation
+ implements RelationshipResourceAction.Read,
+ RelationshipResourceAction.ReadById
+{
+ private HoldBulkMonitor holdBulkMonitor;
+ private HoldBulkService holdBulkService;
+ private FilePlanComponentsApiUtils apiUtils;
+ private PermissionService permissionService;
+
+ @Override
+ public CollectionWithPagingInfo readAll(String holdId, Parameters parameters)
+ {
+ // validate parameters
+ checkNotBlank("holdId", holdId);
+ mandatory("parameters", parameters);
+
+ NodeRef holdRef = apiUtils.lookupAndValidateNodeType(holdId, RecordsManagementModel.TYPE_HOLD);
+
+ checkReadPermissions(holdRef);
+
+ List statuses = holdBulkMonitor.getBulkStatusesWithProcessDetails(holdId);
+ List page = statuses.stream()
+ .map(HoldBulkUtils::toHoldBulkStatusEntry)
+ .skip(parameters.getPaging().getSkipCount())
+ .limit(parameters.getPaging().getMaxItems())
+ .collect(Collectors.toCollection(LinkedList::new));
+
+ int totalItems = statuses.size();
+ boolean hasMore = parameters.getPaging().getSkipCount() + parameters.getPaging().getMaxItems() < totalItems;
+ return CollectionWithPagingInfo.asPaged(parameters.getPaging(), page, hasMore, totalItems);
+ }
+
+ @Override
+ public HoldBulkStatusEntry readById(String holdId, String bulkStatusId, Parameters parameters)
+ throws RelationshipResourceNotFoundException
+ {
+ checkNotBlank("holdId", holdId);
+ checkNotBlank("bulkStatusId", bulkStatusId);
+ mandatory("parameters", parameters);
+
+ NodeRef holdRef = apiUtils.lookupAndValidateNodeType(holdId, RecordsManagementModel.TYPE_HOLD);
+
+ checkReadPermissions(holdRef);
+
+ return Optional.ofNullable(holdBulkMonitor.getBulkStatusWithProcessDetails(holdId, bulkStatusId))
+ .map(HoldBulkUtils::toHoldBulkStatusEntry)
+ .orElseThrow(() -> new EntityNotFoundException(bulkStatusId));
+ }
+
+ @Operation("cancel")
+ @WebApiDescription(title = "Cancel a bulk operation",
+ successStatus = HttpServletResponse.SC_OK)
+ public void cancelBulkOperation(String holdId, String bulkStatusId, BulkCancellationEntry bulkCancellationEntry,
+ Parameters parameters,
+ WithResponse withResponse)
+ {
+ checkNotBlank("holdId", holdId);
+ checkNotBlank("bulkStatusId", bulkStatusId);
+ mandatory("parameters", parameters);
+
+ NodeRef holdRef = apiUtils.lookupAndValidateNodeType(holdId, RecordsManagementModel.TYPE_HOLD);
+
+ checkReadPermissions(holdRef);
+
+ if (holdBulkMonitor.getBulkStatus(bulkStatusId) == null)
+ {
+ throw new NotFoundException("Bulk status not found");
+ }
+
+ holdBulkService.cancelBulkOperation(holdRef, bulkStatusId, new BulkCancellationRequest(bulkCancellationEntry.reason()));
+ }
+
+ private void checkReadPermissions(NodeRef holdRef)
+ {
+ if (permissionService.hasReadPermission(holdRef) == AccessStatus.DENIED)
+ {
+ throw new PermissionDeniedException(I18NUtil.getMessage("permissions.err_access_denied"));
+ }
+ }
+
+ public void setHoldBulkMonitor(HoldBulkMonitor holdBulkMonitor)
+ {
+ this.holdBulkMonitor = holdBulkMonitor;
+ }
+
+ public void setApiUtils(FilePlanComponentsApiUtils apiUtils)
+ {
+ this.apiUtils = apiUtils;
+ }
+
+ public void setPermissionService(PermissionService permissionService)
+ {
+ this.permissionService = permissionService;
+ }
+
+ public void setHoldBulkService(HoldBulkService holdBulkService)
+ {
+ this.holdBulkService = holdBulkService;
+ }
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/holds/HoldsEntityResource.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/holds/HoldsEntityResource.java
index 2e8cdf9231..a7e8fe6dd5 100644
--- a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/holds/HoldsEntityResource.java
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/holds/HoldsEntityResource.java
@@ -30,6 +30,8 @@ import static org.alfresco.module.org_alfresco_module_rm.util.RMParameterCheck.c
import static org.alfresco.util.ParameterCheck.mandatory;
import jakarta.servlet.http.HttpServletResponse;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkOperation;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkService;
import org.alfresco.module.org_alfresco_module_rm.hold.HoldService;
import org.alfresco.module.org_alfresco_module_rm.model.RecordsManagementModel;
import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback;
@@ -42,6 +44,9 @@ import org.alfresco.rest.framework.resource.parameters.Parameters;
import org.alfresco.rest.framework.webscripts.WithResponse;
import org.alfresco.rm.rest.api.impl.ApiNodesModelFactory;
import org.alfresco.rm.rest.api.impl.FilePlanComponentsApiUtils;
+import org.alfresco.rm.rest.api.model.HoldBulkOperation;
+import org.alfresco.rm.rest.api.model.HoldBulkOperationEntry;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkStatus;
import org.alfresco.rm.rest.api.model.HoldDeletionReason;
import org.alfresco.rm.rest.api.model.HoldModel;
import org.alfresco.service.cmr.model.FileFolderService;
@@ -68,6 +73,7 @@ public class HoldsEntityResource implements
private ApiNodesModelFactory nodesModelFactory;
private HoldService holdService;
private TransactionService transactionService;
+ private HoldBulkService holdBulkService;
@Override
public void afterPropertiesSet() throws Exception
@@ -157,6 +163,23 @@ public class HoldsEntityResource implements
return reason;
}
+ @Operation("bulk")
+ @WebApiDescription(title = "Start the hold bulk operation",
+ successStatus = HttpServletResponse.SC_ACCEPTED)
+ public HoldBulkOperationEntry bulk(String holdId, HoldBulkOperation holdBulkOperation, Parameters parameters,
+ WithResponse withResponse)
+ {
+ // validate parameters
+ checkNotBlank("holdId", holdId);
+ mandatory("parameters", parameters);
+
+ NodeRef parentNodeRef = apiUtils.lookupAndValidateNodeType(holdId, RecordsManagementModel.TYPE_HOLD);
+
+ HoldBulkStatus holdBulkStatus = holdBulkService.execute(parentNodeRef,
+ new BulkOperation(holdBulkOperation.query(), holdBulkOperation.op().name()));
+ return new HoldBulkOperationEntry(holdBulkStatus.bulkStatusId(), holdBulkStatus.totalItems());
+ }
+
public void setApiUtils(FilePlanComponentsApiUtils apiUtils)
{
this.apiUtils = apiUtils;
@@ -181,4 +204,9 @@ public class HoldsEntityResource implements
{
this.transactionService = transactionService;
}
+
+ public void setHoldBulkService(HoldBulkService holdBulkService)
+ {
+ this.holdBulkService = holdBulkService;
+ }
}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/BulkCancellationEntry.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/BulkCancellationEntry.java
new file mode 100644
index 0000000000..940f910cbe
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/BulkCancellationEntry.java
@@ -0,0 +1,29 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rm.rest.api.model;
+
+public record BulkCancellationEntry(String reason) {}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkOperation.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkOperation.java
new file mode 100644
index 0000000000..d6cd85edc0
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkOperation.java
@@ -0,0 +1,33 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rm.rest.api.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import org.alfresco.rest.api.search.model.Query;
+
+public record HoldBulkOperation(@JsonProperty(required = true) Query query, @JsonProperty(required = true) HoldBulkOperationType op) {}
\ No newline at end of file
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkOperationEntry.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkOperationEntry.java
new file mode 100644
index 0000000000..a8d058eae9
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkOperationEntry.java
@@ -0,0 +1,29 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rm.rest.api.model;
+
+public record HoldBulkOperationEntry(String bulkStatusId, long totalItems){}
\ No newline at end of file
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkOperationType.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkOperationType.java
new file mode 100644
index 0000000000..3e8df3e134
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkOperationType.java
@@ -0,0 +1,38 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rm.rest.api.model;
+
+/**
+ * This enum represents the types of bulk operations that can be performed on holds
+ */
+public enum HoldBulkOperationType
+{
+ /**
+ * The ADD operation represents adding items to a hold in bulk.
+ */
+ ADD
+}
diff --git a/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkStatusEntry.java b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkStatusEntry.java
new file mode 100644
index 0000000000..2f37c9a3ea
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/source/java/org/alfresco/rm/rest/api/model/HoldBulkStatusEntry.java
@@ -0,0 +1,33 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.rm.rest.api.model;
+
+import java.util.Date;
+
+public record HoldBulkStatusEntry(String bulkStatusId, Date startTime, Date endTime, long processedItems, long errorsCount,
+ long totalItems, String lastError, String status, String cancellationReason, HoldBulkOperation holdBulkOperation) {
+}
diff --git a/amps/ags/rm-community/rm-community-repo/test/java/org/alfresco/module/org_alfresco_module_rm/test/integration/bulk/hold/HoldBulkServiceTest.java b/amps/ags/rm-community/rm-community-repo/test/java/org/alfresco/module/org_alfresco_module_rm/test/integration/bulk/hold/HoldBulkServiceTest.java
new file mode 100644
index 0000000000..6eca9fcf26
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/test/java/org/alfresco/module/org_alfresco_module_rm/test/integration/bulk/hold/HoldBulkServiceTest.java
@@ -0,0 +1,306 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.test.integration.bulk.hold;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import static org.awaitility.Awaitility.await;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import org.alfresco.model.ContentModel;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkCancellationRequest;
+import org.alfresco.module.org_alfresco_module_rm.bulk.BulkOperation;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkMonitor;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkServiceImpl;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkStatus;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkStatus.Status;
+import org.alfresco.module.org_alfresco_module_rm.test.util.BaseRMTestCase;
+import org.alfresco.rest.api.search.model.Query;
+import org.alfresco.service.cmr.repository.NodeRef;
+import org.alfresco.service.cmr.repository.StoreRef;
+import org.alfresco.service.cmr.search.ResultSet;
+import org.alfresco.service.cmr.search.SearchParameters;
+import org.alfresco.service.cmr.search.SearchService;
+import org.mockito.Mockito;
+import org.mockito.stubbing.Answer;
+import org.springframework.extensions.webscripts.GUID;
+
+/**
+ * Hold bulk service integration test.
+ */
+@SuppressWarnings({ "PMD.TestClassWithoutTestCases", "PMD.JUnit4TestShouldUseTestAnnotation" })
+public class HoldBulkServiceTest extends BaseRMTestCase
+{
+ private static final int RECORD_COUNT = 10;
+ private final SearchService searchServiceMock = mock(SearchService.class);
+ private final ResultSet resultSet = mock(ResultSet.class);
+ private HoldBulkServiceImpl holdBulkService;
+ private HoldBulkMonitor holdBulkMonitor;
+
+ @Override
+ protected void initServices()
+ {
+ super.initServices();
+ holdBulkMonitor = (HoldBulkMonitor) applicationContext.getBean("holdBulkMonitor");
+ holdBulkService = (HoldBulkServiceImpl) applicationContext.getBean("holdBulkService");
+ holdBulkService.setSearchService(searchServiceMock);
+ Mockito.when(searchServiceMock.query(any(SearchParameters.class))).thenReturn(resultSet);
+ }
+
+ public void testCancelBulkOperation()
+ {
+ doBehaviourDrivenTest(new BehaviourDrivenTest()
+ {
+ private NodeRef hold;
+ private HoldBulkStatus holdBulkStatus;
+ private final ResultSet resultSet = mock(ResultSet.class);
+
+ public void given()
+ {
+ Mockito.when(resultSet.getNumberFound()).thenReturn(4L);
+ Mockito.when(resultSet.hasMore()).thenReturn(false).thenReturn(true).thenReturn(false);
+ Mockito.when(resultSet.getNodeRefs())
+ .thenAnswer((Answer>) invocationOnMock -> {
+ await().pollDelay(1, SECONDS).until(() -> true);
+ return List.of(new NodeRef(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, GUID.generate()),
+ new NodeRef(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, GUID.generate()));
+ });
+ // create a hold
+ hold = holdService.createHold(filePlan, GUID.generate(), GUID.generate(), GUID.generate());
+ }
+
+ public void when()
+ {
+ BulkOperation bulkOperation = new BulkOperation(new Query("afts", "*", ""), "ADD");
+ // execute the bulk operation
+ holdBulkStatus = holdBulkService.execute(hold, bulkOperation);
+ // cancel the bulk operation
+ holdBulkMonitor.cancelBulkOperation(holdBulkStatus.bulkStatusId(),
+ new BulkCancellationRequest("No reason"));
+ await().atMost(10, SECONDS)
+ .until(() -> Objects.equals(
+ holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId()).getStatus(),
+ Status.CANCELLED.getValue()));
+ }
+
+ public void then()
+ {
+ holdBulkStatus = holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId());
+ assertNotNull(holdBulkStatus.startTime());
+ assertNotNull(holdBulkStatus.endTime());
+ assertEquals(holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId()).getStatus(),
+ HoldBulkStatus.Status.CANCELLED.getValue());
+ assertEquals(holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId()).cancellationReason(),
+ "No reason");
+ }
+ });
+ }
+
+ public void testAddRecordsToHoldViaBulk()
+ {
+ doBehaviourDrivenTest(new BehaviourDrivenTest()
+ {
+ private NodeRef hold;
+ private NodeRef recordFolder;
+ private HoldBulkStatus holdBulkStatus;
+ private final List records = new ArrayList<>(RECORD_COUNT);
+
+ public void given()
+ {
+ Mockito.when(resultSet.getNumberFound()).thenReturn(Long.valueOf(RECORD_COUNT));
+ Mockito.when(resultSet.hasMore()).thenReturn(false).thenReturn(false);
+ // create a hold
+ hold = holdService.createHold(filePlan, GUID.generate(), GUID.generate(), GUID.generate());
+
+ // create a record folder that contains records
+ NodeRef recordCategory = filePlanService.createRecordCategory(filePlan, GUID.generate());
+ recordFolder = recordFolderService.createRecordFolder(recordCategory, GUID.generate());
+ for (int i = 0; i < RECORD_COUNT; i++)
+ {
+ records.add(
+ recordService.createRecordFromContent(recordFolder, GUID.generate(), ContentModel.TYPE_CONTENT,
+ null, null));
+ }
+ Mockito.when(resultSet.getNodeRefs()).thenReturn(records).thenReturn(records)
+ .thenReturn(Collections.emptyList());
+
+ // assert current states
+ assertFalse(freezeService.isFrozen(recordFolder));
+ assertFalse(freezeService.hasFrozenChildren(recordFolder));
+ for (NodeRef record : records)
+ {
+ assertFalse(freezeService.isFrozen(record));
+ }
+
+ // additional check for child held caching
+ assertTrue(nodeService.hasAspect(recordFolder, ASPECT_HELD_CHILDREN));
+ assertEquals(0, nodeService.getProperty(recordFolder, PROP_HELD_CHILDREN_COUNT));
+ }
+
+ public void when()
+ {
+ BulkOperation bulkOperation = new BulkOperation(new Query("afts", "*", ""), "ADD");
+ // execute the bulk operation
+ holdBulkStatus = holdBulkService.execute(hold, bulkOperation);
+ await().atMost(10, SECONDS)
+ .until(() -> Objects.equals(
+ holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId()).getStatus(),
+ Status.DONE.getValue()));
+ }
+
+ public void then()
+ {
+ holdBulkStatus = holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId());
+ assertNotNull(holdBulkStatus.startTime());
+ assertNotNull(holdBulkStatus.endTime());
+ assertEquals(RECORD_COUNT, holdBulkStatus.totalItems());
+ assertEquals(RECORD_COUNT, holdBulkStatus.processedItems());
+ assertEquals(0, holdBulkStatus.errorsCount());
+ assertEquals(holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId()).getStatus(),
+ HoldBulkStatus.Status.DONE.getValue());
+
+ // record is held
+ for (NodeRef record : records)
+ {
+ assertTrue(freezeService.isFrozen(record));
+ }
+
+ // record folder has frozen children
+ assertFalse(freezeService.isFrozen(recordFolder));
+ assertTrue(freezeService.hasFrozenChildren(recordFolder));
+
+ // record folder is not held
+ assertFalse(holdService.getHeld(hold).contains(recordFolder));
+ assertFalse(holdService.heldBy(recordFolder, true).contains(hold));
+
+ for (NodeRef record : records)
+ {
+ // hold contains record
+ assertTrue(holdService.getHeld(hold).contains(record));
+ assertTrue(holdService.heldBy(record, true).contains(hold));
+ }
+
+ // additional check for child held caching
+ assertTrue(nodeService.hasAspect(recordFolder, ASPECT_HELD_CHILDREN));
+ assertEquals(RECORD_COUNT, nodeService.getProperty(recordFolder, PROP_HELD_CHILDREN_COUNT));
+ }
+ });
+ }
+
+ public void testAddRecordFolderToHoldViaBulk()
+ {
+ doBehaviourDrivenTest(new BehaviourDrivenTest()
+ {
+ private NodeRef hold;
+ private NodeRef recordFolder;
+ private final List records = new ArrayList<>(RECORD_COUNT);
+ private HoldBulkStatus holdBulkStatus;
+
+ public void given()
+ {
+ Mockito.when(resultSet.getNumberFound()).thenReturn(1L);
+ Mockito.when(resultSet.hasMore()).thenReturn(false).thenReturn(false);
+ // create a hold
+ hold = holdService.createHold(filePlan, GUID.generate(), GUID.generate(), GUID.generate());
+
+ // create a record folder that contains records
+ NodeRef recordCategory = filePlanService.createRecordCategory(filePlan, GUID.generate());
+ recordFolder = recordFolderService.createRecordFolder(recordCategory, GUID.generate());
+ for (int i = 0; i < RECORD_COUNT; i++)
+ {
+ records.add(
+ recordService.createRecordFromContent(recordFolder, GUID.generate(), ContentModel.TYPE_CONTENT,
+ null, null));
+ }
+ Mockito.when(resultSet.getNodeRefs()).thenReturn(Collections.singletonList(recordFolder))
+ .thenReturn(Collections.singletonList(recordFolder)).thenReturn(Collections.emptyList());
+
+ // assert current states
+ assertFalse(freezeService.isFrozen(recordFolder));
+ assertFalse(freezeService.hasFrozenChildren(recordFolder));
+ for (NodeRef record : records)
+ {
+ assertFalse(freezeService.isFrozen(record));
+ }
+
+ // additional check for child held caching
+ assertTrue(nodeService.hasAspect(recordFolder, ASPECT_HELD_CHILDREN));
+ assertEquals(0, nodeService.getProperty(recordFolder, PROP_HELD_CHILDREN_COUNT));
+ }
+
+ public void when()
+ {
+ BulkOperation bulkOperation = new BulkOperation(new Query("afts", "*", ""), "ADD");
+ // execute the bulk operation
+ holdBulkStatus = holdBulkService.execute(hold, bulkOperation);
+ await().atMost(10, SECONDS)
+ .until(() -> Objects.equals(
+ holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId()).getStatus(),
+ Status.DONE.getValue()));
+ }
+
+ public void then()
+ {
+ holdBulkStatus = holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId());
+ assertNotNull(holdBulkStatus.startTime());
+ assertNotNull(holdBulkStatus.endTime());
+ assertEquals(1, holdBulkStatus.totalItems());
+ assertEquals(1, holdBulkStatus.processedItems());
+ assertEquals(0, holdBulkStatus.errorsCount());
+ assertEquals(holdBulkMonitor.getBulkStatus(holdBulkStatus.bulkStatusId()).getStatus(),
+ HoldBulkStatus.Status.DONE.getValue());
+
+ for (NodeRef record : records)
+ {
+ // record is held
+ assertTrue(freezeService.isFrozen(record));
+ assertFalse(holdService.getHeld(hold).contains(record));
+ assertTrue(holdService.heldBy(record, true).contains(hold));
+ }
+
+ // record folder has frozen children
+ assertTrue(freezeService.isFrozen(recordFolder));
+ assertTrue(freezeService.hasFrozenChildren(recordFolder));
+
+ // hold contains record folder
+ assertTrue(holdService.getHeld(hold).contains(recordFolder));
+ assertTrue(holdService.heldBy(recordFolder, true).contains(hold));
+
+ // additional check for child held caching
+ assertTrue(nodeService.hasAspect(recordFolder, ASPECT_HELD_CHILDREN));
+ assertEquals(RECORD_COUNT, nodeService.getProperty(recordFolder, PROP_HELD_CHILDREN_COUNT));
+ }
+ });
+
+ }
+}
diff --git a/amps/ags/rm-community/rm-community-repo/unit-test/java/org/alfresco/module/org_alfresco_module_rm/bulk/DefaultHoldBulkMonitorUnitTest.java b/amps/ags/rm-community/rm-community-repo/unit-test/java/org/alfresco/module/org_alfresco_module_rm/bulk/DefaultHoldBulkMonitorUnitTest.java
new file mode 100644
index 0000000000..fef0eb6e4d
--- /dev/null
+++ b/amps/ags/rm-community/rm-community-repo/unit-test/java/org/alfresco/module/org_alfresco_module_rm/bulk/DefaultHoldBulkMonitorUnitTest.java
@@ -0,0 +1,164 @@
+/*
+ * #%L
+ * Alfresco Records Management Module
+ * %%
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
+ * %%
+ * This file is part of the Alfresco software.
+ * -
+ * If the software was purchased under a paid Alfresco license, the terms of
+ * the paid license agreement will prevail. Otherwise, the software is
+ * provided under the following open source license terms:
+ * -
+ * Alfresco is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * -
+ * Alfresco is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ * -
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Alfresco. If not, see .
+ * #L%
+ */
+package org.alfresco.module.org_alfresco_module_rm.bulk;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.DefaultHoldBulkMonitor;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkProcessDetails;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkStatus;
+import org.alfresco.module.org_alfresco_module_rm.bulk.hold.HoldBulkStatusAndProcessDetails;
+import org.alfresco.repo.cache.SimpleCache;
+import org.alfresco.service.cmr.repository.NodeRef;
+import org.alfresco.util.Pair;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+public class DefaultHoldBulkMonitorUnitTest
+{
+
+ @Mock
+ private SimpleCache holdProgressCache;
+
+ @Mock
+ private SimpleCache, HoldBulkProcessDetails> holdProcessRegistry;
+
+ private DefaultHoldBulkMonitor holdBulkMonitor;
+
+ @Before
+ public void setUp()
+ {
+ MockitoAnnotations.openMocks(this);
+ holdBulkMonitor = new DefaultHoldBulkMonitor();
+ holdBulkMonitor.setHoldProgressCache(holdProgressCache);
+ holdBulkMonitor.setHoldProcessRegistry(holdProcessRegistry);
+ }
+
+ @Test
+ public void testUpdateBulkStatus()
+ {
+ HoldBulkStatus status = new HoldBulkStatus("bulkStatusId", null, null, 0L, 0L, 0L, null, false, null);
+
+ holdBulkMonitor.updateBulkStatus(status);
+
+ Mockito.verify(holdProgressCache).put("bulkStatusId", status);
+ }
+
+ @Test
+ public void testRegisterProcess()
+ {
+ NodeRef holdRef = new NodeRef("workspace://SpacesStore/holdId");
+ String processId = "processId";
+ when(holdProcessRegistry.get(new Pair<>(holdRef.getId(), processId))).thenReturn(null);
+
+ holdBulkMonitor.registerProcess(holdRef, processId, null);
+
+ Mockito.verify(holdProcessRegistry)
+ .put(new Pair<>(holdRef.getId(), processId), new HoldBulkProcessDetails(processId, null, null));
+ }
+
+ @Test
+ public void testGetBulkStatusesWithProcessDetailsReturnsEmptyListWhenNoProcessesWithProcessDetails()
+ {
+ when(holdProcessRegistry.getKeys()).thenReturn(Collections.emptyList());
+ assertEquals(Collections.emptyList(), holdBulkMonitor.getBulkStatusesWithProcessDetails("holdId"));
+ }
+
+ @Test
+ public void testGetBulkStatus()
+ {
+ BulkOperation bulkOperation = mock(BulkOperation.class);
+ HoldBulkStatus status1 = new HoldBulkStatus("process1", new Date(1000), new Date(2000), 0L, 0L, 0L, null, false,
+ null);
+ when(holdProcessRegistry.get(new Pair<>("holdId", "process1"))).thenReturn(
+ new HoldBulkProcessDetails("process1", null, bulkOperation));
+ when(holdProgressCache.get("process1")).thenReturn(status1);
+
+ assertEquals(new HoldBulkStatusAndProcessDetails(status1,
+ new HoldBulkProcessDetails(status1.bulkStatusId(), null, bulkOperation)),
+ holdBulkMonitor.getBulkStatusWithProcessDetails("holdId", "process1"));
+ }
+
+ @Test
+ public void testGetNonExistingBulkStatus()
+ {
+ BulkOperation bulkOperation = mock(BulkOperation.class);
+ when(holdProcessRegistry.get(new Pair<>("holdId", "process1"))).thenReturn(
+ new HoldBulkProcessDetails("process1", null, bulkOperation));
+ when(holdProgressCache.get("process1")).thenReturn(null);
+
+ assertNull(holdBulkMonitor.getBulkStatusWithProcessDetails("holdId", "process1"));
+ }
+
+ @Test
+ public void testGetBulkStatusesForHoldReturnsSortedStatusesWithProcessDetails()
+ {
+ BulkOperation bulkOperation = mock(BulkOperation.class);
+ HoldBulkStatus status1 = new HoldBulkStatus("process1", new Date(1000), new Date(2000), 0L, 0L, 0L, null, false,
+ null);
+ HoldBulkStatus status2 = new HoldBulkStatus("process2", new Date(3000), null, 0L, 0L, 0L, null, false, null);
+ HoldBulkStatus status3 = new HoldBulkStatus("process3", new Date(4000), null, 0L, 0L, 0L, null, false, null);
+ HoldBulkStatus status4 = new HoldBulkStatus("process4", new Date(500), new Date(800), 0L, 0L, 0L, null, false,
+ null);
+ HoldBulkStatus status5 = new HoldBulkStatus("process5", null, null, 0L, 0L, 0L, null, false, null);
+
+ when(holdProcessRegistry.getKeys()).thenReturn(
+ Arrays.asList(new Pair<>("holdId", "process1"), new Pair<>("holdId", "process2"),
+ new Pair<>("holdId", "process3"), new Pair<>("holdId", "process4"), new Pair<>("holdId", "process5"))
+ );
+ when(holdProcessRegistry.get(new Pair<>("holdId", "process1"))).thenReturn(
+ new HoldBulkProcessDetails("process1", null, bulkOperation));
+ when(holdProcessRegistry.get(new Pair<>("holdId", "process2"))).thenReturn(
+ new HoldBulkProcessDetails("process2", null, bulkOperation));
+ when(holdProcessRegistry.get(new Pair<>("holdId", "process3"))).thenReturn(
+ new HoldBulkProcessDetails("process3", null, bulkOperation));
+ when(holdProcessRegistry.get(new Pair<>("holdId", "process4"))).thenReturn(
+ new HoldBulkProcessDetails("process4", null, bulkOperation));
+ when(holdProcessRegistry.get(new Pair<>("holdId", "process5"))).thenReturn(
+ new HoldBulkProcessDetails("process5", null, bulkOperation));
+ when(holdProgressCache.get("process1")).thenReturn(status1);
+ when(holdProgressCache.get("process2")).thenReturn(status2);
+ when(holdProgressCache.get("process3")).thenReturn(status3);
+ when(holdProgressCache.get("process4")).thenReturn(status4);
+ when(holdProgressCache.get("process5")).thenReturn(status5);
+
+ assertEquals(Arrays.asList(status5, status3, status2, status1, status4).stream().map(
+ status -> new HoldBulkStatusAndProcessDetails(status,
+ new HoldBulkProcessDetails(status.bulkStatusId(), null, bulkOperation))).toList(),
+ holdBulkMonitor.getBulkStatusesWithProcessDetails("holdId"));
+ }
+}
\ No newline at end of file
diff --git a/amps/ags/rm-community/rm-community-rest-api-explorer/src/main/webapp/definitions/gs-core-api.yaml b/amps/ags/rm-community/rm-community-rest-api-explorer/src/main/webapp/definitions/gs-core-api.yaml
index 92bac98ac2..41c728fc7e 100644
--- a/amps/ags/rm-community/rm-community-rest-api-explorer/src/main/webapp/definitions/gs-core-api.yaml
+++ b/amps/ags/rm-community/rm-community-rest-api-explorer/src/main/webapp/definitions/gs-core-api.yaml
@@ -2314,6 +2314,145 @@ paths:
description: Unexpected error
schema:
$ref: '#/definitions/Error'
+ '/holds/{holdId}/bulk-statuses':
+ get:
+ tags:
+ - holds
+ operationId: listBulkStatuses
+ summary: Get bulk statuses
+ description: |
+ Gets bulk statuses for hold with id **holdId**.
+ parameters:
+ - $ref: '#/parameters/holdIdParam'
+ - $ref: '#/parameters/skipCountParam'
+ - $ref: '#/parameters/maxItemsParam'
+ responses:
+ '200':
+ description: Successful response
+ schema:
+ $ref: '#/definitions/HoldBulkStatusPaging'
+ '400':
+ description: |
+ Invalid parameter: **holdId** is not a valid format
+ '401':
+ description: Authentication failed
+ '403':
+ description: Current user does not have permission to read **holdId**
+ '404':
+ description: "**holdId** does not exist"
+ default:
+ description: Unexpected error
+ schema:
+ $ref: '#/definitions/Error'
+ '/holds/{holdId}/bulk-statuses/{bulkStatusId}':
+ get:
+ tags:
+ - holds
+ operationId: getBulkStatus
+ summary: Get a bulk status
+ description: |
+ Gets a bulk status specified by **bulkStatusId** for **holdId**.
+ parameters:
+ - $ref: '#/parameters/holdIdParam'
+ - $ref: '#/parameters/bulkStatusId'
+ responses:
+ '200':
+ description: Successful response
+ schema:
+ $ref: '#/definitions/HoldBulkStatus'
+ '400':
+ description: |
+ Invalid parameter: **holdId** or **bulkStatusId** is not a valid format
+ '401':
+ description: Authentication failed
+ '403':
+ description: Current user does not have permission to read **holdId**
+ '404':
+ description: "**holdId** or **bulkStatusId** does not exist"
+ default:
+ description: Unexpected error
+ schema:
+ $ref: '#/definitions/Error'
+ '/holds/{holdId}/bulk-statuses/{bulkStatusId}/cancel':
+ post:
+ tags:
+ - holds
+ operationId: cancelBulkStatus
+ summary: Cancel the bulk operation
+ description: |
+ Cancels the bulk operation specified by **bulkStatusId** for **holdId**.
+ parameters:
+ - $ref: '#/parameters/holdIdParam'
+ - $ref: '#/parameters/bulkStatusId'
+ - in: body
+ name: cancelReason
+ description: Cancel reason.
+ required: false
+ schema:
+ $ref: '#/definitions/BulkBodyCancel'
+ responses:
+ '200':
+ description: Successful response
+ '400':
+ description: |
+ Invalid parameter: **holdId** or **bulkStatusId** is not a valid format
+ '401':
+ description: Authentication failed
+ '403':
+ description: Current user does not have permission to cancel the bulk process for **holdId** and **bulkStatusId**
+ '404':
+ description: "**holdId** or **bulkStatusId** does not exist"
+ default:
+ description: Unexpected error
+ schema:
+ $ref: '#/definitions/Error'
+ '/holds/{holdId}/bulk':
+ post:
+ tags:
+ - holds
+ operationId: startHoldBulkProcess
+ summary: Start the hold bulk process
+ description: |
+ Start the asynchronous bulk process for a hold with id **holdId** based on search query results.
+
+ ```JSON
+ For example, the following JSON body starts the bulk process to add search query results
+ as children of a hold.
+
+ {
+ "query": {
+ "query": "SITE:swsdp and TYPE:content",
+ "language": "afts"
+ },
+ "op": "ADD"
+ }
+ ```
+ parameters:
+ - $ref: '#/parameters/holdIdParam'
+ - in: body
+ name: holdBulkOperation
+ description: Bulk operation.
+ required: true
+ schema:
+ $ref: '#/definitions/HoldBulkOperation'
+ responses:
+ '202':
+ description: Successful response
+ schema:
+ $ref: '#/definitions/HoldBulkOperationEntry'
+ '400':
+ description: |
+ Invalid parameter: **holdId** is not a valid format or **HoldBulkOperation** is not valid
+ '401':
+ description: Authentication failed
+ '403':
+ description: Current user does not have permission to start the bulk process for **holdId**
+ '404':
+ description: "**holdId** does not exist"
+ default:
+ description: Unexpected error
+ schema:
+ $ref: '#/definitions/Error'
'/holds/{holdId}/delete':
post:
tags:
@@ -2862,6 +3001,12 @@ parameters:
description: The identifier of a child of a hold.
required: true
type: string
+ bulkStatusId:
+ name: bulkStatusId
+ in: path
+ description: The identifier of a bulk process.
+ required: true
+ type: string
## Record
recordIdParam:
name: recordId
@@ -4018,6 +4163,101 @@ definitions:
properties:
reason:
type: string
+ SearchRequestQuery:
+ type: object
+ required:
+ - query
+ properties:
+ language:
+ description: The query language in which the query is written.
+ type: string
+ default: afts
+ enum:
+ - afts
+ - lucene
+ - cmis
+ userQuery:
+ description: The search request typed in by the user
+ type: string
+ query:
+ description: The query which may have been generated in some way from the userQuery
+ type: string
+ HoldBulkOperation:
+ type: object
+ properties:
+ query:
+ $ref: '#/definitions/SearchRequestQuery'
+ op:
+ description: The operation type.
+ type: string
+ default: ADD
+ enum:
+ - ADD
+ HoldBulkOperationEntry:
+ type: object
+ properties:
+ bulkStatusId:
+ type: string
+ totalItems:
+ type: integer
+ format: int64
+ BulkBodyCancel:
+ type: object
+ properties:
+ reason:
+ type: string
+ HoldBulkStatus:
+ type: object
+ properties:
+ bulkStatusId:
+ type: string
+ startTime:
+ type: string
+ format: date-time
+ endTime:
+ type: string
+ format: date-time
+ processedItems:
+ type: integer
+ format: int64
+ errorsCount:
+ type: integer
+ format: int64
+ totalItems:
+ type: integer
+ format: int64
+ lastError:
+ type: string
+ status:
+ type: string
+ enum:
+ - PENDING
+ - IN PROGRESS
+ - DONE
+ - CANCELLED
+ cancellationReason:
+ type: string
+ holdBulkOperation:
+ $ref: '#/definitions/HoldBulkOperation'
+ HoldBulkStatusEntry:
+ type: object
+ required:
+ - entry
+ properties:
+ entry:
+ $ref: '#/definitions/HoldBulkStatus'
+ HoldBulkStatusPaging:
+ type: object
+ properties:
+ list:
+ type: object
+ properties:
+ pagination:
+ $ref: '#/definitions/Pagination'
+ entries:
+ type: array
+ items:
+ $ref: '#/definitions/HoldBulkStatusEntry'
##
RequestBodyFile:
type: object
diff --git a/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/search/RestRequestQueryModel.java b/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/search/RestRequestQueryModel.java
index 3138a75c7d..e167e660e2 100644
--- a/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/search/RestRequestQueryModel.java
+++ b/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/search/RestRequestQueryModel.java
@@ -2,7 +2,7 @@
* #%L
* alfresco-tas-restapi
* %%
- * Copyright (C) 2005 - 2022 Alfresco Software Limited
+ * Copyright (C) 2005 - 2024 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
@@ -25,6 +25,8 @@
*/
package org.alfresco.rest.search;
+import java.util.Objects;
+
import com.fasterxml.jackson.annotation.JsonProperty;
import org.alfresco.rest.core.IRestModel;
@@ -93,6 +95,28 @@ public class RestRequestQueryModel extends TestModel implements IRestModel