Compare commits

..

25 Commits

Author SHA1 Message Date
shamoon
c53e54e4a5 We have to now disable operations that always require an explicit set of IDs
(which is fine because sending or merging all your docs seems silly)
2026-03-14 22:14:53 -07:00
shamoon
60b5a73a00 Dialog too 2026-03-14 22:14:53 -07:00
shamoon
b87741c845 Update some calls with the DocumentSelectionQuery 2026-03-14 22:14:53 -07:00
shamoon
6dc933dabf Wire up selection with hasSelection, allSelected since we dont get list of IDs anymore 2026-03-14 22:14:53 -07:00
shamoon
57e04f614d Pass the all / filters from document service 2026-03-14 22:03:50 -07:00
shamoon
1775846483 Dont allow these ones to get all 2026-03-13 23:51:54 -07:00
shamoon
13671b7d85 Add API all/filters support 2026-03-13 23:51:45 -07:00
shamoon
0bb7d755ab Sonar 2026-03-13 07:17:37 -07:00
shamoon
e4d43175af Fix 2026-03-13 07:17:36 -07:00
shamoon
04945ff3f7 Update api.md 2026-03-13 07:17:36 -07:00
shamoon
7b430e27c6 Frontend use all option for bulk edit objects instead of sending IDs 2026-03-13 07:17:00 -07:00
shamoon
b329581111 Support all for BulkEditObjectsView 2026-03-13 07:16:59 -07:00
shamoon
84e8caf25f Use a backend display_count to fix nested tag thing 2026-03-13 07:16:59 -07:00
shamoon
97602f79fb Not even optional 2026-03-13 07:16:58 -07:00
shamoon
568be982cf Remove this stuff now 2026-03-13 07:16:58 -07:00
shamoon
d753b698db tests 2026-03-13 07:16:58 -07:00
shamoon
eabd11546a Only fetch all IDs on demand 2026-03-13 07:16:57 -07:00
shamoon
43072b7a74 Backend tests 2026-03-13 07:16:57 -07:00
shamoon
1c65a1bb0e Backend deprecate all to only api v < 10 2026-03-13 07:16:56 -07:00
shamoon
0ed3103227 Update api-dashboard3.har 2026-03-13 07:16:37 -07:00
shamoon
ea55ec8bc5 Fix e2e tests 2026-03-13 07:16:37 -07:00
shamoon
c977445718 Fix 2026-03-13 07:16:36 -07:00
shamoon
b313759903 Frontend use the new integrated selection data 2026-03-13 07:16:36 -07:00
shamoon
5f0887046c Tests for include_selection_data 2026-03-13 07:16:35 -07:00
shamoon
047d4eca84 Support include_selection_data in document list/search 2026-03-13 07:16:33 -07:00
55 changed files with 1220 additions and 521 deletions

View File

@@ -437,3 +437,6 @@ Initial API version.
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
for API v9 is dropped.
- The `all` parameter of list endpoints is now deprecated and will be removed in a future version.
- The bulk edit objects endpoint now supports `all` and `filters` parameters to avoid having to send
large lists of object IDs for operations affecting many objects.

View File

@@ -457,7 +457,7 @@ fields and permissions, which will be merged.
#### Types {#workflow-trigger-types}
Currently, there are five events that correspond to workflow trigger 'types':
Currently, there are four events that correspond to workflow trigger 'types':
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
folder or API), file path, file name, mail rule
@@ -469,10 +469,8 @@ Currently, there are five events that correspond to workflow trigger 'types':
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
offsets will trigger after the date, negative offsets will trigger before).
5. **Version Added**: when a new version is added for an existing document. This trigger evaluates filters against the root document
and applies actions to the root document.
The following flow diagram illustrates the document trigger types:
The following flow diagram illustrates the four document trigger types:
```mermaid
flowchart TD
@@ -488,10 +486,6 @@ flowchart TD
'Updated'
trigger(s)"}
version{"Matching
'Version Added'
trigger(s)"}
scheduled{"Documents
matching
trigger(s)"}
@@ -508,15 +502,11 @@ flowchart TD
updated --> |Yes| J[Workflow Actions Run]
updated --> |No| K
J --> K[Document Saved]
L[New Document Version Added] --> version
version --> |Yes| V[Workflow Actions Run]
version --> |No| W
V --> W[Document Saved]
X[Scheduled Task Check<br/>hourly at :05] --> Y[Get All Scheduled Triggers]
Y --> scheduled
scheduled --> |Yes| Z[Workflow Actions Run]
scheduled --> |No| AA[Document Saved]
Z --> AA
L[Scheduled Task Check<br/>hourly at :05] --> M[Get All Scheduled Triggers]
M --> scheduled
scheduled --> |Yes| N[Workflow Actions Run]
scheduled --> |No| O[Document Saved]
N --> O
```
#### Filters {#workflow-trigger-filters}

View File

@@ -468,7 +468,7 @@
"time": 0.951,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=9",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=9",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [

File diff suppressed because one or more lines are too long

View File

@@ -534,7 +534,7 @@
"time": 0.653,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9",
"url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [

View File

@@ -883,7 +883,7 @@
"time": 0.93,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
@@ -961,7 +961,7 @@
"time": -1,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [

View File

@@ -16,7 +16,7 @@ test('basic filtering', async ({ page }) => {
await expect(page).toHaveURL(/tags__id__all=9/)
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
await page.getByRole('button', { name: 'Document type' }).click()
await page.getByRole('menuitem', { name: 'Invoice Test 3' }).click()
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
await expect(page).toHaveURL(/document_type__id__in=1/)
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
await page.getByRole('button', { name: 'Reset filters' }).first().click()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -164,7 +164,7 @@
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
}
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.VersionAdded) {
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (matchingPatternRequired(formGroup)) {
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
@@ -175,7 +175,7 @@
}
</div>
</div>
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.VersionAdded) {
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<div class="row mt-3">
<div class="col">
<div class="trigger-filters mb-3">

View File

@@ -120,10 +120,6 @@ export const WORKFLOW_TYPE_OPTIONS = [
id: WorkflowTriggerType.Scheduled,
name: $localize`Scheduled`,
},
{
id: WorkflowTriggerType.VersionAdded,
name: $localize`Version Added`,
},
]
export const WORKFLOW_ACTION_OPTIONS = [

View File

@@ -20,9 +20,9 @@ import { Subject, filter, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { SelectionDataItem } from 'src/app/data/results'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { HotKeyService } from 'src/app/services/hot-key.service'
import { SelectionDataItem } from 'src/app/services/rest/document.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'

View File

@@ -959,7 +959,7 @@ describe('DocumentDetailComponent', () => {
component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith([doc.id])
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] })
expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()

View File

@@ -1379,25 +1379,27 @@ export class DocumentDetailComponent
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
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
)
},
})
this.documentsService
.reprocessDocuments({ documents: [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
)
},
})
})
}

View File

@@ -92,7 +92,7 @@
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
<i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container>
</button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
</button>
</div>
@@ -103,13 +103,13 @@
class="btn btn-sm btn-outline-primary"
id="dropdownSend"
ngbDropdownToggle
[disabled]="disabled || list.selected.size === 0"
[disabled]="disabled || !list.hasSelection || list.allSelected"
>
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
</div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button>
<button ngbDropdownItem (click)="manageShareLinkBundles()">
@@ -117,7 +117,7 @@
</button>
<div class="dropdown-divider"></div>
@if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()">
<button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
</button>
}

View File

@@ -13,6 +13,7 @@ import { of, throwError } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
@@ -273,6 +274,24 @@ describe('BulkEditorComponent', () => {
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
})
it('should apply list selection data to tags menu when all filtered documents are selected', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(3)
documentListViewService.selectionData = selectionData
const getSelectionDataSpy = jest.spyOn(documentService, 'getSelectionData')
component.openTagsDropdown()
expect(getSelectionDataSpy).not.toHaveBeenCalled()
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
})
it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
@@ -300,13 +319,56 @@ describe('BulkEditorComponent', () => {
parameters: { add_tags: [101], remove_tags: [] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should execute modify tags bulk operation for all filtered documents', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'filterRules', 'get')
.mockReturnValue([{ rule_type: FILTER_TITLE, value: 'apple' }])
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(25)
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setTags({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
all: true,
filters: { title__icontains: 'apple' },
method: 'modify_tags',
parameters: { add_tags: [101], remove_tags: [] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
})
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -332,7 +394,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -423,7 +485,7 @@ describe('BulkEditorComponent', () => {
parameters: { correspondent: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -455,7 +517,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -521,7 +583,7 @@ describe('BulkEditorComponent', () => {
parameters: { document_type: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -553,7 +615,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -619,7 +681,7 @@ describe('BulkEditorComponent', () => {
parameters: { storage_path: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -651,7 +713,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -717,7 +779,7 @@ describe('BulkEditorComponent', () => {
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -749,7 +811,7 @@ describe('BulkEditorComponent', () => {
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -858,7 +920,7 @@ describe('BulkEditorComponent', () => {
documents: [3, 4],
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -951,7 +1013,7 @@ describe('BulkEditorComponent', () => {
documents: [3, 4],
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -986,7 +1048,7 @@ describe('BulkEditorComponent', () => {
source_mode: 'latest_version',
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1027,7 +1089,7 @@ describe('BulkEditorComponent', () => {
metadata_document_id: 3,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1046,7 +1108,7 @@ describe('BulkEditorComponent', () => {
delete_originals: true,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1067,7 +1129,7 @@ describe('BulkEditorComponent', () => {
archive_fallback: true,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1089,22 +1151,39 @@ describe('BulkEditorComponent', () => {
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
fixture.detectChanges()
let downloadSpy = jest.spyOn(documentService, 'bulkDownload')
downloadSpy.mockReturnValue(of(new Blob()))
//archive
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'archive', false)
expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'archive',
false
)
//originals
component.downloadForm.get('downloadFileTypeArchive').patchValue(false)
component.downloadForm.get('downloadFileTypeOriginals').patchValue(true)
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'originals', false)
expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'originals',
false
)
//both
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', false)
expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'both',
false
)
//formatting
component.downloadForm.get('downloadUseFormatting').patchValue(true)
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', true)
expect(downloadSpy).toHaveBeenCalledWith(
{ documents: [3, 4] },
'both',
true
)
httpTestingController.match(
`${environment.apiBaseUrl}documents/bulk_download/`
@@ -1153,7 +1232,7 @@ describe('BulkEditorComponent', () => {
},
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
@@ -1450,7 +1529,7 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.documents).toEqual([3, 4])
expect(modal.componentInstance.documents).toEqual({ documents: [3, 4] })
modal.componentInstance.failed.emit()
expect(toastServiceShowErrorSpy).toHaveBeenCalled()
@@ -1460,7 +1539,7 @@ describe('BulkEditorComponent', () => {
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
expect(listReloadSpy).toHaveBeenCalled()
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`

View File

@@ -16,6 +16,7 @@ import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model'
import { SelectionDataItem } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@@ -30,9 +31,9 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import {
DocumentBulkEditMethod,
DocumentSelectionQuery,
DocumentService,
MergeDocumentsRequest,
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
@@ -41,6 +42,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -261,17 +263,13 @@ export class BulkEditorComponent
modal: NgbModalRef,
method: DocumentBulkEditMethod,
args: any,
overrideDocumentIDs?: number[]
overrideSelection?: DocumentSelectionQuery
) {
if (modal) {
modal.componentInstance.buttonsEnabled = false
}
this.documentService
.bulkEdit(
overrideDocumentIDs ?? Array.from(this.list.selected),
method,
args
)
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
.pipe(first())
.subscribe({
next: () => this.handleOperationSuccess(modal),
@@ -329,7 +327,7 @@ export class BulkEditorComponent
) {
let selectionData = new Map<number, ToggleableItemState>()
items.forEach((i) => {
if (i.document_count == this.list.selected.size) {
if (i.document_count == this.list.selectedCount) {
selectionData.set(i.id, ToggleableItemState.Selected)
} else if (i.document_count > 0) {
selectionData.set(i.id, ToggleableItemState.PartiallySelected)
@@ -338,7 +336,31 @@ export class BulkEditorComponent
selectionModel.init(selectionData)
}
private getSelectionQuery(): DocumentSelectionQuery {
if (this.list.allSelected) {
return {
all: true,
filters: queryParamsFromFilterRules(this.list.filterRules),
}
}
return {
documents: Array.from(this.list.selected),
}
}
private getSelectionSize(): number {
return this.list.selectedCount
}
openTagsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.tagDocumentCounts = selectionData?.selected_tags ?? []
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -349,6 +371,17 @@ export class BulkEditorComponent
}
openDocumentTypeDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.documentTypeDocumentCounts =
selectionData?.selected_document_types ?? []
this.applySelectionData(
this.documentTypeDocumentCounts,
this.documentTypeSelectionModel
)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -362,6 +395,17 @@ export class BulkEditorComponent
}
openCorrespondentDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.correspondentDocumentCounts =
selectionData?.selected_correspondents ?? []
this.applySelectionData(
this.correspondentDocumentCounts,
this.correspondentSelectionModel
)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -375,6 +419,17 @@ export class BulkEditorComponent
}
openStoragePathDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? []
this.applySelectionData(
this.storagePathDocumentCounts,
this.storagePathsSelectionModel
)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -388,6 +443,17 @@ export class BulkEditorComponent
}
openCustomFieldsDropdown() {
if (this.list.allSelected) {
const selectionData = this.list.selectionData
this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? []
this.applySelectionData(
this.customFieldDocumentCounts,
this.customFieldsSelectionModel
)
return
}
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
@@ -437,33 +503,33 @@ export class BulkEditorComponent
changedTags.itemsToRemove.length == 0
) {
let tag = changedTags.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will add the tag "${tag.name}" to ${this.getSelectionSize()} selected document(s).`
} else if (
changedTags.itemsToAdd.length > 1 &&
changedTags.itemsToRemove.length == 0
) {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd
)} to ${this.list.selected.size} selected document(s).`
)} to ${this.getSelectionSize()} selected document(s).`
} else if (
changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length == 1
) {
let tag = changedTags.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the tag "${tag.name}" from ${this.getSelectionSize()} selected document(s).`
} else if (
changedTags.itemsToAdd.length == 0 &&
changedTags.itemsToRemove.length > 1
) {
modal.componentInstance.message = $localize`This operation will remove the tags ${this._localizeList(
changedTags.itemsToRemove
)} from ${this.list.selected.size} selected document(s).`
)} from ${this.getSelectionSize()} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will add the tags ${this._localizeList(
changedTags.itemsToAdd
)} and remove the tags ${this._localizeList(
changedTags.itemsToRemove
)} on ${this.list.selected.size} selected document(s).`
)} on ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
@@ -502,9 +568,9 @@ export class BulkEditorComponent
})
modal.componentInstance.title = $localize`Confirm correspondent assignment`
if (correspondent) {
modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will assign the correspondent "${correspondent.name}" to ${this.getSelectionSize()} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the correspondent from ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
@@ -540,9 +606,9 @@ export class BulkEditorComponent
})
modal.componentInstance.title = $localize`Confirm document type assignment`
if (documentType) {
modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will assign the document type "${documentType.name}" to ${this.getSelectionSize()} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will remove the document type from ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the document type from ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
@@ -578,9 +644,9 @@ export class BulkEditorComponent
})
modal.componentInstance.title = $localize`Confirm storage path assignment`
if (storagePath) {
modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.getSelectionSize()} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
@@ -615,33 +681,33 @@ export class BulkEditorComponent
changedCustomFields.itemsToRemove.length == 0
) {
let customField = changedCustomFields.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.getSelectionSize()} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length > 1 &&
changedCustomFields.itemsToRemove.length == 0
) {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} to ${this.list.selected.size} selected document(s).`
)} to ${this.getSelectionSize()} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 1
) {
let customField = changedCustomFields.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.getSelectionSize()} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length > 1
) {
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} from ${this.list.selected.size} selected document(s).`
)} from ${this.getSelectionSize()} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} and remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} on ${this.list.selected.size} selected document(s).`
)} on ${this.getSelectionSize()} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
@@ -779,7 +845,7 @@ export class BulkEditorComponent
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm`
modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?`
modal.componentInstance.messageBold = $localize`Move ${this.getSelectionSize()} selected document(s) to the trash?`
modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Move to trash`
@@ -789,13 +855,13 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.deleteDocuments(Array.from(this.list.selected))
this.documentService.deleteDocuments(this.getSelectionQuery())
)
})
} else {
this.executeDocumentAction(
null,
this.documentService.deleteDocuments(Array.from(this.list.selected))
this.documentService.deleteDocuments(this.getSelectionQuery())
)
}
}
@@ -811,7 +877,7 @@ export class BulkEditorComponent
: 'originals'
this.documentService
.bulkDownload(
Array.from(this.list.selected),
this.getSelectionQuery(),
downloadFileType,
this.downloadForm.get('downloadUseFormatting').value
)
@@ -827,7 +893,7 @@ export class BulkEditorComponent
backdrop: 'static',
})
modal.componentInstance.title = $localize`Reprocess confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.list.selected.size} selected document(s).`
modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.getSelectionSize()} selected document(s).`
modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
@@ -837,9 +903,7 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.reprocessDocuments(
Array.from(this.list.selected)
)
this.documentService.reprocessDocuments(this.getSelectionQuery())
)
})
}
@@ -866,7 +930,7 @@ export class BulkEditorComponent
})
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm`
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).`
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.getSelectionSize()} document(s).`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]
@@ -877,7 +941,7 @@ export class BulkEditorComponent
this.executeDocumentAction(
modal,
this.documentService.rotateDocuments(
Array.from(this.list.selected),
this.getSelectionQuery(),
rotateDialog.degrees
)
)
@@ -890,7 +954,7 @@ export class BulkEditorComponent
})
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
mergeDialog.title = $localize`Merge confirm`
mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.`
mergeDialog.messageBold = $localize`This operation will merge ${this.getSelectionSize()} selected documents into a new document.`
mergeDialog.btnCaption = $localize`Proceed`
mergeDialog.documentIDs = Array.from(this.list.selected)
mergeDialog.confirmClicked
@@ -935,7 +999,7 @@ export class BulkEditorComponent
(item) => item.id
)
dialog.documents = Array.from(this.list.selected)
dialog.documents = this.getSelectionQuery()
dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload()

View File

@@ -17,7 +17,10 @@ import { SelectComponent } from 'src/app/components/common/input/select/select.c
import { TextComponent } from 'src/app/components/common/input/text/text.component'
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentService } from 'src/app/services/rest/document.service'
import {
DocumentSelectionQuery,
DocumentService,
} from 'src/app/services/rest/document.service'
import { TextAreaComponent } from '../../../common/input/textarea/textarea.component'
@Component({
@@ -76,7 +79,7 @@ export class CustomFieldsBulkEditDialogComponent {
public form: FormGroup = new FormGroup({})
public documents: number[] = []
public documents: DocumentSelectionQuery = { documents: [] }
initForm() {
Object.keys(this.form.controls).forEach((key) => {

View File

@@ -2,8 +2,8 @@
<div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (list.selected.size > 0) {
<pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
@if (list.hasSelection) {
<pngx-clearable-badge [selected]="list.hasSelection" [number]="list.selectedCount" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
@@ -17,7 +17,7 @@
<span class="input-group-text border-0" i18n>Select:</span>
</div>
<div class="btn-group btn-group-sm flex-nowrap">
@if (list.selected.size > 0) {
@if (list.hasSelection) {
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
</button>
@@ -127,11 +127,11 @@
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
}
@if (list.selected.size > 0) {
<span i18n>{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span>
@if (list.hasSelection) {
<span i18n>{list.collectionSize, plural, =1 {Selected {{list.selectedCount}} of one document} other {Selected {{list.selectedCount}} of {{list.collectionSize || 0}} documents}}</span>
}
@if (!list.isReloading) {
@if (list.selected.size === 0) {
@if (!list.hasSelection) {
<span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>
}&nbsp;@if (isFiltered) {
&nbsp;<span i18n>(filtered)</span>
@@ -142,7 +142,7 @@
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
</button>
}
@if (!list.isReloading && list.selected.size > 0) {
@if (!list.isReloading && list.hasSelection) {
<button class="btn btn-link py-0" (click)="list.selectNone()">
<i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
</button>

View File

@@ -240,7 +240,7 @@ export class DocumentListComponent
}
get isBulkEditing(): boolean {
return this.list.selected.size > 0
return this.list.hasSelection
}
toggleDisplayField(field: DisplayField) {
@@ -327,7 +327,7 @@ export class DocumentListComponent
})
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
if (this.list.selected.size > 0) {
if (this.list.hasSelection) {
this.list.selectNone()
} else if (this.isFiltered) {
this.resetFilters()
@@ -356,7 +356,7 @@ export class DocumentListComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
if (this.list.documents.length > 0) {
if (this.list.selected.size > 0) {
if (this.list.hasSelection) {
this.openDocumentDetail(Array.from(this.list.selected)[0])
} else {
this.openDocumentDetail(this.list.documents[0])

View File

@@ -76,6 +76,7 @@ import {
FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type'
import { SelectionData, SelectionDataItem } from 'src/app/data/results'
import {
PermissionAction,
PermissionType,
@@ -84,11 +85,7 @@ import {
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import {
DocumentService,
SelectionData,
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SearchService } from 'src/app/services/rest/search.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'

View File

@@ -9,8 +9,8 @@
<div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (activeManagementList.selectedObjects.size > 0) {
<pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
@if (activeManagementList.hasSelection) {
<pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
@@ -25,7 +25,7 @@
<span class="input-group-text border-0" i18n>Select:</span>
</div>
<div class="btn-group btn-group-sm flex-nowrap">
@if (activeManagementList.selectedObjects.size > 0) {
@if (activeManagementList.hasSelection) {
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
</button>
@@ -40,11 +40,11 @@
</div>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0">
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || !activeManagementList.hasSelection">
<i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0">
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || !activeManagementList.hasSelection">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"

View File

@@ -65,8 +65,8 @@
@if (displayCollectionSize > 0) {
<div>
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
@if (selectedObjects.size > 0) {
&nbsp;({{selectedObjects.size}} selected)
@if (hasSelection) {
&nbsp;({{selectedCount}} selected)
}
</div>
}

View File

@@ -117,7 +117,6 @@ describe('ManagementListComponent', () => {
: tags
return of({
count: results.length,
all: results.map((o) => o.id),
results,
})
}
@@ -231,11 +230,11 @@ describe('ManagementListComponent', () => {
expect(reloadSpy).toHaveBeenCalled()
})
it('should use API count for pagination and all ids for displayed total', fakeAsync(() => {
it('should use API count for pagination and nested ids for displayed total', fakeAsync(() => {
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
of({
count: 1,
all: [1, 2, 3],
display_count: 3,
results: tags.slice(0, 1),
})
)
@@ -315,13 +314,17 @@ describe('ManagementListComponent', () => {
expect(component.togggleAll).toBe(false)
})
it('selectAll should use all IDs when collection size exists', () => {
;(component as any).allIDs = [1, 2, 3, 4]
component.collectionSize = 4
it('selectAll should activate all-selection mode', () => {
;(tagService.listFiltered as jest.Mock).mockClear()
component.collectionSize = tags.length
component.selectAll()
expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4]))
expect(tagService.listFiltered).not.toHaveBeenCalled()
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
expect((component as any).allSelectionActive).toBe(true)
expect(component.hasSelection).toBe(true)
expect(component.selectedCount).toBe(tags.length)
expect(component.togggleAll).toBe(true)
})
@@ -395,6 +398,33 @@ describe('ManagementListComponent', () => {
expect(successToastSpy).toHaveBeenCalled()
})
it('should support bulk edit permissions for all filtered items', () => {
const bulkEditPermsSpy = jest
.spyOn(tagService, 'bulk_edit_objects')
.mockReturnValue(of('OK'))
component.selectAll()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
fixture.detectChanges()
component.setPermissions()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
expect(bulkEditPermsSpy).toHaveBeenCalledWith(
[],
BulkEditObjectOperation.SetPermissions,
{},
true,
true,
{ is_root: true }
)
})
it('should support bulk delete objects', () => {
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
component.toggleSelected(tags[0])
@@ -415,7 +445,11 @@ describe('ManagementListComponent', () => {
modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalledWith(
Array.from(selected),
BulkEditObjectOperation.Delete
BulkEditObjectOperation.Delete,
null,
null,
false,
null
)
expect(errorToastSpy).toHaveBeenCalled()
@@ -426,6 +460,29 @@ describe('ManagementListComponent', () => {
expect(successToastSpy).toHaveBeenCalled()
})
it('should support bulk delete for all filtered items', () => {
const bulkEditSpy = jest
.spyOn(tagService, 'bulk_edit_objects')
.mockReturnValue(of('OK'))
component.selectAll()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
fixture.detectChanges()
component.delete()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit(null)
expect(bulkEditSpy).toHaveBeenCalledWith(
[],
BulkEditObjectOperation.Delete,
null,
null,
true,
{ is_root: true }
)
})
it('should disallow bulk permissions or delete objects if no global perms', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()

View File

@@ -90,7 +90,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public data: T[] = []
private unfilteredData: T[] = []
private allIDs: number[] = []
private currentExtraParams: { [key: string]: any } = null
private allSelectionActive = false
public page = 1
@@ -107,6 +108,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
public selectedObjects: Set<number> = new Set()
public togggleAll: boolean = false
public get hasSelection(): boolean {
return this.selectedObjects.size > 0 || this.allSelectionActive
}
public get selectedCount(): number {
return this.allSelectionActive
? this.displayCollectionSize
: this.selectedObjects.size
}
ngOnInit(): void {
this.reloadData()
@@ -150,11 +161,11 @@ export abstract class ManagementListComponent<T extends MatchingModel>
}
protected getCollectionSize(results: Results<T>): number {
return results.all?.length ?? results.count
return results.count
}
protected getDisplayCollectionSize(results: Results<T>): number {
return this.getCollectionSize(results)
return results.display_count ?? this.getCollectionSize(results)
}
getDocumentCount(object: MatchingModel): number {
@@ -171,6 +182,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
reloadData(extraParams: { [key: string]: any } = null) {
this.loading = true
this.currentExtraParams = extraParams
this.clearSelection()
this.service
.listFiltered(
@@ -189,7 +201,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.data = this.filterData(c.results)
this.collectionSize = this.getCollectionSize(c)
this.displayCollectionSize = this.getDisplayCollectionSize(c)
this.allIDs = c.all
}),
delay(100)
)
@@ -346,7 +357,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
return objects.map((o) => o.id)
}
private getBulkEditFilters(): { [key: string]: any } {
const filters = { ...this.currentExtraParams }
if (this._nameFilter?.length) {
filters['name__icontains'] = this._nameFilter
}
return filters
}
clearSelection() {
this.allSelectionActive = false
this.togggleAll = false
this.selectedObjects.clear()
}
@@ -356,6 +376,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
}
selectPage() {
this.allSelectionActive = false
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected()
}
@@ -365,11 +386,16 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.clearSelection()
return
}
this.selectedObjects = new Set(this.allIDs)
this.allSelectionActive = true
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected()
}
toggleSelected(object) {
if (this.allSelectionActive) {
this.allSelectionActive = false
}
this.selectedObjects.has(object.id)
? this.selectedObjects.delete(object.id)
: this.selectedObjects.add(object.id)
@@ -377,6 +403,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
}
protected areAllPageItemsSelected(): boolean {
if (this.allSelectionActive) {
return this.data.length > 0
}
const ids = this.getSelectableIDs(this.data)
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
}
@@ -390,10 +419,12 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.buttonsEnabled = false
this.service
.bulk_edit_objects(
Array.from(this.selectedObjects),
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
BulkEditObjectOperation.SetPermissions,
permissions,
merge
merge,
this.allSelectionActive,
this.allSelectionActive ? this.getBulkEditFilters() : null
)
.subscribe({
next: () => {
@@ -428,8 +459,12 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.buttonsEnabled = false
this.service
.bulk_edit_objects(
Array.from(this.selectedObjects),
BulkEditObjectOperation.Delete
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
BulkEditObjectOperation.Delete,
null,
null,
this.allSelectionActive,
this.allSelectionActive ? this.getBulkEditFilters() : null
)
.subscribe({
next: () => {

View File

@@ -41,7 +41,6 @@ describe('TagListComponent', () => {
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
of({
count: 3,
all: [1, 2, 3],
results: [
{
id: 1,

View File

@@ -9,7 +9,6 @@ import {
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { Results } from 'src/app/data/results'
import { Tag } from 'src/app/data/tag'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive'
@@ -77,16 +76,6 @@ export class TagListComponent extends ManagementListComponent<Tag> {
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
}
protected override getCollectionSize(results: Results<Tag>): number {
// Tag list pages are requested with is_root=true (when unfiltered), so
// pagination must follow root count even though `all` includes descendants
return results.count
}
protected override getDisplayCollectionSize(results: Results<Tag>): number {
return super.getCollectionSize(results)
}
protected override getSelectableIDs(tags: Tag[]): number[] {
const ids: number[] = []
for (const tag of tags.filter(Boolean)) {

View File

@@ -1,7 +1,26 @@
import { Document } from './document'
export interface Results<T> {
count: number
results: T[]
display_count?: number
all: number[]
results: T[]
}
export interface SelectionDataItem {
id: number
document_count: number
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
export interface DocumentResults extends Results<Document> {
selection_data?: SelectionData
}

View File

@@ -12,7 +12,6 @@ export enum WorkflowTriggerType {
DocumentAdded = 2,
DocumentUpdated = 3,
Scheduled = 4,
VersionAdded = 5,
}
export enum ScheduleDateField {

View File

@@ -126,13 +126,10 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
expect(req.request.method).toEqual('GET')
expect(documentListViewService.isReloading).toBeFalsy()
expect(documentListViewService.activeSavedViewId).toBeNull()
@@ -144,12 +141,12 @@ describe('DocumentListViewService', () => {
it('should handle error on page request out of range', () => {
documentListViewService.currentPage = 50
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush([], { status: 404, statusText: 'Unexpected error' })
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
expect(documentListViewService.currentPage).toEqual(1)
@@ -166,7 +163,7 @@ describe('DocumentListViewService', () => {
]
documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
)
expect(req.request.method).toEqual('GET')
req.flush(
@@ -174,13 +171,13 @@ describe('DocumentListViewService', () => {
{ status: 404, statusText: 'Unexpected error' }
)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
// reset the list
documentListViewService.setFilterRules([])
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
})
@@ -188,7 +185,7 @@ describe('DocumentListViewService', () => {
documentListViewService.currentPage = 1
documentListViewService.sortField = 'custom_field_999'
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush(
@@ -197,7 +194,7 @@ describe('DocumentListViewService', () => {
)
// resets itself
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
})
@@ -212,7 +209,7 @@ describe('DocumentListViewService', () => {
]
documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
)
expect(req.request.method).toEqual('GET')
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
@@ -220,7 +217,7 @@ describe('DocumentListViewService', () => {
// reset the list
documentListViewService.setFilterRules([])
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
})
@@ -229,7 +226,7 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.sortReverse).toBeTruthy()
documentListViewService.setSort('added', false)
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
expect(documentListViewService.sortField).toEqual('added')
@@ -237,12 +234,12 @@ describe('DocumentListViewService', () => {
documentListViewService.sortField = 'created'
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true&include_selection_data=true`
)
expect(documentListViewService.sortField).toEqual('created')
documentListViewService.sortReverse = true
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
expect(documentListViewService.sortReverse).toBeTruthy()
@@ -262,7 +259,7 @@ describe('DocumentListViewService', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
documentListViewService.pageSize
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true`
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
expect(documentListViewService.currentPage).toEqual(page)
@@ -279,7 +276,7 @@ describe('DocumentListViewService', () => {
}
documentListViewService.loadFromQueryParams(convertToParamMap(params))
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
)
expect(req.request.method).toEqual('GET')
expect(documentListViewService.filterRules).toEqual([
@@ -289,15 +286,12 @@ describe('DocumentListViewService', () => {
},
])
req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
})
it('should use filter rules to update query params', () => {
documentListViewService.setFilterRules(filterRules)
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
)
expect(req.request.method).toEqual('GET')
})
@@ -306,34 +300,26 @@ describe('DocumentListViewService', () => {
documentListViewService.currentPage = 2
let req = httpTestingController.expectOne((request) =>
request.urlWithParams.startsWith(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
documentListViewService.setFilterRules(filterRules, true)
const filteredReqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
)
expect(filteredReqs).toHaveLength(1)
filteredReqs[0].flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
expect(documentListViewService.currentPage).toEqual(1)
})
it('should support quick filter', () => {
documentListViewService.quickFilter(filterRules)
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
)
expect(req.request.method).toEqual('GET')
})
@@ -356,21 +342,21 @@ describe('DocumentListViewService', () => {
convertToParamMap(params)
)
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
)
expect(req.request.method).toEqual('GET')
// reset the list
documentListViewService.currentPage = 1
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=9`
)
documentListViewService.setFilterRules([])
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true`
)
documentListViewService.sortField = 'created'
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
documentListViewService.activateSavedView(null)
})
@@ -378,21 +364,18 @@ describe('DocumentListViewService', () => {
it('should support navigating next / previous', () => {
documentListViewService.setFilterRules([])
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.pageSize = 3
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush({
count: 3,
results: documents.slice(0, 3),
})
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/selection_data/`)
.flush([])
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
documentListViewService.getNext(documents[0].id).subscribe((docId) => {
@@ -439,7 +422,7 @@ describe('DocumentListViewService', () => {
expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.pageSize = 3
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
)
jest
.spyOn(documentListViewService, 'getLastPage')
@@ -454,7 +437,7 @@ describe('DocumentListViewService', () => {
expect(reloadSpy).toHaveBeenCalled()
expect(documentListViewService.currentPage).toEqual(2)
const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(reqs.length).toBeGreaterThan(0)
})
@@ -489,11 +472,11 @@ describe('DocumentListViewService', () => {
.mockReturnValue(documents)
documentListViewService.currentPage = 2
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
documentListViewService.pageSize = 3
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
)
const reloadSpy = jest.spyOn(documentListViewService, 'reload')
documentListViewService.getPrevious(1).subscribe({
@@ -503,7 +486,7 @@ describe('DocumentListViewService', () => {
expect(reloadSpy).toHaveBeenCalled()
expect(documentListViewService.currentPage).toEqual(1)
const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(reqs.length).toBeGreaterThan(0)
})
@@ -516,13 +499,10 @@ describe('DocumentListViewService', () => {
it('should support select a document', () => {
documentListViewService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.toggleSelected(documents[0])
@@ -530,12 +510,16 @@ describe('DocumentListViewService', () => {
})
it('should support select all', () => {
documentListViewService.selectAll()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
documentListViewService.reload()
const reloadReq = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
expect(reloadReq.request.method).toEqual('GET')
reloadReq.flush(full_results)
documentListViewService.selectAll()
expect(documentListViewService.allSelected).toBeTruthy()
expect(documentListViewService.selectedCount).toEqual(documents.length)
expect(documentListViewService.selected.size).toEqual(documents.length)
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.selectNone()
@@ -544,16 +528,13 @@ describe('DocumentListViewService', () => {
it('should support select page', () => {
documentListViewService.pageSize = 3
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush({
count: 3,
results: documents.slice(0, 3),
})
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.selectPage()
expect(documentListViewService.selected.size).toEqual(3)
expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
@@ -562,13 +543,10 @@ describe('DocumentListViewService', () => {
it('should support select range', () => {
documentListViewService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
documentListViewService.toggleSelected(documents[0])
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
documentListViewService.selectRangeTo(documents[2])
@@ -578,25 +556,25 @@ describe('DocumentListViewService', () => {
})
it('should support selection range reduction', () => {
documentListViewService.selectAll()
documentListViewService.reload()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
documentListViewService.selectAll()
expect(documentListViewService.selected.size).toEqual(6)
documentListViewService.setFilterRules(filterRules)
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
)
const reqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
)
reqs[0].flush({
req.flush({
count: 3,
results: documents.slice(0, 3),
})
expect(documentListViewService.allSelected).toBeTruthy()
expect(documentListViewService.selected.size).toEqual(3)
})
@@ -604,7 +582,7 @@ describe('DocumentListViewService', () => {
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
documentListViewService.reload()
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
)
expect(cancelSpy).toHaveBeenCalled()
})
@@ -623,7 +601,7 @@ describe('DocumentListViewService', () => {
documentListViewService.setFilterRules([])
expect(documentListViewService.sortField).toEqual('created')
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
})
@@ -650,11 +628,11 @@ describe('DocumentListViewService', () => {
expect(localStorageSpy).toHaveBeenCalled()
// reload triggered
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
documentListViewService.displayFields = null
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
expect(documentListViewService.displayFields).toEqual(
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
@@ -694,7 +672,7 @@ describe('DocumentListViewService', () => {
it('should generate quick filter URL preserving default state', () => {
documentListViewService.reload()
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
expect(urlTree).toBeDefined()

View File

@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core'
import { ParamMap, Router, UrlTree } from '@angular/router'
import { Observable, Subject, first, takeUntil } from 'rxjs'
import { Observable, Subject, takeUntil } from 'rxjs'
import {
DEFAULT_DISPLAY_FIELDS,
DisplayField,
@@ -8,6 +8,7 @@ import {
Document,
} from '../data/document'
import { FilterRule } from '../data/filter-rule'
import { DocumentResults, SelectionData } from '../data/results'
import { SavedView } from '../data/saved-view'
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
import { SETTINGS_KEYS } from '../data/ui-settings'
@@ -17,7 +18,7 @@ import {
isFullTextFilterRule,
} from '../utils/filter-rules'
import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
import { DocumentService, SelectionData } from './rest/document.service'
import { DocumentService } from './rest/document.service'
import { SettingsService } from './settings.service'
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
@@ -65,6 +66,11 @@ export interface ListViewState {
*/
selected?: Set<number>
/**
* True if the full filtered result set is selected.
*/
allSelected?: boolean
/**
* The page size of the list view.
*/
@@ -165,6 +171,20 @@ export class DocumentListViewService {
sortReverse: true,
filterRules: [],
selected: new Set<number>(),
allSelected: false,
}
}
private syncSelectedToCurrentPage() {
if (!this.allSelected) {
return
}
this.selected.clear()
this.documents?.forEach((doc) => this.selected.add(doc.id))
if (!this.collectionSize) {
this.selectNone()
}
}
@@ -260,27 +280,18 @@ export class DocumentListViewService {
activeListViewState.sortField,
activeListViewState.sortReverse,
activeListViewState.filterRules,
{ truncate_content: true }
{ truncate_content: true, include_selection_data: true }
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (result) => {
const resultWithSelectionData = result as DocumentResults
this.initialized = true
this.isReloading = false
activeListViewState.collectionSize = result.count
activeListViewState.documents = result.results
this.documentService
.getSelectionData(result.all)
.pipe(first())
.subscribe({
next: (selectionData) => {
this.selectionData = selectionData
},
error: () => {
this.selectionData = null
},
})
this.selectionData = resultWithSelectionData.selection_data ?? null
this.syncSelectedToCurrentPage()
if (updateQueryParams && !this._activeSavedViewId) {
let base = ['/documents']
@@ -413,6 +424,20 @@ export class DocumentListViewService {
return this.activeListViewState.selected
}
get allSelected(): boolean {
return this.activeListViewState.allSelected ?? false
}
get selectedCount(): number {
return this.allSelected
? (this.collectionSize ?? this.selected.size)
: this.selected.size
}
get hasSelection(): boolean {
return this.allSelected || this.selected.size > 0
}
setSort(field: string, reverse: boolean) {
this.activeListViewState.sortField = field
this.activeListViewState.sortReverse = reverse
@@ -567,11 +592,16 @@ export class DocumentListViewService {
}
selectNone() {
this.activeListViewState.allSelected = false
this.selected.clear()
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
}
reduceSelectionToFilter() {
if (this.allSelected) {
return
}
if (this.selected.size > 0) {
this.documentService
.listAllFilteredIds(this.filterRules)
@@ -586,12 +616,12 @@ export class DocumentListViewService {
}
selectAll() {
this.documentService
.listAllFilteredIds(this.filterRules)
.subscribe((ids) => ids.forEach((id) => this.selected.add(id)))
this.activeListViewState.allSelected = true
this.syncSelectedToCurrentPage()
}
selectPage() {
this.activeListViewState.allSelected = false
this.selected.clear()
this.documents.forEach((doc) => {
this.selected.add(doc.id)
@@ -599,10 +629,13 @@ export class DocumentListViewService {
}
isSelected(d: Document) {
return this.selected.has(d.id)
return this.allSelected || this.selected.has(d.id)
}
toggleSelected(d: Document): void {
if (this.allSelected) {
this.activeListViewState.allSelected = false
}
if (this.selected.has(d.id)) this.selected.delete(d.id)
else this.selected.add(d.id)
this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id)
@@ -610,6 +643,10 @@ export class DocumentListViewService {
}
selectRangeTo(d: Document) {
if (this.allSelected) {
this.activeListViewState.allSelected = false
}
if (this.rangeSelectionAnchorIndex !== null) {
const documentToIndex = this.documentIndexInCurrentView(d.id)
const fromIndex = Math.min(

View File

@@ -96,6 +96,30 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
})
req.flush([])
})
test('should call appropriate api endpoint for bulk delete on all filtered objects', () => {
subscription = service
.bulk_edit_objects(
[],
BulkEditObjectOperation.Delete,
null,
null,
true,
{ name__icontains: 'hello' }
)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}bulk_edit_objects/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
object_type: endpoint,
operation: BulkEditObjectOperation.Delete,
all: true,
filters: { name__icontains: 'hello' },
})
req.flush([])
})
})
beforeEach(() => {

View File

@@ -37,13 +37,22 @@ export abstract class AbstractNameFilterService<
objects: Array<number>,
operation: BulkEditObjectOperation,
permissions: { owner: number; set_permissions: PermissionsObject } = null,
merge: boolean = null
merge: boolean = null,
all: boolean = false,
filters: { [key: string]: any } = null
): Observable<string> {
const params = {
objects,
const params: any = {
object_type: this.resourceName,
operation,
}
if (all) {
params['all'] = true
if (filters) {
params['filters'] = filters
}
} else {
params['objects'] = objects
}
if (operation === BulkEditObjectOperation.SetPermissions) {
params['owner'] = permissions?.owner
params['permissions'] = permissions?.set_permissions

View File

@@ -198,7 +198,7 @@ describe(`DocumentService`, () => {
const content = 'both'
const useFilenameFormatting = false
subscription = service
.bulkDownload(ids, content, useFilenameFormatting)
.bulkDownload({ documents: ids }, content, useFilenameFormatting)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_download/`
@@ -218,7 +218,9 @@ describe(`DocumentService`, () => {
add_tags: [15],
remove_tags: [6],
}
subscription = service.bulkEdit(ids, method, parameters).subscribe()
subscription = service
.bulkEdit({ documents: ids }, method, parameters)
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
)
@@ -230,9 +232,32 @@ describe(`DocumentService`, () => {
})
})
it('should call appropriate api endpoint for bulk edit with all and filters', () => {
const method = 'modify_tags'
const parameters = {
add_tags: [15],
remove_tags: [6],
}
const selection = {
all: true,
filters: { title__icontains: 'apple' },
}
subscription = service.bulkEdit(selection, method, parameters).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_edit/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
all: true,
filters: { title__icontains: 'apple' },
method,
parameters,
})
})
it('should call appropriate api endpoint for delete documents', () => {
const ids = [1, 2, 3]
subscription = service.deleteDocuments(ids).subscribe()
subscription = service.deleteDocuments({ documents: ids }).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/delete/`
)
@@ -244,7 +269,7 @@ describe(`DocumentService`, () => {
it('should call appropriate api endpoint for reprocess documents', () => {
const ids = [1, 2, 3]
subscription = service.reprocessDocuments(ids).subscribe()
subscription = service.reprocessDocuments({ documents: ids }).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/reprocess/`
)
@@ -256,7 +281,7 @@ describe(`DocumentService`, () => {
it('should call appropriate api endpoint for rotate documents', () => {
const ids = [1, 2, 3]
subscription = service.rotateDocuments(ids, 90).subscribe()
subscription = service.rotateDocuments({ documents: ids }, 90).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/rotate/`
)

View File

@@ -12,7 +12,7 @@ import {
import { DocumentMetadata } from 'src/app/data/document-metadata'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { FilterRule } from 'src/app/data/filter-rule'
import { Results } from 'src/app/data/results'
import { Results, SelectionData } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { queryParamsFromFilterRules } from '../../utils/query-params'
import {
@@ -24,19 +24,6 @@ import { SettingsService } from '../settings.service'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { CustomFieldsService } from './custom-fields.service'
export interface SelectionDataItem {
id: number
document_count: number
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
export enum BulkEditSourceMode {
LATEST_VERSION = 'latest_version',
EXPLICIT_SELECTION = 'explicit_selection',
@@ -81,6 +68,12 @@ export interface RemovePasswordDocumentsRequest {
source_mode?: BulkEditSourceMode
}
export interface DocumentSelectionQuery {
documents?: number[]
all?: boolean
filters?: { [key: string]: any }
}
@Injectable({
providedIn: 'root',
})
@@ -338,33 +331,37 @@ export class DocumentService extends AbstractPaperlessService<Document> {
return this.http.get<DocumentMetadata>(url.toString())
}
bulkEdit(ids: number[], method: DocumentBulkEditMethod, args: any) {
bulkEdit(
selection: DocumentSelectionQuery,
method: DocumentBulkEditMethod,
args: any
) {
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
documents: ids,
...selection,
method: method,
parameters: args,
})
}
deleteDocuments(ids: number[]) {
deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'delete'), {
documents: ids,
...selection,
})
}
reprocessDocuments(ids: number[]) {
reprocessDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
documents: ids,
...selection,
})
}
rotateDocuments(
ids: number[],
selection: DocumentSelectionQuery,
degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) {
return this.http.post(this.getResourceUrl(null, 'rotate'), {
documents: ids,
...selection,
degrees,
source_mode: sourceMode,
})
@@ -412,14 +409,14 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}
bulkDownload(
ids: number[],
selection: DocumentSelectionQuery,
content = 'both',
useFilenameFormatting: boolean = false
) {
return this.http.post(
this.getResourceUrl(null, 'bulk_download'),
{
documents: ids,
...selection,
content: content,
follow_formatting: useFilenameFormatting,
},

View File

@@ -10,13 +10,11 @@ class DocumentsConfig(AppConfig):
def ready(self) -> None:
from documents.signals import document_consumption_finished
from documents.signals import document_updated
from documents.signals import document_version_added
from documents.signals.handlers import add_inbox_tags
from documents.signals.handlers import add_or_update_document_in_llm_index
from documents.signals.handlers import add_to_index
from documents.signals.handlers import run_workflows_added
from documents.signals.handlers import run_workflows_updated
from documents.signals.handlers import run_workflows_version_added
from documents.signals.handlers import send_websocket_document_updated
from documents.signals.handlers import set_correspondent
from documents.signals.handlers import set_document_type
@@ -30,7 +28,6 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(set_storage_path)
document_consumption_finished.connect(add_to_index)
document_consumption_finished.connect(run_workflows_added)
document_version_added.connect(run_workflows_version_added)
document_consumption_finished.connect(add_or_update_document_in_llm_index)
document_updated.connect(run_workflows_updated)
document_updated.connect(send_websocket_document_updated)

View File

@@ -46,7 +46,6 @@ from documents.plugins.helpers import ProgressStatusOptions
from documents.signals import document_consumption_finished
from documents.signals import document_consumption_started
from documents.signals import document_updated
from documents.signals import document_version_added
from documents.signals.handlers import run_workflows
from documents.templating.workflows import parse_w_workflow_placeholders
from documents.utils import copy_basic_file_stats
@@ -626,12 +625,6 @@ class ConsumerPlugin(
if self.unmodified_original
else self.working_copy,
)
if document.root_document_id:
document_version_added.send(
sender=self.__class__,
document=document,
logging_group=self.logging_group,
)
# After everything is in the database, copy the files into
# place. If this fails, we'll also rollback the transaction.

View File

@@ -375,6 +375,26 @@ class DelayedQuery:
]
return self._manual_hits_cache
def get_result_ids(self) -> list[int]:
"""
Return all matching document IDs for the current query and ordering.
"""
if self._manual_sort_requested():
return [hit["id"] for hit in self._manual_hits()]
q, mask, suggested_correction = self._get_query()
self.suggested_correction = suggested_correction
sortedby, reverse = self._get_query_sortedby()
results = self.searcher.search(
q,
mask=mask,
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
limit=None,
sortedby=sortedby,
reverse=reverse,
)
return [hit["id"] for hit in results]
def __getitem__(self, item):
if item.start in self.saved_results:
return self.saved_results[item.start]

View File

@@ -689,7 +689,6 @@ def document_matches_workflow(
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
or trigger_type == WorkflowTrigger.WorkflowTriggerType.SCHEDULED
or trigger_type == WorkflowTrigger.WorkflowTriggerType.VERSION_ADDED
):
trigger_matched, reason = existing_document_matches_workflow(
document,

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.2.7 on 2026-03-02 00:00
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0015_document_version_index_and_more"),
]
operations = [
migrations.AlterField(
model_name="workflowtrigger",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Consumption Started"),
(2, "Document Added"),
(3, "Document Updated"),
(4, "Scheduled"),
(5, "Version Added"),
],
default=1,
verbose_name="Workflow Trigger Type",
),
),
]

View File

@@ -1174,7 +1174,6 @@ class WorkflowTrigger(models.Model):
DOCUMENT_ADDED = 2, _("Document Added")
DOCUMENT_UPDATED = 3, _("Document Updated")
SCHEDULED = 4, _("Scheduled")
VERSION_ADDED = 5, _("Version Added")
class DocumentSourceChoices(models.IntegerChoices):
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")

View File

@@ -1540,6 +1540,41 @@ class DocumentListSerializer(serializers.Serializer):
return documents
class DocumentSelectionSerializer(DocumentListSerializer):
documents = serializers.ListField(
required=False,
label="Documents",
write_only=True,
child=serializers.IntegerField(),
)
all = serializers.BooleanField(
default=False,
required=False,
write_only=True,
)
filters = serializers.DictField(
required=False,
allow_empty=True,
write_only=True,
)
def validate(self, attrs):
if attrs.get("all", False):
attrs.setdefault("documents", [])
return attrs
if "documents" not in attrs:
raise serializers.ValidationError(
"documents is required unless all is true.",
)
documents = attrs["documents"]
self._validate_document_id_list(documents)
return attrs
class SourceModeValidationMixin:
def validate_source_mode(self, source_mode: str) -> str:
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
@@ -1547,7 +1582,7 @@ class SourceModeValidationMixin:
return source_mode
class RotateDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
class RotateDocumentsSerializer(DocumentSelectionSerializer, SourceModeValidationMixin):
degrees = serializers.IntegerField(required=True)
source_mode = serializers.CharField(
required=False,
@@ -1630,17 +1665,17 @@ class RemovePasswordDocumentsSerializer(
)
class DeleteDocumentsSerializer(DocumentListSerializer):
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
pass
class ReprocessDocumentsSerializer(DocumentListSerializer):
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
pass
class BulkEditSerializer(
SerializerWithPerms,
DocumentListSerializer,
DocumentSelectionSerializer,
SetPermissionsMixin,
SourceModeValidationMixin,
):
@@ -1955,6 +1990,19 @@ class BulkEditSerializer(
raise serializers.ValidationError("password must be a string")
def validate(self, attrs):
attrs = super().validate(attrs)
if attrs.get("all", False) and attrs["method"] in [
bulk_edit.merge,
bulk_edit.split,
bulk_edit.delete_pages,
bulk_edit.edit_pdf,
bulk_edit.remove_password,
]:
raise serializers.ValidationError(
"This method does not support all=true.",
)
method = attrs["method"]
parameters = attrs["parameters"]
@@ -2212,7 +2260,7 @@ class DocumentVersionLabelSerializer(serializers.Serializer):
return normalized or None
class BulkDownloadSerializer(DocumentListSerializer):
class BulkDownloadSerializer(DocumentSelectionSerializer):
content = serializers.ChoiceField(
choices=["archive", "originals", "both"],
default="archive",
@@ -2571,13 +2619,25 @@ class ShareLinkBundleSerializer(OwnedObjectSerializer):
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
objects = serializers.ListField(
required=True,
allow_empty=False,
required=False,
allow_empty=True,
label="Objects",
write_only=True,
child=serializers.IntegerField(),
)
all = serializers.BooleanField(
default=False,
required=False,
write_only=True,
)
filters = serializers.DictField(
required=False,
allow_empty=True,
write_only=True,
)
object_type = serializers.ChoiceField(
choices=[
"tags",
@@ -2650,10 +2710,20 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
def validate(self, attrs):
object_type = attrs["object_type"]
objects = attrs["objects"]
objects = attrs.get("objects")
apply_to_all = attrs.get("all", False)
operation = attrs.get("operation")
self._validate_objects(objects, object_type)
if apply_to_all:
attrs.setdefault("objects", [])
else:
if objects is None:
raise serializers.ValidationError(
"objects is required unless all is true.",
)
if len(objects) == 0:
raise serializers.ValidationError("objects must not be empty")
self._validate_objects(objects, object_type)
if operation == "set_permissions":
permissions = attrs.get("permissions")

View File

@@ -2,6 +2,5 @@ from django.dispatch import Signal
document_consumption_started = Signal()
document_consumption_finished = Signal()
document_version_added = Signal()
document_consumer_declaration = Signal()
document_updated = Signal()

View File

@@ -783,19 +783,6 @@ def run_workflows_added(
)
def run_workflows_version_added(
sender,
document: Document,
logging_group: uuid.UUID | None = None,
**kwargs,
) -> None:
run_workflows(
trigger_type=WorkflowTrigger.WorkflowTriggerType.VERSION_ADDED,
document=document.root_document,
logging_group=logging_group,
)
def run_workflows_updated(
sender,
document: Document,

View File

@@ -1119,21 +1119,19 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
[u1_doc1.id],
)
def test_pagination_all(self) -> None:
def test_pagination_results(self) -> None:
"""
GIVEN:
- A set of 50 documents
WHEN:
- API request for document filtering
THEN:
- Results are paginated (25 items) and response["all"] returns all ids (50 items)
- Results are paginated (25 items) and count reflects all results (50 items)
"""
t = Tag.objects.create(name="tag")
docs = []
for i in range(50):
d = Document.objects.create(checksum=i, content=f"test{i}")
d.tags.add(t)
docs.append(d)
response = self.client.get(
f"/api/documents/?tags__id__in={t.id}",
@@ -1141,9 +1139,84 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data["results"]
self.assertEqual(len(results), 25)
self.assertEqual(len(response.data["all"]), 50)
self.assertEqual(response.data["count"], 50)
self.assertNotIn("all", response.data)
def test_pagination_all_for_api_version_9(self) -> None:
"""
GIVEN:
- A set of documents matching a filter
WHEN:
- API request uses legacy version 9
THEN:
- Response includes "all" for backward compatibility
"""
t = Tag.objects.create(name="tag")
docs = []
for i in range(4):
d = Document.objects.create(checksum=i, content=f"test{i}")
d.tags.add(t)
docs.append(d)
response = self.client.get(
f"/api/documents/?tags__id__in={t.id}",
headers={"Accept": "application/json; version=9"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("all", response.data)
self.assertCountEqual(response.data["all"], [d.id for d in docs])
def test_list_with_include_selection_data(self) -> None:
correspondent = Correspondent.objects.create(name="c1")
doc_type = DocumentType.objects.create(name="dt1")
storage_path = StoragePath.objects.create(name="sp1")
tag = Tag.objects.create(name="tag")
matching_doc = Document.objects.create(
checksum="A",
correspondent=correspondent,
document_type=doc_type,
storage_path=storage_path,
)
matching_doc.tags.add(tag)
non_matching_doc = Document.objects.create(checksum="B")
non_matching_doc.tags.add(Tag.objects.create(name="other"))
response = self.client.get(
f"/api/documents/?tags__id__in={tag.id}&include_selection_data=true",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("selection_data", response.data)
selected_correspondent = next(
item
for item in response.data["selection_data"]["selected_correspondents"]
if item["id"] == correspondent.id
)
selected_tag = next(
item
for item in response.data["selection_data"]["selected_tags"]
if item["id"] == tag.id
)
selected_type = next(
item
for item in response.data["selection_data"]["selected_document_types"]
if item["id"] == doc_type.id
)
selected_storage_path = next(
item
for item in response.data["selection_data"]["selected_storage_paths"]
if item["id"] == storage_path.id
)
self.assertEqual(selected_correspondent["document_count"], 1)
self.assertEqual(selected_tag["document_count"], 1)
self.assertEqual(selected_type["document_count"], 1)
self.assertEqual(selected_storage_path["document_count"], 1)
def test_statistics(self) -> None:
doc1 = Document.objects.create(
title="none1",

View File

@@ -145,6 +145,22 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
response.data["last_correspondence"],
)
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
response_v10 = self.client.get("/api/correspondents/")
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
self.assertNotIn("all", response_v10.data)
response_v9 = self.client.get(
"/api/correspondents/",
headers={"Accept": "application/json; version=9"},
)
self.assertEqual(response_v9.status_code, status.HTTP_200_OK)
self.assertIn("all", response_v9.data)
self.assertCountEqual(
response_v9.data["all"],
[self.c1.id, self.c2.id, self.c3.id],
)
class TestApiStoragePaths(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/storage_paths/"
@@ -774,6 +790,62 @@ class TestBulkEditObjects(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(StoragePath.objects.count(), 0)
def test_bulk_objects_delete_all_filtered(self) -> None:
"""
GIVEN:
- Existing objects that can be filtered by name
WHEN:
- bulk_edit_objects API endpoint is called with all=true and filters
THEN:
- Matching objects are deleted without passing explicit IDs
"""
Correspondent.objects.create(name="c2")
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"all": True,
"filters": {"name__icontains": "c"},
"object_type": "correspondents",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Correspondent.objects.count(), 0)
def test_bulk_objects_delete_all_filtered_tags_includes_descendants(self) -> None:
"""
GIVEN:
- Root tag with descendants
WHEN:
- bulk_edit_objects API endpoint is called with all=true
THEN:
- Root tags and descendants are deleted
"""
parent = Tag.objects.create(name="parent")
child = Tag.objects.create(name="child", tn_parent=parent)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"all": True,
"filters": {"is_root": True},
"object_type": "tags",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(Tag.objects.filter(id=parent.id).exists())
self.assertFalse(Tag.objects.filter(id=child.id).exists())
def test_bulk_edit_object_permissions_insufficient_global_perms(self) -> None:
"""
GIVEN:
@@ -861,3 +933,40 @@ class TestBulkEditObjects(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions")
def test_bulk_edit_all_filtered_permissions_insufficient_object_perms(
self,
) -> None:
"""
GIVEN:
- Filter-matching objects include one that the user cannot edit
WHEN:
- bulk_edit_objects API endpoint is called with all=true
THEN:
- Operation applies only to editable objects
"""
self.t2.owner = User.objects.get(username="temp_admin")
self.t2.save()
self.user1.user_permissions.add(
*Permission.objects.filter(codename="delete_tag"),
)
self.user1.save()
self.client.force_authenticate(user=self.user1)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"all": True,
"filters": {"name__icontains": "t"},
"object_type": "tags",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(Tag.objects.filter(id=self.t2.id).exists())
self.assertFalse(Tag.objects.filter(id=self.t1.id).exists())

View File

@@ -68,26 +68,88 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
results = response.data["results"]
self.assertEqual(response.data["count"], 3)
self.assertEqual(len(results), 3)
self.assertCountEqual(response.data["all"], [d1.id, d2.id, d3.id])
response = self.client.get("/api/documents/?query=september")
results = response.data["results"]
self.assertEqual(response.data["count"], 1)
self.assertEqual(len(results), 1)
self.assertCountEqual(response.data["all"], [d3.id])
self.assertEqual(results[0]["original_file_name"], "someepdf.pdf")
response = self.client.get("/api/documents/?query=statement")
results = response.data["results"]
self.assertEqual(response.data["count"], 2)
self.assertEqual(len(results), 2)
self.assertCountEqual(response.data["all"], [d2.id, d3.id])
response = self.client.get("/api/documents/?query=sfegdfg")
results = response.data["results"]
self.assertEqual(response.data["count"], 0)
self.assertEqual(len(results), 0)
self.assertCountEqual(response.data["all"], [])
def test_search_returns_all_for_api_version_9(self) -> None:
d1 = Document.objects.create(
title="invoice",
content="bank payment",
checksum="A",
pk=1,
)
d2 = Document.objects.create(
title="bank statement",
content="bank transfer",
checksum="B",
pk=2,
)
with AsyncWriter(index.open_index()) as writer:
index.update_document(writer, d1)
index.update_document(writer, d2)
response = self.client.get(
"/api/documents/?query=bank",
headers={"Accept": "application/json; version=9"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("all", response.data)
self.assertCountEqual(response.data["all"], [d1.id, d2.id])
def test_search_with_include_selection_data(self) -> None:
correspondent = Correspondent.objects.create(name="c1")
doc_type = DocumentType.objects.create(name="dt1")
storage_path = StoragePath.objects.create(name="sp1")
tag = Tag.objects.create(name="tag")
matching_doc = Document.objects.create(
title="bank statement",
content="bank content",
checksum="A",
correspondent=correspondent,
document_type=doc_type,
storage_path=storage_path,
)
matching_doc.tags.add(tag)
with AsyncWriter(index.open_index()) as writer:
index.update_document(writer, matching_doc)
response = self.client.get(
"/api/documents/?query=bank&include_selection_data=true",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("selection_data", response.data)
selected_correspondent = next(
item
for item in response.data["selection_data"]["selected_correspondents"]
if item["id"] == correspondent.id
)
selected_tag = next(
item
for item in response.data["selection_data"]["selected_tags"]
if item["id"] == tag.id
)
self.assertEqual(selected_correspondent["document_count"], 1)
self.assertEqual(selected_tag["document_count"], 1)
def test_search_custom_field_ordering(self) -> None:
custom_field = CustomField.objects.create(

View File

@@ -715,16 +715,9 @@ class TestConsumer(
self._assert_first_last_send_progress()
@override_settings(AUDIT_LOG_ENABLED=True)
@mock.patch("documents.consumer.document_updated.send")
@mock.patch("documents.consumer.document_version_added.send")
@mock.patch("documents.consumer.load_classifier")
def test_consume_version_creates_new_version(
self,
mock_load_classifier: mock.Mock,
mock_document_version_added_send: mock.Mock,
mock_document_updated_send: mock.Mock,
) -> None:
mock_load_classifier.return_value = MagicMock()
def test_consume_version_creates_new_version(self, m) -> None:
m.return_value = MagicMock()
with self.get_consumer(self.get_test_file()) as consumer:
consumer.run()
@@ -792,16 +785,6 @@ class TestConsumer(
self.assertIsNone(version.archive_serial_number)
self.assertEqual(version.original_filename, version_file.name)
self.assertTrue(bool(version.content))
mock_document_version_added_send.assert_called_once()
self.assertEqual(
mock_document_version_added_send.call_args.kwargs["document"].id,
version.id,
)
mock_document_updated_send.assert_called_once()
self.assertEqual(
mock_document_updated_send.call_args.kwargs["document"].id,
root_doc.id,
)
@override_settings(AUDIT_LOG_ENABLED=True)
@mock.patch("documents.consumer.load_classifier")

View File

@@ -60,7 +60,6 @@ from documents.models import WorkflowTrigger
from documents.plugins.base import StopConsumeTaskError
from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_consumption_finished
from documents.signals import document_version_added
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DummyProgressManager
from documents.tests.utils import FileSystemAssertsMixin
@@ -1787,53 +1786,6 @@ class TestWorkflows(
).exists(),
)
def test_version_added_workflow_runs_on_root_document(self) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.VERSION_ADDED,
)
action = WorkflowAction.objects.create(
assign_title="Updated by version",
assign_owner=self.user2,
)
workflow = Workflow.objects.create(
name="Version workflow",
order=0,
)
workflow.triggers.add(trigger)
workflow.actions.add(action)
root_doc = Document.objects.create(
title="root",
correspondent=self.c,
original_filename="root.pdf",
)
version_doc = Document.objects.create(
title="version",
correspondent=self.c,
original_filename="version.pdf",
root_document=root_doc,
)
document_version_added.send(
sender=self.__class__,
document=version_doc,
)
root_doc.refresh_from_db()
version_doc.refresh_from_db()
self.assertEqual(root_doc.title, "Updated by version")
self.assertEqual(root_doc.owner, self.user2)
self.assertIsNone(version_doc.owner)
self.assertEqual(
WorkflowRun.objects.filter(
workflow=workflow,
type=WorkflowTrigger.WorkflowTriggerType.VERSION_ADDED,
document=root_doc,
).count(),
1,
)
def test_document_updated_workflow(self) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,

View File

@@ -555,7 +555,9 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
page = self.paginate_queryset(queryset)
serializer = self.get_serializer(page, many=True)
response = self.get_paginated_response(serializer.data)
if descendant_pks:
response.data["display_count"] = len(children_source)
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
if descendant_pks and api_version < 10:
# Include children in the "all" field, if needed
response.data["all"] = [tag.pk for tag in children_source]
return response
@@ -835,6 +837,61 @@ class DocumentViewSet(
"custom_field_",
)
def _get_selection_data_for_queryset(self, queryset):
correspondents = Correspondent.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__in=queryset),
distinct=True,
),
)
tags = Tag.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__in=queryset),
distinct=True,
),
)
document_types = DocumentType.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__in=queryset),
distinct=True,
),
)
storage_paths = StoragePath.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__in=queryset),
distinct=True,
),
)
custom_fields = CustomField.objects.annotate(
document_count=Count(
"fields__document",
filter=Q(fields__document__in=queryset),
distinct=True,
),
)
return {
"selected_correspondents": [
{"id": t.id, "document_count": t.document_count} for t in correspondents
],
"selected_tags": [
{"id": t.id, "document_count": t.document_count} for t in tags
],
"selected_document_types": [
{"id": t.id, "document_count": t.document_count} for t in document_types
],
"selected_storage_paths": [
{"id": t.id, "document_count": t.document_count} for t in storage_paths
],
"selected_custom_fields": [
{"id": t.id, "document_count": t.document_count} for t in custom_fields
],
}
def get_queryset(self):
latest_version_content = Subquery(
Document.objects.filter(root_document=OuterRef("pk"))
@@ -982,6 +1039,25 @@ class DocumentViewSet(
return response
def list(self, request, *args, **kwargs):
if not get_boolean(
str(request.query_params.get("include_selection_data", "false")),
):
return super().list(request, *args, **kwargs)
queryset = self.filter_queryset(self.get_queryset())
selection_data = self._get_selection_data_for_queryset(queryset)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
response = self.get_paginated_response(serializer.data)
response.data["selection_data"] = selection_data
return response
serializer = self.get_serializer(queryset, many=True)
return Response({"results": serializer.data, "selection_data": selection_data})
def destroy(self, request, *args, **kwargs):
from documents import index
@@ -2002,6 +2078,21 @@ class UnifiedSearchViewSet(DocumentViewSet):
else None
)
if get_boolean(
str(
request.query_params.get(
"include_selection_data",
"false",
),
),
):
result_ids = queryset.get_result_ids()
response.data["selection_data"] = (
self._get_selection_data_for_queryset(
Document.objects.filter(pk__in=result_ids),
)
)
return response
except NotFound:
raise
@@ -2124,7 +2215,36 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet):
ordering_fields = ("name",)
class DocumentOperationPermissionMixin(PassUserMixin):
class DocumentSelectionMixin:
def _resolve_document_ids(
self,
*,
user: User,
validated_data: dict[str, Any],
permission_codename: str = "view_document",
) -> list[int]:
if not validated_data.get("all", False):
# if all is not true, just pass through the provided document ids
return validated_data["documents"]
# otherwise, reconstruct the document list based on the provided filters
filters = validated_data.get("filters") or {}
permitted_documents = get_objects_for_user_owner_aware(
user,
permission_codename,
Document,
)
return list(
DocumentFilterSet(
data=filters,
queryset=permitted_documents,
)
.qs.distinct()
.values_list("pk", flat=True),
)
class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
permission_classes = (IsAuthenticated,)
parser_classes = (parsers.JSONParser,)
METHOD_NAMES_REQUIRING_USER = {
@@ -2218,8 +2338,15 @@ class DocumentOperationPermissionMixin(PassUserMixin):
validated_data: dict[str, Any],
operation_label: str,
):
documents = validated_data["documents"]
parameters = {k: v for k, v in validated_data.items() if k != "documents"}
documents = self._resolve_document_ids(
user=self.request.user,
validated_data=validated_data,
)
parameters = {
k: v
for k, v in validated_data.items()
if k not in {"documents", "all", "filters"}
}
user = self.request.user
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
@@ -2307,7 +2434,10 @@ class BulkEditView(DocumentOperationPermissionMixin):
user = self.request.user
method = serializer.validated_data.get("method")
parameters = serializer.validated_data.get("parameters")
documents = serializer.validated_data.get("documents")
documents = self._resolve_document_ids(
user=user,
validated_data=serializer.validated_data,
)
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
parameters["user"] = user
if not self._has_document_permissions(
@@ -3151,7 +3281,7 @@ class StatisticsView(GenericAPIView):
)
class BulkDownloadView(GenericAPIView):
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView):
permission_classes = (IsAuthenticated,)
serializer_class = BulkDownloadSerializer
parser_classes = (parsers.JSONParser,)
@@ -3160,7 +3290,10 @@ class BulkDownloadView(GenericAPIView):
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,
)
documents = Document.objects.filter(pk__in=ids)
compression = serializer.validated_data.get("compression")
content = serializer.validated_data.get("content")
@@ -3790,20 +3923,55 @@ class BulkEditObjectsView(PassUserMixin):
user = self.request.user
object_type = serializer.validated_data.get("object_type")
object_ids = serializer.validated_data.get("objects")
apply_to_all = serializer.validated_data.get("all")
object_class = serializer.get_object_class(object_type)
operation = serializer.validated_data.get("operation")
model_name = object_class._meta.model_name
perm_codename = (
f"change_{model_name}"
if operation == "set_permissions"
else f"delete_{model_name}"
)
objs = object_class.objects.select_related("owner").filter(pk__in=object_ids)
if apply_to_all:
# Support all to avoid sending large lists of ids for bulk operations, with optional filters
filters = serializer.validated_data.get("filters") or {}
filterset_class = {
"tags": TagFilterSet,
"correspondents": CorrespondentFilterSet,
"document_types": DocumentTypeFilterSet,
"storage_paths": StoragePathFilterSet,
}[object_type]
user_permitted_objects = get_objects_for_user_owner_aware(
user,
perm_codename,
object_class,
)
objs = filterset_class(
data=filters,
queryset=user_permitted_objects,
).qs
if object_type == "tags":
editable_ids = set(user_permitted_objects.values_list("pk", flat=True))
all_ids = set(objs.values_list("pk", flat=True))
for tag in objs:
all_ids.update(
descendant.pk
for descendant in tag.get_descendants()
if descendant.pk in editable_ids
)
objs = object_class.objects.filter(pk__in=all_ids)
objs = objs.select_related("owner")
object_ids = list(objs.values_list("pk", flat=True))
else:
objs = object_class.objects.select_related("owner").filter(
pk__in=object_ids,
)
if not user.is_superuser:
model_name = object_class._meta.model_name
perm = (
f"documents.change_{model_name}"
if operation == "set_permissions"
else f"documents.delete_{model_name}"
)
perm = f"documents.{perm_codename}"
has_perms = user.has_perm(perm) and all(
(obj.owner == user or obj.owner is None) for obj in objs
has_perms_owner_aware(user, perm_codename, obj) for obj in objs
)
if not has_perms:

View File

@@ -9,6 +9,7 @@ from allauth.mfa.recovery_codes.internal.flows import auto_generate_recovery_cod
from allauth.mfa.totp.internal import auth as totp_auth
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialAccount
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.contrib.staticfiles.storage import staticfiles_storage
@@ -56,17 +57,27 @@ class StandardPagination(PageNumberPagination):
page_size_query_param = "page_size"
max_page_size = 100000
def _get_api_version(self) -> int:
request = getattr(self, "request", None)
default_version = settings.REST_FRAMEWORK["DEFAULT_VERSION"]
return int(request.version if request else default_version)
def _should_include_all(self) -> bool:
# TODO: remove legacy `all` support when API v9 is dropped.
return self._get_api_version() < 10
def get_paginated_response(self, data):
response_data = [
("count", self.page.paginator.count),
("next", self.get_next_link()),
("previous", self.get_previous_link()),
]
if self._should_include_all():
response_data.append(("all", self.get_all_result_ids()))
response_data.append(("results", data))
return Response(
OrderedDict(
[
("count", self.page.paginator.count),
("next", self.get_next_link()),
("previous", self.get_previous_link()),
("all", self.get_all_result_ids()),
("results", data),
],
),
OrderedDict(response_data),
)
def get_all_result_ids(self):
@@ -87,11 +98,14 @@ class StandardPagination(PageNumberPagination):
def get_paginated_response_schema(self, schema):
response_schema = super().get_paginated_response_schema(schema)
response_schema["properties"]["all"] = {
"type": "array",
"example": "[1, 2, 3]",
"items": {"type": "integer"},
}
if self._should_include_all():
response_schema["properties"]["all"] = {
"type": "array",
"example": "[1, 2, 3]",
"items": {"type": "integer"},
}
else:
response_schema["properties"].pop("all", None)
return response_schema