mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-10 02:16:12 -05:00
Compare commits
24 Commits
dependabot
...
feature-wf
Author | SHA1 | Date | |
---|---|---|---|
![]() |
914c007103 | ||
![]() |
f3e749511e | ||
![]() |
e715a78b63 | ||
![]() |
1b8033209a | ||
![]() |
3828d07ec6 | ||
![]() |
9c4d09c91c | ||
![]() |
ea6fdc78e6 | ||
![]() |
979ccf4c51 | ||
![]() |
1c75c4d94b | ||
![]() |
3ac5efd86a | ||
![]() |
9dcb74fda0 | ||
![]() |
e759ca58c3 | ||
![]() |
88fcc5f339 | ||
![]() |
495159f0b2 | ||
![]() |
3d9cf696a7 | ||
![]() |
4cf9d7d567 | ||
![]() |
b323c180be | ||
![]() |
0fe5ca9b60 | ||
![]() |
4965480958 | ||
![]() |
1fed785c7d | ||
![]() |
33fd8a6579 | ||
![]() |
e08e34fb90 | ||
![]() |
6164bac66e | ||
![]() |
df86882e8e |
@@ -462,15 +462,24 @@ flowchart TD
|
|||||||
Workflows allow you to filter by:
|
Workflows allow you to filter by:
|
||||||
|
|
||||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
- 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 name, including wildcards e.g. \*.pdf will apply to all pdfs.
|
||||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||||
example, automatically assigning documents to different owners based on the upload directory.
|
example, automatically assigning documents to different owners based on the upload directory.
|
||||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
||||||
- Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags
|
|
||||||
- Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type
|
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
||||||
- Correspondent (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this correspondent
|
|
||||||
- Storage path (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this storage path
|
- Any Tags: Filter for documents with any of the specified tags.
|
||||||
|
- All Tags: Filter for documents with all of the specified tags.
|
||||||
|
- No Tags: Filter for documents with none of the specified tags.
|
||||||
|
- Document type: Filter documents with this document type.
|
||||||
|
- Not Document types: Filter documents without any of these document types.
|
||||||
|
- Correspondent: Filter documents with this correspondent.
|
||||||
|
- Not Correspondents: Filter documents without any of these correspondents.
|
||||||
|
- Storage path: Filter documents with this storage path.
|
||||||
|
- Not Storage paths: Filter documents without any of these storage paths.
|
||||||
|
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
|
@@ -1636,7 +1636,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">44</context>
|
<context context-type="linenumber">45</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
||||||
@@ -1862,7 +1862,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">153</context>
|
<context context-type="linenumber">155</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2134950584701094962" datatype="html">
|
<trans-unit id="2134950584701094962" datatype="html">
|
||||||
@@ -1918,63 +1918,77 @@
|
|||||||
<source>Result</source>
|
<source>Result</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">45</context>
|
<context context-type="linenumber">46</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5404910960991552159" datatype="html">
|
<trans-unit id="5404910960991552159" datatype="html">
|
||||||
<source>Dismiss selected</source>
|
<source>Dismiss selected</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">108</context>
|
<context context-type="linenumber">110</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8829078752502782653" datatype="html">
|
<trans-unit id="8829078752502782653" datatype="html">
|
||||||
<source>Dismiss all</source>
|
<source>Dismiss all</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">109</context>
|
<context context-type="linenumber">111</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1323591410517879795" datatype="html">
|
<trans-unit id="1323591410517879795" datatype="html">
|
||||||
<source>Confirm Dismiss All</source>
|
<source>Confirm Dismiss All</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">150</context>
|
<context context-type="linenumber">152</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4157200209636243740" datatype="html">
|
<trans-unit id="4157200209636243740" datatype="html">
|
||||||
<source>Dismiss all <x id="PH" equiv-text="tasks.size"/> tasks?</source>
|
<source>Dismiss all <x id="PH" equiv-text="tasks.size"/> tasks?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">151</context>
|
<context context-type="linenumber">153</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="3597309129998924778" datatype="html">
|
||||||
|
<source>Error dismissing tasks</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
|
<context context-type="linenumber">161</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="2132179171926568807" datatype="html">
|
||||||
|
<source>Error dismissing task</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
|
<context context-type="linenumber">170</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9011556615675272238" datatype="html">
|
<trans-unit id="9011556615675272238" datatype="html">
|
||||||
<source>queued</source>
|
<source>queued</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">236</context>
|
<context context-type="linenumber">246</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6415892379431855826" datatype="html">
|
<trans-unit id="6415892379431855826" datatype="html">
|
||||||
<source>started</source>
|
<source>started</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">238</context>
|
<context context-type="linenumber">248</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7510279840486540181" datatype="html">
|
<trans-unit id="7510279840486540181" datatype="html">
|
||||||
<source>completed</source>
|
<source>completed</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">240</context>
|
<context context-type="linenumber">250</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4083337005045748464" datatype="html">
|
<trans-unit id="4083337005045748464" datatype="html">
|
||||||
<source>failed</source>
|
<source>failed</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">242</context>
|
<context context-type="linenumber">252</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3418677553313974490" datatype="html">
|
<trans-unit id="3418677553313974490" datatype="html">
|
||||||
@@ -2560,11 +2574,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1025</context>
|
<context context-type="linenumber">1028</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1390</context>
|
<context context-type="linenumber">1393</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -3176,7 +3190,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">978</context>
|
<context context-type="linenumber">981</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -6654,7 +6668,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1389</context>
|
<context context-type="linenumber">1392</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6490688569532630280" datatype="html">
|
<trans-unit id="6490688569532630280" datatype="html">
|
||||||
@@ -7018,53 +7032,53 @@
|
|||||||
<source>Error retrieving metadata</source>
|
<source>Error retrieving metadata</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">666</context>
|
<context context-type="linenumber">669</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3456881259945295697" datatype="html">
|
<trans-unit id="3456881259945295697" datatype="html">
|
||||||
<source>Error retrieving suggestions.</source>
|
<source>Error retrieving suggestions.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">695</context>
|
<context context-type="linenumber">698</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2194092841814123758" datatype="html">
|
<trans-unit id="2194092841814123758" datatype="html">
|
||||||
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">867</context>
|
<context context-type="linenumber">870</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">891</context>
|
<context context-type="linenumber">894</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6626387786259219838" datatype="html">
|
<trans-unit id="6626387786259219838" datatype="html">
|
||||||
<source>Error saving document "<x id="PH" equiv-text="this.document.title"/>"</source>
|
<source>Error saving document "<x id="PH" equiv-text="this.document.title"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">897</context>
|
<context context-type="linenumber">900</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="448882439049417053" datatype="html">
|
<trans-unit id="448882439049417053" datatype="html">
|
||||||
<source>Error saving document</source>
|
<source>Error saving document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">947</context>
|
<context context-type="linenumber">950</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8410796510716511826" datatype="html">
|
<trans-unit id="8410796510716511826" datatype="html">
|
||||||
<source>Do you really want to move the document "<x id="PH" equiv-text="this.document.title"/>" to the trash?</source>
|
<source>Do you really want to move the document "<x id="PH" equiv-text="this.document.title"/>" to the trash?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">979</context>
|
<context context-type="linenumber">982</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="282586936710748252" datatype="html">
|
<trans-unit id="282586936710748252" datatype="html">
|
||||||
<source>Documents can be restored prior to permanent deletion.</source>
|
<source>Documents can be restored prior to permanent deletion.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">980</context>
|
<context context-type="linenumber">983</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -7075,7 +7089,7 @@
|
|||||||
<source>Move to trash</source>
|
<source>Move to trash</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">982</context>
|
<context context-type="linenumber">985</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -7086,14 +7100,14 @@
|
|||||||
<source>Error deleting document</source>
|
<source>Error deleting document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1001</context>
|
<context context-type="linenumber">1004</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="619486176823357521" datatype="html">
|
<trans-unit id="619486176823357521" datatype="html">
|
||||||
<source>Reprocess confirm</source>
|
<source>Reprocess confirm</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1021</context>
|
<context context-type="linenumber">1024</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -7104,81 +7118,81 @@
|
|||||||
<source>This operation will permanently recreate the archive file for this document.</source>
|
<source>This operation will permanently recreate the archive file for this document.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1022</context>
|
<context context-type="linenumber">1025</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="302054111564709516" datatype="html">
|
<trans-unit id="302054111564709516" datatype="html">
|
||||||
<source>The archive file will be re-generated with the current settings.</source>
|
<source>The archive file will be re-generated with the current settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1023</context>
|
<context context-type="linenumber">1026</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8251197608401006898" datatype="html">
|
<trans-unit id="8251197608401006898" datatype="html">
|
||||||
<source>Reprocess operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
|
<source>Reprocess operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1033</context>
|
<context context-type="linenumber">1036</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4409560272830824468" datatype="html">
|
<trans-unit id="4409560272830824468" datatype="html">
|
||||||
<source>Error executing operation</source>
|
<source>Error executing operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1044</context>
|
<context context-type="linenumber">1047</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6030453331794586802" datatype="html">
|
<trans-unit id="6030453331794586802" datatype="html">
|
||||||
<source>Error downloading document</source>
|
<source>Error downloading document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1093</context>
|
<context context-type="linenumber">1096</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4458954481601077369" datatype="html">
|
<trans-unit id="4458954481601077369" datatype="html">
|
||||||
<source>Page Fit</source>
|
<source>Page Fit</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1170</context>
|
<context context-type="linenumber">1173</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4663705961777238777" datatype="html">
|
<trans-unit id="4663705961777238777" datatype="html">
|
||||||
<source>PDF edit operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
<source>PDF edit operation for "<x id="PH" equiv-text="this.document.title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1408</context>
|
<context context-type="linenumber">1411</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9043972994040261999" datatype="html">
|
<trans-unit id="9043972994040261999" datatype="html">
|
||||||
<source>Error executing PDF edit operation</source>
|
<source>Error executing PDF edit operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1420</context>
|
<context context-type="linenumber">1423</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3740891324955700797" datatype="html">
|
<trans-unit id="3740891324955700797" datatype="html">
|
||||||
<source>Print failed.</source>
|
<source>Print failed.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1452</context>
|
<context context-type="linenumber">1455</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6457245677384603573" datatype="html">
|
<trans-unit id="6457245677384603573" datatype="html">
|
||||||
<source>Error loading document for printing.</source>
|
<source>Error loading document for printing.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1460</context>
|
<context context-type="linenumber">1463</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6085793215710522488" datatype="html">
|
<trans-unit id="6085793215710522488" datatype="html">
|
||||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1525</context>
|
<context context-type="linenumber">1528</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1529</context>
|
<context context-type="linenumber">1532</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4958946940233632319" datatype="html">
|
<trans-unit id="4958946940233632319" datatype="html">
|
||||||
|
@@ -16,6 +16,7 @@ import {
|
|||||||
NgbNavItem,
|
NgbNavItem,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
PaperlessTask,
|
PaperlessTask,
|
||||||
@@ -28,6 +29,7 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
|||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
@@ -123,6 +125,7 @@ describe('TasksComponent', () => {
|
|||||||
let router: Router
|
let router: Router
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let reloadSpy
|
let reloadSpy
|
||||||
|
let toastService: ToastService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -157,6 +160,7 @@ describe('TasksComponent', () => {
|
|||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
fixture = TestBed.createComponent(TasksComponent)
|
fixture = TestBed.createComponent(TasksComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
@@ -249,6 +253,42 @@ describe('TasksComponent', () => {
|
|||||||
expect(dismissSpy).toHaveBeenCalledWith(selected)
|
expect(dismissSpy).toHaveBeenCalledWith(selected)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show an error and re-enable modal buttons when dismissing multiple tasks fails', () => {
|
||||||
|
component.selectedTasks = new Set([tasks[0].id, tasks[1].id])
|
||||||
|
const error = new Error('dismiss failed')
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const dismissSpy = jest
|
||||||
|
.spyOn(tasksService, 'dismissTasks')
|
||||||
|
.mockReturnValue(throwError(() => error))
|
||||||
|
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
|
||||||
|
component.dismissTasks()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
|
||||||
|
modal.componentInstance.confirmClicked.emit()
|
||||||
|
|
||||||
|
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
|
||||||
|
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||||
|
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
||||||
|
expect(component.selectedTasks.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show an error when dismissing a single task fails', () => {
|
||||||
|
const error = new Error('dismiss failed')
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const dismissSpy = jest
|
||||||
|
.spyOn(tasksService, 'dismissTasks')
|
||||||
|
.mockReturnValue(throwError(() => error))
|
||||||
|
|
||||||
|
component.dismissTask(tasks[0])
|
||||||
|
|
||||||
|
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id]))
|
||||||
|
expect(toastSpy).toHaveBeenCalledWith('Error dismissing task', error)
|
||||||
|
expect(component.selectedTasks.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('should support dismiss all tasks', () => {
|
it('should support dismiss all tasks', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
@@ -24,6 +24,7 @@ import { PaperlessTask } from 'src/app/data/paperless-task'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
@@ -72,6 +73,7 @@ export class TasksComponent
|
|||||||
tasksService = inject(TasksService)
|
tasksService = inject(TasksService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
|
||||||
public activeTab: TaskTab
|
public activeTab: TaskTab
|
||||||
public selectedTasks: Set<number> = new Set()
|
public selectedTasks: Set<number> = new Set()
|
||||||
@@ -154,11 +156,19 @@ export class TasksComponent
|
|||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissTasks(tasks)
|
this.tasksService.dismissTasks(tasks).subscribe({
|
||||||
|
error: (e) => {
|
||||||
|
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||||
|
modal.componentInstance.buttonsEnabled = true
|
||||||
|
},
|
||||||
|
})
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.tasksService.dismissTasks(tasks)
|
this.tasksService.dismissTasks(tasks).subscribe({
|
||||||
|
error: (e) =>
|
||||||
|
this.toastService.showError($localize`Error dismissing task`, e),
|
||||||
|
})
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,28 +1,36 @@
|
|||||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
@if (useDropdown) {
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||||
<i-bs name="{{icon}}"></i-bs>
|
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
||||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
<i-bs name="{{icon}}"></i-bs>
|
||||||
@if (isActive) {
|
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
@if (isActive) {
|
||||||
}
|
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||||
</button>
|
|
||||||
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
|
|
||||||
<div class="list-group list-group-flush">
|
|
||||||
@for (element of selectionModel.queries; track element.id; let i = $index) {
|
|
||||||
<div class="list-group-item px-0 d-flex flex-nowrap">
|
|
||||||
@switch (element.type) {
|
|
||||||
@case (CustomFieldQueryComponentType.Atom) {
|
|
||||||
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
|
|
||||||
}
|
|
||||||
@case (CustomFieldQueryComponentType.Expression) {
|
|
||||||
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
</button>
|
||||||
|
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
|
||||||
|
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
} @else {
|
||||||
|
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ng-template #list let-queries="queries">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@for (element of queries; track element.id; let i = $index) {
|
||||||
|
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||||
|
@switch (element.type) {
|
||||||
|
@case (CustomFieldQueryComponentType.Atom) {
|
||||||
|
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryComponentType.Expression) {
|
||||||
|
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #comparisonValueTemplate let-atom="atom">
|
<ng-template #comparisonValueTemplate let-atom="atom">
|
||||||
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
||||||
|
@@ -41,9 +41,3 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group-xs {
|
|
||||||
> .btn {
|
|
||||||
border-radius: 0.15rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@@ -120,6 +120,12 @@ export class CustomFieldQueriesModel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addInitialAtom() {
|
||||||
|
this.addAtom(
|
||||||
|
new CustomFieldQueryAtom([null, CustomFieldQueryOperator.Exists, 'true'])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private findElement(
|
private findElement(
|
||||||
queryElement: CustomFieldQueryElement,
|
queryElement: CustomFieldQueryElement,
|
||||||
elements: any[]
|
elements: any[]
|
||||||
@@ -206,6 +212,9 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
@Input()
|
@Input()
|
||||||
applyOnClose = false
|
applyOnClose = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
useDropdown: boolean = true
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||||
}
|
}
|
||||||
@@ -258,13 +267,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
public onOpenChange(open: boolean) {
|
public onOpenChange(open: boolean) {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (this.selectionModel.queries.length === 0) {
|
if (this.selectionModel.queries.length === 0) {
|
||||||
this.selectionModel.addAtom(
|
this.selectionModel.addInitialAtom()
|
||||||
new CustomFieldQueryAtom([
|
|
||||||
null,
|
|
||||||
CustomFieldQueryOperator.Exists,
|
|
||||||
'true',
|
|
||||||
])
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
this.selectionModel.queries.length === 1 &&
|
this.selectionModel.queries.length === 1 &&
|
||||||
|
@@ -156,31 +156,97 @@
|
|||||||
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" horizontal="true" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
||||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" horizontal="true" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
|
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||||
}
|
}
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||||
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (patternRequired) {
|
@if (matchingPatternRequired(formGroup)) {
|
||||||
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||||
}
|
}
|
||||||
@if (patternRequired) {
|
@if (matchingPatternRequired(formGroup)) {
|
||||||
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
|
<pngx-input-check i18n-title title="Case insensitive" horizontal="true" formControlName="is_insensitive"></pngx-input-check>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
|
||||||
<div class="col-md-6">
|
|
||||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
|
|
||||||
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
|
||||||
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
|
|
||||||
<pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col">
|
||||||
|
<div class="trigger-filters mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<label class="form-label mb-0" i18n>Advanced Filters</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary ms-auto"
|
||||||
|
(click)="addFilter(formGroup)"
|
||||||
|
[disabled]="!canAddFilter(formGroup)"
|
||||||
|
>
|
||||||
|
<i-bs name="plus-circle"></i-bs> <span i18n>Add filter</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul class="mt-2 list-group filters" formArrayName="filters">
|
||||||
|
@if (getFiltersFormArray(formGroup).length === 0) {
|
||||||
|
<p class="text-muted small" i18n>No advanced workflow filters defined.</p>
|
||||||
|
}
|
||||||
|
@for (filter of getFiltersFormArray(formGroup).controls; track filter; let filterIndex = $index) {
|
||||||
|
<li [formGroupName]="filterIndex" class="list-group-item">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="w-25">
|
||||||
|
<pngx-input-select
|
||||||
|
i18n-title
|
||||||
|
[items]="getFilterTypeOptions(formGroup, filterIndex)"
|
||||||
|
formControlName="type"
|
||||||
|
[allowNull]="false"
|
||||||
|
></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
@if (isTagsFilter(filter.get('type').value)) {
|
||||||
|
<pngx-input-tags
|
||||||
|
[allowCreate]="false"
|
||||||
|
[title]="null"
|
||||||
|
formControlName="values"
|
||||||
|
></pngx-input-tags>
|
||||||
|
} @else if (
|
||||||
|
isCustomFieldQueryFilter(filter.get('type').value)
|
||||||
|
) {
|
||||||
|
<pngx-custom-fields-query-dropdown
|
||||||
|
[selectionModel]="getCustomFieldQueryModel(filter)"
|
||||||
|
(selectionModelChange)="onCustomFieldQuerySelectionChange(filter, $event)"
|
||||||
|
[useDropdown]="false"
|
||||||
|
></pngx-custom-fields-query-dropdown>
|
||||||
|
@if (!isCustomFieldQueryValid(filter)) {
|
||||||
|
<div class="text-danger small" i18n>
|
||||||
|
Complete the custom field query configuration.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<pngx-input-select
|
||||||
|
[items]="getFilterSelectItems(filter.get('type').value)"
|
||||||
|
[allowNull]="true"
|
||||||
|
[multiple]="isSelectMultiple(filter.get('type').value)"
|
||||||
|
formControlName="values"
|
||||||
|
></pngx-input-select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link text-danger p-0"
|
||||||
|
(click)="removeFilter(formGroup, filterIndex)"
|
||||||
|
>
|
||||||
|
<i-bs name="trash"></i-bs><span class="ms-1" i18n>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
@@ -7,3 +7,7 @@
|
|||||||
.accordion-button {
|
.accordion-button {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .filters .paperless-input-select.mb-3 {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
@@ -11,8 +11,14 @@ import {
|
|||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
|
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
|
||||||
|
import {
|
||||||
|
MATCHING_ALGORITHMS,
|
||||||
|
MATCH_AUTO,
|
||||||
|
MATCH_NONE,
|
||||||
|
} from 'src/app/data/matching-model'
|
||||||
import { Workflow } from 'src/app/data/workflow'
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import {
|
import {
|
||||||
WorkflowAction,
|
WorkflowAction,
|
||||||
@@ -31,6 +37,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
|||||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||||
import { NumberComponent } from '../../input/number/number.component'
|
import { NumberComponent } from '../../input/number/number.component'
|
||||||
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
|
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
|
||||||
@@ -43,6 +50,7 @@ import { EditDialogMode } from '../edit-dialog.component'
|
|||||||
import {
|
import {
|
||||||
DOCUMENT_SOURCE_OPTIONS,
|
DOCUMENT_SOURCE_OPTIONS,
|
||||||
SCHEDULE_DATE_FIELD_OPTIONS,
|
SCHEDULE_DATE_FIELD_OPTIONS,
|
||||||
|
TriggerFilterType,
|
||||||
WORKFLOW_ACTION_OPTIONS,
|
WORKFLOW_ACTION_OPTIONS,
|
||||||
WORKFLOW_TYPE_OPTIONS,
|
WORKFLOW_TYPE_OPTIONS,
|
||||||
WorkflowEditDialogComponent,
|
WorkflowEditDialogComponent,
|
||||||
@@ -375,6 +383,562 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
|
expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should require matching pattern when algorithm is not none', () => {
|
||||||
|
const triggerGroup = new FormGroup({
|
||||||
|
matching_algorithm: new FormControl(MATCH_AUTO),
|
||||||
|
match: new FormControl(''),
|
||||||
|
})
|
||||||
|
expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
|
||||||
|
triggerGroup.get('matching_algorithm').setValue(MATCHING_ALGORITHMS[0].id)
|
||||||
|
expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
|
||||||
|
triggerGroup.get('matching_algorithm').setValue(MATCH_NONE)
|
||||||
|
expect(component.matchingPatternRequired(triggerGroup)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should map filter builder values into trigger filters on save', () => {
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0)
|
||||||
|
component.addFilter(triggerGroup as FormGroup)
|
||||||
|
component.addFilter(triggerGroup as FormGroup)
|
||||||
|
component.addFilter(triggerGroup as FormGroup)
|
||||||
|
|
||||||
|
const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
|
||||||
|
expect(filters.length).toBe(3)
|
||||||
|
|
||||||
|
filters.at(0).get('values').setValue([1])
|
||||||
|
filters.at(1).get('values').setValue([2, 3])
|
||||||
|
filters.at(2).get('values').setValue([4])
|
||||||
|
|
||||||
|
const addFilterOfType = (type: TriggerFilterType) => {
|
||||||
|
const newFilter = component.addFilter(triggerGroup as FormGroup)
|
||||||
|
newFilter.get('type').setValue(type)
|
||||||
|
return newFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
const correspondentIs = addFilterOfType(TriggerFilterType.CorrespondentIs)
|
||||||
|
correspondentIs.get('values').setValue(1)
|
||||||
|
|
||||||
|
const correspondentNot = addFilterOfType(TriggerFilterType.CorrespondentNot)
|
||||||
|
correspondentNot.get('values').setValue([1])
|
||||||
|
|
||||||
|
const documentTypeIs = addFilterOfType(TriggerFilterType.DocumentTypeIs)
|
||||||
|
documentTypeIs.get('values').setValue(1)
|
||||||
|
|
||||||
|
const documentTypeNot = addFilterOfType(TriggerFilterType.DocumentTypeNot)
|
||||||
|
documentTypeNot.get('values').setValue([1])
|
||||||
|
|
||||||
|
const storagePathIs = addFilterOfType(TriggerFilterType.StoragePathIs)
|
||||||
|
storagePathIs.get('values').setValue(1)
|
||||||
|
|
||||||
|
const storagePathNot = addFilterOfType(TriggerFilterType.StoragePathNot)
|
||||||
|
storagePathNot.get('values').setValue([1])
|
||||||
|
|
||||||
|
const customFieldFilter = addFilterOfType(
|
||||||
|
TriggerFilterType.CustomFieldQuery
|
||||||
|
)
|
||||||
|
const customFieldQuery = JSON.stringify(['AND', [[1, 'exact', 'test']]])
|
||||||
|
customFieldFilter.get('values').setValue(customFieldQuery)
|
||||||
|
|
||||||
|
const formValues = component['getFormValues']()
|
||||||
|
|
||||||
|
expect(formValues.triggers[0].filter_has_tags).toEqual([1])
|
||||||
|
expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3])
|
||||||
|
expect(formValues.triggers[0].filter_has_not_tags).toEqual([4])
|
||||||
|
expect(formValues.triggers[0].filter_has_correspondent).toEqual(1)
|
||||||
|
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1])
|
||||||
|
expect(formValues.triggers[0].filter_has_document_type).toEqual(1)
|
||||||
|
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1])
|
||||||
|
expect(formValues.triggers[0].filter_has_storage_path).toEqual(1)
|
||||||
|
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1])
|
||||||
|
expect(formValues.triggers[0].filter_custom_field_query).toEqual(
|
||||||
|
customFieldQuery
|
||||||
|
)
|
||||||
|
expect(formValues.triggers[0].filters).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore empty and null filter values when mapping filters', () => {
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
|
|
||||||
|
const tagsFilter = component.addFilter(triggerGroup)
|
||||||
|
tagsFilter.get('type').setValue(TriggerFilterType.TagsAny)
|
||||||
|
tagsFilter.get('values').setValue([])
|
||||||
|
|
||||||
|
const correspondentFilter = component.addFilter(triggerGroup)
|
||||||
|
correspondentFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
|
||||||
|
correspondentFilter.get('values').setValue(null)
|
||||||
|
|
||||||
|
const formValues = component['getFormValues']()
|
||||||
|
|
||||||
|
expect(formValues.triggers[0].filter_has_tags).toEqual([])
|
||||||
|
expect(formValues.triggers[0].filter_has_correspondent).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should derive single select filters from array values', () => {
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
|
|
||||||
|
const addFilterOfType = (type: TriggerFilterType, value: any) => {
|
||||||
|
const filter = component.addFilter(triggerGroup)
|
||||||
|
filter.get('type').setValue(type)
|
||||||
|
filter.get('values').setValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
addFilterOfType(TriggerFilterType.CorrespondentIs, [5])
|
||||||
|
addFilterOfType(TriggerFilterType.DocumentTypeIs, [6])
|
||||||
|
addFilterOfType(TriggerFilterType.StoragePathIs, [7])
|
||||||
|
|
||||||
|
const formValues = component['getFormValues']()
|
||||||
|
|
||||||
|
expect(formValues.triggers[0].filter_has_correspondent).toEqual(5)
|
||||||
|
expect(formValues.triggers[0].filter_has_document_type).toEqual(6)
|
||||||
|
expect(formValues.triggers[0].filter_has_storage_path).toEqual(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should convert multi-value filter values when aggregating filters', () => {
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
|
|
||||||
|
const setFilter = (type: TriggerFilterType, value: number): void => {
|
||||||
|
const filter = component.addFilter(triggerGroup) as FormGroup
|
||||||
|
filter.get('type').setValue(type)
|
||||||
|
filter.get('values').setValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilter(TriggerFilterType.TagsAll, 11)
|
||||||
|
setFilter(TriggerFilterType.TagsNone, 12)
|
||||||
|
setFilter(TriggerFilterType.CorrespondentNot, 13)
|
||||||
|
setFilter(TriggerFilterType.DocumentTypeNot, 14)
|
||||||
|
setFilter(TriggerFilterType.StoragePathNot, 15)
|
||||||
|
|
||||||
|
const formValues = component['getFormValues']()
|
||||||
|
|
||||||
|
expect(formValues.triggers[0].filter_has_all_tags).toEqual([11])
|
||||||
|
expect(formValues.triggers[0].filter_has_not_tags).toEqual([12])
|
||||||
|
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([13])
|
||||||
|
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([14])
|
||||||
|
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([15])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reuse filter type options and update disabled state', () => {
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
|
component.addFilter(triggerGroup)
|
||||||
|
|
||||||
|
const optionsFirst = component.getFilterTypeOptions(triggerGroup, 0)
|
||||||
|
const optionsSecond = component.getFilterTypeOptions(triggerGroup, 0)
|
||||||
|
expect(optionsFirst).toBe(optionsSecond)
|
||||||
|
|
||||||
|
// to force disabled flag
|
||||||
|
component.addFilter(triggerGroup)
|
||||||
|
const filterArray = component.getFiltersFormArray(triggerGroup)
|
||||||
|
const firstFilter = filterArray.at(0)
|
||||||
|
firstFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
|
||||||
|
|
||||||
|
component.addFilter(triggerGroup)
|
||||||
|
const updatedFilters = component.getFiltersFormArray(triggerGroup)
|
||||||
|
const secondFilter = updatedFilters.at(1)
|
||||||
|
const options = component.getFilterTypeOptions(triggerGroup, 1)
|
||||||
|
const correspondentIsOption = options.find(
|
||||||
|
(option) => option.id === TriggerFilterType.CorrespondentIs
|
||||||
|
)
|
||||||
|
expect(correspondentIsOption.disabled).toBe(true)
|
||||||
|
|
||||||
|
firstFilter.get('type').setValue(TriggerFilterType.DocumentTypeNot)
|
||||||
|
secondFilter.get('type').setValue(TriggerFilterType.TagsAll)
|
||||||
|
const postChangeOptions = component.getFilterTypeOptions(triggerGroup, 1)
|
||||||
|
const correspondentOptionAfter = postChangeOptions.find(
|
||||||
|
(option) => option.id === TriggerFilterType.CorrespondentIs
|
||||||
|
)
|
||||||
|
expect(correspondentOptionAfter.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep multi-entry filter options enabled and allow duplicates', () => {
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
|
|
||||||
|
component.filterDefinitions = [
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.TagsAny,
|
||||||
|
name: 'Any tags',
|
||||||
|
inputType: 'tags',
|
||||||
|
allowMultipleEntries: true,
|
||||||
|
allowMultipleValues: true,
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.CorrespondentIs,
|
||||||
|
name: 'Correspondent is',
|
||||||
|
inputType: 'select',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: false,
|
||||||
|
selectItems: 'correspondents',
|
||||||
|
} as any,
|
||||||
|
]
|
||||||
|
|
||||||
|
const firstFilter = component.addFilter(triggerGroup)
|
||||||
|
firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
|
||||||
|
|
||||||
|
const secondFilter = component.addFilter(triggerGroup)
|
||||||
|
expect(secondFilter).not.toBeNull()
|
||||||
|
|
||||||
|
const options = component.getFilterTypeOptions(triggerGroup, 1)
|
||||||
|
const multiEntryOption = options.find(
|
||||||
|
(option) => option.id === TriggerFilterType.TagsAny
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(multiEntryOption.disabled).toBe(false)
|
||||||
|
expect(component.canAddFilter(triggerGroup)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null when no filter definitions remain available', () => {
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
|
|
||||||
|
component.filterDefinitions = [
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.TagsAny,
|
||||||
|
name: 'Any tags',
|
||||||
|
inputType: 'tags',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: true,
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.CorrespondentIs,
|
||||||
|
name: 'Correspondent is',
|
||||||
|
inputType: 'select',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: false,
|
||||||
|
selectItems: 'correspondents',
|
||||||
|
} as any,
|
||||||
|
]
|
||||||
|
|
||||||
|
const firstFilter = component.addFilter(triggerGroup)
|
||||||
|
firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
|
||||||
|
const secondFilter = component.addFilter(triggerGroup)
|
||||||
|
secondFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
|
||||||
|
|
||||||
|
expect(component.canAddFilter(triggerGroup)).toBe(false)
|
||||||
|
expect(component.addFilter(triggerGroup)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip filter definitions without handlers when building form array', () => {
|
||||||
|
const originalDefinitions = component.filterDefinitions
|
||||||
|
component.filterDefinitions = [
|
||||||
|
{
|
||||||
|
id: 999,
|
||||||
|
name: 'Unsupported',
|
||||||
|
inputType: 'text',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: false,
|
||||||
|
} as any,
|
||||||
|
]
|
||||||
|
|
||||||
|
const trigger = {
|
||||||
|
filter_has_tags: [],
|
||||||
|
filter_has_all_tags: [],
|
||||||
|
filter_has_not_tags: [],
|
||||||
|
filter_has_not_correspondents: [],
|
||||||
|
filter_has_not_document_types: [],
|
||||||
|
filter_has_not_storage_paths: [],
|
||||||
|
filter_has_correspondent: null,
|
||||||
|
filter_has_document_type: null,
|
||||||
|
filter_has_storage_path: null,
|
||||||
|
filter_custom_field_query: null,
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const filters = component['buildFiltersFormArray'](trigger)
|
||||||
|
expect(filters.length).toBe(0)
|
||||||
|
|
||||||
|
component.filterDefinitions = originalDefinitions
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null when adding filter for unknown trigger form group', () => {
|
||||||
|
expect(component.addFilter(new FormGroup({}) as any)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore remove filter calls for unknown trigger form group', () => {
|
||||||
|
expect(() =>
|
||||||
|
component.removeFilter(new FormGroup({}) as any, 0)
|
||||||
|
).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should teardown custom field query model when removing a custom field filter', () => {
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
|
|
||||||
|
component.addFilter(triggerGroup)
|
||||||
|
const filters = component.getFiltersFormArray(triggerGroup)
|
||||||
|
const filterGroup = filters.at(0) as FormGroup
|
||||||
|
filterGroup.get('type').setValue(TriggerFilterType.CustomFieldQuery)
|
||||||
|
|
||||||
|
const model = component.getCustomFieldQueryModel(filterGroup)
|
||||||
|
expect(model).toBeDefined()
|
||||||
|
expect(
|
||||||
|
component['getStoredCustomFieldQueryModel'](filterGroup as any)
|
||||||
|
).toBe(model)
|
||||||
|
|
||||||
|
component.removeFilter(triggerGroup, 0)
|
||||||
|
expect(
|
||||||
|
component['getStoredCustomFieldQueryModel'](filterGroup as any)
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return readable filter names', () => {
|
||||||
|
expect(component.getFilterName(TriggerFilterType.TagsAny)).toBe(
|
||||||
|
'Has any of these tags'
|
||||||
|
)
|
||||||
|
expect(component.getFilterName(999 as any)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should build filter form array from existing trigger filters', () => {
|
||||||
|
const trigger = workflow.triggers[0]
|
||||||
|
trigger.filter_has_tags = [1]
|
||||||
|
trigger.filter_has_all_tags = [2, 3]
|
||||||
|
trigger.filter_has_not_tags = [4]
|
||||||
|
trigger.filter_has_correspondent = 5 as any
|
||||||
|
trigger.filter_has_not_correspondents = [6] as any
|
||||||
|
trigger.filter_has_document_type = 7 as any
|
||||||
|
trigger.filter_has_not_document_types = [8] as any
|
||||||
|
trigger.filter_has_storage_path = 9 as any
|
||||||
|
trigger.filter_has_not_storage_paths = [10] as any
|
||||||
|
trigger.filter_custom_field_query = JSON.stringify([
|
||||||
|
'AND',
|
||||||
|
[[1, 'exact', 'value']],
|
||||||
|
]) as any
|
||||||
|
|
||||||
|
component.object = workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
|
const filters = component.getFiltersFormArray(triggerGroup)
|
||||||
|
expect(filters.length).toBe(10)
|
||||||
|
const customFieldFilter = filters.at(9) as FormGroup
|
||||||
|
expect(customFieldFilter.get('type').value).toBe(
|
||||||
|
TriggerFilterType.CustomFieldQuery
|
||||||
|
)
|
||||||
|
const model = component.getCustomFieldQueryModel(customFieldFilter)
|
||||||
|
expect(model.isValid()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expose select metadata helpers', () => {
|
||||||
|
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentNot)).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentIs)).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
component.correspondents = [{ id: 1, name: 'C1' } as any]
|
||||||
|
component.documentTypes = [{ id: 2, name: 'DT' } as any]
|
||||||
|
component.storagePaths = [{ id: 3, name: 'SP' } as any]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
|
||||||
|
).toEqual(component.correspondents)
|
||||||
|
expect(
|
||||||
|
component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
|
||||||
|
).toEqual(component.documentTypes)
|
||||||
|
expect(
|
||||||
|
component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
|
||||||
|
).toEqual(component.storagePaths)
|
||||||
|
expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component.isCustomFieldQueryFilter(TriggerFilterType.CustomFieldQuery)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty select items when definition is missing', () => {
|
||||||
|
const originalDefinitions = component.filterDefinitions
|
||||||
|
component.filterDefinitions = []
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
|
||||||
|
).toEqual([])
|
||||||
|
|
||||||
|
component.filterDefinitions = originalDefinitions
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty select items when definition has unknown source', () => {
|
||||||
|
const originalDefinitions = component.filterDefinitions
|
||||||
|
component.filterDefinitions = [
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.CorrespondentIs,
|
||||||
|
name: 'Correspondent is',
|
||||||
|
inputType: 'select',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: false,
|
||||||
|
selectItems: 'unknown',
|
||||||
|
} as any,
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
|
||||||
|
).toEqual([])
|
||||||
|
|
||||||
|
component.filterDefinitions = originalDefinitions
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle custom field query selection change and validation states', () => {
|
||||||
|
const formGroup = new FormGroup({
|
||||||
|
values: new FormControl(null),
|
||||||
|
})
|
||||||
|
const model = new CustomFieldQueriesModel()
|
||||||
|
|
||||||
|
const changeSpy = jest.spyOn(
|
||||||
|
component as any,
|
||||||
|
'onCustomFieldQueryModelChanged'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.onCustomFieldQuerySelectionChange(formGroup, model)
|
||||||
|
expect(changeSpy).toHaveBeenCalledWith(formGroup, model)
|
||||||
|
|
||||||
|
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
|
||||||
|
component['setCustomFieldQueryModel'](formGroup as any, model as any)
|
||||||
|
|
||||||
|
const validSpy = jest.spyOn(model, 'isValid').mockReturnValue(false)
|
||||||
|
const emptySpy = jest.spyOn(model, 'isEmpty').mockReturnValue(false)
|
||||||
|
expect(component.isCustomFieldQueryValid(formGroup)).toBe(false)
|
||||||
|
expect(validSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
validSpy.mockReturnValue(true)
|
||||||
|
emptySpy.mockReturnValue(true)
|
||||||
|
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
|
||||||
|
|
||||||
|
emptySpy.mockReturnValue(false)
|
||||||
|
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
|
||||||
|
|
||||||
|
component['clearCustomFieldQueryModel'](formGroup as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should recover from invalid custom field query json and update control on changes', () => {
|
||||||
|
const filterGroup = new FormGroup({
|
||||||
|
values: new FormControl('not-json'),
|
||||||
|
})
|
||||||
|
|
||||||
|
component['ensureCustomFieldQueryModel'](filterGroup, 'not-json')
|
||||||
|
|
||||||
|
const model = component['getStoredCustomFieldQueryModel'](
|
||||||
|
filterGroup as any
|
||||||
|
)
|
||||||
|
expect(model).toBeDefined()
|
||||||
|
expect(model.queries.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const valuesControl = filterGroup.get('values')
|
||||||
|
expect(valuesControl.value).toBeNull()
|
||||||
|
|
||||||
|
const expression = new CustomFieldQueryExpression([
|
||||||
|
CustomFieldQueryLogicalOperator.And,
|
||||||
|
[[1, 'exact', 'value']],
|
||||||
|
])
|
||||||
|
model.queries = [expression]
|
||||||
|
|
||||||
|
jest.spyOn(model, 'isValid').mockReturnValue(true)
|
||||||
|
jest.spyOn(model, 'isEmpty').mockReturnValue(false)
|
||||||
|
|
||||||
|
model.changed.next(model)
|
||||||
|
|
||||||
|
expect(valuesControl.value).toEqual(JSON.stringify(expression.serialize()))
|
||||||
|
|
||||||
|
component['clearCustomFieldQueryModel'](filterGroup as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle custom field query model change edge cases', () => {
|
||||||
|
const groupWithoutControl = new FormGroup({})
|
||||||
|
const dummyModel = {
|
||||||
|
isValid: jest.fn().mockReturnValue(true),
|
||||||
|
isEmpty: jest.fn().mockReturnValue(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
component['onCustomFieldQueryModelChanged'](
|
||||||
|
groupWithoutControl as any,
|
||||||
|
dummyModel as any
|
||||||
|
)
|
||||||
|
).not.toThrow()
|
||||||
|
|
||||||
|
const groupWithControl = new FormGroup({
|
||||||
|
values: new FormControl('initial'),
|
||||||
|
})
|
||||||
|
const emptyModel = {
|
||||||
|
isValid: jest.fn().mockReturnValue(true),
|
||||||
|
isEmpty: jest.fn().mockReturnValue(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
component['onCustomFieldQueryModelChanged'](
|
||||||
|
groupWithControl as any,
|
||||||
|
emptyModel as any
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(groupWithControl.get('values').value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize filter values for single and multi selects', () => {
|
||||||
|
expect(
|
||||||
|
component['normalizeFilterValue'](TriggerFilterType.TagsAny)
|
||||||
|
).toEqual([])
|
||||||
|
expect(
|
||||||
|
component['normalizeFilterValue'](TriggerFilterType.TagsAny, 5)
|
||||||
|
).toEqual([5])
|
||||||
|
expect(
|
||||||
|
component['normalizeFilterValue'](TriggerFilterType.TagsAny, [5, 6])
|
||||||
|
).toEqual([5, 6])
|
||||||
|
expect(
|
||||||
|
component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, [7])
|
||||||
|
).toEqual(7)
|
||||||
|
expect(
|
||||||
|
component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, 8)
|
||||||
|
).toEqual(8)
|
||||||
|
const customFieldJson = JSON.stringify(['AND', [[1, 'exact', 'test']]])
|
||||||
|
expect(
|
||||||
|
component['normalizeFilterValue'](
|
||||||
|
TriggerFilterType.CustomFieldQuery,
|
||||||
|
customFieldJson
|
||||||
|
)
|
||||||
|
).toEqual(customFieldJson)
|
||||||
|
|
||||||
|
const customFieldObject = ['AND', [[1, 'exact', 'other']]]
|
||||||
|
expect(
|
||||||
|
component['normalizeFilterValue'](
|
||||||
|
TriggerFilterType.CustomFieldQuery,
|
||||||
|
customFieldObject
|
||||||
|
)
|
||||||
|
).toEqual(JSON.stringify(customFieldObject))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component['normalizeFilterValue'](
|
||||||
|
TriggerFilterType.CustomFieldQuery,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add and remove filter form groups', () => {
|
||||||
|
component['changeDetector'] = { detectChanges: jest.fn() } as any
|
||||||
|
component.object = undefined
|
||||||
|
component.addTrigger()
|
||||||
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
|
|
||||||
|
component.addFilter(triggerGroup)
|
||||||
|
|
||||||
|
component.removeFilter(triggerGroup, 0)
|
||||||
|
expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
|
||||||
|
|
||||||
|
component.addFilter(triggerGroup)
|
||||||
|
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
|
||||||
|
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
|
||||||
|
expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('should remove selected custom field from the form group', () => {
|
it('should remove selected custom field from the form group', () => {
|
||||||
const formGroup = new FormGroup({
|
const formGroup = new FormGroup({
|
||||||
assign_custom_fields: new FormControl([1, 2, 3]),
|
assign_custom_fields: new FormControl([1, 2, 3]),
|
||||||
|
@@ -6,6 +6,7 @@ import {
|
|||||||
import { NgTemplateOutlet } from '@angular/common'
|
import { NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnInit, inject } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
|
AbstractControl,
|
||||||
FormArray,
|
FormArray,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { first } from 'rxjs'
|
import { Subscription, first, takeUntil } from 'rxjs'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
@@ -45,7 +46,12 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
|||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||||
|
import {
|
||||||
|
CustomFieldQueriesModel,
|
||||||
|
CustomFieldsQueryDropdownComponent,
|
||||||
|
} from '../../custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||||
import { CheckComponent } from '../../input/check/check.component'
|
import { CheckComponent } from '../../input/check/check.component'
|
||||||
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
|
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
|
||||||
import { EntriesComponent } from '../../input/entries/entries.component'
|
import { EntriesComponent } from '../../input/entries/entries.component'
|
||||||
@@ -135,10 +141,235 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export enum TriggerFilterType {
|
||||||
|
TagsAny = 'tags_any',
|
||||||
|
TagsAll = 'tags_all',
|
||||||
|
TagsNone = 'tags_none',
|
||||||
|
CorrespondentIs = 'correspondent_is',
|
||||||
|
CorrespondentNot = 'correspondent_not',
|
||||||
|
DocumentTypeIs = 'document_type_is',
|
||||||
|
DocumentTypeNot = 'document_type_not',
|
||||||
|
StoragePathIs = 'storage_path_is',
|
||||||
|
StoragePathNot = 'storage_path_not',
|
||||||
|
CustomFieldQuery = 'custom_field_query',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerFilterDefinition {
|
||||||
|
id: TriggerFilterType
|
||||||
|
name: string
|
||||||
|
inputType: 'tags' | 'select' | 'customFieldQuery'
|
||||||
|
allowMultipleEntries: boolean
|
||||||
|
allowMultipleValues: boolean
|
||||||
|
selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths'
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type TriggerFilterOption = TriggerFilterDefinition & {
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type TriggerFilterAggregate = {
|
||||||
|
filter_has_tags: number[]
|
||||||
|
filter_has_all_tags: number[]
|
||||||
|
filter_has_not_tags: number[]
|
||||||
|
filter_has_not_correspondents: number[]
|
||||||
|
filter_has_not_document_types: number[]
|
||||||
|
filter_has_not_storage_paths: number[]
|
||||||
|
filter_has_correspondent: number | null
|
||||||
|
filter_has_document_type: number | null
|
||||||
|
filter_has_storage_path: number | null
|
||||||
|
filter_custom_field_query: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterHandler {
|
||||||
|
apply: (aggregate: TriggerFilterAggregate, values: any) => void
|
||||||
|
extract: (trigger: WorkflowTrigger) => any
|
||||||
|
hasValue: (value: any) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUSTOM_FIELD_QUERY_MODEL_KEY = Symbol('customFieldQueryModel')
|
||||||
|
const CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY = Symbol(
|
||||||
|
'customFieldQuerySubscription'
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomFieldFilterGroup = FormGroup & {
|
||||||
|
[CUSTOM_FIELD_QUERY_MODEL_KEY]?: CustomFieldQueriesModel
|
||||||
|
[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?: Subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.TagsAny,
|
||||||
|
name: $localize`Has any of these tags`,
|
||||||
|
inputType: 'tags',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.TagsAll,
|
||||||
|
name: $localize`Has all of these tags`,
|
||||||
|
inputType: 'tags',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.TagsNone,
|
||||||
|
name: $localize`Does not have these tags`,
|
||||||
|
inputType: 'tags',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.CorrespondentIs,
|
||||||
|
name: $localize`Has correspondent`,
|
||||||
|
inputType: 'select',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: false,
|
||||||
|
selectItems: 'correspondents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.CorrespondentNot,
|
||||||
|
name: $localize`Does not have correspondents`,
|
||||||
|
inputType: 'select',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: true,
|
||||||
|
selectItems: 'correspondents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.DocumentTypeIs,
|
||||||
|
name: $localize`Has document type`,
|
||||||
|
inputType: 'select',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: false,
|
||||||
|
selectItems: 'documentTypes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.DocumentTypeNot,
|
||||||
|
name: $localize`Does not have document types`,
|
||||||
|
inputType: 'select',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: true,
|
||||||
|
selectItems: 'documentTypes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.StoragePathIs,
|
||||||
|
name: $localize`Has storage path`,
|
||||||
|
inputType: 'select',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: false,
|
||||||
|
selectItems: 'storagePaths',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.StoragePathNot,
|
||||||
|
name: $localize`Does not have storage paths`,
|
||||||
|
inputType: 'select',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: true,
|
||||||
|
selectItems: 'storagePaths',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: TriggerFilterType.CustomFieldQuery,
|
||||||
|
name: $localize`Matches custom field query`,
|
||||||
|
inputType: 'customFieldQuery',
|
||||||
|
allowMultipleEntries: false,
|
||||||
|
allowMultipleValues: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||||
(a) => a.id !== MATCH_AUTO
|
(a) => a.id !== MATCH_AUTO
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
|
||||||
|
[TriggerFilterType.TagsAny]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_has_tags = Array.isArray(values) ? [...values] : [values]
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_has_tags,
|
||||||
|
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||||
|
},
|
||||||
|
[TriggerFilterType.TagsAll]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_has_all_tags = Array.isArray(values)
|
||||||
|
? [...values]
|
||||||
|
: [values]
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_has_all_tags,
|
||||||
|
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||||
|
},
|
||||||
|
[TriggerFilterType.TagsNone]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_has_not_tags = Array.isArray(values)
|
||||||
|
? [...values]
|
||||||
|
: [values]
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_has_not_tags,
|
||||||
|
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||||
|
},
|
||||||
|
[TriggerFilterType.CorrespondentIs]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_has_correspondent = Array.isArray(values)
|
||||||
|
? (values[0] ?? null)
|
||||||
|
: values
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_has_correspondent,
|
||||||
|
hasValue: (value) => value !== null && value !== undefined,
|
||||||
|
},
|
||||||
|
[TriggerFilterType.CorrespondentNot]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_has_not_correspondents = Array.isArray(values)
|
||||||
|
? [...values]
|
||||||
|
: [values]
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_has_not_correspondents,
|
||||||
|
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||||
|
},
|
||||||
|
[TriggerFilterType.DocumentTypeIs]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_has_document_type = Array.isArray(values)
|
||||||
|
? (values[0] ?? null)
|
||||||
|
: values
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_has_document_type,
|
||||||
|
hasValue: (value) => value !== null && value !== undefined,
|
||||||
|
},
|
||||||
|
[TriggerFilterType.DocumentTypeNot]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_has_not_document_types = Array.isArray(values)
|
||||||
|
? [...values]
|
||||||
|
: [values]
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_has_not_document_types,
|
||||||
|
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||||
|
},
|
||||||
|
[TriggerFilterType.StoragePathIs]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_has_storage_path = Array.isArray(values)
|
||||||
|
? (values[0] ?? null)
|
||||||
|
: values
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_has_storage_path,
|
||||||
|
hasValue: (value) => value !== null && value !== undefined,
|
||||||
|
},
|
||||||
|
[TriggerFilterType.StoragePathNot]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_has_not_storage_paths = Array.isArray(values)
|
||||||
|
? [...values]
|
||||||
|
: [values]
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_has_not_storage_paths,
|
||||||
|
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||||
|
},
|
||||||
|
[TriggerFilterType.CustomFieldQuery]: {
|
||||||
|
apply: (aggregate, values) => {
|
||||||
|
aggregate.filter_custom_field_query = values as string
|
||||||
|
},
|
||||||
|
extract: (trigger) => trigger.filter_custom_field_query,
|
||||||
|
hasValue: (value) =>
|
||||||
|
typeof value === 'string' && value !== null && value.trim().length > 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-workflow-edit-dialog',
|
selector: 'pngx-workflow-edit-dialog',
|
||||||
templateUrl: './workflow-edit-dialog.component.html',
|
templateUrl: './workflow-edit-dialog.component.html',
|
||||||
@@ -153,6 +384,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
|||||||
TextAreaComponent,
|
TextAreaComponent,
|
||||||
TagsComponent,
|
TagsComponent,
|
||||||
CustomFieldsValuesComponent,
|
CustomFieldsValuesComponent,
|
||||||
|
CustomFieldsQueryDropdownComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
@@ -170,6 +402,8 @@ export class WorkflowEditDialogComponent
|
|||||||
{
|
{
|
||||||
public WorkflowTriggerType = WorkflowTriggerType
|
public WorkflowTriggerType = WorkflowTriggerType
|
||||||
public WorkflowActionType = WorkflowActionType
|
public WorkflowActionType = WorkflowActionType
|
||||||
|
public TriggerFilterType = TriggerFilterType
|
||||||
|
public filterDefinitions = TRIGGER_FILTER_DEFINITIONS
|
||||||
|
|
||||||
private correspondentService: CorrespondentService
|
private correspondentService: CorrespondentService
|
||||||
private documentTypeService: DocumentTypeService
|
private documentTypeService: DocumentTypeService
|
||||||
@@ -189,6 +423,11 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
private allowedActionTypes = []
|
private allowedActionTypes = []
|
||||||
|
|
||||||
|
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||||
|
FormArray,
|
||||||
|
TriggerFilterOption[]
|
||||||
|
>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.service = inject(WorkflowService)
|
this.service = inject(WorkflowService)
|
||||||
@@ -390,6 +629,416 @@ export class WorkflowEditDialogComponent
|
|||||||
return this.objectForm.get('actions') as FormArray
|
return this.objectForm.get('actions') as FormArray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override getFormValues(): any {
|
||||||
|
const formValues = super.getFormValues()
|
||||||
|
|
||||||
|
if (formValues?.triggers?.length) {
|
||||||
|
formValues.triggers = formValues.triggers.map(
|
||||||
|
(trigger: any, index: number) => {
|
||||||
|
const triggerFormGroup = this.triggerFields.at(index) as FormGroup
|
||||||
|
const filters = this.getFiltersFormArray(triggerFormGroup)
|
||||||
|
|
||||||
|
const aggregate: TriggerFilterAggregate = {
|
||||||
|
filter_has_tags: [],
|
||||||
|
filter_has_all_tags: [],
|
||||||
|
filter_has_not_tags: [],
|
||||||
|
filter_has_not_correspondents: [],
|
||||||
|
filter_has_not_document_types: [],
|
||||||
|
filter_has_not_storage_paths: [],
|
||||||
|
filter_has_correspondent: null,
|
||||||
|
filter_has_document_type: null,
|
||||||
|
filter_has_storage_path: null,
|
||||||
|
filter_custom_field_query: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const control of filters.controls) {
|
||||||
|
const type = control.get('type').value as TriggerFilterType
|
||||||
|
const values = control.get('values').value
|
||||||
|
|
||||||
|
if (values === null || values === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(values) && values.length === 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = FILTER_HANDLERS[type]
|
||||||
|
handler?.apply(aggregate, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.filter_has_tags = aggregate.filter_has_tags
|
||||||
|
trigger.filter_has_all_tags = aggregate.filter_has_all_tags
|
||||||
|
trigger.filter_has_not_tags = aggregate.filter_has_not_tags
|
||||||
|
trigger.filter_has_not_correspondents =
|
||||||
|
aggregate.filter_has_not_correspondents
|
||||||
|
trigger.filter_has_not_document_types =
|
||||||
|
aggregate.filter_has_not_document_types
|
||||||
|
trigger.filter_has_not_storage_paths =
|
||||||
|
aggregate.filter_has_not_storage_paths
|
||||||
|
trigger.filter_has_correspondent =
|
||||||
|
aggregate.filter_has_correspondent ?? null
|
||||||
|
trigger.filter_has_document_type =
|
||||||
|
aggregate.filter_has_document_type ?? null
|
||||||
|
trigger.filter_has_storage_path =
|
||||||
|
aggregate.filter_has_storage_path ?? null
|
||||||
|
trigger.filter_custom_field_query =
|
||||||
|
aggregate.filter_custom_field_query ?? null
|
||||||
|
|
||||||
|
delete trigger.filters
|
||||||
|
|
||||||
|
return trigger
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formValues
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchingPatternRequired(formGroup: FormGroup): boolean {
|
||||||
|
return formGroup.get('matching_algorithm').value !== MATCH_NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private createFilterFormGroup(
|
||||||
|
type: TriggerFilterType,
|
||||||
|
initialValue?: any
|
||||||
|
): FormGroup {
|
||||||
|
const group = new FormGroup({
|
||||||
|
type: new FormControl(type),
|
||||||
|
values: new FormControl(this.normalizeFilterValue(type, initialValue)),
|
||||||
|
})
|
||||||
|
|
||||||
|
group.get('type').valueChanges.subscribe((newType: TriggerFilterType) => {
|
||||||
|
if (newType === TriggerFilterType.CustomFieldQuery) {
|
||||||
|
this.ensureCustomFieldQueryModel(group)
|
||||||
|
} else {
|
||||||
|
this.clearCustomFieldQueryModel(group)
|
||||||
|
group.get('values').setValue(this.getDefaultFilterValue(newType), {
|
||||||
|
emitEvent: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (type === TriggerFilterType.CustomFieldQuery) {
|
||||||
|
this.ensureCustomFieldQueryModel(group, initialValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFiltersFormArray(trigger: WorkflowTrigger): FormArray {
|
||||||
|
const filters = new FormArray([])
|
||||||
|
|
||||||
|
for (const definition of this.filterDefinitions) {
|
||||||
|
const handler = FILTER_HANDLERS[definition.id]
|
||||||
|
if (!handler) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = handler.extract(trigger)
|
||||||
|
if (!handler.hasValue(value)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.push(this.createFilterFormGroup(definition.id, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
getFiltersFormArray(formGroup: FormGroup): FormArray {
|
||||||
|
return formGroup.get('filters') as FormArray
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilterTypeOptions(formGroup: FormGroup, filterIndex: number) {
|
||||||
|
const filters = this.getFiltersFormArray(formGroup)
|
||||||
|
const options = this.getFilterTypeOptionsForArray(filters)
|
||||||
|
const currentType = filters.at(filterIndex).get('type')
|
||||||
|
.value as TriggerFilterType
|
||||||
|
const usedTypes = new Set(
|
||||||
|
filters.controls.map(
|
||||||
|
(control) => control.get('type').value as TriggerFilterType
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
if (option.allowMultipleEntries) {
|
||||||
|
option.disabled = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
option.disabled = usedTypes.has(option.id) && option.id !== currentType
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
canAddFilter(formGroup: FormGroup): boolean {
|
||||||
|
const filters = this.getFiltersFormArray(formGroup)
|
||||||
|
const usedTypes = new Set(
|
||||||
|
filters.controls.map(
|
||||||
|
(control) => control.get('type').value as TriggerFilterType
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.filterDefinitions.some((definition) => {
|
||||||
|
if (definition.allowMultipleEntries) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !usedTypes.has(definition.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addFilter(triggerFormGroup: FormGroup): FormGroup | null {
|
||||||
|
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
|
||||||
|
if (triggerIndex === -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = this.getFiltersFormArray(triggerFormGroup)
|
||||||
|
|
||||||
|
const availableDefinition = this.filterDefinitions.find((definition) => {
|
||||||
|
if (definition.allowMultipleEntries) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !filters.controls.some(
|
||||||
|
(control) => control.get('type').value === definition.id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!availableDefinition) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.push(this.createFilterFormGroup(availableDefinition.id))
|
||||||
|
triggerFormGroup.markAsDirty()
|
||||||
|
triggerFormGroup.markAsTouched()
|
||||||
|
|
||||||
|
return filters.at(-1) as FormGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFilter(triggerFormGroup: FormGroup, filterIndex: number) {
|
||||||
|
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
|
||||||
|
if (triggerIndex === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = this.getFiltersFormArray(triggerFormGroup)
|
||||||
|
const filterGroup = filters.at(filterIndex) as FormGroup
|
||||||
|
if (filterGroup?.get('type').value === TriggerFilterType.CustomFieldQuery) {
|
||||||
|
this.clearCustomFieldQueryModel(filterGroup)
|
||||||
|
}
|
||||||
|
filters.removeAt(filterIndex)
|
||||||
|
triggerFormGroup.markAsDirty()
|
||||||
|
triggerFormGroup.markAsTouched()
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilterDefinition(
|
||||||
|
type: TriggerFilterType
|
||||||
|
): TriggerFilterDefinition | undefined {
|
||||||
|
return this.filterDefinitions.find((definition) => definition.id === type)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilterName(type: TriggerFilterType): string {
|
||||||
|
return this.getFilterDefinition(type)?.name ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
isTagsFilter(type: TriggerFilterType): boolean {
|
||||||
|
return this.getFilterDefinition(type)?.inputType === 'tags'
|
||||||
|
}
|
||||||
|
|
||||||
|
isCustomFieldQueryFilter(type: TriggerFilterType): boolean {
|
||||||
|
return this.getFilterDefinition(type)?.inputType === 'customFieldQuery'
|
||||||
|
}
|
||||||
|
|
||||||
|
isMultiValueFilter(type: TriggerFilterType): boolean {
|
||||||
|
switch (type) {
|
||||||
|
case TriggerFilterType.TagsAny:
|
||||||
|
case TriggerFilterType.TagsAll:
|
||||||
|
case TriggerFilterType.TagsNone:
|
||||||
|
case TriggerFilterType.CorrespondentNot:
|
||||||
|
case TriggerFilterType.DocumentTypeNot:
|
||||||
|
case TriggerFilterType.StoragePathNot:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelectMultiple(type: TriggerFilterType): boolean {
|
||||||
|
return !this.isTagsFilter(type) && this.isMultiValueFilter(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilterSelectItems(type: TriggerFilterType) {
|
||||||
|
const definition = this.getFilterDefinition(type)
|
||||||
|
if (!definition || definition.inputType !== 'select') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (definition.selectItems) {
|
||||||
|
case 'correspondents':
|
||||||
|
return this.correspondents
|
||||||
|
case 'documentTypes':
|
||||||
|
return this.documentTypes
|
||||||
|
case 'storagePaths':
|
||||||
|
return this.storagePaths
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCustomFieldQueryModel(control: AbstractControl): CustomFieldQueriesModel {
|
||||||
|
return this.ensureCustomFieldQueryModel(control as FormGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCustomFieldQuerySelectionChange(
|
||||||
|
control: AbstractControl,
|
||||||
|
model: CustomFieldQueriesModel
|
||||||
|
) {
|
||||||
|
this.onCustomFieldQueryModelChanged(control as FormGroup, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
isCustomFieldQueryValid(control: AbstractControl): boolean {
|
||||||
|
const model = this.getStoredCustomFieldQueryModel(control as FormGroup)
|
||||||
|
if (!model) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.isEmpty() || model.isValid()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFilterTypeOptionsForArray(
|
||||||
|
filters: FormArray
|
||||||
|
): TriggerFilterOption[] {
|
||||||
|
let cached = this.triggerFilterOptionsMap.get(filters)
|
||||||
|
if (!cached) {
|
||||||
|
cached = this.filterDefinitions.map((definition) => ({
|
||||||
|
...definition,
|
||||||
|
disabled: false,
|
||||||
|
}))
|
||||||
|
this.triggerFilterOptionsMap.set(filters, cached)
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCustomFieldQueryModel(
|
||||||
|
filterGroup: FormGroup,
|
||||||
|
initialValue?: any
|
||||||
|
): CustomFieldQueriesModel {
|
||||||
|
const existingModel = this.getStoredCustomFieldQueryModel(filterGroup)
|
||||||
|
if (existingModel) {
|
||||||
|
return existingModel
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = new CustomFieldQueriesModel()
|
||||||
|
this.setCustomFieldQueryModel(filterGroup, model)
|
||||||
|
|
||||||
|
const rawValue =
|
||||||
|
typeof initialValue === 'string'
|
||||||
|
? initialValue
|
||||||
|
: (filterGroup.get('values').value as string)
|
||||||
|
|
||||||
|
if (rawValue) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue)
|
||||||
|
const expression = new CustomFieldQueryExpression(parsed)
|
||||||
|
model.queries = [expression]
|
||||||
|
} catch {
|
||||||
|
model.clear(false)
|
||||||
|
model.addInitialAtom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = model.changed
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.onCustomFieldQueryModelChanged(filterGroup, model)
|
||||||
|
})
|
||||||
|
filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
|
||||||
|
filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY] = subscription
|
||||||
|
|
||||||
|
this.onCustomFieldQueryModelChanged(filterGroup, model)
|
||||||
|
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearCustomFieldQueryModel(filterGroup: FormGroup) {
|
||||||
|
const group = filterGroup as CustomFieldFilterGroup
|
||||||
|
group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
|
||||||
|
delete group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]
|
||||||
|
delete group[CUSTOM_FIELD_QUERY_MODEL_KEY]
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStoredCustomFieldQueryModel(
|
||||||
|
filterGroup: FormGroup
|
||||||
|
): CustomFieldQueriesModel | null {
|
||||||
|
return (
|
||||||
|
(filterGroup as CustomFieldFilterGroup)[CUSTOM_FIELD_QUERY_MODEL_KEY] ??
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCustomFieldQueryModel(
|
||||||
|
filterGroup: FormGroup,
|
||||||
|
model: CustomFieldQueriesModel
|
||||||
|
) {
|
||||||
|
const group = filterGroup as CustomFieldFilterGroup
|
||||||
|
group[CUSTOM_FIELD_QUERY_MODEL_KEY] = model
|
||||||
|
}
|
||||||
|
|
||||||
|
private onCustomFieldQueryModelChanged(
|
||||||
|
filterGroup: FormGroup,
|
||||||
|
model: CustomFieldQueriesModel
|
||||||
|
) {
|
||||||
|
const control = filterGroup.get('values')
|
||||||
|
if (!control) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.isValid()) {
|
||||||
|
control.setValue(null, { emitEvent: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.isEmpty()) {
|
||||||
|
control.setValue(null, { emitEvent: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(model.queries[0].serialize())
|
||||||
|
control.setValue(serialized, { emitEvent: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultFilterValue(type: TriggerFilterType) {
|
||||||
|
if (type === TriggerFilterType.CustomFieldQuery) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.isMultiValueFilter(type) ? [] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeFilterValue(type: TriggerFilterType, value?: any) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return this.getDefaultFilterValue(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === TriggerFilterType.CustomFieldQuery) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value ? JSON.stringify(value) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMultiValueFilter(type)) {
|
||||||
|
return Array.isArray(value) ? [...value] : [value]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0 ? value[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
private createTriggerField(
|
private createTriggerField(
|
||||||
trigger: WorkflowTrigger,
|
trigger: WorkflowTrigger,
|
||||||
emitEvent: boolean = false
|
emitEvent: boolean = false
|
||||||
@@ -405,16 +1054,7 @@ export class WorkflowEditDialogComponent
|
|||||||
matching_algorithm: new FormControl(trigger.matching_algorithm),
|
matching_algorithm: new FormControl(trigger.matching_algorithm),
|
||||||
match: new FormControl(trigger.match),
|
match: new FormControl(trigger.match),
|
||||||
is_insensitive: new FormControl(trigger.is_insensitive),
|
is_insensitive: new FormControl(trigger.is_insensitive),
|
||||||
filter_has_tags: new FormControl(trigger.filter_has_tags),
|
filters: this.buildFiltersFormArray(trigger),
|
||||||
filter_has_correspondent: new FormControl(
|
|
||||||
trigger.filter_has_correspondent
|
|
||||||
),
|
|
||||||
filter_has_document_type: new FormControl(
|
|
||||||
trigger.filter_has_document_type
|
|
||||||
),
|
|
||||||
filter_has_storage_path: new FormControl(
|
|
||||||
trigger.filter_has_storage_path
|
|
||||||
),
|
|
||||||
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
|
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
|
||||||
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
|
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
|
||||||
schedule_recurring_interval_days: new FormControl(
|
schedule_recurring_interval_days: new FormControl(
|
||||||
@@ -537,6 +1177,12 @@ export class WorkflowEditDialogComponent
|
|||||||
filter_path: null,
|
filter_path: null,
|
||||||
filter_mailrule: null,
|
filter_mailrule: null,
|
||||||
filter_has_tags: [],
|
filter_has_tags: [],
|
||||||
|
filter_has_all_tags: [],
|
||||||
|
filter_has_not_tags: [],
|
||||||
|
filter_has_not_correspondents: [],
|
||||||
|
filter_has_not_document_types: [],
|
||||||
|
filter_has_not_storage_paths: [],
|
||||||
|
filter_custom_field_query: null,
|
||||||
filter_has_correspondent: null,
|
filter_has_correspondent: null,
|
||||||
filter_has_document_type: null,
|
filter_has_document_type: null,
|
||||||
filter_has_storage_path: null,
|
filter_has_storage_path: null,
|
||||||
|
@@ -1,66 +1,68 @@
|
|||||||
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
@if (title || removable) {
|
||||||
@if (title) {
|
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
@if (title) {
|
||||||
}
|
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||||
@if (removable) {
|
}
|
||||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
@if (removable) {
|
||||||
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||||
|
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div [class.col-md-9]="horizontal">
|
||||||
|
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
|
||||||
|
<ng-select name="inputId" [(ngModel)]="value"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[style.color]="textColor"
|
||||||
|
[style.background]="backgroundColor"
|
||||||
|
[class.private]="isPrivate"
|
||||||
|
[clearable]="allowNull"
|
||||||
|
[items]="items"
|
||||||
|
[addTag]="allowCreateNew && addItemRef"
|
||||||
|
addTagText="Add item"
|
||||||
|
i18n-addTagText="Used for both types, correspondents, storage paths"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
[notFoundText]="notFoundText"
|
||||||
|
[multiple]="multiple"
|
||||||
|
[bindLabel]="bindLabel"
|
||||||
|
bindValue="id"
|
||||||
|
(change)="onChange(value)"
|
||||||
|
(search)="onSearch($event)"
|
||||||
|
(focus)="clearLastSearchTerm()"
|
||||||
|
(clear)="clearLastSearchTerm()"
|
||||||
|
(blur)="onBlur()">
|
||||||
|
<ng-template ng-option-tmp let-item="item">
|
||||||
|
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||||
|
</ng-template>
|
||||||
|
</ng-select>
|
||||||
|
@if (allowCreateNew && !hideAddButton) {
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (showFilter) {
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div [class.col-md-9]="horizontal">
|
<div class="invalid-feedback">
|
||||||
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
|
{{error}}
|
||||||
<ng-select name="inputId" [(ngModel)]="value"
|
|
||||||
[disabled]="disabled"
|
|
||||||
[style.color]="textColor"
|
|
||||||
[style.background]="backgroundColor"
|
|
||||||
[class.private]="isPrivate"
|
|
||||||
[clearable]="allowNull"
|
|
||||||
[items]="items"
|
|
||||||
[addTag]="allowCreateNew && addItemRef"
|
|
||||||
addTagText="Add item"
|
|
||||||
i18n-addTagText="Used for both types, correspondents, storage paths"
|
|
||||||
[placeholder]="placeholder"
|
|
||||||
[notFoundText]="notFoundText"
|
|
||||||
[multiple]="multiple"
|
|
||||||
[bindLabel]="bindLabel"
|
|
||||||
bindValue="id"
|
|
||||||
(change)="onChange(value)"
|
|
||||||
(search)="onSearch($event)"
|
|
||||||
(focus)="clearLastSearchTerm()"
|
|
||||||
(clear)="clearLastSearchTerm()"
|
|
||||||
(blur)="onBlur()">
|
|
||||||
<ng-template ng-option-tmp let-item="item">
|
|
||||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
|
||||||
</ng-template>
|
|
||||||
</ng-select>
|
|
||||||
@if (allowCreateNew && !hideAddButton) {
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
|
||||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (showFilter) {
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
|
|
||||||
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{{error}}
|
|
||||||
</div>
|
|
||||||
@if (hint) {
|
|
||||||
<small class="form-text text-muted">{{hint}}</small>
|
|
||||||
}
|
|
||||||
@if (getSuggestions().length > 0) {
|
|
||||||
<small>
|
|
||||||
<span i18n>Suggestions:</span>
|
|
||||||
@for (s of getSuggestions(); track s) {
|
|
||||||
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
|
||||||
}
|
|
||||||
</small>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
@if (hint) {
|
||||||
|
<small class="form-text text-muted">{{hint}}</small>
|
||||||
|
}
|
||||||
|
@if (getSuggestions().length > 0) {
|
||||||
|
<small>
|
||||||
|
<span i18n>Suggestions:</span>
|
||||||
|
@for (s of getSuggestions(); track s) {
|
||||||
|
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
|
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
@if (title) {
|
||||||
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
|
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
||||||
</div>
|
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||||
<div class="input-group flex-nowrap">
|
<div class="input-group flex-nowrap">
|
||||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||||
|
@@ -1212,7 +1212,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
it('should support keyboard shortcuts', () => {
|
it('should support keyboard shortcuts', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
|
|
||||||
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
const hasNextSpy = jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||||
const nextSpy = jest.spyOn(component, 'nextDoc')
|
const nextSpy = jest.spyOn(component, 'nextDoc')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
||||||
@@ -1226,21 +1226,32 @@ describe('DocumentDetailComponent', () => {
|
|||||||
)
|
)
|
||||||
expect(prevSpy).toHaveBeenCalled()
|
expect(prevSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
const isDirtySpy = jest
|
||||||
|
.spyOn(openDocumentsService, 'isDirty')
|
||||||
|
.mockReturnValue(true)
|
||||||
const saveSpy = jest.spyOn(component, 'save')
|
const saveSpy = jest.spyOn(component, 'save')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
||||||
)
|
)
|
||||||
expect(saveSpy).toHaveBeenCalled()
|
expect(saveSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
hasNextSpy.mockReturnValue(true)
|
||||||
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
|
||||||
const saveNextSpy = jest.spyOn(component, 'saveEditNext')
|
const saveNextSpy = jest.spyOn(component, 'saveEditNext')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
||||||
)
|
)
|
||||||
expect(saveNextSpy).toHaveBeenCalled()
|
expect(saveNextSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
saveSpy.mockClear()
|
||||||
|
saveNextSpy.mockClear()
|
||||||
|
isDirtySpy.mockReturnValue(true)
|
||||||
|
hasNextSpy.mockReturnValue(false)
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
||||||
|
)
|
||||||
|
expect(saveNextSpy).not.toHaveBeenCalled()
|
||||||
|
expect(saveSpy).toHaveBeenCalledWith(true)
|
||||||
|
|
||||||
const closeSpy = jest.spyOn(component, 'close')
|
const closeSpy = jest.spyOn(component, 'close')
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
@@ -615,7 +615,10 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
if (this.openDocumentService.isDirty(this.document)) this.saveEditNext()
|
if (this.openDocumentService.isDirty(this.document)) {
|
||||||
|
if (this.hasNext()) this.saveEditNext()
|
||||||
|
else this.save(true)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -40,6 +40,18 @@ export interface WorkflowTrigger extends ObjectWithId {
|
|||||||
|
|
||||||
filter_has_tags?: number[] // Tag.id[]
|
filter_has_tags?: number[] // Tag.id[]
|
||||||
|
|
||||||
|
filter_has_all_tags?: number[] // Tag.id[]
|
||||||
|
|
||||||
|
filter_has_not_tags?: number[] // Tag.id[]
|
||||||
|
|
||||||
|
filter_has_not_correspondents?: number[] // Correspondent.id[]
|
||||||
|
|
||||||
|
filter_has_not_document_types?: number[] // DocumentType.id[]
|
||||||
|
|
||||||
|
filter_has_not_storage_paths?: number[] // StoragePath.id[]
|
||||||
|
|
||||||
|
filter_custom_field_query?: string
|
||||||
|
|
||||||
filter_has_correspondent?: number // Correspondent.id
|
filter_has_correspondent?: number // Correspondent.id
|
||||||
|
|
||||||
filter_has_document_type?: number // DocumentType.id
|
filter_has_document_type?: number // DocumentType.id
|
||||||
|
@@ -51,7 +51,7 @@ describe('TasksService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
||||||
tasksService.dismissTasks(new Set([1, 2, 3]))
|
tasksService.dismissTasks(new Set([1, 2, 3])).subscribe()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}tasks/acknowledge/`
|
`${environment.apiBaseUrl}tasks/acknowledge/`
|
||||||
)
|
)
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Injectable, inject } from '@angular/core'
|
import { Injectable, inject } from '@angular/core'
|
||||||
import { Observable, Subject } from 'rxjs'
|
import { Observable, Subject } from 'rxjs'
|
||||||
import { first, takeUntil } from 'rxjs/operators'
|
import { first, takeUntil, tap } from 'rxjs/operators'
|
||||||
import {
|
import {
|
||||||
PaperlessTask,
|
PaperlessTask,
|
||||||
PaperlessTaskName,
|
PaperlessTaskName,
|
||||||
@@ -68,14 +68,17 @@ export class TasksService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public dismissTasks(task_ids: Set<number>) {
|
public dismissTasks(task_ids: Set<number>) {
|
||||||
this.http
|
return this.http
|
||||||
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||||
tasks: [...task_ids],
|
tasks: [...task_ids],
|
||||||
})
|
})
|
||||||
.pipe(first())
|
.pipe(
|
||||||
.subscribe((r) => {
|
first(),
|
||||||
this.reload()
|
takeUntil(this.unsubscribeNotifer),
|
||||||
})
|
tap(() => {
|
||||||
|
this.reload()
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public cancelPending(): void {
|
public cancelPending(): void {
|
||||||
|
@@ -6,8 +6,11 @@ from fnmatch import fnmatch
|
|||||||
from fnmatch import translate as fnmatch_translate
|
from fnmatch import translate as fnmatch_translate
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
|
from documents.filters import CustomFieldQueryParser
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
@@ -360,10 +363,9 @@ def existing_document_matches_workflow(
|
|||||||
)
|
)
|
||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
# Document tags vs trigger has_tags
|
# Document tags vs trigger has_tags (any of)
|
||||||
if (
|
if trigger.filter_has_tags.all().count() > 0 and (
|
||||||
trigger.filter_has_tags.all().count() > 0
|
document.tags.filter(
|
||||||
and document.tags.filter(
|
|
||||||
id__in=trigger.filter_has_tags.all().values_list("id"),
|
id__in=trigger.filter_has_tags.all().values_list("id"),
|
||||||
).count()
|
).count()
|
||||||
== 0
|
== 0
|
||||||
@@ -374,35 +376,126 @@ def existing_document_matches_workflow(
|
|||||||
)
|
)
|
||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
# Document correspondent vs trigger has_correspondent
|
# Document tags vs trigger has_all_tags (all of)
|
||||||
|
if trigger.filter_has_all_tags.all().count() > 0 and trigger_matched:
|
||||||
|
required_tag_ids = set(
|
||||||
|
trigger.filter_has_all_tags.all().values_list("id", flat=True),
|
||||||
|
)
|
||||||
|
document_tag_ids = set(
|
||||||
|
document.tags.all().values_list("id", flat=True),
|
||||||
|
)
|
||||||
|
missing_tags = required_tag_ids - document_tag_ids
|
||||||
|
if missing_tags:
|
||||||
|
reason = (
|
||||||
|
f"Document tags {document.tags.all()} do not contain all of"
|
||||||
|
f" {trigger.filter_has_all_tags.all()}",
|
||||||
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
|
# Document tags vs trigger has_not_tags (none of)
|
||||||
if (
|
if (
|
||||||
trigger.filter_has_correspondent is not None
|
trigger.filter_has_not_tags.all().count() > 0
|
||||||
and document.correspondent != trigger.filter_has_correspondent
|
and trigger_matched
|
||||||
|
and document.tags.filter(
|
||||||
|
id__in=trigger.filter_has_not_tags.all().values_list("id"),
|
||||||
|
).exists()
|
||||||
):
|
):
|
||||||
reason = (
|
reason = (
|
||||||
f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}",
|
f"Document tags {document.tags.all()} include excluded tags"
|
||||||
|
f" {trigger.filter_has_not_tags.all()}",
|
||||||
)
|
)
|
||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
|
# Document correspondent vs trigger has_correspondent
|
||||||
|
if trigger_matched:
|
||||||
|
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
|
||||||
|
|
||||||
|
if (
|
||||||
|
trigger.filter_has_not_correspondents.all().count() > 0
|
||||||
|
and document.correspondent
|
||||||
|
and trigger.filter_has_not_correspondents.filter(
|
||||||
|
id=document.correspondent_id,
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
reason = (
|
||||||
|
f"Document correspondent {document.correspondent} is excluded by"
|
||||||
|
f" {trigger.filter_has_not_correspondents.all()}",
|
||||||
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
# Document document_type vs trigger has_document_type
|
# Document document_type vs trigger has_document_type
|
||||||
if (
|
if trigger_matched:
|
||||||
trigger.filter_has_document_type is not None
|
if (
|
||||||
and document.document_type != trigger.filter_has_document_type
|
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}",
|
reason = (
|
||||||
)
|
f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}",
|
||||||
trigger_matched = False
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
|
if (
|
||||||
|
trigger.filter_has_not_document_types.all().count() > 0
|
||||||
|
and document.document_type
|
||||||
|
and trigger.filter_has_not_document_types.filter(
|
||||||
|
id=document.document_type_id,
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
reason = (
|
||||||
|
f"Document doc type {document.document_type} is excluded by"
|
||||||
|
f" {trigger.filter_has_not_document_types.all()}",
|
||||||
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
# Document storage_path vs trigger has_storage_path
|
# Document storage_path vs trigger has_storage_path
|
||||||
if (
|
if trigger_matched:
|
||||||
trigger.filter_has_storage_path is not None
|
if (
|
||||||
and document.storage_path != trigger.filter_has_storage_path
|
trigger.filter_has_storage_path is not None
|
||||||
):
|
and document.storage_path != trigger.filter_has_storage_path
|
||||||
reason = (
|
):
|
||||||
f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}",
|
reason = (
|
||||||
)
|
f"Document storage path {document.storage_path} does not match {trigger.filter_has_storage_path}",
|
||||||
trigger_matched = False
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
|
if (
|
||||||
|
trigger.filter_has_not_storage_paths.all().count() > 0
|
||||||
|
and document.storage_path
|
||||||
|
and trigger.filter_has_not_storage_paths.filter(
|
||||||
|
id=document.storage_path_id,
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
reason = (
|
||||||
|
f"Document storage path {document.storage_path} is excluded by"
|
||||||
|
f" {trigger.filter_has_not_storage_paths.all()}",
|
||||||
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
|
if trigger_matched and trigger.filter_custom_field_query:
|
||||||
|
parser = CustomFieldQueryParser("filter_custom_field_query")
|
||||||
|
try:
|
||||||
|
custom_field_q, annotations = parser.parse(
|
||||||
|
trigger.filter_custom_field_query,
|
||||||
|
)
|
||||||
|
except serializers.ValidationError:
|
||||||
|
reason = "Invalid custom field query configuration"
|
||||||
|
trigger_matched = False
|
||||||
|
else:
|
||||||
|
qs = (
|
||||||
|
Document.objects.filter(id=document.id)
|
||||||
|
.annotate(**annotations)
|
||||||
|
.filter(custom_field_q)
|
||||||
|
)
|
||||||
|
if not qs.exists():
|
||||||
|
reason = "Document custom fields do not match the configured custom field query"
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
# Document original_filename vs trigger filename
|
# Document original_filename vs trigger filename
|
||||||
if (
|
if (
|
||||||
@@ -438,21 +531,57 @@ def prefilter_documents_by_workflowtrigger(
|
|||||||
tags__in=trigger.filter_has_tags.all(),
|
tags__in=trigger.filter_has_tags.all(),
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
if trigger.filter_has_all_tags.all().count() > 0:
|
||||||
|
for tag_id in trigger.filter_has_all_tags.all().values_list("id", flat=True):
|
||||||
|
documents = documents.filter(tags__id=tag_id)
|
||||||
|
documents = documents.distinct()
|
||||||
|
|
||||||
|
if trigger.filter_has_not_tags.all().count() > 0:
|
||||||
|
documents = documents.exclude(
|
||||||
|
tags__in=trigger.filter_has_not_tags.all(),
|
||||||
|
).distinct()
|
||||||
|
|
||||||
if trigger.filter_has_correspondent is not None:
|
if trigger.filter_has_correspondent is not None:
|
||||||
documents = documents.filter(
|
documents = documents.filter(
|
||||||
correspondent=trigger.filter_has_correspondent,
|
correspondent=trigger.filter_has_correspondent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if trigger.filter_has_not_correspondents.all().count() > 0:
|
||||||
|
documents = documents.exclude(
|
||||||
|
correspondent__in=trigger.filter_has_not_correspondents.all(),
|
||||||
|
)
|
||||||
|
|
||||||
if trigger.filter_has_document_type is not None:
|
if trigger.filter_has_document_type is not None:
|
||||||
documents = documents.filter(
|
documents = documents.filter(
|
||||||
document_type=trigger.filter_has_document_type,
|
document_type=trigger.filter_has_document_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if trigger.filter_has_not_document_types.all().count() > 0:
|
||||||
|
documents = documents.exclude(
|
||||||
|
document_type__in=trigger.filter_has_not_document_types.all(),
|
||||||
|
)
|
||||||
|
|
||||||
if trigger.filter_has_storage_path is not None:
|
if trigger.filter_has_storage_path is not None:
|
||||||
documents = documents.filter(
|
documents = documents.filter(
|
||||||
storage_path=trigger.filter_has_storage_path,
|
storage_path=trigger.filter_has_storage_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if trigger.filter_has_not_storage_paths.all().count() > 0:
|
||||||
|
documents = documents.exclude(
|
||||||
|
storage_path__in=trigger.filter_has_not_storage_paths.all(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if trigger.filter_custom_field_query:
|
||||||
|
parser = CustomFieldQueryParser("filter_custom_field_query")
|
||||||
|
try:
|
||||||
|
custom_field_q, annotations = parser.parse(
|
||||||
|
trigger.filter_custom_field_query,
|
||||||
|
)
|
||||||
|
except serializers.ValidationError:
|
||||||
|
return documents.none()
|
||||||
|
|
||||||
|
documents = documents.annotate(**annotations).filter(custom_field_q)
|
||||||
|
|
||||||
if trigger.filter_filename is not None and len(trigger.filter_filename) > 0:
|
if trigger.filter_filename is not None and len(trigger.filter_filename) > 0:
|
||||||
# the true fnmatch will actually run later so we just want a loose filter here
|
# the true fnmatch will actually run later so we just want a loose filter here
|
||||||
regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$")
|
regex = fnmatch_translate(trigger.filter_filename).lstrip("^").rstrip("$")
|
||||||
|
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-07 18:52
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="filter_custom_field_query",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="JSON-encoded custom field query expression.",
|
||||||
|
null=True,
|
||||||
|
verbose_name="filter custom field query",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="filter_has_all_tags",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_all",
|
||||||
|
to="documents.tag",
|
||||||
|
verbose_name="has all of these tag(s)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="filter_has_not_correspondents",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not_correspondent",
|
||||||
|
to="documents.correspondent",
|
||||||
|
verbose_name="does not have these correspondent(s)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="filter_has_not_document_types",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not_document_type",
|
||||||
|
to="documents.documenttype",
|
||||||
|
verbose_name="does not have these document type(s)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="filter_has_not_storage_paths",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not_storage_path",
|
||||||
|
to="documents.storagepath",
|
||||||
|
verbose_name="does not have these storage path(s)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="filter_has_not_tags",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not",
|
||||||
|
to="documents.tag",
|
||||||
|
verbose_name="does not have these tag(s)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@@ -1065,6 +1065,20 @@ class WorkflowTrigger(models.Model):
|
|||||||
verbose_name=_("has these tag(s)"),
|
verbose_name=_("has these tag(s)"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
filter_has_all_tags = models.ManyToManyField(
|
||||||
|
Tag,
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_all",
|
||||||
|
verbose_name=_("has all of these tag(s)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_has_not_tags = models.ManyToManyField(
|
||||||
|
Tag,
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not",
|
||||||
|
verbose_name=_("does not have these tag(s)"),
|
||||||
|
)
|
||||||
|
|
||||||
filter_has_document_type = models.ForeignKey(
|
filter_has_document_type = models.ForeignKey(
|
||||||
DocumentType,
|
DocumentType,
|
||||||
null=True,
|
null=True,
|
||||||
@@ -1073,6 +1087,13 @@ class WorkflowTrigger(models.Model):
|
|||||||
verbose_name=_("has this document type"),
|
verbose_name=_("has this document type"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
filter_has_not_document_types = models.ManyToManyField(
|
||||||
|
DocumentType,
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not_document_type",
|
||||||
|
verbose_name=_("does not have these document type(s)"),
|
||||||
|
)
|
||||||
|
|
||||||
filter_has_correspondent = models.ForeignKey(
|
filter_has_correspondent = models.ForeignKey(
|
||||||
Correspondent,
|
Correspondent,
|
||||||
null=True,
|
null=True,
|
||||||
@@ -1081,6 +1102,13 @@ class WorkflowTrigger(models.Model):
|
|||||||
verbose_name=_("has this correspondent"),
|
verbose_name=_("has this correspondent"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
filter_has_not_correspondents = models.ManyToManyField(
|
||||||
|
Correspondent,
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not_correspondent",
|
||||||
|
verbose_name=_("does not have these correspondent(s)"),
|
||||||
|
)
|
||||||
|
|
||||||
filter_has_storage_path = models.ForeignKey(
|
filter_has_storage_path = models.ForeignKey(
|
||||||
StoragePath,
|
StoragePath,
|
||||||
null=True,
|
null=True,
|
||||||
@@ -1089,6 +1117,20 @@ class WorkflowTrigger(models.Model):
|
|||||||
verbose_name=_("has this storage path"),
|
verbose_name=_("has this storage path"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
filter_has_not_storage_paths = models.ManyToManyField(
|
||||||
|
StoragePath,
|
||||||
|
blank=True,
|
||||||
|
related_name="workflowtriggers_has_not_storage_path",
|
||||||
|
verbose_name=_("does not have these storage path(s)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_custom_field_query = models.TextField(
|
||||||
|
_("filter custom field query"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("JSON-encoded custom field query expression."),
|
||||||
|
)
|
||||||
|
|
||||||
schedule_offset_days = models.IntegerField(
|
schedule_offset_days = models.IntegerField(
|
||||||
_("schedule offset days"),
|
_("schedule offset days"),
|
||||||
default=0,
|
default=0,
|
||||||
|
@@ -161,3 +161,21 @@ class PaperlessNotePermissions(BasePermission):
|
|||||||
perms = self.perms_map[request.method]
|
perms = self.perms_map[request.method]
|
||||||
|
|
||||||
return request.user.has_perms(perms)
|
return request.user.has_perms(perms)
|
||||||
|
|
||||||
|
|
||||||
|
class AcknowledgeTasksPermissions(BasePermission):
|
||||||
|
"""
|
||||||
|
Permissions class that checks for model permissions for acknowledging tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
perms_map = {
|
||||||
|
"POST": ["documents.change_paperlesstask"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
|
perms = self.perms_map.get(request.method, [])
|
||||||
|
|
||||||
|
return request.user.has_perms(perms)
|
||||||
|
@@ -43,6 +43,7 @@ if settings.AUDIT_LOG_ENABLED:
|
|||||||
|
|
||||||
from documents import bulk_edit
|
from documents import bulk_edit
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
|
from documents.filters import CustomFieldQueryParser
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
@@ -2194,6 +2195,12 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
|||||||
"match",
|
"match",
|
||||||
"is_insensitive",
|
"is_insensitive",
|
||||||
"filter_has_tags",
|
"filter_has_tags",
|
||||||
|
"filter_has_all_tags",
|
||||||
|
"filter_has_not_tags",
|
||||||
|
"filter_custom_field_query",
|
||||||
|
"filter_has_not_correspondents",
|
||||||
|
"filter_has_not_document_types",
|
||||||
|
"filter_has_not_storage_paths",
|
||||||
"filter_has_correspondent",
|
"filter_has_correspondent",
|
||||||
"filter_has_document_type",
|
"filter_has_document_type",
|
||||||
"filter_has_storage_path",
|
"filter_has_storage_path",
|
||||||
@@ -2219,6 +2226,20 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
|||||||
):
|
):
|
||||||
attrs["filter_path"] = None
|
attrs["filter_path"] = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
"filter_custom_field_query" in attrs
|
||||||
|
and attrs["filter_custom_field_query"] is not None
|
||||||
|
and len(attrs["filter_custom_field_query"]) == 0
|
||||||
|
):
|
||||||
|
attrs["filter_custom_field_query"] = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
"filter_custom_field_query" in attrs
|
||||||
|
and attrs["filter_custom_field_query"] is not None
|
||||||
|
):
|
||||||
|
parser = CustomFieldQueryParser("filter_custom_field_query")
|
||||||
|
parser.parse(attrs["filter_custom_field_query"])
|
||||||
|
|
||||||
trigger_type = attrs.get("type", getattr(self.instance, "type", None))
|
trigger_type = attrs.get("type", getattr(self.instance, "type", None))
|
||||||
if (
|
if (
|
||||||
trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||||
@@ -2414,6 +2435,20 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
|||||||
if triggers is not None and triggers is not serializers.empty:
|
if triggers is not None and triggers is not serializers.empty:
|
||||||
for trigger in triggers:
|
for trigger in triggers:
|
||||||
filter_has_tags = trigger.pop("filter_has_tags", None)
|
filter_has_tags = trigger.pop("filter_has_tags", None)
|
||||||
|
filter_has_all_tags = trigger.pop("filter_has_all_tags", None)
|
||||||
|
filter_has_not_tags = trigger.pop("filter_has_not_tags", None)
|
||||||
|
filter_has_not_correspondents = trigger.pop(
|
||||||
|
"filter_has_not_correspondents",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
filter_has_not_document_types = trigger.pop(
|
||||||
|
"filter_has_not_document_types",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
filter_has_not_storage_paths = trigger.pop(
|
||||||
|
"filter_has_not_storage_paths",
|
||||||
|
None,
|
||||||
|
)
|
||||||
# Convert sources to strings to handle django-multiselectfield v1.0 changes
|
# Convert sources to strings to handle django-multiselectfield v1.0 changes
|
||||||
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger)
|
WorkflowTriggerSerializer.normalize_workflow_trigger_sources(trigger)
|
||||||
trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
|
trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
|
||||||
@@ -2422,6 +2457,22 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
if filter_has_tags is not None:
|
if filter_has_tags is not None:
|
||||||
trigger_instance.filter_has_tags.set(filter_has_tags)
|
trigger_instance.filter_has_tags.set(filter_has_tags)
|
||||||
|
if filter_has_all_tags is not None:
|
||||||
|
trigger_instance.filter_has_all_tags.set(filter_has_all_tags)
|
||||||
|
if filter_has_not_tags is not None:
|
||||||
|
trigger_instance.filter_has_not_tags.set(filter_has_not_tags)
|
||||||
|
if filter_has_not_correspondents is not None:
|
||||||
|
trigger_instance.filter_has_not_correspondents.set(
|
||||||
|
filter_has_not_correspondents,
|
||||||
|
)
|
||||||
|
if filter_has_not_document_types is not None:
|
||||||
|
trigger_instance.filter_has_not_document_types.set(
|
||||||
|
filter_has_not_document_types,
|
||||||
|
)
|
||||||
|
if filter_has_not_storage_paths is not None:
|
||||||
|
trigger_instance.filter_has_not_storage_paths.set(
|
||||||
|
filter_has_not_storage_paths,
|
||||||
|
)
|
||||||
set_triggers.append(trigger_instance)
|
set_triggers.append(trigger_instance)
|
||||||
|
|
||||||
if actions is not None and actions is not serializers.empty:
|
if actions is not None and actions is not serializers.empty:
|
||||||
|
@@ -135,6 +135,44 @@ class TestTasks(DirectoriesMixin, APITestCase):
|
|||||||
response = self.client.get(self.ENDPOINT + "?acknowledged=false")
|
response = self.client.get(self.ENDPOINT + "?acknowledged=false")
|
||||||
self.assertEqual(len(response.data), 0)
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
|
def test_acknowledge_tasks_requires_change_permission(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A regular user initially without change permissions
|
||||||
|
- A regular user with change permissions
|
||||||
|
WHEN:
|
||||||
|
- API call is made to acknowledge tasks
|
||||||
|
THEN:
|
||||||
|
- The first user is forbidden from acknowledging tasks
|
||||||
|
- The second user is allowed to acknowledge tasks
|
||||||
|
"""
|
||||||
|
regular_user = User.objects.create_user(username="test")
|
||||||
|
self.client.force_authenticate(user=regular_user)
|
||||||
|
|
||||||
|
task = PaperlessTask.objects.create(
|
||||||
|
task_id=str(uuid.uuid4()),
|
||||||
|
task_file_name="task_one.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT + "acknowledge/",
|
||||||
|
{"tasks": [task.id]},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
regular_user2 = User.objects.create_user(username="test2")
|
||||||
|
regular_user2.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_paperlesstask"),
|
||||||
|
)
|
||||||
|
regular_user2.save()
|
||||||
|
self.client.force_authenticate(user=regular_user2)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT + "acknowledge/",
|
||||||
|
{"tasks": [task.id]},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_tasks_owner_aware(self):
|
def test_tasks_owner_aware(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
@@ -184,6 +184,17 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
"filter_filename": "*",
|
"filter_filename": "*",
|
||||||
"filter_path": "*/samples/*",
|
"filter_path": "*/samples/*",
|
||||||
"filter_has_tags": [self.t1.id],
|
"filter_has_tags": [self.t1.id],
|
||||||
|
"filter_has_all_tags": [self.t2.id],
|
||||||
|
"filter_has_not_tags": [self.t3.id],
|
||||||
|
"filter_has_not_correspondents": [self.c2.id],
|
||||||
|
"filter_has_not_document_types": [self.dt2.id],
|
||||||
|
"filter_has_not_storage_paths": [self.sp2.id],
|
||||||
|
"filter_custom_field_query": json.dumps(
|
||||||
|
[
|
||||||
|
"AND",
|
||||||
|
[[self.cf1.id, "exact", "value"]],
|
||||||
|
],
|
||||||
|
),
|
||||||
"filter_has_document_type": self.dt.id,
|
"filter_has_document_type": self.dt.id,
|
||||||
"filter_has_correspondent": self.c.id,
|
"filter_has_correspondent": self.c.id,
|
||||||
"filter_has_storage_path": self.sp.id,
|
"filter_has_storage_path": self.sp.id,
|
||||||
@@ -223,6 +234,36 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Workflow.objects.count(), 2)
|
self.assertEqual(Workflow.objects.count(), 2)
|
||||||
|
workflow = Workflow.objects.get(name="Workflow 2")
|
||||||
|
trigger = workflow.triggers.first()
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(trigger.filter_has_tags.values_list("id", flat=True)),
|
||||||
|
{self.t1.id},
|
||||||
|
)
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(trigger.filter_has_all_tags.values_list("id", flat=True)),
|
||||||
|
{self.t2.id},
|
||||||
|
)
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(trigger.filter_has_not_tags.values_list("id", flat=True)),
|
||||||
|
{self.t3.id},
|
||||||
|
)
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(trigger.filter_has_not_correspondents.values_list("id", flat=True)),
|
||||||
|
{self.c2.id},
|
||||||
|
)
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(trigger.filter_has_not_document_types.values_list("id", flat=True)),
|
||||||
|
{self.dt2.id},
|
||||||
|
)
|
||||||
|
self.assertSetEqual(
|
||||||
|
set(trigger.filter_has_not_storage_paths.values_list("id", flat=True)),
|
||||||
|
{self.sp2.id},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
trigger.filter_custom_field_query,
|
||||||
|
json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]),
|
||||||
|
)
|
||||||
|
|
||||||
def test_api_create_invalid_workflow_trigger(self):
|
def test_api_create_invalid_workflow_trigger(self):
|
||||||
"""
|
"""
|
||||||
@@ -376,6 +417,14 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
{
|
{
|
||||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
"filter_has_tags": [self.t1.id],
|
"filter_has_tags": [self.t1.id],
|
||||||
|
"filter_has_all_tags": [self.t2.id],
|
||||||
|
"filter_has_not_tags": [self.t3.id],
|
||||||
|
"filter_has_not_correspondents": [self.c2.id],
|
||||||
|
"filter_has_not_document_types": [self.dt2.id],
|
||||||
|
"filter_has_not_storage_paths": [self.sp2.id],
|
||||||
|
"filter_custom_field_query": json.dumps(
|
||||||
|
["AND", [[self.cf1.id, "exact", "value"]]],
|
||||||
|
),
|
||||||
"filter_has_correspondent": self.c.id,
|
"filter_has_correspondent": self.c.id,
|
||||||
"filter_has_document_type": self.dt.id,
|
"filter_has_document_type": self.dt.id,
|
||||||
},
|
},
|
||||||
@@ -393,6 +442,30 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
workflow = Workflow.objects.get(id=response.data["id"])
|
workflow = Workflow.objects.get(id=response.data["id"])
|
||||||
self.assertEqual(workflow.name, "Workflow Updated")
|
self.assertEqual(workflow.name, "Workflow Updated")
|
||||||
self.assertEqual(workflow.triggers.first().filter_has_tags.first(), self.t1)
|
self.assertEqual(workflow.triggers.first().filter_has_tags.first(), self.t1)
|
||||||
|
self.assertEqual(
|
||||||
|
workflow.triggers.first().filter_has_all_tags.first(),
|
||||||
|
self.t2,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
workflow.triggers.first().filter_has_not_tags.first(),
|
||||||
|
self.t3,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
workflow.triggers.first().filter_has_not_correspondents.first(),
|
||||||
|
self.c2,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
workflow.triggers.first().filter_has_not_document_types.first(),
|
||||||
|
self.dt2,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
workflow.triggers.first().filter_has_not_storage_paths.first(),
|
||||||
|
self.sp2,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
workflow.triggers.first().filter_custom_field_query,
|
||||||
|
json.dumps(["AND", [[self.cf1.id, "exact", "value"]]]),
|
||||||
|
)
|
||||||
self.assertEqual(workflow.actions.first().assign_title, "Action New Title")
|
self.assertEqual(workflow.actions.first().assign_title, "Action New Title")
|
||||||
|
|
||||||
def test_api_update_workflow_no_trigger_actions(self):
|
def test_api_update_workflow_no_trigger_actions(self):
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -31,6 +32,7 @@ from documents import tasks
|
|||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
from documents.matching import document_matches_workflow
|
from documents.matching import document_matches_workflow
|
||||||
|
from documents.matching import existing_document_matches_workflow
|
||||||
from documents.matching import prefilter_documents_by_workflowtrigger
|
from documents.matching import prefilter_documents_by_workflowtrigger
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -46,6 +48,7 @@ from documents.models import WorkflowActionEmail
|
|||||||
from documents.models import WorkflowActionWebhook
|
from documents.models import WorkflowActionWebhook
|
||||||
from documents.models import WorkflowRun
|
from documents.models import WorkflowRun
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
|
from documents.serialisers import WorkflowTriggerSerializer
|
||||||
from documents.signals import document_consumption_finished
|
from documents.signals import document_consumption_finished
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import DummyProgressManager
|
from documents.tests.utils import DummyProgressManager
|
||||||
@@ -1083,6 +1086,406 @@ class TestWorkflows(
|
|||||||
expected_str = f"Document tags {doc.tags.all()} do not include {trigger.filter_has_tags.all()}"
|
expected_str = f"Document tags {doc.tags.all()} do not include {trigger.filter_has_tags.all()}"
|
||||||
self.assertIn(expected_str, cm.output[1])
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
|
def test_document_added_no_match_all_tags(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
trigger.filter_has_all_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.t1])
|
||||||
|
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 contain all of"
|
||||||
|
f" {trigger.filter_has_all_tags.all()}"
|
||||||
|
)
|
||||||
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
|
def test_document_added_excluded_tags(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
trigger.filter_has_not_tags.set([self.t3])
|
||||||
|
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()} include excluded tags"
|
||||||
|
f" {trigger.filter_has_not_tags.all()}"
|
||||||
|
)
|
||||||
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
|
def test_document_added_excluded_correspondent(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
trigger.filter_has_not_correspondents.set([self.c])
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
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} is excluded by"
|
||||||
|
f" {trigger.filter_has_not_correspondents.all()}"
|
||||||
|
)
|
||||||
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
|
def test_document_added_excluded_document_types(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
trigger.filter_has_not_document_types.set([self.dt])
|
||||||
|
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",
|
||||||
|
document_type=self.dt,
|
||||||
|
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} is excluded by"
|
||||||
|
f" {trigger.filter_has_not_document_types.all()}"
|
||||||
|
)
|
||||||
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
|
def test_document_added_excluded_storage_paths(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
trigger.filter_has_not_storage_paths.set([self.sp])
|
||||||
|
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",
|
||||||
|
storage_path=self.sp,
|
||||||
|
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 storage path {doc.storage_path} is excluded by"
|
||||||
|
f" {trigger.filter_has_not_storage_paths.all()}"
|
||||||
|
)
|
||||||
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
|
def test_document_added_custom_field_query_no_match(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_custom_field_query=json.dumps(
|
||||||
|
[
|
||||||
|
"AND",
|
||||||
|
[[self.cf1.id, "exact", "expected"]],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
assign_title="Doc assign owner",
|
||||||
|
assign_owner=self.user2,
|
||||||
|
)
|
||||||
|
workflow = Workflow.objects.create(name="Workflow 1", order=0)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
workflow.save()
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc,
|
||||||
|
field=self.cf1,
|
||||||
|
value_text="other",
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {workflow}"
|
||||||
|
self.assertIn(expected_str, cm.output[0])
|
||||||
|
self.assertIn(
|
||||||
|
"Document custom fields do not match the configured custom field query",
|
||||||
|
cm.output[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_document_added_custom_field_query_match(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_custom_field_query=json.dumps(
|
||||||
|
[
|
||||||
|
"AND",
|
||||||
|
[[self.cf1.id, "exact", "expected"]],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc,
|
||||||
|
field=self.cf1,
|
||||||
|
value_text="expected",
|
||||||
|
)
|
||||||
|
|
||||||
|
matched, reason = existing_document_matches_workflow(doc, trigger)
|
||||||
|
self.assertTrue(matched)
|
||||||
|
self.assertEqual(reason, "")
|
||||||
|
|
||||||
|
def test_prefilter_documents_custom_field_query(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_custom_field_query=json.dumps(
|
||||||
|
[
|
||||||
|
"AND",
|
||||||
|
[[self.cf1.id, "exact", "match"]],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doc1 = Document.objects.create(
|
||||||
|
title="doc 1",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="doc1.pdf",
|
||||||
|
checksum="checksum1",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc1,
|
||||||
|
field=self.cf1,
|
||||||
|
value_text="match",
|
||||||
|
)
|
||||||
|
|
||||||
|
doc2 = Document.objects.create(
|
||||||
|
title="doc 2",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="doc2.pdf",
|
||||||
|
checksum="checksum2",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc2,
|
||||||
|
field=self.cf1,
|
||||||
|
value_text="different",
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered = prefilter_documents_by_workflowtrigger(
|
||||||
|
Document.objects.all(),
|
||||||
|
trigger,
|
||||||
|
)
|
||||||
|
self.assertIn(doc1, filtered)
|
||||||
|
self.assertNotIn(doc2, filtered)
|
||||||
|
|
||||||
|
def test_consumption_trigger_requires_filter_configuration(self):
|
||||||
|
serializer = WorkflowTriggerSerializer(
|
||||||
|
data={
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(serializer.is_valid())
|
||||||
|
errors = serializer.errors.get("non_field_errors", [])
|
||||||
|
self.assertIn(
|
||||||
|
"File name, path or mail rule filter are required",
|
||||||
|
[str(error) for error in errors],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workflow_trigger_serializer_clears_empty_custom_field_query(self):
|
||||||
|
serializer = WorkflowTriggerSerializer(
|
||||||
|
data={
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
"filter_custom_field_query": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||||
|
self.assertIsNone(serializer.validated_data.get("filter_custom_field_query"))
|
||||||
|
|
||||||
|
def test_existing_document_invalid_custom_field_query_configuration(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_custom_field_query="{ not json",
|
||||||
|
)
|
||||||
|
|
||||||
|
document = Document.objects.create(
|
||||||
|
title="doc invalid query",
|
||||||
|
original_filename="invalid.pdf",
|
||||||
|
checksum="checksum-invalid-query",
|
||||||
|
)
|
||||||
|
|
||||||
|
matched, reason = existing_document_matches_workflow(document, trigger)
|
||||||
|
self.assertFalse(matched)
|
||||||
|
self.assertEqual(reason, "Invalid custom field query configuration")
|
||||||
|
|
||||||
|
def test_prefilter_documents_returns_none_for_invalid_custom_field_query(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_custom_field_query="{ not json",
|
||||||
|
)
|
||||||
|
|
||||||
|
Document.objects.create(
|
||||||
|
title="doc",
|
||||||
|
original_filename="doc.pdf",
|
||||||
|
checksum="checksum-prefilter-invalid",
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered = prefilter_documents_by_workflowtrigger(
|
||||||
|
Document.objects.all(),
|
||||||
|
trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(list(filtered), [])
|
||||||
|
|
||||||
|
def test_prefilter_documents_applies_all_filters(self):
|
||||||
|
other_document_type = DocumentType.objects.create(name="Other Type")
|
||||||
|
other_storage_path = StoragePath.objects.create(
|
||||||
|
name="Blocked path",
|
||||||
|
path="/blocked/",
|
||||||
|
)
|
||||||
|
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
filter_has_correspondent=self.c,
|
||||||
|
filter_has_document_type=self.dt,
|
||||||
|
filter_has_storage_path=self.sp,
|
||||||
|
)
|
||||||
|
trigger.filter_has_tags.set([self.t1])
|
||||||
|
trigger.filter_has_all_tags.set([self.t1, self.t2])
|
||||||
|
trigger.filter_has_not_tags.set([self.t3])
|
||||||
|
trigger.filter_has_not_correspondents.set([self.c2])
|
||||||
|
trigger.filter_has_not_document_types.set([other_document_type])
|
||||||
|
trigger.filter_has_not_storage_paths.set([other_storage_path])
|
||||||
|
|
||||||
|
allowed_document = Document.objects.create(
|
||||||
|
title="allowed",
|
||||||
|
correspondent=self.c,
|
||||||
|
document_type=self.dt,
|
||||||
|
storage_path=self.sp,
|
||||||
|
original_filename="allow.pdf",
|
||||||
|
checksum="checksum-prefilter-allowed",
|
||||||
|
)
|
||||||
|
allowed_document.tags.set([self.t1, self.t2])
|
||||||
|
|
||||||
|
blocked_document = Document.objects.create(
|
||||||
|
title="blocked",
|
||||||
|
correspondent=self.c2,
|
||||||
|
document_type=other_document_type,
|
||||||
|
storage_path=other_storage_path,
|
||||||
|
original_filename="block.pdf",
|
||||||
|
checksum="checksum-prefilter-blocked",
|
||||||
|
)
|
||||||
|
blocked_document.tags.set([self.t1, self.t3])
|
||||||
|
|
||||||
|
filtered = prefilter_documents_by_workflowtrigger(
|
||||||
|
Document.objects.all(),
|
||||||
|
trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(allowed_document, filtered)
|
||||||
|
self.assertNotIn(blocked_document, filtered)
|
||||||
|
|
||||||
def test_document_added_no_match_doctype(self):
|
def test_document_added_no_match_doctype(self):
|
||||||
trigger = WorkflowTrigger.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
@@ -136,6 +136,7 @@ from documents.models import WorkflowAction
|
|||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.parsers import get_parser_class_for_mime_type
|
from documents.parsers import get_parser_class_for_mime_type
|
||||||
from documents.parsers import parse_date_generator
|
from documents.parsers import parse_date_generator
|
||||||
|
from documents.permissions import AcknowledgeTasksPermissions
|
||||||
from documents.permissions import PaperlessAdminPermissions
|
from documents.permissions import PaperlessAdminPermissions
|
||||||
from documents.permissions import PaperlessNotePermissions
|
from documents.permissions import PaperlessNotePermissions
|
||||||
from documents.permissions import PaperlessObjectPermissions
|
from documents.permissions import PaperlessObjectPermissions
|
||||||
@@ -2487,7 +2488,11 @@ class TasksViewSet(ReadOnlyModelViewSet):
|
|||||||
queryset = PaperlessTask.objects.filter(task_id=task_id)
|
queryset = PaperlessTask.objects.filter(task_id=task_id)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@action(methods=["post"], detail=False)
|
@action(
|
||||||
|
methods=["post"],
|
||||||
|
detail=False,
|
||||||
|
permission_classes=[IsAuthenticated, AcknowledgeTasksPermissions],
|
||||||
|
)
|
||||||
def acknowledge(self, request):
|
def acknowledge(self, request):
|
||||||
serializer = AcknowledgeTasksViewSerializer(data=request.data)
|
serializer = AcknowledgeTasksViewSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
6
uv.lock
generated
6
uv.lock
generated
@@ -1922,7 +1922,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/61/68/810093cb579daae42
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nltk"
|
name = "nltk"
|
||||||
version = "3.9.2"
|
version = "3.9.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
@@ -1930,9 +1930,9 @@ dependencies = [
|
|||||||
{ name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/76/3a5e4312c19a028770f86fd7c058cf9f4ec4321c6cf7526bab998a5b683c/nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419", size = 2887629, upload-time = "2025-10-01T07:19:23.764Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/3c/87/db8be88ad32c2d042420b6fd9ffd4a149f9a0d7f0e86b3f543be2eeeedd2/nltk-3.9.1.tar.gz", hash = "sha256:87d127bd3de4bd89a4f81265e5fa59cb1b199b27440175370f7417d2bc7ae868", size = 2904691, upload-time = "2024-08-18T19:48:37.769Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/66/7d9e26593edda06e8cb531874633f7c2372279c3b0f46235539fe546df8b/nltk-3.9.1-py3-none-any.whl", hash = "sha256:4fa26829c5b00715afe3061398a8989dc643b92ce7dd93fb4585a70930d168a1", size = 1505442, upload-time = "2024-08-18T19:48:21.909Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
Reference in New Issue
Block a user