diff --git a/docs/usage.md b/docs/usage.md index 12d2fcb2b4..541e37c1f6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -493,7 +493,7 @@ fields and permissions, which will be merged. #### 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 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 added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive offsets will trigger after the date, negative offsets will trigger before). +5. **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 flowchart TD @@ -526,6 +528,10 @@ flowchart TD matching trigger(s)"} + manual{"Matching + 'Manual' + trigger(s)"} + A[New Document] --> consumption consumption --> |Yes| C[Workflow Actions Run] consumption --> |No| D @@ -543,6 +549,10 @@ flowchart TD scheduled --> |Yes| N[Workflow Actions Run] scheduled --> |No| O[Document Saved] N --> O + P[User Runs Workflow] --> manual + manual --> |Yes| Q[Workflow Actions Run] + manual --> |No| R[Document Saved] + Q --> R ``` #### 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 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. -- 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. - 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. - 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 #### 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 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 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. diff --git a/src-ui/src/app/components/common/bootstrap-icon/icons.ts b/src-ui/src/app/components/common/bootstrap-icon/icons.ts index b3086aafef..e1339211bb 100644 --- a/src-ui/src/app/components/common/bootstrap-icon/icons.ts +++ b/src-ui/src/app/components/common/bootstrap-icon/icons.ts @@ -118,6 +118,7 @@ import personFill from 'bootstrap-icons/icons/person-fill.svg' import personLock from 'bootstrap-icons/icons/person-lock.svg' import personSquare from 'bootstrap-icons/icons/person-square.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 plusCircle from 'bootstrap-icons/icons/plus-circle.svg' import plus from 'bootstrap-icons/icons/plus.svg' @@ -279,6 +280,7 @@ export const icons: Record = { personFillLock, personLock, personSquare, + playCircle, playFill, plus, plusCircle, diff --git a/src-ui/src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html new file mode 100644 index 0000000000..e46d3454be --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html @@ -0,0 +1,32 @@ + + + diff --git a/src-ui/src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.spec.ts b/src-ui/src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.spec.ts new file mode 100644 index 0000000000..0468cf64b3 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.spec.ts @@ -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 + + 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') + }) +}) diff --git a/src-ui/src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.ts b/src-ui/src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.ts new file mode 100644 index 0000000000..12915141e8 --- /dev/null +++ b/src-ui/src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.ts @@ -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 + } +} diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index f515754702..538aa1a76a 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -164,7 +164,7 @@ } - @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) { @if (matchingPatternRequired(formGroup)) { @@ -175,7 +175,7 @@ } - @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) {
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 4a89b22778..6b1b80bd03 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -123,6 +123,10 @@ export const WORKFLOW_TYPE_OPTIONS = [ id: WorkflowTriggerType.Scheduled, name: $localize`Scheduled`, }, + { + id: WorkflowTriggerType.Manual, + name: $localize`Manual`, + }, ] export const WORKFLOW_ACTION_OPTIONS = [ diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 00267e76d7..630a97afbe 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -49,6 +49,12 @@ Reprocess + @if (manualWorkflows().length > 0) { + + } + diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 26a42336a2..4c766077a5 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1030,6 +1030,27 @@ describe('DocumentDetailComponent', () => { 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', () => { initNormally() const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 561fc6b681..e3516f4cad 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -68,6 +68,8 @@ import { StoragePath } from 'src/app/data/storage-path' import { Tag } from 'src/app/data/tag' import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings' 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 { CustomDatePipe } from 'src/app/pipes/custom-date.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 { TagService } from 'src/app/services/rest/tag.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 { ToastService } from 'src/app/services/toast.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 { 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 { 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 { 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' @@ -233,6 +237,7 @@ export class DocumentDetailComponent private componentRouterService = inject(ComponentRouterService) private deviceDetectorService = inject(DeviceDetectorService) private savedViewService = inject(SavedViewService) + private readonly workflowService = inject(WorkflowService) private readonly websocketStatusService = inject(WebsocketStatusService) private readonly useNativePdfViewerSetting = this.settings.getSignal( SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER @@ -295,6 +300,7 @@ export class DocumentDetailComponent readonly correspondents = signal(undefined) readonly documentTypes = signal(undefined) readonly storagePaths = signal(undefined) + readonly manualWorkflows = signal([]) documentForm: FormGroup = new FormGroup({ title: new FormControl(''), @@ -836,6 +842,20 @@ export class DocumentDetailComponent .pipe(first(), takeUntil(this.unsubscribeNotifier)) .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, + enabled: true, + }) + .pipe(first(), takeUntil(this.unsubscribeNotifier)) + .subscribe((result) => this.manualWorkflows.set(result.results)) + } this.getCustomFields() @@ -1559,6 +1579,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 { const versions = this.document()?.versions ?? [] if (!versions.length || !this.selectedVersionId()) { diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html index 1b96851515..5e9aff223a 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -98,6 +98,11 @@ + @if (manualWorkflows.length > 0) { + + }
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index 34540ac687..e78545bb80 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -17,6 +17,7 @@ import { FILTER_TITLE } from 'src/app/data/filter-rule-type' import { Results } from 'src/app/data/results' import { StoragePath } from 'src/app/data/storage-path' import { Tag } from 'src/app/data/tag' +import { WorkflowTriggerType } from 'src/app/data/workflow-trigger' import { FilterPipe } from 'src/app/pipes/filter.pipe' import { DocumentListViewService } from 'src/app/services/document-list-view.service' 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 { TagService } from 'src/app/services/rest/tag.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 { ToastService } from 'src/app/services/toast.service' import { TrashService } from 'src/app/services/trash.service' @@ -83,6 +85,7 @@ describe('BulkEditorComponent', () => { let customFieldsService: CustomFieldsService let httpTestingController: HttpTestingController let shareLinkBundleService: ShareLinkBundleService + let workflowService: WorkflowService let router: Router beforeEach(async () => { @@ -173,6 +176,22 @@ describe('BulkEditorComponent', () => { delete: jest.fn(), }, }, + { + provide: WorkflowService, + useValue: { + list: jest.fn(() => + of({ + results: [ + { + id: 5, + name: 'Manual workflow', + triggers: [{ type: WorkflowTriggerType.Manual }], + }, + ], + }) + ), + }, + }, { provide: Router, useValue: { navigate: jest.fn().mockResolvedValue(true) }, @@ -195,6 +214,7 @@ describe('BulkEditorComponent', () => { customFieldsService = TestBed.inject(CustomFieldsService) httpTestingController = TestBed.inject(HttpTestingController) shareLinkBundleService = TestBed.inject(ShareLinkBundleService) + workflowService = TestBed.inject(WorkflowService) router = TestBed.inject(Router) fixture = TestBed.createComponent(BulkEditorComponent) @@ -654,6 +674,51 @@ describe('BulkEditorComponent', () => { ) }) + 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, + enabled: true, + }) + 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', () => { let modal: NgbModalRef modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 94f742366d..066f941bff 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -34,6 +34,7 @@ import { CustomField } from 'src/app/data/custom-field' import { MatchingModel } from 'src/app/data/matching-model' import { SelectionDataItem } from 'src/app/data/results' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' +import { Workflow } from 'src/app/data/workflow' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service' @@ -53,6 +54,7 @@ import { } from 'src/app/services/rest/document.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' +import { WorkflowService } from 'src/app/services/rest/workflow.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { TagService } from 'src/app/services/rest/tag.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 { 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 { 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 { 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' @@ -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 { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component' +import { WorkflowTriggerType } from 'src/app/data/workflow-trigger' @Component({ selector: 'pngx-bulk-editor', @@ -114,8 +118,11 @@ export class BulkEditorComponent public readonly permissionService = inject(PermissionsService) private savedViewService = inject(SavedViewService) private readonly shareLinkBundleService = inject(ShareLinkBundleService) + private readonly workflowService = inject(WorkflowService) private readonly router = inject(Router) + public manualWorkflows: Workflow[] = [] + tagSelectionModel = new FilterableDropdownSelectionModel(true) correspondentSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel() @@ -258,6 +265,20 @@ export class BulkEditorComponent (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, + enabled: true, + }) + .pipe(first()) + .subscribe((result) => (this.manualWorkflows = result.results)) + } this.downloadForm .get('downloadFileTypeArchive') @@ -286,6 +307,28 @@ export class BulkEditorComponent 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( modal: NgbModalRef, method: DocumentBulkEditMethod, diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts index 2bc89f188a..3b01cf562b 100644 --- a/src-ui/src/app/data/workflow-trigger.ts +++ b/src-ui/src/app/data/workflow-trigger.ts @@ -12,6 +12,7 @@ export enum WorkflowTriggerType { DocumentAdded = 2, DocumentUpdated = 3, Scheduled = 4, + Manual = 5, } export enum ScheduleDateField { diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts index ef7a171254..d0822e8b00 100644 --- a/src-ui/src/app/services/rest/document.service.ts +++ b/src-ui/src/app/services/rest/document.service.ts @@ -39,6 +39,7 @@ export type DocumentBulkEditMethod = | 'modify_tags' | 'modify_custom_fields' | 'set_permissions' + | 'run_workflow' export interface MergeDocumentsRequest { metadata_document_id?: number diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 08022df584..13df775c09 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -42,6 +42,7 @@ from documents.plugins.helpers import DocumentsStatusManager from documents.tasks import bulk_update_documents from documents.tasks import consume_file 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.versioning import get_latest_version_for_root from documents.versioning import get_root_document @@ -464,6 +465,25 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["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( doc_ids: list[int], set_permissions: PermissionsSpec, diff --git a/src/documents/filters.py b/src/documents/filters.py index 2c6cfb2699..9c0b476c2c 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -35,6 +35,7 @@ from django_filters.rest_framework import DateTimeFilter from django_filters.rest_framework import Filter from django_filters.rest_framework import FilterSet from django_filters.rest_framework import MultipleChoiceFilter +from django_filters.rest_framework import NumberFilter from drf_spectacular.utils import extend_schema_field from guardian.utils import get_group_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 StoragePath from documents.models import Tag +from documents.models import Workflow from documents.permissions import annotate_authorizing_fields from documents.permissions import permitted_document_ids from documents.permissions import permitted_object_ids @@ -249,6 +251,17 @@ class CustomFieldFilterSet(FilterSet): } +class WorkflowFilterSet(FilterSet): + trigger_type = NumberFilter(field_name="triggers__type", distinct=True) + + class Meta: + model = Workflow + fields = { + "id": ID_KWARGS, + "enabled": ["exact"], + } + + @extend_schema_field(serializers.CharField) class CustomFieldsFilter(Filter): def filter(self, qs, value): diff --git a/src/documents/matching.py b/src/documents/matching.py index 65f52d5672..83360ca47f 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -684,6 +684,7 @@ def document_matches_workflow( trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED or trigger_type == WorkflowTrigger.WorkflowTriggerType.SCHEDULED + or trigger_type == WorkflowTrigger.WorkflowTriggerType.MANUAL ): trigger_matched, reason = existing_document_matches_workflow( document, diff --git a/src/documents/migrations/0029_alter_workflowrun_type_alter_workflowtrigger_type.py b/src/documents/migrations/0029_alter_workflowrun_type_alter_workflowtrigger_type.py new file mode 100644 index 0000000000..024d6ba44e --- /dev/null +++ b/src/documents/migrations/0029_alter_workflowrun_type_alter_workflowtrigger_type.py @@ -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", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index a8a075dd6c..fd5d29671f 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1532,6 +1532,7 @@ class WorkflowTrigger(models.Model): DOCUMENT_ADDED = 2, _("Document Added") DOCUMENT_UPDATED = 3, _("Document Updated") SCHEDULED = 4, _("Scheduled") + MANUAL = 5, _("Manual") class DocumentSourceChoices(models.IntegerChoices): CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder") diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 3fce749904..14193aa2de 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1829,6 +1829,7 @@ class BulkEditSerializer( "modify_tags", "modify_custom_fields", "set_permissions", + "run_workflow", *LEGACY_DOCUMENT_ACTION_METHODS, ], label="Method", @@ -1918,6 +1919,8 @@ class BulkEditSerializer( return bulk_edit.reprocess elif method == "set_permissions": return bulk_edit.set_permissions + elif method == "run_workflow": + return bulk_edit.run_workflow elif method == "rotate": return bulk_edit.rotate elif method == "merge": @@ -2157,6 +2160,35 @@ class BulkEditSerializer( else: 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") + request = self.context.get("request") + user = getattr(request, "user", None) + if user is not None and not user.has_perm("documents.view_workflow"): + raise PermissionDenied( + "Insufficient permissions to run this workflow", + ) + if not workflow.enabled: + raise serializers.ValidationError("Workflow is not enabled") + 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): if "password" not in parameters: raise serializers.ValidationError("password not specified") @@ -2223,6 +2255,8 @@ class BulkEditSerializer( self.validate_parameters_remove_password(parameters) elif method == bulk_edit.reprocess: self._validate_parameters_reprocess(parameters) + elif method == bulk_edit.run_workflow: + self._validate_parameters_run_workflow(parameters) return attrs diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 9b726fdfaf..8297762e97 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -864,6 +864,8 @@ def run_workflows( logging_group: uuid.UUID | None = None, overrides: DocumentMetadataOverrides | None = None, original_file: Path | None = None, + *, + skip_matching: bool = False, ) -> tuple[DocumentMetadataOverrides, str] | None: """ Execute workflows matching a document for the given trigger. When `overrides` is provided @@ -874,6 +876,10 @@ def run_workflows( `document.source_path` (Document) or `document.original_file` (ConsumableDocument). 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 @@ -923,7 +929,11 @@ def run_workflows( ) break - if matching.document_matches_workflow(document, workflow, trigger_type): + if skip_matching or matching.document_matches_workflow( + document, + workflow, + trigger_type, + ): action: WorkflowAction has_move_to_trash_action = False for action in workflow.actions.order_by("order", "pk"): diff --git a/src/documents/tasks.py b/src/documents/tasks.py index 4bb97afc18..b4096d2798 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -53,6 +53,7 @@ from documents.models import ShareLink from documents.models import ShareLinkBundle from documents.models import StoragePath from documents.models import Tag +from documents.models import Workflow from documents.models import WorkflowRun from documents.models import WorkflowTrigger from documents.plugins.base import ConsumeTaskPlugin @@ -691,6 +692,51 @@ 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, + ): + try: + run_workflows( + trigger_type=WorkflowTrigger.WorkflowTriggerType.MANUAL, + document=document, + workflow_to_run=workflow, + skip_matching=not filter_documents, + ) + # Manual runs dont send document_updated signal, so ws here to ensure clients are refreshed + send_websocket_document_updated( + sender=None, + document=document, + ) + except Exception: + logger.exception( + f"Error running workflow {workflow} on document {document.pk}", + ) + + 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 diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index 5f0272aeb8..1f8700a113 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -15,7 +15,11 @@ from documents.models import Document from documents.models import DocumentType from documents.models import StoragePath 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.factories import DocumentFactory from paperless_testing.factories import UserFactory from paperless_testing.permissions import grant_all_global from paperless_testing.permissions import grant_global @@ -622,6 +626,111 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(args[0], [self.doc1.id]) 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, + ), + ) + disabled = Workflow.objects.create( + name="Disabled manual", + order=0, + enabled=False, + ) + disabled.triggers.add( + WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.MANUAL, + ), + ) + for label, parameters in ( + ("missing workflow", {}), + ("nonexistent workflow", {"workflow": 9999}), + ("no manual trigger", {"workflow": non_manual.id}), + ("disabled workflow", {"workflow": disabled.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.views.bulk_edit.run_workflow") + def test_run_workflow_requires_workflow_permission(self, m) -> None: + """Owning the documents is not enough without view_workflow permission.""" + self.setup_mock(m, "run_workflow") + user = UserFactory(username="no_wf_perms") + grant_global(user, "change_document") + doc = DocumentFactory(owner=user) + 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") def test_api_set_storage_path(self, m) -> None: """ diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index 4b5e6eb48d..60fcea92f0 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -100,6 +100,47 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): 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_workflows_filter_enabled(self) -> None: + """ + GIVEN: + - An enabled workflow and a disabled workflow + WHEN: + - Workflows are requested filtered by enabled + THEN: + - Only enabled workflows are returned + """ + Workflow.objects.create(name="Disabled", order=1, enabled=False) + + response = self.client.get(f"{self.ENDPOINT}?enabled=true", format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["id"], self.workflow.id) + def test_api_get_workflow_actions_ordered(self) -> None: """ GIVEN: diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 820882b490..78acc7b382 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -2288,6 +2288,83 @@ class TestWorkflows( doc.refresh_from_db() 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.run_workflows") + def test_manual_workflow_run_continues_after_error( + self, + mock_run_workflows, + mock_ws, + ) -> None: + """A failure on one document does not abort the run for the others.""" + workflow = self._make_manual_workflow() + doc1 = DocumentFactory() + doc2 = DocumentFactory() + + def side_effect(*args, document, **kwargs): + if document.pk == doc1.pk: + raise Exception("boom") + + mock_run_workflows.side_effect = side_effect + + with self.assertLogs("paperless.tasks", level="ERROR"): + tasks.run_workflows_for_documents([doc1.pk, doc2.pk], workflow.pk) + + # Both documents were attempted; the websocket update only fired for the + # one that did not raise. + self.assertEqual(mock_run_workflows.call_count, 2) + ws_doc_ids = [c.kwargs["document"].pk for c in mock_ws.call_args_list] + self.assertEqual(ws_doc_ids, [doc2.pk]) + @mock.patch("documents.tasks.send_websocket_document_updated") def test_workflow_scheduled_trigger_sends_websocket_update( self, diff --git a/src/documents/views.py b/src/documents/views.py index 01db0a1789..ddb7ac8a1c 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -146,6 +146,7 @@ from documents.filters import ShareLinkFilterSet from documents.filters import StoragePathFilterSet from documents.filters import TagFilterSet from documents.filters import TitleContentFilter +from documents.filters import WorkflowFilterSet from documents.mail import EmailAttachment from documents.mail import send_email from documents.matching import match_correspondents @@ -3008,6 +3009,100 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin): ) return Response({"result": "OK", "task_id": task.id}) + def _has_document_permissions( + self, + *, + user: User, + documents: list[int], + method, + parameters: dict[str, Any], + ) -> bool: + if user.is_superuser: + return True + + root_docs = { + get_root_document(doc) + for doc in Document.objects.select_related( + "owner", + "root_document__owner", + ).filter(pk__in=documents) + } + user_is_owner_of_all_documents = all( + (doc.owner == user or doc.owner is None) for doc in root_docs + ) + + # check global and object permissions for all documents + has_perms = ( + user.has_perm( + "documents.change_document", + ) + and not Document.global_objects.filter(pk__in=documents) + .exclude(pk__in=permitted_document_ids(user, perm="change_document")) + .exists() + ) + + # check ownership for methods that change original document + if ( + ( + has_perms + and method + in [ + bulk_edit.set_permissions, + bulk_edit.delete, + bulk_edit.rotate, + bulk_edit.delete_pages, + bulk_edit.edit_pdf, + bulk_edit.merge_as_versions, + bulk_edit.remove_password, + bulk_edit.run_workflow, + ] + ) + or ( + method in [bulk_edit.merge, bulk_edit.split] + and parameters.get("delete_originals") + ) + or (method == bulk_edit.edit_pdf and parameters.get("update_document")) + ): + has_perms = has_perms and user_is_owner_of_all_documents + + # check global add permissions for methods that create documents + if ( + has_perms + and ( + method in [bulk_edit.split, bulk_edit.merge] + or ( + method in [bulk_edit.edit_pdf, bulk_edit.remove_password] + and not parameters.get("update_document") + ) + ) + and not user.has_perm("documents.add_document") + ): + has_perms = False + + # check global delete permissions for methods that delete documents + if ( + has_perms + and ( + method == bulk_edit.delete + # Sources stop being documents of their own, and removing one + # again afterwards needs delete_document + or method == bulk_edit.merge_as_versions + or ( + method in [bulk_edit.merge, bulk_edit.split] + and parameters.get("delete_originals") + ) + or ( + method in [bulk_edit.edit_pdf, bulk_edit.remove_password] + and parameters.get("delete_original") + and not parameters.get("update_document") + ) + ) + and not user.has_perm("documents.delete_document") + ): + has_perms = False + + return has_perms + def _execute_document_action( self, *, @@ -3084,6 +3179,7 @@ class BulkEditView(DocumentOperationPermissionMixin): LEGACY_MODIFIED_FIELDS = { "delete": "deleted_at", "reprocess": "checksum", + "remove_password": None, } serializer_class = BulkEditSerializer @@ -5089,6 +5185,8 @@ class WorkflowViewSet(ModelViewSet[Workflow]): serializer_class = WorkflowSerializer pagination_class = StandardPagination + filter_backends = (DjangoFilterBackend,) + filterset_class = WorkflowFilterSet model = Workflow