From f4ff87e69b0bda468561a2ec8aa8f687727bb624 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:28:55 -0700 Subject: [PATCH] Enhancement (QoL): support deselecting single items from "select all" (#14117) --- .../bulk-editor/bulk-editor.component.spec.ts | 87 ++++++++++++++++- .../bulk-editor/bulk-editor.component.ts | 22 +++-- .../document-list-view.service.spec.ts | 69 +++++++++++++- .../services/document-list-view.service.ts | 48 ++++++++-- .../services/rest/document.service.spec.ts | 20 +++- .../src/app/services/rest/document.service.ts | 7 +- src/documents/serialisers.py | 12 +++ src/documents/tests/test_api_bulk_edit.py | 94 +++++++++++++++++++ src/documents/views.py | 24 ++++- 9 files changed, 350 insertions(+), 33 deletions(-) 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 29212c955..e8d38c11b 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 @@ -9,7 +9,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { By } from '@angular/platform-browser' import { Router } from '@angular/router' import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' -import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { of, throwError } from 'rxjs' import { Correspondent } from 'src/app/data/correspondent' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' @@ -392,6 +392,42 @@ describe('BulkEditorComponent', () => { expect(component.tagSelectionModel.selectionSize()).toEqual(1) }) + it('should request selection data for tags when documents are excluded from an all-filtered selection', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + fixture.detectChanges() + jest + .spyOn(documentListViewService, 'allSelected', 'get') + .mockReturnValue(true) + jest + .spyOn(documentListViewService, 'excluded', 'get') + .mockReturnValue(new Set([4])) + jest + .spyOn(documentListViewService, 'filterRules', 'get') + .mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }]) + jest + .spyOn(documentListViewService, 'selectedCount', 'get') + .mockReturnValue(2) + const adjustedSelectionData: SelectionData = { + ...selectionData, + selected_tags: [{ id: 12, document_count: 2 }], + } + const getSelectionDataSpy = jest + .spyOn(documentService, 'getSelectionData') + .mockReturnValue(of(adjustedSelectionData)) + + component.openTagsDropdown() + + expect(getSelectionDataSpy).toHaveBeenCalledWith({ + all: true, + filters: { title_search: 'apple' }, + excluded_documents: [4], + }) + expect(component.tagDocumentCounts()).toEqual( + adjustedSelectionData.selected_tags + ) + expect(component.tagSelectionModel.selectionSize()).toEqual(1) + }) + it('should apply list selection data to document types menu when all filtered documents are selected', () => { jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) fixture.detectChanges() @@ -460,6 +496,47 @@ describe('BulkEditorComponent', () => { ) }) + it('should request selection data for the other metadata menus when documents are excluded', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + fixture.detectChanges() + jest + .spyOn(documentListViewService, 'allSelected', 'get') + .mockReturnValue(true) + jest + .spyOn(documentListViewService, 'excluded', 'get') + .mockReturnValue(new Set([4])) + jest + .spyOn(documentListViewService, 'filterRules', 'get') + .mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }]) + const getSelectionDataSpy = jest + .spyOn(documentService, 'getSelectionData') + .mockReturnValue(of(selectionData)) + + component.openDocumentTypeDropdown() + component.openCorrespondentDropdown() + component.openStoragePathDropdown() + component.openCustomFieldsDropdown() + + expect(getSelectionDataSpy).toHaveBeenCalledTimes(4) + expect(getSelectionDataSpy).toHaveBeenCalledWith({ + all: true, + filters: { title_search: 'apple' }, + excluded_documents: [4], + }) + expect(component.documentTypeDocumentCounts()).toEqual( + selectionData.selected_document_types + ) + expect(component.correspondentDocumentCounts()).toEqual( + selectionData.selected_correspondents + ) + expect(component.storagePathDocumentCounts()).toEqual( + selectionData.selected_storage_paths + ) + expect(component.customFieldDocumentCounts()).toEqual( + selectionData.selected_custom_fields + ) + }) + it('should execute modify tags bulk operation', () => { jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest @@ -501,16 +578,19 @@ describe('BulkEditorComponent', () => { .mockReturnValue([{ id: 3 }, { id: 4 }]) jest .spyOn(documentListViewService, 'selected', 'get') - .mockReturnValue(new Set([3, 4])) + .mockReturnValue(new Set([3])) jest .spyOn(documentListViewService, 'allSelected', 'get') .mockReturnValue(true) + jest + .spyOn(documentListViewService, 'excluded', 'get') + .mockReturnValue(new Set([4])) jest .spyOn(documentListViewService, 'filterRules', 'get') .mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }]) jest .spyOn(documentListViewService, 'selectedCount', 'get') - .mockReturnValue(25) + .mockReturnValue(24) jest .spyOn(permissionsService, 'currentUserHasObjectPermissions') .mockReturnValue(true) @@ -529,6 +609,7 @@ describe('BulkEditorComponent', () => { expect(req.request.body).toEqual({ all: true, filters: { title_search: 'apple' }, + excluded_documents: [4], method: 'modify_tags', parameters: { add_tags: [101], remove_tags: [] }, }) 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 c8d23c1bb..33181a227 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 @@ -361,6 +361,7 @@ export class BulkEditorComponent return { all: true, filters: queryParamsFromFilterRules(this.list.filterRules), + excluded_documents: Array.from(this.list.excluded), } } @@ -374,7 +375,8 @@ export class BulkEditorComponent } openTagsDropdown() { - if (this.list.allSelected) { + // If none excluded, use the selection data already available in the list view, otherwise fetch + if (this.list.allSelected && this.list.excluded.size === 0) { const selectionData = this.list.selectionData this.tagDocumentCounts.set(selectionData?.selected_tags ?? []) this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel) @@ -382,7 +384,7 @@ export class BulkEditorComponent } this.documentService - .getSelectionData(Array.from(this.list.selected)) + .getSelectionData(this.getSelectionQuery()) .pipe(first()) .subscribe((s) => { this.tagDocumentCounts.set(s.selected_tags) @@ -391,7 +393,7 @@ export class BulkEditorComponent } openDocumentTypeDropdown() { - if (this.list.allSelected) { + if (this.list.allSelected && this.list.excluded.size === 0) { const selectionData = this.list.selectionData this.documentTypeDocumentCounts.set( selectionData?.selected_document_types ?? [] @@ -404,7 +406,7 @@ export class BulkEditorComponent } this.documentService - .getSelectionData(Array.from(this.list.selected)) + .getSelectionData(this.getSelectionQuery()) .pipe(first()) .subscribe((s) => { this.documentTypeDocumentCounts.set(s.selected_document_types) @@ -416,7 +418,7 @@ export class BulkEditorComponent } openCorrespondentDropdown() { - if (this.list.allSelected) { + if (this.list.allSelected && this.list.excluded.size === 0) { const selectionData = this.list.selectionData this.correspondentDocumentCounts.set( selectionData?.selected_correspondents ?? [] @@ -429,7 +431,7 @@ export class BulkEditorComponent } this.documentService - .getSelectionData(Array.from(this.list.selected)) + .getSelectionData(this.getSelectionQuery()) .pipe(first()) .subscribe((s) => { this.correspondentDocumentCounts.set(s.selected_correspondents) @@ -441,7 +443,7 @@ export class BulkEditorComponent } openStoragePathDropdown() { - if (this.list.allSelected) { + if (this.list.allSelected && this.list.excluded.size === 0) { const selectionData = this.list.selectionData this.storagePathDocumentCounts.set( selectionData?.selected_storage_paths ?? [] @@ -454,7 +456,7 @@ export class BulkEditorComponent } this.documentService - .getSelectionData(Array.from(this.list.selected)) + .getSelectionData(this.getSelectionQuery()) .pipe(first()) .subscribe((s) => { this.storagePathDocumentCounts.set(s.selected_storage_paths) @@ -466,7 +468,7 @@ export class BulkEditorComponent } openCustomFieldsDropdown() { - if (this.list.allSelected) { + if (this.list.allSelected && this.list.excluded.size === 0) { const selectionData = this.list.selectionData this.customFieldDocumentCounts.set( selectionData?.selected_custom_fields ?? [] @@ -479,7 +481,7 @@ export class BulkEditorComponent } this.documentService - .getSelectionData(Array.from(this.list.selected)) + .getSelectionData(this.getSelectionQuery()) .pipe(first()) .subscribe((s) => { this.customFieldDocumentCounts.set(s.selected_custom_fields) diff --git a/src-ui/src/app/services/document-list-view.service.spec.ts b/src-ui/src/app/services/document-list-view.service.spec.ts index 9447101ad..772c851e0 100644 --- a/src-ui/src/app/services/document-list-view.service.spec.ts +++ b/src-ui/src/app/services/document-list-view.service.spec.ts @@ -580,7 +580,7 @@ describe('DocumentListViewService', () => { expect(documentListViewService.isSelected(documents[3])).toBeTruthy() }) - it('should clear all-selected mode when toggling a single document', () => { + it('should exclude a toggled document while keeping all-selected mode', () => { documentListViewService.reload() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` @@ -592,8 +592,73 @@ describe('DocumentListViewService', () => { documentListViewService.toggleSelected(documents[0]) - expect(documentListViewService.allSelected).toBeFalsy() + expect(documentListViewService.allSelected).toBeTruthy() + expect(documentListViewService.excluded).toEqual(new Set([documents[0].id])) + expect(documentListViewService.selectedCount).toEqual(documents.length - 1) expect(documentListViewService.isSelected(documents[0])).toBeFalsy() + + documentListViewService.toggleSelected(documents[0]) + + expect(documentListViewService.excluded.size).toEqual(0) + expect(documentListViewService.selectedCount).toEqual(documents.length) + expect(documentListViewService.isSelected(documents[0])).toBeTruthy() + }) + + it('should preserve exclusions across pages', () => { + documentListViewService.pageSize = 3 + let req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true` + ) + req.flush({ count: documents.length, results: documents.slice(0, 3) }) + + documentListViewService.selectAll() + documentListViewService.toggleSelected(documents[0]) + documentListViewService.currentPage = 2 + req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true` + ) + req.flush({ count: documents.length, results: documents.slice(3, 6) }) + + expect(documentListViewService.excluded).toEqual(new Set([documents[0].id])) + expect(documentListViewService.selectedCount).toEqual(documents.length - 1) + expect(documentListViewService.selected).toEqual( + new Set(documents.slice(3, 6).map((document) => document.id)) + ) + + documentListViewService.currentPage = 1 + req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true` + ) + req.flush({ count: documents.length, results: documents.slice(0, 3) }) + + expect(documentListViewService.isSelected(documents[0])).toBeFalsy() + expect(documentListViewService.isSelected(documents[1])).toBeTruthy() + }) + + it('should clear exclusions when filters change', () => { + documentListViewService.reload() + let req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` + ) + req.flush(full_results) + documentListViewService.selectAll() + documentListViewService.toggleSelected(documents[0]) + + documentListViewService.setFilterRules(filterRules) + req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9` + ) + req.flush({ count: 3, results: documents.slice(0, 3) }) + + expect(documentListViewService.allSelected).toBeTruthy() + expect(documentListViewService.excluded.size).toEqual(0) + expect(documentListViewService.selectedCount).toEqual(3) + + documentListViewService.setFilterRules([]) + req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true` + ) + req.flush(full_results) }) it('should clear all-selected mode when selecting a range', () => { diff --git a/src-ui/src/app/services/document-list-view.service.ts b/src-ui/src/app/services/document-list-view.service.ts index de1db2be6..429cde2c0 100644 --- a/src-ui/src/app/services/document-list-view.service.ts +++ b/src-ui/src/app/services/document-list-view.service.ts @@ -85,6 +85,11 @@ export interface ListViewState { */ allSelected?: boolean + /** + * Document IDs excluded from the full filtered result set. + */ + excluded?: Set + /** * The page size of the list view. */ @@ -215,6 +220,7 @@ export class DocumentListViewService { filterRules: [], selected: new Set(), allSelected: false, + excluded: new Set(), } } @@ -224,7 +230,9 @@ export class DocumentListViewService { } this.selected.clear() - this.documents?.forEach((doc) => this.selected.add(doc.id)) + this.documents + ?.filter((doc) => !this.excluded.has(doc.id)) + .forEach((doc) => this.selected.add(doc.id)) if (!this.collectionSize) { this.selectNone() @@ -491,14 +499,23 @@ export class DocumentListViewService { return this.activeListViewState.allSelected ?? false } + get excluded(): Set { + this.trackState() + if (!this.activeListViewState.excluded) { + this.activeListViewState.excluded = new Set() + } + return this.activeListViewState.excluded + } + get selectedCount(): number { - return this.allSelected - ? (this.collectionSize ?? this.selected.size) - : this.selected.size + if (!this.allSelected || this.collectionSize == null) { + return this.selected.size + } + return Math.max(0, this.collectionSize - this.excluded.size) } get hasSelection(): boolean { - return this.allSelected || this.selected.size > 0 + return this.selectedCount > 0 } setSort(field: string, reverse: boolean) { @@ -663,12 +680,14 @@ export class DocumentListViewService { selectNone() { this.activeListViewState.allSelected = false this.selected.clear() + this.excluded.clear() this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null this.markChanged() } reduceSelectionToFilter() { if (this.allSelected) { + this.excluded.clear() return } @@ -688,6 +707,7 @@ export class DocumentListViewService { selectAll() { this.activeListViewState.allSelected = true + this.excluded.clear() this.syncSelectedToCurrentPage() this.markChanged() } @@ -695,6 +715,7 @@ export class DocumentListViewService { selectPage() { this.activeListViewState.allSelected = false this.selected.clear() + this.excluded.clear() this.documents.forEach((doc) => { this.selected.add(doc.id) }) @@ -702,15 +723,23 @@ export class DocumentListViewService { } isSelected(d: Document) { - return this.allSelected || this.selected.has(d.id) + return this.allSelected ? !this.excluded.has(d.id) : this.selected.has(d.id) } toggleSelected(d: Document): void { if (this.allSelected) { - this.activeListViewState.allSelected = false + if (this.excluded.has(d.id)) { + this.excluded.delete(d.id) + this.selected.add(d.id) + } else { + this.excluded.add(d.id) + this.selected.delete(d.id) + } + } else if (this.selected.has(d.id)) { + this.selected.delete(d.id) + } else { + this.selected.add(d.id) } - if (this.selected.has(d.id)) this.selected.delete(d.id) - else this.selected.add(d.id) this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id) this.lastRangeSelectionToIndex = null this.markChanged() @@ -719,6 +748,7 @@ export class DocumentListViewService { selectRangeTo(d: Document) { if (this.allSelected) { this.activeListViewState.allSelected = false + this.excluded.clear() } if (this.rangeSelectionAnchorIndex !== null) { 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 fbb763c46..4250d8e84 100644 --- a/src-ui/src/app/services/rest/document.service.spec.ts +++ b/src-ui/src/app/services/rest/document.service.spec.ts @@ -175,7 +175,7 @@ describe(`DocumentService`, () => { it('should call appropriate api endpoint for getting selection data', () => { const ids = [documents[0].id] - subscription = service.getSelectionData(ids).subscribe() + subscription = service.getSelectionData({ documents: ids }).subscribe() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}${endpoint}/selection_data/` ) @@ -185,6 +185,20 @@ describe(`DocumentService`, () => { }) }) + it('should get selection data with all, filters, and exclusions', () => { + const selection = { + all: true, + filters: { title__icontains: 'apple' }, + excluded_documents: [2, 3], + } + subscription = service.getSelectionData(selection).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/selection_data/` + ) + expect(req.request.method).toEqual('POST') + expect(req.request.body).toEqual(selection) + }) + it('should call appropriate api endpoint for getting suggestions', () => { subscription = service.getSuggestions(documents[0].id).subscribe() const req = httpTestingController.expectOne( @@ -240,7 +254,7 @@ describe(`DocumentService`, () => { }) }) - it('should call appropriate api endpoint for bulk edit with all and filters', () => { + it('should call appropriate api endpoint for bulk edit with all, filters, and exclusions', () => { const method = 'modify_tags' const parameters = { add_tags: [15], @@ -249,6 +263,7 @@ describe(`DocumentService`, () => { const selection = { all: true, filters: { title__icontains: 'apple' }, + excluded_documents: [2, 3], } subscription = service.bulkEdit(selection, method, parameters).subscribe() const req = httpTestingController.expectOne( @@ -258,6 +273,7 @@ describe(`DocumentService`, () => { expect(req.request.body).toEqual({ all: true, filters: { title__icontains: 'apple' }, + excluded_documents: [2, 3], method, parameters, }) diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index 92ba21d33..3272d9695 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -72,6 +72,7 @@ export interface DocumentSelectionQuery { documents?: number[] all?: boolean filters?: { [key: string]: any } + excluded_documents?: number[] } @Injectable({ @@ -407,10 +408,12 @@ export class DocumentService extends AbstractPaperlessService { }) } - getSelectionData(ids: number[]): Observable { + getSelectionData( + selection: DocumentSelectionQuery + ): Observable { return this.http.post( this.getResourceUrl(null, 'selection_data'), - { documents: ids } + selection ) } diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 917c68ca4..c063a72fa 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1647,11 +1647,23 @@ class DocumentSelectionSerializer(DocumentListSerializer): write_only=True, ) + excluded_documents = serializers.ListField( + required=False, + default=list, + write_only=True, + child=serializers.IntegerField(), + ) + def validate(self, attrs): if attrs.get("all", False): attrs.setdefault("documents", []) return attrs + if attrs["excluded_documents"]: + raise serializers.ValidationError( + "excluded_documents is only supported when all is true.", + ) + if "documents" not in attrs: raise serializers.ValidationError( "documents is required unless all is true.", diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 0c13cce40..0657a98cc 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -520,6 +520,30 @@ 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_with_excluded_documents(self, m) -> None: + self.setup_mock(m, "delete") + response = self.client.post( + "/api/documents/delete/", + json.dumps( + { + "all": True, + "excluded_documents": [ + self.doc2.id, + self.doc3.id, + self.doc4.id, + self.doc5.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") @@ -691,6 +715,26 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"documents is required unless all is true", response.content) + def test_api_rejects_excluded_documents_unless_all_is_true(self) -> None: + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id], + "excluded_documents": [self.doc2.id], + "method": "set_storage_path", + "parameters": {"storage_path": self.sp1.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn( + b"excluded_documents is only supported when all is true", + response.content, + ) + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") def test_api_bulk_edit_with_all_true_resolves_documents_from_filters( self, @@ -717,6 +761,29 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(args[0], [self.doc2.id]) self.assertEqual(kwargs["storage_path"], self.sp1.id) + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") + def test_api_bulk_edit_with_all_true_excludes_documents(self, m) -> None: + self.setup_mock(m, "set_storage_path") + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "all": True, + "excluded_documents": [self.doc2.id, self.doc4.id], + "method": "set_storage_path", + "parameters": {"storage_path": self.sp1.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertCountEqual(args[0], [self.doc1.id, self.doc3.id, self.doc5.id]) + self.assertEqual(kwargs["storage_path"], self.sp1.id) + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None: self.setup_mock(m, "set_storage_path") @@ -1077,6 +1144,33 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): ], ) + def test_api_selection_data_with_excluded_documents(self) -> None: + response = self.client.post( + "/api/documents/selection_data/", + json.dumps( + { + "all": True, + "excluded_documents": [self.doc2.id], + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertCountEqual( + response.data["selected_correspondents"], + [ + {"id": self.c1.id, "document_count": 0}, + {"id": self.c2.id, "document_count": 1}, + ], + ) + self.assertCountEqual( + response.data["selected_tags"], + [ + {"id": self.t1.id, "document_count": 1}, + {"id": self.t2.id, "document_count": 2}, + ], + ) + def test_api_selection_data_requires_view_permission(self) -> None: self.doc2.owner = self.user self.doc2.save() diff --git a/src/documents/views.py b/src/documents/views.py index 6f647bf3f..cc09709a4 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -194,7 +194,7 @@ 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 DocumentSelectionSerializer from documents.serialisers import DocumentSerializer from documents.serialisers import DocumentTypeSerializer from documents.serialisers import DocumentVersionLabelSerializer @@ -2933,6 +2933,10 @@ class DocumentSelectionMixin: ) if search_filtered_ids is not None: filtered_documents = filtered_documents.filter(pk__in=search_filtered_ids) + if validated_data.get("excluded_documents"): + filtered_documents = filtered_documents.exclude( + pk__in=validated_data["excluded_documents"], + ) return list(filtered_documents.values_list("pk", flat=True)) @@ -3056,7 +3060,14 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin): parameters = { k: v for k, v in validated_data.items() - if k not in {"documents", "all", "filters", "from_webui"} + if k + not in { + "documents", + "all", + "filters", + "excluded_documents", + "from_webui", + } } user = self.request.user from_webui = validated_data.get("from_webui", False) @@ -3557,16 +3568,19 @@ class PostDocumentView(GenericAPIView[Any]): }, ), ) -class SelectionDataView(GenericAPIView[Any]): +class SelectionDataView(DocumentSelectionMixin, GenericAPIView[Any]): permission_classes = (IsAuthenticated, ViewDocumentsPermissions) - serializer_class = DocumentListSerializer + serializer_class = DocumentSelectionSerializer parser_classes = (parsers.MultiPartParser, parsers.JSONParser) def post(self, request, format=None): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - ids = serializer.validated_data.get("documents") + ids = self._resolve_document_ids( + user=request.user, + validated_data=serializer.validated_data, + ) permitted_documents = Document.objects.filter( id__in=permitted_document_ids(request.user), )