Compare commits

..
39 changed files with 1048 additions and 512 deletions

No files matched your search

+6
View File
@@ -288,6 +288,12 @@ The API supports various bulk-editing operations which are executed asynchronous
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
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
{
"documents": [LIST_OF_DOCUMENT_IDS],
+60 -67
View File
@@ -3424,19 +3424,19 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1034</context>
<context context-type="linenumber">1019</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1073</context>
<context context-type="linenumber">1058</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1096</context>
<context context-type="linenumber">1081</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1132</context>
<context context-type="linenumber">1117</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
@@ -4199,27 +4199,27 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">652</context>
<context context-type="linenumber">637</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">692</context>
<context context-type="linenumber">677</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">730</context>
<context context-type="linenumber">715</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">768</context>
<context context-type="linenumber">753</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">830</context>
<context context-type="linenumber">815</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">968</context>
<context context-type="linenumber">953</context>
</context-group>
</trans-unit>
<trans-unit id="6088768251338567957" datatype="html">
@@ -6668,7 +6668,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">972</context>
<context context-type="linenumber">957</context>
</context-group>
</trans-unit>
<trans-unit id="1616074213645042277" datatype="html">
@@ -8445,7 +8445,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">597</context>
<context context-type="linenumber">582</context>
</context-group>
<note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
</trans-unit>
@@ -8774,7 +8774,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">320</context>
<context context-type="linenumber">318</context>
</context-group>
</trans-unit>
<trans-unit id="7049887240439736400" datatype="html">
@@ -9261,7 +9261,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">970</context>
<context context-type="linenumber">955</context>
</context-group>
</trans-unit>
<trans-unit id="5992101894268263750" datatype="html">
@@ -9286,7 +9286,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1030</context>
<context context-type="linenumber">1015</context>
</context-group>
</trans-unit>
<trans-unit id="2951161989614003846" datatype="html">
@@ -9336,7 +9336,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">322</context>
<context context-type="linenumber">320</context>
</context-group>
</trans-unit>
<trans-unit id="523213570140181690" datatype="html">
@@ -9701,7 +9701,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1130</context>
<context context-type="linenumber">1115</context>
</context-group>
</trans-unit>
<trans-unit id="7251533806673754434" datatype="html">
@@ -9736,53 +9736,46 @@
<source>Run a workflow on <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</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">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 context-type="linenumber">319</context>
</context-group>
</trans-unit>
<trans-unit id="3401633333450192408" datatype="html">
<source>Bulk operation queued.</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">373</context>
<context context-type="linenumber">358</context>
</context-group>
</trans-unit>
<trans-unit id="5709555629190115111" datatype="html">
<source>View task</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">377</context>
<context context-type="linenumber">362</context>
</context-group>
</trans-unit>
<trans-unit id="1215215387232313677" datatype="html">
<source>Error executing bulk operation</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">429</context>
<context context-type="linenumber">414</context>
</context-group>
</trans-unit>
<trans-unit id="7894972847287473517" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</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">589</context>
<context context-type="linenumber">574</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">595</context>
<context context-type="linenumber">580</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">591</context>
<context context-type="linenumber">576</context>
</context-group>
<note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note>
</trans-unit>
@@ -9790,7 +9783,7 @@
<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 context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">599,601</context>
<context context-type="linenumber">584,586</context>
</context-group>
<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>
@@ -9798,14 +9791,14 @@
<source>Confirm tags assignment</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">616</context>
<context context-type="linenumber">601</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">622</context>
<context context-type="linenumber">607</context>
</context-group>
</trans-unit>
<trans-unit id="1894412783609570695" datatype="html">
@@ -9814,14 +9807,14 @@
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</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">627,629</context>
<context context-type="linenumber">612,614</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">635</context>
<context context-type="linenumber">620</context>
</context-group>
</trans-unit>
<trans-unit id="3819792277998068944" datatype="html">
@@ -9830,7 +9823,7 @@
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</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">640,642</context>
<context context-type="linenumber">625,627</context>
</context-group>
</trans-unit>
<trans-unit id="2739066218579571288" datatype="html">
@@ -9841,84 +9834,84 @@
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</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">644,648</context>
<context context-type="linenumber">629,633</context>
</context-group>
</trans-unit>
<trans-unit id="2996713129519325161" datatype="html">
<source>Confirm correspondent assignment</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">685</context>
<context context-type="linenumber">670</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">687</context>
<context context-type="linenumber">672</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">689</context>
<context context-type="linenumber">674</context>
</context-group>
</trans-unit>
<trans-unit id="5393409374423140648" datatype="html">
<source>Confirm document type assignment</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">723</context>
<context context-type="linenumber">708</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">725</context>
<context context-type="linenumber">710</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">727</context>
<context context-type="linenumber">712</context>
</context-group>
</trans-unit>
<trans-unit id="6386555513013840736" datatype="html">
<source>Confirm storage path assignment</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">761</context>
<context context-type="linenumber">746</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">763</context>
<context context-type="linenumber">748</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">765</context>
<context context-type="linenumber">750</context>
</context-group>
</trans-unit>
<trans-unit id="4187352575310415704" datatype="html">
<source>Confirm custom field assignment</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">794</context>
<context context-type="linenumber">779</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">800</context>
<context context-type="linenumber">785</context>
</context-group>
</trans-unit>
<trans-unit id="5789455969634598553" datatype="html">
@@ -9927,14 +9920,14 @@
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</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">805,807</context>
<context context-type="linenumber">790,792</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">813</context>
<context context-type="linenumber">798</context>
</context-group>
</trans-unit>
<trans-unit id="6666899594015948817" datatype="html">
@@ -9943,7 +9936,7 @@
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</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">818,820</context>
<context context-type="linenumber">803,805</context>
</context-group>
</trans-unit>
<trans-unit id="8050047262594964176" datatype="html">
@@ -9954,105 +9947,105 @@
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</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">822,826</context>
<context context-type="linenumber">807,811</context>
</context-group>
</trans-unit>
<trans-unit id="8615059324209654051" datatype="html">
<source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</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">969</context>
<context context-type="linenumber">954</context>
</context-group>
</trans-unit>
<trans-unit id="135114644550399974" datatype="html">
<source>Moved <x id="PH" equiv-text="count"/> document(s) to the trash</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">996</context>
<context context-type="linenumber">981</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1031</context>
<context context-type="linenumber">1016</context>
</context-group>
</trans-unit>
<trans-unit id="7366623494074776040" datatype="html">
<source>The archive files will be re-generated with the current settings.</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">1032</context>
<context context-type="linenumber">1017</context>
</context-group>
</trans-unit>
<trans-unit id="6555329262222566158" datatype="html">
<source>Rotate confirm</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">1070</context>
<context context-type="linenumber">1055</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1071</context>
<context context-type="linenumber">1056</context>
</context-group>
</trans-unit>
<trans-unit id="7910756456450124185" datatype="html">
<source>Merge confirm</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">1094</context>
<context context-type="linenumber">1079</context>
</context-group>
</trans-unit>
<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>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1095</context>
<context context-type="linenumber">1080</context>
</context-group>
</trans-unit>
<trans-unit id="7869008840945899895" datatype="html">
<source>Merged document will be queued for consumption.</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">1118</context>
<context context-type="linenumber">1103</context>
</context-group>
</trans-unit>
<trans-unit id="1549333914563613027" datatype="html">
<source>The selected documents will become versions of the root document.</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">1131</context>
<context context-type="linenumber">1116</context>
</context-group>
</trans-unit>
<trans-unit id="7099667366799629283" datatype="html">
<source>Documents merged as versions.</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">1146</context>
<context context-type="linenumber">1131</context>
</context-group>
</trans-unit>
<trans-unit id="3873496751167944011" datatype="html">
<source>Error updating custom fields.</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">1176</context>
<context context-type="linenumber">1161</context>
</context-group>
</trans-unit>
<trans-unit id="6144801143088984138" datatype="html">
<source>Share link bundle creation requested.</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">1224</context>
<context context-type="linenumber">1209</context>
</context-group>
</trans-unit>
<trans-unit id="46019676931295023" datatype="html">
<source>Share link bundle creation is not available yet.</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">1231</context>
<context context-type="linenumber">1216</context>
</context-group>
</trans-unit>
<trans-unit id="6307402210351946694" datatype="html">
@@ -33,6 +33,7 @@ import { RemoteVersionService } from 'src/app/services/rest/remote-version.servi
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.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 { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
@@ -198,14 +199,14 @@ describe('AppFrameComponent', () => {
fixture.detectChanges()
})
it('refreshes and notifies on completion without client-side task tracking', () => {
it('refreshes and updates the task notification on completion', () => {
const list = TestBed.inject(DocumentListViewService)
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
const tasksReload = jest
.spyOn(tasksService, 'reload')
.mockImplementation(() => {})
const success = jest.spyOn(toastService, 'showSuccess')
const success = jest.spyOn(toastService, 'show')
const refresh = jest
.spyOn(openDocumentsService, 'refreshDocument')
.mockImplementation(() => {})
@@ -230,7 +231,12 @@ describe('AppFrameComponent', () => {
expect(refresh).toHaveBeenCalledWith(document.id)
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
expect(tasksReload).toHaveBeenCalledTimes(1)
expect(success).toHaveBeenCalledWith('Bulk operation completed.')
expect(success).toHaveBeenCalledWith(
expect.objectContaining({
id: 'task-bulk-task',
content: 'Operation completed.',
})
)
fixture.destroy()
bulkEvents.next({
task_id: 'bulk-task',
@@ -245,14 +251,14 @@ describe('AppFrameComponent', () => {
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
const error = jest.spyOn(toastService, 'showError')
const error = jest.spyOn(toastService, 'show')
const success = jest.spyOn(toastService, 'showSuccess')
for (const status of [
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
]) {
bulkEvents.next({
task_id: 'bulk-task',
task_id: `${status}-task`,
operation: 'set_permissions',
status,
error:
@@ -262,11 +268,36 @@ describe('AppFrameComponent', () => {
})
}
expect(reload).toHaveBeenCalledTimes(2)
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
expect(error).toHaveBeenCalledWith(
'Bulk operation revoked.',
'Task expired before execution.'
expect.objectContaining({
content:
'Operation failed. Some changes may already have been applied.',
error: 'Could not update',
})
)
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Operation revoked.',
error: 'Task expired before execution.',
})
)
expect(success).not.toHaveBeenCalled()
})
it('passes delete completion to the shared task notification handler', () => {
const success = jest.spyOn(toastService, 'showSuccess')
const notifications = TestBed.inject(TaskNotificationService)
const handleStatus = jest.spyOn(notifications, 'complete')
bulkEvents.next({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(handleStatus).toHaveBeenCalledWith({
task_id: 'delete-1',
operation: 'delete',
status: PaperlessTaskStatus.Success,
})
expect(success).not.toHaveBeenCalled()
})
@@ -57,6 +57,7 @@ import {
} from 'src/app/services/rest/remote-version.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.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 { ToastService } from 'src/app/services/toast.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
@@ -115,6 +116,7 @@ export class AppFrameComponent
settingsService = inject(SettingsService)
tasksService = inject(TasksService)
private readonly toastService = inject(ToastService)
private readonly taskNotifications = inject(TaskNotificationService)
private modalService = inject(NgbModal)
permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService)
@@ -182,7 +184,7 @@ export class AppFrameComponent
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ status, error }) => {
.subscribe((result) => {
this.documentList.reload(null, false)
this.documentList.reduceSelectionToFilter()
// maybe someday we can optimize to only refresh affected documents
@@ -198,16 +200,7 @@ export class AppFrameComponent
) {
this.tasksService.reload()
}
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.taskNotifications.complete(result)
})
this.lastScrollY = window.scrollY
@@ -7,6 +7,7 @@
background-color: var(--pngx-toast-bg);
color: var(--bs-body-color);
overflow: hidden;
transition: background-color .18s ease, border-color .18s ease;
&.toast-success {
--pngx-toast-accent: var(--pngx-toast-success);
@@ -23,6 +24,13 @@
.toast-icon {
color: var(--pngx-toast-accent);
transition: color .18s ease;
}
@media (prefers-reduced-motion: reduce) {
.toast, .toast-icon {
transition: none;
}
}
.toast-content {
@@ -65,6 +65,36 @@ describe('ToastComponent', () => {
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', () => {
jest.useFakeTimers()
const toast = { content: 'Info', delay: 3000 }
@@ -5,8 +5,10 @@ import {
DestroyRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild,
inject,
signal,
@@ -34,7 +36,7 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
styleUrl: './toast.component.scss',
providers: [DatePipe, CustomDatePipe],
})
export class ToastComponent implements OnInit {
export class ToastComponent implements OnInit, OnChanges {
private clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef)
@@ -69,6 +71,12 @@ export class ToastComponent implements OnInit {
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 {
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
}
@@ -68,9 +68,9 @@ describe('ToastsComponent', () => {
toasts.forEach((t) => toastSubject.next(t))
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
// showing a visible toast again moves it to the top without duplicating
// Updating a visible toast preserves its position without duplicating it.
toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
})
it('should remove a popup when it hides without closing it', () => {
@@ -32,10 +32,11 @@ export class ToastsComponent {
return
}
this.toasts.update((toasts) =>
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
0,
MAX_VISIBLE_TOASTS
)
toasts.some((existing) => existing.id === toast.id)
? toasts.map((existing) =>
existing.id === toast.id ? toast : existing
)
: [toast, ...toasts].slice(0, MAX_VISIBLE_TOASTS)
)
})
}
@@ -1013,30 +1013,45 @@ describe('DocumentDetailComponent', () => {
])
})
it('should support reprocess, confirm and close modal after started', () => {
it('should queue reprocess, show the task link and close the modal', () => {
jest.useFakeTimers()
initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of(true))
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-1' }))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'show')
component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(toastSpy).not.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', () => {
jest.useFakeTimers()
initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
bulkEditSpy.mockReturnValue(of(true))
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-1' }))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const toastSpy = jest.spyOn(toastService, 'showInfo')
const toastSpy = jest.spyOn(toastService, 'show')
component.runWorkflow()
expect(openModal.componentInstance.singleDocument).toBe(true)
expect(openModal.componentInstance.force).toBe(true)
@@ -1048,14 +1063,18 @@ describe('DocumentDetailComponent', () => {
'run_workflow',
{ workflow: 5, filter_documents: false }
)
expect(toastSpy).toHaveBeenCalled()
jest.advanceTimersByTime(400)
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Workflow queued.' })
)
expect(modalCloseSpy).toHaveBeenCalled()
jest.useRealTimers()
})
it('should apply workflow filters when the single-document checkbox is cleared', () => {
initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
bulkEditSpy.mockReturnValue(of(true))
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-2' }))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.runWorkflow()
@@ -1072,7 +1091,7 @@ describe('DocumentDetailComponent', () => {
it('should pass remote OCR choice when reprocessing', () => {
initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of(true))
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-2' }))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.reprocess()
@@ -2001,10 +2020,12 @@ describe('DocumentDetailComponent', () => {
})
it('should support pdf editor, handle error', () => {
jest.useFakeTimers()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
const errorSpy = jest.spyOn(toastService, 'showError')
const queuedToastSpy = jest.spyOn(toastService, 'show')
initNormally()
component.selectedVersionId.set(10)
component.editPdf()
@@ -2023,6 +2044,7 @@ describe('DocumentDetailComponent', () => {
update_document: false,
include_metadata: true,
source_mode: 'explicit_selection',
from_webui: true,
})
req.error(new ErrorEvent('failed'))
expect(errorSpy).toHaveBeenCalled()
@@ -2035,13 +2057,21 @@ describe('DocumentDetailComponent', () => {
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/edit_pdf/`
)
req.flush(true)
expect(closeSpy).toHaveBeenCalled()
req.flush({ result: 'OK', task_id: 'edit-pdf-1' })
jest.advanceTimersByTime(400)
expect(closeSpy).not.toHaveBeenCalled()
expect(queuedToastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'PDF edit queued.' })
)
jest.useRealTimers()
})
it('should support removing password protection from pdfs', () => {
jest.useFakeTimers()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
const queuedToastSpy = jest.spyOn(toastService, 'show')
initNormally()
component.selectedVersionId.set(10)
component.password = 'secret'
@@ -2062,8 +2092,15 @@ describe('DocumentDetailComponent', () => {
include_metadata: false,
delete_original: true,
source_mode: 'explicit_selection',
from_webui: true,
})
req.flush(true)
req.flush({ result: 'OK', task_id: 'remove-password-1' })
jest.advanceTimersByTime(400)
expect(closeSpy).not.toHaveBeenCalled()
expect(queuedToastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Password removal queued.' })
)
jest.useRealTimers()
})
it('should require the current password before removing it', () => {
@@ -2115,9 +2152,9 @@ describe('DocumentDetailComponent', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/remove_password/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'remove-password-2' })
expect(refreshSpy).toHaveBeenCalledWith(doc.id)
expect(refreshSpy).not.toHaveBeenCalled()
})
it('should support keyboard shortcuts', () => {
@@ -97,6 +97,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.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 { TrashService } from 'src/app/services/trash.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
@@ -226,6 +227,7 @@ export class DocumentDetailComponent
private documentListViewService = inject(DocumentListViewService)
private documentTitlePipe = inject(DocumentTitlePipe)
private toastService = inject(ToastService)
private taskNotifications = inject(TaskNotificationService)
private trashService = inject(TrashService)
private settings = inject(SettingsService)
private storagePathService = inject(StoragePathService)
@@ -1558,9 +1560,11 @@ export class DocumentDetailComponent
modal.componentInstance.remoteOcr
)
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Reprocess operation for "${this.document().title}" will begin in the background.`
next: ({ task_id }) => {
this.taskNotifications.showQueued(
task_id,
$localize`Document reprocessing queued.`,
{ content: $localize`Document reprocessing completed.` }
)
if (modal) {
modal.close()
@@ -1597,9 +1601,13 @@ export class DocumentDetailComponent
filter_documents: !modal.componentInstance.force,
})
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Workflow will run on "${this.document().title}" in the background.`
next: ({ task_id }) => {
this.taskNotifications.showQueued(
task_id,
$localize`Workflow queued.`,
{
content: $localize`Workflow completed.`,
}
)
modal.close()
},
@@ -1989,14 +1997,15 @@ export class DocumentDetailComponent
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`PDF edit operation for "${this.document().title}" will begin in the background.`
next: ({ task_id }) => {
this.taskNotifications.showQueued(
task_id,
$localize`PDF edit queued.`,
{
content: $localize`PDF edit completed.`,
}
)
modal.close()
if (modal.componentInstance.deleteOriginal) {
this.openDocumentService.closeDocument(this.document())
}
},
error: (error) => {
if (modal) {
@@ -2046,17 +2055,14 @@ export class DocumentDetailComponent
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Password removal operation for "${this.document().title}" will begin in the background.`
next: ({ task_id }) => {
this.taskNotifications.showQueued(
task_id,
$localize`Password removal queued.`,
{ content: $localize`Password removal completed.` }
)
this.networkActive.set(false)
modal.close()
if (!dialog.updateDocument && dialog.deleteOriginal) {
this.openDocumentService.closeDocument(this.document())
} else if (dialog.updateDocument) {
this.openDocumentService.refreshDocument(this.documentId())
}
},
error: (error) => {
dialog.buttonsEnabled.set(true)
@@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common'
import { SimpleChange, signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { Router } from '@angular/router'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, of, throwError } from 'rxjs'
import { DocumentVersionInfo } from 'src/app/data/document'
@@ -33,7 +34,7 @@ describe('DocumentVersionDropdownComponent', () => {
>
>
let toastService: jest.Mocked<
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'>
>
let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }>
@@ -54,6 +55,7 @@ describe('DocumentVersionDropdownComponent', () => {
}
modalService = { open: jest.fn() }
toastService = {
show: jest.fn(),
showError: jest.fn(),
showInfo: jest.fn(),
showSuccess: jest.fn(),
@@ -88,6 +90,10 @@ describe('DocumentVersionDropdownComponent', () => {
provide: PermissionsService,
useValue: permissionsService,
},
{
provide: Router,
useValue: { navigate: jest.fn() },
},
{
provide: WebsocketStatusService,
useValue: {
@@ -377,7 +383,8 @@ describe('DocumentVersionDropdownComponent', () => {
expect(component.versionLabelDraft).toEqual('')
})
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
it('addExistingDocumentAsVersion should queue with a label and wait to refresh versions', () => {
jest.useFakeTimers()
const confirmClicked = new Subject<number>()
const modal = {
componentInstance: {
@@ -388,14 +395,9 @@ describe('DocumentVersionDropdownComponent', () => {
close: jest.fn(),
}
modalService.open.mockReturnValue(modal as any)
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
// 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))
documentService.mergeDocumentsAsVersions.mockReturnValue(
of({ result: 'OK', task_id: 'versions-1' })
)
component.newVersionLabel = ' Imported '
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
@@ -410,11 +412,18 @@ describe('DocumentVersionDropdownComponent', () => {
'Imported'
)
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
expect(documentService.getVersions).toHaveBeenCalledWith(3)
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
expect(documentService.getVersions).not.toHaveBeenCalled()
expect(versionsEmitSpy).not.toHaveBeenCalled()
expect(selectedEmitSpy).not.toHaveBeenCalled()
expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled()
expect(toastService.showSuccess).toHaveBeenCalled()
jest.advanceTimersByTime(400)
expect(toastService.show).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Existing document queued to be added as a version.',
actionName: 'View task',
})
)
jest.useRealTimers()
})
})
@@ -28,6 +28,7 @@ import { DocumentVersionInfo } from 'src/app/data/document'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
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 {
UploadState,
@@ -77,6 +78,7 @@ export class DocumentVersionDropdownComponent
private readonly toastService = inject(ToastService)
private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly modalService = inject(NgbModal)
private readonly taskNotifications = inject(TaskNotificationService)
private readonly destroy$ = new Subject<void>()
private readonly documentChange$ = new Subject<void>()
@@ -305,23 +307,19 @@ export class DocumentVersionDropdownComponent
versionLabel
)
.pipe(
switchMap(() => this.documentsService.getVersions(this.documentId)),
first(),
finalize(() => dialog.buttonsEnabled.set(true)),
takeUntil(this.destroy$),
takeUntil(this.documentChange$)
)
.subscribe({
next: (document) => {
if (document?.versions?.length) {
this.versionsUpdated.emit(document.versions)
// The API returns versions newest first
this.versionSelected.emit(document.versions[0].id)
}
next: ({ task_id }) => {
this.newVersionLabel = ''
modal.close()
this.toastService.showSuccess(
$localize`Existing document added as a version.`
this.taskNotifications.showQueued(
task_id,
$localize`Existing document queued to be added as a version.`,
{ content: $localize`Existing document added as a version.` }
)
},
error: (error) => {
@@ -686,8 +686,6 @@ describe('BulkEditorComponent', () => {
})
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
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@@ -714,12 +712,7 @@ describe('BulkEditorComponent', () => {
workflow: 5,
filter_documents: false,
})
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)
req.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
@@ -728,27 +721,6 @@ describe('BulkEditorComponent', () => {
) // 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', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
@@ -1267,14 +1239,15 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/`
)
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
const trackSpy = jest
.spyOn(trashService, 'trackMovedToTrash')
.mockImplementation(() => {})
req.flush(true)
req.flush({ result: 'OK', task_id: 'delete-1' })
expect(req.request.body).toEqual({
documents: [3, 4],
from_webui: true,
})
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
expect(trackSpy).toHaveBeenCalledWith('delete-1', 2, [3, 4])
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
@@ -1291,9 +1264,9 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'delete-2' })
// ids aren't known when all matching documents are selected, so no undo
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
expect(trackSpy).toHaveBeenLastCalledWith('delete-2', 0, null)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
@@ -1373,10 +1346,11 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/reprocess/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'reprocess-1' })
expect(req.request.body).toEqual({
documents: [3, 4],
remote_ocr: false,
from_webui: true,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1407,11 +1381,12 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/rotate/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'rotate-1' })
expect(req.request.body).toEqual({
documents: [3, 4],
degrees: 90,
source_mode: 'latest_version',
from_webui: true,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1449,10 +1424,11 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'merge-1' })
expect(req.request.body).toEqual({
documents: [3, 4],
metadata_document_id: 3,
from_webui: true,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1467,11 +1443,12 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'merge-2' })
expect(req.request.body).toEqual({
documents: [3, 4],
metadata_document_id: 3,
delete_originals: true,
from_webui: true,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1488,11 +1465,12 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'merge-3' })
expect(req.request.body).toEqual({
documents: [3, 4],
metadata_document_id: 3,
archive_fallback: true,
from_webui: true,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1504,6 +1482,7 @@ describe('BulkEditorComponent', () => {
})
it('should support merging documents as versions', () => {
jest.useFakeTimers()
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@@ -1531,8 +1510,8 @@ describe('BulkEditorComponent', () => {
.mockReturnValue(true)
const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of(true))
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
.mockReturnValue(of({ result: 'OK', task_id: 'versions-1' }))
const toastSpy = jest.spyOn(toastService, 'show')
fixture.detectChanges()
component.mergeSelectedAsVersions()
@@ -1548,9 +1527,11 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
)
expect(documentListViewService.selected.size).toEqual(0)
expect(toastSuccessSpy).toHaveBeenCalledWith(
'Documents merged as versions.'
jest.advanceTimersByTime(400)
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Bulk operation queued.' })
)
jest.useRealTimers()
})
it('should not report success when merging documents as versions fails', () => {
@@ -1644,6 +1625,7 @@ describe('BulkEditorComponent', () => {
it.each([true, false])(
'shows queued feedback and gates the task link on permission (%s)',
(canViewTasks) => {
jest.useFakeTimers()
jest
.spyOn(permissionsService, 'currentUserCan')
.mockReturnValue(canViewTasks)
@@ -1664,6 +1646,8 @@ describe('BulkEditorComponent', () => {
)
req.flush({ result: 'OK', task_id: 'queued-operation' })
expect(close).toHaveBeenCalledTimes(1)
expect(queued).not.toHaveBeenCalled()
jest.advanceTimersByTime(400)
const notification = queued.mock.calls.at(-1)[0]
expect(notification.content).toBe('Bulk operation queued.')
if (canViewTasks) {
@@ -1679,6 +1663,7 @@ describe('BulkEditorComponent', () => {
expect(reload).not.toHaveBeenCalled()
expect(documentListViewService.selected.size).toBe(0)
expect(documentListViewService.allSelected).toBe(false)
jest.useRealTimers()
}
)
@@ -19,25 +19,18 @@ import {
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver'
import {
first,
map,
Observable,
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
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 { BulkOperationResponse } from 'src/app/data/bulk-operation'
import { CustomField } from 'src/app/data/custom-field'
import { MatchingModel } from 'src/app/data/matching-model'
import { SelectionDataItem } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
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 { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
PermissionAction,
PermissionsService,
@@ -52,12 +45,12 @@ import {
DocumentService,
MergeDocumentsRequest,
} 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 { WorkflowService } from 'src/app/services/rest/workflow.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.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 { TaskNotificationService } from 'src/app/services/task-notification.service'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
@@ -84,7 +77,6 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
@Component({
selector: 'pngx-bulk-editor',
@@ -110,13 +102,12 @@ export class BulkEditorComponent
private documentService = inject(DocumentService)
private trashService = inject(TrashService)
private modalService = inject(NgbModal)
private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService)
private toastService = inject(ToastService)
private taskNotifications = inject(TaskNotificationService)
private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService)
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
private readonly workflowService = inject(WorkflowService)
private readonly router = inject(Router)
@@ -308,9 +299,7 @@ export class BulkEditorComponent
}
get canRunWorkflow(): boolean {
return (
this.userCanEditAll && this.userOwnsAll && this.manualWorkflows.length > 0
)
return this.userCanEditAll && this.manualWorkflows.length > 0
}
runWorkflowSelected() {
@@ -324,23 +313,10 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
this.documentService
.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),
})
this.executeBulkEditMethod(modal, 'run_workflow', {
workflow: modal.componentInstance.workflowId,
filter_documents: !modal.componentInstance.force,
})
})
}
@@ -362,65 +338,39 @@ export class BulkEditorComponent
})
}
private handleOperationQueued(modal: NgbModalRef, taskId: string) {
private handleOperationQueued(
modal: NgbModalRef,
taskId: string,
onQueued?: (taskId: string) => void
) {
modal?.close()
this.list.selectNone()
const canViewTasks = this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.PaperlessTask
)
this.toastService.show({
content: $localize`Bulk operation queued.`,
delay: 5000,
...(canViewTasks
? {
actionName: $localize`View task`,
action: () =>
this.router.navigate(['/tasks'], {
queryParams: { task_id: taskId },
}),
}
: {}),
})
if (onQueued) {
onQueued(taskId)
} else {
this.taskNotifications.showQueued(
taskId,
$localize`Bulk operation queued.`
)
}
}
private executeDocumentAction(
modal: NgbModalRef,
request: Observable<any>,
options: { clearSelection?: boolean; successMessage?: string } = {}
request: Observable<BulkOperationResponse>,
onQueued?: (taskId: string) => void
) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(false)
}
request.pipe(first()).subscribe({
next: () => {
this.handleOperationSuccess(modal, options.clearSelection ?? false)
if (options.successMessage) {
this.toastService.showSuccess(options.successMessage)
}
next: ({ task_id }) => {
this.handleOperationQueued(modal, task_id, onQueued)
},
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) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(true)
@@ -988,16 +938,8 @@ export class BulkEditorComponent
const count = this.getSelectionSize()
this.executeDocumentAction(
modal,
this.documentService
.deleteDocuments(this.getSelectionQuery())
.pipe(
tap(() =>
this.trashService.notifyMovedToTrash(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
)
this.documentService.deleteDocuments(this.getSelectionQuery()),
(taskId) => this.trashService.trackMovedToTrash(taskId, count, documents)
)
}
@@ -1111,11 +1053,7 @@ export class BulkEditorComponent
mergeDialog.buttonsEnabled.set(false)
this.executeDocumentAction(
modal,
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
{ clearSelection: !!args.delete_originals }
)
this.toastService.showInfo(
$localize`Merged document will be queued for consumption.`
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args)
)
})
}
@@ -1140,11 +1078,7 @@ export class BulkEditorComponent
this.documentService.mergeDocumentsAsVersions(
mergeDialog.documentIDs(),
mergeDialog.rootDocumentID()
),
{
clearSelection: true,
successMessage: $localize`Documents merged as versions.`,
}
)
)
})
}
@@ -290,6 +290,7 @@ describe(`DocumentService`, () => {
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
from_webui: true,
})
})
@@ -303,6 +304,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({
documents: ids,
remote_ocr: false,
from_webui: true,
})
})
@@ -317,6 +319,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({
documents: ids,
remote_ocr: true,
from_webui: true,
})
})
@@ -331,6 +334,7 @@ describe(`DocumentService`, () => {
documents: ids,
degrees: 90,
source_mode: 'latest_version',
from_webui: true,
})
})
@@ -346,6 +350,7 @@ describe(`DocumentService`, () => {
documents: ids,
metadata_document_id: 1,
delete_originals: true,
from_webui: true,
})
})
@@ -359,6 +364,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({
documents: ids,
root_document_id: 2,
from_webui: true,
})
})
@@ -374,6 +380,7 @@ describe(`DocumentService`, () => {
documents: ids,
root_document_id: 2,
version_label: 'Imported',
from_webui: true,
})
})
@@ -388,6 +395,7 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({
documents: ids,
operations: [{ page: 1, rotate: 90, doc: 0 }],
from_webui: true,
})
})
@@ -403,6 +411,7 @@ describe(`DocumentService`, () => {
documents: ids,
password: 'secret',
update_document: true,
from_webui: true,
})
})
@@ -351,19 +351,24 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}
deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post(this.getResourceUrl(null, 'delete'), {
...selection,
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'delete'),
{ ...selection, from_webui: true }
)
}
reprocessDocuments(
selection: DocumentSelectionQuery,
remoteOcr: boolean = false
) {
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
...selection,
remote_ocr: remoteOcr,
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'reprocess'),
{
...selection,
remote_ocr: remoteOcr,
from_webui: true,
}
)
}
rotateDocuments(
@@ -371,18 +376,26 @@ export class DocumentService extends AbstractPaperlessService<Document> {
degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) {
return this.http.post(this.getResourceUrl(null, 'rotate'), {
...selection,
degrees,
source_mode: sourceMode,
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'rotate'),
{
...selection,
degrees,
source_mode: sourceMode,
from_webui: true,
}
)
}
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
return this.http.post(this.getResourceUrl(null, 'merge'), {
documents: ids,
...request,
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'merge'),
{
documents: ids,
...request,
from_webui: true,
}
)
}
mergeDocumentsAsVersions(
@@ -390,28 +403,40 @@ export class DocumentService extends AbstractPaperlessService<Document> {
rootDocumentId: number,
versionLabel?: string
) {
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
documents: ids,
root_document_id: rootDocumentId,
...(versionLabel ? { version_label: versionLabel } : {}),
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'merge_as_versions'),
{
documents: ids,
root_document_id: rootDocumentId,
...(versionLabel ? { version_label: versionLabel } : {}),
from_webui: true,
}
)
}
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
documents: ids,
...request,
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'edit_pdf'),
{
documents: ids,
...request,
from_webui: true,
}
)
}
removePasswordDocuments(
ids: number[],
request: RemovePasswordDocumentsRequest
) {
return this.http.post(this.getResourceUrl(null, 'remove_password'), {
documents: ids,
...request,
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'remove_password'),
{
documents: ids,
...request,
from_webui: true,
}
)
}
getSelectionData(
@@ -0,0 +1,145 @@
import { TestBed } from '@angular/core/testing'
import { Router } from '@angular/router'
import { BulkOperationStatus } from '../data/bulk-operation'
import { PaperlessTaskStatus } from '../data/paperless-task'
import { PermissionsService } from './permissions.service'
import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
describe('TaskNotificationService', () => {
let service: TaskNotificationService
let toastService: ToastService
let popups: Toast[]
let notifications: Toast[]
const navigate = jest.fn()
const currentUserCan = jest.fn()
const result = (
status = PaperlessTaskStatus.Success
): BulkOperationStatus => ({
task_id: 'task-1',
operation: 'run_workflow',
status,
})
beforeEach(() => {
jest.useFakeTimers()
currentUserCan.mockReturnValue(true)
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { navigate } },
{ provide: PermissionsService, useValue: { currentUserCan } },
],
})
service = TestBed.inject(TaskNotificationService)
toastService = TestBed.inject(ToastService)
popups = []
notifications = []
toastService.showToast.subscribe((toast) => popups.push(toast))
toastService
.getToasts()
.subscribe((toasts) => (notifications = [...toasts]))
})
afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})
it('replaces a queued notification with its result, retaining the task link', () => {
service.showQueued('task-1', 'Workflow queued.', {
content: 'Workflow completed.',
})
jest.advanceTimersByTime(399)
expect(popups).toHaveLength(0)
jest.advanceTimersByTime(1)
expect(popups[0].content).toBe('Workflow queued.')
service.complete(result())
expect(notifications).toHaveLength(1)
expect(popups[1]).toEqual(
expect.objectContaining({
id: popups[0].id,
content: 'Workflow completed.',
level: ToastLevel.Success,
actionName: 'View task',
})
)
popups[1].action()
expect(navigate).toHaveBeenCalledWith(['/tasks'], {
queryParams: { task_id: 'task-1' },
})
})
it('shows only the result when a task completes during the grace period', () => {
service.showQueued('task-1', 'Queued')
jest.advanceTimersByTime(100)
service.complete(result())
jest.advanceTimersByTime(1000)
expect(popups.map((toast) => toast.content)).toEqual([
'Operation completed.',
])
})
it('does not show a stale queued state when completion beats the HTTP response', () => {
service.complete(result())
service.showQueued('task-1', 'Workflow queued.', {
content: 'Workflow completed.',
})
jest.advanceTimersByTime(1000)
expect(popups.every((toast) => toast.level === ToastLevel.Success)).toBe(
true
)
expect(notifications).toHaveLength(1)
expect(notifications[0].content).toBe('Workflow completed.')
})
it.each([PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked])(
'replaces the queued state with an error for %s',
(status) => {
service.showQueued('task-1', 'Queued', {
content: 'Success',
actionName: 'Undo',
})
jest.advanceTimersByTime(400)
service.complete({ ...result(status), error: 'Task failed' })
expect(notifications).toHaveLength(1)
expect(notifications[0]).toEqual(
expect.objectContaining({
level: ToastLevel.Error,
error: 'Task failed',
actionName: 'View task',
})
)
}
)
it('ignores nonterminal and duplicate outcomes', () => {
service.showQueued('task-1', 'Queued')
service.complete(result(PaperlessTaskStatus.Started))
expect(popups).toHaveLength(0)
service.complete(result())
service.complete(result())
expect(popups).toHaveLength(1)
})
it('keeps concurrent task notifications separate', () => {
service.showQueued('task-1', 'First queued')
service.showQueued('task-2', 'Second queued')
jest.advanceTimersByTime(400)
service.complete(result())
expect(notifications).toHaveLength(2)
expect(notifications.map((toast) => toast.content)).toEqual([
'Second queued',
'Operation completed.',
])
})
it('omits task links without task viewing permission', () => {
currentUserCan.mockReturnValue(false)
service.showQueued('task-1', 'Queued')
jest.advanceTimersByTime(400)
service.complete(result())
expect(popups.every((toast) => !toast.action && !toast.actionName)).toBe(
true
)
})
})
@@ -0,0 +1,110 @@
import { inject, Injectable, OnDestroy } from '@angular/core'
import { Router } from '@angular/router'
import { BulkOperationStatus } from '../data/bulk-operation'
import { PaperlessTaskStatus } from '../data/paperless-task'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from './permissions.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
interface PendingNotification {
success: Partial<Toast>
timer: ReturnType<typeof setTimeout>
}
@Injectable({ providedIn: 'root' })
export class TaskNotificationService implements OnDestroy {
private toastService = inject(ToastService)
private router = inject(Router)
private permissionsService = inject(PermissionsService)
private pending = new Map<string, PendingNotification>()
private completed = new Map<string, BulkOperationStatus>()
ngOnDestroy() {
this.pending.forEach(({ timer }) => clearTimeout(timer))
}
showQueued(taskId: string, content: string, success: Partial<Toast> = {}) {
const completed = this.completed.get(taskId)
if (completed) {
this.showResult(completed, success)
return
}
if (this.pending.has(taskId)) return
this.pending.set(taskId, {
success,
// short delay in case the task finishes super fast
timer: setTimeout(() => this.show(taskId, { content, delay: 5000 }), 400),
})
}
complete(result: BulkOperationStatus) {
if (
![
PaperlessTaskStatus.Success,
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
].includes(result.status) ||
this.completed.has(result.task_id)
)
return
this.completed.set(result.task_id, result)
// Retain recent outcomes in case task finishes quickly
if (this.completed.size > 100) {
this.completed.delete(this.completed.keys().next().value)
}
const pending = this.pending.get(result.task_id)
if (pending) {
clearTimeout(pending.timer)
this.pending.delete(result.task_id)
}
// Trash needs the caller's document IDs to offer Undo on success.
if (
pending ||
result.operation !== 'delete' ||
result.status !== PaperlessTaskStatus.Success
) {
this.showResult(result, pending?.success)
}
}
private showResult(
result: BulkOperationStatus,
success: Partial<Toast> = {}
) {
const succeeded = result.status === PaperlessTaskStatus.Success
this.show(result.task_id, {
content: succeeded
? $localize`Operation completed.`
: result.status === PaperlessTaskStatus.Revoked
? $localize`Operation revoked.`
: $localize`Operation failed. Some changes may already have been applied.`,
delay: succeeded ? 5000 : 10000,
level: succeeded ? ToastLevel.Success : ToastLevel.Error,
...(succeeded ? success : { error: result.error }),
})
}
private show(taskId: string, toast: Toast) {
const canViewTasks = this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.PaperlessTask
)
this.toastService.show({
id: `task-${taskId}`,
...(canViewTasks
? {
actionName: $localize`View task`,
action: () =>
this.router.navigate(['/tasks'], {
queryParams: { task_id: taskId },
}),
}
: {}),
...toast,
})
}
}
@@ -38,6 +38,29 @@ describe('ToastService', () => {
})
})
it('updates a notification by ID without duplicating or moving it', () => {
let notifications: Toast[] = []
toastService
.getToasts()
.subscribe((toasts) => (notifications = [...toasts]))
toastService.show({ id: 'task-1', content: 'Queued', delay: 5000 })
toastService.show({
id: 'other',
content: 'Another notification',
delay: 5000,
})
toastService.show({
id: 'task-1',
content: 'Completed',
delay: 5000,
level: ToastLevel.Success,
})
expect(notifications.map((toast) => toast.content)).toEqual([
'Another notification',
'Completed',
])
})
it('parses error string to object on show', () => {
const toast = {
title: 'Title',
+8 -1
View File
@@ -75,7 +75,14 @@ export class ToastService {
if (persistent) {
// the dropdown is open, so it's seen as it arrives
toast.read = !!this._suppressPopupToasts
this.toasts.unshift(toast)
const index = this.toasts.findIndex(
(existing) => existing.id === toast.id
)
if (index === -1) {
this.toasts.unshift(toast)
} else {
this.toasts[index] = toast
}
this.toastsSubject.next(this.toasts)
}
// Popups are suppressed while the dropdown is open
+51 -1
View File
@@ -5,8 +5,10 @@ import {
HttpTestingController,
} from '@angular/common/http/testing'
import { environment } from 'src/environments/environment'
import { PaperlessTaskStatus } from '../data/paperless-task'
import { DocumentListViewService } from './document-list-view.service'
import { Toast, ToastService } from './toast.service'
import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
import { TrashService } from './trash.service'
describe('TrashService', () => {
@@ -105,4 +107,52 @@ describe('TrashService', () => {
expect(toast.content).toEqual('Moved 500 document(s) to the trash')
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')
})
})
+30 -5
View File
@@ -5,7 +5,8 @@ import { environment } from 'src/environments/environment'
import { Document } from '../data/document'
import { Results } from '../data/results'
import { DocumentListViewService } from './document-list-view.service'
import { ToastLevel, ToastService } from './toast.service'
import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
@Injectable({
providedIn: 'root',
@@ -14,6 +15,22 @@ export class TrashService {
private http = inject(HttpClient)
private toastService = inject(ToastService)
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>> {
const httpParams = new HttpParams().set('page', page.toString())
@@ -43,14 +60,22 @@ export class TrashService {
* Shows a toast with an Undo action when possible
*/
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
this.toastService.show({
return {
content,
delay: 10000,
level: ToastLevel.Success,
actionName: canUndo ? $localize`Undo` : undefined,
action: canUndo ? () => this.undoMoveToTrash(documents) : undefined,
})
...(canUndo
? {
actionName: $localize`Undo`,
action: () => this.undoMoveToTrash(documents),
}
: {}),
}
}
private undoMoveToTrash(documents: number[]) {
+3 -2
View File
@@ -427,6 +427,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
"Detected a possible incompatible database column. See https://docs.paperless-ngx.com/troubleshooting/#convert-uuid-field",
)
logger.error(f"Error deleting documents: {e!s}")
raise
return "OK"
@@ -474,9 +475,9 @@ def run_workflow(
"""
Manually run a single workflow against the given documents.
Done asynchronously via a Celery task.
Called from the tracked bulk operation task.
"""
run_workflows_for_documents.delay(
run_workflows_for_documents(
document_ids=doc_ids,
workflow_id=workflow,
filter_documents=filter_documents,
+65 -2
View File
@@ -1,4 +1,4 @@
"""Queued document metadata edits."""
"""Queued document bulk operations."""
from typing import Any
@@ -12,6 +12,7 @@ from rest_framework.exceptions import PermissionDenied
from documents import bulk_edit
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import PaperlessTask
from documents.permissions import has_document_operation_permissions
from documents.tasks import bulk_update_documents
@@ -25,7 +26,35 @@ MODIFIED_FIELDS = {
"modify_custom_fields": "custom_fields",
"set_permissions": None,
}
QUEUED_OPERATIONS = frozenset(MODIFIED_FIELDS)
LEGACY_MODIFIED_FIELDS = {"delete": "deleted_at", "reprocess": "checksum"}
QUEUED_OPERATIONS = frozenset(
(
*MODIFIED_FIELDS,
"delete",
"reprocess",
"run_workflow",
"rotate",
"merge",
"merge_as_versions",
"split",
"delete_pages",
"edit_pdf",
"remove_password",
),
)
USER_OPERATIONS = frozenset(
{
"split",
"merge",
"rotate",
"delete_pages",
"edit_pdf",
"remove_password",
"merge_as_versions",
},
)
# merge_as_versions doesn't queue any consume tasks
TRIGGER_SOURCE_OPERATIONS = USER_OPERATIONS - {"merge_as_versions"}
def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
@@ -66,6 +95,7 @@ def execute_bulk_operation(
document_ids: list[int],
parameters: dict,
user_id: int,
trigger_source: str = PaperlessTask.TriggerSource.API_UPLOAD,
) -> dict:
if operation not in QUEUED_OPERATIONS:
raise ValueError(f"Unsupported bulk operation: {operation}")
@@ -79,6 +109,39 @@ def 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] = []
modified_field = MODIFIED_FIELDS[operation]
# Keep metadata writes and their audit entries together. Follow-up processing
-1
View File
@@ -789,7 +789,6 @@ def has_document_operation_permissions(
bulk_edit.edit_pdf,
bulk_edit.merge_as_versions,
bulk_edit.remove_password,
bulk_edit.run_workflow,
]
)
or (
+3 -1
View File
@@ -1688,6 +1688,7 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
root_document_id = serializers.IntegerField(required=True)
from_webui = serializers.BooleanField(required=False, default=False)
version_label = serializers.CharField(
required=False,
allow_blank=True,
@@ -1801,11 +1802,12 @@ class RemovePasswordDocumentsSerializer(
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
pass
from_webui = serializers.BooleanField(required=False, default=False)
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
remote_ocr = serializers.BooleanField(required=False, default=False)
from_webui = serializers.BooleanField(required=False, default=False)
class BulkEditSerializer(
+2
View File
@@ -1206,6 +1206,8 @@ def before_task_publish_handler(
"document_count": len(task_kwargs["document_ids"]),
}
owner_id = task_kwargs["user_id"]
if task_kwargs["operation"] == "delete":
task_type = PaperlessTask.TaskType.BULK_DELETE
else:
input_data = _extract_input_data(task_type, task_kwargs)
owner_id = _extract_owner_id(task_type, task_kwargs)
+2 -3
View File
@@ -703,12 +703,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.views.bulk_edit.run_workflow")
def test_run_workflow_requires_ownership(self, m) -> None:
"""A non-owner with change permission cannot run mutating workflow actions."""
"""A non-owner without document ownership is forbidden (actions mutate docs)."""
self.setup_mock(m, "run_workflow")
user = UserFactory(username="non_owner")
user = UserFactory(username="no_perms")
grant_all_global(user)
doc = DocumentFactory(owner=UserFactory(username="other"))
grant_object(user, doc, "change_document")
workflow = self._make_manual_workflow()
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:
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]
bulk_edit.delete(doc_ids)
with self.assertLogs(level="WARNING") as cm:
bulk_edit.delete(doc_ids)
self.assertIn("possible incompatible database column", cm.output[0])
with self.assertRaisesRegex(Exception, "Data too long for column"):
bulk_edit.delete(doc_ids)
self.assertIn("possible incompatible database column", cm.output[0])
class TestPDFActions(DirectoriesMixin, TestCase):
+135 -15
View File
@@ -54,6 +54,7 @@ def test_request_only_queues(bulk_setup, endpoint, operation, parameters):
"document_ids": [doc.pk],
"parameters": parameters,
"user_id": user.pk,
"trigger_source": "web_ui",
}
assert enqueue.call_args.kwargs["headers"]["trigger_source"] == "web_ui"
@@ -102,6 +103,87 @@ def test_permissions_rechecked_on_worker(bulk_setup):
assert doc.correspondent_id is None
@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
def test_queue_failure_does_not_change_documents(bulk_setup):
_, client, doc, correspondent = bulk_setup
@@ -162,6 +244,22 @@ def test_tracked_outcome_records_initiator_and_result(bulk_setup, failed):
assert task.result_data == {"document_count": 1}
@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
def test_large_filtered_selection_is_one_task(bulk_setup):
_, client, _, _ = bulk_setup
@@ -284,24 +382,46 @@ async def test_completion_socket_is_private(anyio_backend):
@pytest.mark.django_db
@pytest.mark.parametrize("endpoint", ["delete", "bulk_edit"])
def test_trash_remains_synchronous(bulk_setup, endpoint):
_, client, doc, _ = bulk_setup
data = {"documents": [doc.pk]}
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"),
):
def test_legacy_trash_queues_without_deleting_in_request(bulk_setup):
user, client, doc, _ = bulk_setup
data = {"documents": [doc.pk], "method": "delete", "parameters": {}}
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
enqueue.return_value.id = "delete-task"
response = client.post(
f"/api/documents/{endpoint}/",
"/api/documents/bulk_edit/",
data,
format="json",
HTTP_ACCEPT="application/json; version=9",
)
assert response.status_code == 200
assert response.data == {"result": "OK"}
assert not Document.objects.filter(pk=doc.pk).exists()
assert Document.deleted_objects.filter(pk=doc.pk).exists()
enqueue.assert_not_called()
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",
}
@pytest.mark.django_db
def test_dedicated_trash_queues_without_deleting_in_request(bulk_setup):
user, client, doc, _ = bulk_setup
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
enqueue.return_value.id = "delete-task"
response = client.post(
"/api/documents/delete/",
{"documents": [doc.pk]},
format="json",
)
assert response.status_code == 200
assert response.data == {"result": "OK", "task_id": "delete-task"}
assert Document.objects.filter(pk=doc.pk).exists()
assert enqueue.call_args.kwargs["kwargs"] == {
"operation": "delete",
"document_ids": [doc.pk],
"parameters": {},
"user_id": user.pk,
"trigger_source": "api_upload",
}
@@ -8,6 +8,7 @@ from rest_framework import status
from rest_framework.test import APITestCase
from documents.bulk_edit import merge_as_versions
from documents.bulk_operations import execute_bulk_operation
from documents.models import Document
from documents.serialisers import MergeDocumentsAsVersionsSerializer
from paperless_testing.factories import UserFactory
@@ -34,6 +35,7 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"root_document_id": self.doc2.id,
"from_webui": False,
},
)
@@ -348,6 +350,17 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
owner=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")
def test_merges_documents_as_versions(self, merge_mock) -> None:
@@ -367,7 +380,10 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {"result": "OK"})
self.assertEqual(
response.data,
{"result": "OK", "task_id": "test-bulk-operation"},
)
merge_mock.assert_called_once_with(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc2.id,
+35 -81
View File
@@ -107,9 +107,9 @@ from documents import bulk_edit
from documents.bulk_download import ArchiveOnlyStrategy
from documents.bulk_download import OriginalAndArchiveStrategy
from documents.bulk_download import OriginalsOnlyStrategy
from documents.bulk_operations import QUEUED_OPERATIONS
from documents.bulk_operations import TRIGGER_SOURCE_OPERATIONS
from documents.bulk_operations import USER_OPERATIONS
from documents.bulk_operations import execute_bulk_operation
from documents.bulk_operations import snapshot_field
from documents.caching import get_llm_suggestion_cache
from documents.caching import get_metadata_cache
from documents.caching import get_suggestion_cache
@@ -2974,38 +2974,30 @@ class DocumentSelectionMixin:
class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
permission_classes = (IsAuthenticated,)
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):
if "update_callback" in parameters:
raise ValueError("Unsupported bulk operation parameter: update_callback")
signature(method).bind(documents, **parameters)
trigger_source = (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
)
final_parameters = parameters.copy()
if method.__name__ in USER_OPERATIONS:
final_parameters["user"] = self.request.user
if method.__name__ in TRIGGER_SOURCE_OPERATIONS:
final_parameters["trigger_source"] = trigger_source
signature(method).bind(documents, **final_parameters)
task = execute_bulk_operation.apply_async(
kwargs={
"operation": method.__name__,
"document_ids": documents,
"parameters": parameters,
"user_id": self.request.user.pk,
"trigger_source": trigger_source,
},
headers={
"trigger_source": (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
),
},
headers={"trigger_source": trigger_source},
)
return Response({"result": "OK", "task_id": task.id})
@@ -3129,15 +3121,6 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
user = self.request.user
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(
user=user,
documents=documents,
@@ -3147,8 +3130,12 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
return HttpResponseForbidden("Insufficient permissions")
try:
result = method(documents, **parameters)
return Response({"result": result})
return self._queue_bulk_operation(
method,
documents,
parameters,
from_webui,
)
except Exception as e:
logger.warning(f"An error occurred performing {operation_label}: {e!s}")
return HttpResponseBadRequest(
@@ -3169,19 +3156,13 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
name="BulkEditDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(required=False),
"task_id": serializers.CharField(),
},
),
},
),
)
class BulkEditView(DocumentOperationPermissionMixin):
LEGACY_MODIFIED_FIELDS = {
"delete": "deleted_at",
"reprocess": "checksum",
"remove_password": None,
}
serializer_class = BulkEditSerializer
def post(self, request, *args, **kwargs):
@@ -3211,14 +3192,6 @@ class BulkEditView(DocumentOperationPermissionMixin):
user=user,
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(
user=user,
documents=documents,
@@ -3228,38 +3201,12 @@ class BulkEditView(DocumentOperationPermissionMixin):
return HttpResponseForbidden("Insufficient permissions")
try:
if method.__name__ in QUEUED_OPERATIONS:
return self._queue_bulk_operation(
method,
documents,
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})
return self._queue_bulk_operation(
method,
documents,
parameters,
from_webui,
)
except Exception as e:
logger.warning(f"An error occurred performing bulk edit: {e!s}")
return HttpResponseBadRequest(
@@ -3276,6 +3223,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
name="RotateDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3303,6 +3251,7 @@ class RotateDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3330,6 +3279,7 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsAsVersionsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3357,6 +3307,7 @@ class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
name="DeleteDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3384,6 +3335,7 @@ class DeleteDocumentsView(DocumentOperationPermissionMixin):
name="ReprocessDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3411,6 +3363,7 @@ class ReprocessDocumentsView(DocumentOperationPermissionMixin):
name="EditPdfDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3438,6 +3391,7 @@ class EditPdfDocumentsView(DocumentOperationPermissionMixin):
name="RemovePasswordDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
-23
View File
@@ -1,23 +0,0 @@
from rest_framework import serializers
OBFUSCATED_PASSWORD_MASK = "*" * 10
def is_obfuscated_password(value: str) -> bool:
"""
True if the value is only mask characters (or empty), meaning a client echoed
the placeholder back rather than supplying a new secret
"""
return value.replace("*", "") == ""
class ObfuscatedPasswordField(serializers.CharField):
"""
Sends a constant mask string instead of the password in the clear
"""
def to_representation(self, value) -> str:
return OBFUSCATED_PASSWORD_MASK
def to_internal_value(self, data):
return data
+3 -4
View File
@@ -20,19 +20,18 @@ from rest_framework import serializers
from rest_framework.authtoken.serializers import AuthTokenSerializer
from paperless.config import OCR_RESERVED_USER_ARGS
from paperless.fields import ObfuscatedPasswordField
from paperless.fields import is_obfuscated_password
from paperless.models import ApplicationConfiguration
from paperless.network import validate_outbound_http_url
from paperless.validators import reject_dangerous_svg
from paperless.validators import validate_raster_image
from paperless_mail.serialisers import ObfuscatedPasswordField
logger = logging.getLogger("paperless.settings")
class PasswordValidationMixin:
def _has_real_password(self, value: str | None) -> bool:
return bool(value) and not is_obfuscated_password(value)
return bool(value) and value.replace("*", "") != ""
def validate_password(self, value: str) -> str:
if not self._has_real_password(value):
@@ -286,7 +285,7 @@ class ApplicationConfigurationSerializer(
if data[field] == "":
data[field] = None
# Not a real value, don't overwrite the stored one
elif is_obfuscated_password(data[field]):
elif len(data[field].replace("*", "")) == 0:
del data[field]
return super().run_validation(data)
@@ -1,39 +0,0 @@
import pytest
from paperless.fields import OBFUSCATED_PASSWORD_MASK
from paperless.fields import ObfuscatedPasswordField
from paperless.fields import is_obfuscated_password
class TestObfuscatedPasswordField:
@pytest.mark.parametrize(
"secret",
[
"a",
"short",
"exactly10!",
"a-much-longer-secret-that-should-not-leak-its-length",
],
)
def test_representation_is_constant_regardless_of_length(self, secret: str) -> None:
assert (
ObfuscatedPasswordField().to_representation(secret)
== OBFUSCATED_PASSWORD_MASK
)
class TestIsObfuscatedPassword:
@pytest.mark.parametrize(
("value", "expected"),
[
("", True),
("*", True),
("******", True),
(OBFUSCATED_PASSWORD_MASK, True),
("real-secret", False),
("***x***", False),
(" ", False),
],
)
def test_detects_placeholder(self, value: str, *, expected: bool) -> None:
assert is_obfuscated_password(value) is expected
+1 -2
View File
@@ -41,7 +41,6 @@ from documents.models import PaperlessTask
from documents.permissions import PaperlessModelPermissions
from documents.permissions import PaperlessObjectPermissions
from documents.tasks import llmindex_index
from paperless.fields import is_obfuscated_password
from paperless.filters import GroupFilterSet
from paperless.filters import UserFilterSet
from paperless.models import ApplicationConfiguration
@@ -261,7 +260,7 @@ class ProfileView(GenericAPIView[Any]):
user = self.request.user if hasattr(self.request, "user") else None
password = serializer.validated_data.pop("password", None)
if password and not is_obfuscated_password(password):
if password and password.replace("*", ""):
user.set_password(password)
user.save()
+15 -4
View File
@@ -8,13 +8,23 @@ from documents.serialisers import CorrespondentField
from documents.serialisers import DocumentTypeField
from documents.serialisers import OwnedObjectSerializer
from documents.serialisers import TagsField
from paperless.fields import ObfuscatedPasswordField
from paperless.fields import is_obfuscated_password
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail
class ObfuscatedPasswordField(serializers.CharField):
"""
Sends *** string instead of password in the clear
"""
def to_representation(self, value) -> str:
return "*" * max(10, len(value))
def to_internal_value(self, data):
return data
class MailAccountSerializer(OwnedObjectSerializer):
password = ObfuscatedPasswordField()
imap_port = serializers.IntegerField(required=True, allow_null=False)
@@ -40,8 +50,9 @@ class MailAccountSerializer(OwnedObjectSerializer):
]
def update(self, instance, validated_data):
if "password" in validated_data and is_obfuscated_password(
validated_data["password"],
if (
"password" in validated_data
and len(validated_data.get("password").replace("*", "")) == 0
):
validated_data.pop("password")
super().update(instance, validated_data)
+3 -3
View File
@@ -30,7 +30,6 @@ from documents.permissions import PaperlessObjectPermissions
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_object_ids
from documents.views import PassUserMixin
from paperless.fields import is_obfuscated_password
from paperless.views import StandardPagination
from paperless_mail.filters import ProcessedMailFilterSet
from paperless_mail.mail import MailError
@@ -127,8 +126,9 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
return HttpResponseForbidden("Insufficient permissions")
# account exists, use the password from there instead of ***
if existing_account is not None and is_obfuscated_password(
serializer.validated_data.get("password"),
if (
len(serializer.validated_data.get("password").replace("*", "")) == 0
and existing_account is not None
):
serializer.validated_data["password"] = existing_account.password
serializer.validated_data["account_type"] = existing_account.account_type