mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Feature: Workflows (#5121)
This commit is contained in:
parent
46e6be319f
commit
3b6ce16f1c
@ -11,6 +11,8 @@ repos:
|
||||
- id: check-json
|
||||
exclude: "tsconfig.*json"
|
||||
- id: check-yaml
|
||||
args:
|
||||
- "--unsafe"
|
||||
- id: check-toml
|
||||
- id: check-executables-have-shebangs
|
||||
- id: end-of-file-fixer
|
||||
|
@ -8,7 +8,6 @@ most of the available filters and ordering fields.
|
||||
|
||||
The API provides the following main endpoints:
|
||||
|
||||
- `/api/consumption_templates/`: Full CRUD support.
|
||||
- `/api/correspondents/`: Full CRUD support.
|
||||
- `/api/custom_fields/`: Full CRUD support.
|
||||
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
||||
@ -24,6 +23,7 @@ The API provides the following main endpoints:
|
||||
- `/api/tags/`: Full CRUD support.
|
||||
- `/api/tasks/`: Read-only.
|
||||
- `/api/users/`: Full CRUD support.
|
||||
- `/api/workflows/`: Full CRUD support.
|
||||
|
||||
All of these endpoints except for the logging endpoint allow you to
|
||||
fetch (and edit and delete where appropriate) individual objects by
|
||||
|
120
docs/usage.md
120
docs/usage.md
@ -238,7 +238,7 @@ do not have an owner set.
|
||||
|
||||
### Default permissions
|
||||
|
||||
Default permissions for documents can be set using consumption templates.
|
||||
Default permissions for documents can be set using workflows.
|
||||
|
||||
For objects created via the web UI (tags, doc types, etc.) the default is to set the current user
|
||||
as owner and no extra permissions, but you explicitly set these under Settings > Permissions.
|
||||
@ -255,29 +255,80 @@ permissions can be granted to limit access to certain parts of the UI (and corre
|
||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST)
|
||||
|
||||
## Consumption templates
|
||||
## Workflows
|
||||
|
||||
Consumption templates were introduced in v2.0 and allow for finer control over what metadata (tags, doc
|
||||
types) and permissions (owner, privileges) are assigned to documents during consumption. In general,
|
||||
templates are applied sequentially (by sort order) but subsequent templates will never override an
|
||||
assignment from a preceding template. The same is true for mail rules, e.g. if you set the correspondent
|
||||
in a mail rule any subsequent consumption templates that are applied _will not_ overwrite this. The
|
||||
exception to this is assignments that can be multiple e.g. tags and permissions, which will be merged.
|
||||
!!! note
|
||||
|
||||
Consumption templates allow you to filter by:
|
||||
v2.3 added "Workflows" and existing "Consumption Templates" were converted automatically to the new more powerful format.
|
||||
|
||||
Workflows allow hooking into the Paperless-ngx document pipeline, for example to alter what metadata (tags, doc types) and
|
||||
permissions (owner, privileges) are assigned to documents. Workflows can have multiple 'triggers' and 'actions'. Triggers
|
||||
are events (with optional filtering rules) that will cause the workflow to be run and actions are the set of sequential
|
||||
actions to apply.
|
||||
|
||||
In general, workflows and any actions they contain are applied sequentially by sort order. For "assignment" actions, subsequent
|
||||
workflow actions will override previous assignments, except for assignments that accept multiple items e.g. tags, custom
|
||||
fields and permissions, which will be merged.
|
||||
|
||||
### Workflow Triggers
|
||||
|
||||
Currently, there are three events that correspond to workflow trigger 'types':
|
||||
|
||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||
folder or API), file path, file name, mail rule
|
||||
2. **Document Added**: _after_ a document is added. At this time, file path and source information is no longer available,
|
||||
but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now
|
||||
be used for filtering.
|
||||
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
||||
tags, doc type, or correspondent.
|
||||
|
||||
The following flow diagram illustrates the three trigger types:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
consumption{"Matching
|
||||
'Consumption'
|
||||
trigger(s)"}
|
||||
|
||||
added{"Matching
|
||||
'Added'
|
||||
trigger(s)"}
|
||||
|
||||
updated{"Matching
|
||||
'Updated'
|
||||
trigger(s)"}
|
||||
|
||||
A[New Document] --> consumption
|
||||
consumption --> |Yes| C[Workflow Actions Run]
|
||||
consumption --> |No| D
|
||||
C --> D[Document Added]
|
||||
D -- Paperless-ngx 'matching' of tags, etc. --> added
|
||||
added --> |Yes| F[Workflow Actions Run]
|
||||
added --> |No| G
|
||||
F --> G[Document Finalized]
|
||||
H[Existing Document Changed] --> updated
|
||||
updated --> |Yes| J[Workflow Actions Run]
|
||||
updated --> |No| K
|
||||
J --> K[Document Saved]
|
||||
```
|
||||
|
||||
#### Filters {#workflow-trigger-filters}
|
||||
|
||||
Workflows allow you to filter by:
|
||||
|
||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||
example, automatically assigning documents to different owners based on the upload directory.
|
||||
- Mail rule. Choosing this option will force 'mail fetch' to be the template source.
|
||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
||||
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
|
||||
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
|
||||
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
||||
|
||||
!!! note
|
||||
### Workflow Actions
|
||||
|
||||
You must include a file name filter, a path filter or a mail rule filter. Use * for either to apply
|
||||
to all files.
|
||||
|
||||
Consumption templates can assign:
|
||||
There is currently one type of workflow action, "Assignment", which can assign:
|
||||
|
||||
- Title, see [title placeholders](usage.md#title-placeholders) below
|
||||
- Tags, correspondent, document types
|
||||
@ -285,21 +336,11 @@ Consumption templates can assign:
|
||||
- View and / or edit permissions to users or groups
|
||||
- Custom fields. Note that no value for the field will be set
|
||||
|
||||
### Consumption template permissions
|
||||
#### Title placeholders
|
||||
|
||||
All users who have application permissions for editing consumption templates can see the same set
|
||||
of templates. In other words, templates themselves intentionally do not have an owner or permissions.
|
||||
|
||||
Given their potentially far-reaching capabilities, you may want to restrict access to templates.
|
||||
|
||||
Upon migration, existing installs will grant access to consumption templates to users who can add
|
||||
documents (and superusers who can always access all parts of the app).
|
||||
|
||||
### Title placeholders
|
||||
|
||||
Consumption template titles can include placeholders, _only for items that are assigned within the template_.
|
||||
This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
||||
applied. You can use the following placeholders:
|
||||
Workflow titles can include placeholders but the available options differ depending on the type of
|
||||
workflow trigger. This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
||||
applied. You can use the following placeholders with any trigger type:
|
||||
|
||||
- `{correspondent}`: assigned correspondent name
|
||||
- `{document_type}`: assigned document type name
|
||||
@ -314,6 +355,27 @@ applied. You can use the following placeholders:
|
||||
- `{added_time}`: added time in HH:MM format
|
||||
- `{original_filename}`: original file name without extension
|
||||
|
||||
The following placeholders are only available for "added" or "updated" triggers
|
||||
|
||||
- `{created}`: created datetime
|
||||
- `{created_year}`: created year
|
||||
- `{created_year_short}`: created year
|
||||
- `{created_month}`: created month
|
||||
- `{created_month_name}`: created month name
|
||||
- `{created_month_name_short}`: created month short name
|
||||
- `{created_day}`: created day
|
||||
- `{created_time}`: created time in HH:MM format
|
||||
|
||||
### Workflow permissions
|
||||
|
||||
All users who have application permissions for editing workflows can see the same set
|
||||
of workflows. In other words, workflows themselves intentionally do not have an owner or permissions.
|
||||
|
||||
Given their potentially far-reaching capabilities, you may want to restrict access to workflows.
|
||||
|
||||
Upon migration, existing installs will grant access to workflows to users who can add
|
||||
documents (and superusers who can always access all parts of the app).
|
||||
|
||||
## Custom Fields {#custom-fields}
|
||||
|
||||
Paperless-ngx supports the use of custom fields for documents as of v2.0, allowing a user
|
||||
|
@ -44,6 +44,11 @@ markdown_extensions:
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- footnotes
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
strict: true
|
||||
nav:
|
||||
- index.md
|
||||
|
1270
src-ui/messages.xlf
1270
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@ -21,7 +21,7 @@ import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
} from './services/permissions.service'
|
||||
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||
@ -214,13 +214,13 @@ export const routes: Routes = [
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'templates',
|
||||
component: ConsumptionTemplatesComponent,
|
||||
path: 'workflows',
|
||||
component: WorkflowsComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.ConsumptionTemplate,
|
||||
type: PermissionType.Workflow,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -176,9 +176,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
},
|
||||
{
|
||||
anchorId: 'tour.consumption-templates',
|
||||
content: $localize`Consumption templates give you finer control over the document ingestion process.`,
|
||||
route: '/templates',
|
||||
anchorId: 'tour.workflows',
|
||||
content: $localize`Workflows give you more control over the document pipeline.`,
|
||||
route: '/workflows',
|
||||
backdropConfig: {
|
||||
offset: 0,
|
||||
},
|
||||
|
@ -95,8 +95,8 @@ import { UsernamePipe } from './pipes/username.pipe'
|
||||
import { LogoComponent } from './components/common/logo/logo.component'
|
||||
import { IsNumberPipe } from './pipes/is-number.pipe'
|
||||
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
|
||||
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
|
||||
import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
import { WorkflowEditDialogComponent } from './components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
@ -108,8 +108,8 @@ import { ProfileEditDialogComponent } from './components/common/profile-edit-dia
|
||||
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
|
||||
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
||||
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
|
||||
import { ConfigComponent } from './components/admin/config/config.component'
|
||||
import { SwitchComponent } from './components/common/input/switch/switch.component'
|
||||
import { ConfigComponent } from './components/admin/config/config.component'
|
||||
|
||||
import localeAf from '@angular/common/locales/af'
|
||||
import localeAr from '@angular/common/locales/ar'
|
||||
@ -253,8 +253,8 @@ function initializeApp(settings: SettingsService) {
|
||||
LogoComponent,
|
||||
IsNumberPipe,
|
||||
ShareLinksDropdownComponent,
|
||||
ConsumptionTemplatesComponent,
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
WorkflowsComponent,
|
||||
WorkflowEditDialogComponent,
|
||||
MailComponent,
|
||||
UsersAndGroupsComponent,
|
||||
FileDropComponent,
|
||||
@ -265,8 +265,8 @@ function initializeApp(settings: SettingsService) {
|
||||
PdfViewerComponent,
|
||||
DocumentLinkComponent,
|
||||
PreviewPopupComponent,
|
||||
ConfigComponent,
|
||||
SwitchComponent,
|
||||
ConfigComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
@ -27,7 +27,7 @@
|
||||
@switch (option.type) {
|
||||
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" title="Enable" i18n-title></pngx-input-switch> }
|
||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
}
|
||||
|
@ -235,14 +235,14 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }"
|
||||
tourAnchor="tour.consumption-templates">
|
||||
<a class="nav-link" routerLink="templates" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Consumption templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#file-earmark-ruled" />
|
||||
</svg><span> <ng-container i18n>Templates</ng-container></span>
|
||||
<use xlink:href="assets/bootstrap-icons.svg#boxes" />
|
||||
</svg><span> <ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
|
@ -1,95 +0,0 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-number i18n-title title="Sort order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h5 class="border-bottom pb-2" i18n>Filters</h5>
|
||||
<p class="small" i18n>Process documents that match <em>all</em> filters specified below.</p>
|
||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h5 class="border-bottom pb-2" i18n>Assignments</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#consumption-templates'>documentation</a>." [error]="error?.assign_title"></pngx-input-text>
|
||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
|
||||
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
|
||||
<div>
|
||||
<label class="form-label" i18n>Assign view permissions</label>
|
||||
<div class="mb-2">
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-label" i18n>Assign edit permissions</label>
|
||||
<div>
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if (error?.non_field_errors) {
|
||||
<span class="text-danger"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
|
||||
</div>
|
||||
</form>
|
@ -1,125 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { FormGroup, FormControl } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { first } from 'rxjs'
|
||||
import {
|
||||
DocumentSource,
|
||||
ConsumptionTemplate,
|
||||
} from 'src/app/data/consumption-template'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { EditDialogComponent } from '../edit-dialog.component'
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { MailRule } from 'src/app/data/mail-rule'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
|
||||
export const DOCUMENT_SOURCE_OPTIONS = [
|
||||
{
|
||||
id: DocumentSource.ConsumeFolder,
|
||||
name: $localize`Consume Folder`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.ApiUpload,
|
||||
name: $localize`API Upload`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.MailFetch,
|
||||
name: $localize`Mail Fetch`,
|
||||
},
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-consumption-template-edit-dialog',
|
||||
templateUrl: './consumption-template-edit-dialog.component.html',
|
||||
styleUrls: ['./consumption-template-edit-dialog.component.scss'],
|
||||
})
|
||||
export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent<ConsumptionTemplate> {
|
||||
templates: ConsumptionTemplate[]
|
||||
correspondents: Correspondent[]
|
||||
documentTypes: DocumentType[]
|
||||
storagePaths: StoragePath[]
|
||||
mailRules: MailRule[]
|
||||
customFields: CustomField[]
|
||||
|
||||
constructor(
|
||||
service: ConsumptionTemplateService,
|
||||
activeModal: NgbActiveModal,
|
||||
correspondentService: CorrespondentService,
|
||||
documentTypeService: DocumentTypeService,
|
||||
storagePathService: StoragePathService,
|
||||
mailRuleService: MailRuleService,
|
||||
userService: UserService,
|
||||
settingsService: SettingsService,
|
||||
customFieldsService: CustomFieldsService
|
||||
) {
|
||||
super(service, activeModal, userService, settingsService)
|
||||
|
||||
correspondentService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
|
||||
documentTypeService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
|
||||
storagePathService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
|
||||
mailRuleService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.mailRules = result.results))
|
||||
|
||||
customFieldsService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.customFields = result.results))
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
return $localize`Create new consumption template`
|
||||
}
|
||||
|
||||
getEditTitle() {
|
||||
return $localize`Edit consumption template`
|
||||
}
|
||||
|
||||
getForm(): FormGroup {
|
||||
return new FormGroup({
|
||||
name: new FormControl(null),
|
||||
account: new FormControl(null),
|
||||
filter_filename: new FormControl(null),
|
||||
filter_path: new FormControl(null),
|
||||
filter_mailrule: new FormControl(null),
|
||||
order: new FormControl(null),
|
||||
sources: new FormControl([]),
|
||||
assign_title: new FormControl(null),
|
||||
assign_tags: new FormControl([]),
|
||||
assign_owner: new FormControl(null),
|
||||
assign_document_type: new FormControl(null),
|
||||
assign_correspondent: new FormControl(null),
|
||||
assign_storage_path: new FormControl(null),
|
||||
assign_view_users: new FormControl([]),
|
||||
assign_view_groups: new FormControl([]),
|
||||
assign_change_users: new FormControl([]),
|
||||
assign_change_groups: new FormControl([]),
|
||||
assign_custom_fields: new FormControl([]),
|
||||
})
|
||||
}
|
||||
|
||||
get sourceOptions() {
|
||||
return DOCUMENT_SOURCE_OPTIONS
|
||||
}
|
||||
}
|
@ -0,0 +1,207 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<pngx-input-number i18n-title title="Sort order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-switch i18n-title title="Enabled" formControlName="enabled" [error]="error?.enabled"></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
<div ngbAccordion>
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton i18n>Triggers</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="d-flex">
|
||||
<p class="p-2" i18n>Trigger Workflow On:</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addTrigger()">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Trigger</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordion [closeOthers]="true">
|
||||
@for (trigger of object?.triggers; track trigger; let i = $index){
|
||||
<div ngbAccordionItem>
|
||||
<div ngbAccordionHeader>
|
||||
<button ngbAccordionButton>{{i + 1}}. {{getTriggerTypeOptionName(triggerFields.controls[i].value.type)}}
|
||||
@if(trigger.id > -1) {
|
||||
<span class="badge bg-dark text-light ms-2">ID: {{trigger.id}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeTrigger(i)">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg>
|
||||
<ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template [ngTemplateOutlet]="triggerForm" [ngTemplateOutletContext]="{ formGroup: triggerFields.controls[i], trigger: trigger }"></ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button class="btn-lg" ngbAccordionButton i18n>Actions</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="d-flex">
|
||||
<p class="p-2" i18n>Apply Actions:</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addAction()">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Action</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)">
|
||||
@for (action of object?.actions; track action; let i = $index){
|
||||
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
|
||||
<div ngbAccordionHeader>
|
||||
<button ngbAccordionButton><ng-container i18n>Action</ng-container> {{i + 1}}
|
||||
@if(action.id > -1) {
|
||||
<span class="badge bg-dark text-light ms-2">ID: {{action.id}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeAction(i)">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg>
|
||||
<ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<pngx-input-select i18n-title title="Action type" [horizontal]="true" [items]="actionTypeOptions" formControlName="type"></pngx-input-select>
|
||||
<input type="hidden" formControlName="id" />
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.assign_title"></pngx-input-text>
|
||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
|
||||
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
|
||||
<div>
|
||||
<label class="form-label" i18n>Assign view permissions</label>
|
||||
<div class="mb-2">
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-label" i18n>Assign edit permissions</label>
|
||||
<div>
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if (error?.non_field_errors) {
|
||||
<span class="text-danger"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-template #triggerForm let-formGroup="formGroup" let-trigger="trigger">
|
||||
<div [formGroup]="formGroup">
|
||||
<input type="hidden" formControlName="id" />
|
||||
<pngx-input-select i18n-title title="Trigger type" [horizontal]="true" [items]="triggerTypeOptions" formControlName="type"></pngx-input-select>
|
||||
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [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) {
|
||||
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (patternRequired) {
|
||||
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||
}
|
||||
@if (patternRequired) {
|
||||
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
|
||||
<div class="col-md-6">
|
||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Has tags" formControlName="filter_has_tags"></pngx-input-tags>
|
||||
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
@ -0,0 +1,5 @@
|
||||
.btn.text-danger {
|
||||
&:hover, &:focus {
|
||||
color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;
|
||||
}
|
||||
}
|
@ -18,24 +18,69 @@ import { PermissionsUserComponent } from '../../input/permissions/permissions-us
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
import { TagsComponent } from '../../input/tags/tags.component'
|
||||
import { TextComponent } from '../../input/text/text.component'
|
||||
import { SwitchComponent } from '../../input/switch/switch.component'
|
||||
import { EditDialogMode } from '../edit-dialog.component'
|
||||
import { ConsumptionTemplateEditDialogComponent } from './consumption-template-edit-dialog.component'
|
||||
import {
|
||||
DOCUMENT_SOURCE_OPTIONS,
|
||||
WORKFLOW_ACTION_OPTIONS,
|
||||
WORKFLOW_TYPE_OPTIONS,
|
||||
WorkflowEditDialogComponent,
|
||||
} from './workflow-edit-dialog.component'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import {
|
||||
WorkflowTriggerType,
|
||||
DocumentSource,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import { CdkDragDrop } from '@angular/cdk/drag-drop'
|
||||
import {
|
||||
WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/app/data/workflow-action'
|
||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
||||
|
||||
const workflow: Workflow = {
|
||||
name: 'Workflow 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowTriggerType.Consumption,
|
||||
sources: [DocumentSource.ConsumeFolder],
|
||||
filter_filename: '*',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'foo',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_owner: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('ConsumptionTemplateEditDialogComponent', () => {
|
||||
let component: ConsumptionTemplateEditDialogComponent
|
||||
let component: WorkflowEditDialogComponent
|
||||
let settingsService: SettingsService
|
||||
let fixture: ComponentFixture<ConsumptionTemplateEditDialogComponent>
|
||||
let fixture: ComponentFixture<WorkflowEditDialogComponent>
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
WorkflowEditDialogComponent,
|
||||
IfPermissionsDirective,
|
||||
IfOwnerDirective,
|
||||
SelectComponent,
|
||||
TextComponent,
|
||||
NumberComponent,
|
||||
SwitchComponent,
|
||||
TagsComponent,
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
@ -113,7 +158,7 @@ describe('ConsumptionTemplateEditDialogComponent', () => {
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(ConsumptionTemplateEditDialogComponent)
|
||||
fixture = TestBed.createComponent(WorkflowEditDialogComponent)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||
component = fixture.componentInstance
|
||||
@ -121,15 +166,70 @@ describe('ConsumptionTemplateEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||
component.dialogMode = EditDialogMode.CREATE
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||
fixture.detectChanges()
|
||||
expect(createTitleSpy).toHaveBeenCalled()
|
||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||
expect(component.object).toBeUndefined()
|
||||
component.addAction()
|
||||
expect(component.object).not.toBeUndefined()
|
||||
expect(component.object.actions).toHaveLength(1)
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
expect(component.object).not.toBeUndefined()
|
||||
expect(component.object.triggers).toHaveLength(1)
|
||||
|
||||
component.dialogMode = EditDialogMode.EDIT
|
||||
fixture.detectChanges()
|
||||
expect(editTitleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return source options, type options, type name', () => {
|
||||
// coverage
|
||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||
expect(
|
||||
component.getTriggerTypeOptionName(WorkflowTriggerType.DocumentAdded)
|
||||
).toEqual('Document Added')
|
||||
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||
expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS)
|
||||
expect(
|
||||
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
||||
).toEqual('Assignment')
|
||||
expect(component.getActionTypeOptionName(null)).toEqual('')
|
||||
})
|
||||
|
||||
it('should support add and remove triggers and actions', () => {
|
||||
component.object = workflow
|
||||
component.addTrigger()
|
||||
expect(component.object.triggers.length).toEqual(2)
|
||||
component.addAction()
|
||||
expect(component.object.actions.length).toEqual(3)
|
||||
component.removeTrigger(1)
|
||||
expect(component.object.triggers.length).toEqual(1)
|
||||
component.removeAction(1)
|
||||
expect(component.object.actions.length).toEqual(2)
|
||||
})
|
||||
|
||||
it('should update order and remove ids from actions on drag n drop', () => {
|
||||
const action1 = workflow.actions[0]
|
||||
const action2 = workflow.actions[1]
|
||||
component.object = workflow
|
||||
component.onActionDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
||||
WorkflowAction[]
|
||||
>)
|
||||
expect(component.object.actions).toEqual([action2, action1])
|
||||
expect(action1.id).toBeNull()
|
||||
expect(action2.id).toBeNull()
|
||||
})
|
||||
|
||||
it('should not include auto matching in algorithms', () => {
|
||||
expect(component.getMatchingAlgorithms()).not.toContain(
|
||||
MATCHING_ALGORITHMS.find((a) => a.id === MATCH_AUTO)
|
||||
)
|
||||
})
|
||||
})
|
@ -0,0 +1,300 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { FormGroup, FormControl, FormArray } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { first } from 'rxjs'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { EditDialogComponent } from '../edit-dialog.component'
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { MailRule } from 'src/app/data/mail-rule'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import {
|
||||
DocumentSource,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import {
|
||||
WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/app/data/workflow-action'
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
|
||||
import {
|
||||
MATCHING_ALGORITHMS,
|
||||
MATCH_AUTO,
|
||||
MATCH_NONE,
|
||||
} from 'src/app/data/matching-model'
|
||||
|
||||
export const DOCUMENT_SOURCE_OPTIONS = [
|
||||
{
|
||||
id: DocumentSource.ConsumeFolder,
|
||||
name: $localize`Consume Folder`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.ApiUpload,
|
||||
name: $localize`API Upload`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.MailFetch,
|
||||
name: $localize`Mail Fetch`,
|
||||
},
|
||||
]
|
||||
|
||||
export const WORKFLOW_TYPE_OPTIONS = [
|
||||
{
|
||||
id: WorkflowTriggerType.Consumption,
|
||||
name: $localize`Consumption Started`,
|
||||
},
|
||||
{
|
||||
id: WorkflowTriggerType.DocumentAdded,
|
||||
name: $localize`Document Added`,
|
||||
},
|
||||
{
|
||||
id: WorkflowTriggerType.DocumentUpdated,
|
||||
name: $localize`Document Updated`,
|
||||
},
|
||||
]
|
||||
|
||||
export const WORKFLOW_ACTION_OPTIONS = [
|
||||
{
|
||||
id: WorkflowActionType.Assignment,
|
||||
name: $localize`Assignment`,
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||
(a) => a.id !== MATCH_AUTO
|
||||
)
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-workflow-edit-dialog',
|
||||
templateUrl: './workflow-edit-dialog.component.html',
|
||||
styleUrls: ['./workflow-edit-dialog.component.scss'],
|
||||
})
|
||||
export class WorkflowEditDialogComponent
|
||||
extends EditDialogComponent<Workflow>
|
||||
implements OnInit
|
||||
{
|
||||
public WorkflowTriggerType = WorkflowTriggerType
|
||||
|
||||
templates: Workflow[]
|
||||
correspondents: Correspondent[]
|
||||
documentTypes: DocumentType[]
|
||||
storagePaths: StoragePath[]
|
||||
mailRules: MailRule[]
|
||||
customFields: CustomField[]
|
||||
|
||||
expandedItem: number = null
|
||||
|
||||
constructor(
|
||||
service: WorkflowService,
|
||||
activeModal: NgbActiveModal,
|
||||
correspondentService: CorrespondentService,
|
||||
documentTypeService: DocumentTypeService,
|
||||
storagePathService: StoragePathService,
|
||||
mailRuleService: MailRuleService,
|
||||
userService: UserService,
|
||||
settingsService: SettingsService,
|
||||
customFieldsService: CustomFieldsService
|
||||
) {
|
||||
super(service, activeModal, userService, settingsService)
|
||||
|
||||
correspondentService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
|
||||
documentTypeService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
|
||||
storagePathService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
|
||||
mailRuleService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.mailRules = result.results))
|
||||
|
||||
customFieldsService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.customFields = result.results))
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
return $localize`Create new workflow`
|
||||
}
|
||||
|
||||
getEditTitle() {
|
||||
return $localize`Edit workflow`
|
||||
}
|
||||
|
||||
getForm(): FormGroup {
|
||||
return new FormGroup({
|
||||
name: new FormControl(null),
|
||||
order: new FormControl(null),
|
||||
enabled: new FormControl(true),
|
||||
triggers: new FormArray([]),
|
||||
actions: new FormArray([]),
|
||||
})
|
||||
}
|
||||
|
||||
getMatchingAlgorithms() {
|
||||
// No auto matching
|
||||
return TRIGGER_MATCHING_ALGORITHMS
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit()
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
get triggerFields(): FormArray {
|
||||
return this.objectForm.get('triggers') as FormArray
|
||||
}
|
||||
|
||||
get actionFields(): FormArray {
|
||||
return this.objectForm.get('actions') as FormArray
|
||||
}
|
||||
|
||||
private updateTriggerActionFields(emitEvent: boolean = false) {
|
||||
this.triggerFields.clear({ emitEvent: false })
|
||||
this.object?.triggers.forEach((trigger) => {
|
||||
this.triggerFields.push(
|
||||
new FormGroup({
|
||||
id: new FormControl(trigger.id),
|
||||
type: new FormControl(trigger.type),
|
||||
sources: new FormControl(trigger.sources),
|
||||
filter_filename: new FormControl(trigger.filter_filename),
|
||||
filter_path: new FormControl(trigger.filter_path),
|
||||
filter_mailrule: new FormControl(trigger.filter_mailrule),
|
||||
matching_algorithm: new FormControl(MATCH_NONE),
|
||||
match: new FormControl(''),
|
||||
is_insensitive: new FormControl(true),
|
||||
filter_has_tags: new FormControl(trigger.filter_has_tags),
|
||||
filter_has_correspondent: new FormControl(
|
||||
trigger.filter_has_correspondent
|
||||
),
|
||||
filter_has_document_type: new FormControl(
|
||||
trigger.filter_has_document_type
|
||||
),
|
||||
}),
|
||||
{ emitEvent }
|
||||
)
|
||||
})
|
||||
|
||||
this.actionFields.clear({ emitEvent: false })
|
||||
this.object?.actions.forEach((action) => {
|
||||
this.actionFields.push(
|
||||
new FormGroup({
|
||||
id: new FormControl(action.id),
|
||||
type: new FormControl(action.type),
|
||||
assign_title: new FormControl(action.assign_title),
|
||||
assign_tags: new FormControl(action.assign_tags),
|
||||
assign_owner: new FormControl(action.assign_owner),
|
||||
assign_document_type: new FormControl(action.assign_document_type),
|
||||
assign_correspondent: new FormControl(action.assign_correspondent),
|
||||
assign_storage_path: new FormControl(action.assign_storage_path),
|
||||
assign_view_users: new FormControl(action.assign_view_users),
|
||||
assign_view_groups: new FormControl(action.assign_view_groups),
|
||||
assign_change_users: new FormControl(action.assign_change_users),
|
||||
assign_change_groups: new FormControl(action.assign_change_groups),
|
||||
assign_custom_fields: new FormControl(action.assign_custom_fields),
|
||||
}),
|
||||
{ emitEvent }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
get sourceOptions() {
|
||||
return DOCUMENT_SOURCE_OPTIONS
|
||||
}
|
||||
|
||||
get triggerTypeOptions() {
|
||||
return WORKFLOW_TYPE_OPTIONS
|
||||
}
|
||||
|
||||
getTriggerTypeOptionName(type: WorkflowTriggerType): string {
|
||||
return this.triggerTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||
}
|
||||
|
||||
addTrigger() {
|
||||
if (!this.object) {
|
||||
this.object = Object.assign({}, this.objectForm.value)
|
||||
}
|
||||
this.object.triggers.push({
|
||||
type: WorkflowTriggerType.Consumption,
|
||||
sources: [],
|
||||
filter_filename: null,
|
||||
filter_path: null,
|
||||
filter_mailrule: null,
|
||||
filter_has_tags: [],
|
||||
filter_has_correspondent: null,
|
||||
filter_has_document_type: null,
|
||||
})
|
||||
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
get actionTypeOptions() {
|
||||
return WORKFLOW_ACTION_OPTIONS
|
||||
}
|
||||
|
||||
getActionTypeOptionName(type: WorkflowActionType): string {
|
||||
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||
}
|
||||
|
||||
addAction() {
|
||||
if (!this.object) {
|
||||
this.object = Object.assign({}, this.objectForm.value)
|
||||
}
|
||||
this.object.actions.push({
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: null,
|
||||
assign_tags: [],
|
||||
assign_document_type: null,
|
||||
assign_correspondent: null,
|
||||
assign_storage_path: null,
|
||||
assign_owner: null,
|
||||
assign_view_users: [],
|
||||
assign_view_groups: [],
|
||||
assign_change_users: [],
|
||||
assign_change_groups: [],
|
||||
assign_custom_fields: [],
|
||||
})
|
||||
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
removeTrigger(index: number) {
|
||||
this.object.triggers.splice(index, 1)
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
removeAction(index: number) {
|
||||
this.object.actions.splice(index, 1)
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
onActionDrop(event: CdkDragDrop<WorkflowAction[]>) {
|
||||
moveItemInArray(
|
||||
this.object.actions,
|
||||
event.previousIndex,
|
||||
event.currentIndex
|
||||
)
|
||||
// removing id will effectively re-create the actions in this order
|
||||
this.object.actions.forEach((a) => (a.id = null))
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
.accordion {
|
||||
--bs-accordion-btn-padding-x: 0.75rem;
|
||||
--bs-accordion-btn-padding-y: 0.375rem;
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
@if (horizontal) {
|
||||
@if (!horizontal) {
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
<label class="form-label" [for]="inputId">{{title}}</label>
|
||||
@if (removable) {
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
@ -15,7 +15,7 @@
|
||||
<div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||
<div class="form-check form-switch">
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||
@if (!horizontal) {
|
||||
@if (horizontal) {
|
||||
<label class="form-check-label" [for]="inputId">{{title}}</label>
|
||||
}
|
||||
@if (hint) {
|
||||
@ -24,4 +24,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,9 +1,9 @@
|
||||
<pngx-page-header title="Consumption Templates" i18n-title>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ConsumptionTemplate }">
|
||||
<pngx-page-header title="Workflows" i18n-title>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editWorkflow()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Workflow }">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Template</ng-container>
|
||||
<ng-container i18n>Add Workflow</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
@ -13,25 +13,27 @@
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col" i18n>Sort order</div>
|
||||
<div class="col" i18n>Document Sources</div>
|
||||
<div class="col" i18n>Status</div>
|
||||
<div class="col" i18n>Triggers</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@for (template of templates; track template) {
|
||||
@for (workflow of workflows; track workflow.id) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editTemplate(template)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.ConsumptionTemplate)">{{template.name}}</button></div>
|
||||
<div class="col d-flex align-items-center"><code>{{template.order}}</code></div>
|
||||
<div class="col d-flex align-items-center">{{getSourceList(template)}}</div>
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editWorkflow(workflow)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Workflow)">{{workflow.name}}</button></div>
|
||||
<div class="col d-flex align-items-center"><code>{{workflow.order}}</code></div>
|
||||
<div class="col d-flex align-items-center"><code> @if(workflow.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code></div>
|
||||
<div class="col d-flex align-items-center">{{getTypesList(workflow)}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editWorkflow(workflow)">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
||||
</svg> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteWorkflow(workflow)">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg> <ng-container i18n>Delete</ng-container>
|
||||
@ -41,7 +43,7 @@
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (templates.length === 0) {
|
||||
<li class="list-group-item" i18n>No templates defined.</li>
|
||||
@if (workflows.length === 0) {
|
||||
<li class="list-group-item" i18n>No workflows defined.</li>
|
||||
}
|
||||
</ul>
|
@ -9,55 +9,76 @@ import {
|
||||
NgbModalModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import {
|
||||
DocumentSource,
|
||||
ConsumptionTemplate,
|
||||
} from 'src/app/data/consumption-template'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ConsumptionTemplatesComponent } from './consumption-templates.component'
|
||||
import { ConsumptionTemplateEditDialogComponent } from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
import { WorkflowsComponent } from './workflows.component'
|
||||
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
DocumentSource,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import { WorkflowActionType } from 'src/app/data/workflow-action'
|
||||
|
||||
const templates: ConsumptionTemplate[] = [
|
||||
const workflows: Workflow[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Template 1',
|
||||
order: 0,
|
||||
sources: [
|
||||
DocumentSource.ConsumeFolder,
|
||||
DocumentSource.ApiUpload,
|
||||
DocumentSource.MailFetch,
|
||||
],
|
||||
filter_filename: 'foo',
|
||||
filter_path: 'bar',
|
||||
assign_tags: [1, 2, 3],
|
||||
},
|
||||
name: 'Workflow 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Template 2',
|
||||
order: 1,
|
||||
sources: [DocumentSource.MailFetch],
|
||||
filter_filename: null,
|
||||
filter_path: 'foo/bar',
|
||||
assign_owner: 1,
|
||||
type: WorkflowTriggerType.Consumption,
|
||||
sources: [DocumentSource.ConsumeFolder],
|
||||
filter_filename: '*',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'foo',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Workflow 2',
|
||||
id: 2,
|
||||
order: 2,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 2,
|
||||
type: WorkflowTriggerType.DocumentAdded,
|
||||
filter_filename: 'foo',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 2,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
describe('ConsumptionTemplatesComponent', () => {
|
||||
let component: ConsumptionTemplatesComponent
|
||||
let fixture: ComponentFixture<ConsumptionTemplatesComponent>
|
||||
let consumptionTemplateService: ConsumptionTemplateService
|
||||
describe('WorkflowsComponent', () => {
|
||||
let component: WorkflowsComponent
|
||||
let fixture: ComponentFixture<WorkflowsComponent>
|
||||
let workflowService: WorkflowService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
ConsumptionTemplatesComponent,
|
||||
WorkflowsComponent,
|
||||
IfPermissionsDirective,
|
||||
PageHeaderComponent,
|
||||
ConfirmDialogComponent,
|
||||
@ -81,18 +102,18 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
],
|
||||
})
|
||||
|
||||
consumptionTemplateService = TestBed.inject(ConsumptionTemplateService)
|
||||
jest.spyOn(consumptionTemplateService, 'listAll').mockReturnValue(
|
||||
workflowService = TestBed.inject(WorkflowService)
|
||||
jest.spyOn(workflowService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: templates.length,
|
||||
all: templates.map((o) => o.id),
|
||||
results: templates,
|
||||
count: workflows.length,
|
||||
all: workflows.map((o) => o.id),
|
||||
results: workflows,
|
||||
})
|
||||
)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
|
||||
fixture = TestBed.createComponent(ConsumptionTemplatesComponent)
|
||||
fixture = TestBed.createComponent(WorkflowsComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
@ -108,8 +129,7 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
createButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog =
|
||||
modal.componentInstance as ConsumptionTemplateEditDialogComponent
|
||||
const editDialog = modal.componentInstance as WorkflowEditDialogComponent
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
@ -117,7 +137,7 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(templates[0])
|
||||
editDialog.succeeded.emit(workflows[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
@ -133,9 +153,8 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
editButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog =
|
||||
modal.componentInstance as ConsumptionTemplateEditDialogComponent
|
||||
expect(editDialog.object).toEqual(templates[0])
|
||||
const editDialog = modal.componentInstance as WorkflowEditDialogComponent
|
||||
expect(editDialog.object).toEqual(workflows[0])
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error editing item' })
|
||||
@ -143,7 +162,7 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(templates[0])
|
||||
editDialog.succeeded.emit(workflows[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
@ -152,7 +171,7 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const deleteSpy = jest.spyOn(consumptionTemplateService, 'delete')
|
||||
const deleteSpy = jest.spyOn(workflowService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]
|
@ -1,33 +1,33 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { ConsumptionTemplate } from 'src/app/data/consumption-template'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
DOCUMENT_SOURCE_OPTIONS,
|
||||
} from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
WorkflowEditDialogComponent,
|
||||
WORKFLOW_TYPE_OPTIONS,
|
||||
} from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-consumption-templates',
|
||||
templateUrl: './consumption-templates.component.html',
|
||||
styleUrls: ['./consumption-templates.component.scss'],
|
||||
selector: 'pngx-workflows',
|
||||
templateUrl: './workflows.component.html',
|
||||
styleUrls: ['./workflows.component.scss'],
|
||||
})
|
||||
export class ConsumptionTemplatesComponent
|
||||
export class WorkflowsComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
public templates: ConsumptionTemplate[] = []
|
||||
public workflows: Workflow[] = []
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
private consumptionTemplateService: ConsumptionTemplateService,
|
||||
private workflowService: WorkflowService,
|
||||
public permissionsService: PermissionsService,
|
||||
private modalService: NgbModal,
|
||||
private toastService: ToastService
|
||||
@ -40,68 +40,74 @@ export class ConsumptionTemplatesComponent
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.consumptionTemplateService
|
||||
this.workflowService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((r) => {
|
||||
this.templates = r.results
|
||||
this.workflows = r.results
|
||||
})
|
||||
}
|
||||
|
||||
getSourceList(template: ConsumptionTemplate): string {
|
||||
return template.sources
|
||||
.map((id) => DOCUMENT_SOURCE_OPTIONS.find((s) => s.id === id).name)
|
||||
getTypesList(template: Workflow): string {
|
||||
return template.triggers
|
||||
.map(
|
||||
(trigger) =>
|
||||
WORKFLOW_TYPE_OPTIONS.find((t) => t.id === trigger.type).name
|
||||
)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
editTemplate(rule: ConsumptionTemplate) {
|
||||
const modal = this.modalService.open(
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
{
|
||||
editWorkflow(workflow: Workflow) {
|
||||
const modal = this.modalService.open(WorkflowEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'xl',
|
||||
}
|
||||
)
|
||||
modal.componentInstance.dialogMode = rule
|
||||
})
|
||||
modal.componentInstance.dialogMode = workflow
|
||||
? EditDialogMode.EDIT
|
||||
: EditDialogMode.CREATE
|
||||
modal.componentInstance.object = rule
|
||||
if (workflow) {
|
||||
// quick "deep" clone so original doesnt get modified
|
||||
const clone = Object.assign({}, workflow)
|
||||
clone.actions = [...workflow.actions]
|
||||
clone.triggers = [...workflow.triggers]
|
||||
modal.componentInstance.object = clone
|
||||
}
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newTemplate) => {
|
||||
.subscribe((newWorkflow) => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Saved template "${newTemplate.name}".`
|
||||
$localize`Saved workflow "${newWorkflow.name}".`
|
||||
)
|
||||
this.consumptionTemplateService.clearCache()
|
||||
this.workflowService.clearCache()
|
||||
this.reload()
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving template.`, e)
|
||||
this.toastService.showError($localize`Error saving workflow.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
deleteTemplate(rule: ConsumptionTemplate) {
|
||||
deleteWorkflow(workflow: Workflow) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete template`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this template.`
|
||||
modal.componentInstance.title = $localize`Confirm delete workflow`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this workflow.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.consumptionTemplateService.delete(rule).subscribe({
|
||||
this.workflowService.delete(workflow).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Deleted template`)
|
||||
this.consumptionTemplateService.clearCache()
|
||||
this.toastService.showInfo($localize`Deleted workflow`)
|
||||
this.workflowService.clearCache()
|
||||
this.reload()
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error deleting template.`, e)
|
||||
this.toastService.showError($localize`Error deleting workflow.`, e)
|
||||
},
|
||||
})
|
||||
})
|
@ -1,23 +1,10 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export enum DocumentSource {
|
||||
ConsumeFolder = 1,
|
||||
ApiUpload = 2,
|
||||
MailFetch = 3,
|
||||
export enum WorkflowActionType {
|
||||
Assignment = 1,
|
||||
}
|
||||
|
||||
export interface ConsumptionTemplate extends ObjectWithId {
|
||||
name: string
|
||||
|
||||
order: number
|
||||
|
||||
sources: DocumentSource[]
|
||||
|
||||
filter_filename: string
|
||||
|
||||
filter_path?: string
|
||||
|
||||
filter_mailrule?: number // MailRule.id
|
||||
export interface WorkflowAction extends ObjectWithId {
|
||||
type: WorkflowActionType
|
||||
|
||||
assign_title?: string
|
||||
|
37
src-ui/src/app/data/workflow-trigger.ts
Normal file
37
src-ui/src/app/data/workflow-trigger.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export enum DocumentSource {
|
||||
ConsumeFolder = 1,
|
||||
ApiUpload = 2,
|
||||
MailFetch = 3,
|
||||
}
|
||||
|
||||
export enum WorkflowTriggerType {
|
||||
Consumption = 1,
|
||||
DocumentAdded = 2,
|
||||
DocumentUpdated = 3,
|
||||
}
|
||||
|
||||
export interface WorkflowTrigger extends ObjectWithId {
|
||||
type: WorkflowTriggerType
|
||||
|
||||
sources?: DocumentSource[]
|
||||
|
||||
filter_filename?: string
|
||||
|
||||
filter_path?: string
|
||||
|
||||
filter_mailrule?: number // MailRule.id
|
||||
|
||||
match?: string
|
||||
|
||||
matching_algorithm?: number
|
||||
|
||||
is_insensitive?: boolean
|
||||
|
||||
filter_has_tags?: number[] // Tag.id[]
|
||||
|
||||
filter_has_correspondent?: number // Correspondent.id
|
||||
|
||||
filter_has_document_type?: number // DocumentType.id
|
||||
}
|
15
src-ui/src/app/data/workflow.ts
Normal file
15
src-ui/src/app/data/workflow.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
import { WorkflowAction } from './workflow-action'
|
||||
import { WorkflowTrigger } from './workflow-trigger'
|
||||
|
||||
export interface Workflow extends ObjectWithId {
|
||||
name: string
|
||||
|
||||
order: number
|
||||
|
||||
enabled: boolean
|
||||
|
||||
triggers: WorkflowTrigger[]
|
||||
|
||||
actions: WorkflowAction[]
|
||||
}
|
@ -252,10 +252,18 @@ describe('PermissionsService', () => {
|
||||
'view_sharelink',
|
||||
'change_sharelink',
|
||||
'delete_sharelink',
|
||||
'add_consumptiontemplate',
|
||||
'view_consumptiontemplate',
|
||||
'change_consumptiontemplate',
|
||||
'delete_consumptiontemplate',
|
||||
'add_workflow',
|
||||
'view_workflow',
|
||||
'change_workflow',
|
||||
'delete_workflow',
|
||||
'add_workflowtrigger',
|
||||
'view_workflowtrigger',
|
||||
'change_workflowtrigger',
|
||||
'delete_workflowtrigger',
|
||||
'add_workflowaction',
|
||||
'view_workflowaction',
|
||||
'change_workflowaction',
|
||||
'delete_workflowaction',
|
||||
'add_customfield',
|
||||
'view_customfield',
|
||||
'change_customfield',
|
||||
|
@ -25,8 +25,10 @@ export enum PermissionType {
|
||||
Group = '%s_group',
|
||||
Admin = '%s_logentry',
|
||||
ShareLink = '%s_sharelink',
|
||||
ConsumptionTemplate = '%s_consumptiontemplate',
|
||||
CustomField = '%s_customfield',
|
||||
Workflow = '%s_workflow',
|
||||
WorkflowTrigger = '%s_workflowtrigger',
|
||||
WorkflowAction = '%s_workflowaction',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
@ -1,64 +0,0 @@
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
import { ConsumptionTemplateService } from './consumption-template.service'
|
||||
import {
|
||||
DocumentSource,
|
||||
ConsumptionTemplate,
|
||||
} from 'src/app/data/consumption-template'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: ConsumptionTemplateService
|
||||
const endpoint = 'consumption_templates'
|
||||
const templates: ConsumptionTemplate[] = [
|
||||
{
|
||||
name: 'Template 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
filter_filename: '*test*',
|
||||
filter_path: null,
|
||||
sources: [DocumentSource.ApiUpload],
|
||||
assign_correspondent: 2,
|
||||
},
|
||||
{
|
||||
name: 'Template 2',
|
||||
id: 2,
|
||||
order: 2,
|
||||
filter_filename: null,
|
||||
filter_path: '/test/',
|
||||
sources: [DocumentSource.ConsumeFolder, DocumentSource.ApiUpload],
|
||||
assign_document_type: 1,
|
||||
},
|
||||
]
|
||||
|
||||
// run common tests
|
||||
commonAbstractPaperlessServiceTests(
|
||||
'consumption_templates',
|
||||
ConsumptionTemplateService
|
||||
)
|
||||
|
||||
describe(`Additional service tests for ConsumptionTemplateService`, () => {
|
||||
it('should reload', () => {
|
||||
service.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
|
||||
)
|
||||
req.flush({
|
||||
results: templates,
|
||||
})
|
||||
expect(service.allTemplates).toEqual(templates)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Dont need to setup again
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(ConsumptionTemplateService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
httpTestingController.verify()
|
||||
})
|
||||
})
|
85
src-ui/src/app/services/rest/workflow.service.spec.ts
Normal file
85
src-ui/src/app/services/rest/workflow.service.spec.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
import { WorkflowService } from './workflow.service'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import {
|
||||
DocumentSource,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import { WorkflowActionType } from 'src/app/data/workflow-action'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: WorkflowService
|
||||
const endpoint = 'workflows'
|
||||
const workflows: Workflow[] = [
|
||||
{
|
||||
name: 'Workflow 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowTriggerType.Consumption,
|
||||
sources: [DocumentSource.ConsumeFolder],
|
||||
filter_filename: '*',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'foo',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Workflow 2',
|
||||
id: 2,
|
||||
order: 2,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 2,
|
||||
type: WorkflowTriggerType.DocumentAdded,
|
||||
filter_filename: 'foo',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 2,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// run common tests
|
||||
commonAbstractPaperlessServiceTests(endpoint, WorkflowService)
|
||||
|
||||
describe(`Additional service tests for WorkflowService`, () => {
|
||||
it('should reload', () => {
|
||||
service.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
|
||||
)
|
||||
req.flush({
|
||||
results: workflows,
|
||||
})
|
||||
expect(service.allWorkflows).toEqual(workflows)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Dont need to setup again
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(WorkflowService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
httpTestingController.verify()
|
||||
})
|
||||
})
|
@ -1,42 +1,42 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { tap } from 'rxjs'
|
||||
import { ConsumptionTemplate } from 'src/app/data/consumption-template'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsumptionTemplateService extends AbstractPaperlessService<ConsumptionTemplate> {
|
||||
export class WorkflowService extends AbstractPaperlessService<Workflow> {
|
||||
loading: boolean
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
super(http, 'consumption_templates')
|
||||
super(http, 'workflows')
|
||||
}
|
||||
|
||||
public reload() {
|
||||
this.loading = true
|
||||
this.listAll().subscribe((r) => {
|
||||
this.templates = r.results
|
||||
this.workflows = r.results
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
private templates: ConsumptionTemplate[] = []
|
||||
private workflows: Workflow[] = []
|
||||
|
||||
public get allTemplates(): ConsumptionTemplate[] {
|
||||
return this.templates
|
||||
public get allWorkflows(): Workflow[] {
|
||||
return this.workflows
|
||||
}
|
||||
|
||||
create(o: ConsumptionTemplate) {
|
||||
create(o: Workflow) {
|
||||
return super.create(o).pipe(tap(() => this.reload()))
|
||||
}
|
||||
|
||||
update(o: ConsumptionTemplate) {
|
||||
update(o: Workflow) {
|
||||
return super.update(o).pipe(tap(() => this.reload()))
|
||||
}
|
||||
|
||||
delete(o: ConsumptionTemplate) {
|
||||
delete(o: Workflow) {
|
||||
return super.delete(o).pipe(tap(() => this.reload()))
|
||||
}
|
||||
}
|
@ -647,8 +647,6 @@ code {
|
||||
}
|
||||
|
||||
.accordion {
|
||||
--bs-accordion-btn-padding-x: 0.75rem;
|
||||
--bs-accordion-btn-padding-y: 0.375rem;
|
||||
--bs-accordion-btn-bg: var(--bs-light);
|
||||
--bs-accordion-btn-color: var(--bs-primary);
|
||||
--bs-accordion-color: var(--bs-body-color);
|
||||
|
@ -9,8 +9,11 @@ class DocumentsConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
from documents.signals import document_consumption_finished
|
||||
from documents.signals import document_updated
|
||||
from documents.signals.handlers import add_inbox_tags
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.signals.handlers import run_workflow_added
|
||||
from documents.signals.handlers import run_workflow_updated
|
||||
from documents.signals.handlers import set_correspondent
|
||||
from documents.signals.handlers import set_document_type
|
||||
from documents.signals.handlers import set_log_entry
|
||||
@ -24,5 +27,7 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(set_storage_path)
|
||||
document_consumption_finished.connect(set_log_entry)
|
||||
document_consumption_finished.connect(add_to_index)
|
||||
document_consumption_finished.connect(run_workflow_added)
|
||||
document_updated.connect(run_workflow_updated)
|
||||
|
||||
AppConfig.ready(self)
|
||||
|
@ -26,8 +26,7 @@ from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.loggers import LoggingMixin
|
||||
from documents.matching import document_matches_template
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.matching import document_matches_workflow
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@ -36,6 +35,8 @@ from documents.models import DocumentType
|
||||
from documents.models import FileInfo
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import DocumentParser
|
||||
from documents.parsers import ParseError
|
||||
from documents.parsers import get_parser_class_for_mime_type
|
||||
@ -602,66 +603,71 @@ class Consumer(LoggingMixin):
|
||||
|
||||
return document
|
||||
|
||||
def get_template_overrides(
|
||||
def get_workflow_overrides(
|
||||
self,
|
||||
input_doc: ConsumableDocument,
|
||||
) -> DocumentMetadataOverrides:
|
||||
"""
|
||||
Match consumption templates to a document based on source and
|
||||
file name filters, path filters or mail rule filter if specified
|
||||
Get overrides from matching workflows
|
||||
"""
|
||||
overrides = DocumentMetadataOverrides()
|
||||
for template in ConsumptionTemplate.objects.all().order_by("order"):
|
||||
for workflow in Workflow.objects.filter(enabled=True).order_by("order"):
|
||||
template_overrides = DocumentMetadataOverrides()
|
||||
|
||||
if document_matches_template(input_doc, template):
|
||||
if template.assign_title is not None:
|
||||
template_overrides.title = template.assign_title
|
||||
if template.assign_tags is not None:
|
||||
if document_matches_workflow(
|
||||
input_doc,
|
||||
workflow,
|
||||
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
):
|
||||
for action in workflow.actions.all():
|
||||
self.log.info(
|
||||
f"Applying overrides in {action} from {workflow}",
|
||||
)
|
||||
if action.assign_title is not None:
|
||||
template_overrides.title = action.assign_title
|
||||
if action.assign_tags is not None:
|
||||
template_overrides.tag_ids = [
|
||||
tag.pk for tag in template.assign_tags.all()
|
||||
tag.pk for tag in action.assign_tags.all()
|
||||
]
|
||||
if template.assign_correspondent is not None:
|
||||
if action.assign_correspondent is not None:
|
||||
template_overrides.correspondent_id = (
|
||||
template.assign_correspondent.pk
|
||||
action.assign_correspondent.pk
|
||||
)
|
||||
if template.assign_document_type is not None:
|
||||
if action.assign_document_type is not None:
|
||||
template_overrides.document_type_id = (
|
||||
template.assign_document_type.pk
|
||||
action.assign_document_type.pk
|
||||
)
|
||||
if template.assign_storage_path is not None:
|
||||
template_overrides.storage_path_id = template.assign_storage_path.pk
|
||||
if template.assign_owner is not None:
|
||||
template_overrides.owner_id = template.assign_owner.pk
|
||||
if template.assign_view_users is not None:
|
||||
if action.assign_storage_path is not None:
|
||||
template_overrides.storage_path_id = (
|
||||
action.assign_storage_path.pk
|
||||
)
|
||||
if action.assign_owner is not None:
|
||||
template_overrides.owner_id = action.assign_owner.pk
|
||||
if action.assign_view_users is not None:
|
||||
template_overrides.view_users = [
|
||||
user.pk for user in template.assign_view_users.all()
|
||||
user.pk for user in action.assign_view_users.all()
|
||||
]
|
||||
if template.assign_view_groups is not None:
|
||||
if action.assign_view_groups is not None:
|
||||
template_overrides.view_groups = [
|
||||
group.pk for group in template.assign_view_groups.all()
|
||||
group.pk for group in action.assign_view_groups.all()
|
||||
]
|
||||
if template.assign_change_users is not None:
|
||||
if action.assign_change_users is not None:
|
||||
template_overrides.change_users = [
|
||||
user.pk for user in template.assign_change_users.all()
|
||||
user.pk for user in action.assign_change_users.all()
|
||||
]
|
||||
if template.assign_change_groups is not None:
|
||||
if action.assign_change_groups is not None:
|
||||
template_overrides.change_groups = [
|
||||
group.pk for group in template.assign_change_groups.all()
|
||||
group.pk for group in action.assign_change_groups.all()
|
||||
]
|
||||
if template.assign_custom_fields is not None:
|
||||
if action.assign_custom_fields is not None:
|
||||
template_overrides.custom_field_ids = [
|
||||
field.pk for field in template.assign_custom_fields.all()
|
||||
field.pk for field in action.assign_custom_fields.all()
|
||||
]
|
||||
|
||||
overrides.update(template_overrides)
|
||||
return overrides
|
||||
|
||||
def _parse_title_placeholders(self, title: str) -> str:
|
||||
"""
|
||||
Consumption template title placeholders can only include items that are
|
||||
assigned as part of this template (since auto-matching hasnt happened yet)
|
||||
"""
|
||||
local_added = timezone.localtime(timezone.now())
|
||||
|
||||
correspondent_name = (
|
||||
@ -680,20 +686,14 @@ class Consumer(LoggingMixin):
|
||||
else None
|
||||
)
|
||||
|
||||
return title.format(
|
||||
correspondent=correspondent_name,
|
||||
document_type=doc_type_name,
|
||||
added=local_added.isoformat(),
|
||||
added_year=local_added.strftime("%Y"),
|
||||
added_year_short=local_added.strftime("%y"),
|
||||
added_month=local_added.strftime("%m"),
|
||||
added_month_name=local_added.strftime("%B"),
|
||||
added_month_name_short=local_added.strftime("%b"),
|
||||
added_day=local_added.strftime("%d"),
|
||||
owner_username=owner_username,
|
||||
original_filename=Path(self.filename).stem,
|
||||
added_time=local_added.strftime("%H:%M"),
|
||||
).strip()
|
||||
return parse_doc_title_w_placeholders(
|
||||
title,
|
||||
correspondent_name,
|
||||
doc_type_name,
|
||||
owner_username,
|
||||
local_added,
|
||||
self.filename,
|
||||
)
|
||||
|
||||
def _store(
|
||||
self,
|
||||
@ -846,3 +846,47 @@ class Consumer(LoggingMixin):
|
||||
self.log.warning("Script stderr:")
|
||||
for line in stderr_str:
|
||||
self.log.warning(line)
|
||||
|
||||
|
||||
def parse_doc_title_w_placeholders(
|
||||
title: str,
|
||||
correspondent_name: str,
|
||||
doc_type_name: str,
|
||||
owner_username: str,
|
||||
local_added: datetime.datetime,
|
||||
original_filename: str,
|
||||
created: Optional[datetime.datetime] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Available title placeholders for Workflows depend on what has already been assigned,
|
||||
e.g. for pre-consumption triggers created will not have been parsed yet, but it will
|
||||
for added / updated triggers
|
||||
"""
|
||||
formatting = {
|
||||
"correspondent": correspondent_name,
|
||||
"document_type": doc_type_name,
|
||||
"added": local_added.isoformat(),
|
||||
"added_year": local_added.strftime("%Y"),
|
||||
"added_year_short": local_added.strftime("%y"),
|
||||
"added_month": local_added.strftime("%m"),
|
||||
"added_month_name": local_added.strftime("%B"),
|
||||
"added_month_name_short": local_added.strftime("%b"),
|
||||
"added_day": local_added.strftime("%d"),
|
||||
"added_time": local_added.strftime("%H:%M"),
|
||||
"owner_username": owner_username,
|
||||
"original_filename": Path(original_filename).stem,
|
||||
}
|
||||
if created is not None:
|
||||
formatting.update(
|
||||
{
|
||||
"created": created.isoformat(),
|
||||
"created_year": created.strftime("%Y"),
|
||||
"created_year_short": created.strftime("%y"),
|
||||
"created_month": created.strftime("%m"),
|
||||
"created_month_name": created.strftime("%B"),
|
||||
"created_month_name_short": created.strftime("%b"),
|
||||
"created_day": created.strftime("%d"),
|
||||
"created_time": created.strftime("%H:%M"),
|
||||
},
|
||||
)
|
||||
return title.format(**formatting).strip()
|
||||
|
@ -33,21 +33,20 @@ class DocumentMetadataOverrides:
|
||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||
"""
|
||||
Merges two DocumentMetadataOverrides objects such that object B's overrides
|
||||
are only applied if the property is empty in object A or merged if multiple
|
||||
are accepted.
|
||||
are applied to object A or merged if multiple are accepted.
|
||||
|
||||
The update is an in-place modification of self
|
||||
"""
|
||||
# only if empty
|
||||
if self.title is None:
|
||||
if other.title is not None:
|
||||
self.title = other.title
|
||||
if self.correspondent_id is None:
|
||||
if other.correspondent_id is not None:
|
||||
self.correspondent_id = other.correspondent_id
|
||||
if self.document_type_id is None:
|
||||
if other.document_type_id is not None:
|
||||
self.document_type_id = other.document_type_id
|
||||
if self.storage_path_id is None:
|
||||
if other.storage_path_id is not None:
|
||||
self.storage_path_id = other.storage_path_id
|
||||
if self.owner_id is None:
|
||||
if other.owner_id is not None:
|
||||
self.owner_id = other.owner_id
|
||||
|
||||
# merge
|
||||
|
@ -23,7 +23,6 @@ from guardian.models import UserObjectPermission
|
||||
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@ -35,6 +34,9 @@ from documents.models import SavedViewFilterRule
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import UiSettings
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.settings import EXPORTER_ARCHIVE_NAME
|
||||
from documents.settings import EXPORTER_FILE_NAME
|
||||
from documents.settings import EXPORTER_THUMBNAIL_NAME
|
||||
@ -285,7 +287,15 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
manifest += json.loads(
|
||||
serializers.serialize("json", ConsumptionTemplate.objects.all()),
|
||||
serializers.serialize("json", WorkflowTrigger.objects.all()),
|
||||
)
|
||||
|
||||
manifest += json.loads(
|
||||
serializers.serialize("json", WorkflowAction.objects.all()),
|
||||
)
|
||||
|
||||
manifest += json.loads(
|
||||
serializers.serialize("json", Workflow.objects.all()),
|
||||
)
|
||||
|
||||
manifest += json.loads(
|
||||
|
@ -1,27 +1,35 @@
|
||||
import logging
|
||||
import re
|
||||
from fnmatch import fnmatch
|
||||
from typing import Union
|
||||
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
|
||||
logger = logging.getLogger("paperless.matching")
|
||||
|
||||
|
||||
def log_reason(matching_model: MatchingModel, document: Document, reason: str):
|
||||
def log_reason(
|
||||
matching_model: Union[MatchingModel, WorkflowTrigger],
|
||||
document: Document,
|
||||
reason: str,
|
||||
):
|
||||
class_name = type(matching_model).__name__
|
||||
name = (
|
||||
matching_model.name if hasattr(matching_model, "name") else str(matching_model)
|
||||
)
|
||||
logger.debug(
|
||||
f"{class_name} {matching_model.name} matched on document "
|
||||
f"{document} because {reason}",
|
||||
f"{class_name} {name} matched on document {document} because {reason}",
|
||||
)
|
||||
|
||||
|
||||
@ -237,65 +245,182 @@ def _split_match(matching_model):
|
||||
]
|
||||
|
||||
|
||||
def document_matches_template(
|
||||
def consumable_document_matches_workflow(
|
||||
document: ConsumableDocument,
|
||||
template: ConsumptionTemplate,
|
||||
) -> bool:
|
||||
trigger: WorkflowTrigger,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Returns True if the incoming document matches all filters and
|
||||
settings from the template, False otherwise
|
||||
Returns True if the ConsumableDocument matches all filters from the workflow trigger,
|
||||
False otherwise. Includes a reason if doesn't match
|
||||
"""
|
||||
|
||||
def log_match_failure(reason: str):
|
||||
logger.info(f"Document did not match template {template.name}")
|
||||
logger.debug(reason)
|
||||
trigger_matched = True
|
||||
reason = ""
|
||||
|
||||
# Document source vs template source
|
||||
if document.source not in [int(x) for x in list(template.sources)]:
|
||||
log_match_failure(
|
||||
# Document source vs trigger source
|
||||
if document.source not in [int(x) for x in list(trigger.sources)]:
|
||||
reason = (
|
||||
f"Document source {document.source.name} not in"
|
||||
f" {[DocumentSource(int(x)).name for x in template.sources]}",
|
||||
f" {[DocumentSource(int(x)).name for x in trigger.sources]}",
|
||||
)
|
||||
return False
|
||||
trigger_matched = False
|
||||
|
||||
# Document mail rule vs template mail rule
|
||||
# Document mail rule vs trigger mail rule
|
||||
if (
|
||||
document.mailrule_id is not None
|
||||
and template.filter_mailrule is not None
|
||||
and document.mailrule_id != template.filter_mailrule.pk
|
||||
and trigger.filter_mailrule is not None
|
||||
and document.mailrule_id != trigger.filter_mailrule.pk
|
||||
):
|
||||
log_match_failure(
|
||||
reason = (
|
||||
f"Document mail rule {document.mailrule_id}"
|
||||
f" != {template.filter_mailrule.pk}",
|
||||
f" != {trigger.filter_mailrule.pk}",
|
||||
)
|
||||
return False
|
||||
trigger_matched = False
|
||||
|
||||
# Document filename vs template filename
|
||||
# Document filename vs trigger filename
|
||||
if (
|
||||
template.filter_filename is not None
|
||||
and len(template.filter_filename) > 0
|
||||
trigger.filter_filename is not None
|
||||
and len(trigger.filter_filename) > 0
|
||||
and not fnmatch(
|
||||
document.original_file.name.lower(),
|
||||
template.filter_filename.lower(),
|
||||
trigger.filter_filename.lower(),
|
||||
)
|
||||
):
|
||||
log_match_failure(
|
||||
reason = (
|
||||
f"Document filename {document.original_file.name} does not match"
|
||||
f" {template.filter_filename.lower()}",
|
||||
f" {trigger.filter_filename.lower()}",
|
||||
)
|
||||
return False
|
||||
trigger_matched = False
|
||||
|
||||
# Document path vs template path
|
||||
# Document path vs trigger path
|
||||
if (
|
||||
template.filter_path is not None
|
||||
and len(template.filter_path) > 0
|
||||
and not document.original_file.match(template.filter_path)
|
||||
trigger.filter_path is not None
|
||||
and len(trigger.filter_path) > 0
|
||||
and not document.original_file.match(trigger.filter_path)
|
||||
):
|
||||
log_match_failure(
|
||||
reason = (
|
||||
f"Document path {document.original_file}"
|
||||
f" does not match {template.filter_path}",
|
||||
f" does not match {trigger.filter_path}",
|
||||
)
|
||||
return False
|
||||
trigger_matched = False
|
||||
|
||||
logger.info(f"Document matched template {template.name}")
|
||||
return (trigger_matched, reason)
|
||||
|
||||
|
||||
def existing_document_matches_workflow(
|
||||
document: Document,
|
||||
trigger: WorkflowTrigger,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Returns True if the Document matches all filters from the workflow trigger,
|
||||
False otherwise. Includes a reason if doesn't match
|
||||
"""
|
||||
|
||||
trigger_matched = True
|
||||
reason = ""
|
||||
|
||||
if trigger.matching_algorithm > MatchingModel.MATCH_NONE and not matches(
|
||||
trigger,
|
||||
document,
|
||||
):
|
||||
reason = (
|
||||
f"Document content matching settings for algorithm '{trigger.matching_algorithm}' did not match",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
# Document tags vs trigger has_tags
|
||||
if (
|
||||
trigger.filter_has_tags.all().count() > 0
|
||||
and document.tags.filter(
|
||||
id__in=trigger.filter_has_tags.all().values_list("id"),
|
||||
).count()
|
||||
== 0
|
||||
):
|
||||
reason = (
|
||||
f"Document tags {document.tags.all()} do not include"
|
||||
f" {trigger.filter_has_tags.all()}",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
# Document correpondent vs trigger has_correspondent
|
||||
if (
|
||||
trigger.filter_has_correspondent is not None
|
||||
and document.correspondent != trigger.filter_has_correspondent
|
||||
):
|
||||
reason = (
|
||||
f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
# Document document_type vs trigger has_document_type
|
||||
if (
|
||||
trigger.filter_has_document_type is not None
|
||||
and document.document_type != trigger.filter_has_document_type
|
||||
):
|
||||
reason = (
|
||||
f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
# Document original_filename vs trigger filename
|
||||
if (
|
||||
trigger.filter_filename is not None
|
||||
and len(trigger.filter_filename) > 0
|
||||
and document.original_filename is not None
|
||||
and not fnmatch(
|
||||
document.original_filename.lower(),
|
||||
trigger.filter_filename.lower(),
|
||||
)
|
||||
):
|
||||
reason = (
|
||||
f"Document filename {document.original_filename} does not match"
|
||||
f" {trigger.filter_filename.lower()}",
|
||||
)
|
||||
trigger_matched = False
|
||||
|
||||
return (trigger_matched, reason)
|
||||
|
||||
|
||||
def document_matches_workflow(
|
||||
document: Union[ConsumableDocument, Document],
|
||||
workflow: Workflow,
|
||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||
) -> bool:
|
||||
"""
|
||||
Returns True if the ConsumableDocument or Document matches all filters and
|
||||
settings from the workflow trigger, False otherwise
|
||||
"""
|
||||
|
||||
trigger_matched = True
|
||||
if workflow.triggers.filter(type=trigger_type).count() == 0:
|
||||
trigger_matched = False
|
||||
logger.info(f"Document did not match {workflow}")
|
||||
logger.debug(f"No matching triggers with type {trigger_type} found")
|
||||
else:
|
||||
for trigger in workflow.triggers.filter(type=trigger_type):
|
||||
if trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION:
|
||||
trigger_matched, reason = consumable_document_matches_workflow(
|
||||
document,
|
||||
trigger,
|
||||
)
|
||||
elif (
|
||||
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
|
||||
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
|
||||
):
|
||||
trigger_matched, reason = existing_document_matches_workflow(
|
||||
document,
|
||||
trigger,
|
||||
)
|
||||
else:
|
||||
# New trigger types need to be explicitly checked above
|
||||
raise Exception(f"Trigger type {trigger_type} not yet supported")
|
||||
|
||||
if trigger_matched:
|
||||
logger.info(f"Document matched {trigger} from {workflow}")
|
||||
# matched, bail early
|
||||
return True
|
||||
else:
|
||||
logger.info(f"Document did not match {workflow}")
|
||||
logger.debug(reason)
|
||||
|
||||
return trigger_matched
|
||||
|
@ -0,0 +1,513 @@
|
||||
# Generated by Django 4.2.7 on 2023-12-23 22:51
|
||||
|
||||
import django.db.models.deletion
|
||||
import multiselectfield.db.fields
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.management import create_permissions
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
def add_workflow_permissions(apps, schema_editor):
|
||||
# create permissions without waiting for post_migrate signal
|
||||
for app_config in apps.get_app_configs():
|
||||
app_config.models_module = True
|
||||
create_permissions(app_config, apps=apps, verbosity=0)
|
||||
app_config.models_module = None
|
||||
|
||||
add_permission = Permission.objects.get(codename="add_document")
|
||||
workflow_permissions = Permission.objects.filter(
|
||||
codename__contains="workflow",
|
||||
)
|
||||
|
||||
for user in User.objects.filter(Q(user_permissions=add_permission)).distinct():
|
||||
user.user_permissions.add(*workflow_permissions)
|
||||
|
||||
for group in Group.objects.filter(Q(permissions=add_permission)).distinct():
|
||||
group.permissions.add(*workflow_permissions)
|
||||
|
||||
|
||||
def remove_workflow_permissions(apps, schema_editor):
|
||||
workflow_permissions = Permission.objects.filter(
|
||||
codename__contains="workflow",
|
||||
)
|
||||
|
||||
for user in User.objects.all():
|
||||
user.user_permissions.remove(*workflow_permissions)
|
||||
|
||||
for group in Group.objects.all():
|
||||
group.permissions.remove(*workflow_permissions)
|
||||
|
||||
|
||||
def migrate_consumption_templates(apps, schema_editor):
|
||||
"""
|
||||
Migrate consumption templates to workflows. At this point ConsumptionTemplate still exists
|
||||
but objects are not returned as their true model so we have to manually do that
|
||||
"""
|
||||
model_name = "ConsumptionTemplate"
|
||||
app_name = "documents"
|
||||
|
||||
ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name)
|
||||
|
||||
with transaction.atomic():
|
||||
for template in ConsumptionTemplate.objects.all():
|
||||
trigger = WorkflowTrigger(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
sources=template.sources,
|
||||
filter_path=template.filter_path,
|
||||
filter_filename=template.filter_filename,
|
||||
)
|
||||
if template.filter_mailrule is not None:
|
||||
trigger.filter_mailrule = MailRule.objects.get(
|
||||
id=template.filter_mailrule.id,
|
||||
)
|
||||
trigger.save()
|
||||
|
||||
action = WorkflowAction.objects.create(
|
||||
assign_title=template.assign_title,
|
||||
)
|
||||
if template.assign_document_type is not None:
|
||||
action.assign_document_type = DocumentType.objects.get(
|
||||
id=template.assign_document_type.id,
|
||||
)
|
||||
if template.assign_correspondent is not None:
|
||||
action.assign_correspondent = Correspondent.objects.get(
|
||||
id=template.assign_correspondent.id,
|
||||
)
|
||||
if template.assign_storage_path is not None:
|
||||
action.assign_storage_path = StoragePath.objects.get(
|
||||
id=template.assign_storage_path.id,
|
||||
)
|
||||
if template.assign_owner is not None:
|
||||
action.assign_owner = User.objects.get(id=template.assign_owner.id)
|
||||
if template.assign_tags is not None:
|
||||
action.assign_tags.set(
|
||||
Tag.objects.filter(
|
||||
id__in=[t.id for t in template.assign_tags.all()],
|
||||
).all(),
|
||||
)
|
||||
if template.assign_view_users is not None:
|
||||
action.assign_view_users.set(
|
||||
User.objects.filter(
|
||||
id__in=[u.id for u in template.assign_view_users.all()],
|
||||
).all(),
|
||||
)
|
||||
if template.assign_view_groups is not None:
|
||||
action.assign_view_groups.set(
|
||||
Group.objects.filter(
|
||||
id__in=[g.id for g in template.assign_view_groups.all()],
|
||||
).all(),
|
||||
)
|
||||
if template.assign_change_users is not None:
|
||||
action.assign_change_users.set(
|
||||
User.objects.filter(
|
||||
id__in=[u.id for u in template.assign_change_users.all()],
|
||||
).all(),
|
||||
)
|
||||
if template.assign_change_groups is not None:
|
||||
action.assign_change_groups.set(
|
||||
Group.objects.filter(
|
||||
id__in=[g.id for g in template.assign_change_groups.all()],
|
||||
).all(),
|
||||
)
|
||||
if template.assign_custom_fields is not None:
|
||||
action.assign_custom_fields.set(
|
||||
CustomField.objects.filter(
|
||||
id__in=[cf.id for cf in template.assign_custom_fields.all()],
|
||||
).all(),
|
||||
)
|
||||
action.save()
|
||||
|
||||
workflow = Workflow.objects.create(
|
||||
name=template.name,
|
||||
order=template.order,
|
||||
)
|
||||
workflow.triggers.set([trigger])
|
||||
workflow.actions.set([action])
|
||||
workflow.save()
|
||||
|
||||
|
||||
def unmigrate_consumption_templates(apps, schema_editor):
|
||||
model_name = "ConsumptionTemplate"
|
||||
app_name = "documents"
|
||||
|
||||
ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name)
|
||||
|
||||
for workflow in Workflow.objects.all():
|
||||
template = ConsumptionTemplate.objects.create(
|
||||
name=workflow.name,
|
||||
order=workflow.order,
|
||||
sources=workflow.triggers.first().sources,
|
||||
filter_path=workflow.triggers.first().filter_path,
|
||||
filter_filename=workflow.triggers.first().filter_filename,
|
||||
filter_mailrule=workflow.triggers.first().filter_mailrule,
|
||||
assign_title=workflow.actions.first().assign_title,
|
||||
assign_document_type=workflow.actions.first().assign_document_type,
|
||||
assign_correspondent=workflow.actions.first().assign_correspondent,
|
||||
assign_storage_path=workflow.actions.first().assign_storage_path,
|
||||
assign_owner=workflow.actions.first().assign_owner,
|
||||
)
|
||||
template.assign_tags.set(workflow.actions.first().assign_tags.all())
|
||||
template.assign_view_users.set(workflow.actions.first().assign_view_users.all())
|
||||
template.assign_view_groups.set(
|
||||
workflow.actions.first().assign_view_groups.all(),
|
||||
)
|
||||
template.assign_change_users.set(
|
||||
workflow.actions.first().assign_change_users.all(),
|
||||
)
|
||||
template.assign_change_groups.set(
|
||||
workflow.actions.first().assign_change_groups.all(),
|
||||
)
|
||||
template.assign_custom_fields.set(
|
||||
workflow.actions.first().assign_custom_fields.all(),
|
||||
)
|
||||
template.save()
|
||||
|
||||
|
||||
def delete_consumption_template_content_type(apps, schema_editor):
|
||||
with transaction.atomic():
|
||||
apps.get_model("contenttypes", "ContentType").objects.filter(
|
||||
app_label="documents",
|
||||
model="consumptiontemplate",
|
||||
).delete()
|
||||
|
||||
|
||||
def undelete_consumption_template_content_type(apps, schema_editor):
|
||||
apps.get_model("contenttypes", "ContentType").objects.create(
|
||||
app_label="documents",
|
||||
model="consumptiontemplate",
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless_mail", "0023_remove_mailrule_filter_attachment_filename_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("documents", "1043_alter_savedviewfilterrule_rule_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Workflow",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(max_length=256, unique=True, verbose_name="name"),
|
||||
),
|
||||
("order", models.IntegerField(default=0, verbose_name="order")),
|
||||
(
|
||||
"enabled",
|
||||
models.BooleanField(default=True, verbose_name="enabled"),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WorkflowAction",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.PositiveIntegerField(
|
||||
choices=[(1, "Assignment")],
|
||||
default=1,
|
||||
verbose_name="Workflow Action Type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_title",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Assign a document title, can include some placeholders, see documentation.",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="assign title",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_change_groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to="auth.group",
|
||||
verbose_name="grant change permissions to these groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_change_users",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="grant change permissions to these users",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_correspondent",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.correspondent",
|
||||
verbose_name="assign this correspondent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_custom_fields",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to="documents.customfield",
|
||||
verbose_name="assign these custom fields",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_document_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.documenttype",
|
||||
verbose_name="assign this document type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_owner",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="assign this owner",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_storage_path",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.storagepath",
|
||||
verbose_name="assign this storage path",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_tags",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
to="documents.tag",
|
||||
verbose_name="assign this tag",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_view_groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to="auth.group",
|
||||
verbose_name="grant view permissions to these groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"assign_view_users",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="grant view permissions to these users",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "workflow action",
|
||||
"verbose_name_plural": "workflow actions",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WorkflowTrigger",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.PositiveIntegerField(
|
||||
choices=[
|
||||
(1, "Consumption Started"),
|
||||
(2, "Document Added"),
|
||||
(3, "Document Updated"),
|
||||
],
|
||||
default=1,
|
||||
verbose_name="Workflow Trigger Type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sources",
|
||||
multiselectfield.db.fields.MultiSelectField(
|
||||
choices=[
|
||||
(1, "Consume Folder"),
|
||||
(2, "Api Upload"),
|
||||
(3, "Mail Fetch"),
|
||||
],
|
||||
default="1,2,3",
|
||||
max_length=5,
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_path",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="filter path",
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_filename",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.",
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="filter filename",
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_mailrule",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="paperless_mail.mailrule",
|
||||
verbose_name="filter documents from this mail rule",
|
||||
),
|
||||
),
|
||||
(
|
||||
"matching_algorithm",
|
||||
models.PositiveIntegerField(
|
||||
choices=[
|
||||
(0, "None"),
|
||||
(1, "Any word"),
|
||||
(2, "All words"),
|
||||
(3, "Exact match"),
|
||||
(4, "Regular expression"),
|
||||
(5, "Fuzzy word"),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="matching algorithm",
|
||||
),
|
||||
),
|
||||
(
|
||||
"match",
|
||||
models.CharField(blank=True, max_length=256, verbose_name="match"),
|
||||
),
|
||||
(
|
||||
"is_insensitive",
|
||||
models.BooleanField(default=True, verbose_name="is insensitive"),
|
||||
),
|
||||
(
|
||||
"filter_has_tags",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
to="documents.tag",
|
||||
verbose_name="has these tag(s)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_has_document_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.documenttype",
|
||||
verbose_name="has this document type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"filter_has_correspondent",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="documents.correspondent",
|
||||
verbose_name="has this correspondent",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "workflow trigger",
|
||||
"verbose_name_plural": "workflow triggers",
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
add_workflow_permissions,
|
||||
remove_workflow_permissions,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflow",
|
||||
name="actions",
|
||||
field=models.ManyToManyField(
|
||||
related_name="workflows",
|
||||
to="documents.workflowaction",
|
||||
verbose_name="actions",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflow",
|
||||
name="triggers",
|
||||
field=models.ManyToManyField(
|
||||
related_name="workflows",
|
||||
to="documents.workflowtrigger",
|
||||
verbose_name="triggers",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_consumption_templates,
|
||||
unmigrate_consumption_templates,
|
||||
),
|
||||
migrations.DeleteModel("ConsumptionTemplate"),
|
||||
migrations.RunPython(
|
||||
delete_consumption_template_content_type,
|
||||
undelete_consumption_template_content_type,
|
||||
),
|
||||
]
|
@ -888,15 +888,31 @@ if settings.AUDIT_LOG_ENABLED:
|
||||
auditlog.register(CustomFieldInstance)
|
||||
|
||||
|
||||
class ConsumptionTemplate(models.Model):
|
||||
class WorkflowTrigger(models.Model):
|
||||
class WorkflowTriggerMatching(models.IntegerChoices):
|
||||
# No auto matching
|
||||
NONE = MatchingModel.MATCH_NONE, _("None")
|
||||
ANY = MatchingModel.MATCH_ANY, _("Any word")
|
||||
ALL = MatchingModel.MATCH_ALL, _("All words")
|
||||
LITERAL = MatchingModel.MATCH_LITERAL, _("Exact match")
|
||||
REGEX = MatchingModel.MATCH_REGEX, _("Regular expression")
|
||||
FUZZY = MatchingModel.MATCH_FUZZY, _("Fuzzy word")
|
||||
|
||||
class WorkflowTriggerType(models.IntegerChoices):
|
||||
CONSUMPTION = 1, _("Consumption Started")
|
||||
DOCUMENT_ADDED = 2, _("Document Added")
|
||||
DOCUMENT_UPDATED = 3, _("Document Updated")
|
||||
|
||||
class DocumentSourceChoices(models.IntegerChoices):
|
||||
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
|
||||
API_UPLOAD = DocumentSource.ApiUpload.value, _("Api Upload")
|
||||
MAIL_FETCH = DocumentSource.MailFetch.value, _("Mail Fetch")
|
||||
|
||||
name = models.CharField(_("name"), max_length=256, unique=True)
|
||||
|
||||
order = models.IntegerField(_("order"), default=0)
|
||||
type = models.PositiveIntegerField(
|
||||
_("Workflow Trigger Type"),
|
||||
choices=WorkflowTriggerType.choices,
|
||||
default=WorkflowTriggerType.CONSUMPTION,
|
||||
)
|
||||
|
||||
sources = MultiSelectField(
|
||||
max_length=5,
|
||||
@ -936,6 +952,56 @@ class ConsumptionTemplate(models.Model):
|
||||
verbose_name=_("filter documents from this mail rule"),
|
||||
)
|
||||
|
||||
match = models.CharField(_("match"), max_length=256, blank=True)
|
||||
|
||||
matching_algorithm = models.PositiveIntegerField(
|
||||
_("matching algorithm"),
|
||||
choices=WorkflowTriggerMatching.choices,
|
||||
default=WorkflowTriggerMatching.NONE,
|
||||
)
|
||||
|
||||
is_insensitive = models.BooleanField(_("is insensitive"), default=True)
|
||||
|
||||
filter_has_tags = models.ManyToManyField(
|
||||
Tag,
|
||||
blank=True,
|
||||
verbose_name=_("has these tag(s)"),
|
||||
)
|
||||
|
||||
filter_has_document_type = models.ForeignKey(
|
||||
DocumentType,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("has this document type"),
|
||||
)
|
||||
|
||||
filter_has_correspondent = models.ForeignKey(
|
||||
Correspondent,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("has this correspondent"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("workflow trigger")
|
||||
verbose_name_plural = _("workflow triggers")
|
||||
|
||||
def __str__(self):
|
||||
return f"WorkflowTrigger {self.pk}"
|
||||
|
||||
|
||||
class WorkflowAction(models.Model):
|
||||
class WorkflowActionType(models.IntegerChoices):
|
||||
ASSIGNMENT = 1, _("Assignment")
|
||||
|
||||
type = models.PositiveIntegerField(
|
||||
_("Workflow Action Type"),
|
||||
choices=WorkflowActionType.choices,
|
||||
default=WorkflowActionType.ASSIGNMENT,
|
||||
)
|
||||
|
||||
assign_title = models.CharField(
|
||||
_("assign title"),
|
||||
max_length=256,
|
||||
@ -1022,8 +1088,33 @@ class ConsumptionTemplate(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("consumption template")
|
||||
verbose_name_plural = _("consumption templates")
|
||||
verbose_name = _("workflow action")
|
||||
verbose_name_plural = _("workflow actions")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
return f"WorkflowAction {self.pk}"
|
||||
|
||||
|
||||
class Workflow(models.Model):
|
||||
name = models.CharField(_("name"), max_length=256, unique=True)
|
||||
|
||||
order = models.IntegerField(_("order"), default=0)
|
||||
|
||||
triggers = models.ManyToManyField(
|
||||
WorkflowTrigger,
|
||||
related_name="workflows",
|
||||
blank=False,
|
||||
verbose_name=_("triggers"),
|
||||
)
|
||||
|
||||
actions = models.ManyToManyField(
|
||||
WorkflowAction,
|
||||
related_name="workflows",
|
||||
blank=False,
|
||||
verbose_name=_("actions"),
|
||||
)
|
||||
|
||||
enabled = models.BooleanField(_("enabled"), default=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Workflow: {self.name}"
|
||||
|
@ -27,7 +27,6 @@ from rest_framework.fields import SerializerMethodField
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@ -41,6 +40,9 @@ from documents.models import ShareLink
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import UiSettings
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import is_mime_type_supported
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import set_permissions_for_object
|
||||
@ -1278,43 +1280,38 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
|
||||
return attrs
|
||||
|
||||
|
||||
class ConsumptionTemplateSerializer(serializers.ModelSerializer):
|
||||
order = serializers.IntegerField(required=False)
|
||||
class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False, allow_null=True)
|
||||
sources = fields.MultipleChoiceField(
|
||||
choices=ConsumptionTemplate.DocumentSourceChoices.choices,
|
||||
allow_empty=False,
|
||||
choices=WorkflowTrigger.DocumentSourceChoices.choices,
|
||||
allow_empty=True,
|
||||
default={
|
||||
DocumentSource.ConsumeFolder,
|
||||
DocumentSource.ApiUpload,
|
||||
DocumentSource.MailFetch,
|
||||
},
|
||||
)
|
||||
assign_correspondent = CorrespondentField(allow_null=True, required=False)
|
||||
assign_tags = TagsField(many=True, allow_null=True, required=False)
|
||||
assign_document_type = DocumentTypeField(allow_null=True, required=False)
|
||||
assign_storage_path = StoragePathField(allow_null=True, required=False)
|
||||
|
||||
type = serializers.ChoiceField(
|
||||
choices=WorkflowTrigger.WorkflowTriggerType.choices,
|
||||
label="Trigger Type",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsumptionTemplate
|
||||
model = WorkflowTrigger
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"order",
|
||||
"sources",
|
||||
"type",
|
||||
"filter_path",
|
||||
"filter_filename",
|
||||
"filter_mailrule",
|
||||
"assign_title",
|
||||
"assign_tags",
|
||||
"assign_correspondent",
|
||||
"assign_document_type",
|
||||
"assign_storage_path",
|
||||
"assign_owner",
|
||||
"assign_view_users",
|
||||
"assign_view_groups",
|
||||
"assign_change_users",
|
||||
"assign_change_groups",
|
||||
"assign_custom_fields",
|
||||
"matching_algorithm",
|
||||
"match",
|
||||
"is_insensitive",
|
||||
"filter_has_tags",
|
||||
"filter_has_correspondent",
|
||||
"filter_has_document_type",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
@ -1322,12 +1319,6 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer):
|
||||
attrs["sources"] = {DocumentSource.MailFetch.value}
|
||||
|
||||
# Empty strings treated as None to avoid unexpected behavior
|
||||
if (
|
||||
"assign_title" in attrs
|
||||
and attrs["assign_title"] is not None
|
||||
and len(attrs["assign_title"]) == 0
|
||||
):
|
||||
attrs["assign_title"] = None
|
||||
if (
|
||||
"filter_filename" in attrs
|
||||
and attrs["filter_filename"] is not None
|
||||
@ -1342,7 +1333,8 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer):
|
||||
attrs["filter_path"] = None
|
||||
|
||||
if (
|
||||
"filter_mailrule" not in attrs
|
||||
attrs["type"] == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||
and "filter_mailrule" not in attrs
|
||||
and ("filter_filename" not in attrs or attrs["filter_filename"] is None)
|
||||
and ("filter_path" not in attrs or attrs["filter_path"] is None)
|
||||
):
|
||||
@ -1351,3 +1343,144 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class WorkflowActionSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False, allow_null=True)
|
||||
assign_correspondent = CorrespondentField(allow_null=True, required=False)
|
||||
assign_tags = TagsField(many=True, allow_null=True, required=False)
|
||||
assign_document_type = DocumentTypeField(allow_null=True, required=False)
|
||||
assign_storage_path = StoragePathField(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = WorkflowAction
|
||||
fields = [
|
||||
"id",
|
||||
"type",
|
||||
"assign_title",
|
||||
"assign_tags",
|
||||
"assign_correspondent",
|
||||
"assign_document_type",
|
||||
"assign_storage_path",
|
||||
"assign_owner",
|
||||
"assign_view_users",
|
||||
"assign_view_groups",
|
||||
"assign_change_users",
|
||||
"assign_change_groups",
|
||||
"assign_custom_fields",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
# Empty strings treated as None to avoid unexpected behavior
|
||||
if (
|
||||
"assign_title" in attrs
|
||||
and attrs["assign_title"] is not None
|
||||
and len(attrs["assign_title"]) == 0
|
||||
):
|
||||
attrs["assign_title"] = None
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class WorkflowSerializer(serializers.ModelSerializer):
|
||||
order = serializers.IntegerField(required=False)
|
||||
|
||||
triggers = WorkflowTriggerSerializer(many=True)
|
||||
actions = WorkflowActionSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Workflow
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"order",
|
||||
"enabled",
|
||||
"triggers",
|
||||
"actions",
|
||||
]
|
||||
|
||||
def update_triggers_and_actions(self, instance: Workflow, triggers, actions):
|
||||
set_triggers = []
|
||||
set_actions = []
|
||||
|
||||
if triggers is not None:
|
||||
for trigger in triggers:
|
||||
filter_has_tags = trigger.pop("filter_has_tags", None)
|
||||
trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
|
||||
id=trigger["id"] if "id" in trigger else None,
|
||||
defaults=trigger,
|
||||
)
|
||||
if filter_has_tags is not None:
|
||||
trigger_instance.filter_has_tags.set(filter_has_tags)
|
||||
set_triggers.append(trigger_instance)
|
||||
|
||||
if actions is not None:
|
||||
for action in actions:
|
||||
assign_tags = action.pop("assign_tags", None)
|
||||
assign_view_users = action.pop("assign_view_users", None)
|
||||
assign_view_groups = action.pop("assign_view_groups", None)
|
||||
assign_change_users = action.pop("assign_change_users", None)
|
||||
assign_change_groups = action.pop("assign_change_groups", None)
|
||||
assign_custom_fields = action.pop("assign_custom_fields", None)
|
||||
action_instance, _ = WorkflowAction.objects.update_or_create(
|
||||
id=action["id"] if "id" in action else None,
|
||||
defaults=action,
|
||||
)
|
||||
if assign_tags is not None:
|
||||
action_instance.assign_tags.set(assign_tags)
|
||||
if assign_view_users is not None:
|
||||
action_instance.assign_view_users.set(assign_view_users)
|
||||
if assign_view_groups is not None:
|
||||
action_instance.assign_view_groups.set(assign_view_groups)
|
||||
if assign_change_users is not None:
|
||||
action_instance.assign_change_users.set(assign_change_users)
|
||||
if assign_change_groups is not None:
|
||||
action_instance.assign_change_groups.set(assign_change_groups)
|
||||
if assign_custom_fields is not None:
|
||||
action_instance.assign_custom_fields.set(assign_custom_fields)
|
||||
set_actions.append(action_instance)
|
||||
|
||||
instance.triggers.set(set_triggers)
|
||||
instance.actions.set(set_actions)
|
||||
instance.save()
|
||||
|
||||
def prune_triggers_and_actions(self):
|
||||
"""
|
||||
ManyToMany fields dont support e.g. on_delete so we need to discard unattached
|
||||
triggers and actionas manually
|
||||
"""
|
||||
for trigger in WorkflowTrigger.objects.all():
|
||||
if trigger.workflows.all().count() == 0:
|
||||
trigger.delete()
|
||||
|
||||
for action in WorkflowAction.objects.all():
|
||||
if action.workflows.all().count() == 0:
|
||||
action.delete()
|
||||
|
||||
def create(self, validated_data) -> Workflow:
|
||||
if "triggers" in validated_data:
|
||||
triggers = validated_data.pop("triggers")
|
||||
|
||||
if "actions" in validated_data:
|
||||
actions = validated_data.pop("actions")
|
||||
|
||||
instance = super().create(validated_data)
|
||||
|
||||
self.update_triggers_and_actions(instance, triggers, actions)
|
||||
|
||||
return instance
|
||||
|
||||
def update(self, instance: Workflow, validated_data) -> Workflow:
|
||||
if "triggers" in validated_data:
|
||||
triggers = validated_data.pop("triggers")
|
||||
|
||||
if "actions" in validated_data:
|
||||
actions = validated_data.pop("actions")
|
||||
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
self.update_triggers_and_actions(instance, triggers, actions)
|
||||
|
||||
self.prune_triggers_and_actions()
|
||||
|
||||
return instance
|
||||
|
@ -3,3 +3,4 @@ from django.dispatch import Signal
|
||||
document_consumption_started = Signal()
|
||||
document_consumption_finished = Signal()
|
||||
document_consumer_declaration = Signal()
|
||||
document_updated = Signal()
|
||||
|
@ -24,14 +24,19 @@ from filelock import FileLock
|
||||
|
||||
from documents import matching
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.consumer import parse_doc_title_w_placeholders
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import MatchingModel
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import set_permissions_for_object
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
|
||||
@ -514,6 +519,105 @@ def add_to_index(sender, document, **kwargs):
|
||||
index.add_or_update_document(document)
|
||||
|
||||
|
||||
def run_workflow_added(sender, document: Document, logging_group=None, **kwargs):
|
||||
run_workflow(
|
||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
document,
|
||||
logging_group,
|
||||
)
|
||||
|
||||
|
||||
def run_workflow_updated(sender, document: Document, logging_group=None, **kwargs):
|
||||
run_workflow(
|
||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
document,
|
||||
logging_group,
|
||||
)
|
||||
|
||||
|
||||
def run_workflow(
|
||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||
document: Document,
|
||||
logging_group=None,
|
||||
):
|
||||
for workflow in Workflow.objects.filter(
|
||||
enabled=True,
|
||||
triggers__type=trigger_type,
|
||||
).order_by("order"):
|
||||
if matching.document_matches_workflow(
|
||||
document,
|
||||
workflow,
|
||||
trigger_type,
|
||||
):
|
||||
for action in workflow.actions.all():
|
||||
logger.info(
|
||||
f"Applying {action} from {workflow}",
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
if action.assign_tags.all().count() > 0:
|
||||
document.tags.add(*action.assign_tags.all())
|
||||
|
||||
if action.assign_correspondent is not None:
|
||||
document.correspondent = action.assign_correspondent
|
||||
|
||||
if action.assign_document_type is not None:
|
||||
document.document_type = action.assign_document_type
|
||||
|
||||
if action.assign_storage_path is not None:
|
||||
document.storage_path = action.assign_storage_path
|
||||
|
||||
if action.assign_owner is not None:
|
||||
document.owner = action.assign_owner
|
||||
|
||||
if action.assign_title is not None:
|
||||
document.title = parse_doc_title_w_placeholders(
|
||||
action.assign_title,
|
||||
document.correspondent.name
|
||||
if document.correspondent is not None
|
||||
else "",
|
||||
document.document_type.name
|
||||
if document.document_type is not None
|
||||
else "",
|
||||
document.owner.username if document.owner is not None else "",
|
||||
document.added,
|
||||
document.original_filename,
|
||||
document.created,
|
||||
)
|
||||
|
||||
if (
|
||||
action.assign_view_users is not None
|
||||
or action.assign_view_groups is not None
|
||||
or action.assign_change_users is not None
|
||||
or action.assign_change_groups is not None
|
||||
):
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": action.assign_view_users.all().values_list("id")
|
||||
or [],
|
||||
"groups": action.assign_view_groups.all().values_list("id")
|
||||
or [],
|
||||
},
|
||||
"change": {
|
||||
"users": action.assign_change_users.all().values_list("id")
|
||||
or [],
|
||||
"groups": action.assign_change_groups.all().values_list(
|
||||
"id",
|
||||
)
|
||||
or [],
|
||||
},
|
||||
}
|
||||
set_permissions_for_object(permissions=permissions, object=document)
|
||||
|
||||
if action.assign_custom_fields is not None:
|
||||
for field in action.assign_custom_fields.all():
|
||||
CustomFieldInstance.objects.create(
|
||||
field=field,
|
||||
document=document,
|
||||
) # adds to document
|
||||
|
||||
document.save()
|
||||
|
||||
|
||||
@before_task_publish.connect
|
||||
def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
|
||||
"""
|
||||
|
@ -36,6 +36,7 @@ from documents.models import Tag
|
||||
from documents.parsers import DocumentParser
|
||||
from documents.parsers import get_parser_class_for_mime_type
|
||||
from documents.sanity_checker import SanityCheckFailedException
|
||||
from documents.signals import document_updated
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
import json
|
||||
@ -157,7 +158,7 @@ def consume_file(
|
||||
overrides.asn = reader.asn
|
||||
logger.info(f"Found ASN in barcode: {overrides.asn}")
|
||||
|
||||
template_overrides = Consumer().get_template_overrides(
|
||||
template_overrides = Consumer().get_workflow_overrides(
|
||||
input_doc=input_doc,
|
||||
)
|
||||
|
||||
@ -215,6 +216,11 @@ def bulk_update_documents(document_ids):
|
||||
ix = index.open_index()
|
||||
|
||||
for doc in documents:
|
||||
document_updated.send(
|
||||
sender=None,
|
||||
document=doc,
|
||||
logging_group=uuid.uuid4(),
|
||||
)
|
||||
post_save.send(Document, instance=doc, created=False)
|
||||
|
||||
with AsyncWriter(ix) as writer:
|
||||
|
@ -1,236 +0,0 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
|
||||
ENDPOINT = "/api/consumption_templates/"
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
self.client.force_authenticate(user=user)
|
||||
self.user2 = User.objects.create(username="user2")
|
||||
self.user3 = User.objects.create(username="user3")
|
||||
self.group1 = Group.objects.create(name="group1")
|
||||
|
||||
self.c = Correspondent.objects.create(name="Correspondent Name")
|
||||
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
|
||||
self.dt = DocumentType.objects.create(name="DocType Name")
|
||||
self.t1 = Tag.objects.create(name="t1")
|
||||
self.t2 = Tag.objects.create(name="t2")
|
||||
self.t3 = Tag.objects.create(name="t3")
|
||||
self.sp = StoragePath.objects.create(path="/test/")
|
||||
self.cf1 = CustomField.objects.create(name="Custom Field 1", data_type="string")
|
||||
self.cf2 = CustomField.objects.create(
|
||||
name="Custom Field 2",
|
||||
data_type="integer",
|
||||
)
|
||||
|
||||
self.ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}",
|
||||
filter_filename="*simple*",
|
||||
filter_path="*/samples/*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
self.ct.assign_tags.add(self.t1)
|
||||
self.ct.assign_tags.add(self.t2)
|
||||
self.ct.assign_tags.add(self.t3)
|
||||
self.ct.assign_view_users.add(self.user3.pk)
|
||||
self.ct.assign_view_groups.add(self.group1.pk)
|
||||
self.ct.assign_change_users.add(self.user3.pk)
|
||||
self.ct.assign_change_groups.add(self.group1.pk)
|
||||
self.ct.assign_custom_fields.add(self.cf1.pk)
|
||||
self.ct.assign_custom_fields.add(self.cf2.pk)
|
||||
self.ct.save()
|
||||
|
||||
def test_api_get_consumption_template(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to get all consumption template
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Existing consumption templates are returned
|
||||
"""
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
|
||||
resp_consumption_template = response.data["results"][0]
|
||||
self.assertEqual(resp_consumption_template["id"], self.ct.id)
|
||||
self.assertEqual(
|
||||
resp_consumption_template["assign_correspondent"],
|
||||
self.ct.assign_correspondent.pk,
|
||||
)
|
||||
|
||||
def test_api_create_consumption_template(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP response
|
||||
- New template is created
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_filename": "*test*",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ConsumptionTemplate.objects.count(), 2)
|
||||
|
||||
def test_api_create_invalid_consumption_template(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template
|
||||
- Neither file name nor path filter are specified
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP 400 response
|
||||
- No template is created
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(ConsumptionTemplate.objects.count(), 1)
|
||||
|
||||
def test_api_create_consumption_template_empty_fields(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template
|
||||
- Path or filename filter or assign title are empty string
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Template is created but filter or title assignment is not set if ""
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_filename": "*test*",
|
||||
"filter_path": "",
|
||||
"assign_title": "",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
ct = ConsumptionTemplate.objects.get(name="Template 2")
|
||||
self.assertEqual(ct.filter_filename, "*test*")
|
||||
self.assertIsNone(ct.filter_path)
|
||||
self.assertIsNone(ct.assign_title)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 3",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_filename": "",
|
||||
"filter_path": "*/test/*",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
ct2 = ConsumptionTemplate.objects.get(name="Template 3")
|
||||
self.assertEqual(ct2.filter_path, "*/test/*")
|
||||
self.assertIsNone(ct2.filter_filename)
|
||||
|
||||
def test_api_create_consumption_template_with_mailrule(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a consumption template with a mail rule but no MailFetch source
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- New template is created with MailFetch as source
|
||||
"""
|
||||
account1 = MailAccount.objects.create(
|
||||
name="Email1",
|
||||
username="username1",
|
||||
password="password1",
|
||||
imap_server="server.example.com",
|
||||
imap_port=443,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
)
|
||||
rule1 = MailRule.objects.create(
|
||||
name="Rule1",
|
||||
account=account1,
|
||||
folder="INBOX",
|
||||
filter_from="from@example.com",
|
||||
filter_to="someone@somewhere.com",
|
||||
filter_subject="subject",
|
||||
filter_body="body",
|
||||
filter_attachment_filename_include="file.pdf",
|
||||
maximum_age=30,
|
||||
action=MailRule.MailAction.MARK_READ,
|
||||
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
|
||||
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
|
||||
order=0,
|
||||
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||
)
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Template 2",
|
||||
"order": 1,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_mailrule": rule1.pk,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ConsumptionTemplate.objects.count(), 2)
|
||||
ct = ConsumptionTemplate.objects.get(name="Template 2")
|
||||
self.assertEqual(ct.sources, [int(DocumentSource.MailFetch).__str__()])
|
435
src/documents/tests/test_api_workflows.py
Normal file
435
src/documents/tests/test_api_workflows.py
Normal file
@ -0,0 +1,435 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
ENDPOINT = "/api/workflows/"
|
||||
ENDPOINT_TRIGGERS = "/api/workflow_triggers/"
|
||||
ENDPOINT_ACTIONS = "/api/workflow_actions/"
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
user = User.objects.create_superuser(username="temp_admin")
|
||||
self.client.force_authenticate(user=user)
|
||||
self.user2 = User.objects.create(username="user2")
|
||||
self.user3 = User.objects.create(username="user3")
|
||||
self.group1 = Group.objects.create(name="group1")
|
||||
|
||||
self.c = Correspondent.objects.create(name="Correspondent Name")
|
||||
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
|
||||
self.dt = DocumentType.objects.create(name="DocType Name")
|
||||
self.dt2 = DocumentType.objects.create(name="DocType Name 2")
|
||||
self.t1 = Tag.objects.create(name="t1")
|
||||
self.t2 = Tag.objects.create(name="t2")
|
||||
self.t3 = Tag.objects.create(name="t3")
|
||||
self.sp = StoragePath.objects.create(name="Storage Path 1", path="/test/")
|
||||
self.sp2 = StoragePath.objects.create(name="Storage Path 2", path="/test2/")
|
||||
self.cf1 = CustomField.objects.create(name="Custom Field 1", data_type="string")
|
||||
self.cf2 = CustomField.objects.create(
|
||||
name="Custom Field 2",
|
||||
data_type="integer",
|
||||
)
|
||||
|
||||
self.trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}",
|
||||
filter_filename="*simple*",
|
||||
filter_path="*/samples/*",
|
||||
)
|
||||
self.action = WorkflowAction.objects.create(
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
self.action.assign_tags.add(self.t1)
|
||||
self.action.assign_tags.add(self.t2)
|
||||
self.action.assign_tags.add(self.t3)
|
||||
self.action.assign_view_users.add(self.user3.pk)
|
||||
self.action.assign_view_groups.add(self.group1.pk)
|
||||
self.action.assign_change_users.add(self.user3.pk)
|
||||
self.action.assign_change_groups.add(self.group1.pk)
|
||||
self.action.assign_custom_fields.add(self.cf1.pk)
|
||||
self.action.assign_custom_fields.add(self.cf2.pk)
|
||||
self.action.save()
|
||||
|
||||
self.workflow = Workflow.objects.create(
|
||||
name="Workflow 1",
|
||||
order=0,
|
||||
)
|
||||
self.workflow.triggers.add(self.trigger)
|
||||
self.workflow.actions.add(self.action)
|
||||
self.workflow.save()
|
||||
|
||||
def test_api_get_workflow(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to get all workflows
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Existing workflows are returned
|
||||
"""
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
|
||||
resp_workflow = response.data["results"][0]
|
||||
self.assertEqual(resp_workflow["id"], self.workflow.id)
|
||||
self.assertEqual(
|
||||
resp_workflow["actions"][0]["assign_correspondent"],
|
||||
self.action.assign_correspondent.pk,
|
||||
)
|
||||
|
||||
def test_api_create_workflow(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a workflow, trigger and action separately
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP response
|
||||
- New workflow, trigger and action are created
|
||||
"""
|
||||
trigger_response = self.client.post(
|
||||
self.ENDPOINT_TRIGGERS,
|
||||
json.dumps(
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_filename": "*",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(trigger_response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
action_response = self.client.post(
|
||||
self.ENDPOINT_ACTIONS,
|
||||
json.dumps(
|
||||
{
|
||||
"assign_title": "Action Title",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(action_response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Workflow 2",
|
||||
"order": 1,
|
||||
"triggers": [
|
||||
{
|
||||
"id": trigger_response.data["id"],
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"type": trigger_response.data["type"],
|
||||
"filter_filename": trigger_response.data["filter_filename"],
|
||||
},
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": action_response.data["id"],
|
||||
"assign_title": action_response.data["assign_title"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Workflow.objects.count(), 2)
|
||||
|
||||
def test_api_create_workflow_nested(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a workflow with nested trigger and action
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP response
|
||||
- New workflow, trigger and action are created
|
||||
"""
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Workflow 2",
|
||||
"order": 1,
|
||||
"triggers": [
|
||||
{
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
"filter_filename": "*",
|
||||
"filter_path": "*/samples/*",
|
||||
"filter_has_tags": [self.t1.id],
|
||||
"filter_has_document_type": self.dt.id,
|
||||
"filter_has_correspondent": self.c.id,
|
||||
},
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"assign_title": "Action Title",
|
||||
"assign_tags": [self.t2.id],
|
||||
"assign_document_type": self.dt2.id,
|
||||
"assign_correspondent": self.c2.id,
|
||||
"assign_storage_path": self.sp2.id,
|
||||
"assign_owner": self.user2.id,
|
||||
"assign_view_users": [self.user2.id],
|
||||
"assign_view_groups": [self.group1.id],
|
||||
"assign_change_users": [self.user2.id],
|
||||
"assign_change_groups": [self.group1.id],
|
||||
"assign_custom_fields": [self.cf2.id],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Workflow.objects.count(), 2)
|
||||
|
||||
def test_api_create_invalid_workflow_trigger(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a workflow trigger
|
||||
- Neither type or file name nor path filter are specified
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Correct HTTP 400 response
|
||||
- No objects are created
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT_TRIGGERS,
|
||||
json.dumps(
|
||||
{
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT_TRIGGERS,
|
||||
json.dumps(
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
self.assertEqual(WorkflowTrigger.objects.count(), 1)
|
||||
|
||||
def test_api_create_workflow_trigger_action_empty_fields(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a workflow trigger and action
|
||||
- Path or filename filter or assign title are empty string
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Template is created but filter or title assignment is not set if ""
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT_TRIGGERS,
|
||||
json.dumps(
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_filename": "*test*",
|
||||
"filter_path": "",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
trigger = WorkflowTrigger.objects.get(id=response.data["id"])
|
||||
self.assertEqual(trigger.filter_filename, "*test*")
|
||||
self.assertIsNone(trigger.filter_path)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT_ACTIONS,
|
||||
json.dumps(
|
||||
{
|
||||
"assign_title": "",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
action = WorkflowAction.objects.get(id=response.data["id"])
|
||||
self.assertIsNone(action.assign_title)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT_TRIGGERS,
|
||||
json.dumps(
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_filename": "",
|
||||
"filter_path": "*/test/*",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
trigger2 = WorkflowTrigger.objects.get(id=response.data["id"])
|
||||
self.assertEqual(trigger2.filter_path, "*/test/*")
|
||||
self.assertIsNone(trigger2.filter_filename)
|
||||
|
||||
def test_api_create_workflow_trigger_with_mailrule(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a workflow trigger with a mail rule but no MailFetch source
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- New trigger is created with MailFetch as source
|
||||
"""
|
||||
account1 = MailAccount.objects.create(
|
||||
name="Email1",
|
||||
username="username1",
|
||||
password="password1",
|
||||
imap_server="server.example.com",
|
||||
imap_port=443,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
)
|
||||
rule1 = MailRule.objects.create(
|
||||
name="Rule1",
|
||||
account=account1,
|
||||
folder="INBOX",
|
||||
filter_from="from@example.com",
|
||||
filter_to="someone@somewhere.com",
|
||||
filter_subject="subject",
|
||||
filter_body="body",
|
||||
filter_attachment_filename_include="file.pdf",
|
||||
maximum_age=30,
|
||||
action=MailRule.MailAction.MARK_READ,
|
||||
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
|
||||
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
|
||||
order=0,
|
||||
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||
)
|
||||
response = self.client.post(
|
||||
self.ENDPOINT_TRIGGERS,
|
||||
json.dumps(
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
"sources": [DocumentSource.ApiUpload],
|
||||
"filter_mailrule": rule1.pk,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(WorkflowTrigger.objects.count(), 2)
|
||||
trigger = WorkflowTrigger.objects.get(id=response.data["id"])
|
||||
self.assertEqual(trigger.sources, [int(DocumentSource.MailFetch).__str__()])
|
||||
|
||||
def test_api_update_workflow_nested_triggers_actions(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing workflow with trigger and action
|
||||
WHEN:
|
||||
- API request to update an existing workflow with nested triggers actions
|
||||
THEN:
|
||||
- Triggers and actions are updated
|
||||
"""
|
||||
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}{self.workflow.id}/",
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Workflow Updated",
|
||||
"order": 1,
|
||||
"triggers": [
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
"filter_has_tags": [self.t1.id],
|
||||
"filter_has_correspondent": self.c.id,
|
||||
"filter_has_document_type": self.dt.id,
|
||||
},
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"assign_title": "Action New Title",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
workflow = Workflow.objects.get(id=response.data["id"])
|
||||
self.assertEqual(workflow.name, "Workflow Updated")
|
||||
self.assertEqual(workflow.triggers.first().filter_has_tags.first(), self.t1)
|
||||
self.assertEqual(workflow.actions.first().assign_title, "Action New Title")
|
||||
|
||||
def test_api_auto_remove_orphaned_triggers_actions(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing trigger and action
|
||||
WHEN:
|
||||
- API request is made which creates new trigger / actions
|
||||
THEN:
|
||||
- "Orphaned" triggers and actions are removed
|
||||
"""
|
||||
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}{self.workflow.id}/",
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Workflow Updated",
|
||||
"order": 1,
|
||||
"triggers": [
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
"filter_has_tags": [self.t1.id],
|
||||
"filter_has_correspondent": self.c.id,
|
||||
"filter_has_document_type": self.dt.id,
|
||||
},
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"assign_title": "Action New Title",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
workflow = Workflow.objects.get(id=response.data["id"])
|
||||
self.assertEqual(WorkflowTrigger.objects.all().count(), 1)
|
||||
self.assertNotEqual(workflow.triggers.first().id, self.trigger.id)
|
||||
self.assertEqual(WorkflowAction.objects.all().count(), 1)
|
||||
self.assertNotEqual(workflow.actions.first().id, self.action.id)
|
@ -1,539 +0,0 @@
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents import tasks
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
SAMPLE_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.c = Correspondent.objects.create(name="Correspondent Name")
|
||||
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
|
||||
self.dt = DocumentType.objects.create(name="DocType Name")
|
||||
self.t1 = Tag.objects.create(name="t1")
|
||||
self.t2 = Tag.objects.create(name="t2")
|
||||
self.t3 = Tag.objects.create(name="t3")
|
||||
self.sp = StoragePath.objects.create(path="/test/")
|
||||
self.cf1 = CustomField.objects.create(name="Custom Field 1", data_type="string")
|
||||
self.cf2 = CustomField.objects.create(
|
||||
name="Custom Field 2",
|
||||
data_type="integer",
|
||||
)
|
||||
|
||||
self.user2 = User.objects.create(username="user2")
|
||||
self.user3 = User.objects.create(username="user3")
|
||||
self.group1 = Group.objects.create(name="group1")
|
||||
|
||||
account1 = MailAccount.objects.create(
|
||||
name="Email1",
|
||||
username="username1",
|
||||
password="password1",
|
||||
imap_server="server.example.com",
|
||||
imap_port=443,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
)
|
||||
self.rule1 = MailRule.objects.create(
|
||||
name="Rule1",
|
||||
account=account1,
|
||||
folder="INBOX",
|
||||
filter_from="from@example.com",
|
||||
filter_to="someone@somewhere.com",
|
||||
filter_subject="subject",
|
||||
filter_body="body",
|
||||
filter_attachment_filename_include="file.pdf",
|
||||
maximum_age=30,
|
||||
action=MailRule.MailAction.MARK_READ,
|
||||
assign_title_from=MailRule.TitleSource.NONE,
|
||||
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
|
||||
order=0,
|
||||
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
|
||||
assign_owner_from_rule=False,
|
||||
)
|
||||
|
||||
return super().setUp()
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_match(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that matches is consumed
|
||||
THEN:
|
||||
- Template overrides are applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*simple*",
|
||||
filter_path="*/samples/*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
ct.assign_tags.add(self.t1)
|
||||
ct.assign_tags.add(self.t2)
|
||||
ct.assign_tags.add(self.t3)
|
||||
ct.assign_view_users.add(self.user3.pk)
|
||||
ct.assign_view_groups.add(self.group1.pk)
|
||||
ct.assign_change_users.add(self.user3.pk)
|
||||
ct.assign_change_groups.add(self.group1.pk)
|
||||
ct.assign_custom_fields.add(self.cf1.pk)
|
||||
ct.assign_custom_fields.add(self.cf2.pk)
|
||||
ct.save()
|
||||
|
||||
self.assertEqual(ct.__str__(), "Template 1")
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
|
||||
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
|
||||
self.assertEqual(
|
||||
overrides["override_tag_ids"],
|
||||
[self.t1.pk, self.t2.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
|
||||
self.assertEqual(overrides["override_owner_id"], self.user2.pk)
|
||||
self.assertEqual(overrides["override_view_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
|
||||
self.assertEqual(overrides["override_change_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
|
||||
self.assertEqual(
|
||||
overrides["override_title"],
|
||||
"Doc from {correspondent}",
|
||||
)
|
||||
self.assertEqual(
|
||||
overrides["override_custom_field_ids"],
|
||||
[self.cf1.pk, self.cf2.pk],
|
||||
)
|
||||
|
||||
info = cm.output[0]
|
||||
expected_str = f"Document matched template {ct}"
|
||||
self.assertIn(expected_str, info)
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_match_mailrule(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that matches is consumed via mail rule
|
||||
THEN:
|
||||
- Template overrides are applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_mailrule=self.rule1,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
ct.assign_tags.add(self.t1)
|
||||
ct.assign_tags.add(self.t2)
|
||||
ct.assign_tags.add(self.t3)
|
||||
ct.assign_view_users.add(self.user3.pk)
|
||||
ct.assign_view_groups.add(self.group1.pk)
|
||||
ct.assign_change_users.add(self.user3.pk)
|
||||
ct.assign_change_groups.add(self.group1.pk)
|
||||
ct.save()
|
||||
|
||||
self.assertEqual(ct.__str__(), "Template 1")
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
mailrule_id=self.rule1.pk,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
|
||||
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
|
||||
self.assertEqual(
|
||||
overrides["override_tag_ids"],
|
||||
[self.t1.pk, self.t2.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
|
||||
self.assertEqual(overrides["override_owner_id"], self.user2.pk)
|
||||
self.assertEqual(overrides["override_view_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
|
||||
self.assertEqual(overrides["override_change_users"], [self.user3.pk])
|
||||
self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
|
||||
self.assertEqual(
|
||||
overrides["override_title"],
|
||||
"Doc from {correspondent}",
|
||||
)
|
||||
|
||||
info = cm.output[0]
|
||||
expected_str = f"Document matched template {ct}"
|
||||
self.assertIn(expected_str, info)
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_match_multiple(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Multiple existing consumption template
|
||||
WHEN:
|
||||
- File that matches is consumed
|
||||
THEN:
|
||||
- Template overrides are applied with subsequent templates only overwriting empty values
|
||||
or merging if multiple
|
||||
"""
|
||||
ct1 = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_path="*/samples/*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
)
|
||||
ct1.assign_tags.add(self.t1)
|
||||
ct1.assign_tags.add(self.t2)
|
||||
ct1.assign_view_users.add(self.user2)
|
||||
ct1.save()
|
||||
ct2 = ConsumptionTemplate.objects.create(
|
||||
name="Template 2",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*simple*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c2,
|
||||
assign_storage_path=self.sp,
|
||||
)
|
||||
ct2.assign_tags.add(self.t3)
|
||||
ct1.assign_view_users.add(self.user3)
|
||||
ct2.save()
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
# template 1
|
||||
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
|
||||
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
|
||||
# template 2
|
||||
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
|
||||
# template 1 & 2
|
||||
self.assertEqual(
|
||||
overrides["override_tag_ids"],
|
||||
[self.t1.pk, self.t2.pk, self.t3.pk],
|
||||
)
|
||||
self.assertEqual(
|
||||
overrides["override_view_users"],
|
||||
[self.user2.pk, self.user3.pk],
|
||||
)
|
||||
|
||||
expected_str = f"Document matched template {ct1}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document matched template {ct2}"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_filename(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on filename is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*foobar*",
|
||||
filter_path=None,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document filename {test_file.name} does not match"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_path(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on path is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_path="*foo/bar*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document path {test_file} does not match"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_mail_rule(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on source is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_mailrule=self.rule1,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
mailrule_id=99,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = "Document mail rule 99 !="
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_no_match_source(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption template
|
||||
WHEN:
|
||||
- File that does not match on source is consumed
|
||||
THEN:
|
||||
- Template overrides are not applied
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_path="*",
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=self.c,
|
||||
assign_document_type=self.dt,
|
||||
assign_storage_path=self.sp,
|
||||
assign_owner=self.user2,
|
||||
)
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ApiUpload,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertIsNone(overrides["override_correspondent_id"])
|
||||
self.assertIsNone(overrides["override_document_type_id"])
|
||||
self.assertIsNone(overrides["override_tag_ids"])
|
||||
self.assertIsNone(overrides["override_storage_path_id"])
|
||||
self.assertIsNone(overrides["override_owner_id"])
|
||||
self.assertIsNone(overrides["override_view_users"])
|
||||
self.assertIsNone(overrides["override_view_groups"])
|
||||
self.assertIsNone(overrides["override_change_users"])
|
||||
self.assertIsNone(overrides["override_change_groups"])
|
||||
self.assertIsNone(overrides["override_title"])
|
||||
|
||||
expected_str = f"Document did not match template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document source {DocumentSource.ApiUpload.name} not in ['{DocumentSource.ConsumeFolder.name}', '{DocumentSource.MailFetch.name}']"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||
def test_consumption_template_repeat_custom_fields(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing consumption templates which assign the same custom field
|
||||
WHEN:
|
||||
- File that matches is consumed
|
||||
THEN:
|
||||
- Custom field is added the first time successfully
|
||||
"""
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*simple*",
|
||||
)
|
||||
ct.assign_custom_fields.add(self.cf1.pk)
|
||||
ct.save()
|
||||
|
||||
ct2 = ConsumptionTemplate.objects.create(
|
||||
name="Template 2",
|
||||
order=1,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*simple*",
|
||||
)
|
||||
ct2.assign_custom_fields.add(self.cf1.pk)
|
||||
ct2.save()
|
||||
|
||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||
|
||||
with mock.patch("documents.tasks.async_to_sync"):
|
||||
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
||||
tasks.consume_file(
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=test_file,
|
||||
),
|
||||
None,
|
||||
)
|
||||
m.assert_called_once()
|
||||
_, overrides = m.call_args
|
||||
self.assertEqual(
|
||||
overrides["override_custom_field_ids"],
|
||||
[self.cf1.pk],
|
||||
)
|
||||
|
||||
expected_str = f"Document matched template {ct}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = f"Document matched template {ct2}"
|
||||
self.assertIn(expected_str, cm.output[1])
|
@ -21,7 +21,6 @@ from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
|
||||
from documents.management.commands import document_exporter
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@ -31,6 +30,9 @@ from documents.models import Note
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import User
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.sanity_checker import check_sanity
|
||||
from documents.settings import EXPORTER_FILE_NAME
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
@ -109,7 +111,16 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.d4.storage_path = self.sp1
|
||||
self.d4.save()
|
||||
|
||||
self.ct1 = ConsumptionTemplate.objects.create(name="CT 1", filter_path="*")
|
||||
self.trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
sources=[1],
|
||||
filter_filename="*",
|
||||
)
|
||||
self.action = WorkflowAction.objects.create(assign_title="new title")
|
||||
self.workflow = Workflow.objects.create(name="Workflow 1", order="0")
|
||||
self.workflow.triggers.add(self.trigger)
|
||||
self.workflow.actions.add(self.action)
|
||||
self.workflow.save()
|
||||
|
||||
super().setUp()
|
||||
|
||||
@ -168,7 +179,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
manifest = self._do_export(use_filename_format=use_filename_format)
|
||||
|
||||
self.assertEqual(len(manifest), 178)
|
||||
self.assertEqual(len(manifest), 190)
|
||||
|
||||
# dont include consumer or AnonymousUser users
|
||||
self.assertEqual(
|
||||
@ -262,7 +273,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
|
||||
self.assertEqual(GroupObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(UserObjectPermission.objects.count(), 1)
|
||||
self.assertEqual(Permission.objects.count(), 128)
|
||||
self.assertEqual(Permission.objects.count(), 136)
|
||||
messages = check_sanity()
|
||||
# everything is alright after the test
|
||||
self.assertEqual(len(messages), 0)
|
||||
@ -694,15 +705,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
os.path.join(self.dirs.media_dir, "documents"),
|
||||
)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 32)
|
||||
self.assertEqual(Permission.objects.count(), 128)
|
||||
self.assertEqual(ContentType.objects.count(), 34)
|
||||
self.assertEqual(Permission.objects.count(), 136)
|
||||
|
||||
manifest = self._do_export()
|
||||
|
||||
with paperless_environment():
|
||||
self.assertEqual(
|
||||
len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
|
||||
128,
|
||||
136,
|
||||
)
|
||||
# add 1 more to db to show objects are not re-created by import
|
||||
Permission.objects.create(
|
||||
@ -710,7 +721,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
codename="test_perm",
|
||||
content_type_id=1,
|
||||
)
|
||||
self.assertEqual(Permission.objects.count(), 129)
|
||||
self.assertEqual(Permission.objects.count(), 137)
|
||||
|
||||
# will cause an import error
|
||||
self.user.delete()
|
||||
@ -719,5 +730,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
with self.assertRaises(IntegrityError):
|
||||
call_command("document_importer", "--no-progress-bar", self.target)
|
||||
|
||||
self.assertEqual(ContentType.objects.count(), 32)
|
||||
self.assertEqual(Permission.objects.count(), 129)
|
||||
self.assertEqual(ContentType.objects.count(), 34)
|
||||
self.assertEqual(Permission.objects.count(), 137)
|
||||
|
@ -33,11 +33,18 @@ class TestReverseMigrateConsumptionTemplate(TestMigrations):
|
||||
self.Permission = apps.get_model("auth", "Permission")
|
||||
self.user = User.objects.create(username="user1")
|
||||
self.group = Group.objects.create(name="group1")
|
||||
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
|
||||
permission = self.Permission.objects.filter(
|
||||
codename="add_consumptiontemplate",
|
||||
).first()
|
||||
if permission is not None:
|
||||
self.user.user_permissions.add(permission.id)
|
||||
self.group.permissions.add(permission.id)
|
||||
|
||||
def test_remove_consumptiontemplate_permissions(self):
|
||||
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
|
||||
permission = self.Permission.objects.filter(
|
||||
codename="add_consumptiontemplate",
|
||||
).first()
|
||||
# can be None ? now that CTs removed
|
||||
if permission is not None:
|
||||
self.assertFalse(self.user.has_perm(f"documents.{permission.codename}"))
|
||||
self.assertFalse(permission in self.group.permissions.all())
|
||||
|
131
src/documents/tests/test_migration_workflows.py
Normal file
131
src/documents/tests/test_migration_workflows.py
Normal file
@ -0,0 +1,131 @@
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.tests.utils import TestMigrations
|
||||
|
||||
|
||||
class TestMigrateWorkflow(TestMigrations):
|
||||
migrate_from = "1043_alter_savedviewfilterrule_rule_type"
|
||||
migrate_to = "1044_workflow_workflowaction_workflowtrigger_and_more"
|
||||
dependencies = (
|
||||
("paperless_mail", "0023_remove_mailrule_filter_attachment_filename_and_more"),
|
||||
)
|
||||
|
||||
def setUpBeforeMigration(self, apps):
|
||||
User = apps.get_model("auth", "User")
|
||||
Group = apps.get_model("auth", "Group")
|
||||
self.Permission = apps.get_model("auth", "Permission")
|
||||
self.user = User.objects.create(username="user1")
|
||||
self.group = Group.objects.create(name="group1")
|
||||
permission = self.Permission.objects.get(codename="add_document")
|
||||
self.user.user_permissions.add(permission.id)
|
||||
self.group.permissions.add(permission.id)
|
||||
|
||||
# create a CT to migrate
|
||||
c = apps.get_model("documents", "Correspondent").objects.create(
|
||||
name="Correspondent Name",
|
||||
)
|
||||
dt = apps.get_model("documents", "DocumentType").objects.create(
|
||||
name="DocType Name",
|
||||
)
|
||||
t1 = apps.get_model("documents", "Tag").objects.create(name="t1")
|
||||
sp = apps.get_model("documents", "StoragePath").objects.create(path="/test/")
|
||||
cf1 = apps.get_model("documents", "CustomField").objects.create(
|
||||
name="Custom Field 1",
|
||||
data_type="string",
|
||||
)
|
||||
ma = apps.get_model("paperless_mail", "MailAccount").objects.create(
|
||||
name="MailAccount 1",
|
||||
)
|
||||
mr = apps.get_model("paperless_mail", "MailRule").objects.create(
|
||||
name="MailRule 1",
|
||||
order=0,
|
||||
account=ma,
|
||||
)
|
||||
|
||||
user2 = User.objects.create(username="user2")
|
||||
user3 = User.objects.create(username="user3")
|
||||
group2 = Group.objects.create(name="group2")
|
||||
|
||||
ConsumptionTemplate = apps.get_model("documents", "ConsumptionTemplate")
|
||||
|
||||
ct = ConsumptionTemplate.objects.create(
|
||||
name="Template 1",
|
||||
order=0,
|
||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||
filter_filename="*simple*",
|
||||
filter_path="*/samples/*",
|
||||
filter_mailrule=mr,
|
||||
assign_title="Doc from {correspondent}",
|
||||
assign_correspondent=c,
|
||||
assign_document_type=dt,
|
||||
assign_storage_path=sp,
|
||||
assign_owner=user2,
|
||||
)
|
||||
|
||||
ct.assign_tags.add(t1)
|
||||
ct.assign_view_users.add(user3)
|
||||
ct.assign_view_groups.add(group2)
|
||||
ct.assign_change_users.add(user3)
|
||||
ct.assign_change_groups.add(group2)
|
||||
ct.assign_custom_fields.add(cf1)
|
||||
ct.save()
|
||||
|
||||
def test_users_with_add_documents_get_add_and_workflow_templates_get_migrated(self):
|
||||
permission = self.Permission.objects.get(codename="add_workflow")
|
||||
self.assertTrue(permission in self.user.user_permissions.all())
|
||||
self.assertTrue(permission in self.group.permissions.all())
|
||||
|
||||
Workflow = self.apps.get_model("documents", "Workflow")
|
||||
self.assertEqual(Workflow.objects.all().count(), 1)
|
||||
|
||||
|
||||
class TestReverseMigrateWorkflow(TestMigrations):
|
||||
migrate_from = "1044_workflow_workflowaction_workflowtrigger_and_more"
|
||||
migrate_to = "1043_alter_savedviewfilterrule_rule_type"
|
||||
|
||||
def setUpBeforeMigration(self, apps):
|
||||
User = apps.get_model("auth", "User")
|
||||
Group = apps.get_model("auth", "Group")
|
||||
self.Permission = apps.get_model("auth", "Permission")
|
||||
self.user = User.objects.create(username="user1")
|
||||
self.group = Group.objects.create(name="group1")
|
||||
permission = self.Permission.objects.filter(
|
||||
codename="add_workflow",
|
||||
).first()
|
||||
if permission is not None:
|
||||
self.user.user_permissions.add(permission.id)
|
||||
self.group.permissions.add(permission.id)
|
||||
|
||||
Workflow = apps.get_model("documents", "Workflow")
|
||||
WorkflowTrigger = apps.get_model("documents", "WorkflowTrigger")
|
||||
WorkflowAction = apps.get_model("documents", "WorkflowAction")
|
||||
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=0,
|
||||
sources=[DocumentSource.ConsumeFolder],
|
||||
filter_path="*/path/*",
|
||||
filter_filename="*file*",
|
||||
)
|
||||
|
||||
action = WorkflowAction.objects.create(
|
||||
assign_title="assign title",
|
||||
)
|
||||
workflow = Workflow.objects.create(
|
||||
name="workflow 1",
|
||||
order=0,
|
||||
)
|
||||
workflow.triggers.set([trigger])
|
||||
workflow.actions.set([action])
|
||||
workflow.save()
|
||||
|
||||
def test_remove_workflow_permissions_and_migrate_workflows_to_consumption_templates(
|
||||
self,
|
||||
):
|
||||
permission = self.Permission.objects.filter(
|
||||
codename="add_workflow",
|
||||
).first()
|
||||
if permission is not None:
|
||||
self.assertFalse(permission in self.user.user_permissions.all())
|
||||
self.assertFalse(permission in self.group.permissions.all())
|
||||
|
||||
ConsumptionTemplate = self.apps.get_model("documents", "ConsumptionTemplate")
|
||||
self.assertEqual(ConsumptionTemplate.objects.all().count(), 1)
|
1017
src/documents/tests/test_workflows.py
Normal file
1017
src/documents/tests/test_workflows.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -265,6 +265,7 @@ class TestMigrations(TransactionTestCase):
|
||||
return apps.get_containing_app_config(type(self).__module__).name
|
||||
|
||||
migrate_from = None
|
||||
dependencies = None
|
||||
migrate_to = None
|
||||
auto_migrate = True
|
||||
|
||||
@ -277,6 +278,8 @@ class TestMigrations(TransactionTestCase):
|
||||
type(self).__name__,
|
||||
)
|
||||
self.migrate_from = [(self.app, self.migrate_from)]
|
||||
if self.dependencies is not None:
|
||||
self.migrate_from.extend(self.dependencies)
|
||||
self.migrate_to = [(self.app, self.migrate_to)]
|
||||
executor = MigrationExecutor(connection)
|
||||
old_apps = executor.loader.project_state(self.migrate_from).apps
|
||||
|
@ -76,7 +76,6 @@ from documents.matching import match_correspondents
|
||||
from documents.matching import match_document_types
|
||||
from documents.matching import match_storage_paths
|
||||
from documents.matching import match_tags
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import Document
|
||||
@ -87,6 +86,9 @@ from documents.models import SavedView
|
||||
from documents.models import ShareLink
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import get_parser_class_for_mime_type
|
||||
from documents.parsers import parse_date_generator
|
||||
from documents.permissions import PaperlessAdminPermissions
|
||||
@ -98,7 +100,6 @@ from documents.serialisers import AcknowledgeTasksViewSerializer
|
||||
from documents.serialisers import BulkDownloadSerializer
|
||||
from documents.serialisers import BulkEditObjectPermissionsSerializer
|
||||
from documents.serialisers import BulkEditSerializer
|
||||
from documents.serialisers import ConsumptionTemplateSerializer
|
||||
from documents.serialisers import CorrespondentSerializer
|
||||
from documents.serialisers import CustomFieldSerializer
|
||||
from documents.serialisers import DocumentListSerializer
|
||||
@ -112,6 +113,10 @@ from documents.serialisers import TagSerializer
|
||||
from documents.serialisers import TagSerializerVersion1
|
||||
from documents.serialisers import TasksViewSerializer
|
||||
from documents.serialisers import UiSettingsViewSerializer
|
||||
from documents.serialisers import WorkflowActionSerializer
|
||||
from documents.serialisers import WorkflowSerializer
|
||||
from documents.serialisers import WorkflowTriggerSerializer
|
||||
from documents.signals import document_updated
|
||||
from documents.tasks import consume_file
|
||||
from paperless import version
|
||||
from paperless.db import GnuPG
|
||||
@ -320,6 +325,12 @@ class DocumentViewSet(
|
||||
from documents import index
|
||||
|
||||
index.add_or_update_document(self.get_object())
|
||||
|
||||
document_updated.send(
|
||||
sender=self.__class__,
|
||||
document=self.get_object(),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
@ -1373,16 +1384,26 @@ class BulkEditObjectPermissionsView(GenericAPIView, PassUserMixin):
|
||||
)
|
||||
|
||||
|
||||
class ConsumptionTemplateViewSet(ModelViewSet):
|
||||
class WorkflowTriggerViewSet(ModelViewSet):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = ConsumptionTemplateSerializer
|
||||
serializer_class = WorkflowTriggerSerializer
|
||||
pagination_class = StandardPagination
|
||||
|
||||
model = ConsumptionTemplate
|
||||
model = WorkflowTrigger
|
||||
|
||||
queryset = (
|
||||
ConsumptionTemplate.objects.prefetch_related(
|
||||
queryset = WorkflowTrigger.objects.all()
|
||||
|
||||
|
||||
class WorkflowActionViewSet(ModelViewSet):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = WorkflowActionSerializer
|
||||
pagination_class = StandardPagination
|
||||
|
||||
model = WorkflowAction
|
||||
|
||||
queryset = WorkflowAction.objects.all().prefetch_related(
|
||||
"assign_tags",
|
||||
"assign_view_users",
|
||||
"assign_view_groups",
|
||||
@ -1390,8 +1411,23 @@ class ConsumptionTemplateViewSet(ModelViewSet):
|
||||
"assign_change_groups",
|
||||
"assign_custom_fields",
|
||||
)
|
||||
.all()
|
||||
|
||||
|
||||
class WorkflowViewSet(ModelViewSet):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = WorkflowSerializer
|
||||
pagination_class = StandardPagination
|
||||
|
||||
model = Workflow
|
||||
|
||||
queryset = (
|
||||
Workflow.objects.all()
|
||||
.order_by("order")
|
||||
.prefetch_related(
|
||||
"triggers",
|
||||
"actions",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-09 10:53-0800\n"
|
||||
"POT-Creation-Date: 2024-01-01 07:54-0800\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@ -25,27 +25,27 @@ msgstr ""
|
||||
msgid "owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:53
|
||||
#: documents/models.py:53 documents/models.py:894
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:54
|
||||
#: documents/models.py:54 documents/models.py:895
|
||||
msgid "Any word"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:55
|
||||
#: documents/models.py:55 documents/models.py:896
|
||||
msgid "All words"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:56
|
||||
#: documents/models.py:56 documents/models.py:897
|
||||
msgid "Exact match"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:57
|
||||
#: documents/models.py:57 documents/models.py:898
|
||||
msgid "Regular expression"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:58
|
||||
#: documents/models.py:58 documents/models.py:899
|
||||
msgid "Fuzzy word"
|
||||
msgstr ""
|
||||
|
||||
@ -53,20 +53,20 @@ msgstr ""
|
||||
msgid "Automatic"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:62 documents/models.py:402 documents/models.py:897
|
||||
#: documents/models.py:62 documents/models.py:402 documents/models.py:1099
|
||||
#: paperless_mail/models.py:18 paperless_mail/models.py:93
|
||||
msgid "name"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:64
|
||||
#: documents/models.py:64 documents/models.py:955
|
||||
msgid "match"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:67
|
||||
#: documents/models.py:67 documents/models.py:958
|
||||
msgid "matching algorithm"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:72
|
||||
#: documents/models.py:72 documents/models.py:963
|
||||
msgid "is insensitive"
|
||||
msgstr ""
|
||||
|
||||
@ -615,118 +615,174 @@ msgstr ""
|
||||
msgid "custom field instances"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:893
|
||||
#: documents/models.py:902
|
||||
msgid "Consumption Started"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:903
|
||||
msgid "Document Added"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:904
|
||||
msgid "Document Updated"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:907
|
||||
msgid "Consume Folder"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:894
|
||||
#: documents/models.py:908
|
||||
msgid "Api Upload"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:895
|
||||
#: documents/models.py:909
|
||||
msgid "Mail Fetch"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:899 paperless_mail/models.py:95
|
||||
msgid "order"
|
||||
#: documents/models.py:912
|
||||
msgid "Workflow Trigger Type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:908
|
||||
#: documents/models.py:924
|
||||
msgid "filter path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:913
|
||||
#: documents/models.py:929
|
||||
msgid ""
|
||||
"Only consume documents with a path that matches this if specified. Wildcards "
|
||||
"specified as * are allowed. Case insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:920
|
||||
#: documents/models.py:936
|
||||
msgid "filter filename"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:925 paperless_mail/models.py:148
|
||||
#: documents/models.py:941 paperless_mail/models.py:148
|
||||
msgid ""
|
||||
"Only consume documents which entirely match this filename if specified. "
|
||||
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:936
|
||||
#: documents/models.py:952
|
||||
msgid "filter documents from this mail rule"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:940
|
||||
#: documents/models.py:968
|
||||
msgid "has these tag(s)"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:976
|
||||
msgid "has this document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:984
|
||||
msgid "has this correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:988
|
||||
msgid "workflow trigger"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:989
|
||||
msgid "workflow triggers"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:997
|
||||
msgid "Assignment"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1000
|
||||
msgid "Workflow Action Type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1006
|
||||
msgid "assign title"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:945
|
||||
#: documents/models.py:1011
|
||||
msgid ""
|
||||
"Assign a document title, can include some placeholders, see documentation."
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:953 paperless_mail/models.py:216
|
||||
#: documents/models.py:1019 paperless_mail/models.py:216
|
||||
msgid "assign this tag"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:961 paperless_mail/models.py:224
|
||||
#: documents/models.py:1027 paperless_mail/models.py:224
|
||||
msgid "assign this document type"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:969 paperless_mail/models.py:238
|
||||
#: documents/models.py:1035 paperless_mail/models.py:238
|
||||
msgid "assign this correspondent"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:977
|
||||
#: documents/models.py:1043
|
||||
msgid "assign this storage path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:986
|
||||
#: documents/models.py:1052
|
||||
msgid "assign this owner"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:993
|
||||
#: documents/models.py:1059
|
||||
msgid "grant view permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1000
|
||||
#: documents/models.py:1066
|
||||
msgid "grant view permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1007
|
||||
#: documents/models.py:1073
|
||||
msgid "grant change permissions to these users"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1014
|
||||
#: documents/models.py:1080
|
||||
msgid "grant change permissions to these groups"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1021
|
||||
#: documents/models.py:1087
|
||||
msgid "assign these custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1025
|
||||
msgid "consumption template"
|
||||
#: documents/models.py:1091
|
||||
msgid "workflow action"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1026
|
||||
msgid "consumption templates"
|
||||
#: documents/models.py:1092
|
||||
msgid "workflow actions"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:105
|
||||
#: documents/models.py:1101 paperless_mail/models.py:95
|
||||
msgid "order"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1107
|
||||
msgid "triggers"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1114
|
||||
msgid "actions"
|
||||
msgstr ""
|
||||
|
||||
#: documents/models.py:1117
|
||||
msgid "enabled"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:111
|
||||
#, python-format
|
||||
msgid "Invalid regular expression: %(error)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:399
|
||||
#: documents/serialisers.py:405
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:865
|
||||
#: documents/serialisers.py:988
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:962
|
||||
#: documents/serialisers.py:1085
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
@ -869,135 +925,286 @@ msgstr ""
|
||||
msgid "Send me instructions!"
|
||||
msgstr ""
|
||||
|
||||
#: documents/validators.py:17
|
||||
#, python-brace-format
|
||||
msgid "Unable to parse URI {value}, missing scheme"
|
||||
msgstr ""
|
||||
|
||||
#: documents/validators.py:22
|
||||
#, python-brace-format
|
||||
msgid "Unable to parse URI {value}, missing net location or path"
|
||||
msgstr ""
|
||||
|
||||
#: documents/validators.py:27
|
||||
#, python-brace-format
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/apps.py:10
|
||||
msgid "Paperless"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:586
|
||||
msgid "English (US)"
|
||||
#: paperless/models.py:25
|
||||
msgid "pdf"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:587
|
||||
msgid "Arabic"
|
||||
#: paperless/models.py:26
|
||||
msgid "pdfa"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:588
|
||||
msgid "Afrikaans"
|
||||
#: paperless/models.py:27
|
||||
msgid "pdfa-1"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:589
|
||||
msgid "Belarusian"
|
||||
#: paperless/models.py:28
|
||||
msgid "pdfa-2"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:590
|
||||
msgid "Bulgarian"
|
||||
#: paperless/models.py:29
|
||||
msgid "pdfa-3"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:591
|
||||
msgid "Catalan"
|
||||
#: paperless/models.py:38
|
||||
msgid "skip"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:592
|
||||
msgid "Czech"
|
||||
#: paperless/models.py:39
|
||||
msgid "redo"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:593
|
||||
msgid "Danish"
|
||||
#: paperless/models.py:40
|
||||
msgid "force"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:594
|
||||
msgid "German"
|
||||
#: paperless/models.py:41
|
||||
msgid "skip_noarchive"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:595
|
||||
msgid "Greek"
|
||||
#: paperless/models.py:49
|
||||
msgid "never"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:596
|
||||
msgid "English (GB)"
|
||||
#: paperless/models.py:50
|
||||
msgid "with_text"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:597
|
||||
msgid "Spanish"
|
||||
#: paperless/models.py:51
|
||||
msgid "always"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:598
|
||||
msgid "Finnish"
|
||||
#: paperless/models.py:59
|
||||
msgid "clean"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:599
|
||||
msgid "French"
|
||||
#: paperless/models.py:60
|
||||
msgid "clean-final"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:600
|
||||
msgid "Hungarian"
|
||||
#: paperless/models.py:61
|
||||
msgid "none"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:69
|
||||
msgid "LeaveColorUnchanged"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:70
|
||||
msgid "RGB"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:71
|
||||
msgid "UseDeviceIndependentColor"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:72
|
||||
msgid "Gray"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:73
|
||||
msgid "CMYK"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:82
|
||||
msgid "Sets the output PDF type"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:94
|
||||
msgid "Do OCR from page 1 to this value"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:100
|
||||
msgid "Do OCR using these languages"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:107
|
||||
msgid "Sets the OCR mode"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:115
|
||||
msgid "Controls the generation of an archive file"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:123
|
||||
msgid "Sets image DPI fallback value"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:130
|
||||
msgid "Controls the unpaper cleaning"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:137
|
||||
msgid "Enables deskew"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:140
|
||||
msgid "Enables page rotation"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:145
|
||||
msgid "Sets the threshold for rotation of pages"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:151
|
||||
msgid "Sets the maximum image size for decompression"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:157
|
||||
msgid "Sets the Ghostscript color conversion strategy"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:165
|
||||
msgid "Adds additional user arguments for OCRMyPDF"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/models.py:170
|
||||
msgid "paperless application settings"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:601
|
||||
msgid "Italian"
|
||||
msgid "English (US)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:602
|
||||
msgid "Luxembourgish"
|
||||
msgid "Arabic"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:603
|
||||
msgid "Norwegian"
|
||||
msgid "Afrikaans"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:604
|
||||
msgid "Dutch"
|
||||
msgid "Belarusian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:605
|
||||
msgid "Polish"
|
||||
msgid "Bulgarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:606
|
||||
msgid "Portuguese (Brazil)"
|
||||
msgid "Catalan"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:607
|
||||
msgid "Portuguese"
|
||||
msgid "Czech"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:608
|
||||
msgid "Romanian"
|
||||
msgid "Danish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:609
|
||||
msgid "Russian"
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:610
|
||||
msgid "Slovak"
|
||||
msgid "Greek"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:611
|
||||
msgid "Slovenian"
|
||||
msgid "English (GB)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:612
|
||||
msgid "Serbian"
|
||||
msgid "Spanish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:613
|
||||
msgid "Swedish"
|
||||
msgid "Finnish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:614
|
||||
msgid "Turkish"
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:615
|
||||
msgid "Ukrainian"
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:616
|
||||
msgid "Italian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:617
|
||||
msgid "Luxembourgish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:618
|
||||
msgid "Norwegian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:619
|
||||
msgid "Dutch"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:620
|
||||
msgid "Polish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:621
|
||||
msgid "Portuguese (Brazil)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:622
|
||||
msgid "Portuguese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:623
|
||||
msgid "Romanian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:624
|
||||
msgid "Russian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:625
|
||||
msgid "Slovak"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:626
|
||||
msgid "Slovenian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:627
|
||||
msgid "Serbian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:628
|
||||
msgid "Swedish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:629
|
||||
msgid "Turkish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:630
|
||||
msgid "Ukrainian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings.py:631
|
||||
msgid "Chinese Simplified"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/urls.py:194
|
||||
#: paperless/urls.py:205
|
||||
msgid "Paperless-ngx administration"
|
||||
msgstr ""
|
||||
|
||||
|
@ -15,7 +15,6 @@ from documents.views import AcknowledgeTasksView
|
||||
from documents.views import BulkDownloadView
|
||||
from documents.views import BulkEditObjectPermissionsView
|
||||
from documents.views import BulkEditView
|
||||
from documents.views import ConsumptionTemplateViewSet
|
||||
from documents.views import CorrespondentViewSet
|
||||
from documents.views import CustomFieldViewSet
|
||||
from documents.views import DocumentTypeViewSet
|
||||
@ -34,6 +33,9 @@ from documents.views import TagViewSet
|
||||
from documents.views import TasksViewSet
|
||||
from documents.views import UiSettingsView
|
||||
from documents.views import UnifiedSearchViewSet
|
||||
from documents.views import WorkflowActionViewSet
|
||||
from documents.views import WorkflowTriggerViewSet
|
||||
from documents.views import WorkflowViewSet
|
||||
from paperless.consumers import StatusConsumer
|
||||
from paperless.views import ApplicationConfigurationViewSet
|
||||
from paperless.views import FaviconView
|
||||
@ -59,7 +61,9 @@ api_router.register(r"groups", GroupViewSet, basename="groups")
|
||||
api_router.register(r"mail_accounts", MailAccountViewSet)
|
||||
api_router.register(r"mail_rules", MailRuleViewSet)
|
||||
api_router.register(r"share_links", ShareLinkViewSet)
|
||||
api_router.register(r"consumption_templates", ConsumptionTemplateViewSet)
|
||||
api_router.register(r"workflow_triggers", WorkflowTriggerViewSet)
|
||||
api_router.register(r"workflow_actions", WorkflowActionViewSet)
|
||||
api_router.register(r"workflows", WorkflowViewSet)
|
||||
api_router.register(r"custom_fields", CustomFieldViewSet)
|
||||
api_router.register(r"config", ApplicationConfigurationViewSet)
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user