mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-07 07:19:22 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62648fffe8 | ||
|
|
b0c8ce25fd | ||
|
|
3c4f5d8959 | ||
|
|
85c52cc27f | ||
|
|
9b3d484e24 | ||
|
|
3a5ba814b6 | ||
|
|
26552f54b4 | ||
|
|
de9d7e54bd | ||
|
|
a5c460a567 | ||
|
|
cfca79e7fc | ||
|
|
9d59f3aaa2 | ||
|
|
332c308f84 | ||
|
|
a0b8da7687 |
No files matched your search
+30
-5
@@ -493,7 +493,7 @@ fields and permissions, which will be merged.
|
|||||||
|
|
||||||
#### Types {#workflow-trigger-types}
|
#### Types {#workflow-trigger-types}
|
||||||
|
|
||||||
Currently, there are four events that correspond to workflow trigger 'types':
|
Currently, there are five events that correspond to workflow trigger 'types':
|
||||||
|
|
||||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
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
|
folder or API), file path, file name, mail rule
|
||||||
@@ -505,8 +505,10 @@ Currently, there are four 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
|
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
|
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).
|
offsets will trigger after the date, negative offsets will trigger before).
|
||||||
|
5. **Manual**: a trigger that only runs when you explicitly choose to run the workflow against one or more existing documents. See
|
||||||
|
[running a workflow manually](#running-a-workflow-manually) below.
|
||||||
|
|
||||||
The following flow diagram illustrates the four document trigger types:
|
The following flow diagram illustrates the document trigger types:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -526,6 +528,10 @@ flowchart TD
|
|||||||
matching
|
matching
|
||||||
trigger(s)"}
|
trigger(s)"}
|
||||||
|
|
||||||
|
manual{"Matching
|
||||||
|
'Manual'
|
||||||
|
trigger(s)"}
|
||||||
|
|
||||||
A[New Document] --> consumption
|
A[New Document] --> consumption
|
||||||
consumption --> |Yes| C[Workflow Actions Run]
|
consumption --> |Yes| C[Workflow Actions Run]
|
||||||
consumption --> |No| D
|
consumption --> |No| D
|
||||||
@@ -543,6 +549,10 @@ flowchart TD
|
|||||||
scheduled --> |Yes| N[Workflow Actions Run]
|
scheduled --> |Yes| N[Workflow Actions Run]
|
||||||
scheduled --> |No| O[Document Saved]
|
scheduled --> |No| O[Document Saved]
|
||||||
N --> O
|
N --> O
|
||||||
|
P[User Runs Workflow] --> manual
|
||||||
|
manual --> |Yes| Q[Workflow Actions Run]
|
||||||
|
manual --> |No| R[Document Saved]
|
||||||
|
Q --> R
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Filters {#workflow-trigger-filters}
|
#### Filters {#workflow-trigger-filters}
|
||||||
@@ -554,9 +564,9 @@ Workflows allow you to filter by:
|
|||||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||||
example, automatically assigning documents to different owners based on the upload directory.
|
example, automatically assigning documents to different owners based on the upload directory.
|
||||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
- Content matching (`Added`, `Updated`, `Scheduled` and `Manual` triggers only). Filter document content using the matching settings.
|
||||||
|
|
||||||
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
There are also 'advanced' filters available for `Added`, `Updated`, `Scheduled` and `Manual` triggers:
|
||||||
|
|
||||||
- Any Tags: Filter for documents with any of the specified tags.
|
- Any Tags: Filter for documents with any of the specified tags.
|
||||||
- All Tags: Filter for documents with all of the specified tags.
|
- All Tags: Filter for documents with all of the specified tags.
|
||||||
@@ -569,6 +579,21 @@ There are also 'advanced' filters available for `Added`, `Updated` and `Schedule
|
|||||||
- Not Storage paths: Filter documents without any of these storage paths.
|
- Not Storage paths: Filter documents without any of these storage paths.
|
||||||
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
||||||
|
|
||||||
|
#### Running a workflow manually {#running-a-workflow-manually}
|
||||||
|
|
||||||
|
A workflow with a **Manual** trigger does not run automatically. Instead, it can be run on demand against existing
|
||||||
|
documents from the web UI:
|
||||||
|
|
||||||
|
- From the document list, select one or more documents and choose **Run workflow** from the "Actions" menu.
|
||||||
|
- From an individual document, choose **Run workflow** from the "Actions" menu.
|
||||||
|
|
||||||
|
You will be asked which workflow to run. By default the workflow's trigger filters still apply, so documents that do
|
||||||
|
not match are skipped. You can instead choose to **run on all selected documents**, which ignores the trigger filters
|
||||||
|
and runs the workflow's actions on every selected document.
|
||||||
|
|
||||||
|
Manual runs happen in the background. As with scheduled workflows, the document list is not refreshed automatically when
|
||||||
|
they finish, so you may need to reload the list to see the results.
|
||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
#### Types {#workflow-action-types}
|
#### Types {#workflow-action-types}
|
||||||
@@ -649,7 +674,7 @@ new version of the same document, this time with normal OCR/text extraction appl
|
|||||||
a password-protected file added with this trigger will briefly exist as an un-OCR'd version before
|
a password-protected file added with this trigger will briefly exist as an un-OCR'd version before
|
||||||
the properly processed version is created.
|
the properly processed version is created.
|
||||||
|
|
||||||
**Document Added**, **Document Updated**, **Scheduled**: these triggers run against a document that
|
**Document Added**, **Document Updated**, **Scheduled**, **Manual**: these triggers run against a document that
|
||||||
already exists, so password removal happens immediately: the decrypted content is queued for
|
already exists, so password removal happens immediately: the decrypted content is queued for
|
||||||
consumption as a new version right away. Note that if the document's initial consumption also
|
consumption as a new version right away. Note that if the document's initial consumption also
|
||||||
happened while it was still encrypted, that original version will likewise be missing OCR text.
|
happened while it was still encrypted, that original version will likewise be missing OCR text.
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import personFill from 'bootstrap-icons/icons/person-fill.svg'
|
|||||||
import personLock from 'bootstrap-icons/icons/person-lock.svg'
|
import personLock from 'bootstrap-icons/icons/person-lock.svg'
|
||||||
import personSquare from 'bootstrap-icons/icons/person-square.svg'
|
import personSquare from 'bootstrap-icons/icons/person-square.svg'
|
||||||
import person from 'bootstrap-icons/icons/person.svg'
|
import person from 'bootstrap-icons/icons/person.svg'
|
||||||
|
import playCircle from 'bootstrap-icons/icons/play-circle.svg'
|
||||||
import playFill from 'bootstrap-icons/icons/play-fill.svg'
|
import playFill from 'bootstrap-icons/icons/play-fill.svg'
|
||||||
import plusCircle from 'bootstrap-icons/icons/plus-circle.svg'
|
import plusCircle from 'bootstrap-icons/icons/plus-circle.svg'
|
||||||
import plus from 'bootstrap-icons/icons/plus.svg'
|
import plus from 'bootstrap-icons/icons/plus.svg'
|
||||||
@@ -279,6 +280,7 @@ export const icons: Record<string, string> = {
|
|||||||
personFillLock,
|
personFillLock,
|
||||||
personLock,
|
personLock,
|
||||||
personSquare,
|
personSquare,
|
||||||
|
playCircle,
|
||||||
playFill,
|
playFill,
|
||||||
plus,
|
plus,
|
||||||
plusCircle,
|
plusCircle,
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (message) {
|
||||||
|
<p class="text-break" [innerHTML]="message"></p>
|
||||||
|
}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="runWorkflowSelect" i18n>Workflow</label>
|
||||||
|
<select class="form-select" id="runWorkflowSelect" [(ngModel)]="workflowId" (ngModelChange)="workflowSelected()">
|
||||||
|
<option [ngValue]="null" disabled i18n>Select a workflow</option>
|
||||||
|
@for (workflow of workflows; track workflow.id) {
|
||||||
|
<option [ngValue]="workflow.id">{{workflow.name}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="runWorkflowForce" [(ngModel)]="force" />
|
||||||
|
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
|
||||||
|
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||||
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||||
|
{{btnCaption}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { RunWorkflowConfirmDialogComponent } from './run-workflow-confirm-dialog.component'
|
||||||
|
|
||||||
|
describe('RunWorkflowConfirmDialogComponent', () => {
|
||||||
|
let component: RunWorkflowConfirmDialogComponent
|
||||||
|
let fixture: ComponentFixture<RunWorkflowConfirmDialogComponent>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [NgbActiveModal],
|
||||||
|
imports: [RunWorkflowConfirmDialogComponent],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(RunWorkflowConfirmDialogComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
component.workflows = [{ id: 5, name: 'Manual workflow' } as any]
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable confirm until a workflow is selected and not force by default', () => {
|
||||||
|
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||||
|
expect(component.force).toBeFalsy()
|
||||||
|
|
||||||
|
component.workflowId = 5
|
||||||
|
component.workflowSelected()
|
||||||
|
expect(component.confirmButtonEnabled).toBeTruthy()
|
||||||
|
|
||||||
|
component.workflowId = null
|
||||||
|
component.workflowSelected()
|
||||||
|
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render an option per workflow', () => {
|
||||||
|
const options = fixture.nativeElement.querySelectorAll(
|
||||||
|
'#runWorkflowSelect option'
|
||||||
|
)
|
||||||
|
// placeholder option + one per workflow
|
||||||
|
expect(options).toHaveLength(2)
|
||||||
|
expect(options[1].textContent).toContain('Manual workflow')
|
||||||
|
})
|
||||||
|
})
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
import { Component } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-run-workflow-confirm-dialog',
|
||||||
|
templateUrl: './run-workflow-confirm-dialog.component.html',
|
||||||
|
imports: [FormsModule],
|
||||||
|
})
|
||||||
|
export class RunWorkflowConfirmDialogComponent extends ConfirmDialogComponent {
|
||||||
|
public workflows: Workflow[] = []
|
||||||
|
|
||||||
|
public workflowId: number = null
|
||||||
|
|
||||||
|
// i.e. ignore the workflow's trigger filters
|
||||||
|
public force: boolean = false
|
||||||
|
|
||||||
|
override confirmButtonEnabled = false
|
||||||
|
|
||||||
|
public workflowSelected(): void {
|
||||||
|
this.confirmButtonEnabled = !!this.workflowId
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -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-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>
|
<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) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
|
||||||
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (matchingPatternRequired(formGroup)) {
|
@if (matchingPatternRequired(formGroup)) {
|
||||||
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="trigger-filters mb-3">
|
<div class="trigger-filters mb-3">
|
||||||
|
|||||||
+4
@@ -123,6 +123,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
|
|||||||
id: WorkflowTriggerType.Scheduled,
|
id: WorkflowTriggerType.Scheduled,
|
||||||
name: $localize`Scheduled`,
|
name: $localize`Scheduled`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowTriggerType.Manual,
|
||||||
|
name: $localize`Manual`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const WORKFLOW_ACTION_OPTIONS = [
|
export const WORKFLOW_ACTION_OPTIONS = [
|
||||||
|
|||||||
@@ -49,6 +49,12 @@
|
|||||||
<pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></pngx-bs-icon><span i18n>Reprocess</span>
|
<pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></pngx-bs-icon><span i18n>Reprocess</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@if (manualWorkflows().length > 0) {
|
||||||
|
<button ngbDropdownItem (click)="runWorkflow()" [disabled]="!userCanEdit || !userIsOwner">
|
||||||
|
<pngx-bs-icon width="1em" height="1em" name="play-circle" class="me-2"></pngx-bs-icon><span i18n>Run workflow</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
||||||
<pngx-bs-icon width="1em" height="1em" name="printer" class="me-2"></pngx-bs-icon><span i18n>Print</span>
|
<pngx-bs-icon width="1em" height="1em" name="printer" class="me-2"></pngx-bs-icon><span i18n>Print</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1030,6 +1030,27 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should run a workflow on the document, confirm and close modal', () => {
|
||||||
|
initNormally()
|
||||||
|
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||||
|
bulkEditSpy.mockReturnValue(of(true))
|
||||||
|
let openModal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
component.runWorkflow()
|
||||||
|
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||||
|
openModal.componentInstance.workflowId = 5
|
||||||
|
openModal.componentInstance.force = true
|
||||||
|
openModal.componentInstance.confirmClicked.next()
|
||||||
|
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||||
|
{ documents: [doc.id] },
|
||||||
|
'run_workflow',
|
||||||
|
{ workflow: 5, filter_documents: false }
|
||||||
|
)
|
||||||
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should pass remote OCR choice when reprocessing', () => {
|
it('should pass remote OCR choice when reprocessing', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ import { StoragePath } from 'src/app/data/storage-path'
|
|||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
|
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -93,6 +95,7 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
|||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
@@ -104,6 +107,7 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
|||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||||
|
import { RunWorkflowConfirmDialogComponent } from '../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -233,6 +237,7 @@ export class DocumentDetailComponent
|
|||||||
private componentRouterService = inject(ComponentRouterService)
|
private componentRouterService = inject(ComponentRouterService)
|
||||||
private deviceDetectorService = inject(DeviceDetectorService)
|
private deviceDetectorService = inject(DeviceDetectorService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
|
private readonly workflowService = inject(WorkflowService)
|
||||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||||
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
||||||
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||||
@@ -295,6 +300,7 @@ export class DocumentDetailComponent
|
|||||||
readonly correspondents = signal<Correspondent[]>(undefined)
|
readonly correspondents = signal<Correspondent[]>(undefined)
|
||||||
readonly documentTypes = signal<DocumentType[]>(undefined)
|
readonly documentTypes = signal<DocumentType[]>(undefined)
|
||||||
readonly storagePaths = signal<StoragePath[]>(undefined)
|
readonly storagePaths = signal<StoragePath[]>(undefined)
|
||||||
|
readonly manualWorkflows = signal<Workflow[]>([])
|
||||||
|
|
||||||
documentForm: FormGroup = new FormGroup({
|
documentForm: FormGroup = new FormGroup({
|
||||||
title: new FormControl(''),
|
title: new FormControl(''),
|
||||||
@@ -836,6 +842,19 @@ export class DocumentDetailComponent
|
|||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => this.users.set(result.results))
|
.subscribe((result) => this.users.set(result.results))
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Workflow
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.workflowService
|
||||||
|
.list(1, 100000, null, null, {
|
||||||
|
trigger_type: WorkflowTriggerType.Manual,
|
||||||
|
})
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => this.manualWorkflows.set(result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.getCustomFields()
|
this.getCustomFields()
|
||||||
|
|
||||||
@@ -1559,6 +1578,39 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runWorkflow() {
|
||||||
|
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Run workflow`
|
||||||
|
modal.componentInstance.message = $localize`Run a workflow on "${this.document().title}".`
|
||||||
|
modal.componentInstance.btnCaption = $localize`Run`
|
||||||
|
modal.componentInstance.workflows = this.manualWorkflows()
|
||||||
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
|
this.documentsService
|
||||||
|
.bulkEdit({ documents: [this.document().id] }, 'run_workflow', {
|
||||||
|
workflow: modal.componentInstance.workflowId,
|
||||||
|
filter_documents: !modal.componentInstance.force,
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showInfo(
|
||||||
|
$localize`Workflow will run on "${this.document().title}" in the background.`
|
||||||
|
)
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error executing operation`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private getSelectedNonLatestVersionId(): number | null {
|
private getSelectedNonLatestVersionId(): number | null {
|
||||||
const versions = this.document()?.versions ?? []
|
const versions = this.document()?.versions ?? []
|
||||||
if (!versions.length || !this.selectedVersionId()) {
|
if (!versions.length || !this.selectedVersionId()) {
|
||||||
|
|||||||
@@ -98,6 +98,11 @@
|
|||||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||||
<pngx-bs-icon name="journal-bookmark-fill" class="me-2"></pngx-bs-icon><ng-container i18n>Merge as versions</ng-container>
|
<pngx-bs-icon name="journal-bookmark-fill" class="me-2"></pngx-bs-icon><ng-container i18n>Merge as versions</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
@if (manualWorkflows.length > 0) {
|
||||||
|
<button ngbDropdownItem (click)="runWorkflowSelected()" [disabled]="!canRunWorkflow">
|
||||||
|
<pngx-bs-icon name="play-circle" class="me-2"></pngx-bs-icon><ng-container i18n>Run workflow</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
|||||||
import { Results } from 'src/app/data/results'
|
import { Results } from 'src/app/data/results'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
|
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.
|
|||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
@@ -83,6 +85,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
let customFieldsService: CustomFieldsService
|
let customFieldsService: CustomFieldsService
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let shareLinkBundleService: ShareLinkBundleService
|
let shareLinkBundleService: ShareLinkBundleService
|
||||||
|
let workflowService: WorkflowService
|
||||||
let router: Router
|
let router: Router
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -173,6 +176,22 @@ describe('BulkEditorComponent', () => {
|
|||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: WorkflowService,
|
||||||
|
useValue: {
|
||||||
|
list: jest.fn(() =>
|
||||||
|
of({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'Manual workflow',
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Manual }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: Router,
|
provide: Router,
|
||||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||||
@@ -195,6 +214,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||||
|
workflowService = TestBed.inject(WorkflowService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
|
||||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||||
@@ -652,6 +672,50 @@ describe('BulkEditorComponent', () => {
|
|||||||
) // listAllFilteredIds
|
) // listAllFilteredIds
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should load manual workflows on init', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(workflowService.list).toHaveBeenCalledWith(1, 100000, null, null, {
|
||||||
|
trigger_type: WorkflowTriggerType.Manual,
|
||||||
|
})
|
||||||
|
expect(component.manualWorkflows).toHaveLength(1)
|
||||||
|
expect(component.manualWorkflows[0].id).toEqual(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should run a workflow on the selection via the run workflow dialog', () => {
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
|
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]))
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.runWorkflowSelected()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
expect(modal.componentInstance.workflows).toEqual(component.manualWorkflows)
|
||||||
|
modal.componentInstance.workflowId = 5
|
||||||
|
modal.componentInstance.force = true
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
|
)
|
||||||
|
expect(req.request.body.method).toEqual('run_workflow')
|
||||||
|
expect(req.request.body.parameters).toEqual({
|
||||||
|
workflow: 5,
|
||||||
|
filter_documents: false,
|
||||||
|
})
|
||||||
|
req.flush(true)
|
||||||
|
httpTestingController.match(
|
||||||
|
`${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 set modal dialog text accordingly for tag edit confirmation', () => {
|
it('should set modal dialog text accordingly for tag edit confirmation', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { CustomField } from 'src/app/data/custom-field'
|
|||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { SelectionDataItem } from 'src/app/data/results'
|
import { SelectionDataItem } from 'src/app/data/results'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
} from 'src/app/services/rest/document.service'
|
} from 'src/app/services/rest/document.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -64,6 +66,7 @@ import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dial
|
|||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
|
import { RunWorkflowConfirmDialogComponent } from '../../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -81,6 +84,7 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
|
|||||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||||
|
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-bulk-editor',
|
selector: 'pngx-bulk-editor',
|
||||||
@@ -114,8 +118,11 @@ export class BulkEditorComponent
|
|||||||
public readonly permissionService = inject(PermissionsService)
|
public readonly permissionService = inject(PermissionsService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
private readonly workflowService = inject(WorkflowService)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
|
|
||||||
|
public manualWorkflows: Workflow[] = []
|
||||||
|
|
||||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
@@ -258,6 +265,19 @@ export class BulkEditorComponent
|
|||||||
(result) => (this.customFieldsSelectionModel.items = result.results)
|
(result) => (this.customFieldsSelectionModel.items = result.results)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
this.permissionService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Workflow
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.workflowService
|
||||||
|
.list(1, 100000, null, null, {
|
||||||
|
trigger_type: WorkflowTriggerType.Manual,
|
||||||
|
})
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.manualWorkflows = result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.downloadForm
|
this.downloadForm
|
||||||
.get('downloadFileTypeArchive')
|
.get('downloadFileTypeArchive')
|
||||||
@@ -286,6 +306,28 @@ export class BulkEditorComponent
|
|||||||
this.unsubscribeNotifier.complete()
|
this.unsubscribeNotifier.complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canRunWorkflow(): boolean {
|
||||||
|
return this.userCanEditAll && this.manualWorkflows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
runWorkflowSelected() {
|
||||||
|
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Run workflow`
|
||||||
|
modal.componentInstance.message = $localize`Run a workflow on ${this.getSelectionSize()} selected document(s).`
|
||||||
|
modal.componentInstance.btnCaption = $localize`Run`
|
||||||
|
modal.componentInstance.workflows = this.manualWorkflows
|
||||||
|
modal.componentInstance.confirmClicked
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.executeBulkEditMethod(modal, 'run_workflow', {
|
||||||
|
workflow: modal.componentInstance.workflowId,
|
||||||
|
filter_documents: !modal.componentInstance.force,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private executeBulkEditMethod(
|
private executeBulkEditMethod(
|
||||||
modal: NgbModalRef,
|
modal: NgbModalRef,
|
||||||
method: DocumentBulkEditMethod,
|
method: DocumentBulkEditMethod,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export enum WorkflowTriggerType {
|
|||||||
DocumentAdded = 2,
|
DocumentAdded = 2,
|
||||||
DocumentUpdated = 3,
|
DocumentUpdated = 3,
|
||||||
Scheduled = 4,
|
Scheduled = 4,
|
||||||
|
Manual = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ScheduleDateField {
|
export enum ScheduleDateField {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type DocumentBulkEditMethod =
|
|||||||
| 'modify_tags'
|
| 'modify_tags'
|
||||||
| 'modify_custom_fields'
|
| 'modify_custom_fields'
|
||||||
| 'set_permissions'
|
| 'set_permissions'
|
||||||
|
| 'run_workflow'
|
||||||
|
|
||||||
export interface MergeDocumentsRequest {
|
export interface MergeDocumentsRequest {
|
||||||
metadata_document_id?: number
|
metadata_document_id?: number
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from documents.plugins.helpers import DocumentsStatusManager
|
|||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
from documents.tasks import remove_document_from_index
|
from documents.tasks import remove_document_from_index
|
||||||
|
from documents.tasks import run_workflows_for_documents
|
||||||
from documents.tasks import update_document_content_maybe_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
@@ -445,6 +446,25 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def run_workflow(
|
||||||
|
doc_ids: list[int],
|
||||||
|
workflow: int,
|
||||||
|
*,
|
||||||
|
filter_documents: bool = True,
|
||||||
|
) -> Literal["OK"]:
|
||||||
|
"""
|
||||||
|
Manually run a single workflow against the given documents.
|
||||||
|
|
||||||
|
Done asynchronously via a Celery task.
|
||||||
|
"""
|
||||||
|
run_workflows_for_documents.delay(
|
||||||
|
document_ids=doc_ids,
|
||||||
|
workflow_id=workflow,
|
||||||
|
filter_documents=filter_documents,
|
||||||
|
)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def set_permissions(
|
def set_permissions(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
set_permissions: PermissionsSpec,
|
set_permissions: PermissionsSpec,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from django_filters.rest_framework import DateTimeFilter
|
|||||||
from django_filters.rest_framework import Filter
|
from django_filters.rest_framework import Filter
|
||||||
from django_filters.rest_framework import FilterSet
|
from django_filters.rest_framework import FilterSet
|
||||||
from django_filters.rest_framework import MultipleChoiceFilter
|
from django_filters.rest_framework import MultipleChoiceFilter
|
||||||
|
from django_filters.rest_framework import NumberFilter
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from guardian.utils import get_group_obj_perms_model
|
from guardian.utils import get_group_obj_perms_model
|
||||||
from guardian.utils import get_user_obj_perms_model
|
from guardian.utils import get_user_obj_perms_model
|
||||||
@@ -52,6 +53,7 @@ from documents.models import ShareLink
|
|||||||
from documents.models import ShareLinkBundle
|
from documents.models import ShareLinkBundle
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.models import Workflow
|
||||||
from documents.permissions import annotate_authorizing_fields
|
from documents.permissions import annotate_authorizing_fields
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
@@ -249,6 +251,16 @@ class CustomFieldFilterSet(FilterSet):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowFilterSet(FilterSet):
|
||||||
|
trigger_type = NumberFilter(field_name="triggers__type", distinct=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Workflow
|
||||||
|
fields = {
|
||||||
|
"id": ID_KWARGS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_field(serializers.CharField)
|
@extend_schema_field(serializers.CharField)
|
||||||
class CustomFieldsFilter(Filter):
|
class CustomFieldsFilter(Filter):
|
||||||
def filter(self, qs, value):
|
def filter(self, qs, value):
|
||||||
|
|||||||
@@ -684,6 +684,7 @@ def document_matches_workflow(
|
|||||||
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
|
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
|
||||||
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
|
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
|
||||||
or trigger_type == WorkflowTrigger.WorkflowTriggerType.SCHEDULED
|
or trigger_type == WorkflowTrigger.WorkflowTriggerType.SCHEDULED
|
||||||
|
or trigger_type == WorkflowTrigger.WorkflowTriggerType.MANUAL
|
||||||
):
|
):
|
||||||
trigger_matched, reason = existing_document_matches_workflow(
|
trigger_matched, reason = existing_document_matches_workflow(
|
||||||
document,
|
document,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 5.2.18 on 2026-11-06 17:12
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0028_replace_tag_tree_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflowrun",
|
||||||
|
name="type",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Consumption Started"),
|
||||||
|
(2, "Document Added"),
|
||||||
|
(3, "Document Updated"),
|
||||||
|
(4, "Scheduled"),
|
||||||
|
(5, "Manual"),
|
||||||
|
],
|
||||||
|
null=True,
|
||||||
|
verbose_name="workflow trigger type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="type",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Consumption Started"),
|
||||||
|
(2, "Document Added"),
|
||||||
|
(3, "Document Updated"),
|
||||||
|
(4, "Scheduled"),
|
||||||
|
(5, "Manual"),
|
||||||
|
],
|
||||||
|
default=1,
|
||||||
|
verbose_name="Workflow Trigger Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1532,6 +1532,7 @@ class WorkflowTrigger(models.Model):
|
|||||||
DOCUMENT_ADDED = 2, _("Document Added")
|
DOCUMENT_ADDED = 2, _("Document Added")
|
||||||
DOCUMENT_UPDATED = 3, _("Document Updated")
|
DOCUMENT_UPDATED = 3, _("Document Updated")
|
||||||
SCHEDULED = 4, _("Scheduled")
|
SCHEDULED = 4, _("Scheduled")
|
||||||
|
MANUAL = 5, _("Manual")
|
||||||
|
|
||||||
class DocumentSourceChoices(models.IntegerChoices):
|
class DocumentSourceChoices(models.IntegerChoices):
|
||||||
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
|
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
|
||||||
|
|||||||
@@ -576,9 +576,12 @@ def annotate_document_count_by_ids(
|
|||||||
or CustomFieldInstance)
|
or CustomFieldInstance)
|
||||||
related_object_field: field on the relation pointing back to queryset pk
|
related_object_field: field on the relation pointing back to queryset pk
|
||||||
document_ids: the document ids to count against -- a concrete list/set,
|
document_ids: the document ids to count against -- a concrete list/set,
|
||||||
or a simple (already resolved) queryset of ids. Keep
|
or a simple (already resolved) queryset of ids. Callers
|
||||||
the list small: it is embedded in the statement as
|
that need this filtered by a complex condition (e.g. a
|
||||||
literal parameters.
|
permission check) should resolve it to a concrete list
|
||||||
|
first if the same ids will be reused across multiple
|
||||||
|
calls, rather than passing the complex queryset itself
|
||||||
|
into each -- see ``_get_selection_data_for_queryset``.
|
||||||
target_field: field on the relation pointing to Document id
|
target_field: field on the relation pointing to Document id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -1829,6 +1829,7 @@ class BulkEditSerializer(
|
|||||||
"modify_tags",
|
"modify_tags",
|
||||||
"modify_custom_fields",
|
"modify_custom_fields",
|
||||||
"set_permissions",
|
"set_permissions",
|
||||||
|
"run_workflow",
|
||||||
*LEGACY_DOCUMENT_ACTION_METHODS,
|
*LEGACY_DOCUMENT_ACTION_METHODS,
|
||||||
],
|
],
|
||||||
label="Method",
|
label="Method",
|
||||||
@@ -1918,6 +1919,8 @@ class BulkEditSerializer(
|
|||||||
return bulk_edit.reprocess
|
return bulk_edit.reprocess
|
||||||
elif method == "set_permissions":
|
elif method == "set_permissions":
|
||||||
return bulk_edit.set_permissions
|
return bulk_edit.set_permissions
|
||||||
|
elif method == "run_workflow":
|
||||||
|
return bulk_edit.run_workflow
|
||||||
elif method == "rotate":
|
elif method == "rotate":
|
||||||
return bulk_edit.rotate
|
return bulk_edit.rotate
|
||||||
elif method == "merge":
|
elif method == "merge":
|
||||||
@@ -2157,6 +2160,27 @@ class BulkEditSerializer(
|
|||||||
else:
|
else:
|
||||||
parameters["remote_ocr"] = False
|
parameters["remote_ocr"] = False
|
||||||
|
|
||||||
|
def _validate_parameters_run_workflow(self, parameters) -> None:
|
||||||
|
if "workflow" not in parameters:
|
||||||
|
raise serializers.ValidationError("workflow not specified")
|
||||||
|
try:
|
||||||
|
workflow = Workflow.objects.get(id=parameters["workflow"])
|
||||||
|
except (Workflow.DoesNotExist, ValueError, TypeError):
|
||||||
|
raise serializers.ValidationError("Workflow does not exist")
|
||||||
|
if not workflow.triggers.filter(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.MANUAL,
|
||||||
|
).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Workflow does not have a manual trigger",
|
||||||
|
)
|
||||||
|
if "filter_documents" in parameters:
|
||||||
|
if not isinstance(parameters["filter_documents"], bool):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"filter_documents must be a boolean",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parameters["filter_documents"] = True
|
||||||
|
|
||||||
def validate_parameters_remove_password(self, parameters):
|
def validate_parameters_remove_password(self, parameters):
|
||||||
if "password" not in parameters:
|
if "password" not in parameters:
|
||||||
raise serializers.ValidationError("password not specified")
|
raise serializers.ValidationError("password not specified")
|
||||||
@@ -2223,6 +2247,8 @@ class BulkEditSerializer(
|
|||||||
self.validate_parameters_remove_password(parameters)
|
self.validate_parameters_remove_password(parameters)
|
||||||
elif method == bulk_edit.reprocess:
|
elif method == bulk_edit.reprocess:
|
||||||
self._validate_parameters_reprocess(parameters)
|
self._validate_parameters_reprocess(parameters)
|
||||||
|
elif method == bulk_edit.run_workflow:
|
||||||
|
self._validate_parameters_run_workflow(parameters)
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|||||||
@@ -862,6 +862,8 @@ def run_workflows(
|
|||||||
logging_group: uuid.UUID | None = None,
|
logging_group: uuid.UUID | None = None,
|
||||||
overrides: DocumentMetadataOverrides | None = None,
|
overrides: DocumentMetadataOverrides | None = None,
|
||||||
original_file: Path | None = None,
|
original_file: Path | None = None,
|
||||||
|
*,
|
||||||
|
skip_matching: bool = False,
|
||||||
) -> tuple[DocumentMetadataOverrides, str] | None:
|
) -> tuple[DocumentMetadataOverrides, str] | None:
|
||||||
"""
|
"""
|
||||||
Execute workflows matching a document for the given trigger. When `overrides` is provided
|
Execute workflows matching a document for the given trigger. When `overrides` is provided
|
||||||
@@ -872,6 +874,10 @@ def run_workflows(
|
|||||||
`document.source_path` (Document) or `document.original_file` (ConsumableDocument).
|
`document.source_path` (Document) or `document.original_file` (ConsumableDocument).
|
||||||
|
|
||||||
Passing `workflow_to_run` skips the workflow query (currently only used by scheduled runs).
|
Passing `workflow_to_run` skips the workflow query (currently only used by scheduled runs).
|
||||||
|
|
||||||
|
Passing `skip_matching` runs the workflow's actions without evaluating its trigger
|
||||||
|
filters against the document. It is only meaningful together with `workflow_to_run`
|
||||||
|
(e.g. a manual run the user has explicitly forced onto the selected documents).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use_overrides = overrides is not None
|
use_overrides = overrides is not None
|
||||||
@@ -921,7 +927,11 @@ def run_workflows(
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
if matching.document_matches_workflow(document, workflow, trigger_type):
|
if skip_matching or matching.document_matches_workflow(
|
||||||
|
document,
|
||||||
|
workflow,
|
||||||
|
trigger_type,
|
||||||
|
):
|
||||||
action: WorkflowAction
|
action: WorkflowAction
|
||||||
has_move_to_trash_action = False
|
has_move_to_trash_action = False
|
||||||
for action in workflow.actions.order_by("order", "pk"):
|
for action in workflow.actions.order_by("order", "pk"):
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from documents.models import ShareLink
|
|||||||
from documents.models import ShareLinkBundle
|
from documents.models import ShareLinkBundle
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.models import Workflow
|
||||||
from documents.models import WorkflowRun
|
from documents.models import WorkflowRun
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.plugins.base import ConsumeTaskPlugin
|
from documents.plugins.base import ConsumeTaskPlugin
|
||||||
@@ -690,6 +691,47 @@ def check_scheduled_workflows() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def run_workflows_for_documents(
|
||||||
|
document_ids: list[int],
|
||||||
|
workflow_id: int,
|
||||||
|
*,
|
||||||
|
filter_documents: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Run a single workflow manually against the given documents.
|
||||||
|
|
||||||
|
``filter_documents`` controls whether the workflow's trigger filters are applied.
|
||||||
|
When True, only documents matching the existing filters will have the workflow run.
|
||||||
|
When False, the workflow will be run on all specified documents regardless of the filters.
|
||||||
|
``
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
workflow = Workflow.objects.get(id=workflow_id)
|
||||||
|
except Workflow.DoesNotExist:
|
||||||
|
logger.warning(
|
||||||
|
f"Manual workflow run requested for missing workflow {workflow_id}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for document in Document.objects.filter(
|
||||||
|
id__in=document_ids,
|
||||||
|
root_document__isnull=True,
|
||||||
|
):
|
||||||
|
run_workflows(
|
||||||
|
trigger_type=WorkflowTrigger.WorkflowTriggerType.MANUAL,
|
||||||
|
document=document,
|
||||||
|
workflow_to_run=workflow,
|
||||||
|
skip_matching=not filter_documents,
|
||||||
|
)
|
||||||
|
# Manual runs dont send a document_updated signal, so push a websocket
|
||||||
|
# update here to ensure clients are refreshed
|
||||||
|
send_websocket_document_updated(
|
||||||
|
sender=None,
|
||||||
|
document=document,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
||||||
"""
|
"""
|
||||||
When a tag's parent changes, ensure all documents containing the tag also have
|
When a tag's parent changes, ensure all documents containing the tag also have
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.models import Workflow
|
||||||
|
from documents.models import WorkflowAction
|
||||||
|
from documents.models import WorkflowTrigger
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.permissions import grant_all_global
|
from paperless_testing.permissions import grant_all_global
|
||||||
from paperless_testing.permissions import grant_global
|
from paperless_testing.permissions import grant_global
|
||||||
@@ -583,6 +587,85 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(args[0], [self.doc1.id])
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(kwargs, {"remote_ocr": True})
|
self.assertEqual(kwargs, {"remote_ocr": True})
|
||||||
|
|
||||||
|
def _make_manual_workflow(self) -> Workflow:
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.MANUAL,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create()
|
||||||
|
workflow = Workflow.objects.create(name="Manual workflow", order=0)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
return workflow
|
||||||
|
|
||||||
|
def _run_workflow(self, parameters, documents=None):
|
||||||
|
return self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": documents or [self.doc1.id],
|
||||||
|
"method": "run_workflow",
|
||||||
|
"parameters": parameters,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("documents.views.bulk_edit.run_workflow")
|
||||||
|
def test_run_workflow(self, m) -> None:
|
||||||
|
"""A valid run_workflow call passes the docs, workflow id and filter flag through."""
|
||||||
|
self.setup_mock(m, "run_workflow")
|
||||||
|
workflow = self._make_manual_workflow()
|
||||||
|
|
||||||
|
response = self._run_workflow({"workflow": workflow.id})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
args, kwargs = m.call_args
|
||||||
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
|
self.assertEqual(kwargs, {"workflow": workflow.id, "filter_documents": True})
|
||||||
|
|
||||||
|
self._run_workflow({"workflow": workflow.id, "filter_documents": False})
|
||||||
|
self.assertFalse(m.call_args.kwargs["filter_documents"])
|
||||||
|
|
||||||
|
@mock.patch("documents.views.bulk_edit.run_workflow")
|
||||||
|
def test_run_workflow_invalid_parameters(self, m) -> None:
|
||||||
|
"""Invalid run_workflow parameters are rejected before dispatch."""
|
||||||
|
self.setup_mock(m, "run_workflow")
|
||||||
|
manual = self._make_manual_workflow()
|
||||||
|
non_manual = Workflow.objects.create(name="Non-manual", order=0)
|
||||||
|
non_manual.triggers.add(
|
||||||
|
WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for label, parameters in (
|
||||||
|
("missing workflow", {}),
|
||||||
|
("nonexistent workflow", {"workflow": 9999}),
|
||||||
|
("no manual trigger", {"workflow": non_manual.id}),
|
||||||
|
("non-bool filter", {"workflow": manual.id, "filter_documents": "nope"}),
|
||||||
|
):
|
||||||
|
with self.subTest(label):
|
||||||
|
response = self._run_workflow(parameters)
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.views.bulk_edit.run_workflow")
|
||||||
|
def test_run_workflow_requires_ownership(self, m) -> None:
|
||||||
|
"""A non-owner without document ownership is forbidden (actions mutate docs)."""
|
||||||
|
self.setup_mock(m, "run_workflow")
|
||||||
|
user = UserFactory(username="no_perms")
|
||||||
|
grant_all_global(user)
|
||||||
|
doc = DocumentFactory(owner=UserFactory(username="other"))
|
||||||
|
workflow = self._make_manual_workflow()
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = self._run_workflow({"workflow": workflow.id}, documents=[doc.id])
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_set_storage_path(self, m) -> None:
|
def test_api_set_storage_path(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -1141,8 +1224,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertCountEqual(
|
self.assertCountEqual(
|
||||||
response.data["selected_document_types"],
|
response.data["selected_document_types"],
|
||||||
[
|
[
|
||||||
{"id": self.dt1.id, "document_count": 1},
|
{"id": self.c1.id, "document_count": 1},
|
||||||
{"id": self.dt2.id, "document_count": 0},
|
{"id": self.c2.id, "document_count": 0},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ from documents.models import WorkflowTrigger
|
|||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.tests.utils import ConsumeTaskMixin
|
from documents.tests.utils import ConsumeTaskMixin
|
||||||
from documents.views import DocumentViewSet
|
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import TagFactory
|
from paperless_testing.factories import TagFactory
|
||||||
@@ -1756,106 +1755,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
}
|
}
|
||||||
self.assertEqual(counts_by_field[custom_field.id], 1)
|
self.assertEqual(counts_by_field[custom_field.id], 1)
|
||||||
|
|
||||||
def test_selection_data_counts_with_shared_by_filter(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Several documents owned by one user with the same correspondent
|
|
||||||
and document type, only one of which is shared
|
|
||||||
WHEN:
|
|
||||||
- Requesting the document list filtered by shared_by__id with
|
|
||||||
include_selection_data=true
|
|
||||||
THEN:
|
|
||||||
- Counts reflect only the shared document, not every document the
|
|
||||||
owner has with that correspondent or document type, regardless of
|
|
||||||
the batch size
|
|
||||||
"""
|
|
||||||
owner = User.objects.create_user(username="owner")
|
|
||||||
other = User.objects.create_user(username="other")
|
|
||||||
correspondent = Correspondent.objects.create(name="c1")
|
|
||||||
doc_type = DocumentType.objects.create(name="dt1")
|
|
||||||
tag = Tag.objects.create(name="tag")
|
|
||||||
docs = [
|
|
||||||
Document.objects.create(
|
|
||||||
checksum=str(i),
|
|
||||||
owner=owner,
|
|
||||||
correspondent=correspondent,
|
|
||||||
document_type=doc_type,
|
|
||||||
)
|
|
||||||
for i in range(3)
|
|
||||||
]
|
|
||||||
for doc in docs:
|
|
||||||
doc.tags.add(tag)
|
|
||||||
grant_object(other, docs[0], "view_document")
|
|
||||||
|
|
||||||
for batch_size in (10_000, 1):
|
|
||||||
with (
|
|
||||||
self.subTest(batch_size=batch_size),
|
|
||||||
mock.patch.object(
|
|
||||||
DocumentViewSet,
|
|
||||||
"selection_data_batch_size",
|
|
||||||
batch_size,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/?shared_by__id={owner.id}"
|
|
||||||
"&include_selection_data=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.data["count"], 1)
|
|
||||||
selection_data = response.data["selection_data"]
|
|
||||||
for key, obj in (
|
|
||||||
("selected_correspondents", correspondent),
|
|
||||||
("selected_document_types", doc_type),
|
|
||||||
("selected_tags", tag),
|
|
||||||
):
|
|
||||||
counts = {
|
|
||||||
item["id"]: item["document_count"]
|
|
||||||
for item in selection_data[key]
|
|
||||||
}
|
|
||||||
self.assertEqual(counts[obj.id], 1, key)
|
|
||||||
|
|
||||||
def test_selection_data_counts_documents_once_when_filter_joins(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document matching a multi-valued filter through two tags
|
|
||||||
WHEN:
|
|
||||||
- Requesting the document list with include_selection_data=true
|
|
||||||
THEN:
|
|
||||||
- The document is counted once per correspondent, not once per
|
|
||||||
matching tag, regardless of the batch size
|
|
||||||
"""
|
|
||||||
correspondent = Correspondent.objects.create(name="c1")
|
|
||||||
tag_a = Tag.objects.create(name="a")
|
|
||||||
tag_b = Tag.objects.create(name="b")
|
|
||||||
both = Document.objects.create(checksum="1", correspondent=correspondent)
|
|
||||||
both.tags.add(tag_a, tag_b)
|
|
||||||
only_a = Document.objects.create(checksum="2", correspondent=correspondent)
|
|
||||||
only_a.tags.add(tag_a)
|
|
||||||
|
|
||||||
for batch_size in (10_000, 1):
|
|
||||||
with (
|
|
||||||
self.subTest(batch_size=batch_size),
|
|
||||||
mock.patch.object(
|
|
||||||
DocumentViewSet,
|
|
||||||
"selection_data_batch_size",
|
|
||||||
batch_size,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/?tags__id__in={tag_a.id},{tag_b.id}"
|
|
||||||
"&include_selection_data=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
counts = {
|
|
||||||
item["id"]: item["document_count"]
|
|
||||||
for item in response.data["selection_data"][
|
|
||||||
"selected_correspondents"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
self.assertEqual(counts[correspondent.id], 2)
|
|
||||||
|
|
||||||
def test_statistics(self) -> None:
|
def test_statistics(self) -> None:
|
||||||
doc1 = Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
title="none1",
|
title="none1",
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from documents.models import Tag
|
|||||||
from documents.models import Workflow
|
from documents.models import Workflow
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
from documents.search import reset_backend
|
from documents.search import reset_backend
|
||||||
from documents.views import DocumentViewSet
|
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
@@ -346,39 +345,38 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
storage_path = StoragePath.objects.create(name="sp1")
|
storage_path = StoragePath.objects.create(name="sp1")
|
||||||
tag = Tag.objects.create(name="tag")
|
tag = Tag.objects.create(name="tag")
|
||||||
|
|
||||||
for checksum, title in (
|
matching_doc = Document.objects.create(
|
||||||
("A", "bank statement"),
|
title="bank statement",
|
||||||
("B", "bank account"),
|
content="bank content",
|
||||||
("C", "credit card"),
|
checksum="A",
|
||||||
):
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title=title,
|
|
||||||
checksum=checksum,
|
|
||||||
correspondent=correspondent,
|
correspondent=correspondent,
|
||||||
document_type=doc_type,
|
document_type=doc_type,
|
||||||
storage_path=storage_path,
|
storage_path=storage_path,
|
||||||
)
|
)
|
||||||
doc.tags.add(tag)
|
matching_doc.tags.add(tag)
|
||||||
get_backend().add_or_update(doc)
|
|
||||||
|
get_backend().add_or_update(matching_doc)
|
||||||
|
|
||||||
with mock.patch.object(DocumentViewSet, "selection_data_batch_size", 1):
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
"/api/documents/?query=bank&page_size=1&include_selection_data=true",
|
"/api/documents/?query=bank&include_selection_data=true",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["count"], 2)
|
self.assertIn("selection_data", response.data)
|
||||||
self.assertEqual(len(response.data["results"]), 1)
|
|
||||||
for key, pk in (
|
selected_correspondent = next(
|
||||||
("selected_correspondents", correspondent.pk),
|
item
|
||||||
("selected_document_types", doc_type.pk),
|
for item in response.data["selection_data"]["selected_correspondents"]
|
||||||
("selected_storage_paths", storage_path.pk),
|
if item["id"] == correspondent.id
|
||||||
("selected_tags", tag.pk),
|
|
||||||
):
|
|
||||||
self.assertEqual(
|
|
||||||
response.data["selection_data"][key],
|
|
||||||
[{"id": pk, "document_count": 2}],
|
|
||||||
)
|
)
|
||||||
|
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:
|
def test_search_custom_field_ordering(self) -> None:
|
||||||
custom_field = CustomField.objects.create(
|
custom_field = CustomField.objects.create(
|
||||||
|
|||||||
@@ -100,6 +100,30 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
self.action.assign_correspondent.pk,
|
self.action.assign_correspondent.pk,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_api_get_workflows_filter_trigger_type(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A consumption workflow and a manual workflow
|
||||||
|
WHEN:
|
||||||
|
- Workflows are requested filtered by trigger_type
|
||||||
|
THEN:
|
||||||
|
- Only workflows with a matching trigger are returned
|
||||||
|
"""
|
||||||
|
manual_trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.MANUAL,
|
||||||
|
)
|
||||||
|
manual_workflow = Workflow.objects.create(name="Manual", order=1)
|
||||||
|
manual_workflow.triggers.add(manual_trigger)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"{self.ENDPOINT}?trigger_type={WorkflowTrigger.WorkflowTriggerType.MANUAL}",
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["count"], 1)
|
||||||
|
self.assertEqual(response.data["results"][0]["id"], manual_workflow.id)
|
||||||
|
|
||||||
def test_api_get_workflow_actions_ordered(self) -> None:
|
def test_api_get_workflow_actions_ordered(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -2288,6 +2288,56 @@ class TestWorkflows(
|
|||||||
doc.refresh_from_db()
|
doc.refresh_from_db()
|
||||||
self.assertEqual(doc.owner, self.user2)
|
self.assertEqual(doc.owner, self.user2)
|
||||||
|
|
||||||
|
def _make_manual_workflow(self, *, require_tag: Tag | None = None) -> Workflow:
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.MANUAL,
|
||||||
|
)
|
||||||
|
if require_tag is not None:
|
||||||
|
trigger.filter_has_all_tags.add(require_tag)
|
||||||
|
action = WorkflowAction.objects.create(assign_owner=self.user2)
|
||||||
|
workflow = Workflow.objects.create(name="Manual workflow", order=0)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
return workflow
|
||||||
|
|
||||||
|
@mock.patch("documents.tasks.send_websocket_document_updated")
|
||||||
|
def test_manual_workflow_run_applies_and_notifies(self, mock_ws) -> None:
|
||||||
|
"""A manual run applies the workflow's actions and pushes a websocket update."""
|
||||||
|
workflow = self._make_manual_workflow()
|
||||||
|
doc = DocumentFactory()
|
||||||
|
|
||||||
|
tasks.run_workflows_for_documents([doc.pk], workflow.pk)
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.owner, self.user2)
|
||||||
|
self.assertEqual(mock_ws.call_args.kwargs["document"].pk, doc.pk)
|
||||||
|
|
||||||
|
def test_manual_workflow_run_respects_filters(self) -> None:
|
||||||
|
"""With filter_documents=True, a document failing the trigger filters is skipped."""
|
||||||
|
workflow = self._make_manual_workflow(require_tag=self.t1)
|
||||||
|
doc = DocumentFactory()
|
||||||
|
|
||||||
|
tasks.run_workflows_for_documents([doc.pk], workflow.pk, filter_documents=True)
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertIsNone(doc.owner)
|
||||||
|
|
||||||
|
def test_manual_workflow_run_force_ignores_filters(self) -> None:
|
||||||
|
"""With filter_documents=False, actions run regardless of the trigger filters."""
|
||||||
|
workflow = self._make_manual_workflow(require_tag=self.t1)
|
||||||
|
doc = DocumentFactory()
|
||||||
|
|
||||||
|
tasks.run_workflows_for_documents([doc.pk], workflow.pk, filter_documents=False)
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
self.assertEqual(doc.owner, self.user2)
|
||||||
|
|
||||||
|
def test_manual_workflow_run_missing_workflow(self) -> None:
|
||||||
|
"""A missing workflow id logs a warning rather than raising."""
|
||||||
|
doc = DocumentFactory()
|
||||||
|
with self.assertLogs("paperless.tasks", level="WARNING"):
|
||||||
|
tasks.run_workflows_for_documents([doc.pk], 9999)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.send_websocket_document_updated")
|
@mock.patch("documents.tasks.send_websocket_document_updated")
|
||||||
def test_workflow_scheduled_trigger_sends_websocket_update(
|
def test_workflow_scheduled_trigger_sends_websocket_update(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+72
-74
@@ -5,13 +5,11 @@ import platform
|
|||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import Counter
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from itertools import islice
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import mktime
|
from time import mktime
|
||||||
from time import sleep
|
from time import sleep
|
||||||
@@ -142,6 +140,7 @@ from documents.filters import ShareLinkFilterSet
|
|||||||
from documents.filters import StoragePathFilterSet
|
from documents.filters import StoragePathFilterSet
|
||||||
from documents.filters import TagFilterSet
|
from documents.filters import TagFilterSet
|
||||||
from documents.filters import TitleContentFilter
|
from documents.filters import TitleContentFilter
|
||||||
|
from documents.filters import WorkflowFilterSet
|
||||||
from documents.mail import EmailAttachment
|
from documents.mail import EmailAttachment
|
||||||
from documents.mail import send_email
|
from documents.mail import send_email
|
||||||
from documents.matching import match_correspondents
|
from documents.matching import match_correspondents
|
||||||
@@ -170,6 +169,7 @@ from documents.permissions import PaperlessNotePermissions
|
|||||||
from documents.permissions import PaperlessObjectPermissions
|
from documents.permissions import PaperlessObjectPermissions
|
||||||
from documents.permissions import TrashPermissions
|
from documents.permissions import TrashPermissions
|
||||||
from documents.permissions import ViewDocumentsPermissions
|
from documents.permissions import ViewDocumentsPermissions
|
||||||
|
from documents.permissions import annotate_document_count_by_ids
|
||||||
from documents.permissions import annotate_document_count_for_related_queryset
|
from documents.permissions import annotate_document_count_for_related_queryset
|
||||||
from documents.permissions import get_document_count_filter_for_user
|
from documents.permissions import get_document_count_filter_for_user
|
||||||
from documents.permissions import get_permission_grantees
|
from documents.permissions import get_permission_grantees
|
||||||
@@ -1015,80 +1015,71 @@ class DocumentViewSet(
|
|||||||
"custom_field_",
|
"custom_field_",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bound the IN clauses used for search results and through-table lookups.
|
def _get_selection_data_for_queryset(self, queryset):
|
||||||
selection_data_batch_size = 10_000
|
# Resolve once instead of once per model below. `queryset` can carry an
|
||||||
|
# arbitrarily expensive WHERE clause (user filters plus the permission
|
||||||
|
# filter); re-embedding it as a subquery inside 5 separate Count(...)
|
||||||
|
# calls forces the database to re-evaluate that whole thing 5 times, and
|
||||||
|
# -- for FK relations especially -- can defeat semi-join planning
|
||||||
|
# entirely at scale. A concrete id list is cheap to reuse.
|
||||||
|
# order_by() drops the default/user ordering -- irrelevant for a plain
|
||||||
|
# id list, but left in place it forces a sort over the full filtered
|
||||||
|
# set before the ids can even be collected.
|
||||||
|
document_ids = list(queryset.order_by().values_list("pk", flat=True))
|
||||||
|
|
||||||
def _get_selection_data_for_queryset(
|
correspondents = Correspondent.objects.annotate(
|
||||||
self,
|
document_count=Count(
|
||||||
queryset: QuerySet[Document],
|
"documents",
|
||||||
*,
|
filter=Q(documents__id__in=document_ids),
|
||||||
search_ids: list[int] | None = None,
|
distinct=True,
|
||||||
) -> dict[str, list[dict[str, int]]]:
|
|
||||||
# Keep permission and filter predicates at document granularity.
|
|
||||||
# Reusing the filtered queryset as a subquery for each count is slow.
|
|
||||||
rows = (
|
|
||||||
queryset.order_by()
|
|
||||||
.prefetch_related(None)
|
|
||||||
.values_list(
|
|
||||||
"pk",
|
|
||||||
"correspondent_id",
|
|
||||||
"document_type_id",
|
|
||||||
"storage_path_id",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
batch_size = self.selection_data_batch_size
|
|
||||||
|
|
||||||
def document_rows():
|
|
||||||
if search_ids is None:
|
|
||||||
yield from rows.iterator()
|
|
||||||
else:
|
|
||||||
for start in range(0, len(search_ids), batch_size):
|
|
||||||
yield from rows.filter(
|
|
||||||
pk__in=search_ids[start : start + batch_size],
|
|
||||||
).iterator()
|
|
||||||
|
|
||||||
correspondents: Counter[int | None] = Counter()
|
|
||||||
document_types: Counter[int | None] = Counter()
|
|
||||||
storage_paths: Counter[int | None] = Counter()
|
|
||||||
document_ids: set[int] = set()
|
|
||||||
for pk, correspondent, document_type, storage_path in document_rows():
|
|
||||||
# Filters that join multi-valued relations can repeat a document.
|
|
||||||
if pk in document_ids:
|
|
||||||
continue
|
|
||||||
document_ids.add(pk)
|
|
||||||
correspondents[correspondent] += 1
|
|
||||||
document_types[document_type] += 1
|
|
||||||
storage_paths[storage_path] += 1
|
|
||||||
|
|
||||||
tags: Counter[int | None] = Counter()
|
|
||||||
custom_fields: Counter[int | None] = Counter()
|
|
||||||
ids = iter(document_ids)
|
|
||||||
while batch := list(islice(ids, batch_size)):
|
|
||||||
for model, field, counts in (
|
|
||||||
(Document.tags.through, "tag_id", tags),
|
|
||||||
(CustomFieldInstance, "field_id", custom_fields),
|
|
||||||
):
|
|
||||||
counts.update(
|
|
||||||
dict(
|
|
||||||
model.objects.filter(document_id__in=batch)
|
|
||||||
.order_by()
|
|
||||||
.values_list(field)
|
|
||||||
.annotate(document_count=Count("document_id")),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
document_types = DocumentType.objects.annotate(
|
||||||
def selection(model: type[Model], counts: Counter[int | None]):
|
document_count=Count(
|
||||||
return [
|
"documents",
|
||||||
{"id": pk, "document_count": counts[pk]}
|
filter=Q(documents__id__in=document_ids),
|
||||||
for pk in model._default_manager.values_list("pk", flat=True)
|
distinct=True,
|
||||||
]
|
),
|
||||||
|
)
|
||||||
|
storage_paths = StoragePath.objects.annotate(
|
||||||
|
document_count=Count(
|
||||||
|
"documents",
|
||||||
|
filter=Q(documents__id__in=document_ids),
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Tag and CustomField reach Document through an M2M/through-model table;
|
||||||
|
# a plain Count(filter=...) there is a much more expensive plan than the
|
||||||
|
# FK relations above once the bridge table is large -- see
|
||||||
|
# annotate_document_count_by_ids() for why.
|
||||||
|
tags = annotate_document_count_by_ids(
|
||||||
|
Tag.objects.all(),
|
||||||
|
through_model=Document.tags.through,
|
||||||
|
related_object_field="tag_id",
|
||||||
|
document_ids=document_ids,
|
||||||
|
)
|
||||||
|
custom_fields = annotate_document_count_by_ids(
|
||||||
|
CustomField.objects.all(),
|
||||||
|
through_model=CustomFieldInstance,
|
||||||
|
related_object_field="field_id",
|
||||||
|
document_ids=document_ids,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"selected_correspondents": selection(Correspondent, correspondents),
|
"selected_correspondents": [
|
||||||
"selected_tags": selection(Tag, tags),
|
{"id": t.id, "document_count": t.document_count} for t in correspondents
|
||||||
"selected_document_types": selection(DocumentType, document_types),
|
],
|
||||||
"selected_storage_paths": selection(StoragePath, storage_paths),
|
"selected_tags": [
|
||||||
"selected_custom_fields": selection(CustomField, custom_fields),
|
{"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
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -2735,10 +2726,13 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
if get_boolean(
|
if get_boolean(
|
||||||
str(request.query_params.get("include_selection_data", "false")),
|
str(request.query_params.get("include_selection_data", "false")),
|
||||||
):
|
):
|
||||||
|
# NOTE: pk__in=ordered_ids generates a large SQL IN clause
|
||||||
|
# for big result sets. Acceptable today but may need a temp
|
||||||
|
# table or chunked approach if selection_data becomes slow
|
||||||
|
# at scale (tens of thousands of matching documents).
|
||||||
response.data["selection_data"] = (
|
response.data["selection_data"] = (
|
||||||
self._get_selection_data_for_queryset(
|
self._get_selection_data_for_queryset(
|
||||||
filtered_qs,
|
filtered_qs.filter(pk__in=result.ordered_ids),
|
||||||
search_ids=result.ordered_ids,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
@@ -3027,6 +3021,7 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
bulk_edit.edit_pdf,
|
bulk_edit.edit_pdf,
|
||||||
bulk_edit.merge_as_versions,
|
bulk_edit.merge_as_versions,
|
||||||
bulk_edit.remove_password,
|
bulk_edit.remove_password,
|
||||||
|
bulk_edit.run_workflow,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
@@ -3166,6 +3161,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
"edit_pdf": None,
|
"edit_pdf": None,
|
||||||
"reprocess": "checksum",
|
"reprocess": "checksum",
|
||||||
"remove_password": None,
|
"remove_password": None,
|
||||||
|
"run_workflow": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
serializer_class = BulkEditSerializer
|
serializer_class = BulkEditSerializer
|
||||||
@@ -5196,6 +5192,8 @@ class WorkflowViewSet(ModelViewSet[Workflow]):
|
|||||||
|
|
||||||
serializer_class = WorkflowSerializer
|
serializer_class = WorkflowSerializer
|
||||||
pagination_class = StandardPagination
|
pagination_class = StandardPagination
|
||||||
|
filter_backends = (DjangoFilterBackend,)
|
||||||
|
filterset_class = WorkflowFilterSet
|
||||||
|
|
||||||
model = Workflow
|
model = Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-11-07 02:45+0000\n"
|
"POT-Creation-Date: 2026-11-06 20:19+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1661,8 +1661,8 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:483 documents/serialisers.py:844
|
#: documents/serialisers.py:483 documents/serialisers.py:844
|
||||||
#: documents/serialisers.py:2850 documents/views.py:349 documents/views.py:615
|
#: documents/serialisers.py:2850 documents/views.py:348 documents/views.py:614
|
||||||
#: documents/views.py:2755 paperless_mail/serialisers.py:156
|
#: documents/views.py:2748 paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1702,7 +1702,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2936 documents/views.py:4805
|
#: documents/serialisers.py:2936 documents/views.py:4798
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1953,40 +1953,40 @@ msgstr ""
|
|||||||
msgid ", "
|
msgid ", "
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:304
|
#: documents/views.py:303
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:342 documents/views.py:2752
|
#: documents/views.py:341 documents/views.py:2745
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1679
|
#: documents/views.py:1669
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1690
|
#: documents/views.py:1680
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1702
|
#: documents/views.py:1692
|
||||||
msgid "AI backend rejected the request. Check logs for details."
|
msgid "AI backend rejected the request. Check logs for details."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4821
|
#: documents/views.py:4814
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4868
|
#: documents/views.py:4861
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4934
|
#: documents/views.py:4927
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4948
|
#: documents/views.py:4941
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user