Add option to delete original documents after merge

This commit is contained in:
Dominik Bruhn 2024-06-07 11:04:15 +02:00
parent d8c96b6e4a
commit ef9d1b5188
9 changed files with 152 additions and 7 deletions

View File

@ -412,11 +412,14 @@ The following methods are supported:
- `"merge": true or false` (defaults to false)
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
removing them) or be merged with existing permissions.
- `merge`
- `merge` and `merge_and_delete_originals`
- No additional `parameters` required.
- The ordering of the merged document is determined by the list of IDs.
- Optional `parameters`:
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
- As the name implies, `merge_and_delete_originals` deletes the original
documents after merging. This requires the calling user being the owner of
all documents that are merged.
- `split`
- Requires `parameters`:
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`

View File

@ -2970,11 +2970,18 @@
<context context-type="linenumber">24</context>
</context-group>
</trans-unit>
<trans-unit id="2519605321077387027" datatype="html">
<source> Delete original documents after merge </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
<context context-type="linenumber">32,34</context>
</context-group>
</trans-unit>
<trans-unit id="5138283234724909648" datatype="html">
<source>Note that only PDFs will be included.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
<context context-type="linenumber">30</context>
<context context-type="linenumber">36</context>
</context-group>
</trans-unit>
<trans-unit id="8157388568390631653" datatype="html">
@ -6417,7 +6424,7 @@
<source>Merged document will be queued for consumption.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">819</context>
<context context-type="linenumber">824</context>
</context-group>
</trans-unit>
<trans-unit id="2784168796433474565" datatype="html">

View File

@ -27,6 +27,12 @@
}
</select>
</div>
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" value="" id="deleteOriginals" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments">
<label class="form-check-label" for="deleteOrginals" i18n>
Delete original documents after merge
</label>
</div>
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
</div>
<div class="modal-footer">

View File

@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
import { Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
@ -16,6 +17,7 @@ export class MergeConfirmDialogComponent
implements OnInit
{
public documentIDs: number[] = []
public deleteOriginals: boolean = false
private _documents: Document[] = []
get documents(): Document[] {
return this._documents
@ -27,7 +29,8 @@ export class MergeConfirmDialogComponent
constructor(
activeModal: NgbActiveModal,
private documentService: DocumentService
private documentService: DocumentService,
private permissionService: PermissionsService
) {
super(activeModal)
}
@ -48,4 +51,10 @@ export class MergeConfirmDialogComponent
getDocument(documentID: number): Document {
return this.documents.find((d) => d.id === documentID)
}
get userOwnsAllDocuments(): boolean {
return this.documents.every((d) =>
this.permissionService.currentUserOwnsObject(d)
)
}
}

View File

@ -814,7 +814,12 @@ export class BulkEditorComponent
args['metadata_document_id'] = mergeDialog.metadataDocumentID
}
mergeDialog.buttonsEnabled = false
this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
this.executeBulkOperation(
modal,
mergeDialog.deleteOriginals ? 'merge_and_delete_originals' : 'merge',
args,
mergeDialog.documentIDs
)
this.toastService.showInfo(
$localize`Merged document will be queued for consumption.`
)

View File

@ -234,7 +234,11 @@ def rotate(doc_ids: list[int], degrees: int):
return "OK"
def merge(doc_ids: list[int], metadata_document_id: Optional[int] = None):
def merge(
doc_ids: list[int],
metadata_document_id: Optional[int] = None,
delete_originals: bool = False,
):
logger.info(
f"Attempting to merge {len(doc_ids)} documents into a single document.",
)
@ -285,9 +289,20 @@ def merge(doc_ids: list[int], metadata_document_id: Optional[int] = None):
overrides,
)
if delete_originals:
logger.info("Removing original documents after merge")
delete(affected_docs)
return "OK"
def merge_and_delete_originals(
doc_ids: list[int],
metadata_document_id: Optional[int] = None,
):
return merge(doc_ids, metadata_document_id, True)
def split(doc_ids: list[int], pages: list[list[int]]):
logger.info(
f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",

View File

@ -943,6 +943,7 @@ class BulkEditSerializer(
"set_permissions",
"rotate",
"merge",
"merge_and_delete_originals",
"split",
"delete_pages",
],
@ -999,6 +1000,8 @@ class BulkEditSerializer(
return bulk_edit.rotate
elif method == "merge":
return bulk_edit.merge
elif method == "merge_and_delete_originals":
return bulk_edit.merge_and_delete_originals
elif method == "split":
return bulk_edit.split
elif method == "delete_pages":

View File

@ -376,6 +376,45 @@ class TestPDFActions(DirectoriesMixin, TestCase):
/ "0000003.pdf",
sample3,
)
sample4 = self.dirs.scratch_dir / "sample4.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample4,
)
sample4_archive = self.dirs.archive_dir / "sample4_archive.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample4_archive,
)
sample5 = self.dirs.scratch_dir / "sample5.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000002.pdf",
sample5,
)
sample5_archive = self.dirs.archive_dir / "sample5_archive.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000002.pdf",
sample5_archive,
)
self.doc1 = Document.objects.create(
checksum="A",
title="A",
@ -410,6 +449,24 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mime_type="image/jpeg",
)
self.doc1_delete_after_merge = Document.objects.create(
checksum="Ad",
title="Adelete",
filename=sample4,
mime_type="application/pdf",
)
self.doc1_delete_after_merge.archive_filename = sample4_archive
self.doc1_delete_after_merge.save()
self.doc2_delete_after_merge = Document.objects.create(
checksum="Bd",
title="Bdelete",
filename=sample5,
mime_type="application/pdf",
)
self.doc2_delete_after_merge.archive_filename = sample5_archive
self.doc2_delete_after_merge.save()
@mock.patch("documents.tasks.consume_file.delay")
def test_merge(self, mock_consume_file):
"""
@ -444,6 +501,41 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertEqual(result, "OK")
@mock.patch("documents.tasks.consume_file.delay")
def test_merge_and_delete_originals(self, mock_consume_file):
"""
GIVEN:
- Existing documents
WHEN:
- Merge action with deleting documents is called with 2 documents
THEN:
- Consume file should be called
- Documents should be deleted
"""
doc_ids = [self.doc1_delete_after_merge.id, self.doc2_delete_after_merge.id]
result = bulk_edit.merge_and_delete_originals(doc_ids)
self.assertEqual(result, "OK")
expected_filename = (
f"{'_'.join([str(doc_id) for doc_id in doc_ids])[:100]}_merged.pdf"
)
mock_consume_file.assert_called()
consume_file_args, _ = mock_consume_file.call_args
self.assertEqual(
Path(consume_file_args[0].original_file).name,
expected_filename,
)
self.assertEqual(consume_file_args[1].title, None)
with self.assertRaises(Document.DoesNotExist):
Document.objects.get(id=self.doc1_delete_after_merge.id)
with self.assertRaises(Document.DoesNotExist):
Document.objects.get(id=self.doc2_delete_after_merge.id)
@mock.patch("documents.tasks.consume_file.delay")
@mock.patch("pikepdf.open")
def test_merge_with_errors(self, mock_open_pdf, mock_consume_file):

View File

@ -968,7 +968,12 @@ class BulkEditView(PassUserMixin):
has_perms = (
all((doc.owner == user or doc.owner is None) for doc in document_objs)
if method
in [bulk_edit.set_permissions, bulk_edit.delete, bulk_edit.rotate]
in [
bulk_edit.set_permissions,
bulk_edit.delete,
bulk_edit.rotate,
bulk_edit.merge_and_delete_originals,
]
else all(
has_perms_owner_aware(user, "change_document", doc)
for doc in document_objs