diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86b7a7dc7..16d0b32ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/docs/api.md b/docs/api.md index 82244936f..39a06f37f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/docs/usage.md b/docs/usage.md index 42701728d..7e8db4acf 100644 --- a/docs/usage.md +++ b/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 diff --git a/mkdocs.yml b/mkdocs.yml index 816ba932c..8e1e822af 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 0f28ee783..763576677 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -346,8 +346,8 @@ 172 - - Consumption templates give you finer control over the document ingestion process. + + Workflows give you more control over the document pipeline. src/app/app.component.ts 180 @@ -388,6 +388,156 @@ 208 + + Configuration + + src/app/components/admin/config/config.component.html + 1 + + + src/app/components/app-frame/app-frame.component.html + 276 + + + src/app/components/app-frame/app-frame.component.html + 280 + + + + + + src/app/components/admin/config/config.component.html + 8,9 + + + src/app/components/admin/tasks/tasks.component.html + 11 + + + src/app/components/common/input/tags/tags.component.html + 4 + + + src/app/components/common/permissions-select/permissions-select.component.html + 22 + + + + Read the documentation about this setting + + src/app/components/admin/config/config.component.html + 19 + + + + Enable + + src/app/components/admin/config/config.component.html + 30 + + + + Discard + + src/app/components/admin/config/config.component.html + 48 + + + src/app/components/document-detail/document-detail.component.html + 294 + + + + Save + + src/app/components/admin/config/config.component.html + 51 + + + src/app/components/admin/settings/settings.component.html + 337 + + + src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html + 25 + + + src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html + 16 + + + src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html + 27 + + + src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html + 17 + + + src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html + 37 + + + src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html + 49 + + + src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html + 26 + + + src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html + 28 + + + src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html + 36 + + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 171 + + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 58 + + + src/app/components/document-detail/document-detail.component.html + 286 + + + src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html + 21 + + + + Error retrieving config + + src/app/components/admin/config/config.component.ts + 79 + + + + Invalid JSON + + src/app/components/admin/config/config.component.ts + 105 + + + + Configuration updated + + src/app/components/admin/config/config.component.ts + 148 + + + + An error occurred updating configuration + + src/app/components/admin/config/config.component.ts + 153 + + Logs @@ -396,11 +546,11 @@ src/app/components/app-frame/app-frame.component.html - 300 + 309 src/app/components/app-frame/app-frame.component.html - 305 + 314 @@ -852,12 +1002,12 @@ 236 - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 47 + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 116 - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 66 + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 135 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -879,12 +1029,12 @@ 246 - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 55 + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 124 - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 74 + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 143 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -909,8 +1059,8 @@ 255 - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 80 + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 149 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -1013,10 +1163,6 @@ src/app/components/admin/users-groups/users-groups.component.html 63 - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 10 - src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html 9 @@ -1050,12 +1196,12 @@ 8 - src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html - 8 + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 10 - src/app/components/manage/consumption-templates/consumption-templates.component.html - 14 + src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html + 8 src/app/components/manage/custom-fields/custom-fields.component.html @@ -1101,6 +1247,10 @@ src/app/components/manage/management-list/management-list.component.html 41 + + src/app/components/manage/workflows/workflows.component.html + 14 +  Appears on @@ -1149,6 +1299,10 @@ src/app/components/admin/users-groups/users-groups.component.html 66 + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 66 + src/app/components/document-detail/document-detail.component.html 49 @@ -1157,10 +1311,6 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html 86 - - src/app/components/manage/consumption-templates/consumption-templates.component.html - 17 - src/app/components/manage/custom-fields/custom-fields.component.html 16 @@ -1189,6 +1339,10 @@ src/app/components/manage/management-list/management-list.component.html 47 + + src/app/components/manage/workflows/workflows.component.html + 18 + Delete @@ -1208,6 +1362,14 @@ src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts 53 + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 48 + + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 92 + src/app/components/common/permissions-select/permissions-select.component.html 9 @@ -1224,10 +1386,6 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html 142 - - src/app/components/manage/consumption-templates/consumption-templates.component.html - 37 - src/app/components/manage/custom-fields/custom-fields.component.html 35 @@ -1276,6 +1434,10 @@ src/app/components/manage/management-list/management-list.component.ts 205 + + src/app/components/manage/workflows/workflows.component.html + 39 + No saved views defined. @@ -1284,65 +1446,6 @@ 319 - - Save - - src/app/components/admin/settings/settings.component.html - 337 - - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 93 - - - src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html - 25 - - - src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html - 16 - - - src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html - 27 - - - src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html - 17 - - - src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html - 37 - - - src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html - 49 - - - src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html - 26 - - - src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html - 28 - - - src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html - 36 - - - src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html - 58 - - - src/app/components/document-detail/document-detail.component.html - 286 - - - src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html - 21 - - Use system language @@ -1433,7 +1536,7 @@ src/app/components/app-frame/app-frame.component.html - 287 + 296 @@ -1459,21 +1562,6 @@ 5 - - - - src/app/components/admin/tasks/tasks.component.html - 11 - - - src/app/components/common/input/tags/tags.component.html - 4 - - - src/app/components/common/permissions-select/permissions-select.component.html - 22 - - Created @@ -1635,11 +1723,11 @@ src/app/components/app-frame/app-frame.component.html - 276 + 285 src/app/components/app-frame/app-frame.component.html - 280 + 289 @@ -1716,10 +1804,6 @@ src/app/components/document-list/document-card-small/document-card-small.component.html 105 - - src/app/components/manage/consumption-templates/consumption-templates.component.html - 32 - src/app/components/manage/custom-fields/custom-fields.component.html 30 @@ -1764,6 +1848,10 @@ src/app/components/manage/management-list/management-list.component.html 103 + + src/app/components/manage/workflows/workflows.component.html + 34 + Add Group @@ -1840,10 +1928,6 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts 500 - - src/app/components/manage/consumption-templates/consumption-templates.component.ts - 91 - src/app/components/manage/custom-fields/custom-fields.component.ts 73 @@ -1856,6 +1940,10 @@ src/app/components/manage/mail/mail.component.ts 173 + + src/app/components/manage/workflows/workflows.component.ts + 97 + Proceed @@ -1875,10 +1963,6 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts 502 - - src/app/components/manage/consumption-templates/consumption-templates.component.ts - 93 - src/app/components/manage/custom-fields/custom-fields.component.ts 75 @@ -1891,6 +1975,10 @@ src/app/components/manage/mail/mail.component.ts 175 + + src/app/components/manage/workflows/workflows.component.ts + 99 + Deleted user @@ -1992,11 +2080,11 @@ src/app/components/app-frame/app-frame.component.html - 310 + 319 src/app/components/app-frame/app-frame.component.html - 315 + 324 @@ -2165,19 +2253,20 @@ 1 - - Consumption templates + + Workflows src/app/components/app-frame/app-frame.component.html 241 - - - Templates src/app/components/app-frame/app-frame.component.html 245 + + src/app/components/manage/workflows/workflows.component.html + 1 + Mail @@ -2201,49 +2290,49 @@ File Tasks src/app/components/app-frame/app-frame.component.html - 294,296 + 303,305 GitHub src/app/components/app-frame/app-frame.component.html - 322 + 331 is available. src/app/components/app-frame/app-frame.component.html - 331,332 + 340,341 Click to view. src/app/components/app-frame/app-frame.component.html - 332 + 341 Paperless-ngx can automatically check for updates src/app/components/app-frame/app-frame.component.html - 336 + 345 How does this work? src/app/components/app-frame/app-frame.component.html - 343,345 + 352,354 Update available src/app/components/app-frame/app-frame.component.html - 359 + 368 @@ -2426,278 +2515,6 @@ 57 - - Sort order - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 13 - - - src/app/components/manage/consumption-templates/consumption-templates.component.html - 15 - - - - Filters - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 18 - - - - Process documents that match all filters specified below. - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 19 - - - - Filter sources - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 20 - - - - Filter filename - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 21 - - - - Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive. - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 21 - - - - Filter path - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 22 - - - - Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.</a> - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 22 - - - - Filter mail rule - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 23 - - - - Apply to documents consumed via this mail rule. - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 23 - - - - Assignments - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 28 - - - - Assign title - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 33 - - - - Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#consumption-templates'>documentation</a>. - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 33 - - - - Assign tags - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 34 - - - - Assign document type - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 35 - - - src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html - 35 - - - - Assign correspondent - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 36 - - - src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html - 38 - - - - Assign storage path - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 37 - - - - Assign custom fields - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 38 - - - - Assign owner - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 41 - - - - Assign view permissions - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 43 - - - - Assign edit permissions - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 62 - - - - Error - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 90 - - - src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html - 46 - - - src/app/components/common/toasts/toasts.component.html - 30 - - - - Cancel - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.html - 92 - - - src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html - 24 - - - src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html - 15 - - - src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html - 26 - - - src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html - 16 - - - src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html - 36 - - - src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html - 48 - - - src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html - 25 - - - src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html - 27 - - - src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html - 35 - - - src/app/components/common/permissions-dialog/permissions-dialog.component.html - 22 - - - src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html - 57 - - - src/app/components/common/select-dialog/select-dialog.component.html - 12 - - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 6 - - - src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html - 20 - - - - Consume Folder - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts - 27 - - - - API Upload - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts - 31 - - - - Mail Fetch - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts - 35 - - - - Create new consumption template - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts - 92 - - - - Edit consumption template - - src/app/components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component.ts - 96 - - Matching algorithm @@ -2754,6 +2571,73 @@ src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html 18 + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 194 + + + + Cancel + + src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html + 24 + + + src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html + 15 + + + src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html + 26 + + + src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html + 16 + + + src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html + 36 + + + src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html + 48 + + + src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html + 25 + + + src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html + 27 + + + src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html + 35 + + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 170 + + + src/app/components/common/permissions-dialog/permissions-dialog.component.html + 22 + + + src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html + 57 + + + src/app/components/common/select-dialog/select-dialog.component.html + 12 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 6 + + + src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html + 20 + Create new correspondent @@ -3081,6 +2965,10 @@ src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html 28 + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 84 + Action is only performed when documents are consumed from the mail. Mails without attachments remain entirely untouched. @@ -3110,6 +2998,17 @@ 33 + + Assign document type + + src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html + 35 + + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 104 + + Assign correspondent from @@ -3117,6 +3016,17 @@ 36 + + Assign correspondent + + src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html + 38 + + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 105 + + Assign owner from rule @@ -3124,6 +3034,21 @@ 40 + + Error + + src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.html + 46 + + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 168 + + + src/app/components/common/toasts/toasts.component.html + 30 + + Only process attachments @@ -3400,6 +3325,291 @@ 48 + + Sort order + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 13 + + + src/app/components/manage/workflows/workflows.component.html + 15 + + + + Enabled + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 16 + + + src/app/components/manage/workflows/workflows.component.html + 27 + + + + Triggers + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 22 + + + src/app/components/manage/workflows/workflows.component.html + 17 + + + + Trigger Workflow On: + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 28 + + + + Add Trigger + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 33 + + + + Apply Actions: + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 72 + + + + Add Action + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 77 + + + + Action type + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 98 + + + + Assign title + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 102 + + + + Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 102 + + + + Assign tags + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 103 + + + + Assign storage path + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 106 + + + + Assign custom fields + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 107 + + + + Assign owner + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 110 + + + + Assign view permissions + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 112 + + + + Assign edit permissions + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 131 + + + + Trigger type + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 178 + + + + Trigger for documents that match all filters specified below. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 179 + + + + Filter filename + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 182 + + + + Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 182 + + + + Filter sources + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 184 + + + + Filter path + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 185 + + + + Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.</a> + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 185 + + + + Filter mail rule + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 186 + + + + Apply to documents consumed via this mail rule. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 186 + + + + Content matching algorithm + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 189 + + + + Content matching pattern + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 191 + + + + Has tags + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 200 + + + + Has correspondent + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 201 + + + + Has document type + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html + 202 + + + + Consume Folder + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 38 + + + + API Upload + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 42 + + + + Mail Fetch + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 46 + + + + Consumption Started + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 53 + + + + Document Added + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 57 + + + + Document Updated + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 61 + + + + Assignment + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 68 + + + + Create new workflow + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 136 + + + + Edit workflow + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 140 + + All @@ -3482,15 +3692,19 @@ src/app/components/common/input/number/number.component.html - 9 + 11 src/app/components/common/input/select/select.component.html 11 + + src/app/components/common/input/switch/switch.component.html + 10 + src/app/components/common/input/text/text.component.html - 9 + 11 src/app/components/common/input/url/url.component.html @@ -3949,6 +4163,10 @@ src/app/components/common/toasts/toasts.component.html 28 + + src/app/components/manage/workflows/workflows.component.html + 16 + Copy Raw Error @@ -4527,13 +4745,6 @@ 291 - - Discard - - src/app/components/document-detail/document-detail.component.html - 294 - - An error occurred loading content: @@ -5505,76 +5716,6 @@ 88 - - Consumption Templates - - src/app/components/manage/consumption-templates/consumption-templates.component.html - 1 - - - - Add Template - - src/app/components/manage/consumption-templates/consumption-templates.component.html - 6 - - - - Document Sources - - src/app/components/manage/consumption-templates/consumption-templates.component.html - 16 - - - - No templates defined. - - src/app/components/manage/consumption-templates/consumption-templates.component.html - 45 - - - - Saved template "". - - src/app/components/manage/consumption-templates/consumption-templates.component.ts - 73 - - - - Error saving template. - - src/app/components/manage/consumption-templates/consumption-templates.component.ts - 81 - - - - Confirm delete template - - src/app/components/manage/consumption-templates/consumption-templates.component.ts - 89 - - - - This operation will permanently delete this template. - - src/app/components/manage/consumption-templates/consumption-templates.component.ts - 90 - - - - Deleted template - - src/app/components/manage/consumption-templates/consumption-templates.component.ts - 99 - - - - Error deleting template. - - src/app/components/manage/consumption-templates/consumption-templates.component.ts - 104 - - correspondent @@ -6072,6 +6213,69 @@ 53 + + Add Workflow + + src/app/components/manage/workflows/workflows.component.html + 6 + + + + Disabled + + src/app/components/manage/workflows/workflows.component.html + 27 + + + + No workflows defined. + + src/app/components/manage/workflows/workflows.component.html + 47 + + + + Saved workflow "". + + src/app/components/manage/workflows/workflows.component.ts + 79 + + + + Error saving workflow. + + src/app/components/manage/workflows/workflows.component.ts + 87 + + + + Confirm delete workflow + + src/app/components/manage/workflows/workflows.component.ts + 95 + + + + This operation will permanently delete this workflow. + + src/app/components/manage/workflows/workflows.component.ts + 96 + + + + Deleted workflow + + src/app/components/manage/workflows/workflows.component.ts + 105 + + + + Error deleting workflow. + + src/app/components/manage/workflows/workflows.component.ts + 110 + + Not Found @@ -6226,6 +6430,104 @@ 46 + + OCR Settings + + src/app/data/paperless-config.ts + 49 + + + + Output Type + + src/app/data/paperless-config.ts + 73 + + + + Language + + src/app/data/paperless-config.ts + 81 + + + + Pages + + src/app/data/paperless-config.ts + 88 + + + + Mode + + src/app/data/paperless-config.ts + 95 + + + + Skip Archive File + + src/app/data/paperless-config.ts + 103 + + + + Image DPI + + src/app/data/paperless-config.ts + 111 + + + + Clean + + src/app/data/paperless-config.ts + 118 + + + + Deskew + + src/app/data/paperless-config.ts + 126 + + + + Rotate Pages + + src/app/data/paperless-config.ts + 133 + + + + Rotate Pages Threshold + + src/app/data/paperless-config.ts + 140 + + + + Max Image Pixels + + src/app/data/paperless-config.ts + 147 + + + + Color Conversion Strategy + + src/app/data/paperless-config.ts + 154 + + + + OCR Arguments + + src/app/data/paperless-config.ts + 162 + + Warning: You have unsaved changes to your document(s). diff --git a/src-ui/src/app/app-routing.module.ts b/src-ui/src/app/app-routing.module.ts index 89ed06e39..6da2cd253 100644 --- a/src-ui/src/app/app-routing.module.ts +++ b/src-ui/src/app/app-routing.module.ts @@ -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, }, }, }, diff --git a/src-ui/src/app/app.component.ts b/src-ui/src/app/app.component.ts index 2ab37d55e..e93fde30c 100644 --- a/src-ui/src/app/app.component.ts +++ b/src-ui/src/app/app.component.ts @@ -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, }, diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index 6d8d58944..ad76bdb74 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -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, diff --git a/src-ui/src/app/components/admin/config/config.component.html b/src-ui/src/app/components/admin/config/config.component.html index 48cac6bfa..a3eb0b8ab 100644 --- a/src-ui/src/app/components/admin/config/config.component.html +++ b/src-ui/src/app/components/admin/config/config.component.html @@ -27,7 +27,7 @@ @switch (option.type) { @case (ConfigOptionType.Select) { } @case (ConfigOptionType.Number) { } - @case (ConfigOptionType.Boolean) { } + @case (ConfigOptionType.Boolean) { } @case (ConfigOptionType.String) { } @case (ConfigOptionType.JSON) { } } diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 234099d60..32241333e 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -235,14 +235,14 @@ - @for (template of templates; track template) { + @for (workflow of workflows; track workflow.id) {
  • -
    -
    {{template.order}}
    -
    {{getSourceList(template)}}
    +
    +
    {{workflow.order}}
    +
    @if(workflow.enabled) { Enabled } @else { Disabled }
    +
    {{getTypesList(workflow)}}
    - -
  • } - @if (templates.length === 0) { -
  • No templates defined.
  • + @if (workflows.length === 0) { +
  • No workflows defined.
  • } diff --git a/src-ui/src/app/components/manage/consumption-templates/consumption-templates.component.scss b/src-ui/src/app/components/manage/workflows/workflows.component.scss similarity index 100% rename from src-ui/src/app/components/manage/consumption-templates/consumption-templates.component.scss rename to src-ui/src/app/components/manage/workflows/workflows.component.scss diff --git a/src-ui/src/app/components/manage/consumption-templates/consumption-templates.component.spec.ts b/src-ui/src/app/components/manage/workflows/workflows.component.spec.ts similarity index 68% rename from src-ui/src/app/components/manage/consumption-templates/consumption-templates.component.spec.ts rename to src-ui/src/app/components/manage/workflows/workflows.component.spec.ts index 2cb365576..4382d56f5 100644 --- a/src-ui/src/app/components/manage/consumption-templates/consumption-templates.component.spec.ts +++ b/src-ui/src/app/components/manage/workflows/workflows.component.spec.ts @@ -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, + 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', + }, ], - filter_filename: 'foo', - filter_path: 'bar', - assign_tags: [1, 2, 3], }, { - id: 1, - name: 'Template 2', - order: 1, - sources: [DocumentSource.MailFetch], - filter_filename: null, - filter_path: 'foo/bar', - assign_owner: 1, + 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 - let consumptionTemplateService: ConsumptionTemplateService +describe('WorkflowsComponent', () => { + let component: WorkflowsComponent + let fixture: ComponentFixture + 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] diff --git a/src-ui/src/app/components/manage/consumption-templates/consumption-templates.component.ts b/src-ui/src/app/components/manage/workflows/workflows.component.ts similarity index 52% rename from src-ui/src/app/components/manage/consumption-templates/consumption-templates.component.ts rename to src-ui/src/app/components/manage/workflows/workflows.component.ts index 301699abd..293473888 100644 --- a/src-ui/src/app/components/manage/consumption-templates/consumption-templates.component.ts +++ b/src-ui/src/app/components/manage/workflows/workflows.component.ts @@ -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 = 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, - { - backdrop: 'static', - size: 'xl', - } - ) - modal.componentInstance.dialogMode = rule + editWorkflow(workflow: Workflow) { + const modal = this.modalService.open(WorkflowEditDialogComponent, { + backdrop: 'static', + size: 'xl', + }) + 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) }, }) }) diff --git a/src-ui/src/app/data/consumption-template.ts b/src-ui/src/app/data/workflow-action.ts similarity index 64% rename from src-ui/src/app/data/consumption-template.ts rename to src-ui/src/app/data/workflow-action.ts index cc85712c8..a0da5f03a 100644 --- a/src-ui/src/app/data/consumption-template.ts +++ b/src-ui/src/app/data/workflow-action.ts @@ -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 diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts new file mode 100644 index 000000000..3e3bf8cf8 --- /dev/null +++ b/src-ui/src/app/data/workflow-trigger.ts @@ -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 +} diff --git a/src-ui/src/app/data/workflow.ts b/src-ui/src/app/data/workflow.ts new file mode 100644 index 000000000..740507a62 --- /dev/null +++ b/src-ui/src/app/data/workflow.ts @@ -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[] +} diff --git a/src-ui/src/app/services/permissions.service.spec.ts b/src-ui/src/app/services/permissions.service.spec.ts index 968082ae9..66276fbbb 100644 --- a/src-ui/src/app/services/permissions.service.spec.ts +++ b/src-ui/src/app/services/permissions.service.spec.ts @@ -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', diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts index a4e30d57e..3a1b99377 100644 --- a/src-ui/src/app/services/permissions.service.ts +++ b/src-ui/src/app/services/permissions.service.ts @@ -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({ diff --git a/src-ui/src/app/services/rest/consumption-template.service.spec.ts b/src-ui/src/app/services/rest/consumption-template.service.spec.ts deleted file mode 100644 index 920d0575c..000000000 --- a/src-ui/src/app/services/rest/consumption-template.service.spec.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src-ui/src/app/services/rest/workflow.service.spec.ts b/src-ui/src/app/services/rest/workflow.service.spec.ts new file mode 100644 index 000000000..cdffda3e1 --- /dev/null +++ b/src-ui/src/app/services/rest/workflow.service.spec.ts @@ -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() + }) +}) diff --git a/src-ui/src/app/services/rest/consumption-template.service.ts b/src-ui/src/app/services/rest/workflow.service.ts similarity index 56% rename from src-ui/src/app/services/rest/consumption-template.service.ts rename to src-ui/src/app/services/rest/workflow.service.ts index eb932ebf7..0b489bc67 100644 --- a/src-ui/src/app/services/rest/consumption-template.service.ts +++ b/src-ui/src/app/services/rest/workflow.service.ts @@ -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 { +export class WorkflowService extends AbstractPaperlessService { 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())) } } diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss index e128b27fa..c8e8e8d5c 100644 --- a/src-ui/src/styles.scss +++ b/src-ui/src/styles.scss @@ -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); diff --git a/src/documents/apps.py b/src/documents/apps.py index d681b9a87..7ed006d06 100644 --- a/src/documents/apps.py +++ b/src/documents/apps.py @@ -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) diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 5d6fe7f65..11faeea43 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -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: - template_overrides.tag_ids = [ - tag.pk for tag in template.assign_tags.all() - ] - if template.assign_correspondent is not None: - template_overrides.correspondent_id = ( - template.assign_correspondent.pk + 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 template.assign_document_type is not None: - template_overrides.document_type_id = ( - template.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: - template_overrides.view_users = [ - user.pk for user in template.assign_view_users.all() - ] - if template.assign_view_groups is not None: - template_overrides.view_groups = [ - group.pk for group in template.assign_view_groups.all() - ] - if template.assign_change_users is not None: - template_overrides.change_users = [ - user.pk for user in template.assign_change_users.all() - ] - if template.assign_change_groups is not None: - template_overrides.change_groups = [ - group.pk for group in template.assign_change_groups.all() - ] - if template.assign_custom_fields is not None: - template_overrides.custom_field_ids = [ - field.pk for field in template.assign_custom_fields.all() - ] + 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 action.assign_tags.all() + ] + if action.assign_correspondent is not None: + template_overrides.correspondent_id = ( + action.assign_correspondent.pk + ) + if action.assign_document_type is not None: + template_overrides.document_type_id = ( + action.assign_document_type.pk + ) + 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 action.assign_view_users.all() + ] + if action.assign_view_groups is not None: + template_overrides.view_groups = [ + group.pk for group in action.assign_view_groups.all() + ] + if action.assign_change_users is not None: + template_overrides.change_users = [ + user.pk for user in action.assign_change_users.all() + ] + if action.assign_change_groups is not None: + template_overrides.change_groups = [ + group.pk for group in action.assign_change_groups.all() + ] + if action.assign_custom_fields is not None: + template_overrides.custom_field_ids = [ + field.pk for field in action.assign_custom_fields.all() + ] - overrides.update(template_overrides) + 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() diff --git a/src/documents/data_models.py b/src/documents/data_models.py index 0d506cd6a..6bf3f4f96 100644 --- a/src/documents/data_models.py +++ b/src/documents/data_models.py @@ -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 diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py index bd5e322e3..b08b0b208 100644 --- a/src/documents/management/commands/document_exporter.py +++ b/src/documents/management/commands/document_exporter.py @@ -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( diff --git a/src/documents/matching.py b/src/documents/matching.py index 9c6e11ca7..ec28f80ca 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -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 True + 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 diff --git a/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py b/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py new file mode 100644 index 000000000..521de61b8 --- /dev/null +++ b/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py @@ -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, + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index d95bf46e1..b943fa2b5 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -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}" diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index c65d4d2ff..b1dd9aee9 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -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 diff --git a/src/documents/signals/__init__.py b/src/documents/signals/__init__.py index 393630008..fbb55d9fe 100644 --- a/src/documents/signals/__init__.py +++ b/src/documents/signals/__init__.py @@ -3,3 +3,4 @@ from django.dispatch import Signal document_consumption_started = Signal() document_consumption_finished = Signal() document_consumer_declaration = Signal() +document_updated = Signal() diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 117e3c38d..d536a3967 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -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): """ diff --git a/src/documents/tasks.py b/src/documents/tasks.py index d0728a719..19e40db5b 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -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: diff --git a/src/documents/tests/test_api_consumption_templates.py b/src/documents/tests/test_api_consumption_templates.py deleted file mode 100644 index e32294050..000000000 --- a/src/documents/tests/test_api_consumption_templates.py +++ /dev/null @@ -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__()]) diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py new file mode 100644 index 000000000..d7a7ad6ff --- /dev/null +++ b/src/documents/tests/test_api_workflows.py @@ -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) diff --git a/src/documents/tests/test_consumption_templates.py b/src/documents/tests/test_consumption_templates.py deleted file mode 100644 index 6f671bfc4..000000000 --- a/src/documents/tests/test_consumption_templates.py +++ /dev/null @@ -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]) diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index 898dfbc53..a51bd4662 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -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) diff --git a/src/documents/tests/test_migration_consumption_templates.py b/src/documents/tests/test_migration_consumption_templates.py index 3374530a2..917007116 100644 --- a/src/documents/tests/test_migration_consumption_templates.py +++ b/src/documents/tests/test_migration_consumption_templates.py @@ -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") - self.user.user_permissions.add(permission.id) - self.group.permissions.add(permission.id) + 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") - self.assertFalse(self.user.has_perm(f"documents.{permission.codename}")) - self.assertFalse(permission in self.group.permissions.all()) + 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()) diff --git a/src/documents/tests/test_migration_workflows.py b/src/documents/tests/test_migration_workflows.py new file mode 100644 index 000000000..742757783 --- /dev/null +++ b/src/documents/tests/test_migration_workflows.py @@ -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) diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py new file mode 100644 index 000000000..2e516e24c --- /dev/null +++ b/src/documents/tests/test_workflows.py @@ -0,0 +1,1017 @@ +from datetime import timedelta +from pathlib import Path +from unittest import mock + +from django.contrib.auth.models import Group +from django.contrib.auth.models import User +from django.utils import timezone +from rest_framework.test import APITestCase + +from documents import tasks +from documents.data_models import ConsumableDocument +from documents.data_models import DocumentSource +from documents.matching import document_matches_workflow +from documents.models import Correspondent +from documents.models import CustomField +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 WorkflowAction +from documents.models import WorkflowTrigger +from documents.signals import document_consumption_finished +from documents.tests.utils import DirectoriesMixin +from documents.tests.utils import FileSystemAssertsMixin +from paperless_mail.models import MailAccount +from paperless_mail.models import MailRule + + +class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, APITestCase): + 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_workflow_match(self, m): + """ + GIVEN: + - Existing workflow + WHEN: + - File that matches is consumed + THEN: + - Template overrides are applied + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", + filter_filename="*simple*", + filter_path="*/samples/*", + ) + 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, + ) + action.assign_tags.add(self.t1) + action.assign_tags.add(self.t2) + action.assign_tags.add(self.t3) + action.assign_view_users.add(self.user3.pk) + action.assign_view_groups.add(self.group1.pk) + action.assign_change_users.add(self.user3.pk) + action.assign_change_groups.add(self.group1.pk) + action.assign_custom_fields.add(self.cf1.pk) + action.assign_custom_fields.add(self.cf2.pk) + action.save() + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + self.assertEqual(w.__str__(), "Workflow: Workflow 1") + self.assertEqual(trigger.__str__(), "WorkflowTrigger 1") + self.assertEqual(action.__str__(), "WorkflowAction 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 {trigger} from {w}" + self.assertIn(expected_str, info) + + @mock.patch("documents.consumer.Consumer.try_consume_file") + def test_workflow_match_mailrule(self, m): + """ + GIVEN: + - Existing workflow + WHEN: + - File that matches is consumed via mail rule + THEN: + - Template overrides are applied + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", + filter_mailrule=self.rule1, + ) + + 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, + ) + action.assign_tags.add(self.t1) + action.assign_tags.add(self.t2) + action.assign_tags.add(self.t3) + action.assign_view_users.add(self.user3.pk) + action.assign_view_groups.add(self.group1.pk) + action.assign_change_users.add(self.user3.pk) + action.assign_change_groups.add(self.group1.pk) + action.save() + + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.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, + 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 {trigger} from {w}" + self.assertIn(expected_str, info) + + @mock.patch("documents.consumer.Consumer.try_consume_file") + def test_workflow_match_multiple(self, m): + """ + GIVEN: + - Multiple existing workflow + WHEN: + - File that matches is consumed + THEN: + - Template overrides are applied with subsequent templates overwriting previous values + or merging if multiple + """ + trigger1 = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", + filter_path="*/samples/*", + ) + action1 = WorkflowAction.objects.create( + assign_title="Doc from {correspondent}", + assign_correspondent=self.c, + assign_document_type=self.dt, + ) + action1.assign_tags.add(self.t1) + action1.assign_tags.add(self.t2) + action1.assign_view_users.add(self.user2) + action1.save() + + w1 = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w1.triggers.add(trigger1) + w1.actions.add(action1) + w1.save() + + trigger2 = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", + filter_filename="*simple*", + ) + action2 = WorkflowAction.objects.create( + assign_title="Doc from {correspondent}", + assign_correspondent=self.c2, + assign_storage_path=self.sp, + ) + action2.assign_tags.add(self.t3) + action2.assign_view_users.add(self.user3) + action2.save() + + w2 = Workflow.objects.create( + name="Workflow 2", + order=0, + ) + w2.triggers.add(trigger2) + w2.actions.add(action2) + w2.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_document_type_id"], self.dt.pk) + # template 2 + self.assertEqual(overrides["override_correspondent_id"], self.c2.pk) + 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 {trigger1} from {w1}" + self.assertIn(expected_str, cm.output[0]) + expected_str = f"Document matched {trigger2} from {w2}" + self.assertIn(expected_str, cm.output[1]) + + @mock.patch("documents.consumer.Consumer.try_consume_file") + def test_workflow_no_match_filename(self, m): + """ + GIVEN: + - Existing workflow + WHEN: + - File that does not match on filename is consumed + THEN: + - Template overrides are not applied + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", + filter_filename="*foobar*", + filter_path=None, + ) + 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, + ) + action.save() + + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + 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 {w}" + 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_workflow_no_match_path(self, m): + """ + GIVEN: + - Existing workflow + WHEN: + - File that does not match on path is consumed + THEN: + - Template overrides are not applied + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", + filter_path="*foo/bar*", + ) + 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, + ) + action.save() + + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + 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 {w}" + 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_workflow_no_match_mail_rule(self, m): + """ + GIVEN: + - Existing workflow + WHEN: + - File that does not match on source is consumed + THEN: + - Template overrides are not applied + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", + filter_mailrule=self.rule1, + ) + 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, + ) + action.save() + + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + 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 {w}" + 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_workflow_no_match_source(self, m): + """ + GIVEN: + - Existing workflow + WHEN: + - File that does not match on source is consumed + THEN: + - Template overrides are not applied + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", + filter_path="*", + ) + 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, + ) + action.save() + + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + 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 {w}" + 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]) + + def test_document_added_no_match_trigger_type(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + ) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + action.save() + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + doc.save() + + with self.assertLogs("paperless.matching", level="DEBUG") as cm: + document_matches_workflow( + doc, + w, + WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + ) + expected_str = f"Document did not match {w}" + self.assertIn(expected_str, cm.output[0]) + expected_str = f"No matching triggers with type {WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED} found" + self.assertIn(expected_str, cm.output[1]) + + @mock.patch("documents.consumer.Consumer.try_consume_file") + def test_workflow_repeat_custom_fields(self, m): + """ + GIVEN: + - Existing workflows which assign the same custom field + WHEN: + - File that matches is consumed + THEN: + - Custom field is added the first time successfully + """ + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION, + sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", + filter_filename="*simple*", + ) + action1 = WorkflowAction.objects.create() + action1.assign_custom_fields.add(self.cf1.pk) + action1.save() + + action2 = WorkflowAction.objects.create() + action2.assign_custom_fields.add(self.cf1.pk) + action2.save() + + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action1, action2) + w.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 {trigger} from {w}" + self.assertIn(expected_str, cm.output[0]) + + def test_document_added_workflow(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + filter_filename="*sample*", + ) + action = WorkflowAction.objects.create( + assign_title="Doc created in {created_year}", + assign_correspondent=self.c2, + assign_document_type=self.dt, + assign_storage_path=self.sp, + assign_owner=self.user2, + ) + action.assign_tags.add(self.t1) + action.assign_tags.add(self.t2) + action.assign_tags.add(self.t3) + action.assign_view_users.add(self.user3.pk) + action.assign_view_groups.add(self.group1.pk) + action.assign_change_users.add(self.user3.pk) + action.assign_change_groups.add(self.group1.pk) + action.assign_custom_fields.add(self.cf1.pk) + action.assign_custom_fields.add(self.cf2.pk) + action.save() + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + now = timezone.localtime(timezone.now()) + created = now - timedelta(weeks=520) + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + added=now, + created=created, + ) + + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + + self.assertEqual(doc.correspondent, self.c2) + self.assertEqual(doc.title, f"Doc created in {created.year}") + + def test_document_added_no_match_filename(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + filter_filename="*foobar*", + ) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + action.save() + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + doc.tags.set([self.t3]) + doc.save() + + with self.assertLogs("paperless.matching", level="DEBUG") as cm: + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + expected_str = f"Document did not match {w}" + self.assertIn(expected_str, cm.output[0]) + expected_str = f"Document filename {doc.original_filename} does not match" + self.assertIn(expected_str, cm.output[1]) + + def test_document_added_match_content_matching(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + matching_algorithm=MatchingModel.MATCH_LITERAL, + match="foo", + is_insensitive=True, + ) + action = WorkflowAction.objects.create( + assign_title="Doc content matching worked", + assign_owner=self.user2, + ) + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + content="Hello world foo bar", + ) + + with self.assertLogs("paperless.matching", level="DEBUG") as cm: + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + expected_str = f"WorkflowTrigger {trigger} matched on document" + expected_str2 = 'because it contains this string: "foo"' + self.assertIn(expected_str, cm.output[0]) + self.assertIn(expected_str2, cm.output[0]) + expected_str = f"Document matched {trigger} from {w}" + self.assertIn(expected_str, cm.output[1]) + + def test_document_added_no_match_content_matching(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + matching_algorithm=MatchingModel.MATCH_LITERAL, + match="foo", + is_insensitive=True, + ) + action = WorkflowAction.objects.create( + assign_title="Doc content matching worked", + assign_owner=self.user2, + ) + action.save() + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + content="Hello world bar", + ) + + with self.assertLogs("paperless.matching", level="DEBUG") as cm: + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + expected_str = f"Document did not match {w}" + self.assertIn(expected_str, cm.output[0]) + expected_str = f"Document content matching settings for algorithm '{trigger.matching_algorithm}' did not match" + self.assertIn(expected_str, cm.output[1]) + + def test_document_added_no_match_tags(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + ) + trigger.filter_has_tags.set([self.t1, self.t2]) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + doc.tags.set([self.t3]) + doc.save() + + with self.assertLogs("paperless.matching", level="DEBUG") as cm: + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + expected_str = f"Document did not match {w}" + self.assertIn(expected_str, cm.output[0]) + expected_str = f"Document tags {doc.tags.all()} do not include {trigger.filter_has_tags.all()}" + self.assertIn(expected_str, cm.output[1]) + + def test_document_added_no_match_doctype(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + filter_has_document_type=self.dt, + ) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + action.save() + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + original_filename="sample.pdf", + ) + + with self.assertLogs("paperless.matching", level="DEBUG") as cm: + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + expected_str = f"Document did not match {w}" + self.assertIn(expected_str, cm.output[0]) + expected_str = f"Document doc type {doc.document_type} does not match {trigger.filter_has_document_type}" + self.assertIn(expected_str, cm.output[1]) + + def test_document_added_no_match_correspondent(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + filter_has_correspondent=self.c, + ) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + assign_owner=self.user2, + ) + action.save() + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c2, + original_filename="sample.pdf", + ) + + with self.assertLogs("paperless.matching", level="DEBUG") as cm: + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + expected_str = f"Document did not match {w}" + self.assertIn(expected_str, cm.output[0]) + expected_str = f"Document correspondent {doc.correspondent} does not match {trigger.filter_has_correspondent}" + self.assertIn(expected_str, cm.output[1]) + + def test_document_updated_workflow(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, + filter_has_document_type=self.dt, + ) + action = WorkflowAction.objects.create() + action.assign_custom_fields.add(self.cf1) + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + + superuser = User.objects.create_superuser("superuser") + self.client.force_authenticate(user=superuser) + + self.client.patch( + f"/api/documents/{doc.id}/", + {"document_type": self.dt.id}, + format="json", + ) + + self.assertEqual(doc.custom_fields.all().count(), 1) + + def test_workflow_enabled_disabled(self): + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED, + filter_filename="*sample*", + ) + action = WorkflowAction.objects.create( + assign_title="Title assign correspondent", + assign_correspondent=self.c2, + ) + w = Workflow.objects.create( + name="Workflow 1", + order=0, + enabled=False, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + action2 = WorkflowAction.objects.create( + assign_title="Title assign owner", + assign_owner=self.user2, + ) + w2 = Workflow.objects.create( + name="Workflow 2", + order=0, + enabled=True, + ) + w2.triggers.add(trigger) + w2.actions.add(action2) + w2.save() + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + ) + + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + + self.assertEqual(doc.correspondent, self.c) + self.assertEqual(doc.title, "Title assign owner") + self.assertEqual(doc.owner, self.user2) + + def test_new_trigger_type_raises_exception(self): + trigger = WorkflowTrigger.objects.create( + type=4, + ) + action = WorkflowAction.objects.create( + assign_title="Doc assign owner", + ) + w = Workflow.objects.create( + name="Workflow 1", + order=0, + ) + w.triggers.add(trigger) + w.actions.add(action) + w.save() + + doc = Document.objects.create( + title="test", + ) + self.assertRaises(Exception, document_matches_workflow, doc, w, 4) diff --git a/src/documents/tests/utils.py b/src/documents/tests/utils.py index fe7dbb059..0b6d8fcad 100644 --- a/src/documents/tests/utils.py +++ b/src/documents/tests/utils.py @@ -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 diff --git a/src/documents/views.py b/src/documents/views.py index e8c6db0de..84633cc03 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -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,25 +1384,50 @@ 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 = 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", + "assign_change_users", + "assign_change_groups", + "assign_custom_fields", + ) + + +class WorkflowViewSet(ModelViewSet): + permission_classes = (IsAuthenticated, PaperlessObjectPermissions) + + serializer_class = WorkflowSerializer + pagination_class = StandardPagination + + model = Workflow queryset = ( - ConsumptionTemplate.objects.prefetch_related( - "assign_tags", - "assign_view_users", - "assign_view_groups", - "assign_change_users", - "assign_change_groups", - "assign_custom_fields", - ) - .all() + Workflow.objects.all() .order_by("order") + .prefetch_related( + "triggers", + "actions", + ) ) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index a317ddfd0..3cb19d63c 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -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 "" diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 1c0dafd65..25190e0d8 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -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)