mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-13 09:35:23 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f4ffbdb5 | ||
|
|
bfd5628dcc | ||
|
|
3dbeca603e | ||
|
|
62d729e301 |
No files matched your search
@@ -8,7 +8,7 @@ ARG DEBIAN_FRONTEND=noninteractive
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
# Can be workflow provided, defaults set for manual building
|
# Can be workflow provided, defaults set for manual building
|
||||||
ARG JBIG2ENC_VERSION=0.32
|
ARG JBIG2ENC_VERSION=0.30
|
||||||
|
|
||||||
# Set Python environment variables
|
# Set Python environment variables
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
|||||||
+1
-1
@@ -99,7 +99,7 @@ ARG DEBIAN_FRONTEND=noninteractive
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
# Can be workflow provided, defaults set for manual building
|
# Can be workflow provided, defaults set for manual building
|
||||||
ARG JBIG2ENC_VERSION=0.32
|
ARG JBIG2ENC_VERSION=0.30
|
||||||
|
|
||||||
# Set Python environment variables
|
# Set Python environment variables
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
|||||||
+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.
|
||||||
|
|||||||
+298
-211
File diff suppressed because it is too large.
Load diff
@@ -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,20 @@ 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,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => this.manualWorkflows.set(result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.getCustomFields()
|
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 {
|
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)
|
||||||
@@ -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', () => {
|
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,20 @@ 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,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.manualWorkflows = result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.downloadForm
|
this.downloadForm
|
||||||
.get('downloadFileTypeArchive')
|
.get('downloadFileTypeArchive')
|
||||||
@@ -286,6 +307,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 {
|
||||||
|
|||||||
@@ -39,6 +39,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
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
@@ -464,6 +465,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,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)
|
@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")
|
||||||
|
|||||||
@@ -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,35 @@ 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")
|
||||||
|
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):
|
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 +2255,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
|
||||||
|
|
||||||
|
|||||||
@@ -864,6 +864,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
|
||||||
@@ -874,6 +876,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
|
||||||
@@ -923,7 +929,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"):
|
||||||
|
|||||||
@@ -53,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.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
|
||||||
@@ -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:
|
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
|
||||||
|
|||||||
@@ -15,7 +15,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
|
||||||
@@ -622,6 +626,111 @@ 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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")
|
@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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -100,6 +100,47 @@ 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_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:
|
def test_api_get_workflow_actions_ordered(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -2288,6 +2288,83 @@ 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.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")
|
@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,
|
||||||
|
|||||||
@@ -146,6 +146,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
|
||||||
@@ -3008,6 +3009,100 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
)
|
)
|
||||||
return Response({"result": "OK", "task_id": task.id})
|
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(
|
def _execute_document_action(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -3084,6 +3179,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
LEGACY_MODIFIED_FIELDS = {
|
LEGACY_MODIFIED_FIELDS = {
|
||||||
"delete": "deleted_at",
|
"delete": "deleted_at",
|
||||||
"reprocess": "checksum",
|
"reprocess": "checksum",
|
||||||
|
"remove_password": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
serializer_class = BulkEditSerializer
|
serializer_class = BulkEditSerializer
|
||||||
@@ -5089,6 +5185,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
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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-11 23:50+0000\n"
|
"POT-Creation-Date: 2026-11-12 21:28+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"
|
||||||
@@ -21,43 +21,43 @@ msgstr ""
|
|||||||
msgid "Documents"
|
msgid "Documents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:336
|
#: documents/filters.py:349
|
||||||
msgid "Expected a date or a relative date such as \"today\" or \"in 30 days\"."
|
msgid "Expected a date or a relative date such as \"today\" or \"in 30 days\"."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:514
|
#: documents/filters.py:527
|
||||||
msgid "Value must be valid JSON."
|
msgid "Value must be valid JSON."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:535
|
#: documents/filters.py:548
|
||||||
msgid "Invalid custom field query expression"
|
msgid "Invalid custom field query expression"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:545
|
#: documents/filters.py:558
|
||||||
msgid "Invalid expression list. Must be nonempty."
|
msgid "Invalid expression list. Must be nonempty."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:566
|
#: documents/filters.py:579
|
||||||
msgid "Invalid logical operator {op!r}"
|
msgid "Invalid logical operator {op!r}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:580
|
#: documents/filters.py:593
|
||||||
msgid "Maximum number of query conditions exceeded."
|
msgid "Maximum number of query conditions exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:644
|
#: documents/filters.py:657
|
||||||
msgid "{name!r} is not a valid custom field."
|
msgid "{name!r} is not a valid custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:681
|
#: documents/filters.py:694
|
||||||
msgid "{data_type} does not support query expr {expr!r}."
|
msgid "{data_type} does not support query expr {expr!r}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:802 documents/models.py:195
|
#: documents/filters.py:815 documents/models.py:195
|
||||||
msgid "Maximum nesting depth exceeded."
|
msgid "Maximum nesting depth exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:1179
|
#: documents/filters.py:1192
|
||||||
msgid "Custom field not found"
|
msgid "Custom field not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -93,20 +93,20 @@ msgstr ""
|
|||||||
msgid "Automatic"
|
msgid "Automatic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:69 documents/models.py:709 documents/models.py:2149
|
#: documents/models.py:69 documents/models.py:709 documents/models.py:2150
|
||||||
#: paperless_mail/models.py:23 paperless_mail/models.py:143
|
#: paperless_mail/models.py:23 paperless_mail/models.py:143
|
||||||
msgid "name"
|
msgid "name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:71 documents/models.py:1592
|
#: documents/models.py:71 documents/models.py:1593
|
||||||
msgid "match"
|
msgid "match"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:74 documents/models.py:1595
|
#: documents/models.py:74 documents/models.py:1596
|
||||||
msgid "matching algorithm"
|
msgid "matching algorithm"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:79 documents/models.py:1600
|
#: documents/models.py:79 documents/models.py:1601
|
||||||
msgid "is insensitive"
|
msgid "is insensitive"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ msgid "Optional short label for a document version."
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:402 documents/models.py:1027 documents/models.py:1058
|
#: documents/models.py:402 documents/models.py:1027 documents/models.py:1058
|
||||||
#: documents/models.py:1162 documents/models.py:2192
|
#: documents/models.py:1162 documents/models.py:2193
|
||||||
msgid "document"
|
msgid "document"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -496,7 +496,7 @@ msgstr ""
|
|||||||
msgid "Tag"
|
msgid "Tag"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:683 documents/models.py:698 documents/models.py:1870
|
#: documents/models.py:683 documents/models.py:698 documents/models.py:1871
|
||||||
msgid "Tags"
|
msgid "Tags"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -528,19 +528,19 @@ msgstr ""
|
|||||||
msgid "Large Cards"
|
msgid "Large Cards"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:695 documents/models.py:1869
|
#: documents/models.py:695 documents/models.py:1870
|
||||||
msgid "Title"
|
msgid "Title"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:696 documents/models.py:926 documents/models.py:1544
|
#: documents/models.py:696 documents/models.py:926 documents/models.py:1545
|
||||||
msgid "Created"
|
msgid "Created"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:697 documents/models.py:1543
|
#: documents/models.py:697 documents/models.py:1544
|
||||||
msgid "Added"
|
msgid "Added"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:699 documents/models.py:1871
|
#: documents/models.py:699 documents/models.py:1872
|
||||||
msgid "Correspondent"
|
msgid "Correspondent"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -860,7 +860,7 @@ msgstr ""
|
|||||||
msgid "Index Optimize"
|
msgid "Index Optimize"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:865 documents/models.py:1539
|
#: documents/models.py:865 documents/models.py:1540
|
||||||
msgid "Mail Fetch"
|
msgid "Mail Fetch"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -900,7 +900,7 @@ msgstr ""
|
|||||||
msgid "Scheduled"
|
msgid "Scheduled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:883 documents/models.py:1540
|
#: documents/models.py:883 documents/models.py:1541
|
||||||
msgid "Web UI"
|
msgid "Web UI"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -920,7 +920,7 @@ msgstr ""
|
|||||||
msgid "System"
|
msgid "System"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:888
|
#: documents/models.py:888 documents/models.py:1535
|
||||||
msgid "Manual"
|
msgid "Manual"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1178,491 +1178,491 @@ msgstr ""
|
|||||||
msgid "Document Updated"
|
msgid "Document Updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1537
|
#: documents/models.py:1538
|
||||||
msgid "Consume Folder"
|
msgid "Consume Folder"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1538
|
#: documents/models.py:1539
|
||||||
msgid "Api Upload"
|
msgid "Api Upload"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1545
|
#: documents/models.py:1546
|
||||||
msgid "Modified"
|
msgid "Modified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1546
|
#: documents/models.py:1547
|
||||||
msgid "Custom Field"
|
msgid "Custom Field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1549
|
#: documents/models.py:1550
|
||||||
msgid "Workflow Trigger Type"
|
msgid "Workflow Trigger Type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1561
|
#: documents/models.py:1562
|
||||||
msgid "filter path"
|
msgid "filter path"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1566
|
#: documents/models.py:1567
|
||||||
msgid ""
|
msgid ""
|
||||||
"Only consume documents with a path that matches this if specified. Wildcards "
|
"Only consume documents with a path that matches this if specified. Wildcards "
|
||||||
"specified as * are allowed. Case insensitive."
|
"specified as * are allowed. Case insensitive."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1573
|
#: documents/models.py:1574
|
||||||
msgid "filter filename"
|
msgid "filter filename"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1578 paperless_mail/models.py:200
|
#: documents/models.py:1579 paperless_mail/models.py:200
|
||||||
msgid ""
|
msgid ""
|
||||||
"Only consume documents which entirely match this filename if specified. "
|
"Only consume documents which entirely match this filename if specified. "
|
||||||
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
|
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1589
|
#: documents/models.py:1590
|
||||||
msgid "filter documents from this mail rule"
|
msgid "filter documents from this mail rule"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1605
|
#: documents/models.py:1606
|
||||||
msgid "has these tag(s)"
|
msgid "has these tag(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1612
|
#: documents/models.py:1613
|
||||||
msgid "has all of these tag(s)"
|
msgid "has all of these tag(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1619
|
#: documents/models.py:1620
|
||||||
msgid "does not have these tag(s)"
|
msgid "does not have these tag(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1627
|
#: documents/models.py:1628
|
||||||
msgid "has this document type"
|
msgid "has this document type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1634
|
#: documents/models.py:1635
|
||||||
msgid "has one of these document types"
|
msgid "has one of these document types"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1641
|
#: documents/models.py:1642
|
||||||
msgid "does not have these document type(s)"
|
msgid "does not have these document type(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1649
|
#: documents/models.py:1650
|
||||||
msgid "has this correspondent"
|
msgid "has this correspondent"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1656
|
#: documents/models.py:1657
|
||||||
msgid "does not have these correspondent(s)"
|
msgid "does not have these correspondent(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1663
|
#: documents/models.py:1664
|
||||||
msgid "has one of these correspondents"
|
msgid "has one of these correspondents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1671
|
#: documents/models.py:1672
|
||||||
msgid "has this storage path"
|
msgid "has this storage path"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1678
|
#: documents/models.py:1679
|
||||||
msgid "has one of these storage paths"
|
msgid "has one of these storage paths"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1685
|
#: documents/models.py:1686
|
||||||
msgid "does not have these storage path(s)"
|
msgid "does not have these storage path(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1689
|
#: documents/models.py:1690
|
||||||
msgid "filter custom field query"
|
msgid "filter custom field query"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1692
|
#: documents/models.py:1693
|
||||||
msgid "JSON-encoded custom field query expression."
|
msgid "JSON-encoded custom field query expression."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1696
|
#: documents/models.py:1697
|
||||||
msgid "schedule offset days"
|
msgid "schedule offset days"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1699
|
#: documents/models.py:1700
|
||||||
msgid "The number of days to offset the schedule trigger by."
|
msgid "The number of days to offset the schedule trigger by."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1704
|
#: documents/models.py:1705
|
||||||
msgid "schedule is recurring"
|
msgid "schedule is recurring"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1707
|
#: documents/models.py:1708
|
||||||
msgid "If the schedule should be recurring."
|
msgid "If the schedule should be recurring."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1712
|
#: documents/models.py:1713
|
||||||
msgid "schedule recurring delay in days"
|
msgid "schedule recurring delay in days"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1716
|
#: documents/models.py:1717
|
||||||
msgid "The number of days between recurring schedule triggers."
|
msgid "The number of days between recurring schedule triggers."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1721
|
#: documents/models.py:1722
|
||||||
msgid "schedule date field"
|
msgid "schedule date field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1726
|
#: documents/models.py:1727
|
||||||
msgid "The field to check for a schedule trigger."
|
msgid "The field to check for a schedule trigger."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1735
|
#: documents/models.py:1736
|
||||||
msgid "schedule date custom field"
|
msgid "schedule date custom field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1739
|
#: documents/models.py:1740
|
||||||
msgid "workflow trigger"
|
msgid "workflow trigger"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1740
|
#: documents/models.py:1741
|
||||||
msgid "workflow triggers"
|
msgid "workflow triggers"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1748
|
#: documents/models.py:1749
|
||||||
msgid "email subject"
|
msgid "email subject"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1752
|
#: documents/models.py:1753
|
||||||
msgid ""
|
msgid ""
|
||||||
"The subject of the email, can include some placeholders, see documentation."
|
"The subject of the email, can include some placeholders, see documentation."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1758
|
#: documents/models.py:1759
|
||||||
msgid "email body"
|
msgid "email body"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1761
|
#: documents/models.py:1762
|
||||||
msgid ""
|
msgid ""
|
||||||
"The body (message) of the email, can include some placeholders, see "
|
"The body (message) of the email, can include some placeholders, see "
|
||||||
"documentation."
|
"documentation."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1767
|
#: documents/models.py:1768
|
||||||
msgid "emails to"
|
msgid "emails to"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1770
|
#: documents/models.py:1771
|
||||||
msgid "The destination email addresses, comma separated."
|
msgid "The destination email addresses, comma separated."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1776
|
#: documents/models.py:1777
|
||||||
msgid "include document in email"
|
msgid "include document in email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1787
|
#: documents/models.py:1788
|
||||||
msgid "webhook url"
|
msgid "webhook url"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1790
|
#: documents/models.py:1791
|
||||||
msgid "The destination URL for the notification."
|
msgid "The destination URL for the notification."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1795
|
#: documents/models.py:1796
|
||||||
msgid "use parameters"
|
msgid "use parameters"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1800
|
#: documents/models.py:1801
|
||||||
msgid "send as JSON"
|
msgid "send as JSON"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1804
|
#: documents/models.py:1805
|
||||||
msgid "webhook parameters"
|
msgid "webhook parameters"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1807
|
#: documents/models.py:1808
|
||||||
msgid "The parameters to send with the webhook URL if body not used."
|
msgid "The parameters to send with the webhook URL if body not used."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1811
|
#: documents/models.py:1812
|
||||||
msgid "webhook body"
|
msgid "webhook body"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1814
|
#: documents/models.py:1815
|
||||||
msgid "The body to send with the webhook URL if parameters not used."
|
msgid "The body to send with the webhook URL if parameters not used."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1818
|
#: documents/models.py:1819
|
||||||
msgid "webhook headers"
|
msgid "webhook headers"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1821
|
#: documents/models.py:1822
|
||||||
msgid "The headers to send with the webhook URL."
|
msgid "The headers to send with the webhook URL."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1826
|
#: documents/models.py:1827
|
||||||
msgid "include document in webhook"
|
msgid "include document in webhook"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1837
|
#: documents/models.py:1838
|
||||||
msgid "Assignment"
|
msgid "Assignment"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1841
|
#: documents/models.py:1842
|
||||||
msgid "Removal"
|
msgid "Removal"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1845 documents/templates/account/password_reset.html:15
|
#: documents/models.py:1846 documents/templates/account/password_reset.html:15
|
||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1849
|
#: documents/models.py:1850
|
||||||
msgid "Webhook"
|
msgid "Webhook"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1853
|
#: documents/models.py:1854
|
||||||
msgid "Password removal"
|
msgid "Password removal"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1857
|
#: documents/models.py:1858
|
||||||
msgid "Move to trash"
|
msgid "Move to trash"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1861
|
#: documents/models.py:1862
|
||||||
msgid "Remote OCR"
|
msgid "Remote OCR"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1865
|
#: documents/models.py:1866
|
||||||
msgid "Apply AI suggestions"
|
msgid "Apply AI suggestions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1872
|
#: documents/models.py:1873
|
||||||
msgid "Document type"
|
msgid "Document type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1873
|
#: documents/models.py:1874
|
||||||
msgid "Storage path"
|
msgid "Storage path"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1874
|
#: documents/models.py:1875
|
||||||
msgid "Created date"
|
msgid "Created date"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1877
|
#: documents/models.py:1878
|
||||||
msgid "Workflow Action Type"
|
msgid "Workflow Action Type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1882 documents/models.py:2151
|
#: documents/models.py:1883 documents/models.py:2152
|
||||||
#: paperless_mail/models.py:145
|
#: paperless_mail/models.py:145
|
||||||
msgid "order"
|
msgid "order"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1885
|
#: documents/models.py:1886
|
||||||
msgid "assign title"
|
msgid "assign title"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1889
|
#: documents/models.py:1890
|
||||||
msgid "Assign a document title, must be a Jinja2 template, see documentation."
|
msgid "Assign a document title, must be a Jinja2 template, see documentation."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1897 paperless_mail/models.py:274
|
#: documents/models.py:1898 paperless_mail/models.py:274
|
||||||
msgid "assign this tag"
|
msgid "assign this tag"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1906 paperless_mail/models.py:282
|
#: documents/models.py:1907 paperless_mail/models.py:282
|
||||||
msgid "assign this document type"
|
msgid "assign this document type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1915 paperless_mail/models.py:296
|
#: documents/models.py:1916 paperless_mail/models.py:296
|
||||||
msgid "assign this correspondent"
|
msgid "assign this correspondent"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1924
|
#: documents/models.py:1925
|
||||||
msgid "assign this storage path"
|
msgid "assign this storage path"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1933
|
#: documents/models.py:1934
|
||||||
msgid "assign this owner"
|
msgid "assign this owner"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1940
|
#: documents/models.py:1941
|
||||||
msgid "grant view permissions to these users"
|
msgid "grant view permissions to these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1947
|
#: documents/models.py:1948
|
||||||
msgid "grant view permissions to these groups"
|
msgid "grant view permissions to these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1954
|
#: documents/models.py:1955
|
||||||
msgid "grant change permissions to these users"
|
msgid "grant change permissions to these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1961
|
#: documents/models.py:1962
|
||||||
msgid "grant change permissions to these groups"
|
msgid "grant change permissions to these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1968
|
#: documents/models.py:1969
|
||||||
msgid "assign these custom fields"
|
msgid "assign these custom fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1972
|
#: documents/models.py:1973
|
||||||
msgid "custom field values"
|
msgid "custom field values"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1976
|
#: documents/models.py:1977
|
||||||
msgid "Optional values to assign to the custom fields."
|
msgid "Optional values to assign to the custom fields."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1985
|
#: documents/models.py:1986
|
||||||
msgid "remove these tag(s)"
|
msgid "remove these tag(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1990
|
#: documents/models.py:1991
|
||||||
msgid "remove all tags"
|
msgid "remove all tags"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1997
|
#: documents/models.py:1998
|
||||||
msgid "remove these document type(s)"
|
msgid "remove these document type(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2002
|
#: documents/models.py:2003
|
||||||
msgid "remove all document types"
|
msgid "remove all document types"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2009
|
#: documents/models.py:2010
|
||||||
msgid "remove these correspondent(s)"
|
msgid "remove these correspondent(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2014
|
#: documents/models.py:2015
|
||||||
msgid "remove all correspondents"
|
msgid "remove all correspondents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2021
|
#: documents/models.py:2022
|
||||||
msgid "remove these storage path(s)"
|
msgid "remove these storage path(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2026
|
#: documents/models.py:2027
|
||||||
msgid "remove all storage paths"
|
msgid "remove all storage paths"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2033
|
#: documents/models.py:2034
|
||||||
msgid "remove these owner(s)"
|
msgid "remove these owner(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2038
|
#: documents/models.py:2039
|
||||||
msgid "remove all owners"
|
msgid "remove all owners"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2045
|
#: documents/models.py:2046
|
||||||
msgid "remove view permissions for these users"
|
msgid "remove view permissions for these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2052
|
#: documents/models.py:2053
|
||||||
msgid "remove view permissions for these groups"
|
msgid "remove view permissions for these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2059
|
#: documents/models.py:2060
|
||||||
msgid "remove change permissions for these users"
|
msgid "remove change permissions for these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2066
|
#: documents/models.py:2067
|
||||||
msgid "remove change permissions for these groups"
|
msgid "remove change permissions for these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2071
|
#: documents/models.py:2072
|
||||||
msgid "remove all permissions"
|
msgid "remove all permissions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2078
|
#: documents/models.py:2079
|
||||||
msgid "remove these custom fields"
|
msgid "remove these custom fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2083
|
#: documents/models.py:2084
|
||||||
msgid "remove all custom fields"
|
msgid "remove all custom fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2092
|
#: documents/models.py:2093
|
||||||
msgid "email"
|
msgid "email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2101
|
#: documents/models.py:2102
|
||||||
msgid "webhook"
|
msgid "webhook"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2105
|
#: documents/models.py:2106
|
||||||
msgid "passwords"
|
msgid "passwords"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2109
|
#: documents/models.py:2110
|
||||||
msgid ""
|
msgid ""
|
||||||
"Passwords to try when removing PDF protection. Separate with commas or new "
|
"Passwords to try when removing PDF protection. Separate with commas or new "
|
||||||
"lines."
|
"lines."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2114
|
#: documents/models.py:2115
|
||||||
msgid "AI suggestion fields"
|
msgid "AI suggestion fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2118
|
#: documents/models.py:2119
|
||||||
msgid "Which of the AI-suggested fields to apply to the document."
|
msgid "Which of the AI-suggested fields to apply to the document."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2123
|
#: documents/models.py:2124
|
||||||
msgid "create missing objects"
|
msgid "create missing objects"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2126
|
#: documents/models.py:2127
|
||||||
msgid ""
|
msgid ""
|
||||||
"Create suggested tags, correspondents, document types and storage paths that "
|
"Create suggested tags, correspondents, document types and storage paths that "
|
||||||
"do not already exist instead of skipping them."
|
"do not already exist instead of skipping them."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2132
|
#: documents/models.py:2133
|
||||||
msgid "overwrite existing values"
|
msgid "overwrite existing values"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2135
|
#: documents/models.py:2136
|
||||||
msgid ""
|
msgid ""
|
||||||
"Apply suggestions even if the document already has a value for that field. "
|
"Apply suggestions even if the document already has a value for that field. "
|
||||||
"Tags are always added to, never replaced."
|
"Tags are always added to, never replaced."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2141
|
#: documents/models.py:2142
|
||||||
msgid "workflow action"
|
msgid "workflow action"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2142
|
#: documents/models.py:2143
|
||||||
msgid "workflow actions"
|
msgid "workflow actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2157
|
#: documents/models.py:2158
|
||||||
msgid "triggers"
|
msgid "triggers"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2164
|
#: documents/models.py:2165
|
||||||
msgid "actions"
|
msgid "actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2167 paperless_mail/models.py:154
|
#: documents/models.py:2168 paperless_mail/models.py:154
|
||||||
msgid "enabled"
|
msgid "enabled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2178
|
#: documents/models.py:2179
|
||||||
msgid "workflow"
|
msgid "workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2182
|
#: documents/models.py:2183
|
||||||
msgid "workflow trigger type"
|
msgid "workflow trigger type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2196
|
#: documents/models.py:2197
|
||||||
msgid "date run"
|
msgid "date run"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2202
|
#: documents/models.py:2203
|
||||||
msgid "workflow run"
|
msgid "workflow run"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:2203
|
#: documents/models.py:2204
|
||||||
msgid "workflow runs"
|
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:354 documents/views.py:620
|
#: documents/serialisers.py:2884 documents/views.py:355 documents/views.py:621
|
||||||
#: documents/views.py:2760 paperless_mail/serialisers.py:156
|
#: documents/views.py:2761 paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1670,39 +1670,39 @@ msgstr ""
|
|||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2317
|
#: documents/serialisers.py:2351
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2361
|
#: documents/serialisers.py:2395
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2368
|
#: documents/serialisers.py:2402
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2385 documents/serialisers.py:2395
|
#: documents/serialisers.py:2419 documents/serialisers.py:2429
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2390
|
#: documents/serialisers.py:2424
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2537
|
#: documents/serialisers.py:2571
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2906
|
#: documents/serialisers.py:2940
|
||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2936 documents/views.py:4698
|
#: documents/serialisers.py:2970 documents/views.py:4794
|
||||||
#, 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:309
|
#: documents/views.py:310
|
||||||
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:347 documents/views.py:2757
|
#: documents/views.py:348 documents/views.py:2758
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1684
|
#: documents/views.py:1685
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1695
|
#: documents/views.py:1696
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1707
|
#: documents/views.py:1708
|
||||||
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:4714
|
#: documents/views.py:4810
|
||||||
#, 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:4761
|
#: documents/views.py:4857
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4827
|
#: documents/views.py:4923
|
||||||
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:4841
|
#: documents/views.py:4937
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user