Compare commits

..
Author SHA1 Message Date
shamoon aad70575b7 Tweak/fix: auto-close CF dropdown 2026-11-13 10:10:06 -08:00
58 changed files with 599 additions and 2849 deletions

No files matched your search

+2 -8
View File
@@ -285,14 +285,8 @@ The API supports various bulk-editing operations which are executed asynchronous
### Documents
Document bulk operations return `{"result":"OK","task_id":"…"}` when queued.
Check `/api/tasks/?task_id={uuid}` for the outcome, including any follow-up processing,
deletion, or recovery. Failed operations may leave partial changes applied.
For operations with child tasks, `result_data` includes `succeeded_count` and
`failed_count` (task counts). Merges report skipped inputs in `failed_document_ids`.
The `/api/documents/bulk_edit/` endpoint accepts:
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
a json payload of the format:
```json
{
+3 -4
View File
@@ -587,10 +587,9 @@ documents from the web UI:
- From the document list, select one or more documents and choose **Run workflow** from the "Actions" menu.
- From an individual document, choose **Run workflow** from the "Actions" menu.
You will be asked which workflow to run. From the document list, the workflow's trigger filters apply by default, so
documents that do not match are skipped. You can choose to **run on all selected documents** to ignore the filters.
From an individual document, the option to ignore trigger filters is selected by default. Clear it to run the workflow
only if the document matches its trigger filters.
You will be asked which workflow to run. By default the workflow's trigger filters still apply, so documents that do
not match are skipped. You can instead choose to **run on all selected documents**, which ignores the trigger filters
and runs the workflow's actions on every selected document.
Manual runs happen in the background. As with scheduled workflows, the document list is not refreshed automatically when
they finish, so you may need to reload the list to see the results.
+80 -105
View File
@@ -1366,7 +1366,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1973</context>
<context context-type="linenumber">1971</context>
</context-group>
</trans-unit>
<trans-unit id="1577733187050997705" datatype="html">
@@ -1940,10 +1940,6 @@
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.ts</context>
<context context-type="linenumber">45</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.ts</context>
<context context-type="linenumber">59</context>
</context-group>
</trans-unit>
<trans-unit id="3066660568529853846" datatype="html">
<source>Error retrieving groups</source>
@@ -3424,23 +3420,23 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1974</context>
<context context-type="linenumber">1972</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">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>
@@ -4203,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">
@@ -4343,7 +4339,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2027</context>
<context context-type="linenumber">2025</context>
</context-group>
</trans-unit>
<trans-unit id="6661109599266152398" datatype="html">
@@ -4354,7 +4350,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2028</context>
<context context-type="linenumber">2026</context>
</context-group>
</trans-unit>
<trans-unit id="5162686434580248853" datatype="html">
@@ -4365,7 +4361,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2029</context>
<context context-type="linenumber">2027</context>
</context-group>
</trans-unit>
<trans-unit id="6665634854532231106" datatype="html">
@@ -4417,32 +4413,18 @@
<context context-type="linenumber">13,14</context>
</context-group>
</trans-unit>
<trans-unit id="261810320774538615" datatype="html">
<source>Run on this document regardless of trigger filters</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
<context context-type="linenumber">22,23</context>
</context-group>
</trans-unit>
<trans-unit id="2322522720854680528" datatype="html">
<source>Ignore the workflow&apos;s trigger filters for this document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
<context context-type="linenumber">23,24</context>
</context-group>
</trans-unit>
<trans-unit id="4512110365422311821" datatype="html">
<source>Run on all selected documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
<context context-type="linenumber">25,26</context>
<context context-type="linenumber">21,22</context>
</context-group>
</trans-unit>
<trans-unit id="6575910678008555513" datatype="html">
<source>Ignore the workflow&apos;s trigger filters and run on every selected document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
<context context-type="linenumber">26,27</context>
<context context-type="linenumber">22,23</context>
</context-group>
</trans-unit>
<trans-unit id="2509141182388535183" datatype="html">
@@ -6672,7 +6654,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">
@@ -8449,7 +8431,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>
@@ -8778,7 +8760,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">
@@ -9265,7 +9247,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">
@@ -9290,7 +9272,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">
@@ -9322,7 +9304,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1609</context>
<context context-type="linenumber">1607</context>
</context-group>
</trans-unit>
<trans-unit id="838042392151392247" datatype="html">
@@ -9340,88 +9322,88 @@
</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">
<source>Workflow will run on &quot;<x id="PH" equiv-text="this.document().title"/>&quot; in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1602</context>
<context context-type="linenumber">1600</context>
</context-group>
</trans-unit>
<trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1673</context>
<context context-type="linenumber">1671</context>
</context-group>
</trans-unit>
<trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1751</context>
<context context-type="linenumber">1749</context>
</context-group>
</trans-unit>
<trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1994</context>
<context context-type="linenumber">1992</context>
</context-group>
</trans-unit>
<trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2006</context>
<context context-type="linenumber">2004</context>
</context-group>
</trans-unit>
<trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2017</context>
<context context-type="linenumber">2015</context>
</context-group>
</trans-unit>
<trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2051</context>
<context context-type="linenumber">2049</context>
</context-group>
</trans-unit>
<trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2065</context>
<context context-type="linenumber">2063</context>
</context-group>
</trans-unit>
<trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2115</context>
<context context-type="linenumber">2113</context>
</context-group>
</trans-unit>
<trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2124</context>
<context context-type="linenumber">2122</context>
</context-group>
</trans-unit>
<trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2207</context>
<context context-type="linenumber">2205</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2213</context>
<context context-type="linenumber">2211</context>
</context-group>
</trans-unit>
<trans-unit id="4958946940233632319" datatype="html">
@@ -9705,7 +9687,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">
@@ -9740,53 +9722,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>
@@ -9794,7 +9769,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>
@@ -9802,14 +9777,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">
@@ -9818,14 +9793,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">
@@ -9834,7 +9809,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">
@@ -9845,84 +9820,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">
@@ -9931,14 +9906,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">
@@ -9947,7 +9922,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">
@@ -9958,105 +9933,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">
@@ -175,13 +175,7 @@
</div>
</td>
<td class="overflow-auto name-col">
@let documentCount = taskDocumentCount(task);
<div>
{{ taskDisplayName(task) }}
@if (documentCount !== null && documentCount > 1) {
<small class="text-muted ms-1" i18n>({documentCount, plural, =1 {1 document} other {{{ documentCount }} documents}})</small>
}
</div>
<div>{{ taskDisplayName(task) }}</div>
<div class="small text-muted">
@if (taskShowsSeparateTypeLabel(task)) {
<span class="me-2">{{ task.task_type_display }}</span>
@@ -895,117 +895,6 @@ describe('TasksComponent', () => {
expect(sanityTaskRow.nativeElement.textContent).toContain('System')
})
it.each([
['modify_tags', 12, 'Bulk operation: Update tags', '(12 documents)'],
['run_workflow', 1, 'Run workflow', null],
['delete', 3, 'Bulk operation: Move to trash', '(3 documents)'],
['reprocess', 2, 'Bulk operation: Document reprocessing', '(2 documents)'],
])(
'shows the operation, document count and outcome for %s',
(operation, count, label, summary) => {
const task: PaperlessTask = {
...tasks[4],
task_type:
operation === 'delete'
? PaperlessTaskType.BulkDelete
: PaperlessTaskType.BulkUpdate,
task_type_display:
operation === 'delete' ? 'Bulk Delete' : 'Bulk Update',
trigger_source: PaperlessTaskTriggerSource.WebUI,
trigger_source_display: 'Web UI',
input_data: { operation, document_count: count },
result_data: { operation, document_count: count },
}
component.setSection(TaskSection.Completed)
flushTaskLists(25, 1, [task])
fixture.detectChanges()
const name = fixture.nativeElement.querySelector('tbody .name-col')
expect(name.textContent).toContain(label)
const countLabel = name.querySelector('small.text-muted')
if (summary) {
expect(countLabel.textContent).toContain(summary)
} else {
expect(countLabel).toBeNull()
expect(name.textContent).not.toContain('Bulk operation:')
}
expect(name.textContent).not.toContain('·')
expect(name.textContent).toContain('Web UI')
expect(name.textContent).not.toContain(task.task_type_display)
expect(
name.closest('tr').querySelector('.results-col').textContent
).toContain(
[
'reprocess',
'rotate',
'merge',
'split',
'delete_pages',
'edit_pdf',
'remove_password',
].includes(operation)
? 'Submitted for processing'
: 'Completed'
)
}
)
it('shows operation context while work is pending without reporting completion', () => {
const task = {
...tasks[2],
task_type: PaperlessTaskType.BulkUpdate,
task_type_display: 'Bulk Update',
input_data: { operation: 'reprocess', document_count: 1 },
}
component.setSection(TaskSection.InProgress)
flushTaskLists(25, 1, [task])
fixture.detectChanges()
const name = fixture.nativeElement.querySelector('tbody .name-col')
expect(name.textContent).toContain('Document reprocessing')
expect(name.textContent).not.toContain('Bulk operation:')
expect(name.querySelector('small')).toBeNull()
expect(component.taskResultMessage(task)).toBeNull()
})
it.each([{}, { operation: 'future_operation' }])(
'preserves the fallback for legacy or unknown operation data: %j',
(input_data) => {
const task = {
...tasks[4],
task_type: PaperlessTaskType.BulkUpdate,
task_type_display: 'Bulk Update',
input_data,
result_data: {},
}
expect(component.taskDisplayName(task)).toBe('Bulk Update')
expect(component.taskDocumentCount(task)).toBeNull()
expect(component.taskResultMessage(task)).toBeNull()
}
)
it.each([-1, 1.5, '2', null, undefined])(
'does not display an invalid document count: %s',
(document_count) => {
expect(
component.taskDocumentCount({
...tasks[4],
input_data: { operation: 'modify_tags', document_count },
})
).toBeNull()
}
)
it('retains error details for failed operation tasks', () => {
expect(
component.taskResultMessage({
...tasks[0],
input_data: { operation: 'modify_tags', document_count: 12 },
result_data: { error_message: 'Insufficient permissions' },
})
).toBe('Insufficient permissions')
})
it('should filter tasks by result', () => {
component.setSection(TaskSection.NeedsAttention)
component.filterTargetID = 1
@@ -33,10 +33,6 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import {
documentOperationHasFollowUpTasks,
getDocumentOperationLabel,
} from 'src/app/utils/document-operation'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -418,30 +414,11 @@ export class TasksComponent
}
taskDisplayName(task: PaperlessTask): string {
const filename = task.input_data?.filename?.toString()
if (filename) return filename
const operation = this.taskOperationLabel(task)
const count = this.taskDocumentCount(task)
if (operation && count !== null && count > 1) {
return $localize`Bulk operation: ${operation}`
}
return operation || task.task_type_display
return task.input_data?.filename?.toString() || task.task_type_display
}
taskShowsSeparateTypeLabel(task: PaperlessTask): boolean {
return (
!this.taskOperationLabel(task) &&
this.taskDisplayName(task) !== task.task_type_display
)
}
private taskOperationLabel(task: PaperlessTask): string | null {
return getDocumentOperationLabel(task.input_data?.operation)
}
taskDocumentCount(task: PaperlessTask): number | null {
const count = task.input_data?.document_count
return Number.isInteger(count) && count >= 0 ? count : null
return this.taskDisplayName(task) !== task.task_type_display
}
taskResultMessage(task: PaperlessTask): string | null {
@@ -469,15 +446,6 @@ export class TasksComponent
return errorMessage
}
if (
task.status === PaperlessTaskStatus.Success &&
this.taskOperationLabel(task)
) {
return documentOperationHasFollowUpTasks(task.input_data?.operation)
? $localize`Submitted for processing`
: $localize`Completed`
}
return null
}
@@ -33,7 +33,6 @@ import { RemoteVersionService } from 'src/app/services/rest/remote-version.servi
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { 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'
@@ -199,14 +198,14 @@ describe('AppFrameComponent', () => {
fixture.detectChanges()
})
it('refreshes and updates the task notification on completion', () => {
it('refreshes and notifies on completion without client-side task tracking', () => {
const list = TestBed.inject(DocumentListViewService)
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
const tasksReload = jest
.spyOn(tasksService, 'reload')
.mockImplementation(() => {})
const success = jest.spyOn(toastService, 'show')
const success = jest.spyOn(toastService, 'showSuccess')
const refresh = jest
.spyOn(openDocumentsService, 'refreshDocument')
.mockImplementation(() => {})
@@ -231,12 +230,7 @@ describe('AppFrameComponent', () => {
expect(refresh).toHaveBeenCalledWith(document.id)
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
expect(tasksReload).toHaveBeenCalledTimes(1)
expect(success).toHaveBeenCalledWith(
expect.objectContaining({
id: 'task-bulk-task',
content: "Operation 'Set permissions' completed.",
})
)
expect(success).toHaveBeenCalledWith('Bulk operation completed.')
fixture.destroy()
bulkEvents.next({
task_id: 'bulk-task',
@@ -251,14 +245,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, 'show')
const error = jest.spyOn(toastService, 'showError')
const success = jest.spyOn(toastService, 'showSuccess')
for (const status of [
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
]) {
bulkEvents.next({
task_id: `${status}-task`,
task_id: 'bulk-task',
operation: 'set_permissions',
status,
error:
@@ -268,36 +262,11 @@ describe('AppFrameComponent', () => {
})
}
expect(reload).toHaveBeenCalledTimes(2)
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
content:
"Operation 'Set permissions' failed. Some changes may already have been applied.",
error: 'Could not update',
})
'Bulk operation revoked.',
'Task expired before execution.'
)
expect(error).toHaveBeenCalledWith(
expect.objectContaining({
content: "Operation 'Set permissions' 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,7 +57,6 @@ 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'
@@ -116,7 +115,6 @@ 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)
@@ -184,7 +182,7 @@ export class AppFrameComponent
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((result) => {
.subscribe(({ status, error }) => {
this.documentList.reload(null, false)
this.documentList.reduceSelectionToFilter()
// maybe someday we can optimize to only refresh affected documents
@@ -200,7 +198,16 @@ export class AppFrameComponent
) {
this.tasksService.reload()
}
this.taskNotifications.complete(result)
if (status === PaperlessTaskStatus.Success) {
this.toastService.showSuccess($localize`Bulk operation completed.`)
} else if (status === PaperlessTaskStatus.Revoked) {
this.toastService.showError($localize`Bulk operation revoked.`, error)
} else {
this.toastService.showError(
$localize`Bulk operation failed. Some changes may already have been applied.`,
error
)
}
})
this.lastScrollY = window.scrollY
@@ -18,13 +18,8 @@
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="runWorkflowForce" [(ngModel)]="force" />
@if (singleDocument) {
<label class="form-check-label" for="runWorkflowForce" i18n>Run on this document regardless of trigger filters</label>
<div class="form-text" i18n>Ignore the workflow's trigger filters for this document.</div>
} @else {
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
}
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
</div>
</div>
<div class="modal-footer">
@@ -39,25 +39,4 @@ describe('RunWorkflowConfirmDialogComponent', () => {
expect(options).toHaveLength(2)
expect(options[1].textContent).toContain('Manual workflow')
})
it('should describe the checkbox for a selection or a single document', async () => {
const checkbox = fixture.nativeElement.querySelector('#runWorkflowForce')
expect(checkbox.checked).toBe(false)
expect(fixture.nativeElement.textContent).toContain(
'Run on all selected documents'
)
component.singleDocument = true
component.force = true
fixture.detectChanges()
await fixture.whenStable()
fixture.detectChanges()
expect(checkbox.checked).toBe(true)
expect(fixture.nativeElement.textContent).toContain(
'Run on this document regardless of trigger filters'
)
expect(fixture.nativeElement.textContent).not.toContain(
'Run on all selected documents'
)
})
})
@@ -13,8 +13,6 @@ export class RunWorkflowConfirmDialogComponent extends ConfirmDialogComponent {
public workflowId: number = null
public singleDocument: boolean = false
// i.e. ignore the workflow's trigger filters
public force: boolean = false
@@ -4,6 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import {
NgbDropdown,
NgbDropdownModule,
NgbModal,
NgbModalModule,
@@ -250,4 +251,14 @@ describe('CustomFieldsDropdownComponent', () => {
expect(createFieldSpy).not.toHaveBeenCalled()
expect(addFieldSpy).not.toHaveBeenCalled()
})
it('should close the dropdown after selecting a filtered field', () => {
const dropdown = fixture.debugElement
.query(By.directive(NgbDropdown))
.injector.get(NgbDropdown)
dropdown.open()
component.filterText = 'Field 1'
component.listFilterEnter()
expect(dropdown.isOpen()).toBe(false)
})
})
@@ -11,7 +11,11 @@ import {
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import {
NgbDropdown,
NgbDropdownModule,
NgbModal,
} from '@ng-bootstrap/ng-bootstrap'
import { first, takeUntil } from 'rxjs'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
@@ -63,6 +67,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
created: EventEmitter<CustomField> = new EventEmitter()
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
@ViewChild('fieldDropdown') fieldDropdown: NgbDropdown
@ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = []
@@ -120,6 +125,7 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
addField(field: CustomField) {
this.added.emit(field)
this.fieldDropdown.close()
this.updateUnusedFields()
}
@@ -7,7 +7,6 @@
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);
@@ -24,13 +23,6 @@
.toast-icon {
color: var(--pngx-toast-accent);
transition: color .18s ease;
}
@media (prefers-reduced-motion: reduce) {
.toast, .toast-icon {
transition: none;
}
}
.toast-content {
@@ -65,36 +65,6 @@ 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,10 +5,8 @@ import {
DestroyRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild,
inject,
signal,
@@ -36,10 +34,10 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
styleUrl: './toast.component.scss',
providers: [DatePipe, CustomDatePipe],
})
export class ToastComponent implements OnInit, OnChanges {
private readonly clipboard = inject(Clipboard)
private readonly customDatePipe = inject(CustomDatePipe)
private readonly destroyRef = inject(DestroyRef)
export class ToastComponent implements OnInit {
private clipboard = inject(Clipboard)
private customDatePipe = inject(CustomDatePipe)
private destroyRef = inject(DestroyRef)
readonly copied = signal(false)
readonly ToastLevel = ToastLevel
readonly delayRemaining = signal<number>(null)
@@ -71,12 +69,6 @@ export class ToastComponent implements OnInit, OnChanges {
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'])
// Updating a visible toast preserves its position without duplicating it.
// showing a visible toast again moves it to the top without duplicating
toastSubject.next(toasts[1])
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
})
it('should remove a popup when it hides without closing it', () => {
@@ -32,11 +32,10 @@ export class ToastsComponent {
return
}
this.toasts.update((toasts) =>
toasts.some((existing) => existing.id === toast.id)
? toasts.map((existing) =>
existing.id === toast.id ? toast : existing
)
: [toast, ...toasts].slice(0, MAX_VISIBLE_TOASTS)
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
0,
MAX_VISIBLE_TOASTS
)
)
})
}
@@ -1013,85 +1013,48 @@ describe('DocumentDetailComponent', () => {
])
})
it('should queue reprocess, show the task link and close the modal', () => {
jest.useFakeTimers()
it('should support reprocess, confirm and close modal after started', () => {
initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-1' }))
reprocessSpy.mockReturnValue(of(true))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'show')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
expect(toastSpy).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({ result: 'OK', task_id: 'workflow-1' }))
bulkEditSpy.mockReturnValue(of(true))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const toastSpy = jest.spyOn(toastService, 'show')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.runWorkflow()
expect(openModal.componentInstance.singleDocument).toBe(true)
expect(openModal.componentInstance.force).toBe(true)
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.workflowId = 5
openModal.componentInstance.force = true
openModal.componentInstance.confirmClicked.next()
expect(bulkEditSpy).toHaveBeenCalledWith(
{ documents: [doc.id] },
'run_workflow',
{ workflow: 5, filter_documents: false }
)
jest.advanceTimersByTime(400)
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: 'Workflow queued.' })
)
expect(toastSpy).toHaveBeenCalled()
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({ result: 'OK', task_id: 'workflow-2' }))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.runWorkflow()
openModal.componentInstance.workflowId = 5
openModal.componentInstance.force = false
openModal.componentInstance.confirmClicked.next()
expect(bulkEditSpy).toHaveBeenCalledWith(
{ documents: [doc.id] },
'run_workflow',
{ workflow: 5, filter_documents: true }
)
})
it('should pass remote OCR choice when reprocessing', () => {
initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-2' }))
reprocessSpy.mockReturnValue(of(true))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
component.reprocess()
@@ -2020,12 +1983,10 @@ 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()
@@ -2044,7 +2005,6 @@ describe('DocumentDetailComponent', () => {
update_document: false,
include_metadata: true,
source_mode: 'explicit_selection',
from_webui: true,
})
req.error(new ErrorEvent('failed'))
expect(errorSpy).toHaveBeenCalled()
@@ -2057,21 +2017,13 @@ describe('DocumentDetailComponent', () => {
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/edit_pdf/`
)
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()
req.flush(true)
expect(closeSpy).toHaveBeenCalled()
})
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'
@@ -2092,15 +2044,8 @@ describe('DocumentDetailComponent', () => {
include_metadata: false,
delete_original: true,
source_mode: 'explicit_selection',
from_webui: 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()
req.flush(true)
})
it('should require the current password before removing it', () => {
@@ -2152,9 +2097,9 @@ describe('DocumentDetailComponent', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/remove_password/`
)
req.flush({ result: 'OK', task_id: 'remove-password-2' })
req.flush(true)
expect(refreshSpy).not.toHaveBeenCalled()
expect(refreshSpy).toHaveBeenCalledWith(doc.id)
})
it('should support keyboard shortcuts', () => {
@@ -97,7 +97,6 @@ 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'
@@ -227,7 +226,6 @@ 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)
@@ -1560,11 +1558,9 @@ export class DocumentDetailComponent
modal.componentInstance.remoteOcr
)
.subscribe({
next: ({ task_id }) => {
this.taskNotifications.showQueued(
task_id,
$localize`Document reprocessing queued.`,
{ content: $localize`Document submitted for reprocessing.` }
next: () => {
this.toastService.showInfo(
$localize`Reprocess operation for "${this.document().title}" will begin in the background.`
)
if (modal) {
modal.close()
@@ -1591,8 +1587,6 @@ export class DocumentDetailComponent
modal.componentInstance.message = $localize`Run a workflow on "${this.document().title}".`
modal.componentInstance.btnCaption = $localize`Run`
modal.componentInstance.workflows = this.manualWorkflows()
modal.componentInstance.singleDocument = true
modal.componentInstance.force = true
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
this.documentsService
@@ -1601,13 +1595,9 @@ export class DocumentDetailComponent
filter_documents: !modal.componentInstance.force,
})
.subscribe({
next: ({ task_id }) => {
this.taskNotifications.showQueued(
task_id,
$localize`Workflow queued.`,
{
content: $localize`Workflow completed.`,
}
next: () => {
this.toastService.showInfo(
$localize`Workflow will run on "${this.document().title}" in the background.`
)
modal.close()
},
@@ -1997,15 +1987,14 @@ export class DocumentDetailComponent
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: ({ task_id }) => {
this.taskNotifications.showQueued(
task_id,
$localize`PDF edit queued.`,
{
content: $localize`PDF edit submitted for processing.`,
}
next: () => {
this.toastService.showInfo(
$localize`PDF edit operation for "${this.document().title}" will begin in the background.`
)
modal.close()
if (modal.componentInstance.deleteOriginal) {
this.openDocumentService.closeDocument(this.document())
}
},
error: (error) => {
if (modal) {
@@ -2055,16 +2044,17 @@ export class DocumentDetailComponent
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: ({ task_id }) => {
this.taskNotifications.showQueued(
task_id,
$localize`Password removal queued.`,
{
content: $localize`Password removal submitted for processing.`,
}
next: () => {
this.toastService.showInfo(
$localize`Password removal operation for "${this.document().title}" will begin in the background.`
)
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,7 +2,6 @@ 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'
@@ -34,7 +33,7 @@ describe('DocumentVersionDropdownComponent', () => {
>
>
let toastService: jest.Mocked<
Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'>
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
>
let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }>
@@ -55,7 +54,6 @@ describe('DocumentVersionDropdownComponent', () => {
}
modalService = { open: jest.fn() }
toastService = {
show: jest.fn(),
showError: jest.fn(),
showInfo: jest.fn(),
showSuccess: jest.fn(),
@@ -90,10 +88,6 @@ describe('DocumentVersionDropdownComponent', () => {
provide: PermissionsService,
useValue: permissionsService,
},
{
provide: Router,
useValue: { navigate: jest.fn() },
},
{
provide: WebsocketStatusService,
useValue: {
@@ -383,8 +377,7 @@ describe('DocumentVersionDropdownComponent', () => {
expect(component.versionLabelDraft).toEqual('')
})
it('addExistingDocumentAsVersion should queue with a label and wait to refresh versions', () => {
jest.useFakeTimers()
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
const confirmClicked = new Subject<number>()
const modal = {
componentInstance: {
@@ -395,9 +388,14 @@ describe('DocumentVersionDropdownComponent', () => {
close: jest.fn(),
}
modalService.open.mockReturnValue(modal as any)
documentService.mergeDocumentsAsVersions.mockReturnValue(
of({ result: 'OK', task_id: 'versions-1' })
)
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))
component.newVersionLabel = ' Imported '
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
@@ -412,18 +410,11 @@ describe('DocumentVersionDropdownComponent', () => {
'Imported'
)
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
expect(documentService.getVersions).not.toHaveBeenCalled()
expect(versionsEmitSpy).not.toHaveBeenCalled()
expect(selectedEmitSpy).not.toHaveBeenCalled()
expect(documentService.getVersions).toHaveBeenCalledWith(3)
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
expect(component.newVersionLabel).toEqual('')
expect(modal.close).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()
expect(toastService.showSuccess).toHaveBeenCalled()
})
})
@@ -28,7 +28,6 @@ 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,
@@ -78,7 +77,6 @@ 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>()
@@ -307,19 +305,23 @@ 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: ({ task_id }) => {
next: (document) => {
if (document?.versions?.length) {
this.versionsUpdated.emit(document.versions)
// The API returns versions newest first
this.versionSelected.emit(document.versions[0].id)
}
this.newVersionLabel = ''
modal.close()
this.taskNotifications.showQueued(
task_id,
$localize`Existing document queued to be added as a version.`,
{ content: $localize`Existing document added as a version.` }
this.toastService.showSuccess(
$localize`Existing document added as a version.`
)
},
error: (error) => {
@@ -14,8 +14,7 @@ import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
import { Results, SelectionData } from 'src/app/data/results'
import { Results } from 'src/app/data/results'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
@@ -29,7 +28,10 @@ import {
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import {
DocumentService,
SelectionData,
} from 'src/app/services/rest/document.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
@@ -37,7 +39,6 @@ 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 { environment } from 'src/environments/environment'
@@ -685,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)
@@ -700,8 +699,6 @@ describe('BulkEditorComponent', () => {
component.runWorkflowSelected()
expect(modal).not.toBeUndefined()
expect(modal.componentInstance.workflows).toEqual(component.manualWorkflows)
expect(modal.componentInstance.singleDocument).toBe(false)
expect(modal.componentInstance.force).toBe(false)
modal.componentInstance.workflowId = 5
modal.componentInstance.force = true
modal.componentInstance.confirm()
@@ -713,12 +710,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
@@ -727,27 +719,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]))
@@ -1266,15 +1237,14 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/`
)
const trackSpy = jest
.spyOn(trashService, 'trackMovedToTrash')
const notifySpy = jest
.spyOn(trashService, 'notifyMovedToTrash')
.mockImplementation(() => {})
req.flush({ result: 'OK', task_id: 'delete-1' })
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
from_webui: true,
})
expect(trackSpy).toHaveBeenCalledWith('delete-1', 2, [3, 4])
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [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 +1261,9 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/delete/`
)
req.flush({ result: 'OK', task_id: 'delete-2' })
req.flush(true)
// ids aren't known when all matching documents are selected, so no undo
expect(trackSpy).toHaveBeenLastCalledWith('delete-2', 0, null)
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
@@ -1373,11 +1343,10 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/reprocess/`
)
req.flush({ result: 'OK', task_id: 'reprocess-1' })
req.flush(true)
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`
@@ -1388,7 +1357,6 @@ describe('BulkEditorComponent', () => {
})
it('should support rotate', () => {
const toast = jest.spyOn(toastService, 'show')
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@@ -1409,20 +1377,11 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/rotate/`
)
req.flush({ result: 'OK', task_id: 'rotate-1' })
TestBed.inject(TaskNotificationService).complete({
task_id: 'rotate-1',
operation: 'rotate',
status: PaperlessTaskStatus.Success,
})
expect(toast.mock.calls.at(-1)[0].content).toBe(
"Bulk operation 'Rotate documents' submitted for processing."
)
req.flush(true)
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`
@@ -1460,11 +1419,10 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/`
)
req.flush({ result: 'OK', task_id: 'merge-1' })
req.flush(true)
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`
@@ -1479,12 +1437,11 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/`
)
req.flush({ result: 'OK', task_id: 'merge-2' })
req.flush(true)
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`
@@ -1501,12 +1458,11 @@ describe('BulkEditorComponent', () => {
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/merge/`
)
req.flush({ result: 'OK', task_id: 'merge-3' })
req.flush(true)
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`
@@ -1518,7 +1474,6 @@ 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)
@@ -1546,8 +1501,8 @@ describe('BulkEditorComponent', () => {
.mockReturnValue(true)
const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of({ result: 'OK', task_id: 'versions-1' }))
const toastSpy = jest.spyOn(toastService, 'show')
.mockReturnValue(of(true))
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
fixture.detectChanges()
component.mergeSelectedAsVersions()
@@ -1563,21 +1518,9 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
)
expect(documentListViewService.selected.size).toEqual(0)
jest.advanceTimersByTime(400)
expect(toastSpy).toHaveBeenCalledWith(
expect.objectContaining({
content: "Bulk operation 'Merge as versions' queued.",
})
expect(toastSuccessSpy).toHaveBeenCalledWith(
'Documents merged as versions.'
)
TestBed.inject(TaskNotificationService).complete({
task_id: 'versions-1',
operation: 'merge_as_versions',
status: PaperlessTaskStatus.Success,
})
expect(toastSpy.mock.calls.at(-1)[0].content).toBe(
"Bulk operation 'Merge as versions' completed."
)
jest.useRealTimers()
})
it('should not report success when merging documents as versions fails', () => {
@@ -1668,22 +1611,12 @@ describe('BulkEditorComponent', () => {
)
})
it.each<[boolean, number]>([
[true, 2],
[false, 2],
[true, 1],
])(
'names queued work and gates the task link (permission %s, documents %s)',
(canViewTasks, count) => {
jest.useFakeTimers()
it.each([true, false])(
'shows queued feedback and gates the task link on permission (%s)',
(canViewTasks) => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockReturnValue(canViewTasks)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(
new Set(Array.from({ length: count }, (_, index) => index + 3))
)
const queued = jest.spyOn(toastService, 'show')
let modal: NgbModalRef
modalService.activeInstances.subscribe((instances) => {
@@ -1701,11 +1634,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]
const prefix = count > 1 ? 'Bulk operation' : 'Operation'
expect(notification.content).toBe(`${prefix} \'Set permissions\' queued.`)
expect(notification.content).toBe('Bulk operation queued.')
if (canViewTasks) {
expect(notification.actionName).toBe('View task')
notification.action()
@@ -1719,15 +1649,6 @@ describe('BulkEditorComponent', () => {
expect(reload).not.toHaveBeenCalled()
expect(documentListViewService.selected.size).toBe(0)
expect(documentListViewService.allSelected).toBe(false)
TestBed.inject(TaskNotificationService).complete({
task_id: 'queued-operation',
operation: 'set_permissions',
status: PaperlessTaskStatus.Success,
})
const completed = queued.mock.calls.at(-1)[0]
expect(completed.content).toBe(`${prefix} \'Set permissions\' completed.`)
expect(completed.action).toBeUndefined()
jest.useRealTimers()
}
)
@@ -1808,6 +1729,7 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Tag 2' },
],
count: 2,
all: [1, 2],
}
const modalInstance = {
@@ -1850,6 +1772,7 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Correspondent 2' },
],
count: 2,
all: [1, 2],
}
const modalInstance = {
@@ -1898,6 +1821,7 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Document Type 2' },
],
count: 2,
all: [1, 2],
}
const modalInstance = {
@@ -1943,6 +1867,7 @@ describe('BulkEditorComponent', () => {
{ id: 2, name: 'Storage Path 2' },
],
count: 2,
all: [1, 2],
}
const modalInstance = {
@@ -1996,6 +1921,7 @@ describe('BulkEditorComponent', () => {
},
],
count: 2,
all: [1, 2],
}
const modalInstance = {
@@ -19,18 +19,25 @@ import {
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { saveAs } from 'file-saver'
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
import {
first,
map,
Observable,
Subject,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { 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,
@@ -45,18 +52,14 @@ 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 {
documentOperationHasFollowUpTasks,
getDocumentOperationLabel,
} from 'src/app/utils/document-operation'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
@@ -81,6 +84,7 @@ 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',
@@ -106,12 +110,13 @@ 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)
@@ -303,9 +308,7 @@ export class BulkEditorComponent
}
get canRunWorkflow(): boolean {
return (
this.userCanEditAll && this.userOwnsAll && this.manualWorkflows.length > 0
)
return this.userCanEditAll && this.manualWorkflows.length > 0
}
runWorkflowSelected() {
@@ -319,23 +322,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,
})
})
}
@@ -352,62 +342,70 @@ export class BulkEditorComponent
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
.pipe(first())
.subscribe({
next: ({ task_id }) =>
this.handleOperationQueued(modal, task_id, method),
next: ({ task_id }) => this.handleOperationQueued(modal, task_id),
error: (error) => this.handleOperationError(modal, error),
})
}
private handleOperationQueued(
modal: NgbModalRef,
taskId: string,
operation: string,
onQueued?: (taskId: string) => void
) {
const bulk = this.getSelectionSize() > 1
private handleOperationQueued(modal: NgbModalRef, taskId: string) {
modal?.close()
this.list.selectNone()
if (onQueued) {
onQueued(taskId)
} else {
const label = getDocumentOperationLabel(operation)
const submittedMessage = bulk
? $localize`Bulk operation '${label}' submitted for processing.`
: $localize`Operation '${label}' submitted for processing.`
const completedMessage = bulk
? $localize`Bulk operation '${label}' completed.`
: $localize`Operation '${label}' completed.`
this.taskNotifications.showQueued(
taskId,
bulk
? $localize`Bulk operation '${label}' queued.`
: $localize`Operation '${label}' queued.`,
{
content: documentOperationHasFollowUpTasks(operation)
? submittedMessage
: completedMessage,
}
)
}
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 },
}),
}
: {}),
})
}
private executeDocumentAction(
modal: NgbModalRef,
operation: string,
request: Observable<BulkOperationResponse>,
onQueued?: (taskId: string) => void
request: Observable<any>,
options: { clearSelection?: boolean; successMessage?: string } = {}
) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(false)
}
request.pipe(first()).subscribe({
next: ({ task_id }) => {
this.handleOperationQueued(modal, task_id, operation, onQueued)
next: () => {
this.handleOperationSuccess(modal, options.clearSelection ?? false)
if (options.successMessage) {
this.toastService.showSuccess(options.successMessage)
}
},
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)
@@ -975,9 +973,16 @@ export class BulkEditorComponent
const count = this.getSelectionSize()
this.executeDocumentAction(
modal,
'delete',
this.documentService.deleteDocuments(this.getSelectionQuery()),
(taskId) => this.trashService.trackMovedToTrash(taskId, count, documents)
this.documentService
.deleteDocuments(this.getSelectionQuery())
.pipe(
tap(() =>
this.trashService.notifyMovedToTrash(
$localize`Moved ${count} document(s) to the trash`,
documents
)
)
)
)
}
@@ -1018,7 +1023,6 @@ export class BulkEditorComponent
modal.componentInstance.buttonsEnabled.set(false)
this.executeDocumentAction(
modal,
'reprocess',
this.documentService.reprocessDocuments(
this.getSelectionQuery(),
modal.componentInstance.remoteOcr
@@ -1059,7 +1063,6 @@ export class BulkEditorComponent
rotateDialog.buttonsEnabled.set(false)
this.executeDocumentAction(
modal,
'rotate',
this.documentService.rotateDocuments(
this.getSelectionQuery(),
rotateDialog.degrees
@@ -1093,8 +1096,11 @@ export class BulkEditorComponent
mergeDialog.buttonsEnabled.set(false)
this.executeDocumentAction(
modal,
'merge',
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args)
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
{ clearSelection: !!args.delete_originals }
)
this.toastService.showInfo(
$localize`Merged document will be queued for consumption.`
)
})
}
@@ -1116,11 +1122,14 @@ export class BulkEditorComponent
.subscribe(() => {
this.executeDocumentAction(
modal,
'merge_as_versions',
this.documentService.mergeDocumentsAsVersions(
mergeDialog.documentIDs(),
mergeDialog.rootDocumentID()
)
),
{
clearSelection: true,
successMessage: $localize`Documents merged as versions.`,
}
)
})
}
@@ -1145,7 +1154,7 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe(({ task_id }) =>
this.handleOperationQueued(null, task_id, 'modify_custom_fields')
this.handleOperationQueued(null, task_id)
)
dialog.failed.subscribe((error) => {
this.toastService.showError(
@@ -16,7 +16,7 @@
</pngx-input-select>
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
<pngx-permissions-form [users]="users()" accordion="true" formControlName="permissions_form"></pngx-permissions-form>
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
@if (error()?.filter_rules) {
<div class="alert alert-danger" role="alert">
<h6 class="alert-heading" i18n>Filter rules error occurred while saving this view</h6>
@@ -3,7 +3,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { of } from 'rxjs'
import { User } from 'src/app/data/user'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { CheckComponent } from '../../common/input/check/check.component'
@@ -15,7 +14,6 @@ import { TextComponent } from '../../common/input/text/text.component'
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
describe('SaveViewConfigDialogComponent', () => {
const users = [{ id: 10, username: 'user10' }] as User[]
let component: SaveViewConfigDialogComponent
let fixture: ComponentFixture<SaveViewConfigDialogComponent>
let modal: NgbActiveModal
@@ -27,7 +25,7 @@ describe('SaveViewConfigDialogComponent', () => {
{
provide: UserService,
useValue: {
listAll: () => of({ results: users }),
listAll: () => of({ results: [] }),
},
},
{
@@ -126,19 +124,6 @@ describe('SaveViewConfigDialogComponent', () => {
})
})
it('should list users in the owner selector', () => {
fixture.debugElement
.query(By.css('button[ngbAccordionButton]'))
.nativeElement.click()
fixture.detectChanges()
const permissionsForm = fixture.debugElement.query(
By.directive(PermissionsFormComponent)
)
const ownerSelect = permissionsForm.query(By.directive(SelectComponent))
expect(ownerSelect.componentInstance.items).toEqual(users)
})
it('should support default name', () => {
const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit')
const modalCloseSpy = jest.spyOn(modal, 'close')
@@ -6,7 +6,6 @@ import {
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
FormControl,
FormGroup,
@@ -14,14 +13,11 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { catchError, map, of } from 'rxjs'
import {
DEFAULT_SAVED_VIEW_ICON,
SAVED_VIEW_ICONS,
} from 'src/app/data/saved-view-icons'
import { User } from 'src/app/data/user'
import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { CheckComponent } from '../../common/input/check/check.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../../common/input/select/select.component'
@@ -42,8 +38,6 @@ import { TextComponent } from '../../common/input/text/text.component'
})
export class SaveViewConfigDialogComponent implements OnInit {
private modal = inject(NgbActiveModal)
private userService = inject(UserService)
private toastService = inject(ToastService)
readonly error = signal(undefined)
readonly buttonsEnabled = signal(true)
readonly defaultName = signal('')
@@ -52,16 +46,7 @@ export class SaveViewConfigDialogComponent implements OnInit {
@Output()
public saveClicked = new EventEmitter()
readonly users = toSignal(
this.userService.listAll().pipe(
map((result) => result.results),
catchError((error) => {
this.toastService.showError($localize`Error retrieving users`, error)
return of([])
})
),
{ initialValue: undefined as User[] }
)
users: User[]
readonly savedViewIcons = SAVED_VIEW_ICONS
setDefaultName(value: string) {
@@ -240,7 +240,7 @@ describe('OpenDocumentsService', () => {
expect(refreshed.tags).toEqual([4])
})
it('keeps documents open after a network error during refresh', () => {
it('should handle error on refresh documents', () => {
subscriptions.push(
openDocumentsService.openDocument(documents[1]).subscribe()
)
@@ -250,63 +250,7 @@ describe('OpenDocumentsService', () => {
)
expect(req.request.method).toEqual('GET')
req.error(new ErrorEvent('timeout'))
expect(openDocumentsService.getOpenDocument(documents[1].id)).toBe(
documents[1]
)
})
it.each([500, 403])(
'keeps documents open after HTTP %s during refresh',
(status) => {
const doc = { ...documents[0] }
openDocumentsService.openDocument(doc).subscribe()
openDocumentsService.refreshDocument(doc.id)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/?full_perms=true`
)
.flush({}, { status, statusText: 'Error' })
expect(openDocumentsService.getOpenDocument(doc.id)).toBe(doc)
}
)
it('preserves unsaved edits when a refreshed document no longer exists', () => {
const doc = { ...documents[0], title: 'Unsaved title' }
openDocumentsService.openDocument(doc).subscribe()
openDocumentsService.setDirty(doc, true, { title: doc.title })
openDocumentsService.refreshDocument(doc.id)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/?full_perms=true`
)
.flush({}, { status: 404, statusText: 'Not Found' })
expect(openDocumentsService.getOpenDocument(doc.id)).toBe(doc)
expect(openDocumentsService.isDirty(doc)).toBe(true)
expect(
JSON.parse(sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS))[0]
.title
).toBe('Unsaved title')
})
it('removes only the missing document when tab positions change during refresh', () => {
const first = { ...documents[0] }
const second = { ...documents[1] }
openDocumentsService.openDocument(first).subscribe()
openDocumentsService.openDocument(second).subscribe()
openDocumentsService.refreshDocument(first.id)
openDocumentsService.refreshDocument(second.id)
const firstRequest = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${first.id}/?full_perms=true`
)
const secondRequest = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/${second.id}/?full_perms=true`
)
openDocumentsService.closeDocument(second).subscribe()
firstRequest.flush({}, { status: 404, statusText: 'Not Found' })
expect(openDocumentsService.getOpenDocuments()).toHaveLength(0)
openDocumentsService.openDocument(first).subscribe()
secondRequest.flush({}, { status: 404, statusText: 'Not Found' })
expect(openDocumentsService.getOpenDocuments()).toEqual([first])
})
it('should log error on sessionStorage save', () => {
@@ -1,4 +1,3 @@
import { HttpErrorResponse } from '@angular/common/http'
import { Injectable, inject, signal } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Observable, Subject, of } from 'rxjs'
@@ -62,12 +61,7 @@ export class OpenDocumentsService {
Object.assign(openDoc, doc, unsavedEdits)
this.save()
},
error: (error: HttpErrorResponse) => {
if (error.status !== 404) return
const index = this.openDocuments.findIndex((doc) => doc.id === id)
if (index === -1 || this.isDirty(this.openDocuments[index])) return
error: () => {
this.openDocuments.splice(index, 1)
this.save()
},
@@ -290,7 +290,6 @@ describe(`DocumentService`, () => {
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
from_webui: true,
})
})
@@ -304,7 +303,6 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({
documents: ids,
remote_ocr: false,
from_webui: true,
})
})
@@ -319,7 +317,6 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({
documents: ids,
remote_ocr: true,
from_webui: true,
})
})
@@ -334,7 +331,6 @@ describe(`DocumentService`, () => {
documents: ids,
degrees: 90,
source_mode: 'latest_version',
from_webui: true,
})
})
@@ -350,7 +346,6 @@ describe(`DocumentService`, () => {
documents: ids,
metadata_document_id: 1,
delete_originals: true,
from_webui: true,
})
})
@@ -364,7 +359,6 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({
documents: ids,
root_document_id: 2,
from_webui: true,
})
})
@@ -380,7 +374,6 @@ describe(`DocumentService`, () => {
documents: ids,
root_document_id: 2,
version_label: 'Imported',
from_webui: true,
})
})
@@ -395,7 +388,6 @@ describe(`DocumentService`, () => {
expect(req.request.body).toEqual({
documents: ids,
operations: [{ page: 1, rotate: 90, doc: 0 }],
from_webui: true,
})
})
@@ -411,7 +403,6 @@ describe(`DocumentService`, () => {
documents: ids,
password: 'secret',
update_document: true,
from_webui: true,
})
})
@@ -351,24 +351,19 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}
deleteDocuments(selection: DocumentSelectionQuery) {
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'delete'),
{ ...selection, from_webui: true }
)
return this.http.post(this.getResourceUrl(null, 'delete'), {
...selection,
})
}
reprocessDocuments(
selection: DocumentSelectionQuery,
remoteOcr: boolean = false
) {
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'reprocess'),
{
...selection,
remote_ocr: remoteOcr,
from_webui: true,
}
)
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
...selection,
remote_ocr: remoteOcr,
})
}
rotateDocuments(
@@ -376,26 +371,18 @@ export class DocumentService extends AbstractPaperlessService<Document> {
degrees: number,
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
) {
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'rotate'),
{
...selection,
degrees,
source_mode: sourceMode,
from_webui: true,
}
)
return this.http.post(this.getResourceUrl(null, 'rotate'), {
...selection,
degrees,
source_mode: sourceMode,
})
}
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'merge'),
{
documents: ids,
...request,
from_webui: true,
}
)
return this.http.post(this.getResourceUrl(null, 'merge'), {
documents: ids,
...request,
})
}
mergeDocumentsAsVersions(
@@ -403,40 +390,28 @@ export class DocumentService extends AbstractPaperlessService<Document> {
rootDocumentId: number,
versionLabel?: string
) {
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'merge_as_versions'),
{
documents: ids,
root_document_id: rootDocumentId,
...(versionLabel ? { version_label: versionLabel } : {}),
from_webui: true,
}
)
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
documents: ids,
root_document_id: rootDocumentId,
...(versionLabel ? { version_label: versionLabel } : {}),
})
}
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'edit_pdf'),
{
documents: ids,
...request,
from_webui: true,
}
)
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
documents: ids,
...request,
})
}
removePasswordDocuments(
ids: number[],
request: RemovePasswordDocumentsRequest
) {
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'remove_password'),
{
documents: ids,
...request,
from_webui: true,
}
)
return this.http.post(this.getResourceUrl(null, 'remove_password'), {
documents: ids,
...request,
})
}
getSelectionData(
@@ -1,186 +0,0 @@
import { TestBed } from '@angular/core/testing'
import { Router } from '@angular/router'
import { BulkOperationStatus } from '../data/bulk-operation'
import { PaperlessTaskStatus } from '../data/paperless-task'
import { PermissionsService } from './permissions.service'
import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
describe('TaskNotificationService', () => {
let service: TaskNotificationService
let toastService: ToastService
let popups: Toast[]
let notifications: Toast[]
const navigate = jest.fn()
const currentUserCan = jest.fn()
const result = (
status = PaperlessTaskStatus.Success
): BulkOperationStatus => ({
task_id: 'task-1',
operation: 'run_workflow',
status,
})
beforeEach(() => {
jest.useFakeTimers()
currentUserCan.mockReturnValue(true)
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { navigate } },
{ provide: PermissionsService, useValue: { currentUserCan } },
],
})
service = TestBed.inject(TaskNotificationService)
toastService = TestBed.inject(ToastService)
popups = []
notifications = []
toastService.showToast.subscribe((toast) => popups.push(toast))
toastService
.getToasts()
.subscribe((toasts) => (notifications = [...toasts]))
})
afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})
it('replaces a queued notification with its result, removing the task link on success', () => {
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.')
expect(popups[0].actionName).toBe('View task')
popups[0].action()
expect(navigate).toHaveBeenCalledWith(['/tasks'], {
queryParams: { task_id: 'task-1' },
})
service.complete(result())
expect(notifications).toHaveLength(1)
expect(popups[1]).toEqual(
expect.objectContaining({
id: popups[0].id,
content: 'Workflow completed.',
level: ToastLevel.Success,
})
)
expect(popups[1].action).toBeUndefined()
expect(popups[1].actionName).toBeUndefined()
})
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 'Run workflow' completed.",
])
})
it.each([
'reprocess',
'rotate',
'merge',
'split',
'delete_pages',
'edit_pdf',
'remove_password',
])('reports %s as submitted rather than completed', (operation) => {
service.showQueued('task-1', 'Queued')
service.complete({ ...result(), operation })
expect(notifications[0].content).toContain('submitted for processing.')
expect(notifications[0].content).not.toContain('completed')
expect(notifications[0].action).toBeUndefined()
})
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.')
expect(notifications[0].action).toBeUndefined()
})
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 'Run workflow' 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
)
})
it('identifies concurrent operations even when completion arrives before their queue responses', () => {
service.complete({
task_id: 'tags',
operation: 'modify_tags',
status: PaperlessTaskStatus.Success,
})
service.complete({
task_id: 'correspondent',
operation: 'set_correspondent',
status: PaperlessTaskStatus.Success,
})
expect(notifications.map((toast) => toast.content)).toEqual([
"Operation 'Set correspondent' completed.",
"Operation 'Update tags' completed.",
])
})
it('keeps the generic fallback for unknown operation names', () => {
service.complete({ ...result(), operation: 'future_operation' })
expect(notifications[0].content).toBe('Operation completed.')
})
})
@@ -1,128 +0,0 @@
import { inject, Injectable, OnDestroy } from '@angular/core'
import { Router } from '@angular/router'
import { BulkOperationStatus } from '../data/bulk-operation'
import { PaperlessTaskStatus } from '../data/paperless-task'
import {
documentOperationHasFollowUpTasks,
getDocumentOperationLabel,
} from '../utils/document-operation'
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 readonly toastService = inject(ToastService)
private readonly router = inject(Router)
private readonly permissionsService = inject(PermissionsService)
private readonly pending = new Map<string, PendingNotification>()
private readonly 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
const operation = getDocumentOperationLabel(result.operation)
let content: string
if (succeeded) {
let operationText = $localize`Operation`
if (operation) operationText += ` '${operation}'`
const resultText = documentOperationHasFollowUpTasks(result.operation)
? $localize`submitted for processing`
: $localize`completed`
content = `${operationText} ${resultText}.`
} else if (result.status === PaperlessTaskStatus.Revoked) {
content = operation
? $localize`Operation '${operation}' revoked.`
: $localize`Operation revoked.`
} else {
content = operation
? $localize`Operation '${operation}' failed. Some changes may already have been applied.`
: $localize`Operation failed. Some changes may already have been applied.`
}
this.show(result.task_id, {
content,
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 && toast.level !== ToastLevel.Success
? {
actionName: $localize`View task`,
action: () =>
this.router.navigate(['/tasks'], {
queryParams: { task_id: taskId },
}),
}
: {}),
...toast,
})
}
}
@@ -38,29 +38,6 @@ describe('ToastService', () => {
})
})
it('updates a notification by ID without duplicating or moving it', () => {
let notifications: Toast[] = []
toastService
.getToasts()
.subscribe((toasts) => (notifications = [...toasts]))
toastService.show({ id: 'task-1', content: 'Queued', delay: 5000 })
toastService.show({
id: 'other',
content: 'Another notification',
delay: 5000,
})
toastService.show({
id: 'task-1',
content: 'Completed',
delay: 5000,
level: ToastLevel.Success,
})
expect(notifications.map((toast) => toast.content)).toEqual([
'Another notification',
'Completed',
])
})
it('parses error string to object on show', () => {
const toast = {
title: 'Title',
+1 -8
View File
@@ -75,14 +75,7 @@ export class ToastService {
if (persistent) {
// the dropdown is open, so it's seen as it arrives
toast.read = !!this._suppressPopupToasts
const index = this.toasts.findIndex(
(existing) => existing.id === toast.id
)
if (index === -1) {
this.toasts.unshift(toast)
} else {
this.toasts[index] = toast
}
this.toasts.unshift(toast)
this.toastsSubject.next(this.toasts)
}
// Popups are suppressed while the dropdown is open
+1 -51
View File
@@ -5,10 +5,8 @@ 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 { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
import { Toast, ToastService } from './toast.service'
import { TrashService } from './trash.service'
describe('TrashService', () => {
@@ -107,52 +105,4 @@ 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')
})
})
+5 -32
View File
@@ -5,8 +5,7 @@ import { environment } from 'src/environments/environment'
import { Document } from '../data/document'
import { Results } from '../data/results'
import { DocumentListViewService } from './document-list-view.service'
import { TaskNotificationService } from './task-notification.service'
import { Toast, ToastLevel, ToastService } from './toast.service'
import { ToastLevel, ToastService } from './toast.service'
@Injectable({
providedIn: 'root',
@@ -15,24 +14,6 @@ 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,
count > 1
? $localize`Bulk move to trash queued.`
: $localize`Move to trash 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())
@@ -62,22 +43,14 @@ 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
return {
this.toastService.show({
content,
delay: 10000,
level: ToastLevel.Success,
...(canUndo
? {
actionName: $localize`Undo`,
action: () => this.undoMoveToTrash(documents),
}
: {}),
}
actionName: canUndo ? $localize`Undo` : undefined,
action: canUndo ? () => this.undoMoveToTrash(documents) : undefined,
})
}
private undoMoveToTrash(documents: number[]) {
@@ -1,11 +0,0 @@
import { getDocumentOperationLabel } from './document-operation'
describe('Document Operation Utils', () => {
it('should retrieve the document operation label correctly for an existing operation, null otherwise', () => {
const result = getDocumentOperationLabel('modify_custom_fields')
expect(result).toBe('Update custom fields')
const resultNull = getDocumentOperationLabel('non_existing_operation')
expect(resultNull).toBeNull()
})
})
@@ -1,38 +0,0 @@
const OPERATION_LABELS: Record<string, string> = {
set_correspondent: $localize`Set correspondent`,
set_document_type: $localize`Set document type`,
set_storage_path: $localize`Set storage path`,
add_tag: $localize`Add tag`,
remove_tag: $localize`Remove tag`,
modify_tags: $localize`Update tags`,
modify_custom_fields: $localize`Update custom fields`,
set_permissions: $localize`Set permissions`,
delete: $localize`Move to trash`,
reprocess: $localize`Document reprocessing`,
run_workflow: $localize`Run workflow`,
rotate: $localize`Rotate documents`,
merge: $localize`Merge documents`,
merge_as_versions: $localize`Merge as versions`,
split: $localize`Split documents`,
delete_pages: $localize`Delete pages`,
edit_pdf: $localize`Edit PDF`,
remove_password: $localize`Remove password`,
}
export function getDocumentOperationLabel(operation?: string): string | null {
const label = operation ? OPERATION_LABELS[operation] : null
return label ?? null
}
// These operations queue processing in separate tasks
export function documentOperationHasFollowUpTasks(operation?: string): boolean {
return [
'reprocess',
'rotate',
'merge',
'split',
'delete_pages',
'edit_pdf',
'remove_password',
].includes(operation ?? '')
}
+1 -2
View File
@@ -18,8 +18,7 @@
"es2022",
"dom"
],
"useDefineForClassFields": false,
"types": ["jest", "node"]
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
+47 -131
View File
@@ -39,8 +39,6 @@ from documents.models import Tag
from documents.permissions import PermissionsSpec
from documents.permissions import set_permissions_for_objects
from documents.plugins.helpers import DocumentsStatusManager
from documents.task_tracking import queue_consumption
from documents.task_tracking import queue_processing_tasks
from documents.tasks import bulk_update_documents
from documents.tasks import consume_file
from documents.tasks import remove_document_from_index
@@ -429,17 +427,11 @@ 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"
def reprocess(
doc_ids: list[int],
*,
remote_ocr: bool = False,
parent_task_id: str | None = None,
) -> Literal["OK"]:
def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
"""
Re-run parsing for the given documents.
@@ -464,16 +456,6 @@ def reprocess(
.values_list("source_id", flat=True)
.distinct()
)
if parent_task_id is not None:
from documents.task_tracking import queue_reprocessing_tasks
queue_reprocessing_tasks(
parent_task_id,
list(source_ids),
remote_ocr=remote_ocr,
)
return "OK"
for source_id in source_ids:
update_document_content_maybe_archive_file.apply_async(
kwargs={"document_id": source_id, "remote_ocr": remote_ocr},
@@ -492,9 +474,9 @@ def run_workflow(
"""
Manually run a single workflow against the given documents.
Called from the tracked bulk operation task.
Done asynchronously via a Celery task.
"""
run_workflows_for_documents(
run_workflows_for_documents.delay(
document_ids=doc_ids,
workflow_id=workflow,
filter_documents=filter_documents,
@@ -538,7 +520,6 @@ def rotate(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
) -> Literal["OK"]:
logger.info(
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
@@ -576,22 +557,22 @@ def rotate(
if user is not None:
overrides.actor_id = user.id
queue_consumption(
input_doc=ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
overrides=overrides,
trigger_source=trigger_source,
parent_task_id=parent_task_id,
consume_file.apply_async(
kwargs={
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
"overrides": overrides,
},
headers={"trigger_source": trigger_source},
)
logger.info(
f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees",
)
except Exception as e:
logger.exception(f"Error rotating document {pair.root_doc.id}: {e}")
raise
return "OK"
@@ -605,15 +586,13 @@ def merge(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
) -> Literal["OK"] | dict:
) -> Literal["OK"]:
logger.info(
f"Attempting to merge {len(doc_ids)} documents into a single document.",
)
qs = Document.objects.select_related("root_document").filter(id__in=doc_ids)
docs_by_id = {doc.id: doc for doc in qs}
affected_docs: list[int] = []
failed_ids: list[int] = []
handoff_asn: int | None = None
with pdf_ops.PdfMerger() as merger:
# use doc_ids to preserve order
@@ -639,14 +618,11 @@ def merge(
if handoff_asn is None and doc.archive_serial_number is not None:
handoff_asn = doc.archive_serial_number
except Exception as e:
failed_ids.append(doc.id)
logger.exception(
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
)
if len(affected_docs) == 0:
logger.warning("No documents were merged")
if parent_task_id is not None:
raise ValueError("None of the selected documents could be merged.")
return "OK"
filepath = (
@@ -689,23 +665,6 @@ def merge(
overrides=overrides,
).set(headers={"trigger_source": trigger_source})
if parent_task_id is not None:
backup = (
release_archive_serial_numbers(affected_docs) if delete_originals else {}
)
queue_processing_tasks(
parent_task_id,
[consume_task],
delete_ids=affected_docs if delete_originals else None,
asn_backup=backup,
)
if failed_ids:
return {
"failed_document_ids": failed_ids,
"error_message": f"Documents {failed_ids} were excluded from the merge.",
}
return "OK"
if delete_originals:
backup = release_archive_serial_numbers(affected_docs)
logger.info(
@@ -742,15 +701,7 @@ def merge_as_versions(
)
documents_by_id = {document.id: document for document in documents}
if any(document.root_document_id is not None for document in documents):
raise ValueError("Only top-level documents can be merged as versions.")
source_ids = [doc_id for doc_id in doc_ids if doc_id != root_document_id]
if Document.global_objects.filter(root_document_id__in=source_ids).exists():
raise ValueError(
"Documents with existing versions cannot be merged into another document.",
)
root_document = documents_by_id[root_document_id]
next_version_index = (
Document.global_objects.filter(
@@ -850,7 +801,6 @@ def split(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
) -> Literal["OK"]:
logger.info(
f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",
@@ -894,17 +844,7 @@ def split(
).set(headers={"trigger_source": trigger_source}),
)
if parent_task_id is not None:
backup = (
release_archive_serial_numbers([doc.id]) if delete_originals else {}
)
queue_processing_tasks(
parent_task_id,
consume_tasks,
delete_ids=[doc.id] if delete_originals else None,
asn_backup=backup,
)
elif delete_originals:
if delete_originals:
backup = release_archive_serial_numbers([doc.id])
logger.info(
"Queueing removal of original document after consumption of the split documents",
@@ -924,7 +864,6 @@ def split(
except Exception as e:
logger.exception(f"Error splitting document {doc.id}: {e}")
raise
return "OK"
@@ -936,7 +875,6 @@ def delete_pages(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
) -> Literal["OK"]:
logger.info(
f"Attempting to delete pages {pages} from {len(doc_ids)} documents",
@@ -955,22 +893,22 @@ def delete_pages(
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
if user is not None:
overrides.actor_id = user.id
queue_consumption(
input_doc=ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
overrides=overrides,
trigger_source=trigger_source,
parent_task_id=parent_task_id,
consume_file.apply_async(
kwargs={
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
"overrides": overrides,
},
headers={"trigger_source": trigger_source},
)
logger.info(
f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}",
)
except Exception as e:
logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}")
raise
return "OK"
@@ -985,7 +923,6 @@ def edit_pdf(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
) -> Literal["OK"]:
"""
Operations is a list of dictionaries describing the final PDF pages.
@@ -1030,15 +967,16 @@ def edit_pdf(
if user is not None:
overrides.owner_id = user.id
overrides.actor_id = user.id
queue_consumption(
input_doc=ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
overrides=overrides,
trigger_source=trigger_source,
parent_task_id=parent_task_id,
consume_file.apply_async(
kwargs={
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
"overrides": overrides,
},
headers={"trigger_source": trigger_source},
)
else:
version_filepaths = pdf_ops.build_pdfs(
@@ -1078,17 +1016,7 @@ def edit_pdf(
).set(headers={"trigger_source": trigger_source}),
)
if parent_task_id is not None:
backup = (
release_archive_serial_numbers([doc.id]) if delete_original else {}
)
queue_processing_tasks(
parent_task_id,
consume_tasks,
delete_ids=[doc.id] if delete_original else None,
asn_backup=backup,
)
elif delete_original:
if delete_original:
backup = release_archive_serial_numbers([doc.id])
try:
chord(
@@ -1122,7 +1050,6 @@ def remove_password(
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
user: User | None = None,
trigger_source: PaperlessTask.TriggerSource = PaperlessTask.TriggerSource.WEB_UI,
parent_task_id: str | None = None,
source_paths_by_id: Mapping[int, Path] | None = None,
) -> Literal["OK"]:
"""
@@ -1165,15 +1092,16 @@ def remove_password(
if user is not None:
overrides.owner_id = user.id
overrides.actor_id = user.id
queue_consumption(
input_doc=ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
overrides=overrides,
trigger_source=trigger_source,
parent_task_id=parent_task_id,
consume_file.apply_async(
kwargs={
"input_doc": ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
root_document_id=pair.root_doc.id,
),
"overrides": overrides,
},
headers={"trigger_source": trigger_source},
)
else:
consume_tasks = []
@@ -1196,19 +1124,7 @@ def remove_password(
).set(headers={"trigger_source": trigger_source}),
)
if parent_task_id is not None:
backup = (
release_archive_serial_numbers([doc.id])
if delete_original
else {}
)
queue_processing_tasks(
parent_task_id,
consume_tasks,
delete_ids=[doc.id] if delete_original else None,
asn_backup=backup,
)
elif delete_original:
if delete_original:
chord(
header=consume_tasks,
body=delete.si([doc.id]),
+3 -76
View File
@@ -1,4 +1,4 @@
"""Queued document bulk operations."""
"""Queued document metadata edits."""
from typing import Any
@@ -12,7 +12,6 @@ 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
@@ -26,35 +25,7 @@ MODIFIED_FIELDS = {
"modify_custom_fields": "custom_fields",
"set_permissions": None,
}
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"}
QUEUED_OPERATIONS = frozenset(MODIFIED_FIELDS)
def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
@@ -89,14 +60,12 @@ def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
return values
@shared_task(bind=True)
@shared_task
def execute_bulk_operation(
self,
operation: str,
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}")
@@ -110,48 +79,6 @@ 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 TRIGGER_SOURCE_OPERATIONS | {"reprocess"}
and self.request.id is not None
):
execution_parameters["parent_task_id"] = self.request.id
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 {}
)
result = 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),
**(result if isinstance(result, dict) else {}),
}
affected_ids: list[int] = []
modified_field = MODIFIED_FIELDS[operation]
# Keep metadata writes and their audit entries together. Follow-up processing
@@ -1,30 +0,0 @@
# Generated by Django 5.2.18 on 2026-11-14 19:59
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0030_note_format"),
]
operations = [
migrations.AddField(
model_name="paperlesstask",
name="dispatched",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="paperlesstask",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="children",
to="documents.paperlesstask",
),
),
]
-10
View File
@@ -970,16 +970,6 @@ class PaperlessTask(ModelWithOwner):
help_text=_("Structured result data from task execution"),
)
parent = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="children",
)
dispatched = models.BooleanField(default=False)
# Acknowledgment
acknowledged = models.BooleanField(
default=False,
-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 (
+14 -5
View File
@@ -1688,7 +1688,6 @@ 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,
@@ -1717,8 +1716,19 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
"root_document_id must be one of the selected documents.",
)
# Version state checks (e.g., whether a document is already part of a version chain)
# moved to merge_as_versions() under row locks, since it could change between queueing and execution
selected_documents = Document.objects.filter(id__in=documents)
if selected_documents.filter(root_document__isnull=False).exists():
raise serializers.ValidationError(
"Only top-level documents can be merged as versions.",
)
source_document_ids = set(documents) - {attrs["root_document_id"]}
if Document.global_objects.filter(
root_document_id__in=source_document_ids,
).exists():
raise serializers.ValidationError(
"Documents with existing versions cannot be merged into another document.",
)
return attrs
@@ -1791,12 +1801,11 @@ class RemovePasswordDocumentsSerializer(
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
from_webui = serializers.BooleanField(required=False, default=False)
pass
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
remote_ocr = serializers.BooleanField(required=False, default=False)
from_webui = serializers.BooleanField(required=False, default=False)
class BulkEditSerializer(
-46
View File
@@ -56,8 +56,6 @@ from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.permissions import restrict_queryset_to_visible
from documents.plugins.helpers import DocumentsStatusManager
from documents.task_tracking import finish_dispatch
from documents.task_tracking import update_parent
from documents.templating.utils import convert_format_str_to_template_format
from documents.utils import compute_checksum
from documents.utils import copy_file_with_basic_stats
@@ -1079,7 +1077,6 @@ TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = {
"documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT,
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
"documents.task_tracking.finish_file_operation": PaperlessTask.TaskType.BULK_DELETE,
"documents.tasks.apply_ai_suggestions": PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS,
BULK_OPERATION_TASK_NAME: PaperlessTask.TaskType.BULK_UPDATE,
}
@@ -1128,8 +1125,6 @@ def _extract_input_data(
data["overrides"] = override_dict
return data
if task_type == PaperlessTask.TaskType.REPROCESS_DOCUMENT:
return {"document_id": task_kwargs["document_id"]}
if task_type == PaperlessTask.TaskType.MAIL_FETCH:
account_ids = task_kwargs.get("account_ids")
if account_ids is not None:
@@ -1211,25 +1206,10 @@ 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)
trigger_source = _determine_trigger_source(headers)
# Consumption can fan out again (e.g. barcode splitting). Keep those
# descendants attached so their caller cannot finish before them.
parent = None
if task_type == PaperlessTask.TaskType.CONSUME_FILE and headers.get(
"parent_id",
):
parent = PaperlessTask.objects.filter(
task_id=headers["parent_id"],
parent__isnull=False,
).first()
if parent is not None:
owner_id = parent.owner_id
trigger_source = parent.trigger_source
# A retried task is republished with the same task_id, so this fires
# again for it; get_or_create keeps the original PENDING record
@@ -1242,7 +1222,6 @@ def before_task_publish_handler(
"status": PaperlessTask.Status.PENDING,
"input_data": input_data,
"owner_id": owner_id,
"parent": parent,
},
)
except Exception: # pragma: no cover
@@ -1328,10 +1307,6 @@ def task_postrun_handler(
except PaperlessTask.DoesNotExist:
return
if task_instance.children.exists():
finish_dispatch(task_instance, retval if isinstance(retval, dict) else {})
return
task_instance.status = new_status
task_instance.date_done = now
changed_fields = ["status", "date_done"]
@@ -1354,21 +1329,7 @@ def task_postrun_handler(
task_instance.status = PaperlessTask.Status.FAILURE
changed_fields.append("status")
if (
task_instance.parent_id is not None
and task_instance.task_type == PaperlessTask.TaskType.CONSUME_FILE
and (not isinstance(retval, dict) or "document_id" not in retval)
and not (isinstance(retval, dict) and "duplicate_of" in retval)
):
task_instance.status = PaperlessTask.Status.FAILURE
task_instance.result_data = {
**(retval if isinstance(retval, dict) else {}),
"error_message": "Consumption stopped without creating a document.",
}
changed_fields.extend(["status", "result_data"])
task_instance.save(update_fields=changed_fields)
update_parent(task_instance)
if task and task.name == BULK_OPERATION_TASK_NAME:
_send_bulk_operation_status(task_instance)
except Exception: # pragma: no cover
@@ -1425,10 +1386,6 @@ def task_failure_handler(
}
task_qs = PaperlessTask.objects.filter(task_id=task_id)
parent = task_qs.first()
if parent is not None and parent.children.exists():
finish_dispatch(parent, result_data)
return
task_instance = task_qs.values("date_started", "date_created").first()
if task_instance:
date_started = task_instance["date_started"]
@@ -1440,7 +1397,6 @@ def task_failure_handler(
date_started - date_created
).total_seconds()
task_qs.update(**update_fields)
update_parent(task_qs.get())
if sender and sender.name == BULK_OPERATION_TASK_NAME:
_send_bulk_operation_status(task_qs.first())
except Exception: # pragma: no cover
@@ -1487,8 +1443,6 @@ def task_revoked_handler(
"reason": (reason + " Check worker logs for details."),
},
)
if task_instance := task_qs.first():
update_parent(task_instance)
if sender and sender.name == BULK_OPERATION_TASK_NAME:
_send_bulk_operation_status(task_qs.first())
except Exception: # pragma: no cover
-242
View File
@@ -1,242 +0,0 @@
"""Completion tracking for operations which dispatch document processing tasks."""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING
from celery import shared_task
from django.db import transaction
from django.utils import timezone
from documents.models import PaperlessTask
if TYPE_CHECKING:
from celery.canvas import Signature
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
def queue_consumption(
*,
input_doc: ConsumableDocument,
overrides: DocumentMetadataOverrides,
trigger_source: str,
parent_task_id: str | None,
) -> None:
from documents.tasks import consume_file
kwargs = {"input_doc": input_doc, "overrides": overrides}
if parent_task_id is None:
consume_file.apply_async(
kwargs=kwargs,
headers={"trigger_source": trigger_source},
)
else:
queue_processing_tasks(parent_task_id, [consume_file.s(**kwargs)])
def queue_reprocessing_tasks(
parent_task_id: str,
document_ids: list[int],
*,
remote_ocr: bool,
) -> None:
from documents.tasks import update_document_content_maybe_archive_file
queue_processing_tasks(
parent_task_id,
[
update_document_content_maybe_archive_file.s(
document_id=document_id,
remote_ocr=remote_ocr,
)
for document_id in document_ids
],
)
def queue_processing_tasks(
parent_task_id: str,
signatures: list[Signature],
*,
delete_ids: list[int] | None = None,
asn_backup: dict | None = None,
) -> None:
from documents.signals.handlers import TRACKED_TASKS
from documents.signals.handlers import _extract_input_data
parent = PaperlessTask.objects.get(task_id=parent_task_id)
children = [
PaperlessTask(
task_id=str(uuid.uuid4()),
task_type=TRACKED_TASKS[signature.task],
trigger_source=parent.trigger_source,
owner_id=parent.owner_id,
parent=parent,
input_data=_extract_input_data(
TRACKED_TASKS[signature.task],
signature.kwargs,
),
)
for signature in signatures
]
# Dependencies and their final step must be registered together.
with transaction.atomic():
PaperlessTask.objects.bulk_create(children)
if delete_ids:
PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_type=PaperlessTask.TaskType.BULK_DELETE,
owner_id=parent.owner_id,
trigger_source=parent.trigger_source,
parent=parent,
input_data={
"dependencies": [child.pk for child in children],
"delete_ids": delete_ids,
"asn_backup": asn_backup or {},
},
)
for index, (signature, child) in enumerate(zip(signatures, children, strict=True)):
try:
signature.apply_async(
task_id=child.task_id,
headers={"trigger_source": parent.trigger_source},
)
except Exception:
PaperlessTask.objects.filter(
pk__in=[task.pk for task in children[index:]],
).update(
status=PaperlessTask.Status.FAILURE,
date_done=timezone.now(),
result_data={"error_message": "Could not publish processing task."},
)
raise
@shared_task
def finish_file_operation(
dependencies: list[int],
delete_ids: list[int],
asn_backup: dict,
) -> dict[str, str]:
from documents.bulk_edit import delete
from documents.bulk_edit import restore_archive_serial_numbers
statuses = list(
PaperlessTask.objects.filter(pk__in=dependencies).values_list(
"status",
flat=True,
),
)
if len(statuses) != len(dependencies):
raise ValueError(
"Processing task records are missing; originals were preserved.",
)
if any(status != PaperlessTask.Status.SUCCESS for status in statuses):
restore_archive_serial_numbers(asn_backup)
return {"reason": "Originals preserved; archive serial numbers restored."}
delete(delete_ids)
return {"reason": "Originals moved to trash."}
def _publish_finalizer(task: PaperlessTask) -> None:
try:
finish_file_operation.apply_async(
task_id=task.task_id,
kwargs=task.input_data,
headers={"trigger_source": task.trigger_source},
)
except Exception as error:
from documents.signals.handlers import task_failure_handler
task_failure_handler(
sender=finish_file_operation,
task_id=task.task_id,
exception=error,
)
def _schedule_finalizers(parent: PaperlessTask) -> None:
for task in parent.children.filter(
task_type=PaperlessTask.TaskType.BULK_DELETE,
status=PaperlessTask.Status.PENDING,
):
dependencies = task.input_data.get("dependencies")
if dependencies is None or task.result_data:
continue
if (
PaperlessTask.objects.filter(pk__in=dependencies)
.exclude(
status__in=PaperlessTask.COMPLETE_STATUSES,
)
.exists()
):
continue
task.result_data = {"scheduled": True}
task.save(update_fields=["result_data"])
transaction.on_commit(lambda task=task: _publish_finalizer(task))
def finish_dispatch(task: PaperlessTask, result_data: dict) -> None:
"""Record the scheduling outcome without completing an active parent."""
with transaction.atomic():
parent = PaperlessTask.objects.select_for_update().get(pk=task.pk)
if parent.is_complete:
return
parent.dispatched = True
parent.result_data = result_data
parent.save(update_fields=["dispatched", "result_data"])
_finalize_parent(parent)
def update_parent(task: PaperlessTask) -> None:
if task.parent_id is None:
return
with transaction.atomic():
parent = PaperlessTask.objects.select_for_update().get(pk=task.parent_id)
_finalize_parent(parent)
def _finalize_parent(parent: PaperlessTask) -> None:
if parent.is_complete or not parent.dispatched:
return
_schedule_finalizers(parent)
statuses = list(parent.children.values_list("status", flat=True))
if any(status not in PaperlessTask.COMPLETE_STATUSES for status in statuses):
return
succeeded = statuses.count(PaperlessTask.Status.SUCCESS)
failed = len(statuses) - succeeded
result_data = parent.result_data or {}
if failed and not result_data.get("error_message"):
result_data["error_message"] = (
f"{succeeded} child task(s) succeeded; {failed} failed or were revoked. "
"See child tasks for details."
)
parent.status = (
PaperlessTask.Status.FAILURE
if result_data.get("error_message")
else PaperlessTask.Status.SUCCESS
)
parent.result_data = {
**parent.input_data,
**result_data,
"succeeded_count": succeeded,
"failed_count": failed,
}
parent.date_done = timezone.now()
if parent.date_started:
parent.duration_seconds = (
parent.date_done - parent.date_started
).total_seconds()
parent.wait_time_seconds = (
parent.date_started - parent.date_created
).total_seconds()
parent.save()
from documents.signals.handlers import _send_bulk_operation_status
if "operation" in parent.input_data:
transaction.on_commit(lambda: _send_bulk_operation_status(parent))
update_parent(parent)
+4 -11
View File
@@ -394,10 +394,11 @@ def update_document_content_maybe_archive_file(
)
if not parser_class:
raise ValueError(
logger.error(
f"No parser found for mime type {mime_type}, cannot "
f"reprocess document {document} (ID: {document_id})",
f"archive document {document} (ID: {document_id})",
)
return
with parser_class() as parser:
parser.configure(ParserContext())
@@ -511,7 +512,6 @@ def update_document_content_maybe_archive_file(
logger.exception(
f"Error while parsing document {document} (ID: {document_id})",
)
raise
@shared_task
@@ -713,9 +713,8 @@ def run_workflows_for_documents(
logger.warning(
f"Manual workflow run requested for missing workflow {workflow_id}",
)
raise
return
failed_ids = []
for document in Document.objects.filter(
id__in=document_ids,
root_document__isnull=True,
@@ -733,16 +732,10 @@ def run_workflows_for_documents(
document=document,
)
except Exception:
failed_ids.append(document.pk)
logger.exception(
f"Error running workflow {workflow} on document {document.pk}",
)
if failed_ids:
raise RuntimeError(
f"Workflow {workflow_id} failed for document(s): {failed_ids}. Check logs for details.",
)
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
"""
+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)
+9 -14
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:
with self.assertRaisesRegex(Exception, "Data too long for column"):
bulk_edit.delete(doc_ids)
self.assertIn("possible incompatible database column", cm.output[0])
bulk_edit.delete(doc_ids)
self.assertIn("possible incompatible database column", cm.output[0])
class TestPDFActions(DirectoriesMixin, TestCase):
@@ -1038,9 +1038,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_consume_file.assert_not_called()
@mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s")
def test_split(self, mock_consume_file, mock_group) -> None:
def test_split(self, mock_consume_file) -> None:
"""
GIVEN:
- Existing documents
@@ -1061,7 +1060,6 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertIsNone(mock_consume_file.call_args.kwargs["overrides"].asn)
self.assertEqual(result, "OK")
mock_group.return_value.delay.assert_called_once()
@mock.patch("documents.bulk_edit.group")
@mock.patch("documents.pdf_ops.build_pdfs")
@@ -1170,8 +1168,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
sig.apply_async.side_effect = Exception("boom")
mock_chord.return_value = sig
with self.assertRaisesMessage(Exception, "boom"):
bulk_edit.split(doc_ids, pages, delete_originals=True)
result = bulk_edit.split(doc_ids, pages, delete_originals=True)
self.assertEqual(result, "OK")
self.doc2.refresh_from_db()
self.assertEqual(self.doc2.archive_serial_number, 222)
@@ -1193,8 +1191,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
pages = [[1, 2], [3]]
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
with self.assertRaisesMessage(Exception, "Error building PDFs"):
bulk_edit.split(doc_ids, pages)
bulk_edit.split(doc_ids, pages)
error_str = cm.output[0]
expected_str = "Error splitting document 2"
self.assertIn(expected_str, error_str)
@@ -1243,8 +1240,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
doc_ids = [self.doc2.id, self.doc3.id]
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
with self.assertRaisesMessage(Exception, "Error rotating PDF"):
bulk_edit.rotate(doc_ids, 90)
bulk_edit.rotate(doc_ids, 90)
error_str = cm.output[0]
expected_str = "Error rotating document"
self.assertIn(expected_str, error_str)
@@ -1379,8 +1375,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
pages = [1, 3]
with self.assertLogs("paperless.bulk_edit", level="ERROR") as cm:
with self.assertRaisesMessage(Exception, "Error removing pages"):
bulk_edit.delete_pages(doc_ids, pages)
bulk_edit.delete_pages(doc_ids, pages)
error_str = cm.output[0]
expected_str = "Error deleting pages from document"
self.assertIn(expected_str, error_str)
+15 -167
View File
@@ -54,7 +54,6 @@ 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"
@@ -103,119 +102,6 @@ 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
@pytest.mark.parametrize(
"operation,parameters,preparation",
[
("rotate", {"degrees": 90}, "rotate_pdf"),
("split", {"pages": [[1]]}, "build_pdfs"),
("delete_pages", {"pages": [1]}, "remove_pages"),
],
)
def test_preparation_failure_propagates_from_worker(
bulk_setup,
operation,
parameters,
preparation,
):
user, _, doc, _ = bulk_setup
doc.mime_type = "application/pdf"
doc.save()
with (
mock.patch(
f"documents.bulk_edit.pdf_ops.{preparation}",
side_effect=RuntimeError("preparation failed"),
),
mock.patch("documents.bulk_edit.consume_file.apply_async") as consume,
mock.patch("documents.bulk_edit.consume_file.s") as consume_signature,
pytest.raises(RuntimeError, match="preparation failed"),
):
execute_bulk_operation(operation, [doc.pk], parameters, user.pk)
consume.assert_not_called()
consume_signature.assert_not_called()
@pytest.mark.django_db
def test_queue_failure_does_not_change_documents(bulk_setup):
_, client, doc, correspondent = bulk_setup
@@ -276,22 +162,6 @@ 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
@@ -414,46 +284,24 @@ async def test_completion_socket_is_private(anyio_backend):
@pytest.mark.django_db
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"
@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"),
):
response = client.post(
"/api/documents/bulk_edit/",
f"/api/documents/{endpoint}/",
data,
format="json",
HTTP_ACCEPT="application/json; version=9",
)
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",
}
@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",
}
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()
+1 -2
View File
@@ -69,8 +69,7 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
doc.save()
shutil.copy(sample_file, doc.source_path)
with self.assertRaisesMessage(ValueError, "No parser found"):
update_document_content_maybe_archive_file(doc.pk)
update_document_content_maybe_archive_file(doc.pk)
doc = Document.objects.get(id=doc.id)
@@ -8,7 +8,6 @@ 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
@@ -35,7 +34,6 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"root_document_id": self.doc2.id,
"from_webui": False,
},
)
@@ -105,6 +103,67 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
self.assertFalse(serializer.is_valid())
self.assertIn("documents", serializer.errors)
def test_rejects_selected_version(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [version.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Only top-level documents can be merged as versions.",
)
def test_rejects_source_document_with_versions(self) -> None:
Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Documents with existing versions cannot be merged into another document.",
)
def test_rejects_source_document_with_trashed_versions(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
version.delete() # trashed, but still points at doc1
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Documents with existing versions cannot be merged into another document.",
)
def test_allows_root_document_with_versions(self) -> None:
Document.objects.create(
checksum="D",
@@ -123,95 +182,6 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
class TestMergeDocumentsAsVersions(TestCase):
def setUp(self) -> None:
self.doc1 = Document.objects.create(checksum="test-A", title="A")
self.doc2 = Document.objects.create(checksum="test-B", title="B")
def test_rejects_selected_version(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
with self.assertRaisesMessage(
ValueError,
"Only top-level documents can be merged as versions.",
):
merge_as_versions(
[version.id, self.doc2.id],
root_document_id=self.doc2.id,
)
def test_rejects_source_document_with_versions(self) -> None:
Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
with self.assertRaisesMessage(
ValueError,
"Documents with existing versions cannot be merged into another document.",
):
merge_as_versions(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc2.id,
)
def test_rejects_source_document_with_trashed_versions(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
version.delete() # trashed, but still points at doc1
with self.assertRaisesMessage(
ValueError,
"Documents with existing versions cannot be merged into another document.",
):
merge_as_versions(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc2.id,
)
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
def test_rejects_source_that_gained_versions_after_validation(
self,
_get_backend_mock,
_bulk_update_mock,
_status_manager_mock,
) -> None:
destination = Document.objects.create(checksum="destination")
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, destination.id],
"root_document_id": destination.id,
},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
merge_as_versions(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc1.id,
)
with self.assertRaisesMessage(
ValueError,
"Documents with existing versions cannot be merged into another document.",
):
merge_as_versions(
serializer.validated_data["documents"],
root_document_id=serializer.validated_data["root_document_id"],
)
self.doc1.refresh_from_db()
self.doc2.refresh_from_db()
self.assertIsNone(self.doc1.root_document_id)
self.assertEqual(self.doc2.root_document_id, self.doc1.id)
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
@@ -378,17 +348,6 @@ 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:
@@ -408,10 +367,7 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data,
{"result": "OK", "task_id": "test-bulk-operation"},
)
self.assertEqual(response.data, {"result": "OK"})
merge_mock.assert_called_once_with(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc2.id,
@@ -484,9 +440,8 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
merge_mock.assert_not_called()
@mock.patch("documents.views.execute_bulk_operation.apply_async")
def test_queues_source_document_with_versions(self, enqueue_mock) -> None:
enqueue_mock.return_value.id = "queued-merge"
@mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_rejects_source_document_with_versions(self, merge_mock) -> None:
Document.objects.create(
checksum="C",
title="C",
@@ -504,9 +459,8 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
enqueue_mock.assert_called_once()
self.assertEqual(response.data["task_id"], "queued-merge")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
merge_mock.assert_not_called()
self.doc1.refresh_from_db()
self.assertIsNone(self.doc1.root_document_id)
-543
View File
@@ -1,543 +0,0 @@
import shutil
import uuid
from unittest import mock
import pytest
from documents.bulk_operations import execute_bulk_operation
from documents.models import PaperlessTask
from documents.signals.handlers import task_failure_handler
from documents.signals.handlers import task_postrun_handler
from documents.signals.handlers import task_revoked_handler
from documents.tasks import update_document_content_maybe_archive_file
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import UserFactory
@pytest.fixture
def operation(paperless_dirs):
user = UserFactory(superuser=True)
documents = DocumentFactory.create_batch(3)
parent = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_type=PaperlessTask.TaskType.BULK_UPDATE,
owner=user,
trigger_source=PaperlessTask.TriggerSource.WEB_UI,
input_data={"operation": "reprocess", "document_count": 3},
)
kwargs = {
"operation": "reprocess",
"document_ids": [document.pk for document in documents],
"parameters": {"remote_ocr": True},
"user_id": user.pk,
}
return parent, kwargs
@pytest.fixture
def pdf_documents(paperless_dirs, tmp_path):
from documents.models import Document
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
documents = DocumentFactory.create_batch(2, mime_type="application/pdf")
for document in documents:
source = tmp_path / f"{document.pk}.pdf"
shutil.copy(SIMPLE_DIGITAL_PDF, source)
Document.objects.filter(pk=document.pk).update(filename=str(source))
document.refresh_from_db()
return documents
def complete(child, status="SUCCESS"):
if status == "FAILURE":
task_failure_handler(
sender=update_document_content_maybe_archive_file,
task_id=child.task_id,
exception=RuntimeError("Parser failed"),
)
elif status == "REVOKED":
task_revoked_handler(
sender=update_document_content_maybe_archive_file,
request=mock.Mock(id=child.task_id),
)
else:
task_postrun_handler(
task=update_document_content_maybe_archive_file,
task_id=child.task_id,
state=status,
)
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("outcome", ["SUCCESS", "FAILURE", "REVOKED"])
def test_parent_waits_for_all_children(operation, outcome):
parent, kwargs = operation
with (
mock.patch(
"documents.tasks.update_document_content_maybe_archive_file.apply_async",
) as publish,
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
assert parent.date_done is None
children = list(parent.children.order_by("pk"))
assert len(children) == 3
assert publish.call_count == 3
assert all(child.owner_id == parent.owner_id for child in children)
assert all(child.trigger_source == parent.trigger_source for child in children)
assert all(call.args[1]["remote_ocr"] for call in publish.call_args_list)
complete(children[0], outcome)
complete(children[1])
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
notify.assert_not_called()
complete(children[2])
parent.refresh_from_db()
assert parent.status == ("success" if outcome == "SUCCESS" else "failure")
assert parent.result_data["succeeded_count"] == (
3 if outcome == "SUCCESS" else 2
)
assert parent.result_data["failed_count"] == (0 if outcome == "SUCCESS" else 1)
assert parent.date_done is not None
assert parent.duration_seconds >= 0
notify.assert_called_once()
complete(children[2])
notify.assert_called_once()
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("outcome", ["SUCCESS", "FAILURE"])
def test_fast_children_wait_for_dispatch_to_finish(operation, outcome):
parent, kwargs = operation
def publish(*args, task_id, **kwargs):
# Every child is registered before the first publication.
assert parent.children.count() == 3
complete(PaperlessTask.objects.get(task_id=task_id), outcome)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
assert not parent.dispatched
with (
mock.patch(
"documents.tasks.update_document_content_maybe_archive_file.apply_async",
side_effect=publish,
),
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
parent.refresh_from_db()
assert parent.status == ("success" if outcome == "SUCCESS" else "failure")
notify.assert_called_once()
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("published_count", [0, 1])
def test_publish_failure_waits_for_published_children(operation, published_count):
parent, kwargs = operation
with (
mock.patch(
"documents.tasks.update_document_content_maybe_archive_file.apply_async",
side_effect=[None] * published_count + [RuntimeError("Broker unavailable")],
),
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=False)
parent.refresh_from_db()
if published_count:
assert parent.status == PaperlessTask.Status.STARTED
notify.assert_not_called()
complete(parent.children.get(status=PaperlessTask.Status.PENDING))
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.FAILURE
assert parent.result_data["error_message"] == "Broker unavailable"
assert parent.result_data["succeeded_count"] == published_count
assert parent.result_data["failed_count"] == 3 - published_count
notify.assert_called_once()
@pytest.mark.django_db(transaction=True)
def test_empty_selection_completes_without_children(operation):
parent, kwargs = operation
kwargs["document_ids"] = []
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.SUCCESS
assert not parent.children.exists()
@pytest.mark.django_db
def test_client_cannot_supply_parent_task_id(operation):
from rest_framework.test import APIClient
parent, kwargs = operation
client = APIClient()
client.force_authenticate(parent.owner)
with mock.patch("documents.views.execute_bulk_operation.apply_async") as publish:
response = client.post(
"/api/documents/bulk_edit/",
{
"documents": kwargs["document_ids"],
"method": "set_correspondent",
"parameters": {"correspondent": None, "parent_task_id": parent.task_id},
},
format="json",
)
assert response.status_code == 400
publish.assert_not_called()
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize(
"name,parameters",
[
("rotate", {"degrees": 90}),
("merge", {}),
("split", {"pages": [[1]]}),
("delete_pages", {"pages": [1]}),
("edit_pdf", {"operations": [{"page": 1}], "update_document": True}),
("edit_pdf", {"operations": [{"page": 1}]}),
("remove_password", {"password": "secret", "update_document": True}),
("remove_password", {"password": "secret"}),
],
)
def test_file_operations_wait_for_consumption(
operation,
pdf_documents,
name,
parameters,
):
from documents.tasks import consume_file
parent, kwargs = operation
documents = pdf_documents
kwargs.update(
operation=name,
document_ids=[doc.pk for doc in documents]
if name == "merge"
else [documents[0].pk],
parameters=parameters,
)
parent.input_data = {
"operation": name,
"document_count": len(kwargs["document_ids"]),
}
parent.save()
with (
mock.patch("documents.tasks.consume_file.apply_async") as publish,
mock.patch("documents.bulk_edit.pdf_ops.needs_decrypt", return_value=True),
mock.patch(
"documents.bulk_edit.pdf_ops.decrypt_pdf",
return_value=documents[0].source_path,
),
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
child = parent.children.get()
assert child.task_type == PaperlessTask.TaskType.CONSUME_FILE
publish.assert_called_once()
task_postrun_handler(
task=consume_file,
task_id=child.task_id,
state="SUCCESS",
retval={"document_id": 100},
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.SUCCESS
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize(
"outcome",
["success", "failure", "duplicate", "stopped", "revoked"],
)
def test_replacement_waits_for_deletion_or_recovery(operation, pdf_documents, outcome):
from documents.models import Document
from documents.task_tracking import finish_file_operation
from documents.tasks import consume_file
parent, kwargs = operation
doc = pdf_documents[0]
doc.archive_serial_number = 123
Document.objects.filter(pk=doc.pk).update(archive_serial_number=123)
kwargs.update(
operation="edit_pdf",
document_ids=[doc.pk],
parameters={"operations": [{"page": 1}], "delete_original": True},
)
parent.input_data = {"operation": "edit_pdf", "document_count": 1}
parent.save()
with (
mock.patch("documents.tasks.consume_file.apply_async"),
mock.patch(
"documents.task_tracking.finish_file_operation.apply_async",
) as finalize,
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
consume = parent.children.get(task_type=PaperlessTask.TaskType.CONSUME_FILE)
finalizer = parent.children.get(task_type=PaperlessTask.TaskType.BULK_DELETE)
finalize.assert_not_called()
if outcome == "failure":
complete(consume, "FAILURE")
elif outcome == "revoked":
complete(consume, "REVOKED")
else:
retval = (
{"document_id": 100}
if outcome == "success"
else (
{"duplicate_of": 100}
if outcome == "duplicate"
else {"reason": "Stopped"}
)
)
task_postrun_handler(
task=consume_file,
task_id=consume.task_id,
state="SUCCESS",
retval=retval,
)
finalize.assert_called_once()
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
assert Document.objects.filter(pk=doc.pk).exists()
notify.assert_not_called()
finish_file_operation.apply(
kwargs=finalizer.input_data,
task_id=finalizer.task_id,
throw=True,
)
parent.refresh_from_db()
assert parent.status == ("success" if outcome == "success" else "failure")
notify.assert_called_once()
if outcome == "success":
assert not Document.objects.filter(pk=doc.pk).exists()
else:
doc.refresh_from_db()
assert not doc.is_deleted
assert doc.archive_serial_number == 123
@pytest.mark.django_db(transaction=True)
def test_consumption_descendants_keep_operation_active(operation):
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentSource
from documents.signals.handlers import before_task_publish_handler
from documents.task_tracking import finish_dispatch
from documents.task_tracking import queue_processing_tasks
from documents.tasks import consume_file
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
parent, _ = operation
parent.status = PaperlessTask.Status.STARTED
parent.save()
input_doc = ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=SIMPLE_DIGITAL_PDF,
)
with mock.patch("documents.tasks.consume_file.apply_async"):
queue_processing_tasks(parent.task_id, [consume_file.s(input_doc=input_doc)])
child = parent.children.get()
finish_dispatch(parent, {})
descendant_id = str(uuid.uuid4())
before_task_publish_handler(
headers={
"task": consume_file.name,
"id": descendant_id,
"parent_id": child.task_id,
},
body=((), {"input_doc": input_doc}, {}),
)
descendant = PaperlessTask.objects.get(task_id=descendant_id)
assert descendant.parent_id == child.pk
assert descendant.owner_id == parent.owner_id
task_postrun_handler(
task=consume_file,
task_id=child.task_id,
state="SUCCESS",
retval={"reason": "Barcode splitting complete"},
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
task_postrun_handler(
task=consume_file,
task_id=descendant_id,
state="SUCCESS",
retval={"document_id": 100},
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.SUCCESS
@pytest.mark.django_db(transaction=True)
@pytest.mark.parametrize("failure", ["delete", "restore", "publish"])
def test_final_step_failures_reach_parent(operation, pdf_documents, failure):
from documents.task_tracking import finish_file_operation
from documents.tasks import consume_file
parent, kwargs = operation
doc = pdf_documents[0]
kwargs.update(
operation="edit_pdf",
document_ids=[doc.pk],
parameters={"operations": [{"page": 1}], "delete_original": True},
)
with (
mock.patch("documents.tasks.consume_file.apply_async"),
mock.patch(
"documents.task_tracking.finish_file_operation.apply_async",
side_effect=RuntimeError("Publish failed")
if failure == "publish"
else None,
),
mock.patch("documents.signals.handlers._send_bulk_operation_status") as notify,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
child = parent.children.get(task_type=PaperlessTask.TaskType.CONSUME_FILE)
if failure == "restore":
complete(child, "FAILURE")
else:
task_postrun_handler(
task=consume_file,
task_id=child.task_id,
state="SUCCESS",
retval={"document_id": 100},
)
finalizer = parent.children.get(task_type=PaperlessTask.TaskType.BULK_DELETE)
if failure != "publish":
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
notify.assert_not_called()
target = (
"documents.bulk_edit.delete"
if failure == "delete"
else "documents.bulk_edit.restore_archive_serial_numbers"
)
with mock.patch(target, side_effect=RuntimeError("Final step failed")):
finish_file_operation.apply(
kwargs=finalizer.input_data,
task_id=finalizer.task_id,
throw=False,
)
parent.refresh_from_db()
finalizer.refresh_from_db()
assert finalizer.status == PaperlessTask.Status.FAILURE
assert parent.status == PaperlessTask.Status.FAILURE
notify.assert_called_once()
doc.refresh_from_db()
assert not doc.is_deleted
@pytest.mark.django_db(transaction=True)
def test_partial_merge_preserves_skipped_original_and_reports_it(
operation,
pdf_documents,
):
from documents.models import Document
from documents.task_tracking import finish_file_operation
from documents.tasks import consume_file
parent, kwargs = operation
included, excluded = pdf_documents
excluded.source_path.unlink()
kwargs.update(
operation="merge",
document_ids=[included.pk, excluded.pk],
parameters={"delete_originals": True},
)
parent.input_data = {"operation": "merge", "document_count": 2}
parent.save()
with (
mock.patch("documents.tasks.consume_file.apply_async") as consume,
mock.patch("documents.task_tracking.finish_file_operation.apply_async"),
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
consume.assert_called_once()
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
assert parent.result_data["failed_document_ids"] == [excluded.pk]
child = parent.children.get(task_type=PaperlessTask.TaskType.CONSUME_FILE)
task_postrun_handler(
task=consume_file,
task_id=child.task_id,
state="SUCCESS",
retval={"document_id": 100},
)
finalizer = parent.children.get(task_type=PaperlessTask.TaskType.BULK_DELETE)
assert finalizer.input_data["delete_ids"] == [included.pk]
finish_file_operation.apply(
kwargs=finalizer.input_data,
task_id=finalizer.task_id,
throw=True,
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.FAILURE
assert parent.result_data["failed_document_ids"] == [excluded.pk]
assert not Document.objects.filter(pk=included.pk).exists()
assert Document.objects.filter(pk=excluded.pk).exists()
@pytest.mark.django_db(transaction=True)
def test_merge_with_no_valid_inputs_fails(operation, pdf_documents):
parent, kwargs = operation
for doc in pdf_documents:
doc.source_path.unlink()
kwargs.update(
operation="merge",
document_ids=[doc.pk for doc in pdf_documents],
parameters={},
)
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=False)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.FAILURE
assert not parent.children.exists()
@pytest.mark.django_db(transaction=True)
def test_split_waits_for_every_output_before_recovery(operation, pdf_documents):
from documents.task_tracking import finish_file_operation
from documents.tasks import consume_file
parent, kwargs = operation
doc = pdf_documents[0]
kwargs.update(
operation="split",
document_ids=[doc.pk],
parameters={"pages": [[1], [1]], "delete_originals": True},
)
with (
mock.patch("documents.tasks.consume_file.apply_async"),
mock.patch(
"documents.task_tracking.finish_file_operation.apply_async",
) as finalize,
):
execute_bulk_operation.apply(kwargs=kwargs, task_id=parent.task_id, throw=True)
children = list(
parent.children.filter(task_type=PaperlessTask.TaskType.CONSUME_FILE),
)
assert len(children) == 2
complete(children[0], "FAILURE")
finalize.assert_not_called()
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.STARTED
task_postrun_handler(
task=consume_file,
task_id=children[1].task_id,
state="SUCCESS",
retval={"document_id": 100},
)
finalize.assert_called_once()
finalizer = parent.children.get(task_type=PaperlessTask.TaskType.BULK_DELETE)
finish_file_operation.apply(
kwargs=finalizer.input_data,
task_id=finalizer.task_id,
throw=True,
)
parent.refresh_from_db()
assert parent.status == PaperlessTask.Status.FAILURE
doc.refresh_from_db()
assert not doc.is_deleted
+1 -58
View File
@@ -1,5 +1,4 @@
import shutil
import uuid
from datetime import timedelta
from pathlib import Path
from unittest import mock
@@ -17,7 +16,6 @@ from documents import tasks
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.models import Tag
from documents.models import WorkflowAction
from documents.sanity_checker import SanityCheckFailedException
@@ -282,60 +280,6 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
tasks.update_document_content_maybe_archive_file(doc.pk)
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
@mock.patch("documents.tasks.get_parser_registry")
def test_missing_parser_records_failure(self, registry) -> None:
doc = DocumentFactory(content="Original content", mime_type="application/pdf")
registry.return_value.get_parser_for_file.return_value = None
task = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_type=PaperlessTask.TaskType.REPROCESS_DOCUMENT,
)
result = tasks.update_document_content_maybe_archive_file.apply(
args=[doc.pk],
task_id=task.task_id,
throw=False,
)
self.assertTrue(result.failed())
task.refresh_from_db()
self.assertEqual(task.status, PaperlessTask.Status.FAILURE)
self.assertIn("No parser found", task.result_data["error_message"])
doc.refresh_from_db()
self.assertEqual(doc.content, "Original content")
@mock.patch("documents.tasks.should_produce_archive", return_value=False)
@mock.patch("documents.tasks.get_parser_registry")
def test_parser_failure_records_failure(self, registry, _produce_archive) -> None:
for method in ("parse", "get_thumbnail"):
with self.subTest(method=method):
doc = DocumentFactory(
content="Original content",
mime_type="application/pdf",
)
parser = mock.MagicMock()
getattr(parser, method).side_effect = RuntimeError("Processing failed")
parser_class = mock.MagicMock()
parser_class.return_value.__enter__.return_value = parser
registry.return_value.get_parser_for_file.return_value = parser_class
task = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_type=PaperlessTask.TaskType.REPROCESS_DOCUMENT,
)
result = tasks.update_document_content_maybe_archive_file.apply(
args=[doc.pk],
task_id=task.task_id,
throw=False,
)
self.assertTrue(result.failed())
task.refresh_from_db()
self.assertEqual(task.status, PaperlessTask.Status.FAILURE)
self.assertEqual(task.result_data["error_message"], "Processing failed")
doc.refresh_from_db()
self.assertEqual(doc.content, "Original content")
def _create_root_with_version(self) -> tuple[Document, Document]:
sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(SIMPLE_DIGITAL_PDF, sample1)
@@ -426,8 +370,7 @@ class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
)
def _allow_remote(self, **kwargs) -> bool:
with self.assertRaisesMessage(ValueError, "No parser found"):
tasks.update_document_content_maybe_archive_file(self.doc.pk, **kwargs)
tasks.update_document_content_maybe_archive_file(self.doc.pk, **kwargs)
_, call_kwargs = self.mock_registry.return_value.get_parser_for_file.call_args
return call_kwargs["allow_remote"]
+3 -12
View File
@@ -2333,12 +2333,9 @@ class TestWorkflows(
self.assertEqual(doc.owner, self.user2)
def test_manual_workflow_run_missing_workflow(self) -> None:
"""A workflow removed after queueing fails the task."""
"""A missing workflow id logs a warning rather than raising."""
doc = DocumentFactory()
with (
self.assertLogs("paperless.tasks", level="WARNING"),
self.assertRaises(Workflow.DoesNotExist),
):
with self.assertLogs("paperless.tasks", level="WARNING"):
tasks.run_workflows_for_documents([doc.pk], 9999)
@mock.patch("documents.tasks.send_websocket_document_updated")
@@ -2359,13 +2356,7 @@ class TestWorkflows(
mock_run_workflows.side_effect = side_effect
with (
self.assertLogs("paperless.tasks", level="ERROR"),
self.assertRaisesMessage(
RuntimeError,
f"failed for document(s): [{doc1.pk}]",
),
):
with self.assertLogs("paperless.tasks", level="ERROR"):
tasks.run_workflows_for_documents([doc1.pk, doc2.pk], workflow.pk)
# Both documents were attempted; the websocket update only fired for the
+83 -38
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 TRIGGER_SOURCE_OPERATIONS
from documents.bulk_operations import USER_OPERATIONS
from documents.bulk_operations import QUEUED_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,31 +2974,38 @@ 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):
for parameter in ("update_callback", "parent_task_id"):
if parameter in parameters:
raise ValueError(f"Unsupported bulk operation parameter: {parameter}")
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)
if "update_callback" in parameters:
raise ValueError("Unsupported bulk operation parameter: update_callback")
signature(method).bind(documents, **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": trigger_source},
headers={
"trigger_source": (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
),
},
)
return Response({"result": "OK", "task_id": task.id})
@@ -3122,6 +3129,15 @@ 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,
@@ -3131,12 +3147,8 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
return HttpResponseForbidden("Insufficient permissions")
try:
return self._queue_bulk_operation(
method,
documents,
parameters,
from_webui,
)
result = method(documents, **parameters)
return Response({"result": result})
except Exception as e:
logger.warning(f"An error occurred performing {operation_label}: {e!s}")
return HttpResponseBadRequest(
@@ -3157,13 +3169,19 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
name="BulkEditDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
"task_id": serializers.CharField(required=False),
},
),
},
),
)
class BulkEditView(DocumentOperationPermissionMixin):
LEGACY_MODIFIED_FIELDS = {
"delete": "deleted_at",
"reprocess": "checksum",
"remove_password": None,
}
serializer_class = BulkEditSerializer
def post(self, request, *args, **kwargs):
@@ -3193,6 +3211,14 @@ 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,
@@ -3202,12 +3228,38 @@ class BulkEditView(DocumentOperationPermissionMixin):
return HttpResponseForbidden("Insufficient permissions")
try:
return self._queue_bulk_operation(
method,
documents,
parameters,
from_webui,
)
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})
except Exception as e:
logger.warning(f"An error occurred performing bulk edit: {e!s}")
return HttpResponseBadRequest(
@@ -3224,7 +3276,6 @@ class BulkEditView(DocumentOperationPermissionMixin):
name="RotateDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3252,7 +3303,6 @@ class RotateDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3280,7 +3330,6 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
name="MergeDocumentsAsVersionsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3308,7 +3357,6 @@ class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
name="DeleteDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3336,7 +3384,6 @@ class DeleteDocumentsView(DocumentOperationPermissionMixin):
name="ReprocessDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3364,7 +3411,6 @@ class ReprocessDocumentsView(DocumentOperationPermissionMixin):
name="EditPdfDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},
@@ -3392,7 +3438,6 @@ class EditPdfDocumentsView(DocumentOperationPermissionMixin):
name="RemovePasswordDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(),
},
),
},