Enhancement (QoL): support deselecting single items from "select all" (#14117)

This commit is contained in:
shamoon
2026-09-18 01:28:55 +00:00
committed by GitHub
parent d80997a070
commit f4ff87e69b
9 changed files with 350 additions and 33 deletions
@@ -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: [] },
})
@@ -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)
@@ -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', () => {
@@ -85,6 +85,11 @@ export interface ListViewState {
*/
allSelected?: boolean
/**
* Document IDs excluded from the full filtered result set.
*/
excluded?: Set<number>
/**
* The page size of the list view.
*/
@@ -215,6 +220,7 @@ export class DocumentListViewService {
filterRules: [],
selected: new Set<number>(),
allSelected: false,
excluded: new Set<number>(),
}
}
@@ -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<number> {
this.trackState()
if (!this.activeListViewState.excluded) {
this.activeListViewState.excluded = new Set<number>()
}
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) {
@@ -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,
})
@@ -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<Document> {
})
}
getSelectionData(ids: number[]): Observable<SelectionData> {
getSelectionData(
selection: DocumentSelectionQuery
): Observable<SelectionData> {
return this.http.post<SelectionData>(
this.getResourceUrl(null, 'selection_data'),
{ documents: ids }
selection
)
}
+12
View File
@@ -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.",
+94
View File
@@ -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()
+19 -5
View File
@@ -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),
)