Skip to content

Commit

Permalink
Working on object batch deletions
Browse files Browse the repository at this point in the history
  • Loading branch information
diamondap committed Jan 22, 2024
1 parent ac76efb commit 58d4bbd
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 20 deletions.
12 changes: 12 additions & 0 deletions pgmodels/intellectual_object.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions pgmodels/intellectual_object_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions pgmodels/work_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions pgmodels/work_item_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions views/deletions/already_approved.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
<div class="box">
<div class="box-header"><h1>Deletion Previously Confirmed</h1></div>
<div class="box-content">
<p>The deletion of file <b>{{ .itemIdentifier }}</b> was approved by {{ .deletionRequest.ConfirmedBy.Name }} ({{ .deletionRequest.ConfirmedBy.Email }}) on {{ dateUS .deletionRequest.ConfirmedAt }}.</p>
<p>The deletion of <b>{{ .itemIdentifier }}</b> was approved by {{ .deletionRequest.ConfirmedBy.Name }} ({{ .deletionRequest.ConfirmedBy.Email }}) on {{ dateUS .deletionRequest.ConfirmedAt }}.</p>

<p>If the file has not yet been deleted, it will be soon.</p>
<p>If the items have not yet been deleted, they will be soon.</p>

<a class="button mr-3" href="/deletions">Back to Deletions List</a>
</div>
Expand Down
4 changes: 2 additions & 2 deletions views/deletions/already_cancelled.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
<div class="box">
<div class="box-header"><h1>Deletion Previously Cancelled</h1></div>
<div class="box-content">
<p>The deletion of file <b>{{ .itemIdentifier }}</b> was cancelled by {{ .deletionRequest.CancelledBy.Name }} ({{ .deletionRequest.CancelledBy.Email }} on {{ dateUS .deletionRequest.CancelledAt }}.</p>
<p>The deletion of <b>{{ .itemIdentifier }}</b> was cancelled by {{ .deletionRequest.CancelledBy.Name }} ({{ .deletionRequest.CancelledBy.Email }} on {{ dateUS .deletionRequest.CancelledAt }}.</p>

<p>This deletion request will not be executed. Unless the file was deleted by a subsequent request, this file should still exist.</p>
<p>This deletion request will not be executed. Unless the files or objects were deleted by a subsequent request, these items should still exist.</p>

<a class="button mr-3" href="/deletions">Back to Deletions List</a>
</div>
Expand Down
19 changes: 16 additions & 3 deletions views/deletions/review.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
<div class="box">
<div class="box-header"><h1>Review Deletion Request</h1></div>
<div class="box-content">
<p>User {{ .deletionRequest.RequestedBy.Name }} ({{ .deletionRequest.RequestedBy.Email }}) wants to delete the following item:</p>
<p>User {{ .deletionRequest.RequestedBy.Name }} ({{ .deletionRequest.RequestedBy.Email }}) wants to delete the following items:</p>

<!-- Single file deletion -->
{{ if (eq .itemType "file") }}

<h3>Generic File</h3>
Expand All @@ -18,7 +19,9 @@ <h3>Generic File</h3>
Updated: {{ dateUS .file.UpdatedAt }} <br/>
</p>

{{ else }}

<!-- Single object deletion -->
{{ else if (eq .itemType "single object") }}

<h3>Intellectual Object</h3>

Expand All @@ -32,9 +35,19 @@ <h3>Intellectual Object</h3>
Updated: {{ dateUS .object.UpdatedAt }} <br/>
</p>


<!-- Bulk object deletion -->
{{ else if (eq .itemType "object list") }}

<h3>Intellectual Objects</h3>

{{ range $index, $obj := .objectList }}
<p><b>{{ $obj.Identifier }}</b></p>
{{ end }}

{{ end }}

<p>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.</p>
<p>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.</p>

<div class="is-flex">
<button class="button mr-3" onclick="document.forms['deletionCancelForm'].submit()">Cancel</button>
Expand Down
39 changes: 39 additions & 0 deletions web/api/admin/intellectual_objects_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package admin_api

import (
"net/http"
"strconv"

"github.com/APTrust/registry/common"
"github.com/APTrust/registry/constants"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
59 changes: 50 additions & 9 deletions web/webui/deletion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions web/webui/deletion_requests_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 58d4bbd

Please sign in to comment.