diff --git a/pgmodels/intellectual_object.go b/pgmodels/intellectual_object.go index d5b867a..99b4ff6 100644 --- a/pgmodels/intellectual_object.go +++ b/pgmodels/intellectual_object.go @@ -74,6 +74,18 @@ func IntellectualObjectSelect(query *Query) ([]*IntellectualObject, error) { return objects, err } +// CountObjectsThatCanBeDeleted returns the number of active objects in the +// list of object IDs that belong to the specified institution. We use this +// when running batch deletions to ensure that no objects belong to an +// institution other than the one requesting the deletion. +// +// If we get a list of 100 ids, the return value should be 100. If it's not +// some object in the ID list was already deleted, or it belongs to someone +// else. +func CountObjectsThatCanBeDeleted(institutionID int64, objIDs []int64) (int, error) { + return common.Context().DB.Model((*IntellectualObject)(nil)).Where(`institution_id = ? and state = 'A' and id in (?)`, institutionID, pg.In(objIDs)).Count() +} + // Save saves this object to the database. This will peform an insert // if IntellectualObject.ID is zero. Otherwise, it updates. func (obj *IntellectualObject) Save() error { diff --git a/pgmodels/intellectual_object_test.go b/pgmodels/intellectual_object_test.go index 476756d..cb530f4 100644 --- a/pgmodels/intellectual_object_test.go +++ b/pgmodels/intellectual_object_test.go @@ -94,6 +94,34 @@ func TestObjHasActiveFiles(t *testing.T) { } +func TestCountObjectsThatCanBeDeleted(t *testing.T) { + // These are the ids of non-deleted objects belonging to institution 3. + // These are loaded from fixture data. + idsBelongingToInst3 := []int64{4, 5, 6, 12, 13} + + // All five items should be OK to delete, because all five + // belong to inst 3 and are in active state. + numberOkToDelete, err := pgmodels.CountObjectsThatCanBeDeleted(3, idsBelongingToInst3) + require.NoError(t, err) + assert.Equal(t, len(idsBelongingToInst3), numberOkToDelete) + + // We should get zero here, because none of these objects + // belong to inst2. + numberOkToDelete, err = pgmodels.CountObjectsThatCanBeDeleted(2, idsBelongingToInst3) + require.NoError(t, err) + assert.Equal(t, 0, numberOkToDelete) + + // In this set, the first three items belong to inst 3 and + // are active. ID 14 is already deleted, and items 1 and 2 + // belong to a different institution. So we should get three + // because only the first three items belong to inst 3 AND + // are currently active. + miscIds := []int64{4, 5, 6, 14, 1, 2} + numberOkToDelete, err = pgmodels.CountObjectsThatCanBeDeleted(3, miscIds) + require.NoError(t, err) + assert.Equal(t, 3, numberOkToDelete) +} + func TestObjLastIngestEvent(t *testing.T) { obj, err := pgmodels.IntellectualObjectByID(6) require.Nil(t, err) diff --git a/pgmodels/work_item.go b/pgmodels/work_item.go index e93f426..d90431b 100644 --- a/pgmodels/work_item.go +++ b/pgmodels/work_item.go @@ -9,6 +9,7 @@ import ( "github.com/APTrust/registry/common" "github.com/APTrust/registry/constants" v "github.com/asaskevich/govalidator" + "github.com/go-pg/pg/v10" "github.com/jinzhu/copier" "github.com/stretchr/stew/slice" ) @@ -116,6 +117,12 @@ func WorkItemsPendingForObject(instID int64, bagName string) ([]*WorkItem, error return WorkItemSelect(query) } +// WorkItemsPendingForObjectBatch returns the number of WorkItems pending +func WorkItemsPendingForObjectBatch(objIDs []int64) (int, error) { + completed := common.InterfaceList(constants.CompletedStatusValues) + return common.Context().DB.Model((*WorkItem)(nil)).Where(`intellectual_object_id in (?) and status not in (?)`, pg.In(objIDs), pg.In(completed)).Count() +} + // WorkItemsPendingForFile returns a list of in-progress WorkItems // for the GenericFile with the specified ID. func WorkItemsPendingForFile(fileID int64) ([]*WorkItem, error) { diff --git a/pgmodels/work_item_test.go b/pgmodels/work_item_test.go index fd4a759..b517323 100644 --- a/pgmodels/work_item_test.go +++ b/pgmodels/work_item_test.go @@ -439,6 +439,27 @@ func TestNewDeletionItem(t *testing.T) { require.Nil(t, item6) } +func TestWorkItemsPendingForObjectBatch(t *testing.T) { + db.LoadFixtures() + + // These objects belong to institution 3 + // and have no pending WorkItems in the fixture data. + objIDs := []int64{5, 6, 12, 13} + itemCount, err := pgmodels.WorkItemsPendingForObjectBatch(objIDs) + require.NoError(t, err) + assert.Equal(t, 0, itemCount) + + // Now add in Intel Obj 4, which has three + // pending WorkItems. The function should return + // the number of unfinished work items for this + // batch of objects. + objIDs = []int64{4, 5, 6, 12, 13} + itemCount, err = pgmodels.WorkItemsPendingForObjectBatch(objIDs) + require.NoError(t, err) + assert.Equal(t, 3, itemCount) + +} + func TestIsRestorationSpotTest(t *testing.T) { // This is not a spot test because it's not even a restoration. diff --git a/views/deletions/already_approved.html b/views/deletions/already_approved.html index 9b93c86..f970c97 100644 --- a/views/deletions/already_approved.html +++ b/views/deletions/already_approved.html @@ -5,9 +5,9 @@

Deletion Previously Confirmed

-

The deletion of file {{ .itemIdentifier }} was approved by {{ .deletionRequest.ConfirmedBy.Name }} ({{ .deletionRequest.ConfirmedBy.Email }}) on {{ dateUS .deletionRequest.ConfirmedAt }}.

+

The deletion of {{ .itemIdentifier }} was approved by {{ .deletionRequest.ConfirmedBy.Name }} ({{ .deletionRequest.ConfirmedBy.Email }}) on {{ dateUS .deletionRequest.ConfirmedAt }}.

-

If the file has not yet been deleted, it will be soon.

+

If the items have not yet been deleted, they will be soon.

Back to Deletions List
diff --git a/views/deletions/already_cancelled.html b/views/deletions/already_cancelled.html index 8c1e20b..520e2f4 100644 --- a/views/deletions/already_cancelled.html +++ b/views/deletions/already_cancelled.html @@ -5,9 +5,9 @@

Deletion Previously Cancelled

-

The deletion of file {{ .itemIdentifier }} was cancelled by {{ .deletionRequest.CancelledBy.Name }} ({{ .deletionRequest.CancelledBy.Email }} on {{ dateUS .deletionRequest.CancelledAt }}.

+

The deletion of {{ .itemIdentifier }} was cancelled by {{ .deletionRequest.CancelledBy.Name }} ({{ .deletionRequest.CancelledBy.Email }} on {{ dateUS .deletionRequest.CancelledAt }}.

-

This deletion request will not be executed. Unless the file was deleted by a subsequent request, this file should still exist.

+

This deletion request will not be executed. Unless the files or objects were deleted by a subsequent request, these items should still exist.

Back to Deletions List
diff --git a/views/deletions/review.html b/views/deletions/review.html index 4f6a966..0b40f22 100644 --- a/views/deletions/review.html +++ b/views/deletions/review.html @@ -5,8 +5,9 @@

Review Deletion Request

-

User {{ .deletionRequest.RequestedBy.Name }} ({{ .deletionRequest.RequestedBy.Email }}) wants to delete the following item:

+

User {{ .deletionRequest.RequestedBy.Name }} ({{ .deletionRequest.RequestedBy.Email }}) wants to delete the following items:

+ {{ if (eq .itemType "file") }}

Generic File

@@ -18,7 +19,9 @@

Generic File

Updated: {{ dateUS .file.UpdatedAt }}

- {{ else }} + + + {{ else if (eq .itemType "single object") }}

Intellectual Object

@@ -32,9 +35,19 @@

Intellectual Object

Updated: {{ dateUS .object.UpdatedAt }}

+ + + {{ else if (eq .itemType "object list") }} + +

Intellectual Objects

+ + {{ range $index, $obj := .objectList }} +

{{ $obj.Identifier }}

+ {{ end }} + {{ end }} -

Do you want to approve or cancel this request? If you approve, the file(s) will be deleted as soon as possible. Deletion cannot be undone. If you cancel, the file(s) will stay and no one else will be able to approve this request.

+

Do you want to approve or cancel this request? If you approve, the items(s) will be deleted as soon as possible. Deletion cannot be undone. If you cancel, the file(s) will stay and no one else will be able to approve this request.

diff --git a/web/api/admin/intellectual_objects_controller.go b/web/api/admin/intellectual_objects_controller.go index 27171ac..59c2d70 100644 --- a/web/api/admin/intellectual_objects_controller.go +++ b/web/api/admin/intellectual_objects_controller.go @@ -2,6 +2,7 @@ package admin_api import ( "net/http" + "strconv" "github.com/APTrust/registry/common" "github.com/APTrust/registry/constants" @@ -53,6 +54,32 @@ func IntellectualObjectInitRestore(c *gin.Context) { c.JSON(http.StatusCreated, workItem) } +// IntellectualObjectInitBatchDelete creates an deletion request for +// multiple objects. This request must be approved by an administrator +// at the depositing institution before the deletion will actually be queued. +// +// POST /objects/init_batch_delete/:id +func IntellectualObjectInitBatchDelete(c *gin.Context) { + req := api.NewRequest(c) + objectIDs, err := StringSliceToInt64Slice(c.Request.PostForm["objectID"]) + if api.AbortIfError(c, err) { + return + } + institutionID, err := strconv.ParseInt(c.Request.PostFormValue("institutionID"), 10, 64) + if api.AbortIfError(c, err) { + return + } + del, err := webui.NewDeletionForObjectBatch(institutionID, objectIDs, req.CurrentUser, req.BaseURL()) + if api.AbortIfError(c, err) { + return + } + _, err = del.CreateRequestAlert() + if api.AbortIfError(c, err) { + return + } + c.JSON(http.StatusCreated, del.DeletionRequest) +} + // IntellectualObjectDelete marks an object record as deleted. // It also creates a deletion premis event. Before it does any of // that, it checks a number of pre-conditions. See the @@ -127,3 +154,15 @@ func CoerceObjectStorageOption(existingObject, submittedObject *pgmodels.Intelle submittedObject.StorageOption = existingObject.StorageOption } } + +func StringSliceToInt64Slice(strSlice []string) ([]int64, error) { + var err error + ints := make([]int64, len(strSlice)) + for i, strValue := range strSlice { + ints[i], err = strconv.ParseInt(strValue, 10, 64) + if err != nil { + break + } + } + return ints, err +} diff --git a/web/webui/deletion.go b/web/webui/deletion.go index 5fe4968..0b448c9 100644 --- a/web/webui/deletion.go +++ b/web/webui/deletion.go @@ -76,7 +76,46 @@ func NewDeletionForObject(objID int64, currentUser *pgmodels.User, baseURL strin baseURL: baseURL, currentUser: currentUser, } - err = del.initObjectDeletionRequest(objID) + err = del.initObjectDeletionRequest(obj.InstitutionID, []int64{objID}) + if err != nil { + return nil, err + } + err = del.loadInstAdmins() + return del, err +} + +// NewDeletionForObjectBatch creates a new DeletionRequest for a batch of +// IntellectualObjects and returns the Deletion object. This constructor +// is only for initializing new DeletionRequests, not for reviewing, approving +// or cancelling existing requests. +func NewDeletionForObjectBatch(institutionID int64, objIDs []int64, currentUser *pgmodels.User, baseURL string) (*Deletion, error) { + + // Make sure that all objects belong to the specified institution. + validObjectCount, err := pgmodels.CountObjectsThatCanBeDeleted(institutionID, objIDs) + if err != nil { + return nil, err + } + if validObjectCount != len(objIDs) { + common.Context().Log.Error().Msgf("Batch deletion requested for %d objects, of which only %d are valid. InstitutionID = %d. Current user = %s. IDs: %v", + len(objIDs), validObjectCount, institutionID, currentUser.Email, objIDs) + return nil, fmt.Errorf("one or more object ids is invalid") + } + + // Make sure there are no pending work items for these objects. + pendingWorkItems, err := pgmodels.WorkItemsPendingForObjectBatch(objIDs) + if err != nil { + return nil, err + } + if pendingWorkItems > 0 { + common.Context().Log.Warn().Msgf("Some objects in batch deletion request have pending work items. Object IDs: %v", objIDs) + return nil, common.ErrPendingWorkItems + } + + del := &Deletion{ + baseURL: baseURL, + currentUser: currentUser, + } + err = del.initObjectDeletionRequest(institutionID, objIDs) if err != nil { return nil, err } @@ -168,20 +207,22 @@ func (del *Deletion) initFileDeletionRequest(genericFileID int64) error { // We do not save the plaintext version of the token, // only the encrypted version. When this new DeletionRequest goes out of // scope, there's no further access to the token, so get it while you can. -func (del *Deletion) initObjectDeletionRequest(objID int64) error { - obj, err := pgmodels.IntellectualObjectByID(objID) - if err != nil { - return err - } - +func (del *Deletion) initObjectDeletionRequest(institutionID int64, objIDs []int64) error { deletionRequest, err := pgmodels.NewDeletionRequest() if err != nil { return err } - deletionRequest.InstitutionID = obj.InstitutionID + deletionRequest.InstitutionID = institutionID deletionRequest.RequestedByID = del.currentUser.ID deletionRequest.RequestedAt = time.Now().UTC() - deletionRequest.AddObject(obj) + + for _, objID := range objIDs { + obj, err := pgmodels.IntellectualObjectByID(objID) + if err != nil { + return err + } + deletionRequest.AddObject(obj) + } err = deletionRequest.Save() if err != nil { return err diff --git a/web/webui/deletion_requests_controller.go b/web/webui/deletion_requests_controller.go index a79e20b..02675ef 100644 --- a/web/webui/deletion_requests_controller.go +++ b/web/webui/deletion_requests_controller.go @@ -77,13 +77,18 @@ func DeletionRequestReview(c *gin.Context) { req.TemplateData["deletionRequest"] = del.DeletionRequest req.TemplateData["token"] = c.Query("token") - if len(del.DeletionRequest.IntellectualObjects) > 0 { - req.TemplateData["itemType"] = "object" - req.TemplateData["itemIdentifier"] = del.DeletionRequest.IntellectualObjects[0].Identifier + if len(del.DeletionRequest.IntellectualObjects) == 1 { + req.TemplateData["itemType"] = "single object" + req.TemplateData["itemIdentifier"] = fmt.Sprintf("object %s", del.DeletionRequest.IntellectualObjects[0].Identifier) req.TemplateData["object"] = del.DeletionRequest.IntellectualObjects[0] + } else if len(del.DeletionRequest.IntellectualObjects) > 1 { + // Bulk object deletion + req.TemplateData["itemType"] = "object list" + req.TemplateData["itemIdentifier"] = fmt.Sprintf("%d objects", len(del.DeletionRequest.IntellectualObjects)) + req.TemplateData["objectList"] = del.DeletionRequest.IntellectualObjects } else if len(del.DeletionRequest.GenericFiles) > 0 { req.TemplateData["itemType"] = "file" - req.TemplateData["itemIdentifier"] = del.DeletionRequest.GenericFiles[0].Identifier + req.TemplateData["itemIdentifier"] = fmt.Sprintf("file %s", del.DeletionRequest.GenericFiles[0].Identifier) req.TemplateData["file"] = del.DeletionRequest.GenericFiles[0] } else { common.Context().Log.Info().Msgf("DeletionRequest with ID %d has no associated files or objects.", req.Auth.ResourceID)