diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 6fcb8a051..753dc7ca8 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -950,8 +950,8 @@ describe('DocumentDetailComponent', () => { it('should support reprocess, confirm and close modal after started', () => { initNormally() - const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') - bulkEditSpy.mockReturnValue(of(true)) + const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') + reprocessSpy.mockReturnValue(of(true)) let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') @@ -959,7 +959,7 @@ describe('DocumentDetailComponent', () => { component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') openModal.componentInstance.confirmClicked.next() - expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'reprocess', {}) + expect(reprocessSpy).toHaveBeenCalledWith([doc.id]) expect(modalSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled() @@ -967,13 +967,13 @@ describe('DocumentDetailComponent', () => { it('should show error if redo ocr call fails', () => { initNormally() - const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') + const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const toastSpy = jest.spyOn(toastService, 'showError') component.reprocess() const modalCloseSpy = jest.spyOn(openModal, 'close') - bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred'))) + reprocessSpy.mockReturnValue(throwError(() => new Error('error occurred'))) openModal.componentInstance.confirmClicked.next() expect(toastSpy).toHaveBeenCalled() expect(modalCloseSpy).not.toHaveBeenCalled() diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 16d0018e1..25c854e53 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -1379,27 +1379,25 @@ export class DocumentDetailComponent modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.buttonsEnabled = false - this.documentsService - .bulkEdit([this.document.id], 'reprocess', {}) - .subscribe({ - next: () => { - this.toastService.showInfo( - $localize`Reprocess operation for "${this.document.title}" will begin in the background.` - ) - if (modal) { - modal.close() - } - }, - error: (error) => { - if (modal) { - modal.componentInstance.buttonsEnabled = true - } - this.toastService.showError( - $localize`Error executing operation`, - error - ) - }, - }) + this.documentsService.reprocessDocuments([this.document.id]).subscribe({ + next: () => { + this.toastService.showInfo( + $localize`Reprocess operation for "${this.document.title}" will begin in the background.` + ) + if (modal) { + modal.close() + } + }, + error: (error) => { + if (modal) { + modal.componentInstance.buttonsEnabled = true + } + this.toastService.showError( + $localize`Error executing operation`, + error + ) + }, + }) }) } diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index d3ad1a841..893dae97f 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -849,13 +849,11 @@ describe('BulkEditorComponent', () => { expect(modal).not.toBeUndefined() modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/delete/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'delete', - parameters: {}, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` @@ -868,7 +866,7 @@ describe('BulkEditorComponent', () => { fixture.detectChanges() component.applyDelete() req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/delete/` ) }) @@ -944,13 +942,11 @@ describe('BulkEditorComponent', () => { expect(modal).not.toBeUndefined() modal.componentInstance.confirm() let req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/bulk_edit/` + `${environment.apiBaseUrl}documents/reprocess/` ) req.flush(true) expect(req.request.body).toEqual({ documents: [3, 4], - method: 'reprocess', - parameters: {}, }) httpTestingController.match( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index c1c3d22e2..c23cd6c5f 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -787,10 +787,16 @@ export class BulkEditorComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { modal.componentInstance.buttonsEnabled = false - this.executeBulkEditMethod(modal, 'delete', {}) + this.executeDocumentAction( + modal, + this.documentService.deleteDocuments(Array.from(this.list.selected)) + ) }) } else { - this.executeBulkEditMethod(null, 'delete', {}) + this.executeDocumentAction( + null, + this.documentService.deleteDocuments(Array.from(this.list.selected)) + ) } } @@ -829,7 +835,12 @@ export class BulkEditorComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { modal.componentInstance.buttonsEnabled = false - this.executeBulkEditMethod(modal, 'reprocess', {}) + this.executeDocumentAction( + modal, + this.documentService.reprocessDocuments( + Array.from(this.list.selected) + ) + ) }) } diff --git a/src-ui/src/app/services/rest/document.service.spec.ts b/src-ui/src/app/services/rest/document.service.spec.ts index 8dc13e214..b3a9757ff 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -230,6 +230,30 @@ describe(`DocumentService`, () => { }) }) + it('should call appropriate api endpoint for delete documents', () => { + const ids = [1, 2, 3] + subscription = service.deleteDocuments(ids).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/delete/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + }) + }) + + it('should call appropriate api endpoint for reprocess documents', () => { + const ids = [1, 2, 3] + subscription = service.reprocessDocuments(ids).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/reprocess/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual({ + documents: ids, + }) + }) + it('should call appropriate api endpoint for rotate documents', () => { const ids = [1, 2, 3] subscription = service.rotateDocuments(ids, 90).subscribe() diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index ecde41d24..971396bac 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -50,8 +50,6 @@ export type DocumentBulkEditMethod = | 'remove_tag' | 'modify_tags' | 'modify_custom_fields' - | 'delete' - | 'reprocess' | 'set_permissions' export interface MergeDocumentsRequest { @@ -348,6 +346,18 @@ export class DocumentService extends AbstractPaperlessService { }) } + deleteDocuments(ids: number[]) { + return this.http.post(this.getResourceUrl(null, 'delete'), { + documents: ids, + }) + } + + reprocessDocuments(ids: number[]) { + return this.http.post(this.getResourceUrl(null, 'reprocess'), { + documents: ids, + }) + } + rotateDocuments( ids: number[], degrees: number, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 1a2b56d0f..bbb96de08 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1745,6 +1745,14 @@ class RemovePasswordDocumentsSerializer( ) +class DeleteDocumentsSerializer(DocumentListSerializer): + pass + + +class ReprocessDocumentsSerializer(DocumentListSerializer): + pass + + class BulkEditSerializer( SerializerWithPerms, DocumentListSerializer, @@ -1754,6 +1762,8 @@ class BulkEditSerializer( # TODO: remove this and related backwards compatibility code when API v9 is dropped # split, delete_pages can be removed entirely MOVED_DOCUMENT_ACTION_ENDPOINTS = { + "delete": "/api/documents/delete/", + "reprocess": "/api/documents/reprocess/", "rotate": "/api/documents/rotate/", "merge": "/api/documents/merge/", "edit_pdf": "/api/documents/edit_pdf/", @@ -1772,8 +1782,6 @@ class BulkEditSerializer( "remove_tag", "modify_tags", "modify_custom_fields", - "delete", - "reprocess", "set_permissions", *LEGACY_DOCUMENT_ACTION_METHODS, ], diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 0ffe6149e..04c3b5ff8 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -422,6 +422,34 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(args[0], [self.doc1.id]) self.assertEqual(len(kwargs), 0) + @mock.patch("documents.views.bulk_edit.delete") + def test_delete_documents_endpoint(self, m) -> None: + self.setup_mock(m, "delete") + response = self.client.post( + "/api/documents/delete/", + json.dumps({"documents": [self.doc1.id]}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertEqual(args[0], [self.doc1.id]) + self.assertEqual(len(kwargs), 0) + + @mock.patch("documents.views.bulk_edit.reprocess") + def test_reprocess_documents_endpoint(self, m) -> None: + self.setup_mock(m, "reprocess") + response = self.client.post( + "/api/documents/reprocess/", + json.dumps({"documents": [self.doc1.id]}), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertEqual(args[0], [self.doc1.id]) + self.assertEqual(len(kwargs), 0) + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") def test_api_set_storage_path(self, m) -> None: """ @@ -1218,6 +1246,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): def test_bulk_edit_allows_legacy_file_methods_with_warning(self) -> None: method_payloads = { + "delete": {}, + "reprocess": {}, "rotate": {"degrees": 90}, "merge": {"metadata_document_id": self.doc2.id}, "edit_pdf": {"operations": [{"page": 1}]}, diff --git a/src/documents/tests/test_api_schema.py b/src/documents/tests/test_api_schema.py index 83c178cd6..e14762f68 100644 --- a/src/documents/tests/test_api_schema.py +++ b/src/documents/tests/test_api_schema.py @@ -31,12 +31,14 @@ class TestApiSchema(APITestCase): self.assertEqual(schema_response.status_code, status.HTTP_200_OK) paths = schema_response.data["paths"] + self.assertIn("/api/documents/delete/", paths) + self.assertIn("/api/documents/reprocess/", paths) self.assertIn("/api/documents/rotate/", paths) self.assertIn("/api/documents/merge/", paths) self.assertIn("/api/documents/edit_pdf/", paths) self.assertIn("/api/documents/remove_password/", paths) - def test_schema_bulk_edit_advertises_legacy_file_edit_methods(self) -> None: + def test_schema_bulk_edit_advertises_legacy_document_action_methods(self) -> None: schema_response = self.client.get(self.ENDPOINT) self.assertEqual(schema_response.status_code, status.HTTP_200_OK) @@ -48,7 +50,9 @@ class TestApiSchema(APITestCase): enum_ref = method_schema["allOf"][0]["$ref"].split("/")[-1] advertised_methods = schema[enum_ref]["enum"] - for file_method in [ + for action_method in [ + "delete", + "reprocess", "rotate", "merge", "edit_pdf", @@ -56,4 +60,4 @@ class TestApiSchema(APITestCase): "split", "delete_pages", ]: - self.assertIn(file_method, advertised_methods) + self.assertIn(action_method, advertised_methods) diff --git a/src/documents/views.py b/src/documents/views.py index 29a2b7805..d51cde25f 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -176,6 +176,7 @@ from documents.serialisers import BulkEditObjectsSerializer from documents.serialisers import BulkEditSerializer from documents.serialisers import CorrespondentSerializer from documents.serialisers import CustomFieldSerializer +from documents.serialisers import DeleteDocumentsSerializer from documents.serialisers import DocumentListSerializer from documents.serialisers import DocumentSerializer from documents.serialisers import DocumentTypeSerializer @@ -187,6 +188,7 @@ from documents.serialisers import MergeDocumentsSerializer from documents.serialisers import NotesSerializer from documents.serialisers import PostDocumentSerializer from documents.serialisers import RemovePasswordDocumentsSerializer +from documents.serialisers import ReprocessDocumentsSerializer from documents.serialisers import RotateDocumentsSerializer from documents.serialisers import RunTaskViewSerializer from documents.serialisers import SavedViewSerializer @@ -2421,6 +2423,60 @@ class MergeDocumentsView(DocumentOperationPermissionMixin): ) +@extend_schema_view( + post=extend_schema( + operation_id="documents_delete", + description="Move selected documents to trash", + responses={ + 200: inline_serializer( + name="DeleteDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class DeleteDocumentsView(DocumentOperationPermissionMixin): + serializer_class = DeleteDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.delete, + validated_data=serializer.validated_data, + operation_label="document delete", + ) + + +@extend_schema_view( + post=extend_schema( + operation_id="documents_reprocess", + description="Reprocess selected documents", + responses={ + 200: inline_serializer( + name="ReprocessDocumentsResult", + fields={ + "result": serializers.CharField(), + }, + ), + }, + ), +) +class ReprocessDocumentsView(DocumentOperationPermissionMixin): + serializer_class = ReprocessDocumentsSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + return self._execute_document_action( + method=bulk_edit.reprocess, + validated_data=serializer.validated_data, + operation_label="document reprocess", + ) + + @extend_schema_view( post=extend_schema( operation_id="documents_edit_pdf", diff --git a/src/paperless/urls.py b/src/paperless/urls.py index df8d059bd..88e1a3178 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -21,6 +21,7 @@ from documents.views import BulkEditView from documents.views import ChatStreamingView from documents.views import CorrespondentViewSet from documents.views import CustomFieldViewSet +from documents.views import DeleteDocumentsView from documents.views import DocumentTypeViewSet from documents.views import EditPdfDocumentsView from documents.views import GlobalSearchView @@ -30,6 +31,7 @@ from documents.views import MergeDocumentsView from documents.views import PostDocumentView from documents.views import RemoteVersionView from documents.views import RemovePasswordDocumentsView +from documents.views import ReprocessDocumentsView from documents.views import RotateDocumentsView from documents.views import SavedViewViewSet from documents.views import SearchAutoCompleteView @@ -136,6 +138,16 @@ urlpatterns = [ BulkEditView.as_view(), name="bulk_edit", ), + re_path( + "^delete/", + DeleteDocumentsView.as_view(), + name="delete_documents", + ), + re_path( + "^reprocess/", + ReprocessDocumentsView.as_view(), + name="reprocess_documents", + ), re_path( "^rotate/", RotateDocumentsView.as_view(),