mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-14 10:02:45 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc0144309 | ||
|
|
2cb5d87247 | ||
|
|
dc292d7ede | ||
|
|
c7fcdbed61 | ||
|
|
d2197bf05e | ||
|
|
cf11735a2b | ||
|
|
871711d394 | ||
|
|
823d1a7835 | ||
|
|
2813eed184 | ||
|
|
05426daee0 | ||
|
|
b11078fd92 | ||
|
|
f0e52756ea | ||
|
|
00301c503a |
No files matched your search
@@ -288,6 +288,12 @@ The API supports various bulk-editing operations which are executed asynchronous
|
|||||||
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
|
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
|
||||||
a json payload of the format:
|
a json payload of the format:
|
||||||
|
|
||||||
|
Document bulk operations, including the dedicated document action endpoints, return
|
||||||
|
`{"result":"OK","task_id":"…"}` when queued. Use `/api/tasks/?task_id={uuid}`
|
||||||
|
to check the task outcome. Actions that create or reprocess files may start further
|
||||||
|
consumption or reprocessing tasks and completion of the bulk task means those tasks
|
||||||
|
were scheduled, not that their file processing has finished.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"documents": [LIST_OF_DOCUMENT_IDS],
|
"documents": [LIST_OF_DOCUMENT_IDS],
|
||||||
|
|||||||
+60
-71
@@ -1940,10 +1940,6 @@
|
|||||||
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context>
|
||||||
<context context-type="linenumber">45</context>
|
<context context-type="linenumber">45</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.ts</context>
|
|
||||||
<context context-type="linenumber">59</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3066660568529853846" datatype="html">
|
<trans-unit id="3066660568529853846" datatype="html">
|
||||||
<source>Error retrieving groups</source>
|
<source>Error retrieving groups</source>
|
||||||
@@ -3428,19 +3424,19 @@
|
|||||||
</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>
|
||||||
<context context-type="linenumber">1034</context>
|
<context context-type="linenumber">1019</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>
|
||||||
<context context-type="linenumber">1073</context>
|
<context context-type="linenumber">1058</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>
|
||||||
<context context-type="linenumber">1096</context>
|
<context context-type="linenumber">1081</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>
|
||||||
<context context-type="linenumber">1132</context>
|
<context context-type="linenumber">1117</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
|
||||||
@@ -4203,27 +4199,27 @@
|
|||||||
</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>
|
||||||
<context context-type="linenumber">652</context>
|
<context context-type="linenumber">637</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>
|
||||||
<context context-type="linenumber">692</context>
|
<context context-type="linenumber">677</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>
|
||||||
<context context-type="linenumber">730</context>
|
<context context-type="linenumber">715</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>
|
||||||
<context context-type="linenumber">768</context>
|
<context context-type="linenumber">753</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>
|
||||||
<context context-type="linenumber">830</context>
|
<context context-type="linenumber">815</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>
|
||||||
<context context-type="linenumber">968</context>
|
<context context-type="linenumber">953</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6088768251338567957" datatype="html">
|
<trans-unit id="6088768251338567957" datatype="html">
|
||||||
@@ -6672,7 +6668,7 @@
|
|||||||
</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>
|
||||||
<context context-type="linenumber">972</context>
|
<context context-type="linenumber">957</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1616074213645042277" datatype="html">
|
<trans-unit id="1616074213645042277" datatype="html">
|
||||||
@@ -8449,7 +8445,7 @@
|
|||||||
</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>
|
||||||
<context context-type="linenumber">597</context>
|
<context context-type="linenumber">582</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
|
<note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -8778,7 +8774,7 @@
|
|||||||
</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>
|
||||||
<context context-type="linenumber">320</context>
|
<context context-type="linenumber">318</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7049887240439736400" datatype="html">
|
<trans-unit id="7049887240439736400" datatype="html">
|
||||||
@@ -9265,7 +9261,7 @@
|
|||||||
</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>
|
||||||
<context context-type="linenumber">970</context>
|
<context context-type="linenumber">955</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5992101894268263750" datatype="html">
|
<trans-unit id="5992101894268263750" datatype="html">
|
||||||
@@ -9290,7 +9286,7 @@
|
|||||||
</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>
|
||||||
<context context-type="linenumber">1030</context>
|
<context context-type="linenumber">1015</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2951161989614003846" datatype="html">
|
<trans-unit id="2951161989614003846" datatype="html">
|
||||||
@@ -9340,7 +9336,7 @@
|
|||||||
</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>
|
||||||
<context context-type="linenumber">322</context>
|
<context context-type="linenumber">320</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="523213570140181690" datatype="html">
|
<trans-unit id="523213570140181690" datatype="html">
|
||||||
@@ -9705,7 +9701,7 @@
|
|||||||
</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>
|
||||||
<context context-type="linenumber">1130</context>
|
<context context-type="linenumber">1115</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7251533806673754434" datatype="html">
|
<trans-unit id="7251533806673754434" datatype="html">
|
||||||
@@ -9740,53 +9736,46 @@
|
|||||||
<source>Run a workflow on <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>Run a workflow on <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">321</context>
|
<context context-type="linenumber">319</context>
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="8161885405686762788" datatype="html">
|
|
||||||
<source>Workflow will run on the selected documents in the background.</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
|
||||||
<context context-type="linenumber">339</context>
|
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3401633333450192408" datatype="html">
|
<trans-unit id="3401633333450192408" datatype="html">
|
||||||
<source>Bulk operation queued.</source>
|
<source>Bulk operation queued.</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">373</context>
|
<context context-type="linenumber">358</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5709555629190115111" datatype="html">
|
<trans-unit id="5709555629190115111" datatype="html">
|
||||||
<source>View task</source>
|
<source>View task</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">377</context>
|
<context context-type="linenumber">362</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1215215387232313677" datatype="html">
|
<trans-unit id="1215215387232313677" datatype="html">
|
||||||
<source>Error executing bulk operation</source>
|
<source>Error executing bulk operation</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">429</context>
|
<context context-type="linenumber">414</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7894972847287473517" datatype="html">
|
<trans-unit id="7894972847287473517" datatype="html">
|
||||||
<source>"<x id="PH" equiv-text="items[0].name"/>"</source>
|
<source>"<x id="PH" equiv-text="items[0].name"/>"</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">589</context>
|
<context context-type="linenumber">574</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>
|
||||||
<context context-type="linenumber">595</context>
|
<context context-type="linenumber">580</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8639884465898458690" datatype="html">
|
<trans-unit id="8639884465898458690" datatype="html">
|
||||||
<source>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</source>
|
<source>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">591</context>
|
<context context-type="linenumber">576</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">This is for messages like 'modify "tag1" and "tag2"'</note>
|
<note priority="1" from="description">This is for messages like 'modify "tag1" and "tag2"'</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -9794,7 +9783,7 @@
|
|||||||
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</source>
|
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">599,601</context>
|
<context context-type="linenumber">584,586</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<note priority="1" from="description">this is for messages like 'modify "tag1", "tag2" and "tag3"'</note>
|
<note priority="1" from="description">this is for messages like 'modify "tag1", "tag2" and "tag3"'</note>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
@@ -9802,14 +9791,14 @@
|
|||||||
<source>Confirm tags assignment</source>
|
<source>Confirm tags assignment</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">616</context>
|
<context context-type="linenumber">601</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6619516195038467207" datatype="html">
|
<trans-unit id="6619516195038467207" datatype="html">
|
||||||
<source>This operation will add the tag "<x id="PH" equiv-text="tag.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will add the tag "<x id="PH" equiv-text="tag.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">622</context>
|
<context context-type="linenumber">607</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1894412783609570695" datatype="html">
|
<trans-unit id="1894412783609570695" datatype="html">
|
||||||
@@ -9818,14 +9807,14 @@
|
|||||||
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">627,629</context>
|
<context context-type="linenumber">612,614</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7181166515756808573" datatype="html">
|
<trans-unit id="7181166515756808573" datatype="html">
|
||||||
<source>This operation will remove the tag "<x id="PH" equiv-text="tag.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will remove the tag "<x id="PH" equiv-text="tag.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">635</context>
|
<context context-type="linenumber">620</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3819792277998068944" datatype="html">
|
<trans-unit id="3819792277998068944" datatype="html">
|
||||||
@@ -9834,7 +9823,7 @@
|
|||||||
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">640,642</context>
|
<context context-type="linenumber">625,627</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2739066218579571288" datatype="html">
|
<trans-unit id="2739066218579571288" datatype="html">
|
||||||
@@ -9845,84 +9834,84 @@
|
|||||||
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">644,648</context>
|
<context context-type="linenumber">629,633</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2996713129519325161" datatype="html">
|
<trans-unit id="2996713129519325161" datatype="html">
|
||||||
<source>Confirm correspondent assignment</source>
|
<source>Confirm correspondent assignment</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">685</context>
|
<context context-type="linenumber">670</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6900893559485781849" datatype="html">
|
<trans-unit id="6900893559485781849" datatype="html">
|
||||||
<source>This operation will assign the correspondent "<x id="PH" equiv-text="correspondent.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the correspondent "<x id="PH" equiv-text="correspondent.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">687</context>
|
<context context-type="linenumber">672</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1257522660364398440" datatype="html">
|
<trans-unit id="1257522660364398440" datatype="html">
|
||||||
<source>This operation will remove the correspondent from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will remove the correspondent from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">689</context>
|
<context context-type="linenumber">674</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5393409374423140648" datatype="html">
|
<trans-unit id="5393409374423140648" datatype="html">
|
||||||
<source>Confirm document type assignment</source>
|
<source>Confirm document type assignment</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">723</context>
|
<context context-type="linenumber">708</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="332180123895325027" datatype="html">
|
<trans-unit id="332180123895325027" datatype="html">
|
||||||
<source>This operation will assign the document type "<x id="PH" equiv-text="documentType.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the document type "<x id="PH" equiv-text="documentType.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">725</context>
|
<context context-type="linenumber">710</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2236642492594872779" datatype="html">
|
<trans-unit id="2236642492594872779" datatype="html">
|
||||||
<source>This operation will remove the document type from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will remove the document type from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">727</context>
|
<context context-type="linenumber">712</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6386555513013840736" datatype="html">
|
<trans-unit id="6386555513013840736" datatype="html">
|
||||||
<source>Confirm storage path assignment</source>
|
<source>Confirm storage path assignment</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">761</context>
|
<context context-type="linenumber">746</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8750527458618415924" datatype="html">
|
<trans-unit id="8750527458618415924" datatype="html">
|
||||||
<source>This operation will assign the storage path "<x id="PH" equiv-text="storagePath.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the storage path "<x id="PH" equiv-text="storagePath.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">763</context>
|
<context context-type="linenumber">748</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="60728365335056946" datatype="html">
|
<trans-unit id="60728365335056946" datatype="html">
|
||||||
<source>This operation will remove the storage path from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will remove the storage path from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">765</context>
|
<context context-type="linenumber">750</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4187352575310415704" datatype="html">
|
<trans-unit id="4187352575310415704" datatype="html">
|
||||||
<source>Confirm custom field assignment</source>
|
<source>Confirm custom field assignment</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">794</context>
|
<context context-type="linenumber">779</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7966494636326273856" datatype="html">
|
<trans-unit id="7966494636326273856" datatype="html">
|
||||||
<source>This operation will assign the custom field "<x id="PH" equiv-text="customField.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will assign the custom field "<x id="PH" equiv-text="customField.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">800</context>
|
<context context-type="linenumber">785</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5789455969634598553" datatype="html">
|
<trans-unit id="5789455969634598553" datatype="html">
|
||||||
@@ -9931,14 +9920,14 @@
|
|||||||
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">805,807</context>
|
<context context-type="linenumber">790,792</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5648572354333199245" datatype="html">
|
<trans-unit id="5648572354333199245" datatype="html">
|
||||||
<source>This operation will remove the custom field "<x id="PH" equiv-text="customField.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will remove the custom field "<x id="PH" equiv-text="customField.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">813</context>
|
<context context-type="linenumber">798</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6666899594015948817" datatype="html">
|
<trans-unit id="6666899594015948817" datatype="html">
|
||||||
@@ -9947,7 +9936,7 @@
|
|||||||
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">818,820</context>
|
<context context-type="linenumber">803,805</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8050047262594964176" datatype="html">
|
<trans-unit id="8050047262594964176" datatype="html">
|
||||||
@@ -9958,105 +9947,105 @@
|
|||||||
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">822,826</context>
|
<context context-type="linenumber">807,811</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8615059324209654051" datatype="html">
|
<trans-unit id="8615059324209654051" datatype="html">
|
||||||
<source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source>
|
<source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">969</context>
|
<context context-type="linenumber">954</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="135114644550399974" datatype="html">
|
<trans-unit id="135114644550399974" datatype="html">
|
||||||
<source>Moved <x id="PH" equiv-text="count"/> document(s) to the trash</source>
|
<source>Moved <x id="PH" equiv-text="count"/> document(s) to the trash</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">996</context>
|
<context context-type="linenumber">981</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8585195717323764335" datatype="html">
|
<trans-unit id="8585195717323764335" datatype="html">
|
||||||
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1031</context>
|
<context context-type="linenumber">1016</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7366623494074776040" datatype="html">
|
<trans-unit id="7366623494074776040" datatype="html">
|
||||||
<source>The archive files will be re-generated with the current settings.</source>
|
<source>The archive files 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-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
<context context-type="linenumber">1032</context>
|
<context context-type="linenumber">1017</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6555329262222566158" datatype="html">
|
<trans-unit id="6555329262222566158" datatype="html">
|
||||||
<source>Rotate confirm</source>
|
<source>Rotate confirm</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1070</context>
|
<context context-type="linenumber">1055</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5203024009814367559" datatype="html">
|
<trans-unit id="5203024009814367559" datatype="html">
|
||||||
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source>
|
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1071</context>
|
<context context-type="linenumber">1056</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7910756456450124185" datatype="html">
|
<trans-unit id="7910756456450124185" datatype="html">
|
||||||
<source>Merge confirm</source>
|
<source>Merge confirm</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1094</context>
|
<context context-type="linenumber">1079</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7643543647233874431" datatype="html">
|
<trans-unit id="7643543647233874431" datatype="html">
|
||||||
<source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source>
|
<source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1095</context>
|
<context context-type="linenumber">1080</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7869008840945899895" datatype="html">
|
<trans-unit id="7869008840945899895" datatype="html">
|
||||||
<source>Merged document will be queued for consumption.</source>
|
<source>Merged document will be queued for consumption.</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1118</context>
|
<context context-type="linenumber">1103</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1549333914563613027" datatype="html">
|
<trans-unit id="1549333914563613027" datatype="html">
|
||||||
<source>The selected documents will become versions of the root document.</source>
|
<source>The selected documents will become versions of the root document.</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1131</context>
|
<context context-type="linenumber">1116</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7099667366799629283" datatype="html">
|
<trans-unit id="7099667366799629283" datatype="html">
|
||||||
<source>Documents merged as versions.</source>
|
<source>Documents merged as versions.</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1146</context>
|
<context context-type="linenumber">1131</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3873496751167944011" datatype="html">
|
<trans-unit id="3873496751167944011" datatype="html">
|
||||||
<source>Error updating custom fields.</source>
|
<source>Error updating custom fields.</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1176</context>
|
<context context-type="linenumber">1161</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6144801143088984138" datatype="html">
|
<trans-unit id="6144801143088984138" datatype="html">
|
||||||
<source>Share link bundle creation requested.</source>
|
<source>Share link bundle creation requested.</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1224</context>
|
<context context-type="linenumber">1209</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="46019676931295023" datatype="html">
|
<trans-unit id="46019676931295023" datatype="html">
|
||||||
<source>Share link bundle creation is not available yet.</source>
|
<source>Share link bundle creation is not available yet.</source>
|
||||||
<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>
|
||||||
<context context-type="linenumber">1231</context>
|
<context context-type="linenumber">1216</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6307402210351946694" datatype="html">
|
<trans-unit id="6307402210351946694" datatype="html">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { RemoteVersionService } from 'src/app/services/rest/remote-version.servi
|
|||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { TaskNotificationService } from 'src/app/services/task-notification.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 { ToastService } from 'src/app/services/toast.service'
|
||||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
@@ -198,14 +199,14 @@ describe('AppFrameComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refreshes and notifies on completion without client-side task tracking', () => {
|
it('refreshes and updates the task notification on completion', () => {
|
||||||
const list = TestBed.inject(DocumentListViewService)
|
const list = TestBed.inject(DocumentListViewService)
|
||||||
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||||
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||||
const tasksReload = jest
|
const tasksReload = jest
|
||||||
.spyOn(tasksService, 'reload')
|
.spyOn(tasksService, 'reload')
|
||||||
.mockImplementation(() => {})
|
.mockImplementation(() => {})
|
||||||
const success = jest.spyOn(toastService, 'showSuccess')
|
const success = jest.spyOn(toastService, 'show')
|
||||||
const refresh = jest
|
const refresh = jest
|
||||||
.spyOn(openDocumentsService, 'refreshDocument')
|
.spyOn(openDocumentsService, 'refreshDocument')
|
||||||
.mockImplementation(() => {})
|
.mockImplementation(() => {})
|
||||||
@@ -230,7 +231,12 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(refresh).toHaveBeenCalledWith(document.id)
|
expect(refresh).toHaveBeenCalledWith(document.id)
|
||||||
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
|
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
|
||||||
expect(tasksReload).toHaveBeenCalledTimes(1)
|
expect(tasksReload).toHaveBeenCalledTimes(1)
|
||||||
expect(success).toHaveBeenCalledWith('Bulk operation completed.')
|
expect(success).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'task-bulk-task',
|
||||||
|
content: 'Operation completed.',
|
||||||
|
})
|
||||||
|
)
|
||||||
fixture.destroy()
|
fixture.destroy()
|
||||||
bulkEvents.next({
|
bulkEvents.next({
|
||||||
task_id: 'bulk-task',
|
task_id: 'bulk-task',
|
||||||
@@ -245,14 +251,14 @@ describe('AppFrameComponent', () => {
|
|||||||
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||||
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||||
jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
|
jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
|
||||||
const error = jest.spyOn(toastService, 'showError')
|
const error = jest.spyOn(toastService, 'show')
|
||||||
const success = jest.spyOn(toastService, 'showSuccess')
|
const success = jest.spyOn(toastService, 'showSuccess')
|
||||||
for (const status of [
|
for (const status of [
|
||||||
PaperlessTaskStatus.Failure,
|
PaperlessTaskStatus.Failure,
|
||||||
PaperlessTaskStatus.Revoked,
|
PaperlessTaskStatus.Revoked,
|
||||||
]) {
|
]) {
|
||||||
bulkEvents.next({
|
bulkEvents.next({
|
||||||
task_id: 'bulk-task',
|
task_id: `${status}-task`,
|
||||||
operation: 'set_permissions',
|
operation: 'set_permissions',
|
||||||
status,
|
status,
|
||||||
error:
|
error:
|
||||||
@@ -262,11 +268,36 @@ describe('AppFrameComponent', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
expect(reload).toHaveBeenCalledTimes(2)
|
expect(reload).toHaveBeenCalledTimes(2)
|
||||||
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
|
|
||||||
expect(error).toHaveBeenCalledWith(
|
expect(error).toHaveBeenCalledWith(
|
||||||
'Bulk operation revoked.',
|
expect.objectContaining({
|
||||||
'Task expired before execution.'
|
content:
|
||||||
|
'Operation failed. Some changes may already have been applied.',
|
||||||
|
error: 'Could not update',
|
||||||
|
})
|
||||||
)
|
)
|
||||||
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: 'Operation revoked.',
|
||||||
|
error: 'Task expired before execution.',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(success).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes delete completion to the shared task notification handler', () => {
|
||||||
|
const success = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
const notifications = TestBed.inject(TaskNotificationService)
|
||||||
|
const handleStatus = jest.spyOn(notifications, 'complete')
|
||||||
|
bulkEvents.next({
|
||||||
|
task_id: 'delete-1',
|
||||||
|
operation: 'delete',
|
||||||
|
status: PaperlessTaskStatus.Success,
|
||||||
|
})
|
||||||
|
expect(handleStatus).toHaveBeenCalledWith({
|
||||||
|
task_id: 'delete-1',
|
||||||
|
operation: 'delete',
|
||||||
|
status: PaperlessTaskStatus.Success,
|
||||||
|
})
|
||||||
expect(success).not.toHaveBeenCalled()
|
expect(success).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
} from 'src/app/services/rest/remote-version.service'
|
} from 'src/app/services/rest/remote-version.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { TaskNotificationService } from 'src/app/services/task-notification.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 { ToastService } from 'src/app/services/toast.service'
|
||||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
@@ -115,6 +116,7 @@ export class AppFrameComponent
|
|||||||
settingsService = inject(SettingsService)
|
settingsService = inject(SettingsService)
|
||||||
tasksService = inject(TasksService)
|
tasksService = inject(TasksService)
|
||||||
private readonly toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
|
private readonly taskNotifications = inject(TaskNotificationService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private djangoMessagesService = inject(DjangoMessagesService)
|
private djangoMessagesService = inject(DjangoMessagesService)
|
||||||
@@ -182,7 +184,7 @@ export class AppFrameComponent
|
|||||||
),
|
),
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
)
|
)
|
||||||
.subscribe(({ status, error }) => {
|
.subscribe((result) => {
|
||||||
this.documentList.reload(null, false)
|
this.documentList.reload(null, false)
|
||||||
this.documentList.reduceSelectionToFilter()
|
this.documentList.reduceSelectionToFilter()
|
||||||
// maybe someday we can optimize to only refresh affected documents
|
// maybe someday we can optimize to only refresh affected documents
|
||||||
@@ -198,16 +200,7 @@ export class AppFrameComponent
|
|||||||
) {
|
) {
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
}
|
}
|
||||||
if (status === PaperlessTaskStatus.Success) {
|
this.taskNotifications.complete(result)
|
||||||
this.toastService.showSuccess($localize`Bulk operation completed.`)
|
|
||||||
} else if (status === PaperlessTaskStatus.Revoked) {
|
|
||||||
this.toastService.showError($localize`Bulk operation revoked.`, error)
|
|
||||||
} else {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Bulk operation failed. Some changes may already have been applied.`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.lastScrollY = window.scrollY
|
this.lastScrollY = window.scrollY
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
background-color: var(--pngx-toast-bg);
|
background-color: var(--pngx-toast-bg);
|
||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: background-color .18s ease, border-color .18s ease;
|
||||||
|
|
||||||
&.toast-success {
|
&.toast-success {
|
||||||
--pngx-toast-accent: var(--pngx-toast-success);
|
--pngx-toast-accent: var(--pngx-toast-success);
|
||||||
@@ -23,6 +24,13 @@
|
|||||||
|
|
||||||
.toast-icon {
|
.toast-icon {
|
||||||
color: var(--pngx-toast-accent);
|
color: var(--pngx-toast-accent);
|
||||||
|
transition: color .18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.toast, .toast-icon {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-content {
|
.toast-content {
|
||||||
|
|||||||
@@ -65,6 +65,36 @@ describe('ToastComponent', () => {
|
|||||||
expect(component.delayRemaining()).toEqual(3000)
|
expect(component.delayRemaining()).toEqual(3000)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('updates its color and icon in place and restarts the countdown', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
fixture.componentRef.setInput('toast', {
|
||||||
|
id: 'task-1',
|
||||||
|
content: 'Queued',
|
||||||
|
delay: 5000,
|
||||||
|
})
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.onShown(component.toast)
|
||||||
|
jest.advanceTimersByTime(4500)
|
||||||
|
const element = fixture.nativeElement.querySelector('ngb-toast')
|
||||||
|
fixture.componentRef.setInput('toast', {
|
||||||
|
id: 'task-1',
|
||||||
|
content: 'Completed',
|
||||||
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
})
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('ngb-toast')).toBe(element)
|
||||||
|
expect(element.classList).toContain('toast-success')
|
||||||
|
expect(element.querySelector('[name="check-circle"]')).not.toBeNull()
|
||||||
|
expect(component.delayRemaining()).toBe(5000)
|
||||||
|
const hide = jest.spyOn(component.ngbToast, 'hide')
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(hide).not.toHaveBeenCalled()
|
||||||
|
jest.advanceTimersByTime(4200)
|
||||||
|
expect(hide).toHaveBeenCalled()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should hide the toast when the countdown ends', () => {
|
it('should hide the toast when the countdown ends', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const toast = { content: 'Info', delay: 3000 }
|
const toast = { content: 'Info', delay: 3000 }
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
DestroyRef,
|
DestroyRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
|
OnChanges,
|
||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
|
SimpleChanges,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
@@ -34,7 +36,7 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
|
|||||||
styleUrl: './toast.component.scss',
|
styleUrl: './toast.component.scss',
|
||||||
providers: [DatePipe, CustomDatePipe],
|
providers: [DatePipe, CustomDatePipe],
|
||||||
})
|
})
|
||||||
export class ToastComponent implements OnInit {
|
export class ToastComponent implements OnInit, OnChanges {
|
||||||
private clipboard = inject(Clipboard)
|
private clipboard = inject(Clipboard)
|
||||||
private customDatePipe = inject(CustomDatePipe)
|
private customDatePipe = inject(CustomDatePipe)
|
||||||
private destroyRef = inject(DestroyRef)
|
private destroyRef = inject(DestroyRef)
|
||||||
@@ -69,6 +71,12 @@ export class ToastComponent implements OnInit {
|
|||||||
if (this.autohide) this.delayRemaining.set(this.toast.delay)
|
if (this.autohide) this.delayRemaining.set(this.toast.delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes.toast && !changes.toast.firstChange && this.autohide) {
|
||||||
|
this.onShown(this.toast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get relativeTimestamp(): string {
|
get relativeTimestamp(): string {
|
||||||
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
|
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ describe('ToastsComponent', () => {
|
|||||||
toasts.forEach((t) => toastSubject.next(t))
|
toasts.forEach((t) => toastSubject.next(t))
|
||||||
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
|
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
|
||||||
|
|
||||||
// showing a visible toast again moves it to the top without duplicating
|
// Updating a visible toast preserves its position without duplicating it.
|
||||||
toastSubject.next(toasts[1])
|
toastSubject.next(toasts[1])
|
||||||
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
|
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should remove a popup when it hides without closing it', () => {
|
it('should remove a popup when it hides without closing it', () => {
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ export class ToastsComponent {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.toasts.update((toasts) =>
|
this.toasts.update((toasts) =>
|
||||||
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
|
toasts.some((existing) => existing.id === toast.id)
|
||||||
0,
|
? toasts.map((existing) =>
|
||||||
MAX_VISIBLE_TOASTS
|
existing.id === toast.id ? toast : existing
|
||||||
)
|
)
|
||||||
|
: [toast, ...toasts].slice(0, MAX_VISIBLE_TOASTS)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1013,30 +1013,45 @@ describe('DocumentDetailComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support reprocess, confirm and close modal after started', () => {
|
it('should queue reprocess, show the task link and close the modal', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
initNormally()
|
initNormally()
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||||
reprocessSpy.mockReturnValue(of(true))
|
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-1' }))
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
component.reprocess()
|
component.reprocess()
|
||||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||||
openModal.componentInstance.confirmClicked.next()
|
openModal.componentInstance.confirmClicked.next()
|
||||||
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
|
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
|
jest.advanceTimersByTime(400)
|
||||||
|
expect(toastSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: 'Document reprocessing queued.',
|
||||||
|
actionName: 'View task',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
toastSpy.mock.calls.at(-1)[0].action()
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/tasks'], {
|
||||||
|
queryParams: { task_id: 'reprocess-1' },
|
||||||
|
})
|
||||||
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should run a workflow on the document, confirm and close modal', () => {
|
it('should run a workflow on the document, confirm and close modal', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
initNormally()
|
initNormally()
|
||||||
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||||
bulkEditSpy.mockReturnValue(of(true))
|
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-1' }))
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
component.runWorkflow()
|
component.runWorkflow()
|
||||||
expect(openModal.componentInstance.singleDocument).toBe(true)
|
expect(openModal.componentInstance.singleDocument).toBe(true)
|
||||||
expect(openModal.componentInstance.force).toBe(true)
|
expect(openModal.componentInstance.force).toBe(true)
|
||||||
@@ -1048,14 +1063,18 @@ describe('DocumentDetailComponent', () => {
|
|||||||
'run_workflow',
|
'run_workflow',
|
||||||
{ workflow: 5, filter_documents: false }
|
{ workflow: 5, filter_documents: false }
|
||||||
)
|
)
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
jest.advanceTimersByTime(400)
|
||||||
|
expect(toastSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: 'Workflow queued.' })
|
||||||
|
)
|
||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply workflow filters when the single-document checkbox is cleared', () => {
|
it('should apply workflow filters when the single-document checkbox is cleared', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||||
bulkEditSpy.mockReturnValue(of(true))
|
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-2' }))
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
component.runWorkflow()
|
component.runWorkflow()
|
||||||
@@ -1072,7 +1091,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
it('should pass remote OCR choice when reprocessing', () => {
|
it('should pass remote OCR choice when reprocessing', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||||
reprocessSpy.mockReturnValue(of(true))
|
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-2' }))
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
component.reprocess()
|
component.reprocess()
|
||||||
@@ -2001,10 +2020,12 @@ describe('DocumentDetailComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support pdf editor, handle error', () => {
|
it('should support pdf editor, handle error', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const queuedToastSpy = jest.spyOn(toastService, 'show')
|
||||||
initNormally()
|
initNormally()
|
||||||
component.selectedVersionId.set(10)
|
component.selectedVersionId.set(10)
|
||||||
component.editPdf()
|
component.editPdf()
|
||||||
@@ -2023,6 +2044,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
update_document: false,
|
update_document: false,
|
||||||
include_metadata: true,
|
include_metadata: true,
|
||||||
source_mode: 'explicit_selection',
|
source_mode: 'explicit_selection',
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
req.error(new ErrorEvent('failed'))
|
req.error(new ErrorEvent('failed'))
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
@@ -2035,13 +2057,21 @@ describe('DocumentDetailComponent', () => {
|
|||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/edit_pdf/`
|
`${environment.apiBaseUrl}documents/edit_pdf/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'edit-pdf-1' })
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
jest.advanceTimersByTime(400)
|
||||||
|
expect(closeSpy).not.toHaveBeenCalled()
|
||||||
|
expect(queuedToastSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: 'PDF edit queued.' })
|
||||||
|
)
|
||||||
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support removing password protection from pdfs', () => {
|
it('should support removing password protection from pdfs', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
|
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||||
|
const queuedToastSpy = jest.spyOn(toastService, 'show')
|
||||||
initNormally()
|
initNormally()
|
||||||
component.selectedVersionId.set(10)
|
component.selectedVersionId.set(10)
|
||||||
component.password = 'secret'
|
component.password = 'secret'
|
||||||
@@ -2062,8 +2092,15 @@ describe('DocumentDetailComponent', () => {
|
|||||||
include_metadata: false,
|
include_metadata: false,
|
||||||
delete_original: true,
|
delete_original: true,
|
||||||
source_mode: 'explicit_selection',
|
source_mode: 'explicit_selection',
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'remove-password-1' })
|
||||||
|
jest.advanceTimersByTime(400)
|
||||||
|
expect(closeSpy).not.toHaveBeenCalled()
|
||||||
|
expect(queuedToastSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: 'Password removal queued.' })
|
||||||
|
)
|
||||||
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should require the current password before removing it', () => {
|
it('should require the current password before removing it', () => {
|
||||||
@@ -2115,9 +2152,9 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/remove_password/`
|
`${environment.apiBaseUrl}documents/remove_password/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'remove-password-2' })
|
||||||
|
|
||||||
expect(refreshSpy).toHaveBeenCalledWith(doc.id)
|
expect(refreshSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support keyboard shortcuts', () => {
|
it('should support keyboard shortcuts', () => {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
|||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
import { 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 { TaskNotificationService } from 'src/app/services/task-notification.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||||
@@ -226,6 +227,7 @@ export class DocumentDetailComponent
|
|||||||
private documentListViewService = inject(DocumentListViewService)
|
private documentListViewService = inject(DocumentListViewService)
|
||||||
private documentTitlePipe = inject(DocumentTitlePipe)
|
private documentTitlePipe = inject(DocumentTitlePipe)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
|
private taskNotifications = inject(TaskNotificationService)
|
||||||
private trashService = inject(TrashService)
|
private trashService = inject(TrashService)
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
private storagePathService = inject(StoragePathService)
|
private storagePathService = inject(StoragePathService)
|
||||||
@@ -1558,9 +1560,11 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.remoteOcr
|
modal.componentInstance.remoteOcr
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: ({ task_id }) => {
|
||||||
this.toastService.showInfo(
|
this.taskNotifications.showQueued(
|
||||||
$localize`Reprocess operation for "${this.document().title}" will begin in the background.`
|
task_id,
|
||||||
|
$localize`Document reprocessing queued.`,
|
||||||
|
{ content: $localize`Document reprocessing completed.` }
|
||||||
)
|
)
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.close()
|
modal.close()
|
||||||
@@ -1597,9 +1601,13 @@ export class DocumentDetailComponent
|
|||||||
filter_documents: !modal.componentInstance.force,
|
filter_documents: !modal.componentInstance.force,
|
||||||
})
|
})
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: ({ task_id }) => {
|
||||||
this.toastService.showInfo(
|
this.taskNotifications.showQueued(
|
||||||
$localize`Workflow will run on "${this.document().title}" in the background.`
|
task_id,
|
||||||
|
$localize`Workflow queued.`,
|
||||||
|
{
|
||||||
|
content: $localize`Workflow completed.`,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
modal.close()
|
modal.close()
|
||||||
},
|
},
|
||||||
@@ -1989,14 +1997,15 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: ({ task_id }) => {
|
||||||
this.toastService.showInfo(
|
this.taskNotifications.showQueued(
|
||||||
$localize`PDF edit operation for "${this.document().title}" will begin in the background.`
|
task_id,
|
||||||
|
$localize`PDF edit queued.`,
|
||||||
|
{
|
||||||
|
content: $localize`PDF edit completed.`,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
modal.close()
|
modal.close()
|
||||||
if (modal.componentInstance.deleteOriginal) {
|
|
||||||
this.openDocumentService.closeDocument(this.document())
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
@@ -2046,17 +2055,14 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: ({ task_id }) => {
|
||||||
this.toastService.showInfo(
|
this.taskNotifications.showQueued(
|
||||||
$localize`Password removal operation for "${this.document().title}" will begin in the background.`
|
task_id,
|
||||||
|
$localize`Password removal queued.`,
|
||||||
|
{ content: $localize`Password removal completed.` }
|
||||||
)
|
)
|
||||||
this.networkActive.set(false)
|
this.networkActive.set(false)
|
||||||
modal.close()
|
modal.close()
|
||||||
if (!dialog.updateDocument && dialog.deleteOriginal) {
|
|
||||||
this.openDocumentService.closeDocument(this.document())
|
|
||||||
} else if (dialog.updateDocument) {
|
|
||||||
this.openDocumentService.refreshDocument(this.documentId())
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
dialog.buttonsEnabled.set(true)
|
dialog.buttonsEnabled.set(true)
|
||||||
|
|||||||
+23
-14
@@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common'
|
|||||||
import { SimpleChange, signal } from '@angular/core'
|
import { SimpleChange, signal } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||||
@@ -33,7 +34,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
>
|
>
|
||||||
>
|
>
|
||||||
let toastService: jest.Mocked<
|
let toastService: jest.Mocked<
|
||||||
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
|
Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'>
|
||||||
>
|
>
|
||||||
let finished$: Subject<{ taskId: string }>
|
let finished$: Subject<{ taskId: string }>
|
||||||
let failed$: Subject<{ taskId: string; message?: string }>
|
let failed$: Subject<{ taskId: string; message?: string }>
|
||||||
@@ -54,6 +55,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
}
|
}
|
||||||
modalService = { open: jest.fn() }
|
modalService = { open: jest.fn() }
|
||||||
toastService = {
|
toastService = {
|
||||||
|
show: jest.fn(),
|
||||||
showError: jest.fn(),
|
showError: jest.fn(),
|
||||||
showInfo: jest.fn(),
|
showInfo: jest.fn(),
|
||||||
showSuccess: jest.fn(),
|
showSuccess: jest.fn(),
|
||||||
@@ -88,6 +90,10 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
provide: PermissionsService,
|
provide: PermissionsService,
|
||||||
useValue: permissionsService,
|
useValue: permissionsService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: Router,
|
||||||
|
useValue: { navigate: jest.fn() },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: WebsocketStatusService,
|
provide: WebsocketStatusService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -377,7 +383,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
expect(component.versionLabelDraft).toEqual('')
|
expect(component.versionLabelDraft).toEqual('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
|
it('addExistingDocumentAsVersion should queue with a label and wait to refresh versions', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
const confirmClicked = new Subject<number>()
|
const confirmClicked = new Subject<number>()
|
||||||
const modal = {
|
const modal = {
|
||||||
componentInstance: {
|
componentInstance: {
|
||||||
@@ -388,14 +395,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
close: jest.fn(),
|
close: jest.fn(),
|
||||||
}
|
}
|
||||||
modalService.open.mockReturnValue(modal as any)
|
modalService.open.mockReturnValue(modal as any)
|
||||||
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
|
documentService.mergeDocumentsAsVersions.mockReturnValue(
|
||||||
// Newest first, as the API returns them. The merged document has a lower id
|
of({ result: 'OK', task_id: 'versions-1' })
|
||||||
// than the root, which is the whole point of merging an existing document.
|
)
|
||||||
const versions: DocumentVersionInfo[] = [
|
|
||||||
{ id: 2, is_root: false, checksum: 'cccc' },
|
|
||||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
|
||||||
]
|
|
||||||
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
|
|
||||||
component.newVersionLabel = ' Imported '
|
component.newVersionLabel = ' Imported '
|
||||||
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
||||||
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
|
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
|
||||||
@@ -410,11 +412,18 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
'Imported'
|
'Imported'
|
||||||
)
|
)
|
||||||
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
|
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
|
||||||
expect(documentService.getVersions).toHaveBeenCalledWith(3)
|
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
expect(versionsEmitSpy).not.toHaveBeenCalled()
|
||||||
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
expect(selectedEmitSpy).not.toHaveBeenCalled()
|
||||||
expect(component.newVersionLabel).toEqual('')
|
expect(component.newVersionLabel).toEqual('')
|
||||||
expect(modal.close).toHaveBeenCalled()
|
expect(modal.close).toHaveBeenCalled()
|
||||||
expect(toastService.showSuccess).toHaveBeenCalled()
|
jest.advanceTimersByTime(400)
|
||||||
|
expect(toastService.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: 'Existing document queued to be added as a version.',
|
||||||
|
actionName: 'View task',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
+7
-9
@@ -28,6 +28,7 @@ import { DocumentVersionInfo } from 'src/app/data/document'
|
|||||||
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 { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { TaskNotificationService } from 'src/app/services/task-notification.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import {
|
import {
|
||||||
UploadState,
|
UploadState,
|
||||||
@@ -77,6 +78,7 @@ export class DocumentVersionDropdownComponent
|
|||||||
private readonly toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||||
private readonly modalService = inject(NgbModal)
|
private readonly modalService = inject(NgbModal)
|
||||||
|
private readonly taskNotifications = inject(TaskNotificationService)
|
||||||
private readonly destroy$ = new Subject<void>()
|
private readonly destroy$ = new Subject<void>()
|
||||||
private readonly documentChange$ = new Subject<void>()
|
private readonly documentChange$ = new Subject<void>()
|
||||||
|
|
||||||
@@ -305,23 +307,19 @@ export class DocumentVersionDropdownComponent
|
|||||||
versionLabel
|
versionLabel
|
||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(() => this.documentsService.getVersions(this.documentId)),
|
|
||||||
first(),
|
first(),
|
||||||
finalize(() => dialog.buttonsEnabled.set(true)),
|
finalize(() => dialog.buttonsEnabled.set(true)),
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
takeUntil(this.documentChange$)
|
takeUntil(this.documentChange$)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (document) => {
|
next: ({ task_id }) => {
|
||||||
if (document?.versions?.length) {
|
|
||||||
this.versionsUpdated.emit(document.versions)
|
|
||||||
// The API returns versions newest first
|
|
||||||
this.versionSelected.emit(document.versions[0].id)
|
|
||||||
}
|
|
||||||
this.newVersionLabel = ''
|
this.newVersionLabel = ''
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showSuccess(
|
this.taskNotifications.showQueued(
|
||||||
$localize`Existing document added as a version.`
|
task_id,
|
||||||
|
$localize`Existing document queued to be added as a version.`,
|
||||||
|
{ content: $localize`Existing document added as a version.` }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
|
|||||||
@@ -686,8 +686,6 @@ describe('BulkEditorComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should run a workflow on the selection via the run workflow dialog', () => {
|
it('should run a workflow on the selection via the run workflow dialog', () => {
|
||||||
const toast = jest.spyOn(toastService, 'show')
|
|
||||||
const infoToast = jest.spyOn(toastService, 'showInfo')
|
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
@@ -714,12 +712,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
workflow: 5,
|
workflow: 5,
|
||||||
filter_documents: false,
|
filter_documents: false,
|
||||||
})
|
})
|
||||||
req.flush({ result: 'OK' })
|
req.flush(true)
|
||||||
expect(infoToast).toHaveBeenCalledWith(
|
|
||||||
'Workflow will run on the selected documents in the background.'
|
|
||||||
)
|
|
||||||
expect(toast.mock.calls.at(-1)[0].actionName).toBeUndefined()
|
|
||||||
expect(documentListViewService.selected.size).toBe(0)
|
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
@@ -728,27 +721,6 @@ describe('BulkEditorComponent', () => {
|
|||||||
) // listAllFilteredIds
|
) // listAllFilteredIds
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should require ownership of selected documents to run a workflow', () => {
|
|
||||||
component.manualWorkflows = [{ id: 5 }] as any
|
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
|
||||||
jest
|
|
||||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
|
||||||
.mockReturnValue(true)
|
|
||||||
const ownsDocument = jest
|
|
||||||
.spyOn(permissionsService, 'currentUserOwnsObject')
|
|
||||||
.mockReturnValue(false)
|
|
||||||
jest
|
|
||||||
.spyOn(documentListViewService, 'documents', 'get')
|
|
||||||
.mockReturnValue([{ id: 3 }])
|
|
||||||
jest
|
|
||||||
.spyOn(documentListViewService, 'selected', 'get')
|
|
||||||
.mockReturnValue(new Set([3]))
|
|
||||||
|
|
||||||
expect(component.canRunWorkflow).toBe(false)
|
|
||||||
ownsDocument.mockReturnValue(true)
|
|
||||||
expect(component.canRunWorkflow).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should set modal dialog text accordingly for tag edit confirmation', () => {
|
it('should set modal dialog text accordingly for tag edit confirmation', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
@@ -1267,14 +1239,15 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/delete/`
|
`${environment.apiBaseUrl}documents/delete/`
|
||||||
)
|
)
|
||||||
const notifySpy = jest
|
const trackSpy = jest
|
||||||
.spyOn(trashService, 'notifyMovedToTrash')
|
.spyOn(trashService, 'trackMovedToTrash')
|
||||||
.mockImplementation(() => {})
|
.mockImplementation(() => {})
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'delete-1' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
|
expect(trackSpy).toHaveBeenCalledWith('delete-1', 2, [3, 4])
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
@@ -1291,9 +1264,9 @@ describe('BulkEditorComponent', () => {
|
|||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/delete/`
|
`${environment.apiBaseUrl}documents/delete/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'delete-2' })
|
||||||
// ids aren't known when all matching documents are selected, so no undo
|
// ids aren't known when all matching documents are selected, so no undo
|
||||||
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
|
expect(trackSpy).toHaveBeenLastCalledWith('delete-2', 0, null)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
@@ -1373,10 +1346,11 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/reprocess/`
|
`${environment.apiBaseUrl}documents/reprocess/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'reprocess-1' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
remote_ocr: false,
|
remote_ocr: false,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
@@ -1407,11 +1381,12 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/rotate/`
|
`${environment.apiBaseUrl}documents/rotate/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'rotate-1' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
degrees: 90,
|
degrees: 90,
|
||||||
source_mode: 'latest_version',
|
source_mode: 'latest_version',
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
@@ -1449,10 +1424,11 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/merge/`
|
`${environment.apiBaseUrl}documents/merge/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'merge-1' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
metadata_document_id: 3,
|
metadata_document_id: 3,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
@@ -1467,11 +1443,12 @@ describe('BulkEditorComponent', () => {
|
|||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/merge/`
|
`${environment.apiBaseUrl}documents/merge/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'merge-2' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
metadata_document_id: 3,
|
metadata_document_id: 3,
|
||||||
delete_originals: true,
|
delete_originals: true,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
@@ -1488,11 +1465,12 @@ describe('BulkEditorComponent', () => {
|
|||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/merge/`
|
`${environment.apiBaseUrl}documents/merge/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush({ result: 'OK', task_id: 'merge-3' })
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
metadata_document_id: 3,
|
metadata_document_id: 3,
|
||||||
archive_fallback: true,
|
archive_fallback: true,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
@@ -1504,6 +1482,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support merging documents as versions', () => {
|
it('should support merging documents as versions', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
@@ -1531,8 +1510,8 @@ describe('BulkEditorComponent', () => {
|
|||||||
.mockReturnValue(true)
|
.mockReturnValue(true)
|
||||||
const mergeAsVersionsSpy = jest
|
const mergeAsVersionsSpy = jest
|
||||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||||
.mockReturnValue(of(true))
|
.mockReturnValue(of({ result: 'OK', task_id: 'versions-1' }))
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
component.mergeSelectedAsVersions()
|
component.mergeSelectedAsVersions()
|
||||||
@@ -1548,9 +1527,11 @@ describe('BulkEditorComponent', () => {
|
|||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.selected.size).toEqual(0)
|
expect(documentListViewService.selected.size).toEqual(0)
|
||||||
expect(toastSuccessSpy).toHaveBeenCalledWith(
|
jest.advanceTimersByTime(400)
|
||||||
'Documents merged as versions.'
|
expect(toastSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: 'Bulk operation queued.' })
|
||||||
)
|
)
|
||||||
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not report success when merging documents as versions fails', () => {
|
it('should not report success when merging documents as versions fails', () => {
|
||||||
@@ -1644,6 +1625,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
it.each([true, false])(
|
it.each([true, false])(
|
||||||
'shows queued feedback and gates the task link on permission (%s)',
|
'shows queued feedback and gates the task link on permission (%s)',
|
||||||
(canViewTasks) => {
|
(canViewTasks) => {
|
||||||
|
jest.useFakeTimers()
|
||||||
jest
|
jest
|
||||||
.spyOn(permissionsService, 'currentUserCan')
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
.mockReturnValue(canViewTasks)
|
.mockReturnValue(canViewTasks)
|
||||||
@@ -1664,6 +1646,8 @@ describe('BulkEditorComponent', () => {
|
|||||||
)
|
)
|
||||||
req.flush({ result: 'OK', task_id: 'queued-operation' })
|
req.flush({ result: 'OK', task_id: 'queued-operation' })
|
||||||
expect(close).toHaveBeenCalledTimes(1)
|
expect(close).toHaveBeenCalledTimes(1)
|
||||||
|
expect(queued).not.toHaveBeenCalled()
|
||||||
|
jest.advanceTimersByTime(400)
|
||||||
const notification = queued.mock.calls.at(-1)[0]
|
const notification = queued.mock.calls.at(-1)[0]
|
||||||
expect(notification.content).toBe('Bulk operation queued.')
|
expect(notification.content).toBe('Bulk operation queued.')
|
||||||
if (canViewTasks) {
|
if (canViewTasks) {
|
||||||
@@ -1679,6 +1663,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(reload).not.toHaveBeenCalled()
|
expect(reload).not.toHaveBeenCalled()
|
||||||
expect(documentListViewService.selected.size).toBe(0)
|
expect(documentListViewService.selected.size).toBe(0)
|
||||||
expect(documentListViewService.allSelected).toBe(false)
|
expect(documentListViewService.allSelected).toBe(false)
|
||||||
|
jest.useRealTimers()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,25 +19,18 @@ import {
|
|||||||
NgbModalRef,
|
NgbModalRef,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { saveAs } from 'file-saver'
|
import { saveAs } from 'file-saver'
|
||||||
import {
|
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
||||||
first,
|
|
||||||
map,
|
|
||||||
Observable,
|
|
||||||
Subject,
|
|
||||||
switchMap,
|
|
||||||
takeUntil,
|
|
||||||
tap,
|
|
||||||
} from 'rxjs'
|
|
||||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { BulkOperationResponse } from 'src/app/data/bulk-operation'
|
||||||
import { CustomField } from 'src/app/data/custom-field'
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { SelectionDataItem } from 'src/app/data/results'
|
import { SelectionDataItem } from 'src/app/data/results'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { Workflow } from 'src/app/data/workflow'
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
|
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
@@ -52,12 +45,12 @@ import {
|
|||||||
DocumentService,
|
DocumentService,
|
||||||
MergeDocumentsRequest,
|
MergeDocumentsRequest,
|
||||||
} from 'src/app/services/rest/document.service'
|
} from 'src/app/services/rest/document.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
|
import { 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 { TaskNotificationService } from 'src/app/services/task-notification.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
import { flattenTags } from 'src/app/utils/flatten-tags'
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
@@ -84,7 +77,6 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
|
|||||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||||
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-bulk-editor',
|
selector: 'pngx-bulk-editor',
|
||||||
@@ -110,13 +102,12 @@ export class BulkEditorComponent
|
|||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
private trashService = inject(TrashService)
|
private trashService = inject(TrashService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private openDocumentService = inject(OpenDocumentsService)
|
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
|
private taskNotifications = inject(TaskNotificationService)
|
||||||
private storagePathService = inject(StoragePathService)
|
private storagePathService = inject(StoragePathService)
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
public readonly permissionService = inject(PermissionsService)
|
public readonly permissionService = inject(PermissionsService)
|
||||||
private savedViewService = inject(SavedViewService)
|
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
private readonly workflowService = inject(WorkflowService)
|
private readonly workflowService = inject(WorkflowService)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
@@ -308,9 +299,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get canRunWorkflow(): boolean {
|
get canRunWorkflow(): boolean {
|
||||||
return (
|
return this.userCanEditAll && this.manualWorkflows.length > 0
|
||||||
this.userCanEditAll && this.userOwnsAll && this.manualWorkflows.length > 0
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runWorkflowSelected() {
|
runWorkflowSelected() {
|
||||||
@@ -324,23 +313,10 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
this.executeBulkEditMethod(modal, 'run_workflow', {
|
||||||
this.documentService
|
|
||||||
.bulkEdit(this.getSelectionQuery(), 'run_workflow', {
|
|
||||||
workflow: modal.componentInstance.workflowId,
|
workflow: modal.componentInstance.workflowId,
|
||||||
filter_documents: !modal.componentInstance.force,
|
filter_documents: !modal.componentInstance.force,
|
||||||
})
|
})
|
||||||
.pipe(first())
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
modal.close()
|
|
||||||
this.list.selectNone()
|
|
||||||
this.toastService.showInfo(
|
|
||||||
$localize`Workflow will run on the selected documents in the background.`
|
|
||||||
)
|
|
||||||
},
|
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,65 +338,39 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOperationQueued(modal: NgbModalRef, taskId: string) {
|
private handleOperationQueued(
|
||||||
|
modal: NgbModalRef,
|
||||||
|
taskId: string,
|
||||||
|
onQueued?: (taskId: string) => void
|
||||||
|
) {
|
||||||
modal?.close()
|
modal?.close()
|
||||||
this.list.selectNone()
|
this.list.selectNone()
|
||||||
const canViewTasks = this.permissionService.currentUserCan(
|
if (onQueued) {
|
||||||
PermissionAction.View,
|
onQueued(taskId)
|
||||||
PermissionType.PaperlessTask
|
} else {
|
||||||
|
this.taskNotifications.showQueued(
|
||||||
|
taskId,
|
||||||
|
$localize`Bulk operation queued.`
|
||||||
)
|
)
|
||||||
this.toastService.show({
|
|
||||||
content: $localize`Bulk operation queued.`,
|
|
||||||
delay: 5000,
|
|
||||||
...(canViewTasks
|
|
||||||
? {
|
|
||||||
actionName: $localize`View task`,
|
|
||||||
action: () =>
|
|
||||||
this.router.navigate(['/tasks'], {
|
|
||||||
queryParams: { task_id: taskId },
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
: {}),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private executeDocumentAction(
|
private executeDocumentAction(
|
||||||
modal: NgbModalRef,
|
modal: NgbModalRef,
|
||||||
request: Observable<any>,
|
request: Observable<BulkOperationResponse>,
|
||||||
options: { clearSelection?: boolean; successMessage?: string } = {}
|
onQueued?: (taskId: string) => void
|
||||||
) {
|
) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
}
|
}
|
||||||
request.pipe(first()).subscribe({
|
request.pipe(first()).subscribe({
|
||||||
next: () => {
|
next: ({ task_id }) => {
|
||||||
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
this.handleOperationQueued(modal, task_id, onQueued)
|
||||||
if (options.successMessage) {
|
|
||||||
this.toastService.showSuccess(options.successMessage)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
error: (error) => this.handleOperationError(modal, error),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOperationSuccess(
|
|
||||||
modal: NgbModalRef,
|
|
||||||
clearSelection: boolean = false
|
|
||||||
) {
|
|
||||||
if (clearSelection) {
|
|
||||||
this.list.selected.clear()
|
|
||||||
}
|
|
||||||
this.list.reload()
|
|
||||||
this.list.reduceSelectionToFilter()
|
|
||||||
this.list.selected.forEach((id) => {
|
|
||||||
this.openDocumentService.refreshDocument(id)
|
|
||||||
})
|
|
||||||
this.savedViewService.maybeRefreshDocumentCounts()
|
|
||||||
if (modal) {
|
|
||||||
modal.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleOperationError(modal: NgbModalRef, error: any) {
|
private handleOperationError(modal: NgbModalRef, error: any) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled.set(true)
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
@@ -988,16 +938,8 @@ export class BulkEditorComponent
|
|||||||
const count = this.getSelectionSize()
|
const count = this.getSelectionSize()
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService
|
this.documentService.deleteDocuments(this.getSelectionQuery()),
|
||||||
.deleteDocuments(this.getSelectionQuery())
|
(taskId) => this.trashService.trackMovedToTrash(taskId, count, documents)
|
||||||
.pipe(
|
|
||||||
tap(() =>
|
|
||||||
this.trashService.notifyMovedToTrash(
|
|
||||||
$localize`Moved ${count} document(s) to the trash`,
|
|
||||||
documents
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,11 +1053,7 @@ export class BulkEditorComponent
|
|||||||
mergeDialog.buttonsEnabled.set(false)
|
mergeDialog.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args)
|
||||||
{ clearSelection: !!args.delete_originals }
|
|
||||||
)
|
|
||||||
this.toastService.showInfo(
|
|
||||||
$localize`Merged document will be queued for consumption.`
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1140,11 +1078,7 @@ export class BulkEditorComponent
|
|||||||
this.documentService.mergeDocumentsAsVersions(
|
this.documentService.mergeDocumentsAsVersions(
|
||||||
mergeDialog.documentIDs(),
|
mergeDialog.documentIDs(),
|
||||||
mergeDialog.rootDocumentID()
|
mergeDialog.rootDocumentID()
|
||||||
),
|
)
|
||||||
{
|
|
||||||
clearSelection: true,
|
|
||||||
successMessage: $localize`Documents merged as versions.`,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
</pngx-input-select>
|
</pngx-input-select>
|
||||||
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
|
||||||
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
|
||||||
<pngx-permissions-form [users]="users()" accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
||||||
@if (error()?.filter_rules) {
|
@if (error()?.filter_rules) {
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
<h6 class="alert-heading" i18n>Filter rules error occurred while saving this view</h6>
|
<h6 class="alert-heading" i18n>Filter rules error occurred while saving this view</h6>
|
||||||
|
|||||||
+1
-16
@@ -3,7 +3,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { of } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
import { User } from 'src/app/data/user'
|
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { CheckComponent } from '../../common/input/check/check.component'
|
import { CheckComponent } from '../../common/input/check/check.component'
|
||||||
@@ -15,7 +14,6 @@ import { TextComponent } from '../../common/input/text/text.component'
|
|||||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
|
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
|
||||||
|
|
||||||
describe('SaveViewConfigDialogComponent', () => {
|
describe('SaveViewConfigDialogComponent', () => {
|
||||||
const users = [{ id: 10, username: 'user10' }] as User[]
|
|
||||||
let component: SaveViewConfigDialogComponent
|
let component: SaveViewConfigDialogComponent
|
||||||
let fixture: ComponentFixture<SaveViewConfigDialogComponent>
|
let fixture: ComponentFixture<SaveViewConfigDialogComponent>
|
||||||
let modal: NgbActiveModal
|
let modal: NgbActiveModal
|
||||||
@@ -27,7 +25,7 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
{
|
{
|
||||||
provide: UserService,
|
provide: UserService,
|
||||||
useValue: {
|
useValue: {
|
||||||
listAll: () => of({ results: users }),
|
listAll: () => of({ results: [] }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -126,19 +124,6 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should list users in the owner selector', () => {
|
|
||||||
fixture.debugElement
|
|
||||||
.query(By.css('button[ngbAccordionButton]'))
|
|
||||||
.nativeElement.click()
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
const permissionsForm = fixture.debugElement.query(
|
|
||||||
By.directive(PermissionsFormComponent)
|
|
||||||
)
|
|
||||||
const ownerSelect = permissionsForm.query(By.directive(SelectComponent))
|
|
||||||
expect(ownerSelect.componentInstance.items).toEqual(users)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support default name', () => {
|
it('should support default name', () => {
|
||||||
const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit')
|
const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit')
|
||||||
const modalCloseSpy = jest.spyOn(modal, 'close')
|
const modalCloseSpy = jest.spyOn(modal, 'close')
|
||||||
|
|||||||
+1
-16
@@ -6,7 +6,6 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -14,14 +13,11 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { catchError, map, of } from 'rxjs'
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_SAVED_VIEW_ICON,
|
DEFAULT_SAVED_VIEW_ICON,
|
||||||
SAVED_VIEW_ICONS,
|
SAVED_VIEW_ICONS,
|
||||||
} from 'src/app/data/saved-view-icons'
|
} from 'src/app/data/saved-view-icons'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { CheckComponent } from '../../common/input/check/check.component'
|
import { CheckComponent } from '../../common/input/check/check.component'
|
||||||
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||||
import { SelectComponent } from '../../common/input/select/select.component'
|
import { SelectComponent } from '../../common/input/select/select.component'
|
||||||
@@ -42,8 +38,6 @@ import { TextComponent } from '../../common/input/text/text.component'
|
|||||||
})
|
})
|
||||||
export class SaveViewConfigDialogComponent implements OnInit {
|
export class SaveViewConfigDialogComponent implements OnInit {
|
||||||
private modal = inject(NgbActiveModal)
|
private modal = inject(NgbActiveModal)
|
||||||
private userService = inject(UserService)
|
|
||||||
private toastService = inject(ToastService)
|
|
||||||
readonly error = signal(undefined)
|
readonly error = signal(undefined)
|
||||||
readonly buttonsEnabled = signal(true)
|
readonly buttonsEnabled = signal(true)
|
||||||
readonly defaultName = signal('')
|
readonly defaultName = signal('')
|
||||||
@@ -52,16 +46,7 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
|||||||
@Output()
|
@Output()
|
||||||
public saveClicked = new EventEmitter()
|
public saveClicked = new EventEmitter()
|
||||||
|
|
||||||
readonly users = toSignal(
|
users: User[]
|
||||||
this.userService.listAll().pipe(
|
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => {
|
|
||||||
this.toastService.showError($localize`Error retrieving users`, error)
|
|
||||||
return of([])
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as User[] }
|
|
||||||
)
|
|
||||||
readonly savedViewIcons = SAVED_VIEW_ICONS
|
readonly savedViewIcons = SAVED_VIEW_ICONS
|
||||||
|
|
||||||
setDefaultName(value: string) {
|
setDefaultName(value: string) {
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ describe(`DocumentService`, () => {
|
|||||||
expect(req.request.method).toEqual('POST')
|
expect(req.request.method).toEqual('POST')
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: ids,
|
documents: ids,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -303,6 +304,7 @@ describe(`DocumentService`, () => {
|
|||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: ids,
|
documents: ids,
|
||||||
remote_ocr: false,
|
remote_ocr: false,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -317,6 +319,7 @@ describe(`DocumentService`, () => {
|
|||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: ids,
|
documents: ids,
|
||||||
remote_ocr: true,
|
remote_ocr: true,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -331,6 +334,7 @@ describe(`DocumentService`, () => {
|
|||||||
documents: ids,
|
documents: ids,
|
||||||
degrees: 90,
|
degrees: 90,
|
||||||
source_mode: 'latest_version',
|
source_mode: 'latest_version',
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -346,6 +350,7 @@ describe(`DocumentService`, () => {
|
|||||||
documents: ids,
|
documents: ids,
|
||||||
metadata_document_id: 1,
|
metadata_document_id: 1,
|
||||||
delete_originals: true,
|
delete_originals: true,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -359,6 +364,7 @@ describe(`DocumentService`, () => {
|
|||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: ids,
|
documents: ids,
|
||||||
root_document_id: 2,
|
root_document_id: 2,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -374,6 +380,7 @@ describe(`DocumentService`, () => {
|
|||||||
documents: ids,
|
documents: ids,
|
||||||
root_document_id: 2,
|
root_document_id: 2,
|
||||||
version_label: 'Imported',
|
version_label: 'Imported',
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -388,6 +395,7 @@ describe(`DocumentService`, () => {
|
|||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: ids,
|
documents: ids,
|
||||||
operations: [{ page: 1, rotate: 90, doc: 0 }],
|
operations: [{ page: 1, rotate: 90, doc: 0 }],
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -403,6 +411,7 @@ describe(`DocumentService`, () => {
|
|||||||
documents: ids,
|
documents: ids,
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
update_document: true,
|
update_document: true,
|
||||||
|
from_webui: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -351,19 +351,24 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteDocuments(selection: DocumentSelectionQuery) {
|
deleteDocuments(selection: DocumentSelectionQuery) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'delete'), {
|
return this.http.post<BulkOperationResponse>(
|
||||||
...selection,
|
this.getResourceUrl(null, 'delete'),
|
||||||
})
|
{ ...selection, from_webui: true }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
reprocessDocuments(
|
reprocessDocuments(
|
||||||
selection: DocumentSelectionQuery,
|
selection: DocumentSelectionQuery,
|
||||||
remoteOcr: boolean = false
|
remoteOcr: boolean = false
|
||||||
) {
|
) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
return this.http.post<BulkOperationResponse>(
|
||||||
|
this.getResourceUrl(null, 'reprocess'),
|
||||||
|
{
|
||||||
...selection,
|
...selection,
|
||||||
remote_ocr: remoteOcr,
|
remote_ocr: remoteOcr,
|
||||||
})
|
from_webui: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
rotateDocuments(
|
rotateDocuments(
|
||||||
@@ -371,18 +376,26 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
degrees: number,
|
degrees: number,
|
||||||
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
|
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
|
||||||
) {
|
) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'rotate'), {
|
return this.http.post<BulkOperationResponse>(
|
||||||
|
this.getResourceUrl(null, 'rotate'),
|
||||||
|
{
|
||||||
...selection,
|
...selection,
|
||||||
degrees,
|
degrees,
|
||||||
source_mode: sourceMode,
|
source_mode: sourceMode,
|
||||||
})
|
from_webui: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
|
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'merge'), {
|
return this.http.post<BulkOperationResponse>(
|
||||||
|
this.getResourceUrl(null, 'merge'),
|
||||||
|
{
|
||||||
documents: ids,
|
documents: ids,
|
||||||
...request,
|
...request,
|
||||||
})
|
from_webui: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeDocumentsAsVersions(
|
mergeDocumentsAsVersions(
|
||||||
@@ -390,28 +403,40 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
rootDocumentId: number,
|
rootDocumentId: number,
|
||||||
versionLabel?: string
|
versionLabel?: string
|
||||||
) {
|
) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
|
return this.http.post<BulkOperationResponse>(
|
||||||
|
this.getResourceUrl(null, 'merge_as_versions'),
|
||||||
|
{
|
||||||
documents: ids,
|
documents: ids,
|
||||||
root_document_id: rootDocumentId,
|
root_document_id: rootDocumentId,
|
||||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
...(versionLabel ? { version_label: versionLabel } : {}),
|
||||||
})
|
from_webui: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
|
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
|
return this.http.post<BulkOperationResponse>(
|
||||||
|
this.getResourceUrl(null, 'edit_pdf'),
|
||||||
|
{
|
||||||
documents: ids,
|
documents: ids,
|
||||||
...request,
|
...request,
|
||||||
})
|
from_webui: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
removePasswordDocuments(
|
removePasswordDocuments(
|
||||||
ids: number[],
|
ids: number[],
|
||||||
request: RemovePasswordDocumentsRequest
|
request: RemovePasswordDocumentsRequest
|
||||||
) {
|
) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'remove_password'), {
|
return this.http.post<BulkOperationResponse>(
|
||||||
|
this.getResourceUrl(null, 'remove_password'),
|
||||||
|
{
|
||||||
documents: ids,
|
documents: ids,
|
||||||
...request,
|
...request,
|
||||||
})
|
from_webui: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelectionData(
|
getSelectionData(
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import { BulkOperationStatus } from '../data/bulk-operation'
|
||||||
|
import { PaperlessTaskStatus } from '../data/paperless-task'
|
||||||
|
import { PermissionsService } from './permissions.service'
|
||||||
|
import { TaskNotificationService } from './task-notification.service'
|
||||||
|
import { Toast, ToastLevel, ToastService } from './toast.service'
|
||||||
|
|
||||||
|
describe('TaskNotificationService', () => {
|
||||||
|
let service: TaskNotificationService
|
||||||
|
let toastService: ToastService
|
||||||
|
let popups: Toast[]
|
||||||
|
let notifications: Toast[]
|
||||||
|
const navigate = jest.fn()
|
||||||
|
const currentUserCan = jest.fn()
|
||||||
|
const result = (
|
||||||
|
status = PaperlessTaskStatus.Success
|
||||||
|
): BulkOperationStatus => ({
|
||||||
|
task_id: 'task-1',
|
||||||
|
operation: 'run_workflow',
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
currentUserCan.mockReturnValue(true)
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: Router, useValue: { navigate } },
|
||||||
|
{ provide: PermissionsService, useValue: { currentUserCan } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
service = TestBed.inject(TaskNotificationService)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
popups = []
|
||||||
|
notifications = []
|
||||||
|
toastService.showToast.subscribe((toast) => popups.push(toast))
|
||||||
|
toastService
|
||||||
|
.getToasts()
|
||||||
|
.subscribe((toasts) => (notifications = [...toasts]))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces a queued notification with its result, retaining the task link', () => {
|
||||||
|
service.showQueued('task-1', 'Workflow queued.', {
|
||||||
|
content: 'Workflow completed.',
|
||||||
|
})
|
||||||
|
jest.advanceTimersByTime(399)
|
||||||
|
expect(popups).toHaveLength(0)
|
||||||
|
jest.advanceTimersByTime(1)
|
||||||
|
expect(popups[0].content).toBe('Workflow queued.')
|
||||||
|
service.complete(result())
|
||||||
|
expect(notifications).toHaveLength(1)
|
||||||
|
expect(popups[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: popups[0].id,
|
||||||
|
content: 'Workflow completed.',
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
actionName: 'View task',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
popups[1].action()
|
||||||
|
expect(navigate).toHaveBeenCalledWith(['/tasks'], {
|
||||||
|
queryParams: { task_id: 'task-1' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows only the result when a task completes during the grace period', () => {
|
||||||
|
service.showQueued('task-1', 'Queued')
|
||||||
|
jest.advanceTimersByTime(100)
|
||||||
|
service.complete(result())
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(popups.map((toast) => toast.content)).toEqual([
|
||||||
|
'Operation completed.',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show a stale queued state when completion beats the HTTP response', () => {
|
||||||
|
service.complete(result())
|
||||||
|
service.showQueued('task-1', 'Workflow queued.', {
|
||||||
|
content: 'Workflow completed.',
|
||||||
|
})
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(popups.every((toast) => toast.level === ToastLevel.Success)).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(notifications).toHaveLength(1)
|
||||||
|
expect(notifications[0].content).toBe('Workflow completed.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked])(
|
||||||
|
'replaces the queued state with an error for %s',
|
||||||
|
(status) => {
|
||||||
|
service.showQueued('task-1', 'Queued', {
|
||||||
|
content: 'Success',
|
||||||
|
actionName: 'Undo',
|
||||||
|
})
|
||||||
|
jest.advanceTimersByTime(400)
|
||||||
|
service.complete({ ...result(status), error: 'Task failed' })
|
||||||
|
expect(notifications).toHaveLength(1)
|
||||||
|
expect(notifications[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
level: ToastLevel.Error,
|
||||||
|
error: 'Task failed',
|
||||||
|
actionName: 'View task',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it('ignores nonterminal and duplicate outcomes', () => {
|
||||||
|
service.showQueued('task-1', 'Queued')
|
||||||
|
service.complete(result(PaperlessTaskStatus.Started))
|
||||||
|
expect(popups).toHaveLength(0)
|
||||||
|
service.complete(result())
|
||||||
|
service.complete(result())
|
||||||
|
expect(popups).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps concurrent task notifications separate', () => {
|
||||||
|
service.showQueued('task-1', 'First queued')
|
||||||
|
service.showQueued('task-2', 'Second queued')
|
||||||
|
jest.advanceTimersByTime(400)
|
||||||
|
service.complete(result())
|
||||||
|
expect(notifications).toHaveLength(2)
|
||||||
|
expect(notifications.map((toast) => toast.content)).toEqual([
|
||||||
|
'Second queued',
|
||||||
|
'Operation completed.',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits task links without task viewing permission', () => {
|
||||||
|
currentUserCan.mockReturnValue(false)
|
||||||
|
service.showQueued('task-1', 'Queued')
|
||||||
|
jest.advanceTimersByTime(400)
|
||||||
|
service.complete(result())
|
||||||
|
expect(popups.every((toast) => !toast.action && !toast.actionName)).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { inject, Injectable, OnDestroy } from '@angular/core'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import { BulkOperationStatus } from '../data/bulk-operation'
|
||||||
|
import { PaperlessTaskStatus } from '../data/paperless-task'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from './permissions.service'
|
||||||
|
import { Toast, ToastLevel, ToastService } from './toast.service'
|
||||||
|
|
||||||
|
interface PendingNotification {
|
||||||
|
success: Partial<Toast>
|
||||||
|
timer: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TaskNotificationService implements OnDestroy {
|
||||||
|
private toastService = inject(ToastService)
|
||||||
|
private router = inject(Router)
|
||||||
|
private permissionsService = inject(PermissionsService)
|
||||||
|
private pending = new Map<string, PendingNotification>()
|
||||||
|
private completed = new Map<string, BulkOperationStatus>()
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.pending.forEach(({ timer }) => clearTimeout(timer))
|
||||||
|
}
|
||||||
|
|
||||||
|
showQueued(taskId: string, content: string, success: Partial<Toast> = {}) {
|
||||||
|
const completed = this.completed.get(taskId)
|
||||||
|
if (completed) {
|
||||||
|
this.showResult(completed, success)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.pending.has(taskId)) return
|
||||||
|
this.pending.set(taskId, {
|
||||||
|
success,
|
||||||
|
// short delay in case the task finishes super fast
|
||||||
|
timer: setTimeout(() => this.show(taskId, { content, delay: 5000 }), 400),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(result: BulkOperationStatus) {
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
PaperlessTaskStatus.Success,
|
||||||
|
PaperlessTaskStatus.Failure,
|
||||||
|
PaperlessTaskStatus.Revoked,
|
||||||
|
].includes(result.status) ||
|
||||||
|
this.completed.has(result.task_id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
this.completed.set(result.task_id, result)
|
||||||
|
// Retain recent outcomes in case task finishes quickly
|
||||||
|
if (this.completed.size > 100) {
|
||||||
|
this.completed.delete(this.completed.keys().next().value)
|
||||||
|
}
|
||||||
|
const pending = this.pending.get(result.task_id)
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this.pending.delete(result.task_id)
|
||||||
|
}
|
||||||
|
// Trash needs the caller's document IDs to offer Undo on success.
|
||||||
|
if (
|
||||||
|
pending ||
|
||||||
|
result.operation !== 'delete' ||
|
||||||
|
result.status !== PaperlessTaskStatus.Success
|
||||||
|
) {
|
||||||
|
this.showResult(result, pending?.success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showResult(
|
||||||
|
result: BulkOperationStatus,
|
||||||
|
success: Partial<Toast> = {}
|
||||||
|
) {
|
||||||
|
const succeeded = result.status === PaperlessTaskStatus.Success
|
||||||
|
this.show(result.task_id, {
|
||||||
|
content: succeeded
|
||||||
|
? $localize`Operation completed.`
|
||||||
|
: result.status === PaperlessTaskStatus.Revoked
|
||||||
|
? $localize`Operation revoked.`
|
||||||
|
: $localize`Operation failed. Some changes may already have been applied.`,
|
||||||
|
delay: succeeded ? 5000 : 10000,
|
||||||
|
level: succeeded ? ToastLevel.Success : ToastLevel.Error,
|
||||||
|
...(succeeded ? success : { error: result.error }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private show(taskId: string, toast: Toast) {
|
||||||
|
const canViewTasks = this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.PaperlessTask
|
||||||
|
)
|
||||||
|
this.toastService.show({
|
||||||
|
id: `task-${taskId}`,
|
||||||
|
...(canViewTasks
|
||||||
|
? {
|
||||||
|
actionName: $localize`View task`,
|
||||||
|
action: () =>
|
||||||
|
this.router.navigate(['/tasks'], {
|
||||||
|
queryParams: { task_id: taskId },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...toast,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,29 @@ describe('ToastService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('updates a notification by ID without duplicating or moving it', () => {
|
||||||
|
let notifications: Toast[] = []
|
||||||
|
toastService
|
||||||
|
.getToasts()
|
||||||
|
.subscribe((toasts) => (notifications = [...toasts]))
|
||||||
|
toastService.show({ id: 'task-1', content: 'Queued', delay: 5000 })
|
||||||
|
toastService.show({
|
||||||
|
id: 'other',
|
||||||
|
content: 'Another notification',
|
||||||
|
delay: 5000,
|
||||||
|
})
|
||||||
|
toastService.show({
|
||||||
|
id: 'task-1',
|
||||||
|
content: 'Completed',
|
||||||
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
})
|
||||||
|
expect(notifications.map((toast) => toast.content)).toEqual([
|
||||||
|
'Another notification',
|
||||||
|
'Completed',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('parses error string to object on show', () => {
|
it('parses error string to object on show', () => {
|
||||||
const toast = {
|
const toast = {
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
|
|||||||
@@ -75,7 +75,14 @@ export class ToastService {
|
|||||||
if (persistent) {
|
if (persistent) {
|
||||||
// the dropdown is open, so it's seen as it arrives
|
// the dropdown is open, so it's seen as it arrives
|
||||||
toast.read = !!this._suppressPopupToasts
|
toast.read = !!this._suppressPopupToasts
|
||||||
|
const index = this.toasts.findIndex(
|
||||||
|
(existing) => existing.id === toast.id
|
||||||
|
)
|
||||||
|
if (index === -1) {
|
||||||
this.toasts.unshift(toast)
|
this.toasts.unshift(toast)
|
||||||
|
} else {
|
||||||
|
this.toasts[index] = toast
|
||||||
|
}
|
||||||
this.toastsSubject.next(this.toasts)
|
this.toastsSubject.next(this.toasts)
|
||||||
}
|
}
|
||||||
// Popups are suppressed while the dropdown is open
|
// Popups are suppressed while the dropdown is open
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
HttpTestingController,
|
HttpTestingController,
|
||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
|
import { PaperlessTaskStatus } from '../data/paperless-task'
|
||||||
import { DocumentListViewService } from './document-list-view.service'
|
import { DocumentListViewService } from './document-list-view.service'
|
||||||
import { Toast, ToastService } from './toast.service'
|
import { TaskNotificationService } from './task-notification.service'
|
||||||
|
import { Toast, ToastLevel, ToastService } from './toast.service'
|
||||||
import { TrashService } from './trash.service'
|
import { TrashService } from './trash.service'
|
||||||
|
|
||||||
describe('TrashService', () => {
|
describe('TrashService', () => {
|
||||||
@@ -105,4 +107,52 @@ describe('TrashService', () => {
|
|||||||
expect(toast.content).toEqual('Moved 500 document(s) to the trash')
|
expect(toast.content).toEqual('Moved 500 document(s) to the trash')
|
||||||
expect(toast.action).toBeUndefined()
|
expect(toast.action).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers Undo only after the matching delete task succeeds', () => {
|
||||||
|
let toasts: Toast[] = []
|
||||||
|
toastService.getToasts().subscribe((t) => (toasts = [...t]))
|
||||||
|
service.trackMovedToTrash('delete-1', 2, [1, 2])
|
||||||
|
expect(toasts).toHaveLength(0)
|
||||||
|
const notifications = TestBed.inject(TaskNotificationService)
|
||||||
|
notifications.complete({
|
||||||
|
task_id: 'other-delete',
|
||||||
|
operation: 'delete',
|
||||||
|
status: PaperlessTaskStatus.Success,
|
||||||
|
})
|
||||||
|
expect(toasts).toHaveLength(0)
|
||||||
|
notifications.complete({
|
||||||
|
task_id: 'delete-1',
|
||||||
|
operation: 'delete',
|
||||||
|
status: PaperlessTaskStatus.Success,
|
||||||
|
})
|
||||||
|
expect(toasts).toHaveLength(1)
|
||||||
|
expect(toasts[0].actionName).toBe('Undo')
|
||||||
|
toasts[0].action()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}trash/`
|
||||||
|
)
|
||||||
|
expect(req.request.body).toEqual({ action: 'restore', documents: [1, 2] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles completion before the queue response and does not offer Undo on failure', () => {
|
||||||
|
let toasts: Toast[] = []
|
||||||
|
toastService.getToasts().subscribe((t) => (toasts = [...t]))
|
||||||
|
const notifications = TestBed.inject(TaskNotificationService)
|
||||||
|
notifications.complete({
|
||||||
|
task_id: 'delete-1',
|
||||||
|
operation: 'delete',
|
||||||
|
status: PaperlessTaskStatus.Success,
|
||||||
|
})
|
||||||
|
service.trackMovedToTrash('delete-1', 1, [1])
|
||||||
|
expect(toasts[0].actionName).toBe('Undo')
|
||||||
|
service.trackMovedToTrash('delete-2', 1, [2])
|
||||||
|
notifications.complete({
|
||||||
|
task_id: 'delete-2',
|
||||||
|
operation: 'delete',
|
||||||
|
status: PaperlessTaskStatus.Failure,
|
||||||
|
})
|
||||||
|
expect(toasts).toHaveLength(2)
|
||||||
|
expect(toasts[0].level).toBe(ToastLevel.Error)
|
||||||
|
expect(toasts[0].actionName).not.toBe('Undo')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -5,7 +5,8 @@ import { environment } from 'src/environments/environment'
|
|||||||
import { Document } from '../data/document'
|
import { Document } from '../data/document'
|
||||||
import { Results } from '../data/results'
|
import { Results } from '../data/results'
|
||||||
import { DocumentListViewService } from './document-list-view.service'
|
import { DocumentListViewService } from './document-list-view.service'
|
||||||
import { ToastLevel, ToastService } from './toast.service'
|
import { TaskNotificationService } from './task-notification.service'
|
||||||
|
import { Toast, ToastLevel, ToastService } from './toast.service'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -14,6 +15,22 @@ export class TrashService {
|
|||||||
private http = inject(HttpClient)
|
private http = inject(HttpClient)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private documentListViewService = inject(DocumentListViewService)
|
private documentListViewService = inject(DocumentListViewService)
|
||||||
|
private taskNotifications = inject(TaskNotificationService)
|
||||||
|
|
||||||
|
public trackMovedToTrash(
|
||||||
|
taskId: string,
|
||||||
|
count: number,
|
||||||
|
documents?: number[]
|
||||||
|
) {
|
||||||
|
this.taskNotifications.showQueued(
|
||||||
|
taskId,
|
||||||
|
$localize`Bulk operation queued.`,
|
||||||
|
this.movedToTrashToast(
|
||||||
|
$localize`Moved ${count} document(s) to the trash`,
|
||||||
|
documents
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public getTrash(page: number = 1): Observable<Results<Document>> {
|
public getTrash(page: number = 1): Observable<Results<Document>> {
|
||||||
const httpParams = new HttpParams().set('page', page.toString())
|
const httpParams = new HttpParams().set('page', page.toString())
|
||||||
@@ -43,14 +60,22 @@ export class TrashService {
|
|||||||
* Shows a toast with an Undo action when possible
|
* Shows a toast with an Undo action when possible
|
||||||
*/
|
*/
|
||||||
public notifyMovedToTrash(content: string, documents?: number[]) {
|
public notifyMovedToTrash(content: string, documents?: number[]) {
|
||||||
|
this.toastService.show(this.movedToTrashToast(content, documents))
|
||||||
|
}
|
||||||
|
|
||||||
|
private movedToTrashToast(content: string, documents?: number[]): Toast {
|
||||||
const canUndo = documents?.length > 0
|
const canUndo = documents?.length > 0
|
||||||
this.toastService.show({
|
return {
|
||||||
content,
|
content,
|
||||||
delay: 10000,
|
delay: 10000,
|
||||||
level: ToastLevel.Success,
|
level: ToastLevel.Success,
|
||||||
actionName: canUndo ? $localize`Undo` : undefined,
|
...(canUndo
|
||||||
action: canUndo ? () => this.undoMoveToTrash(documents) : undefined,
|
? {
|
||||||
})
|
actionName: $localize`Undo`,
|
||||||
|
action: () => this.undoMoveToTrash(documents),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private undoMoveToTrash(documents: number[]) {
|
private undoMoveToTrash(documents: number[]) {
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
"Detected a possible incompatible database column. See https://docs.paperless-ngx.com/troubleshooting/#convert-uuid-field",
|
"Detected a possible incompatible database column. See https://docs.paperless-ngx.com/troubleshooting/#convert-uuid-field",
|
||||||
)
|
)
|
||||||
logger.error(f"Error deleting documents: {e!s}")
|
logger.error(f"Error deleting documents: {e!s}")
|
||||||
|
raise
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -474,9 +475,9 @@ def run_workflow(
|
|||||||
"""
|
"""
|
||||||
Manually run a single workflow against the given documents.
|
Manually run a single workflow against the given documents.
|
||||||
|
|
||||||
Done asynchronously via a Celery task.
|
Called from the tracked bulk operation task.
|
||||||
"""
|
"""
|
||||||
run_workflows_for_documents.delay(
|
run_workflows_for_documents(
|
||||||
document_ids=doc_ids,
|
document_ids=doc_ids,
|
||||||
workflow_id=workflow,
|
workflow_id=workflow,
|
||||||
filter_documents=filter_documents,
|
filter_documents=filter_documents,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Queued document metadata edits."""
|
"""Queued document bulk operations."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from rest_framework.exceptions import PermissionDenied
|
|||||||
from documents import bulk_edit
|
from documents import bulk_edit
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from documents.models import PaperlessTask
|
||||||
from documents.permissions import has_document_operation_permissions
|
from documents.permissions import has_document_operation_permissions
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
|
|
||||||
@@ -25,7 +26,35 @@ MODIFIED_FIELDS = {
|
|||||||
"modify_custom_fields": "custom_fields",
|
"modify_custom_fields": "custom_fields",
|
||||||
"set_permissions": None,
|
"set_permissions": None,
|
||||||
}
|
}
|
||||||
QUEUED_OPERATIONS = frozenset(MODIFIED_FIELDS)
|
LEGACY_MODIFIED_FIELDS = {"delete": "deleted_at", "reprocess": "checksum"}
|
||||||
|
QUEUED_OPERATIONS = frozenset(
|
||||||
|
(
|
||||||
|
*MODIFIED_FIELDS,
|
||||||
|
"delete",
|
||||||
|
"reprocess",
|
||||||
|
"run_workflow",
|
||||||
|
"rotate",
|
||||||
|
"merge",
|
||||||
|
"merge_as_versions",
|
||||||
|
"split",
|
||||||
|
"delete_pages",
|
||||||
|
"edit_pdf",
|
||||||
|
"remove_password",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
USER_OPERATIONS = frozenset(
|
||||||
|
{
|
||||||
|
"split",
|
||||||
|
"merge",
|
||||||
|
"rotate",
|
||||||
|
"delete_pages",
|
||||||
|
"edit_pdf",
|
||||||
|
"remove_password",
|
||||||
|
"merge_as_versions",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# merge_as_versions doesn't queue any consume tasks
|
||||||
|
TRIGGER_SOURCE_OPERATIONS = USER_OPERATIONS - {"merge_as_versions"}
|
||||||
|
|
||||||
|
|
||||||
def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
|
def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
|
||||||
@@ -66,6 +95,7 @@ def execute_bulk_operation(
|
|||||||
document_ids: list[int],
|
document_ids: list[int],
|
||||||
parameters: dict,
|
parameters: dict,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
trigger_source: str = PaperlessTask.TriggerSource.API_UPLOAD,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if operation not in QUEUED_OPERATIONS:
|
if operation not in QUEUED_OPERATIONS:
|
||||||
raise ValueError(f"Unsupported bulk operation: {operation}")
|
raise ValueError(f"Unsupported bulk operation: {operation}")
|
||||||
@@ -79,6 +109,39 @@ def execute_bulk_operation(
|
|||||||
):
|
):
|
||||||
raise PermissionDenied("Insufficient permissions to execute bulk operation")
|
raise PermissionDenied("Insufficient permissions to execute bulk operation")
|
||||||
|
|
||||||
|
if operation == "run_workflow" and not user.has_perm("documents.view_workflow"):
|
||||||
|
raise PermissionDenied("Insufficient permissions to run this workflow")
|
||||||
|
|
||||||
|
if operation not in MODIFIED_FIELDS:
|
||||||
|
execution_parameters = parameters.copy()
|
||||||
|
if operation in USER_OPERATIONS:
|
||||||
|
execution_parameters["user"] = user
|
||||||
|
if operation in TRIGGER_SOURCE_OPERATIONS:
|
||||||
|
execution_parameters["trigger_source"] = trigger_source
|
||||||
|
modified_field = LEGACY_MODIFIED_FIELDS.get(operation)
|
||||||
|
with set_actor(user):
|
||||||
|
old_values = (
|
||||||
|
snapshot_field(document_ids, modified_field)
|
||||||
|
if settings.AUDIT_LOG_ENABLED and modified_field
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
method(document_ids, **execution_parameters)
|
||||||
|
if settings.AUDIT_LOG_ENABLED and modified_field:
|
||||||
|
from auditlog.models import LogEntry
|
||||||
|
|
||||||
|
new_values = snapshot_field(document_ids, modified_field)
|
||||||
|
for doc in Document.objects.filter(pk__in=document_ids):
|
||||||
|
LogEntry.objects.log_create(
|
||||||
|
instance=doc,
|
||||||
|
changes={
|
||||||
|
modified_field: [old_values[doc.pk], new_values[doc.pk]],
|
||||||
|
},
|
||||||
|
action=LogEntry.Action.UPDATE,
|
||||||
|
actor=user,
|
||||||
|
additional_data={"reason": f"Bulk edit: {operation}"},
|
||||||
|
)
|
||||||
|
return {"operation": operation, "document_count": len(document_ids)}
|
||||||
|
|
||||||
affected_ids: list[int] = []
|
affected_ids: list[int] = []
|
||||||
modified_field = MODIFIED_FIELDS[operation]
|
modified_field = MODIFIED_FIELDS[operation]
|
||||||
# Keep metadata writes and their audit entries together. Follow-up processing
|
# Keep metadata writes and their audit entries together. Follow-up processing
|
||||||
|
|||||||
@@ -789,7 +789,6 @@ def has_document_operation_permissions(
|
|||||||
bulk_edit.edit_pdf,
|
bulk_edit.edit_pdf,
|
||||||
bulk_edit.merge_as_versions,
|
bulk_edit.merge_as_versions,
|
||||||
bulk_edit.remove_password,
|
bulk_edit.remove_password,
|
||||||
bulk_edit.run_workflow,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
or (
|
or (
|
||||||
|
|||||||
@@ -1688,6 +1688,7 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin
|
|||||||
|
|
||||||
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
||||||
root_document_id = serializers.IntegerField(required=True)
|
root_document_id = serializers.IntegerField(required=True)
|
||||||
|
from_webui = serializers.BooleanField(required=False, default=False)
|
||||||
version_label = serializers.CharField(
|
version_label = serializers.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
allow_blank=True,
|
allow_blank=True,
|
||||||
@@ -1801,11 +1802,12 @@ class RemovePasswordDocumentsSerializer(
|
|||||||
|
|
||||||
|
|
||||||
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
||||||
pass
|
from_webui = serializers.BooleanField(required=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
||||||
remote_ocr = serializers.BooleanField(required=False, default=False)
|
remote_ocr = serializers.BooleanField(required=False, default=False)
|
||||||
|
from_webui = serializers.BooleanField(required=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
class BulkEditSerializer(
|
class BulkEditSerializer(
|
||||||
|
|||||||
@@ -1206,6 +1206,8 @@ def before_task_publish_handler(
|
|||||||
"document_count": len(task_kwargs["document_ids"]),
|
"document_count": len(task_kwargs["document_ids"]),
|
||||||
}
|
}
|
||||||
owner_id = task_kwargs["user_id"]
|
owner_id = task_kwargs["user_id"]
|
||||||
|
if task_kwargs["operation"] == "delete":
|
||||||
|
task_type = PaperlessTask.TaskType.BULK_DELETE
|
||||||
else:
|
else:
|
||||||
input_data = _extract_input_data(task_type, task_kwargs)
|
input_data = _extract_input_data(task_type, task_kwargs)
|
||||||
owner_id = _extract_owner_id(task_type, task_kwargs)
|
owner_id = _extract_owner_id(task_type, task_kwargs)
|
||||||
|
|||||||
@@ -703,12 +703,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.run_workflow")
|
@mock.patch("documents.views.bulk_edit.run_workflow")
|
||||||
def test_run_workflow_requires_ownership(self, m) -> None:
|
def test_run_workflow_requires_ownership(self, m) -> None:
|
||||||
"""A non-owner with change permission cannot run mutating workflow actions."""
|
"""A non-owner without document ownership is forbidden (actions mutate docs)."""
|
||||||
self.setup_mock(m, "run_workflow")
|
self.setup_mock(m, "run_workflow")
|
||||||
user = UserFactory(username="non_owner")
|
user = UserFactory(username="no_perms")
|
||||||
grant_all_global(user)
|
grant_all_global(user)
|
||||||
doc = DocumentFactory(owner=UserFactory(username="other"))
|
doc = DocumentFactory(owner=UserFactory(username="other"))
|
||||||
grant_object(user, doc, "change_document")
|
|
||||||
workflow = self._make_manual_workflow()
|
workflow = self._make_manual_workflow()
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
|||||||
@@ -698,8 +698,8 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
def test_delete_documents_old_uuid_field(self, m) -> None:
|
def test_delete_documents_old_uuid_field(self, m) -> None:
|
||||||
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
||||||
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
doc_ids = [self.doc1.id, self.doc2.id, self.doc3.id]
|
||||||
bulk_edit.delete(doc_ids)
|
|
||||||
with self.assertLogs(level="WARNING") as cm:
|
with self.assertLogs(level="WARNING") as cm:
|
||||||
|
with self.assertRaisesRegex(Exception, "Data too long for column"):
|
||||||
bulk_edit.delete(doc_ids)
|
bulk_edit.delete(doc_ids)
|
||||||
self.assertIn("possible incompatible database column", cm.output[0])
|
self.assertIn("possible incompatible database column", cm.output[0])
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ def test_request_only_queues(bulk_setup, endpoint, operation, parameters):
|
|||||||
"document_ids": [doc.pk],
|
"document_ids": [doc.pk],
|
||||||
"parameters": parameters,
|
"parameters": parameters,
|
||||||
"user_id": user.pk,
|
"user_id": user.pk,
|
||||||
|
"trigger_source": "web_ui",
|
||||||
}
|
}
|
||||||
assert enqueue.call_args.kwargs["headers"]["trigger_source"] == "web_ui"
|
assert enqueue.call_args.kwargs["headers"]["trigger_source"] == "web_ui"
|
||||||
|
|
||||||
@@ -102,6 +103,87 @@ def test_permissions_rechecked_on_worker(bulk_setup):
|
|||||||
assert doc.correspondent_id is None
|
assert doc.correspondent_id is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_document_action_runs_with_user_in_worker(bulk_setup):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
with mock.patch("documents.bulk_edit.rotate") as rotate:
|
||||||
|
result = execute_bulk_operation(
|
||||||
|
"rotate",
|
||||||
|
[doc.pk],
|
||||||
|
{"degrees": 90},
|
||||||
|
user.pk,
|
||||||
|
trigger_source="web_ui",
|
||||||
|
)
|
||||||
|
rotate.assert_called_once_with(
|
||||||
|
[doc.pk],
|
||||||
|
degrees=90,
|
||||||
|
user=user,
|
||||||
|
trigger_source="web_ui",
|
||||||
|
)
|
||||||
|
assert result == {"operation": "rotate", "document_count": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_document_action_queue_contains_only_serializable_parameters(bulk_setup):
|
||||||
|
user, client, doc, _ = bulk_setup
|
||||||
|
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
|
||||||
|
enqueue.return_value.id = "rotate-task"
|
||||||
|
response = client.post(
|
||||||
|
"/api/documents/rotate/",
|
||||||
|
{"documents": [doc.pk], "degrees": 90, "from_webui": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.data == {"result": "OK", "task_id": "rotate-task"}
|
||||||
|
assert enqueue.call_args.kwargs["kwargs"] == {
|
||||||
|
"operation": "rotate",
|
||||||
|
"document_ids": [doc.pk],
|
||||||
|
"parameters": {"degrees": 90, "source_mode": "latest_version"},
|
||||||
|
"user_id": user.pk,
|
||||||
|
"trigger_source": "web_ui",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_reprocess_keeps_bulk_audit_entry(bulk_setup, settings):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
settings.AUDIT_LOG_ENABLED = True
|
||||||
|
LogEntry.objects.all().delete()
|
||||||
|
with mock.patch("documents.bulk_edit.reprocess"):
|
||||||
|
execute_bulk_operation("reprocess", [doc.pk], {}, user.pk)
|
||||||
|
entry = LogEntry.objects.get_for_object(doc).get()
|
||||||
|
assert entry.changes_dict["checksum"] == [doc.checksum, doc.checksum]
|
||||||
|
assert entry.actor_id == user.pk
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_workflow_runs_in_tracked_task_instead_of_dispatching_another(bulk_setup):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
with mock.patch("documents.bulk_edit.run_workflows_for_documents") as run:
|
||||||
|
execute_bulk_operation(
|
||||||
|
"run_workflow",
|
||||||
|
[doc.pk],
|
||||||
|
{"workflow": 7, "filter_documents": False},
|
||||||
|
user.pk,
|
||||||
|
)
|
||||||
|
run.assert_called_once_with(
|
||||||
|
document_ids=[doc.pk],
|
||||||
|
workflow_id=7,
|
||||||
|
filter_documents=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_delete_failure_propagates_from_worker(bulk_setup):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
with mock.patch(
|
||||||
|
"documents.models.Document.delete",
|
||||||
|
side_effect=RuntimeError("delete failed"),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="delete failed"):
|
||||||
|
execute_bulk_operation("delete", [doc.pk], {}, user.pk)
|
||||||
|
assert Document.objects.filter(pk=doc.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_queue_failure_does_not_change_documents(bulk_setup):
|
def test_queue_failure_does_not_change_documents(bulk_setup):
|
||||||
_, client, doc, correspondent = bulk_setup
|
_, client, doc, correspondent = bulk_setup
|
||||||
@@ -162,6 +244,22 @@ def test_tracked_outcome_records_initiator_and_result(bulk_setup, failed):
|
|||||||
assert task.result_data == {"document_count": 1}
|
assert task.result_data == {"document_count": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_queued_delete_is_tracked_as_bulk_delete(bulk_setup):
|
||||||
|
user, _, doc, _ = bulk_setup
|
||||||
|
before_task_publish_handler(
|
||||||
|
headers={"task": execute_bulk_operation.name, "id": "delete-task"},
|
||||||
|
body=(
|
||||||
|
(),
|
||||||
|
{"operation": "delete", "document_ids": [doc.pk], "user_id": user.pk},
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
task = PaperlessTask.objects.get(task_id="delete-task")
|
||||||
|
assert task.task_type == PaperlessTask.TaskType.BULK_DELETE
|
||||||
|
assert task.owner_id == user.pk
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_large_filtered_selection_is_one_task(bulk_setup):
|
def test_large_filtered_selection_is_one_task(bulk_setup):
|
||||||
_, client, _, _ = bulk_setup
|
_, client, _, _ = bulk_setup
|
||||||
@@ -284,24 +382,46 @@ async def test_completion_socket_is_private(anyio_backend):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.parametrize("endpoint", ["delete", "bulk_edit"])
|
def test_legacy_trash_queues_without_deleting_in_request(bulk_setup):
|
||||||
def test_trash_remains_synchronous(bulk_setup, endpoint):
|
user, client, doc, _ = bulk_setup
|
||||||
_, client, doc, _ = bulk_setup
|
data = {"documents": [doc.pk], "method": "delete", "parameters": {}}
|
||||||
data = {"documents": [doc.pk]}
|
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
|
||||||
if endpoint == "bulk_edit":
|
enqueue.return_value.id = "delete-task"
|
||||||
data.update(method="delete", parameters={})
|
|
||||||
with (
|
|
||||||
mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue,
|
|
||||||
mock.patch("documents.search.get_backend"),
|
|
||||||
):
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/documents/{endpoint}/",
|
"/api/documents/bulk_edit/",
|
||||||
data,
|
data,
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_ACCEPT="application/json; version=9",
|
HTTP_ACCEPT="application/json; version=9",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data == {"result": "OK"}
|
assert response.data == {"result": "OK", "task_id": "delete-task"}
|
||||||
assert not Document.objects.filter(pk=doc.pk).exists()
|
assert Document.objects.filter(pk=doc.pk).exists()
|
||||||
assert Document.deleted_objects.filter(pk=doc.pk).exists()
|
assert enqueue.call_args.kwargs["kwargs"] == {
|
||||||
enqueue.assert_not_called()
|
"operation": "delete",
|
||||||
|
"document_ids": [doc.pk],
|
||||||
|
"parameters": {},
|
||||||
|
"user_id": user.pk,
|
||||||
|
"trigger_source": "api_upload",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dedicated_trash_queues_without_deleting_in_request(bulk_setup):
|
||||||
|
user, client, doc, _ = bulk_setup
|
||||||
|
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
|
||||||
|
enqueue.return_value.id = "delete-task"
|
||||||
|
response = client.post(
|
||||||
|
"/api/documents/delete/",
|
||||||
|
{"documents": [doc.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == {"result": "OK", "task_id": "delete-task"}
|
||||||
|
assert Document.objects.filter(pk=doc.pk).exists()
|
||||||
|
assert enqueue.call_args.kwargs["kwargs"] == {
|
||||||
|
"operation": "delete",
|
||||||
|
"document_ids": [doc.pk],
|
||||||
|
"parameters": {},
|
||||||
|
"user_id": user.pk,
|
||||||
|
"trigger_source": "api_upload",
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.bulk_edit import merge_as_versions
|
from documents.bulk_edit import merge_as_versions
|
||||||
|
from documents.bulk_operations import execute_bulk_operation
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
@@ -34,6 +35,7 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
|||||||
{
|
{
|
||||||
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
||||||
"root_document_id": self.doc2.id,
|
"root_document_id": self.doc2.id,
|
||||||
|
"from_webui": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -348,6 +350,17 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
|||||||
owner=self.user,
|
owner=self.user,
|
||||||
)
|
)
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
enqueue = mock.patch(
|
||||||
|
"documents.views.execute_bulk_operation.apply_async",
|
||||||
|
side_effect=lambda *, kwargs, headers: self.run_operation(kwargs),
|
||||||
|
)
|
||||||
|
enqueue.start()
|
||||||
|
self.addCleanup(enqueue.stop)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def run_operation(kwargs):
|
||||||
|
execute_bulk_operation(**kwargs)
|
||||||
|
return mock.Mock(id="test-bulk-operation")
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||||
def test_merges_documents_as_versions(self, merge_mock) -> None:
|
def test_merges_documents_as_versions(self, merge_mock) -> None:
|
||||||
@@ -367,7 +380,10 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data, {"result": "OK"})
|
self.assertEqual(
|
||||||
|
response.data,
|
||||||
|
{"result": "OK", "task_id": "test-bulk-operation"},
|
||||||
|
)
|
||||||
merge_mock.assert_called_once_with(
|
merge_mock.assert_called_once_with(
|
||||||
[self.doc1.id, self.doc2.id],
|
[self.doc1.id, self.doc2.id],
|
||||||
root_document_id=self.doc2.id,
|
root_document_id=self.doc2.id,
|
||||||
|
|||||||
+29
-75
@@ -107,9 +107,9 @@ from documents import bulk_edit
|
|||||||
from documents.bulk_download import ArchiveOnlyStrategy
|
from documents.bulk_download import ArchiveOnlyStrategy
|
||||||
from documents.bulk_download import OriginalAndArchiveStrategy
|
from documents.bulk_download import OriginalAndArchiveStrategy
|
||||||
from documents.bulk_download import OriginalsOnlyStrategy
|
from documents.bulk_download import OriginalsOnlyStrategy
|
||||||
from documents.bulk_operations import QUEUED_OPERATIONS
|
from documents.bulk_operations import TRIGGER_SOURCE_OPERATIONS
|
||||||
|
from documents.bulk_operations import USER_OPERATIONS
|
||||||
from documents.bulk_operations import execute_bulk_operation
|
from documents.bulk_operations import execute_bulk_operation
|
||||||
from documents.bulk_operations import snapshot_field
|
|
||||||
from documents.caching import get_llm_suggestion_cache
|
from documents.caching import get_llm_suggestion_cache
|
||||||
from documents.caching import get_metadata_cache
|
from documents.caching import get_metadata_cache
|
||||||
from documents.caching import get_suggestion_cache
|
from documents.caching import get_suggestion_cache
|
||||||
@@ -2974,38 +2974,30 @@ class DocumentSelectionMixin:
|
|||||||
class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
parser_classes = (parsers.JSONParser,)
|
parser_classes = (parsers.JSONParser,)
|
||||||
METHOD_NAMES_REQUIRING_USER = {
|
|
||||||
"split",
|
|
||||||
"merge",
|
|
||||||
"rotate",
|
|
||||||
"delete_pages",
|
|
||||||
"edit_pdf",
|
|
||||||
"remove_password",
|
|
||||||
"merge_as_versions",
|
|
||||||
}
|
|
||||||
# merge_as_versions doesn't queue any consume tasks
|
|
||||||
METHOD_NAMES_REQUIRING_TRIGGER_SOURCE = METHOD_NAMES_REQUIRING_USER - {
|
|
||||||
"merge_as_versions",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _queue_bulk_operation(self, method, documents, parameters, from_webui):
|
def _queue_bulk_operation(self, method, documents, parameters, from_webui):
|
||||||
if "update_callback" in parameters:
|
if "update_callback" in parameters:
|
||||||
raise ValueError("Unsupported bulk operation parameter: update_callback")
|
raise ValueError("Unsupported bulk operation parameter: update_callback")
|
||||||
signature(method).bind(documents, **parameters)
|
trigger_source = (
|
||||||
|
PaperlessTask.TriggerSource.WEB_UI
|
||||||
|
if from_webui
|
||||||
|
else PaperlessTask.TriggerSource.API_UPLOAD
|
||||||
|
)
|
||||||
|
final_parameters = parameters.copy()
|
||||||
|
if method.__name__ in USER_OPERATIONS:
|
||||||
|
final_parameters["user"] = self.request.user
|
||||||
|
if method.__name__ in TRIGGER_SOURCE_OPERATIONS:
|
||||||
|
final_parameters["trigger_source"] = trigger_source
|
||||||
|
signature(method).bind(documents, **final_parameters)
|
||||||
task = execute_bulk_operation.apply_async(
|
task = execute_bulk_operation.apply_async(
|
||||||
kwargs={
|
kwargs={
|
||||||
"operation": method.__name__,
|
"operation": method.__name__,
|
||||||
"document_ids": documents,
|
"document_ids": documents,
|
||||||
"parameters": parameters,
|
"parameters": parameters,
|
||||||
"user_id": self.request.user.pk,
|
"user_id": self.request.user.pk,
|
||||||
|
"trigger_source": trigger_source,
|
||||||
},
|
},
|
||||||
headers={
|
headers={"trigger_source": trigger_source},
|
||||||
"trigger_source": (
|
|
||||||
PaperlessTask.TriggerSource.WEB_UI
|
|
||||||
if from_webui
|
|
||||||
else PaperlessTask.TriggerSource.API_UPLOAD
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return Response({"result": "OK", "task_id": task.id})
|
return Response({"result": "OK", "task_id": task.id})
|
||||||
|
|
||||||
@@ -3129,15 +3121,6 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
user = self.request.user
|
user = self.request.user
|
||||||
from_webui = validated_data.get("from_webui", False)
|
from_webui = validated_data.get("from_webui", False)
|
||||||
|
|
||||||
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
|
|
||||||
parameters["user"] = user
|
|
||||||
if method.__name__ in self.METHOD_NAMES_REQUIRING_TRIGGER_SOURCE:
|
|
||||||
parameters["trigger_source"] = (
|
|
||||||
PaperlessTask.TriggerSource.WEB_UI
|
|
||||||
if from_webui
|
|
||||||
else PaperlessTask.TriggerSource.API_UPLOAD
|
|
||||||
)
|
|
||||||
|
|
||||||
if not has_document_operation_permissions(
|
if not has_document_operation_permissions(
|
||||||
user=user,
|
user=user,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
@@ -3147,8 +3130,12 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = method(documents, **parameters)
|
return self._queue_bulk_operation(
|
||||||
return Response({"result": result})
|
method,
|
||||||
|
documents,
|
||||||
|
parameters,
|
||||||
|
from_webui,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"An error occurred performing {operation_label}: {e!s}")
|
logger.warning(f"An error occurred performing {operation_label}: {e!s}")
|
||||||
return HttpResponseBadRequest(
|
return HttpResponseBadRequest(
|
||||||
@@ -3169,19 +3156,13 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
name="BulkEditDocumentsResult",
|
name="BulkEditDocumentsResult",
|
||||||
fields={
|
fields={
|
||||||
"result": serializers.CharField(),
|
"result": serializers.CharField(),
|
||||||
"task_id": serializers.CharField(required=False),
|
"task_id": serializers.CharField(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
class BulkEditView(DocumentOperationPermissionMixin):
|
class BulkEditView(DocumentOperationPermissionMixin):
|
||||||
LEGACY_MODIFIED_FIELDS = {
|
|
||||||
"delete": "deleted_at",
|
|
||||||
"reprocess": "checksum",
|
|
||||||
"remove_password": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
serializer_class = BulkEditSerializer
|
serializer_class = BulkEditSerializer
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
@@ -3211,14 +3192,6 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
user=user,
|
user=user,
|
||||||
validated_data=serializer.validated_data,
|
validated_data=serializer.validated_data,
|
||||||
)
|
)
|
||||||
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
|
|
||||||
parameters["user"] = user
|
|
||||||
if method.__name__ in self.METHOD_NAMES_REQUIRING_TRIGGER_SOURCE:
|
|
||||||
parameters["trigger_source"] = (
|
|
||||||
PaperlessTask.TriggerSource.WEB_UI
|
|
||||||
if from_webui
|
|
||||||
else PaperlessTask.TriggerSource.API_UPLOAD
|
|
||||||
)
|
|
||||||
if not has_document_operation_permissions(
|
if not has_document_operation_permissions(
|
||||||
user=user,
|
user=user,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
@@ -3228,38 +3201,12 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if method.__name__ in QUEUED_OPERATIONS:
|
|
||||||
return self._queue_bulk_operation(
|
return self._queue_bulk_operation(
|
||||||
method,
|
method,
|
||||||
documents,
|
documents,
|
||||||
parameters,
|
parameters,
|
||||||
from_webui,
|
from_webui,
|
||||||
)
|
)
|
||||||
modified_field = self.LEGACY_MODIFIED_FIELDS.get(method.__name__)
|
|
||||||
if settings.AUDIT_LOG_ENABLED and modified_field:
|
|
||||||
old_values = snapshot_field(documents, modified_field)
|
|
||||||
|
|
||||||
result = method(documents, **parameters)
|
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED and modified_field:
|
|
||||||
new_values = snapshot_field(documents, modified_field)
|
|
||||||
for doc in Document.objects.filter(pk__in=documents):
|
|
||||||
LogEntry.objects.log_create(
|
|
||||||
instance=doc,
|
|
||||||
changes={
|
|
||||||
modified_field: [
|
|
||||||
old_values[doc.pk],
|
|
||||||
new_values[doc.pk],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
action=LogEntry.Action.UPDATE,
|
|
||||||
actor=user,
|
|
||||||
additional_data={
|
|
||||||
"reason": f"Bulk edit: {method.__name__}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response({"result": result})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"An error occurred performing bulk edit: {e!s}")
|
logger.warning(f"An error occurred performing bulk edit: {e!s}")
|
||||||
return HttpResponseBadRequest(
|
return HttpResponseBadRequest(
|
||||||
@@ -3276,6 +3223,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
name="RotateDocumentsResult",
|
name="RotateDocumentsResult",
|
||||||
fields={
|
fields={
|
||||||
"result": serializers.CharField(),
|
"result": serializers.CharField(),
|
||||||
|
"task_id": serializers.CharField(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -3303,6 +3251,7 @@ class RotateDocumentsView(DocumentOperationPermissionMixin):
|
|||||||
name="MergeDocumentsResult",
|
name="MergeDocumentsResult",
|
||||||
fields={
|
fields={
|
||||||
"result": serializers.CharField(),
|
"result": serializers.CharField(),
|
||||||
|
"task_id": serializers.CharField(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -3330,6 +3279,7 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
|
|||||||
name="MergeDocumentsAsVersionsResult",
|
name="MergeDocumentsAsVersionsResult",
|
||||||
fields={
|
fields={
|
||||||
"result": serializers.CharField(),
|
"result": serializers.CharField(),
|
||||||
|
"task_id": serializers.CharField(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -3357,6 +3307,7 @@ class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
|
|||||||
name="DeleteDocumentsResult",
|
name="DeleteDocumentsResult",
|
||||||
fields={
|
fields={
|
||||||
"result": serializers.CharField(),
|
"result": serializers.CharField(),
|
||||||
|
"task_id": serializers.CharField(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -3384,6 +3335,7 @@ class DeleteDocumentsView(DocumentOperationPermissionMixin):
|
|||||||
name="ReprocessDocumentsResult",
|
name="ReprocessDocumentsResult",
|
||||||
fields={
|
fields={
|
||||||
"result": serializers.CharField(),
|
"result": serializers.CharField(),
|
||||||
|
"task_id": serializers.CharField(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -3411,6 +3363,7 @@ class ReprocessDocumentsView(DocumentOperationPermissionMixin):
|
|||||||
name="EditPdfDocumentsResult",
|
name="EditPdfDocumentsResult",
|
||||||
fields={
|
fields={
|
||||||
"result": serializers.CharField(),
|
"result": serializers.CharField(),
|
||||||
|
"task_id": serializers.CharField(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -3438,6 +3391,7 @@ class EditPdfDocumentsView(DocumentOperationPermissionMixin):
|
|||||||
name="RemovePasswordDocumentsResult",
|
name="RemovePasswordDocumentsResult",
|
||||||
fields={
|
fields={
|
||||||
"result": serializers.CharField(),
|
"result": serializers.CharField(),
|
||||||
|
"task_id": serializers.CharField(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user