Compare commits

..
36 changed files with 474 additions and 1029 deletions

No files matched your search

-6
View File
@@ -288,12 +288,6 @@ 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],
+71 -60
View File
@@ -1940,6 +1940,10 @@
<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>
@@ -3424,19 +3428,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">1019</context> <context context-type="linenumber">1034</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">1058</context> <context context-type="linenumber">1073</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">1081</context> <context context-type="linenumber">1096</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">1117</context> <context context-type="linenumber">1132</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>
@@ -4199,27 +4203,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">637</context> <context context-type="linenumber">652</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">677</context> <context context-type="linenumber">692</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">715</context> <context context-type="linenumber">730</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">753</context> <context context-type="linenumber">768</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">815</context> <context context-type="linenumber">830</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">953</context> <context context-type="linenumber">968</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6088768251338567957" datatype="html"> <trans-unit id="6088768251338567957" datatype="html">
@@ -6668,7 +6672,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">957</context> <context context-type="linenumber">972</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1616074213645042277" datatype="html"> <trans-unit id="1616074213645042277" datatype="html">
@@ -8445,7 +8449,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">582</context> <context context-type="linenumber">597</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>
@@ -8774,7 +8778,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">318</context> <context context-type="linenumber">320</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7049887240439736400" datatype="html"> <trans-unit id="7049887240439736400" datatype="html">
@@ -9261,7 +9265,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">955</context> <context context-type="linenumber">970</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5992101894268263750" datatype="html"> <trans-unit id="5992101894268263750" datatype="html">
@@ -9286,7 +9290,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">1015</context> <context context-type="linenumber">1030</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2951161989614003846" datatype="html"> <trans-unit id="2951161989614003846" datatype="html">
@@ -9336,7 +9340,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">322</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="523213570140181690" datatype="html"> <trans-unit id="523213570140181690" datatype="html">
@@ -9701,7 +9705,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">1115</context> <context context-type="linenumber">1130</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7251533806673754434" datatype="html"> <trans-unit id="7251533806673754434" datatype="html">
@@ -9736,46 +9740,53 @@
<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">319</context> <context context-type="linenumber">321</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">358</context> <context context-type="linenumber">373</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">362</context> <context context-type="linenumber">377</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">414</context> <context context-type="linenumber">429</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7894972847287473517" datatype="html"> <trans-unit id="7894972847287473517" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</source> <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</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">574</context> <context context-type="linenumber">589</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">580</context> <context context-type="linenumber">595</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8639884465898458690" datatype="html"> <trans-unit id="8639884465898458690" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</source> <source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</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">576</context> <context context-type="linenumber">591</context>
</context-group> </context-group>
<note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note> <note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note>
</trans-unit> </trans-unit>
@@ -9783,7 +9794,7 @@
<source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</source> <source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</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">584,586</context> <context context-type="linenumber">599,601</context>
</context-group> </context-group>
<note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note> <note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note>
</trans-unit> </trans-unit>
@@ -9791,14 +9802,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">601</context> <context context-type="linenumber">616</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 &quot;<x id="PH" equiv-text="tag.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; 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">607</context> <context context-type="linenumber">622</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1894412783609570695" datatype="html"> <trans-unit id="1894412783609570695" datatype="html">
@@ -9807,14 +9818,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">612,614</context> <context context-type="linenumber">627,629</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 &quot;<x id="PH" equiv-text="tag.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; 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">620</context> <context context-type="linenumber">635</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3819792277998068944" datatype="html"> <trans-unit id="3819792277998068944" datatype="html">
@@ -9823,7 +9834,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">625,627</context> <context context-type="linenumber">640,642</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2739066218579571288" datatype="html"> <trans-unit id="2739066218579571288" datatype="html">
@@ -9834,84 +9845,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">629,633</context> <context context-type="linenumber">644,648</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">670</context> <context context-type="linenumber">685</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 &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; 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">672</context> <context context-type="linenumber">687</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">674</context> <context context-type="linenumber">689</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">708</context> <context context-type="linenumber">723</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 &quot;<x id="PH" equiv-text="documentType.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; 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">710</context> <context context-type="linenumber">725</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">712</context> <context context-type="linenumber">727</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">746</context> <context context-type="linenumber">761</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 &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; 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">748</context> <context context-type="linenumber">763</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">750</context> <context context-type="linenumber">765</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">779</context> <context context-type="linenumber">794</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 &quot;<x id="PH" equiv-text="customField.name"/>&quot; to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will assign the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; 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">785</context> <context context-type="linenumber">800</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5789455969634598553" datatype="html"> <trans-unit id="5789455969634598553" datatype="html">
@@ -9920,14 +9931,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">790,792</context> <context context-type="linenumber">805,807</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 &quot;<x id="PH" equiv-text="customField.name"/>&quot; from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source> <source>This operation will remove the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; 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">798</context> <context context-type="linenumber">813</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6666899594015948817" datatype="html"> <trans-unit id="6666899594015948817" datatype="html">
@@ -9936,7 +9947,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">803,805</context> <context context-type="linenumber">818,820</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8050047262594964176" datatype="html"> <trans-unit id="8050047262594964176" datatype="html">
@@ -9947,105 +9958,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">807,811</context> <context context-type="linenumber">822,826</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">954</context> <context context-type="linenumber">969</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">981</context> <context context-type="linenumber">996</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">1016</context> <context context-type="linenumber">1031</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">1017</context> <context context-type="linenumber">1032</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">1055</context> <context context-type="linenumber">1070</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">1056</context> <context context-type="linenumber">1071</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">1079</context> <context context-type="linenumber">1094</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">1080</context> <context context-type="linenumber">1095</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">1103</context> <context context-type="linenumber">1118</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">1116</context> <context context-type="linenumber">1131</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">1131</context> <context context-type="linenumber">1146</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">1161</context> <context context-type="linenumber">1176</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">1209</context> <context context-type="linenumber">1224</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">1216</context> <context context-type="linenumber">1231</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6307402210351946694" datatype="html"> <trans-unit id="6307402210351946694" datatype="html">
@@ -33,7 +33,6 @@ 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'
@@ -199,14 +198,14 @@ describe('AppFrameComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('refreshes and updates the task notification on completion', () => { it('refreshes and notifies on completion without client-side task tracking', () => {
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, 'show') const success = jest.spyOn(toastService, 'showSuccess')
const refresh = jest const refresh = jest
.spyOn(openDocumentsService, 'refreshDocument') .spyOn(openDocumentsService, 'refreshDocument')
.mockImplementation(() => {}) .mockImplementation(() => {})
@@ -231,12 +230,7 @@ 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( expect(success).toHaveBeenCalledWith('Bulk operation completed.')
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',
@@ -251,14 +245,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, 'show') const error = jest.spyOn(toastService, 'showError')
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: `${status}-task`, task_id: 'bulk-task',
operation: 'set_permissions', operation: 'set_permissions',
status, status,
error: error:
@@ -268,36 +262,11 @@ 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(
expect.objectContaining({ 'Bulk operation revoked.',
content: 'Task expired before execution.'
'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,7 +57,6 @@ 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'
@@ -116,7 +115,6 @@ 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)
@@ -184,7 +182,7 @@ export class AppFrameComponent
), ),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe((result) => { .subscribe(({ status, error }) => {
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
@@ -200,7 +198,16 @@ export class AppFrameComponent
) { ) {
this.tasksService.reload() this.tasksService.reload()
} }
this.taskNotifications.complete(result) if (status === PaperlessTaskStatus.Success) {
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,7 +7,6 @@
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);
@@ -24,13 +23,6 @@
.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,36 +65,6 @@ 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,10 +5,8 @@ import {
DestroyRef, DestroyRef,
EventEmitter, EventEmitter,
Input, Input,
OnChanges,
OnInit, OnInit,
Output, Output,
SimpleChanges,
ViewChild, ViewChild,
inject, inject,
signal, signal,
@@ -36,7 +34,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, OnChanges { export class ToastComponent implements OnInit {
private clipboard = inject(Clipboard) private clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe) private customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef) private destroyRef = inject(DestroyRef)
@@ -71,12 +69,6 @@ export class ToastComponent implements OnInit, OnChanges {
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'])
// Updating a visible toast preserves its position without duplicating it. // showing a visible toast again moves it to the top without duplicating
toastSubject.next(toasts[1]) toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1']) expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
}) })
it('should remove a popup when it hides without closing it', () => { it('should remove a popup when it hides without closing it', () => {
@@ -32,11 +32,10 @@ export class ToastsComponent {
return return
} }
this.toasts.update((toasts) => this.toasts.update((toasts) =>
toasts.some((existing) => existing.id === toast.id) [toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
? toasts.map((existing) => 0,
existing.id === toast.id ? toast : existing MAX_VISIBLE_TOASTS
) )
: [toast, ...toasts].slice(0, MAX_VISIBLE_TOASTS)
) )
}) })
} }
@@ -1013,45 +1013,30 @@ describe('DocumentDetailComponent', () => {
]) ])
}) })
it('should queue reprocess, show the task link and close the modal', () => { it('should support reprocess, confirm and close modal after started', () => {
jest.useFakeTimers()
initNormally() initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments') const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-1' })) reprocessSpy.mockReturnValue(of(true))
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, 'show') const toastSpy = jest.spyOn(toastService, 'showInfo')
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).not.toHaveBeenCalled() expect(toastSpy).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({ result: 'OK', task_id: 'workflow-1' })) bulkEditSpy.mockReturnValue(of(true))
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, 'show') const toastSpy = jest.spyOn(toastService, 'showInfo')
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)
@@ -1063,18 +1048,14 @@ describe('DocumentDetailComponent', () => {
'run_workflow', 'run_workflow',
{ workflow: 5, filter_documents: false } { workflow: 5, filter_documents: false }
) )
jest.advanceTimersByTime(400) expect(toastSpy).toHaveBeenCalled()
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({ result: 'OK', task_id: 'workflow-2' })) bulkEditSpy.mockReturnValue(of(true))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.runWorkflow() component.runWorkflow()
@@ -1091,7 +1072,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({ result: 'OK', task_id: 'reprocess-2' })) reprocessSpy.mockReturnValue(of(true))
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.reprocess() component.reprocess()
@@ -2020,12 +2001,10 @@ 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()
@@ -2044,7 +2023,6 @@ 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()
@@ -2057,21 +2035,13 @@ describe('DocumentDetailComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/edit_pdf/` `${environment.apiBaseUrl}documents/edit_pdf/`
) )
req.flush({ result: 'OK', task_id: 'edit-pdf-1' }) req.flush(true)
jest.advanceTimersByTime(400) expect(closeSpy).toHaveBeenCalled()
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'
@@ -2092,15 +2062,8 @@ 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({ result: 'OK', task_id: 'remove-password-1' }) req.flush(true)
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', () => {
@@ -2152,9 +2115,9 @@ describe('DocumentDetailComponent', () => {
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/remove_password/` `${environment.apiBaseUrl}documents/remove_password/`
) )
req.flush({ result: 'OK', task_id: 'remove-password-2' }) req.flush(true)
expect(refreshSpy).not.toHaveBeenCalled() expect(refreshSpy).toHaveBeenCalledWith(doc.id)
}) })
it('should support keyboard shortcuts', () => { it('should support keyboard shortcuts', () => {
@@ -97,7 +97,6 @@ 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'
@@ -227,7 +226,6 @@ 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)
@@ -1560,11 +1558,9 @@ export class DocumentDetailComponent
modal.componentInstance.remoteOcr modal.componentInstance.remoteOcr
) )
.subscribe({ .subscribe({
next: ({ task_id }) => { next: () => {
this.taskNotifications.showQueued( this.toastService.showInfo(
task_id, $localize`Reprocess operation for "${this.document().title}" will begin in the background.`
$localize`Document reprocessing queued.`,
{ content: $localize`Document reprocessing completed.` }
) )
if (modal) { if (modal) {
modal.close() modal.close()
@@ -1601,13 +1597,9 @@ export class DocumentDetailComponent
filter_documents: !modal.componentInstance.force, filter_documents: !modal.componentInstance.force,
}) })
.subscribe({ .subscribe({
next: ({ task_id }) => { next: () => {
this.taskNotifications.showQueued( this.toastService.showInfo(
task_id, $localize`Workflow will run on "${this.document().title}" in the background.`
$localize`Workflow queued.`,
{
content: $localize`Workflow completed.`,
}
) )
modal.close() modal.close()
}, },
@@ -1997,15 +1989,14 @@ export class DocumentDetailComponent
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: ({ task_id }) => { next: () => {
this.taskNotifications.showQueued( this.toastService.showInfo(
task_id, $localize`PDF edit operation for "${this.document().title}" will begin in the background.`
$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) {
@@ -2055,14 +2046,17 @@ export class DocumentDetailComponent
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: ({ task_id }) => { next: () => {
this.taskNotifications.showQueued( this.toastService.showInfo(
task_id, $localize`Password removal operation for "${this.document().title}" will begin in the background.`
$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)
@@ -2,7 +2,6 @@ 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'
@@ -34,7 +33,7 @@ describe('DocumentVersionDropdownComponent', () => {
> >
> >
let toastService: jest.Mocked< let toastService: jest.Mocked<
Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'> Pick<ToastService, '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 }>
@@ -55,7 +54,6 @@ 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(),
@@ -90,10 +88,6 @@ describe('DocumentVersionDropdownComponent', () => {
provide: PermissionsService, provide: PermissionsService,
useValue: permissionsService, useValue: permissionsService,
}, },
{
provide: Router,
useValue: { navigate: jest.fn() },
},
{ {
provide: WebsocketStatusService, provide: WebsocketStatusService,
useValue: { useValue: {
@@ -383,8 +377,7 @@ describe('DocumentVersionDropdownComponent', () => {
expect(component.versionLabelDraft).toEqual('') expect(component.versionLabelDraft).toEqual('')
}) })
it('addExistingDocumentAsVersion should queue with a label and wait to refresh versions', () => { it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
jest.useFakeTimers()
const confirmClicked = new Subject<number>() const confirmClicked = new Subject<number>()
const modal = { const modal = {
componentInstance: { componentInstance: {
@@ -395,9 +388,14 @@ describe('DocumentVersionDropdownComponent', () => {
close: jest.fn(), close: jest.fn(),
} }
modalService.open.mockReturnValue(modal as any) modalService.open.mockReturnValue(modal as any)
documentService.mergeDocumentsAsVersions.mockReturnValue( documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
of({ result: 'OK', task_id: 'versions-1' }) // Newest first, as the API returns them. The merged document has a lower id
) // 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')
@@ -412,18 +410,11 @@ describe('DocumentVersionDropdownComponent', () => {
'Imported' 'Imported'
) )
expect(documentService.updateVersionLabel).not.toHaveBeenCalled() expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
expect(documentService.getVersions).not.toHaveBeenCalled() expect(documentService.getVersions).toHaveBeenCalledWith(3)
expect(versionsEmitSpy).not.toHaveBeenCalled() expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).not.toHaveBeenCalled() expect(selectedEmitSpy).toHaveBeenCalledWith(2)
expect(component.newVersionLabel).toEqual('') expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled() expect(modal.close).toHaveBeenCalled()
jest.advanceTimersByTime(400) expect(toastService.showSuccess).toHaveBeenCalled()
expect(toastService.show).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Existing document queued to be added as a version.',
actionName: 'View task',
})
)
jest.useRealTimers()
}) })
}) })
@@ -28,7 +28,6 @@ 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,
@@ -78,7 +77,6 @@ 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>()
@@ -307,19 +305,23 @@ 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: ({ task_id }) => { next: (document) => {
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.taskNotifications.showQueued( this.toastService.showSuccess(
task_id, $localize`Existing document added as a version.`
$localize`Existing document queued to be added as a version.`,
{ content: $localize`Existing document added as a version.` }
) )
}, },
error: (error) => { error: (error) => {
@@ -686,6 +686,8 @@ 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)
@@ -712,7 +714,12 @@ describe('BulkEditorComponent', () => {
workflow: 5, workflow: 5,
filter_documents: false, filter_documents: false,
}) })
req.flush(true) req.flush({ result: 'OK' })
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
@@ -721,6 +728,27 @@ 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]))
@@ -1239,15 +1267,14 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/delete/`
) )
const trackSpy = jest const notifySpy = jest
.spyOn(trashService, 'trackMovedToTrash') .spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {}) .mockImplementation(() => {})
req.flush({ result: 'OK', task_id: 'delete-1' }) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
from_webui: true,
}) })
expect(trackSpy).toHaveBeenCalledWith('delete-1', 2, [3, 4]) expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [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
@@ -1264,9 +1291,9 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/` `${environment.apiBaseUrl}documents/delete/`
) )
req.flush({ result: 'OK', task_id: 'delete-2' }) req.flush(true)
// 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(trackSpy).toHaveBeenLastCalledWith('delete-2', 0, null) expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), 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
@@ -1346,11 +1373,10 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/reprocess/` `${environment.apiBaseUrl}documents/reprocess/`
) )
req.flush({ result: 'OK', task_id: 'reprocess-1' }) req.flush(true)
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`
@@ -1381,12 +1407,11 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/rotate/` `${environment.apiBaseUrl}documents/rotate/`
) )
req.flush({ result: 'OK', task_id: 'rotate-1' }) req.flush(true)
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`
@@ -1424,11 +1449,10 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/merge/`
) )
req.flush({ result: 'OK', task_id: 'merge-1' }) req.flush(true)
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`
@@ -1443,12 +1467,11 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/merge/`
) )
req.flush({ result: 'OK', task_id: 'merge-2' }) req.flush(true)
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`
@@ -1465,12 +1488,11 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/` `${environment.apiBaseUrl}documents/merge/`
) )
req.flush({ result: 'OK', task_id: 'merge-3' }) req.flush(true)
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`
@@ -1482,7 +1504,6 @@ 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)
@@ -1510,8 +1531,8 @@ describe('BulkEditorComponent', () => {
.mockReturnValue(true) .mockReturnValue(true)
const mergeAsVersionsSpy = jest const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions') .spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of({ result: 'OK', task_id: 'versions-1' })) .mockReturnValue(of(true))
const toastSpy = jest.spyOn(toastService, 'show') const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
fixture.detectChanges() fixture.detectChanges()
component.mergeSelectedAsVersions() component.mergeSelectedAsVersions()
@@ -1527,11 +1548,9 @@ 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)
jest.advanceTimersByTime(400) expect(toastSuccessSpy).toHaveBeenCalledWith(
expect(toastSpy).toHaveBeenCalledWith( 'Documents merged as versions.'
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', () => {
@@ -1625,7 +1644,6 @@ 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)
@@ -1646,8 +1664,6 @@ 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) {
@@ -1663,7 +1679,6 @@ 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,18 +19,25 @@ import {
NgbModalRef, NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs' import {
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,
@@ -45,12 +52,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'
@@ -77,6 +84,7 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component' import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component' import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
@Component({ @Component({
selector: 'pngx-bulk-editor', selector: 'pngx-bulk-editor',
@@ -102,12 +110,13 @@ 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)
@@ -299,7 +308,9 @@ export class BulkEditorComponent
} }
get canRunWorkflow(): boolean { get canRunWorkflow(): boolean {
return this.userCanEditAll && this.manualWorkflows.length > 0 return (
this.userCanEditAll && this.userOwnsAll && this.manualWorkflows.length > 0
)
} }
runWorkflowSelected() { runWorkflowSelected() {
@@ -313,10 +324,23 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.executeBulkEditMethod(modal, 'run_workflow', { modal.componentInstance.buttonsEnabled.set(false)
workflow: modal.componentInstance.workflowId, this.documentService
filter_documents: !modal.componentInstance.force, .bulkEdit(this.getSelectionQuery(), 'run_workflow', {
}) workflow: modal.componentInstance.workflowId,
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),
})
}) })
} }
@@ -338,39 +362,65 @@ export class BulkEditorComponent
}) })
} }
private handleOperationQueued( private handleOperationQueued(modal: NgbModalRef, taskId: string) {
modal: NgbModalRef,
taskId: string,
onQueued?: (taskId: string) => void
) {
modal?.close() modal?.close()
this.list.selectNone() this.list.selectNone()
if (onQueued) { const canViewTasks = this.permissionService.currentUserCan(
onQueued(taskId) PermissionAction.View,
} else { PermissionType.PaperlessTask
this.taskNotifications.showQueued( )
taskId, this.toastService.show({
$localize`Bulk operation queued.` 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<BulkOperationResponse>, request: Observable<any>,
onQueued?: (taskId: string) => void options: { clearSelection?: boolean; successMessage?: string } = {}
) { ) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled.set(false) modal.componentInstance.buttonsEnabled.set(false)
} }
request.pipe(first()).subscribe({ request.pipe(first()).subscribe({
next: ({ task_id }) => { next: () => {
this.handleOperationQueued(modal, task_id, onQueued) this.handleOperationSuccess(modal, options.clearSelection ?? false)
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)
@@ -938,8 +988,16 @@ export class BulkEditorComponent
const count = this.getSelectionSize() const count = this.getSelectionSize()
this.executeDocumentAction( this.executeDocumentAction(
modal, modal,
this.documentService.deleteDocuments(this.getSelectionQuery()), this.documentService
(taskId) => this.trashService.trackMovedToTrash(taskId, count, documents) .deleteDocuments(this.getSelectionQuery())
.pipe(
tap(() =>
this.trashService.notifyMovedToTrash(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
)
) )
} }
@@ -1053,7 +1111,11 @@ 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.`
) )
}) })
} }
@@ -1078,7 +1140,11 @@ 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.`,
}
) )
}) })
} }
@@ -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 accordion="true" formControlName="permissions_form"></pngx-permissions-form> <pngx-permissions-form [users]="users()" 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>
@@ -3,6 +3,7 @@ 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'
@@ -14,6 +15,7 @@ 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
@@ -25,7 +27,7 @@ describe('SaveViewConfigDialogComponent', () => {
{ {
provide: UserService, provide: UserService,
useValue: { useValue: {
listAll: () => of({ results: [] }), listAll: () => of({ results: users }),
}, },
}, },
{ {
@@ -124,6 +126,19 @@ 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')
@@ -6,6 +6,7 @@ import {
inject, inject,
signal, signal,
} from '@angular/core' } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -13,11 +14,14 @@ 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'
@@ -38,6 +42,8 @@ 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('')
@@ -46,7 +52,16 @@ export class SaveViewConfigDialogComponent implements OnInit {
@Output() @Output()
public saveClicked = new EventEmitter() public saveClicked = new EventEmitter()
users: User[] readonly users = toSignal(
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,7 +290,6 @@ 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,
}) })
}) })
@@ -304,7 +303,6 @@ 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,
}) })
}) })
@@ -319,7 +317,6 @@ 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,
}) })
}) })
@@ -334,7 +331,6 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
degrees: 90, degrees: 90,
source_mode: 'latest_version', source_mode: 'latest_version',
from_webui: true,
}) })
}) })
@@ -350,7 +346,6 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
metadata_document_id: 1, metadata_document_id: 1,
delete_originals: true, delete_originals: true,
from_webui: true,
}) })
}) })
@@ -364,7 +359,6 @@ 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,
}) })
}) })
@@ -380,7 +374,6 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
root_document_id: 2, root_document_id: 2,
version_label: 'Imported', version_label: 'Imported',
from_webui: true,
}) })
}) })
@@ -395,7 +388,6 @@ 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,
}) })
}) })
@@ -411,7 +403,6 @@ describe(`DocumentService`, () => {
documents: ids, documents: ids,
password: 'secret', password: 'secret',
update_document: true, update_document: true,
from_webui: true,
}) })
}) })
@@ -351,24 +351,19 @@ export class DocumentService extends AbstractPaperlessService<Document> {
} }
deleteDocuments(selection: DocumentSelectionQuery) { deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post<BulkOperationResponse>( return this.http.post(this.getResourceUrl(null, 'delete'), {
this.getResourceUrl(null, 'delete'), ...selection,
{ ...selection, from_webui: true } })
)
} }
reprocessDocuments( reprocessDocuments(
selection: DocumentSelectionQuery, selection: DocumentSelectionQuery,
remoteOcr: boolean = false remoteOcr: boolean = false
) { ) {
return this.http.post<BulkOperationResponse>( return this.http.post(this.getResourceUrl(null, 'reprocess'), {
this.getResourceUrl(null, 'reprocess'), ...selection,
{ remote_ocr: remoteOcr,
...selection, })
remote_ocr: remoteOcr,
from_webui: true,
}
)
} }
rotateDocuments( rotateDocuments(
@@ -376,26 +371,18 @@ export class DocumentService extends AbstractPaperlessService<Document> {
degrees: number, degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) { ) {
return this.http.post<BulkOperationResponse>( return this.http.post(this.getResourceUrl(null, 'rotate'), {
this.getResourceUrl(null, 'rotate'), ...selection,
{ degrees,
...selection, source_mode: sourceMode,
degrees, })
source_mode: sourceMode,
from_webui: true,
}
)
} }
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) { mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
return this.http.post<BulkOperationResponse>( return this.http.post(this.getResourceUrl(null, 'merge'), {
this.getResourceUrl(null, 'merge'), documents: ids,
{ ...request,
documents: ids, })
...request,
from_webui: true,
}
)
} }
mergeDocumentsAsVersions( mergeDocumentsAsVersions(
@@ -403,40 +390,28 @@ export class DocumentService extends AbstractPaperlessService<Document> {
rootDocumentId: number, rootDocumentId: number,
versionLabel?: string versionLabel?: string
) { ) {
return this.http.post<BulkOperationResponse>( return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
this.getResourceUrl(null, 'merge_as_versions'), documents: ids,
{ root_document_id: rootDocumentId,
documents: ids, ...(versionLabel ? { version_label: versionLabel } : {}),
root_document_id: rootDocumentId, })
...(versionLabel ? { version_label: versionLabel } : {}),
from_webui: true,
}
)
} }
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) { editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
return this.http.post<BulkOperationResponse>( return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
this.getResourceUrl(null, 'edit_pdf'), documents: ids,
{ ...request,
documents: ids, })
...request,
from_webui: true,
}
)
} }
removePasswordDocuments( removePasswordDocuments(
ids: number[], ids: number[],
request: RemovePasswordDocumentsRequest request: RemovePasswordDocumentsRequest
) { ) {
return this.http.post<BulkOperationResponse>( return this.http.post(this.getResourceUrl(null, 'remove_password'), {
this.getResourceUrl(null, 'remove_password'), documents: ids,
{ ...request,
documents: ids, })
...request,
from_webui: true,
}
)
} }
getSelectionData( getSelectionData(
@@ -1,145 +0,0 @@
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
)
})
})
@@ -1,110 +0,0 @@
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,29 +38,6 @@ 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',
+1 -8
View File
@@ -75,14 +75,7 @@ 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( this.toasts.unshift(toast)
(existing) => existing.id === toast.id
)
if (index === -1) {
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
+1 -51
View File
@@ -5,10 +5,8 @@ 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 { TaskNotificationService } from './task-notification.service' import { Toast, ToastService } from './toast.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
import { TrashService } from './trash.service' import { TrashService } from './trash.service'
describe('TrashService', () => { describe('TrashService', () => {
@@ -107,52 +105,4 @@ 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 -30
View File
@@ -5,8 +5,7 @@ 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 { TaskNotificationService } from './task-notification.service' import { ToastLevel, ToastService } from './toast.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -15,22 +14,6 @@ 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())
@@ -60,22 +43,14 @@ 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
return { this.toastService.show({
content, content,
delay: 10000, delay: 10000,
level: ToastLevel.Success, level: ToastLevel.Success,
...(canUndo actionName: canUndo ? $localize`Undo` : undefined,
? { action: canUndo ? () => this.undoMoveToTrash(documents) : undefined,
actionName: $localize`Undo`, })
action: () => this.undoMoveToTrash(documents),
}
: {}),
}
} }
private undoMoveToTrash(documents: number[]) { private undoMoveToTrash(documents: number[]) {
+2 -3
View File
@@ -427,7 +427,6 @@ 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"
@@ -475,9 +474,9 @@ def run_workflow(
""" """
Manually run a single workflow against the given documents. Manually run a single workflow against the given documents.
Called from the tracked bulk operation task. Done asynchronously via a Celery task.
""" """
run_workflows_for_documents( run_workflows_for_documents.delay(
document_ids=doc_ids, document_ids=doc_ids,
workflow_id=workflow, workflow_id=workflow,
filter_documents=filter_documents, filter_documents=filter_documents,
+2 -65
View File
@@ -1,4 +1,4 @@
"""Queued document bulk operations.""" """Queued document metadata edits."""
from typing import Any from typing import Any
@@ -12,7 +12,6 @@ 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
@@ -26,35 +25,7 @@ MODIFIED_FIELDS = {
"modify_custom_fields": "custom_fields", "modify_custom_fields": "custom_fields",
"set_permissions": None, "set_permissions": None,
} }
LEGACY_MODIFIED_FIELDS = {"delete": "deleted_at", "reprocess": "checksum"} QUEUED_OPERATIONS = frozenset(MODIFIED_FIELDS)
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]:
@@ -95,7 +66,6 @@ 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}")
@@ -109,39 +79,6 @@ 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
+1
View File
@@ -789,6 +789,7 @@ 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 (
+1 -3
View File
@@ -1688,7 +1688,6 @@ 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,
@@ -1802,12 +1801,11 @@ class RemovePasswordDocumentsSerializer(
class DeleteDocumentsSerializer(DocumentSelectionSerializer): class DeleteDocumentsSerializer(DocumentSelectionSerializer):
from_webui = serializers.BooleanField(required=False, default=False) pass
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(
-2
View File
@@ -1206,8 +1206,6 @@ 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)
+3 -2
View File
@@ -703,11 +703,12 @@ 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 without document ownership is forbidden (actions mutate docs).""" """A non-owner with change permission cannot run mutating workflow actions."""
self.setup_mock(m, "run_workflow") self.setup_mock(m, "run_workflow")
user = UserFactory(username="no_perms") user = UserFactory(username="non_owner")
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)
+3 -3
View File
@@ -698,10 +698,10 @@ 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])
class TestPDFActions(DirectoriesMixin, TestCase): class TestPDFActions(DirectoriesMixin, TestCase):
+15 -135
View File
@@ -54,7 +54,6 @@ 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"
@@ -103,87 +102,6 @@ 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
@@ -244,22 +162,6 @@ 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
@@ -382,46 +284,24 @@ async def test_completion_socket_is_private(anyio_backend):
@pytest.mark.django_db @pytest.mark.django_db
def test_legacy_trash_queues_without_deleting_in_request(bulk_setup): @pytest.mark.parametrize("endpoint", ["delete", "bulk_edit"])
user, client, doc, _ = bulk_setup def test_trash_remains_synchronous(bulk_setup, endpoint):
data = {"documents": [doc.pk], "method": "delete", "parameters": {}} _, client, doc, _ = bulk_setup
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue: data = {"documents": [doc.pk]}
enqueue.return_value.id = "delete-task" if endpoint == "bulk_edit":
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(
"/api/documents/bulk_edit/", f"/api/documents/{endpoint}/",
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", "task_id": "delete-task"} assert response.data == {"result": "OK"}
assert Document.objects.filter(pk=doc.pk).exists() assert not Document.objects.filter(pk=doc.pk).exists()
assert enqueue.call_args.kwargs["kwargs"] == { assert Document.deleted_objects.filter(pk=doc.pk).exists()
"operation": "delete", enqueue.assert_not_called()
"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,7 +8,6 @@ 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
@@ -35,7 +34,6 @@ 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,
}, },
) )
@@ -350,17 +348,6 @@ 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:
@@ -380,10 +367,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual( self.assertEqual(response.data, {"result": "OK"})
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,
+81 -35
View File
@@ -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 TRIGGER_SOURCE_OPERATIONS from documents.bulk_operations import QUEUED_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,30 +2974,38 @@ 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")
trigger_source = ( signature(method).bind(documents, **parameters)
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={"trigger_source": trigger_source}, headers={
"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})
@@ -3121,6 +3129,15 @@ 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,
@@ -3130,12 +3147,8 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
try: try:
return self._queue_bulk_operation( result = method(documents, **parameters)
method, return Response({"result": result})
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(
@@ -3156,13 +3169,19 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
name="BulkEditDocumentsResult", name="BulkEditDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(), "task_id": serializers.CharField(required=False),
}, },
), ),
}, },
), ),
) )
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):
@@ -3192,6 +3211,14 @@ 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,
@@ -3201,12 +3228,38 @@ class BulkEditView(DocumentOperationPermissionMixin):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
try: try:
return self._queue_bulk_operation( if method.__name__ in QUEUED_OPERATIONS:
method, return self._queue_bulk_operation(
documents, method,
parameters, documents,
from_webui, parameters,
) 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(
@@ -3223,7 +3276,6 @@ class BulkEditView(DocumentOperationPermissionMixin):
name="RotateDocumentsResult", name="RotateDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3251,7 +3303,6 @@ class RotateDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsResult", name="MergeDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3279,7 +3330,6 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsAsVersionsResult", name="MergeDocumentsAsVersionsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3307,7 +3357,6 @@ class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
name="DeleteDocumentsResult", name="DeleteDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3335,7 +3384,6 @@ class DeleteDocumentsView(DocumentOperationPermissionMixin):
name="ReprocessDocumentsResult", name="ReprocessDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3363,7 +3411,6 @@ class ReprocessDocumentsView(DocumentOperationPermissionMixin):
name="EditPdfDocumentsResult", name="EditPdfDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },
@@ -3391,7 +3438,6 @@ class EditPdfDocumentsView(DocumentOperationPermissionMixin):
name="RemovePasswordDocumentsResult", name="RemovePasswordDocumentsResult",
fields={ fields={
"result": serializers.CharField(), "result": serializers.CharField(),
"task_id": serializers.CharField(),
}, },
), ),
}, },