mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-14 21:58:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac8dd7a04 | ||
|
|
f1b21628a9 | ||
|
|
05b7697c35 | ||
|
|
c626ecd9bc | ||
|
|
aeed83b14a | ||
|
|
72ea38ab12 | ||
|
|
4421d4fe58 | ||
|
|
4d64632f70 | ||
|
|
26094bc863 | ||
|
|
9dbad4de09 | ||
|
|
4a54935b3d | ||
|
|
d53c9070ba | ||
|
|
f885833a38 | ||
|
|
197c80ea68 | ||
|
|
a0c9500b6a | ||
|
|
df8e95cbd4 | ||
|
|
95944a553d | ||
|
|
2256cb3d38 | ||
|
|
9a8163fbbb | ||
|
|
d5f9605daf |
+135
-96
@@ -1345,7 +1345,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1808</context>
|
<context context-type="linenumber">1865</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1577733187050997705" datatype="html">
|
<trans-unit id="1577733187050997705" datatype="html">
|
||||||
@@ -2068,7 +2068,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">215</context>
|
<context context-type="linenumber">219</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="131016739441837046" datatype="html">
|
<trans-unit id="131016739441837046" datatype="html">
|
||||||
@@ -2079,7 +2079,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">227</context>
|
<context context-type="linenumber">231</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3643866539136239218" datatype="html">
|
<trans-unit id="3643866539136239218" datatype="html">
|
||||||
@@ -2415,15 +2415,15 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">317</context>
|
<context context-type="linenumber">321</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">351</context>
|
<context context-type="linenumber">355</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">673</context>
|
<context context-type="linenumber">690</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||||
@@ -2612,11 +2612,18 @@
|
|||||||
<context context-type="linenumber">100</context>
|
<context context-type="linenumber">100</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="5672013191898828327" datatype="html">
|
||||||
|
<source>Apply AI Suggestions</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
|
<context context-type="linenumber">104</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="9172233176401579786" datatype="html">
|
<trans-unit id="9172233176401579786" datatype="html">
|
||||||
<source>Scheduled</source>
|
<source>Scheduled</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">110</context>
|
<context context-type="linenumber">114</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
@@ -2627,7 +2634,7 @@
|
|||||||
<source>Web UI</source>
|
<source>Web UI</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">112</context>
|
<context context-type="linenumber">116</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
@@ -2638,7 +2645,7 @@
|
|||||||
<source>API Upload</source>
|
<source>API Upload</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">115</context>
|
<context context-type="linenumber">119</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
|
||||||
@@ -2649,106 +2656,106 @@
|
|||||||
<source>Folder Consume</source>
|
<source>Folder Consume</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">119</context>
|
<context context-type="linenumber">123</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1456901843819961615" datatype="html">
|
<trans-unit id="1456901843819961615" datatype="html">
|
||||||
<source>Email Consume</source>
|
<source>Email Consume</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">123</context>
|
<context context-type="linenumber">127</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="29832309535656200" datatype="html">
|
<trans-unit id="29832309535656200" datatype="html">
|
||||||
<source>System</source>
|
<source>System</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">125</context>
|
<context context-type="linenumber">129</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2233560223291461480" datatype="html">
|
<trans-unit id="2233560223291461480" datatype="html">
|
||||||
<source>Manual</source>
|
<source>Manual</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">126</context>
|
<context context-type="linenumber">130</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5404910960991552159" datatype="html">
|
<trans-unit id="5404910960991552159" datatype="html">
|
||||||
<source>Dismiss selected</source>
|
<source>Dismiss selected</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">239</context>
|
<context context-type="linenumber">243</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9169677036332103838" datatype="html">
|
<trans-unit id="9169677036332103838" datatype="html">
|
||||||
<source>Dismiss visible</source>
|
<source>Dismiss visible</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">240</context>
|
<context context-type="linenumber">244</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3169751690815214293" datatype="html">
|
<trans-unit id="3169751690815214293" datatype="html">
|
||||||
<source>Confirm Dismiss</source>
|
<source>Confirm Dismiss</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">314</context>
|
<context context-type="linenumber">318</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5029621907742319073" datatype="html">
|
<trans-unit id="5029621907742319073" datatype="html">
|
||||||
<source>Dismiss <x id="PH" equiv-text="tasks.size"/> tasks?</source>
|
<source>Dismiss <x id="PH" equiv-text="tasks.size"/> tasks?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">315</context>
|
<context context-type="linenumber">319</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3597309129998924778" datatype="html">
|
<trans-unit id="3597309129998924778" datatype="html">
|
||||||
<source>Error dismissing tasks</source>
|
<source>Error dismissing tasks</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">326</context>
|
<context context-type="linenumber">330</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">360</context>
|
<context context-type="linenumber">364</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2132179171926568807" datatype="html">
|
<trans-unit id="2132179171926568807" datatype="html">
|
||||||
<source>Error dismissing task</source>
|
<source>Error dismissing task</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">338</context>
|
<context context-type="linenumber">342</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1323591410517879795" datatype="html">
|
<trans-unit id="1323591410517879795" datatype="html">
|
||||||
<source>Confirm Dismiss All</source>
|
<source>Confirm Dismiss All</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">348</context>
|
<context context-type="linenumber">352</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4157200209636243740" datatype="html">
|
<trans-unit id="4157200209636243740" datatype="html">
|
||||||
<source>Dismiss all <x id="PH" equiv-text="this.totalTasks()"/> tasks?</source>
|
<source>Dismiss all <x id="PH" equiv-text="this.totalTasks()"/> tasks?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">349</context>
|
<context context-type="linenumber">353</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8149502458056418229" datatype="html">
|
<trans-unit id="8149502458056418229" datatype="html">
|
||||||
<source>Success. New document id <x id="PH" equiv-text="documentId"/> created</source>
|
<source>Success. New document id <x id="PH" equiv-text="documentId"/> created</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">408</context>
|
<context context-type="linenumber">412</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8760066891202884337" datatype="html">
|
<trans-unit id="8760066891202884337" datatype="html">
|
||||||
<source>Duplicate of document #<x id="PH" equiv-text="duplicateOf"/></source>
|
<source>Duplicate of document #<x id="PH" equiv-text="duplicateOf"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">418</context>
|
<context context-type="linenumber">422</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
|
||||||
<context context-type="linenumber">452</context>
|
<context context-type="linenumber">456</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3418677553313974490" datatype="html">
|
<trans-unit id="3418677553313974490" datatype="html">
|
||||||
@@ -3330,11 +3337,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1422</context>
|
<context context-type="linenumber">1479</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1809</context>
|
<context context-type="linenumber">1866</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -3935,7 +3942,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1375</context>
|
<context context-type="linenumber">1432</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -4079,7 +4086,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1862</context>
|
<context context-type="linenumber">1919</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6661109599266152398" datatype="html">
|
<trans-unit id="6661109599266152398" datatype="html">
|
||||||
@@ -4090,7 +4097,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1863</context>
|
<context context-type="linenumber">1920</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5162686434580248853" datatype="html">
|
<trans-unit id="5162686434580248853" datatype="html">
|
||||||
@@ -4101,7 +4108,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1864</context>
|
<context context-type="linenumber">1921</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6665634854532231106" datatype="html">
|
<trans-unit id="6665634854532231106" datatype="html">
|
||||||
@@ -6238,7 +6245,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1379</context>
|
<context context-type="linenumber">1436</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -8662,152 +8669,203 @@
|
|||||||
<source>Error retrieving metadata</source>
|
<source>Error retrieving metadata</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">440</context>
|
<context context-type="linenumber">442</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2218903673684131427" datatype="html">
|
<trans-unit id="2218903673684131427" datatype="html">
|
||||||
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
|
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">542,544</context>
|
<context context-type="linenumber">545,547</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1000,1002</context>
|
<context context-type="linenumber">1024,1026</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6357361810318120957" datatype="html">
|
<trans-unit id="6357361810318120957" datatype="html">
|
||||||
<source>Document was updated</source>
|
<source>Document was updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">668</context>
|
<context context-type="linenumber">685</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5154064822428631306" datatype="html">
|
<trans-unit id="5154064822428631306" datatype="html">
|
||||||
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
|
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">669</context>
|
<context context-type="linenumber">686</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8462497568316256794" datatype="html">
|
<trans-unit id="8462497568316256794" datatype="html">
|
||||||
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
|
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">670</context>
|
<context context-type="linenumber">687</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7967484035994732534" datatype="html">
|
<trans-unit id="7967484035994732534" datatype="html">
|
||||||
<source>Reload</source>
|
<source>Reload</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">672</context>
|
<context context-type="linenumber">689</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2907037627372942104" datatype="html">
|
<trans-unit id="2907037627372942104" datatype="html">
|
||||||
<source>Document reloaded with latest changes.</source>
|
<source>Document reloaded with latest changes.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">728</context>
|
<context context-type="linenumber">745</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6435639868943916539" datatype="html">
|
<trans-unit id="6435639868943916539" datatype="html">
|
||||||
<source>Document reloaded.</source>
|
<source>Document reloaded.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">739</context>
|
<context context-type="linenumber">756</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6142395741265832184" datatype="html">
|
<trans-unit id="6142395741265832184" datatype="html">
|
||||||
<source>Next document</source>
|
<source>Next document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">841</context>
|
<context context-type="linenumber">858</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="651985345816518480" datatype="html">
|
<trans-unit id="651985345816518480" datatype="html">
|
||||||
<source>Previous document</source>
|
<source>Previous document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">851</context>
|
<context context-type="linenumber">868</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2885986061416655600" datatype="html">
|
<trans-unit id="2885986061416655600" datatype="html">
|
||||||
<source>Close document</source>
|
<source>Close document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">859</context>
|
<context context-type="linenumber">876</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
<context context-type="linenumber">143</context>
|
<context context-type="linenumber">151</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8229691481345614469" datatype="html">
|
<trans-unit id="8229691481345614469" datatype="html">
|
||||||
<source>Save document</source>
|
<source>Save document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">866</context>
|
<context context-type="linenumber">883</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1784543155727940353" datatype="html">
|
<trans-unit id="1784543155727940353" datatype="html">
|
||||||
<source>Save and close / next</source>
|
<source>Save and close / next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">875</context>
|
<context context-type="linenumber">892</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7427704425579737895" datatype="html">
|
<trans-unit id="7427704425579737895" datatype="html">
|
||||||
<source>Error retrieving version content</source>
|
<source>Error retrieving version content</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">983</context>
|
<context context-type="linenumber">1006</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="159901853873315050" datatype="html">
|
||||||
|
<source>Unsaved Changes</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
|
<context context-type="linenumber">1050</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context>
|
||||||
|
<context context-type="linenumber">15</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
|
||||||
|
<context context-type="linenumber">27</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
|
<context context-type="linenumber">143</context>
|
||||||
|
</context-group>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
|
<context context-type="linenumber">170</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="5905048729613762185" datatype="html">
|
||||||
|
<source>You have unsaved changes to the content of this version.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
|
<context context-type="linenumber">1051</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="85184271222513014" datatype="html">
|
||||||
|
<source>Switching versions will discard them.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
|
<context context-type="linenumber">1052</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="2565707334844767610" datatype="html">
|
||||||
|
<source>Discard and switch</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
|
<context context-type="linenumber">1054</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="2109314380040637387" datatype="html">
|
||||||
|
<source>Save and switch</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
|
<context context-type="linenumber">1056</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3456881259945295697" datatype="html">
|
<trans-unit id="3456881259945295697" datatype="html">
|
||||||
<source>Error retrieving suggestions.</source>
|
<source>Error retrieving suggestions.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1043</context>
|
<context context-type="linenumber">1099</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2194092841814123758" datatype="html">
|
<trans-unit id="2194092841814123758" datatype="html">
|
||||||
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1255</context>
|
<context context-type="linenumber">1311</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1282</context>
|
<context context-type="linenumber">1339</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6626387786259219838" datatype="html">
|
<trans-unit id="6626387786259219838" datatype="html">
|
||||||
<source>Error saving document "<x id="PH" equiv-text="this.document().title"/>"</source>
|
<source>Error saving document "<x id="PH" equiv-text="this.document().title"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1288</context>
|
<context context-type="linenumber">1345</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="448882439049417053" datatype="html">
|
<trans-unit id="448882439049417053" datatype="html">
|
||||||
<source>Error saving document</source>
|
<source>Error saving document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1343</context>
|
<context context-type="linenumber">1400</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8410796510716511826" datatype="html">
|
<trans-unit id="8410796510716511826" datatype="html">
|
||||||
<source>Do you really want to move the document "<x id="PH" equiv-text="this.document().title"/>" to the trash?</source>
|
<source>Do you really want to move the document "<x id="PH" equiv-text="this.document().title"/>" to the trash?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1376</context>
|
<context context-type="linenumber">1433</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="282586936710748252" datatype="html">
|
<trans-unit id="282586936710748252" datatype="html">
|
||||||
<source>Documents can be restored prior to permanent deletion.</source>
|
<source>Documents can be restored prior to permanent deletion.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1377</context>
|
<context context-type="linenumber">1434</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -8818,14 +8876,14 @@
|
|||||||
<source>Error deleting document</source>
|
<source>Error deleting document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1398</context>
|
<context context-type="linenumber">1455</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="619486176823357521" datatype="html">
|
<trans-unit id="619486176823357521" datatype="html">
|
||||||
<source>Reprocess confirm</source>
|
<source>Reprocess confirm</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1418</context>
|
<context context-type="linenumber">1475</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -8836,102 +8894,102 @@
|
|||||||
<source>This operation will permanently recreate the archive file for this document.</source>
|
<source>This operation will permanently recreate the archive file for this document.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1419</context>
|
<context context-type="linenumber">1476</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="302054111564709516" datatype="html">
|
<trans-unit id="302054111564709516" datatype="html">
|
||||||
<source>The archive file will be re-generated with the current settings.</source>
|
<source>The archive file will be re-generated with the current settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1420</context>
|
<context context-type="linenumber">1477</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4700389117298802932" datatype="html">
|
<trans-unit id="4700389117298802932" datatype="html">
|
||||||
<source>Reprocess operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
<source>Reprocess operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1433</context>
|
<context context-type="linenumber">1490</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4409560272830824468" datatype="html">
|
<trans-unit id="4409560272830824468" datatype="html">
|
||||||
<source>Error executing operation</source>
|
<source>Error executing operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1444</context>
|
<context context-type="linenumber">1501</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6030453331794586802" datatype="html">
|
<trans-unit id="6030453331794586802" datatype="html">
|
||||||
<source>Error downloading document</source>
|
<source>Error downloading document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1508</context>
|
<context context-type="linenumber">1565</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4458954481601077369" datatype="html">
|
<trans-unit id="4458954481601077369" datatype="html">
|
||||||
<source>Page Fit</source>
|
<source>Page Fit</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1586</context>
|
<context context-type="linenumber">1643</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4663705961777238777" datatype="html">
|
<trans-unit id="4663705961777238777" datatype="html">
|
||||||
<source>PDF edit operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
<source>PDF edit operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1829</context>
|
<context context-type="linenumber">1886</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9043972994040261999" datatype="html">
|
<trans-unit id="9043972994040261999" datatype="html">
|
||||||
<source>Error executing PDF edit operation</source>
|
<source>Error executing PDF edit operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1841</context>
|
<context context-type="linenumber">1898</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6172690334763056188" datatype="html">
|
<trans-unit id="6172690334763056188" datatype="html">
|
||||||
<source>Please enter the current password before attempting to remove it.</source>
|
<source>Please enter the current password before attempting to remove it.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1852</context>
|
<context context-type="linenumber">1909</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="968660764814228922" datatype="html">
|
<trans-unit id="968660764814228922" datatype="html">
|
||||||
<source>Password removal operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
<source>Password removal operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1886</context>
|
<context context-type="linenumber">1943</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2282118435712883014" datatype="html">
|
<trans-unit id="2282118435712883014" datatype="html">
|
||||||
<source>Error executing password removal operation</source>
|
<source>Error executing password removal operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1900</context>
|
<context context-type="linenumber">1957</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3740891324955700797" datatype="html">
|
<trans-unit id="3740891324955700797" datatype="html">
|
||||||
<source>Print failed.</source>
|
<source>Print failed.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1950</context>
|
<context context-type="linenumber">2007</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6457245677384603573" datatype="html">
|
<trans-unit id="6457245677384603573" datatype="html">
|
||||||
<source>Error loading document for printing.</source>
|
<source>Error loading document for printing.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1959</context>
|
<context context-type="linenumber">2016</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6085793215710522488" datatype="html">
|
<trans-unit id="6085793215710522488" datatype="html">
|
||||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">2042</context>
|
<context context-type="linenumber">2099</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">2048</context>
|
<context context-type="linenumber">2105</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4958946940233632319" datatype="html">
|
<trans-unit id="4958946940233632319" datatype="html">
|
||||||
@@ -12290,25 +12348,6 @@
|
|||||||
<context context-type="linenumber">16</context>
|
<context context-type="linenumber">16</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="159901853873315050" datatype="html">
|
|
||||||
<source>Unsaved Changes</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context>
|
|
||||||
<context context-type="linenumber">15</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
|
|
||||||
<context context-type="linenumber">27</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
|
||||||
<context context-type="linenumber">135</context>
|
|
||||||
</context-group>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
|
||||||
<context context-type="linenumber">162</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="2573823578527613511" datatype="html">
|
<trans-unit id="2573823578527613511" datatype="html">
|
||||||
<source>You have unsaved changes.</source>
|
<source>You have unsaved changes.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@@ -12317,7 +12356,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
<context context-type="linenumber">163</context>
|
<context context-type="linenumber">171</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3305084982600522070" datatype="html">
|
<trans-unit id="3305084982600522070" datatype="html">
|
||||||
@@ -12457,28 +12496,28 @@
|
|||||||
<source>You have unsaved changes to the document</source>
|
<source>You have unsaved changes to the document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
<context context-type="linenumber">137</context>
|
<context context-type="linenumber">145</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2089045849587358256" datatype="html">
|
<trans-unit id="2089045849587358256" datatype="html">
|
||||||
<source>Are you sure you want to close this document?</source>
|
<source>Are you sure you want to close this document?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
<context context-type="linenumber">141</context>
|
<context context-type="linenumber">149</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6755718693176327396" datatype="html">
|
<trans-unit id="6755718693176327396" datatype="html">
|
||||||
<source>Are you sure you want to close all documents?</source>
|
<source>Are you sure you want to close all documents?</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
<context context-type="linenumber">164</context>
|
<context context-type="linenumber">172</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4215561719980781894" datatype="html">
|
<trans-unit id="4215561719980781894" datatype="html">
|
||||||
<source>Close documents</source>
|
<source>Close documents</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
<context context-type="linenumber">166</context>
|
<context context-type="linenumber">174</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1206520795340730278" datatype="html">
|
<trans-unit id="1206520795340730278" datatype="html">
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const TASK_TYPE_OPTIONS: Array<{
|
|||||||
value: PaperlessTaskType.BulkDelete,
|
value: PaperlessTaskType.BulkDelete,
|
||||||
label: $localize`Bulk Delete`,
|
label: $localize`Bulk Delete`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: PaperlessTaskType.ApplyAiSuggestions,
|
||||||
|
label: $localize`Apply AI Suggestions`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const TRIGGER_SOURCE_OPTIONS: Array<{
|
const TRIGGER_SOURCE_OPTIONS: Array<{
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ import { Subject, of, throwError } from 'rxjs'
|
|||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
|
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
|
||||||
import { DataType } from 'src/app/data/datatype'
|
import { DataType } from 'src/app/data/datatype'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document, DocumentVersionInfo } from 'src/app/data/document'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
import {
|
import {
|
||||||
FILTER_CORRESPONDENT,
|
FILTER_CORRESPONDENT,
|
||||||
@@ -100,13 +101,18 @@ const doc: Document = {
|
|||||||
custom_fields: [
|
custom_fields: [
|
||||||
{
|
{
|
||||||
field: 0,
|
field: 0,
|
||||||
document: 3,
|
|
||||||
created: new Date(),
|
|
||||||
value: 'custom foo bar',
|
value: 'custom foo bar',
|
||||||
},
|
},
|
||||||
],
|
] as CustomFieldInstance[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Newest first, as the API returns them: 12 is the latest, 3 is the root
|
||||||
|
const docVersions: DocumentVersionInfo[] = [
|
||||||
|
{ id: 12, is_root: false },
|
||||||
|
{ id: 10, is_root: false },
|
||||||
|
{ id: doc.id, is_root: true },
|
||||||
|
]
|
||||||
|
|
||||||
const customFields = [
|
const customFields = [
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -2045,6 +2051,208 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(saveSpy).toHaveBeenCalled()
|
expect(saveSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('selectVersion should use the version content as the baseline and ignore stale responses', () => {
|
||||||
|
initNormally()
|
||||||
|
const version10Content = new Subject<Document>()
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValueOnce(version10Content)
|
||||||
|
.mockReturnValueOnce(of({ content: 'version 12 content' } as Document))
|
||||||
|
const version10Metadata = new Subject<any>()
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'getMetadata')
|
||||||
|
.mockReturnValueOnce(version10Metadata)
|
||||||
|
.mockReturnValueOnce(of({ lang: 'de' }))
|
||||||
|
|
||||||
|
component.selectVersion(10)
|
||||||
|
component.selectVersion(12)
|
||||||
|
version10Content.next({ content: 'version 10 content' } as Document)
|
||||||
|
version10Metadata.next({ lang: 'en' })
|
||||||
|
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 12 content'
|
||||||
|
)
|
||||||
|
expect(component.store.value.content).toEqual('version 12 content')
|
||||||
|
expect(component.metadata().lang).toEqual('de')
|
||||||
|
expect(
|
||||||
|
httpTestingController.expectOne(component.previewUrl()).cancelled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
httpTestingController.match((req) => req.url.includes('version=10'))[0]
|
||||||
|
?.cancelled
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should confirm before discarding unsaved content edits when switching versions', () => {
|
||||||
|
initNormally()
|
||||||
|
component.document().versions = docVersions
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockImplementation((id, versionID) =>
|
||||||
|
of({ content: `version ${versionID} content` } as Document)
|
||||||
|
)
|
||||||
|
let openModal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
// shared fields carry over between versions, so no confirmation
|
||||||
|
component.documentForm.get('title').setValue('Edited title')
|
||||||
|
component.documentForm.get('title').markAsDirty()
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
component.onVersionSelected(12)
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
|
||||||
|
component.documentForm.get('content').setValue('edited content')
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
component.onVersionSelected(12) // already selected, nothing to do
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(
|
||||||
|
ConfirmDialogComponent,
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
openModal.componentInstance.cancel()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'edited content'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
openModal.componentInstance.confirmClicked.emit()
|
||||||
|
expect(component.selectedVersionId()).toEqual(10)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 10 content'
|
||||||
|
)
|
||||||
|
expect(component.documentForm.get('content').dirty).toBeFalsy()
|
||||||
|
expect(component.documentForm.get('title').value).toEqual('Edited title')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save unsaved content edits to the current version before switching, and stay if that fails', () => {
|
||||||
|
initNormally()
|
||||||
|
component.document().versions = docVersions
|
||||||
|
component.selectedVersionId.set(12)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValue(of({ content: 'version 10 content' } as Document))
|
||||||
|
const savedDoc = new Subject<Document>()
|
||||||
|
const patchSpy = jest
|
||||||
|
.spyOn(documentService, 'patch')
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('failed to save')))
|
||||||
|
.mockReturnValueOnce(savedDoc)
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
component.documentForm.get('content').setValue('edited content')
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
let modal: NgbModalRef = modalSpy.mock.results[0].value
|
||||||
|
const closeSpy = jest.spyOn(modal, 'close')
|
||||||
|
modal.componentInstance.alternativeClicked.emit()
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'edited content'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
modal = modalSpy.mock.results[1].value
|
||||||
|
modal.componentInstance.alternativeClicked.emit()
|
||||||
|
expect(patchSpy).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ content: 'edited content' }),
|
||||||
|
12
|
||||||
|
)
|
||||||
|
component.onVersionSelected(doc.id) // ignored while saving
|
||||||
|
expect(modalSpy).toHaveBeenCalledTimes(2)
|
||||||
|
savedDoc.next(doc)
|
||||||
|
expect(component.selectedVersionId()).toEqual(10)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 10 content'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should switch without confirmation when the selected version was deleted, even while saving', () => {
|
||||||
|
initNormally()
|
||||||
|
component.document().versions = docVersions
|
||||||
|
component.selectedVersionId.set(10)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValue(of({ content: 'version 12 content' } as Document))
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
component.documentForm.get('content').setValue('edited content')
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
component.networkActive.set(true)
|
||||||
|
|
||||||
|
// the version dropdown emits this after deleting the selected version
|
||||||
|
component.onVersionsUpdated(docVersions.filter((v) => v.id !== 10))
|
||||||
|
component.onVersionSelected(12)
|
||||||
|
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 12 content'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore the selected version and its unsaved content when returning to a document', () => {
|
||||||
|
initNormally()
|
||||||
|
const openDoc = component.document()
|
||||||
|
openDoc.versions = docVersions
|
||||||
|
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockImplementation((id, versionID) =>
|
||||||
|
of(
|
||||||
|
(versionID
|
||||||
|
? { content: `version ${versionID} content` }
|
||||||
|
: { ...doc, versions: docVersions }) as Document
|
||||||
|
)
|
||||||
|
)
|
||||||
|
component.selectVersion(10)
|
||||||
|
// an edit that happens to match the latest version's content
|
||||||
|
component.documentForm.get('content').setValue(doc.content)
|
||||||
|
openDoc.__changedFields = ['content']
|
||||||
|
|
||||||
|
component['loadDocument'](doc.id)
|
||||||
|
|
||||||
|
expect(component.selectedVersionId()).toEqual(10)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(doc.content)
|
||||||
|
expect(openDocumentsService.isDirty(openDoc)).toBeTruthy()
|
||||||
|
const patchSpy = jest
|
||||||
|
.spyOn(documentService, 'patch')
|
||||||
|
.mockReturnValue(of(doc))
|
||||||
|
component.save()
|
||||||
|
expect(patchSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: doc.content }),
|
||||||
|
10
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to the latest version when the remembered version no longer exists', () => {
|
||||||
|
initNormally()
|
||||||
|
const openDoc = component.document()
|
||||||
|
openDoc.versions = docVersions
|
||||||
|
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||||
|
jest.spyOn(documentService, 'get').mockImplementation((id, versionID) =>
|
||||||
|
of(
|
||||||
|
(versionID
|
||||||
|
? { content: `version ${versionID} content` }
|
||||||
|
: {
|
||||||
|
...doc,
|
||||||
|
content: 'version 12 content',
|
||||||
|
versions: docVersions.filter((v) => v.id !== 10),
|
||||||
|
}) as Document
|
||||||
|
)
|
||||||
|
)
|
||||||
|
component.selectVersion(10)
|
||||||
|
|
||||||
|
component['loadDocument'](doc.id)
|
||||||
|
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 12 content'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
|
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
|
||||||
currentUserCan = false
|
currentUserCan = false
|
||||||
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
|
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
|||||||
import * as UTIF from 'utif'
|
import * as UTIF from 'utif'
|
||||||
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||||
|
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -304,6 +304,7 @@ export class DocumentDetailComponent
|
|||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
docChangeNotifier: Subject<any> = new Subject()
|
docChangeNotifier: Subject<any> = new Subject()
|
||||||
|
versionChangeNotifier: Subject<void> = new Subject()
|
||||||
private incomingUpdateModal: NgbModalRef
|
private incomingUpdateModal: NgbModalRef
|
||||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||||
private lastLocalSaveModified: string | null = null
|
private lastLocalSaveModified: string | null = null
|
||||||
@@ -417,7 +418,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
@@ -533,7 +535,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (res) => this.previewText.set(res.toString()),
|
next: (res) => this.previewText.set(res.toString()),
|
||||||
@@ -595,6 +598,13 @@ export class DocumentDetailComponent
|
|||||||
openDocument.duplicate_documents = doc.duplicate_documents
|
openDocument.duplicate_documents = doc.duplicate_documents
|
||||||
this.openDocumentService.save()
|
this.openDocumentService.save()
|
||||||
}
|
}
|
||||||
|
// use server versions
|
||||||
|
if (openDocument) {
|
||||||
|
openDocument.versions = doc.versions
|
||||||
|
if (!openDocument.__changedFields?.includes('content')) {
|
||||||
|
openDocument.content = doc.content
|
||||||
|
}
|
||||||
|
}
|
||||||
let useDoc = openDocument || doc
|
let useDoc = openDocument || doc
|
||||||
if (openDocument && forceRemote) {
|
if (openDocument && forceRemote) {
|
||||||
Object.assign(openDocument, doc)
|
Object.assign(openDocument, doc)
|
||||||
@@ -642,7 +652,14 @@ export class DocumentDetailComponent
|
|||||||
this.documentForm.patchValue({ title: titleValue })
|
this.documentForm.patchValue({ title: titleValue })
|
||||||
this.documentForm.get('title').markAsDirty()
|
this.documentForm.get('title').markAsDirty()
|
||||||
})
|
})
|
||||||
|
const keepContentEdits =
|
||||||
|
useDoc.__selectedVersionId === this.selectedVersionId() &&
|
||||||
|
!!useDoc.__changedFields?.includes('content')
|
||||||
this.setupDirtyTracking(useDoc, doc)
|
this.setupDirtyTracking(useDoc, doc)
|
||||||
|
// Maybe load the stored version
|
||||||
|
if (useDoc.__selectedVersionId) {
|
||||||
|
this.selectVersion(this.selectedVersionId(), keepContentEdits)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -903,9 +920,11 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
updateComponent(doc: Document) {
|
updateComponent(doc: Document) {
|
||||||
this.document.set(doc)
|
this.document.set(doc)
|
||||||
// Default selected version is the newest version, which the API returns first
|
// Load the selected version, or default to API first (newest)
|
||||||
const versions = doc.versions ?? []
|
const versions = doc.versions ?? []
|
||||||
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
|
const selectedVersion =
|
||||||
|
versions.find((v) => v.id === doc.__selectedVersionId) ?? versions[0]
|
||||||
|
this.selectedVersionId.set(selectedVersion?.id ?? doc.id)
|
||||||
this.previewLoaded.set(false)
|
this.previewLoaded.set(false)
|
||||||
this.requiresPassword = false
|
this.requiresPassword = false
|
||||||
this.updateFormForCustomFields()
|
this.updateFormForCustomFields()
|
||||||
@@ -940,8 +959,12 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update file preview and download target to a specific version (by document id)
|
// Update file preview and download target to a specific version (by document id)
|
||||||
selectVersion(versionId: number) {
|
selectVersion(versionId: number, keepContentEdits: boolean = false) {
|
||||||
|
this.versionChangeNotifier.next()
|
||||||
this.selectedVersionId.set(versionId)
|
this.selectedVersionId.set(versionId)
|
||||||
|
// remember so the version can be restored when returning to the document
|
||||||
|
this.document().__selectedVersionId = versionId
|
||||||
|
this.openDocumentService.save()
|
||||||
this.previewLoaded.set(false)
|
this.previewLoaded.set(false)
|
||||||
this.previewUrl.set(
|
this.previewUrl.set(
|
||||||
this.documentsService.getPreviewUrl(
|
this.documentsService.getPreviewUrl(
|
||||||
@@ -963,20 +986,20 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
const content = doc?.content ?? ''
|
const content = doc?.content ?? ''
|
||||||
this.document().content = content
|
if (keepContentEdits) {
|
||||||
this.documentForm.patchValue(
|
this.store.next({ ...this.store.value, content })
|
||||||
{
|
} else {
|
||||||
content,
|
// Update in-place and avoid the debounce wait
|
||||||
},
|
this.store.value.content = content
|
||||||
{
|
this.documentForm.patchValue({ content })
|
||||||
emitEvent: false,
|
this.documentForm.get('content').markAsPristine()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
@@ -991,7 +1014,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (res) => this.previewText.set(res.toString()),
|
next: (res) => this.previewText.set(res.toString()),
|
||||||
@@ -1005,7 +1029,39 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onVersionSelected(versionId: number) {
|
onVersionSelected(versionId: number) {
|
||||||
this.selectVersion(versionId)
|
if (versionId === this.selectedVersionId()) return
|
||||||
|
// Bail if the selected version was just deleted.
|
||||||
|
const selectedVersionExists = this.document()?.versions?.some(
|
||||||
|
(v) => v.id === this.selectedVersionId()
|
||||||
|
)
|
||||||
|
if (this.networkActive() && selectedVersionExists) return
|
||||||
|
if (
|
||||||
|
!selectedVersionExists ||
|
||||||
|
this.documentForm.get('content').value === this.store.value.content
|
||||||
|
) {
|
||||||
|
this.selectVersion(versionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm any unsaved content changes
|
||||||
|
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Unsaved Changes`
|
||||||
|
modal.componentInstance.messageBold = $localize`You have unsaved changes to the content of this version.`
|
||||||
|
modal.componentInstance.message = $localize`Switching versions will discard them.`
|
||||||
|
modal.componentInstance.btnClass = 'btn-secondary'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Discard and switch`
|
||||||
|
modal.componentInstance.alternativeBtnClass = 'btn-primary'
|
||||||
|
modal.componentInstance.alternativeBtnCaption = $localize`Save and switch`
|
||||||
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
this.selectVersion(versionId)
|
||||||
|
})
|
||||||
|
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
this.save(false, () => this.selectVersion(versionId))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onVersionsUpdated(versions: DocumentVersionInfo[]) {
|
onVersionsUpdated(versions: DocumentVersionInfo[]) {
|
||||||
@@ -1233,7 +1289,7 @@ export class DocumentDetailComponent
|
|||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
save(close: boolean = false) {
|
save(close: boolean = false, savedCallback: () => void = null) {
|
||||||
this.networkActive.set(true)
|
this.networkActive.set(true)
|
||||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||||
this.documentsService
|
this.documentsService
|
||||||
@@ -1266,6 +1322,7 @@ export class DocumentDetailComponent
|
|||||||
this.flushPendingIncomingUpdate()
|
this.flushPendingIncomingUpdate()
|
||||||
}
|
}
|
||||||
this.savedViewService.maybeRefreshDocumentCounts()
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
|
savedCallback?.()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.networkActive.set(false)
|
this.networkActive.set(false)
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export interface Document extends ObjectWithPermissions {
|
|||||||
|
|
||||||
// Frontend only
|
// Frontend only
|
||||||
__changedFields?: string[]
|
__changedFields?: string[]
|
||||||
|
__selectedVersionId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentVersionInfo {
|
export interface DocumentVersionInfo {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export enum PaperlessTaskType {
|
|||||||
ReprocessDocument = 'reprocess_document',
|
ReprocessDocument = 'reprocess_document',
|
||||||
BuildShareLink = 'build_share_link',
|
BuildShareLink = 'build_share_link',
|
||||||
BulkDelete = 'bulk_delete',
|
BulkDelete = 'bulk_delete',
|
||||||
|
ApplyAiSuggestions = 'apply_ai_suggestions',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PaperlessTaskTriggerSource {
|
export enum PaperlessTaskTriggerSource {
|
||||||
|
|||||||
@@ -221,6 +221,25 @@ describe('OpenDocumentsService', () => {
|
|||||||
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
|
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should refresh documents in place and keep unsaved edits', () => {
|
||||||
|
const openDoc = { ...documents[0] }
|
||||||
|
subscriptions.push(openDocumentsService.openDocument(openDoc).subscribe())
|
||||||
|
openDoc.title = 'Unsaved title'
|
||||||
|
openDocumentsService.setDirty(openDoc, true, { title: openDoc.title })
|
||||||
|
|
||||||
|
openDocumentsService.refreshDocument(openDoc.id)
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/${openDoc.id}/?full_perms=true`
|
||||||
|
)
|
||||||
|
.flush({ ...documents[0], tags: [4] })
|
||||||
|
|
||||||
|
const refreshed = openDocumentsService.getOpenDocument(openDoc.id)
|
||||||
|
expect(refreshed).toBe(openDoc)
|
||||||
|
expect(refreshed.title).toEqual('Unsaved title')
|
||||||
|
expect(refreshed.tags).toEqual([4])
|
||||||
|
})
|
||||||
|
|
||||||
it('should handle error on refresh documents', () => {
|
it('should handle error on refresh documents', () => {
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
openDocumentsService.openDocument(documents[1]).subscribe()
|
openDocumentsService.openDocument(documents[1]).subscribe()
|
||||||
|
|||||||
@@ -50,7 +50,15 @@ export class OpenDocumentsService {
|
|||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
this.documentService.get(id).subscribe({
|
this.documentService.get(id).subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
this.openDocuments[index] = doc
|
const openDoc = this.openDocuments.find((d) => d.id == id)
|
||||||
|
if (!openDoc) return
|
||||||
|
const unsavedEdits = Object.fromEntries(
|
||||||
|
(openDoc.__changedFields ?? []).map((field) => [
|
||||||
|
field,
|
||||||
|
openDoc[field],
|
||||||
|
])
|
||||||
|
)
|
||||||
|
Object.assign(openDoc, doc, unsavedEdits)
|
||||||
this.save()
|
this.save()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from documents.models import DocumentType
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
@@ -433,10 +433,13 @@ def set_permissions(
|
|||||||
else:
|
else:
|
||||||
qs.update(owner=owner)
|
qs.update(owner=owner)
|
||||||
|
|
||||||
for doc in qs:
|
|
||||||
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
|
|
||||||
|
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
|
set_permissions_for_objects(
|
||||||
|
permissions=set_permissions,
|
||||||
|
model=Document,
|
||||||
|
pks=affected_docs,
|
||||||
|
merge=merge,
|
||||||
|
)
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
bulk_update_documents.apply_async(
|
||||||
kwargs={"document_ids": affected_docs},
|
kwargs={"document_ids": affected_docs},
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
If the queryset already annotated ``effective_content``, that value is used.
|
If the queryset already annotated ``effective_content``, that value is used.
|
||||||
"""
|
"""
|
||||||
# Here to avoid circular import
|
# Here to avoid circular import
|
||||||
|
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
|
|
||||||
@@ -384,6 +385,19 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
if self.root_document_id is not None or self.pk is None:
|
if self.root_document_id is not None or self.pk is None:
|
||||||
return self.content
|
return self.content
|
||||||
|
|
||||||
|
latest_version_prefetch = getattr(
|
||||||
|
self,
|
||||||
|
LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if latest_version_prefetch is not None:
|
||||||
|
# Empty list means prefetch ran and found no versions — use own content.
|
||||||
|
return (
|
||||||
|
latest_version_prefetch[0].content
|
||||||
|
if latest_version_prefetch
|
||||||
|
else self.content
|
||||||
|
)
|
||||||
|
|
||||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
||||||
prefetched_versions = (
|
prefetched_versions = (
|
||||||
prefetched_cache.get("versions")
|
prefetched_cache.get("versions")
|
||||||
|
|||||||
@@ -173,6 +173,179 @@ def set_permissions_for_object(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
||||||
|
"""
|
||||||
|
Resolves `codenames` to Permission rows, raising like the single-object
|
||||||
|
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
||||||
|
if any codename doesn't exist -- e.g. a client-supplied action name that
|
||||||
|
was never validated (BulkEditObjectsSerializer._validate_permissions
|
||||||
|
calls validate_set_permissions() only for its side-effecting id checks
|
||||||
|
and discards the filtered dict it returns, so an unrecognized action key
|
||||||
|
reaches this function as-is). A plain `.filter()` with no existence
|
||||||
|
check would otherwise silently build zero rows and no-op instead of
|
||||||
|
reporting the bad input.
|
||||||
|
"""
|
||||||
|
permission_objs = list(
|
||||||
|
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
||||||
|
)
|
||||||
|
missing = codenames - {p.codename for p in permission_objs}
|
||||||
|
if missing:
|
||||||
|
raise Permission.DoesNotExist(
|
||||||
|
f"Permission matching query does not exist for codename(s): "
|
||||||
|
f"{', '.join(sorted(missing))}",
|
||||||
|
)
|
||||||
|
return permission_objs
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_bulk_permission_entry(
|
||||||
|
*,
|
||||||
|
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
||||||
|
identity_model: type[User] | type[Group],
|
||||||
|
identity_field: str,
|
||||||
|
ids: list[int],
|
||||||
|
codename: str,
|
||||||
|
permission_objs: list[Permission],
|
||||||
|
ctype: ContentType,
|
||||||
|
object_pks: list[str],
|
||||||
|
merge: bool,
|
||||||
|
) -> None:
|
||||||
|
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
||||||
|
# so avoid fetching full User/Group rows for identities that may not
|
||||||
|
# even end up being granted anything new.
|
||||||
|
add_ids = set(
|
||||||
|
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not merge:
|
||||||
|
existing_ids = set(
|
||||||
|
perm_model.objects.filter(
|
||||||
|
content_type=ctype,
|
||||||
|
object_pk__in=object_pks,
|
||||||
|
permission__codename=codename,
|
||||||
|
)
|
||||||
|
.values_list(f"{identity_field}_id", flat=True)
|
||||||
|
.distinct(),
|
||||||
|
)
|
||||||
|
remove_ids = existing_ids - add_ids
|
||||||
|
if remove_ids:
|
||||||
|
perm_model.objects.filter(
|
||||||
|
content_type=ctype,
|
||||||
|
object_pk__in=object_pks,
|
||||||
|
permission__codename=codename,
|
||||||
|
**{f"{identity_field}_id__in": remove_ids},
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
if not add_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
perm_model(
|
||||||
|
content_type=ctype,
|
||||||
|
object_pk=pk,
|
||||||
|
permission=permission_obj,
|
||||||
|
**{f"{identity_field}_id": identity_id},
|
||||||
|
)
|
||||||
|
for permission_obj in permission_objs
|
||||||
|
for pk in object_pks
|
||||||
|
for identity_id in add_ids
|
||||||
|
]
|
||||||
|
# ignore_conflicts skips only rows that already exist as an exact
|
||||||
|
# (identity, permission, object) match -- the same de-dup the
|
||||||
|
# underlying (user|group, permission, object_pk) unique constraint
|
||||||
|
# already enforces for the single-object assign_perm() this replaces,
|
||||||
|
# so it doesn't change what counts as "already granted". batch_size
|
||||||
|
# caps how many rows go into a single INSERT statement.
|
||||||
|
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
||||||
|
|
||||||
|
|
||||||
|
def set_permissions_for_objects(
|
||||||
|
permissions: dict,
|
||||||
|
model: type[Model],
|
||||||
|
pks: QuerySet | list,
|
||||||
|
*,
|
||||||
|
merge: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Bulk equivalent of set_permissions_for_object: applies the same
|
||||||
|
permission changes to every object identified by `pks` at once.
|
||||||
|
|
||||||
|
Takes a model + pks (rather than model instances) deliberately -- the
|
||||||
|
permission rows built below only ever need `pk`, `content_type`, and
|
||||||
|
identity ids, so callers shouldn't have to fetch full rows (with every
|
||||||
|
other field) just to hand them to this function.
|
||||||
|
|
||||||
|
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
||||||
|
passing a list as the object routes to bulk_assign_perm, which skips
|
||||||
|
creating a direct permission row for anyone who already has the
|
||||||
|
permission via ANY group membership (it checks
|
||||||
|
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
||||||
|
unlike the single-object assign_perm this replaces, which always
|
||||||
|
ensures a direct row via get_or_create regardless of group-derived
|
||||||
|
access. Losing that guarantee would mean a later revocation of the
|
||||||
|
group's grant silently strips access an admin explicitly asked to be
|
||||||
|
direct. Bulk-creating rows straight against the permission models
|
||||||
|
instead (see _apply_bulk_permission_entry) preserves the original
|
||||||
|
always-create-a-direct-row semantics while still batching every object
|
||||||
|
and every identity into one query per action, rather than one query per
|
||||||
|
(object, user) pair.
|
||||||
|
"""
|
||||||
|
object_pks = [str(pk) for pk in pks]
|
||||||
|
if not object_pks: # pragma: no cover
|
||||||
|
return
|
||||||
|
|
||||||
|
model_name = model.__name__.lower()
|
||||||
|
ctype = ContentType.objects.get_for_model(model)
|
||||||
|
|
||||||
|
# Every action is resolved up front, before anything is written, so an
|
||||||
|
# unrecognized action name (see _resolve_permissions) aborts the whole
|
||||||
|
# call instead of leaving the actions ahead of it already applied --
|
||||||
|
# BulkEditObjectsSerializer lets unknown keys through and its view turns
|
||||||
|
# the exception into a 400, so a half-applied change would otherwise be
|
||||||
|
# reported to the client as a failure.
|
||||||
|
permissions_by_action: dict[str, list[Permission]] = {}
|
||||||
|
for action, entry in permissions.items():
|
||||||
|
if "users" not in entry and "groups" not in entry:
|
||||||
|
continue
|
||||||
|
implied_codenames = {f"{action}_{model_name}"}
|
||||||
|
if action == "change":
|
||||||
|
# change gives view too
|
||||||
|
implied_codenames.add(f"view_{model_name}")
|
||||||
|
permissions_by_action[action] = _resolve_permissions(
|
||||||
|
implied_codenames,
|
||||||
|
ctype,
|
||||||
|
)
|
||||||
|
|
||||||
|
for action, entry in permissions.items():
|
||||||
|
codename = f"{action}_{model_name}"
|
||||||
|
permission_objs = permissions_by_action.get(action, [])
|
||||||
|
|
||||||
|
if "users" in entry:
|
||||||
|
_apply_bulk_permission_entry(
|
||||||
|
perm_model=UserObjectPermission,
|
||||||
|
identity_model=User,
|
||||||
|
identity_field="user",
|
||||||
|
ids=entry["users"],
|
||||||
|
codename=codename,
|
||||||
|
permission_objs=permission_objs,
|
||||||
|
ctype=ctype,
|
||||||
|
object_pks=object_pks,
|
||||||
|
merge=merge,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "groups" in entry:
|
||||||
|
_apply_bulk_permission_entry(
|
||||||
|
perm_model=GroupObjectPermission,
|
||||||
|
identity_model=Group,
|
||||||
|
identity_field="group",
|
||||||
|
ids=entry["groups"],
|
||||||
|
codename=codename,
|
||||||
|
permission_objs=permission_objs,
|
||||||
|
ctype=ctype,
|
||||||
|
object_pks=object_pks,
|
||||||
|
merge=merge,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def permitted_object_ids(
|
def permitted_object_ids(
|
||||||
user: User | None,
|
user: User | None,
|
||||||
model: type[Model],
|
model: type[Model],
|
||||||
@@ -484,16 +657,41 @@ class ViewDocumentsPermissions(BasePermission):
|
|||||||
return request.user.has_perms(self.perms_map.get(request.method, []))
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
|
class TrashPermissions(BasePermission):
|
||||||
|
"""Check the global document permission for each trash operation."""
|
||||||
|
|
||||||
|
perms_map = {
|
||||||
|
"OPTIONS": ["documents.view_document"],
|
||||||
|
"HEAD": ["documents.view_document"],
|
||||||
|
"GET": ["documents.view_document"],
|
||||||
|
"POST": ["documents.delete_document"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
class PaperlessNotePermissions(BasePermission):
|
class PaperlessNotePermissions(BasePermission):
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for Notes.
|
Permissions class that checks for model permissions for Notes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
"OPTIONS": ["documents.view_note"],
|
"OPTIONS": ["documents.view_note", "documents.view_document"],
|
||||||
"GET": ["documents.view_note"],
|
"GET": ["documents.view_note", "documents.view_document"],
|
||||||
"POST": ["documents.add_note"],
|
"POST": [
|
||||||
"DELETE": ["documents.delete_note"],
|
"documents.add_note",
|
||||||
|
"documents.view_document",
|
||||||
|
"documents.change_document",
|
||||||
|
],
|
||||||
|
"DELETE": [
|
||||||
|
"documents.delete_note",
|
||||||
|
"documents.view_document",
|
||||||
|
"documents.change_document",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ from documents.templating.utils import convert_format_str_to_template_format
|
|||||||
from documents.templating.workflows import validate_workflow_template
|
from documents.templating.workflows import validate_workflow_template
|
||||||
from documents.validators import uri_validator
|
from documents.validators import uri_validator
|
||||||
from documents.validators import url_validator
|
from documents.validators import url_validator
|
||||||
|
from documents.versioning import has_prefetched_effective_content
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -1152,8 +1153,14 @@ class DocumentSerializer(
|
|||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
doc = super().to_representation(instance)
|
doc = super().to_representation(instance)
|
||||||
if "content" in self.fields and hasattr(instance, "effective_content"):
|
if "content" in self.fields and has_prefetched_effective_content(instance):
|
||||||
doc["content"] = getattr(instance, "effective_content") or ""
|
# Only resolve version-aware content when it's cheap: an SQL
|
||||||
|
# annotation or a versions prefetch is already on the instance.
|
||||||
|
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
|
||||||
|
# which build their own querysets) gets the document's own,
|
||||||
|
# unresolved content instead of paying for an extra per-instance
|
||||||
|
# query -- same as before effective_content resolution existed.
|
||||||
|
doc["content"] = instance.get_effective_content() or ""
|
||||||
if self.truncate_content and "content" in self.fields:
|
if self.truncate_content and "content" in self.fields:
|
||||||
doc["content"] = doc.get("content")[0:550]
|
doc["content"] = doc.get("content")[0:550]
|
||||||
return doc
|
return doc
|
||||||
@@ -1328,6 +1335,7 @@ class DocumentSerializer(
|
|||||||
"root_document",
|
"root_document",
|
||||||
"versions",
|
"versions",
|
||||||
)
|
)
|
||||||
|
read_only_fields = ("deleted_at",)
|
||||||
list_serializer_class = OwnedObjectListSerializer
|
list_serializer_class = OwnedObjectListSerializer
|
||||||
|
|
||||||
|
|
||||||
@@ -2029,10 +2037,10 @@ class BulkEditSerializer(
|
|||||||
raise serializers.ValidationError("remove_custom_fields not specified")
|
raise serializers.ValidationError("remove_custom_fields not specified")
|
||||||
|
|
||||||
def _validate_owner(self, owner):
|
def _validate_owner(self, owner):
|
||||||
ownerUser = User.objects.get(pk=owner)
|
try:
|
||||||
if ownerUser is None:
|
return User.objects.get(pk=owner)
|
||||||
|
except (User.DoesNotExist, TypeError, ValueError):
|
||||||
raise serializers.ValidationError("Specified owner cannot be found")
|
raise serializers.ValidationError("Specified owner cannot be found")
|
||||||
return ownerUser
|
|
||||||
|
|
||||||
def _validate_parameters_set_permissions(self, parameters) -> None:
|
def _validate_parameters_set_permissions(self, parameters) -> None:
|
||||||
if "set_permissions" not in parameters:
|
if "set_permissions" not in parameters:
|
||||||
@@ -2052,7 +2060,7 @@ class BulkEditSerializer(
|
|||||||
or not float(parameters["degrees"]).is_integer()
|
or not float(parameters["degrees"]).is_integer()
|
||||||
):
|
):
|
||||||
raise serializers.ValidationError("invalid rotation degrees")
|
raise serializers.ValidationError("invalid rotation degrees")
|
||||||
except ValueError:
|
except (TypeError, ValueError):
|
||||||
raise serializers.ValidationError("invalid rotation degrees")
|
raise serializers.ValidationError("invalid rotation degrees")
|
||||||
|
|
||||||
def _validate_source_mode(self, parameters) -> None:
|
def _validate_source_mode(self, parameters) -> None:
|
||||||
@@ -2065,6 +2073,8 @@ class BulkEditSerializer(
|
|||||||
def _validate_parameters_split(self, parameters) -> None:
|
def _validate_parameters_split(self, parameters) -> None:
|
||||||
if "pages" not in parameters:
|
if "pages" not in parameters:
|
||||||
raise serializers.ValidationError("pages not specified")
|
raise serializers.ValidationError("pages not specified")
|
||||||
|
if not isinstance(parameters["pages"], str):
|
||||||
|
raise serializers.ValidationError("invalid pages specified")
|
||||||
try:
|
try:
|
||||||
pages = []
|
pages = []
|
||||||
docs = parameters["pages"].split(",")
|
docs = parameters["pages"].split(",")
|
||||||
@@ -2832,10 +2842,14 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
|||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
def validate_document(self, document):
|
def validate_document(self, document):
|
||||||
if self.user is not None and has_perms_owner_aware(
|
if (
|
||||||
self.user,
|
self.user is not None
|
||||||
"view_document",
|
and self.user.has_perm("documents.view_document")
|
||||||
document,
|
and has_perms_owner_aware(
|
||||||
|
self.user,
|
||||||
|
"view_document",
|
||||||
|
document,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
return document
|
return document
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
@@ -3596,6 +3610,8 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
|
|
||||||
if "actions" in validated_data:
|
if "actions" in validated_data:
|
||||||
actions = validated_data.pop("actions")
|
actions = validated_data.pop("actions")
|
||||||
|
for action in actions:
|
||||||
|
action.pop("id", None)
|
||||||
|
|
||||||
instance = super().create(validated_data)
|
instance = super().create(validated_data)
|
||||||
|
|
||||||
|
|||||||
@@ -1189,13 +1189,18 @@ def before_task_publish_handler(
|
|||||||
trigger_source = _determine_trigger_source(headers)
|
trigger_source = _determine_trigger_source(headers)
|
||||||
owner_id = _extract_owner_id(task_type, task_kwargs)
|
owner_id = _extract_owner_id(task_type, task_kwargs)
|
||||||
|
|
||||||
PaperlessTask.objects.create(
|
# A retried task is republished with the same task_id, so this fires
|
||||||
|
# again for it; get_or_create keeps the original PENDING record
|
||||||
|
# instead of raising a duplicate-key IntegrityError on the retry.
|
||||||
|
PaperlessTask.objects.get_or_create(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
task_type=task_type,
|
defaults={
|
||||||
trigger_source=trigger_source,
|
"task_type": task_type,
|
||||||
status=PaperlessTask.Status.PENDING,
|
"trigger_source": trigger_source,
|
||||||
input_data=input_data,
|
"status": PaperlessTask.Status.PENDING,
|
||||||
owner_id=owner_id,
|
"input_data": input_data,
|
||||||
|
"owner_id": owner_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
logger.exception("Creating PaperlessTask failed")
|
logger.exception("Creating PaperlessTask failed")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -326,6 +327,9 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
|
|
||||||
def test_download_insufficient_permissions(self) -> None:
|
def test_download_insufficient_permissions(self) -> None:
|
||||||
user = User.objects.create_user(username="temp_user")
|
user = User.objects.create_user(username="temp_user")
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
self.doc2.owner = self.user
|
self.doc2.owner = self.user
|
||||||
|
|||||||
@@ -1084,6 +1084,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
|
assign_perm("view_document", user1, self.doc2)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/selection_data/",
|
"/api/documents/selection_data/",
|
||||||
json.dumps({"documents": [self.doc2.id]}),
|
json.dumps({"documents": [self.doc2.id]}),
|
||||||
@@ -1091,7 +1093,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
|
||||||
|
user1.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
user1 = User.objects.get(pk=user1.pk)
|
||||||
|
self.client.force_authenticate(user=user1)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/selection_data/",
|
||||||
|
json.dumps({"documents": [self.doc2.id]}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions(self, m) -> None:
|
def test_set_permissions(self, m) -> None:
|
||||||
@@ -1152,6 +1165,65 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertIn(b"set_permissions not specified", response.content)
|
self.assertIn(b"set_permissions not specified", response.content)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
|
def test_set_permissions_rejects_nonexistent_owner(self, m) -> None:
|
||||||
|
"""
|
||||||
|
BulkEditSerializer._validate_owner called User.objects.get(pk=owner)
|
||||||
|
with no try/except, so a syntactically valid but nonexistent user
|
||||||
|
id raised an uncaught User.DoesNotExist instead of a clean 400.
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "set_permissions")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "set_permissions",
|
||||||
|
"parameters": {
|
||||||
|
"set_permissions": {"view": {"users": [self.user.id]}},
|
||||||
|
"owner": 999999,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
|
def test_set_permissions_rejects_wrong_typed_owner(self, m) -> None:
|
||||||
|
"""
|
||||||
|
_validate_owner only caught User.DoesNotExist -- a wrong-typed
|
||||||
|
owner (list/dict/non-numeric string) reaches
|
||||||
|
User.objects.get(pk=owner) and raises an uncaught TypeError or
|
||||||
|
ValueError instead, since `parameters` is a bare DictField with
|
||||||
|
no type checking on "owner" at that level.
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "set_permissions")
|
||||||
|
|
||||||
|
for bad_owner in (["not", "an", "id"], {"nested": "dict"}, "not-a-number"):
|
||||||
|
with self.subTest(owner=bad_owner):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "set_permissions",
|
||||||
|
"parameters": {
|
||||||
|
"set_permissions": {
|
||||||
|
"view": {"users": [self.user.id]},
|
||||||
|
},
|
||||||
|
"owner": bad_owner,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_merge(self, m) -> None:
|
def test_set_permissions_merge(self, m) -> None:
|
||||||
self.setup_mock(m, "set_permissions")
|
self.setup_mock(m, "set_permissions")
|
||||||
@@ -1425,6 +1497,53 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
m.assert_not_called()
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.rotate")
|
||||||
|
def test_bulk_edit_rotate_rejects_null_degrees(self, m) -> None:
|
||||||
|
"""
|
||||||
|
BulkEditSerializer._validate_parameters_rotate's
|
||||||
|
`float(parameters["degrees"])` raised an uncaught TypeError for
|
||||||
|
None (only ValueError was caught), reachable via the legacy
|
||||||
|
generic /api/documents/bulk_edit/ method="rotate" path (the
|
||||||
|
dedicated /api/documents/rotate/ endpoint isn't affected, its
|
||||||
|
`degrees` field is a typed IntegerField).
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "rotate")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "rotate",
|
||||||
|
"parameters": {"degrees": None},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("documents.serialisers.bulk_edit.split")
|
||||||
|
def test_bulk_edit_split_rejects_null_pages(self, m) -> None:
|
||||||
|
"""
|
||||||
|
BulkEditSerializer._validate_parameters_split called
|
||||||
|
parameters["pages"].split(",") with no type check, so a null
|
||||||
|
value raised an uncaught AttributeError instead of a clean 400.
|
||||||
|
"""
|
||||||
|
self.setup_mock(m, "split")
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "split",
|
||||||
|
"parameters": {"pages": None},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
m.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.rotate")
|
@mock.patch("documents.views.bulk_edit.rotate")
|
||||||
def test_rotate_insufficient_permissions(self, m) -> None:
|
def test_rotate_insufficient_permissions(self, m) -> None:
|
||||||
self.doc1.owner = User.objects.get(username="temp_admin")
|
self.doc1.owner = User.objects.get(username="temp_admin")
|
||||||
|
|||||||
@@ -38,6 +38,42 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
def test_answer_is_not_compressed(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A client that accepts compressed responses
|
||||||
|
WHEN:
|
||||||
|
- It asks the chat endpoint a question
|
||||||
|
THEN:
|
||||||
|
- The answer is streamed unencoded, chunk for chunk
|
||||||
|
|
||||||
|
The stream compressors buffer, so a compressed answer arrives in one
|
||||||
|
piece. The view cannot opt out by flagging the request: DRF's request
|
||||||
|
wrapper proxies reads but keeps writes to itself, so the flag never
|
||||||
|
reaches the Django request the middleware sees.
|
||||||
|
"""
|
||||||
|
chunks = [f"token{i} " for i in range(40)]
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.views.AIConfig",
|
||||||
|
return_value=self._mock_ai_enabled(),
|
||||||
|
),
|
||||||
|
mock.patch(
|
||||||
|
"documents.views.stream_chat_with_documents",
|
||||||
|
return_value=iter(chunks),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/documents/chat/",
|
||||||
|
{"q": "What is in my archive?"},
|
||||||
|
format="json",
|
||||||
|
HTTP_ACCEPT_ENCODING="gzip, deflate, br, zstd",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == status.HTTP_200_OK
|
||||||
|
assert not resp.has_header("Content-Encoding")
|
||||||
|
assert list(resp.streaming_content) == [c.encode() for c in chunks]
|
||||||
|
|
||||||
def test_missing_question_is_rejected(self) -> None:
|
def test_missing_question_is_rejected(self) -> None:
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"documents.views.AIConfig",
|
"documents.views.AIConfig",
|
||||||
|
|||||||
@@ -3615,6 +3615,55 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertEqual(response.content, b"Insufficient permissions to delete notes")
|
self.assertEqual(response.content, b"Insufficient permissions to delete notes")
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
def test_notes_require_global_document_permissions(self) -> None:
|
||||||
|
user = User.objects.create_user(username="note_editor")
|
||||||
|
user.user_permissions.add(
|
||||||
|
*Permission.objects.filter(
|
||||||
|
codename__in=["view_note", "add_note", "delete_note"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="notes",
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
note = Note.objects.create(note="Existing", document=doc, user=user)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
user = User.objects.get(pk=user.pk)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/documents/{doc.pk}/notes/",
|
||||||
|
data={"note": "New"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_document"),
|
||||||
|
)
|
||||||
|
user = User.objects.get(pk=user.pk)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/documents/{doc.pk}/notes/",
|
||||||
|
data={"note": "New"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
response = self.client.delete(
|
||||||
|
f"/api/documents/{doc.pk}/notes/?id={note.pk}",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_delete_note(self) -> None:
|
def test_delete_note(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -3981,6 +4030,21 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
|
|
||||||
assign_perm("view_document", user1, doc)
|
assign_perm("view_document", user1, doc)
|
||||||
|
|
||||||
|
create_resp = self.client.post(
|
||||||
|
"/api/share_links/",
|
||||||
|
data={
|
||||||
|
"document": doc.pk,
|
||||||
|
"file_version": "original",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user1.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
user1 = User.objects.get(pk=user1.pk)
|
||||||
|
self.client.force_authenticate(user1)
|
||||||
create_resp = self.client.post(
|
create_resp = self.client.post(
|
||||||
"/api/share_links/",
|
"/api/share_links/",
|
||||||
data={
|
data={
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import connection
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -452,6 +457,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
unprivileged = User.objects.create_user(username="unprivileged")
|
unprivileged = User.objects.create_user(username="unprivileged")
|
||||||
|
unprivileged.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -483,6 +491,23 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=viewer)
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}test/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"document": document.id,
|
||||||
|
"path": "path/{{ title }}",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
viewer = User.objects.get(pk=viewer.pk)
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f"{self.ENDPOINT}test/",
|
f"{self.ENDPOINT}test/",
|
||||||
@@ -525,6 +550,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
password="password",
|
password="password",
|
||||||
email="owner@example.com",
|
email="owner@example.com",
|
||||||
)
|
)
|
||||||
|
owner.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -600,6 +628,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
checksum="123",
|
checksum="123",
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -687,6 +718,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
document.tags.add(private_tag)
|
document.tags.add(private_tag)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -740,6 +774,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
value_int=42,
|
value_int=42,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -842,6 +879,66 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(StoragePath.objects.count(), 0)
|
self.assertEqual(StoragePath.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_bulk_objects_set_permissions_batched_across_object_count(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Many tags are being bulk-edited to set permissions at once
|
||||||
|
WHEN:
|
||||||
|
- bulk_edit_objects API endpoint is called with set_permissions
|
||||||
|
operation over a small batch vs. a much larger one
|
||||||
|
THEN:
|
||||||
|
- Permissions are applied correctly at both scales
|
||||||
|
- Query count does not grow with the number of tags, i.e. each
|
||||||
|
user/group is applied across all tags with one batched call
|
||||||
|
rather than one call per (tag, identity) pair
|
||||||
|
"""
|
||||||
|
group1 = Group.objects.create(name="perm-group")
|
||||||
|
permissions = {
|
||||||
|
"view": {"users": [self.user1.id, self.user2.id], "groups": [group1.id]},
|
||||||
|
"change": {"users": [self.user1.id], "groups": [group1.id]},
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_with_n_tags(n: int) -> int:
|
||||||
|
tags = [Tag.objects.create(name=f"perm-tag-{n}-{i}") for i in range(n)]
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/bulk_edit_objects/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"objects": [t.id for t in tags],
|
||||||
|
"object_type": "tags",
|
||||||
|
"operation": "set_permissions",
|
||||||
|
"permissions": permissions,
|
||||||
|
"merge": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
for tag in tags:
|
||||||
|
self.assertEqual(get_users_with_perms(tag).count(), 2)
|
||||||
|
self.assertEqual(get_groups_with_perms(tag).count(), 1)
|
||||||
|
return len(ctx.captured_queries)
|
||||||
|
|
||||||
|
small_batch_queries = run_with_n_tags(5)
|
||||||
|
large_batch_queries = run_with_n_tags(50)
|
||||||
|
|
||||||
|
# A tolerance rather than equality, matching the N+1 check in
|
||||||
|
# test_views.py: bulk_create's batch_size caps rows per INSERT, so a
|
||||||
|
# large enough selection does legitimately add statements, and the
|
||||||
|
# per-process ContentType cache makes the first run carry an extra
|
||||||
|
# query. Neither can hide a regression to per-object assignment,
|
||||||
|
# which would be ~10x the small-batch count here.
|
||||||
|
self.assertLessEqual(
|
||||||
|
large_batch_queries,
|
||||||
|
small_batch_queries + 5,
|
||||||
|
"Permission assignment appears to scale with object count: "
|
||||||
|
f"{small_batch_queries} queries for 5 tags vs. "
|
||||||
|
f"{large_batch_queries} for 50",
|
||||||
|
)
|
||||||
|
|
||||||
def test_bulk_objects_delete_all_filtered(self) -> None:
|
def test_bulk_objects_delete_all_filtered(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(Document.global_objects.count(), 0)
|
self.assertEqual(Document.global_objects.count(), 0)
|
||||||
|
|
||||||
|
def test_trash_list_requires_global_document_view_permission(self) -> None:
|
||||||
|
user = User.objects.create_user(username="trash_owner")
|
||||||
|
document = Document.objects.create(title="Owned", owner=user)
|
||||||
|
document.delete()
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/trash/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
def test_trash_api_empty_all(self) -> None:
|
def test_trash_api_empty_all(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -194,6 +194,48 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Workflow.objects.count(), 2)
|
self.assertEqual(Workflow.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_api_create_workflow_ignores_nested_action_id(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An existing workflow action
|
||||||
|
WHEN:
|
||||||
|
- API request to create a workflow includes that action's ID
|
||||||
|
THEN:
|
||||||
|
- A new action is created without changing the existing action
|
||||||
|
"""
|
||||||
|
original_title = self.action.assign_title
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Workflow 2",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"sources": [DocumentSource.ApiUpload],
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"filter_filename": "*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": self.action.id,
|
||||||
|
"assign_title": "New Action Title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.action.refresh_from_db()
|
||||||
|
self.assertEqual(self.action.assign_title, original_title)
|
||||||
|
new_action = Workflow.objects.get(name="Workflow 2").actions.get()
|
||||||
|
self.assertNotEqual(new_action.id, self.action.id)
|
||||||
|
self.assertEqual(new_action.assign_title, "New Action Title")
|
||||||
|
|
||||||
def test_api_create_workflow_nested(self) -> None:
|
def test_api_create_workflow_nested(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ from unittest import mock
|
|||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import connection
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
@@ -19,6 +22,7 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -515,6 +519,178 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(groups_with_perms.count(), 2)
|
self.assertEqual(groups_with_perms.count(), 2)
|
||||||
|
|
||||||
|
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
||||||
|
def test_set_permissions_batched_across_document_count(
|
||||||
|
self,
|
||||||
|
m,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Many documents are being bulk-edited to set permissions at once
|
||||||
|
WHEN:
|
||||||
|
- set_permissions runs over a small batch vs. a much larger one
|
||||||
|
THEN:
|
||||||
|
- Permissions are applied correctly at both scales
|
||||||
|
- Query count does not grow with the number of documents, i.e.
|
||||||
|
each user/group is applied across all documents with one
|
||||||
|
batched call rather than one call per (document, identity)
|
||||||
|
pair
|
||||||
|
"""
|
||||||
|
permissions = {
|
||||||
|
"view": {
|
||||||
|
"users": [self.user1.id, self.user2.id],
|
||||||
|
"groups": [self.group2.id],
|
||||||
|
},
|
||||||
|
"change": {
|
||||||
|
"users": [self.user1.id],
|
||||||
|
"groups": [self.group2.id],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_with_n_documents(n: int) -> int:
|
||||||
|
docs = [
|
||||||
|
Document.objects.create(checksum=f"perm-{n}-{i}", title=f"perm-{n}-{i}")
|
||||||
|
for i in range(n)
|
||||||
|
]
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
bulk_edit.set_permissions(
|
||||||
|
[doc.id for doc in docs],
|
||||||
|
set_permissions=permissions,
|
||||||
|
owner=self.owner,
|
||||||
|
merge=False,
|
||||||
|
)
|
||||||
|
for doc in docs:
|
||||||
|
self.assertEqual(get_users_with_perms(doc).count(), 2)
|
||||||
|
self.assertEqual(get_groups_with_perms(doc).count(), 1)
|
||||||
|
return len(ctx.captured_queries)
|
||||||
|
|
||||||
|
small_batch_queries = run_with_n_documents(5)
|
||||||
|
large_batch_queries = run_with_n_documents(50)
|
||||||
|
|
||||||
|
# A tolerance rather than equality, matching the N+1 check in
|
||||||
|
# test_views.py: bulk_create's batch_size caps rows per INSERT, so a
|
||||||
|
# large enough selection does legitimately add statements, and the
|
||||||
|
# per-process ContentType cache makes the first run carry an extra
|
||||||
|
# query. Neither can hide a regression to per-document assignment,
|
||||||
|
# which would be ~10x the small-batch count here.
|
||||||
|
self.assertLessEqual(
|
||||||
|
large_batch_queries,
|
||||||
|
small_batch_queries + 5,
|
||||||
|
"Permission assignment appears to scale with document count: "
|
||||||
|
f"{small_batch_queries} queries for 5 documents vs. "
|
||||||
|
f"{large_batch_queries} for 50",
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
||||||
|
def test_set_permissions_grants_direct_perm_even_if_already_granted_via_group(
|
||||||
|
self,
|
||||||
|
m,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A user already has view access to a document via group
|
||||||
|
membership, with no direct grant of their own
|
||||||
|
WHEN:
|
||||||
|
- set_permissions explicitly grants that same user direct view
|
||||||
|
access via bulk_edit
|
||||||
|
THEN:
|
||||||
|
- A direct permission grant is created for the user, not skipped
|
||||||
|
because they already have equivalent access via the group
|
||||||
|
|
||||||
|
Regression test: guardian's queryset-aware assign_perm() (routed to
|
||||||
|
when the target is a list/queryset) skips creating a direct row for
|
||||||
|
anyone whose ObjectPermissionChecker.has_perm() already returns True
|
||||||
|
-- which includes group-derived access. The single-object assign_perm
|
||||||
|
this bulk path replaces has no such check; it always ensures a
|
||||||
|
direct row via get_or_create. Losing that guarantee would mean
|
||||||
|
revoking the group's grant later silently strips access that was
|
||||||
|
supposed to be explicit.
|
||||||
|
"""
|
||||||
|
self.doc1.owner = self.user1
|
||||||
|
self.doc1.save()
|
||||||
|
self.user1.groups.add(self.group1)
|
||||||
|
assign_perm("view_document", self.group1, self.doc1)
|
||||||
|
|
||||||
|
bulk_edit.set_permissions(
|
||||||
|
[self.doc1.id],
|
||||||
|
set_permissions={
|
||||||
|
"view": {"users": [self.user1.id], "groups": []},
|
||||||
|
},
|
||||||
|
merge=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
direct_users = get_users_with_perms(
|
||||||
|
self.doc1,
|
||||||
|
only_with_perms_in=["view_document"],
|
||||||
|
with_group_users=False,
|
||||||
|
)
|
||||||
|
self.assertIn(self.user1, direct_users)
|
||||||
|
|
||||||
|
def test_set_permissions_for_objects_raises_for_unknown_action(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An unrecognized permission action name with users to grant it
|
||||||
|
to
|
||||||
|
WHEN:
|
||||||
|
- set_permissions_for_objects is called
|
||||||
|
THEN:
|
||||||
|
- Permission.DoesNotExist is raised, not a silent no-op
|
||||||
|
|
||||||
|
Regression test: the endpoint that calls this
|
||||||
|
(BulkEditObjectPermissionsView) never actually validates action
|
||||||
|
names against the raw client-supplied permissions dict --
|
||||||
|
BulkEditObjectsSerializer._validate_permissions calls
|
||||||
|
validate_set_permissions() only for its side-effecting user/group id
|
||||||
|
checks and discards the filtered dict it returns -- so a bogus
|
||||||
|
action key reaches this function as-is. Resolving the Permission via
|
||||||
|
a bare `.filter()` (which returns empty instead of raising) would
|
||||||
|
silently drop the grant and report success.
|
||||||
|
"""
|
||||||
|
with self.assertRaises(Permission.DoesNotExist):
|
||||||
|
set_permissions_for_objects(
|
||||||
|
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
|
||||||
|
Document,
|
||||||
|
[self.doc1.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_set_permissions_for_objects_unknown_action_applies_nothing(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A permissions dict with a valid action ordered ahead of an
|
||||||
|
unrecognized one
|
||||||
|
WHEN:
|
||||||
|
- set_permissions_for_objects is called
|
||||||
|
THEN:
|
||||||
|
- Permission.DoesNotExist is raised
|
||||||
|
- The valid action ahead of it is not applied either
|
||||||
|
|
||||||
|
Every action is resolved before any row is written, so a bad action
|
||||||
|
name cannot leave a half-applied change behind. That matters because
|
||||||
|
BulkEditObjectsView turns this exception into a 400: without the
|
||||||
|
up-front resolution the client would be told the request failed
|
||||||
|
while the leading action had already been committed.
|
||||||
|
"""
|
||||||
|
with self.assertRaises(Permission.DoesNotExist):
|
||||||
|
set_permissions_for_objects(
|
||||||
|
{
|
||||||
|
"view": {"users": [self.user1.id], "groups": []},
|
||||||
|
"not_a_real_action": {"users": [self.user1.id], "groups": []},
|
||||||
|
},
|
||||||
|
Document,
|
||||||
|
[self.doc1.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn(
|
||||||
|
self.user1,
|
||||||
|
get_users_with_perms(
|
||||||
|
self.doc1,
|
||||||
|
only_with_perms_in=["view_document"],
|
||||||
|
with_group_users=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@mock.patch("documents.models.Document.delete")
|
@mock.patch("documents.models.Document.delete")
|
||||||
def test_delete_documents_old_uuid_field(self, m) -> None:
|
def test_delete_documents_old_uuid_field(self, m) -> None:
|
||||||
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.db import connection
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
||||||
|
from documents.versioning import has_prefetched_effective_content
|
||||||
|
from documents.versioning import latest_version_content_prefetch
|
||||||
|
from documents.views import DocumentViewSet
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeedsEffectiveContentAnnotation:
|
||||||
|
"""
|
||||||
|
DocumentViewSet._needs_effective_content_annotation() decides whether
|
||||||
|
the effective_content correlated subquery is worth attaching to the
|
||||||
|
queryset at all -- see TestDocumentListEffectiveContentAnnotation below
|
||||||
|
for why. This only checks that decision's own logic (a plain query-param
|
||||||
|
membership test), not that Django/DRF's filtering machinery works.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("params", "expected"),
|
||||||
|
[
|
||||||
|
({}, False),
|
||||||
|
({"ordering": "-added"}, False),
|
||||||
|
({"tags__id__in": "1,2"}, False),
|
||||||
|
({"search": ""}, False),
|
||||||
|
({"search": " "}, False),
|
||||||
|
({"content__icontains": ""}, False),
|
||||||
|
({"search": "foo"}, True),
|
||||||
|
({"title_content": "foo"}, True),
|
||||||
|
({"content__istartswith": "foo"}, True),
|
||||||
|
({"content__iendswith": "foo"}, True),
|
||||||
|
({"content__icontains": "foo"}, True),
|
||||||
|
({"content__iexact": "foo"}, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_detects_content_filter_params(
|
||||||
|
self,
|
||||||
|
params: dict[str, str],
|
||||||
|
expected: bool, # noqa: FBT001
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A view bound to a request carrying the given query params
|
||||||
|
WHEN:
|
||||||
|
- Checking whether the effective_content annotation is needed
|
||||||
|
THEN:
|
||||||
|
- It is needed only for requests that actually filter on it
|
||||||
|
"""
|
||||||
|
view = DocumentViewSet()
|
||||||
|
view.request = SimpleNamespace(query_params=params)
|
||||||
|
|
||||||
|
assert view._needs_effective_content_annotation() is expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeedsEffectiveContentPrefetch:
|
||||||
|
"""
|
||||||
|
DocumentViewSet._needs_effective_content_prefetch() decides whether the
|
||||||
|
single-version content prefetch is worth attaching. It has to read the
|
||||||
|
`fields` param exactly the way get_serializer() does, or a request whose
|
||||||
|
response includes content ends up without the prefetch and pays
|
||||||
|
get_effective_content()'s per-instance fallback instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("params", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param({}, True, id="no-fields-param-keeps-every-field"),
|
||||||
|
pytest.param({"fields": ""}, True, id="blank-fields-keeps-every-field"),
|
||||||
|
pytest.param(
|
||||||
|
{"fields": "id,content"},
|
||||||
|
True,
|
||||||
|
id="content-among-requested-fields",
|
||||||
|
),
|
||||||
|
pytest.param({"fields": "content"}, True, id="content-only"),
|
||||||
|
pytest.param({"fields": "id"}, False, id="content-not-requested"),
|
||||||
|
pytest.param(
|
||||||
|
{"fields": "id,title"},
|
||||||
|
False,
|
||||||
|
id="several-fields-without-content",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_detects_whether_content_can_reach_the_response(
|
||||||
|
self,
|
||||||
|
params: dict[str, str],
|
||||||
|
expected: bool, # noqa: FBT001
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A view bound to a request carrying the given query params
|
||||||
|
WHEN:
|
||||||
|
- Checking whether the content prefetch is needed
|
||||||
|
THEN:
|
||||||
|
- It is needed exactly when get_serializer() would emit content,
|
||||||
|
which treats a blank `fields` the same as an absent one
|
||||||
|
"""
|
||||||
|
view = DocumentViewSet()
|
||||||
|
view.request = SimpleNamespace(query_params=params)
|
||||||
|
|
||||||
|
assert view._needs_effective_content_prefetch() is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestDocumentListEffectiveContentAnnotation:
|
||||||
|
"""
|
||||||
|
DocumentViewSet.get_queryset() only attaches the effective_content
|
||||||
|
correlated subquery when a request actually filters on it. Attaching it
|
||||||
|
unconditionally re-executes it once per candidate row before the page's
|
||||||
|
LIMIT is applied -- fine on SQLite/Postgres, but pathological on
|
||||||
|
MariaDB's default cardinality estimation for the root_document_id
|
||||||
|
self-join once candidate counts get large (see the root_document_id /
|
||||||
|
effective_content perf investigation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_list_without_content_filter_skips_annotation_but_returns_latest_content(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document whose latest version has different content
|
||||||
|
WHEN:
|
||||||
|
- Listing documents with no search/content-filter param
|
||||||
|
THEN:
|
||||||
|
- The response still reflects the latest version's content
|
||||||
|
- The database never evaluates effective_content per row
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="old-root-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="new-version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = admin_client.get("/api/documents/?fields=id,content")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["results"] == [
|
||||||
|
{"id": root.id, "content": "new-version-content"},
|
||||||
|
]
|
||||||
|
assert not any(
|
||||||
|
"effective_content" in query["sql"] for query in ctx.captured_queries
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"fields_param",
|
||||||
|
[
|
||||||
|
pytest.param("", id="blank-fields"),
|
||||||
|
pytest.param("id,content", id="content-requested"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_content_resolves_without_a_query_per_document(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
fields_param: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- One versioned root document, then two more
|
||||||
|
WHEN:
|
||||||
|
- Listing documents with a `fields` param that keeps content
|
||||||
|
THEN:
|
||||||
|
- Every root's content resolves to its latest version's
|
||||||
|
- The query count does not grow with the number of documents,
|
||||||
|
i.e. a blank `fields` does not skip the prefetch and fall back
|
||||||
|
to loading each root's deferred version content
|
||||||
|
"""
|
||||||
|
first = DocumentFactory(content="first-root-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=first,
|
||||||
|
version_index=1,
|
||||||
|
content="first-version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as one_document:
|
||||||
|
response = admin_client.get(f"/api/documents/?fields={fields_param}")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert [r["content"] for r in response.data["results"]] == [
|
||||||
|
"first-version-content",
|
||||||
|
]
|
||||||
|
|
||||||
|
for index in range(2):
|
||||||
|
root = DocumentFactory(content=f"root-content-{index}")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content=f"version-content-{index}",
|
||||||
|
)
|
||||||
|
with CaptureQueriesContext(connection) as three_documents:
|
||||||
|
response = admin_client.get(f"/api/documents/?fields={fields_param}")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert sorted(r["content"] for r in response.data["results"]) == [
|
||||||
|
"first-version-content",
|
||||||
|
"version-content-0",
|
||||||
|
"version-content-1",
|
||||||
|
]
|
||||||
|
assert len(_get_document_queries(three_documents)) == len(
|
||||||
|
_get_document_queries(one_document),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_without_content_field_skips_prefetch_and_omits_content(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A versioned root document
|
||||||
|
WHEN:
|
||||||
|
- Listing documents without asking for content
|
||||||
|
THEN:
|
||||||
|
- Content is neither serialized nor resolved
|
||||||
|
- Nothing pays for the prefetch or the per-instance fallback
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="root-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = admin_client.get("/api/documents/?fields=id")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["results"] == [{"id": root.id}]
|
||||||
|
assert _get_effective_content_fallback_queries(ctx) == []
|
||||||
|
# Only the list query itself reads a content column: no extra query
|
||||||
|
# for the skipped prefetch, none for a per-instance fallback
|
||||||
|
content_queries = [
|
||||||
|
query
|
||||||
|
for query in ctx.captured_queries
|
||||||
|
if '"documents_document"."content"' in query["sql"]
|
||||||
|
]
|
||||||
|
assert len(content_queries) == 1
|
||||||
|
|
||||||
|
def test_latest_version_content_prefetch_carries_only_the_newest_version(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with two versions
|
||||||
|
WHEN:
|
||||||
|
- Fetching the root through latest_version_content_prefetch()
|
||||||
|
THEN:
|
||||||
|
- The prefetch carries only the single newest version, not every
|
||||||
|
historical version's content (the whole point of not reusing
|
||||||
|
the metadata-only "versions" prefetch for this)
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="root-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="older-version-content",
|
||||||
|
)
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=2,
|
||||||
|
content="newest-version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched_root = (
|
||||||
|
Document.objects.filter(pk=root.pk)
|
||||||
|
.prefetch_related(
|
||||||
|
latest_version_content_prefetch(),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
)
|
||||||
|
|
||||||
|
latest = getattr(fetched_root, LATEST_VERSION_CONTENT_PREFETCH_ATTR)
|
||||||
|
assert [v.content for v in latest] == ["newest-version-content"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasPrefetchedEffectiveContent:
|
||||||
|
"""
|
||||||
|
DocumentSerializer.to_representation() only calls get_effective_content()
|
||||||
|
when has_prefetched_effective_content() says it's cheap -- otherwise a
|
||||||
|
caller that never set up an annotation or prefetch (TrashView,
|
||||||
|
GlobalSearchView, which build their own querysets and don't display
|
||||||
|
content at all) would pay for a per-instance query nobody asked for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_false_with_no_annotation_or_prefetch(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document the ORM never annotated or prefetched for
|
||||||
|
WHEN:
|
||||||
|
- Asking whether its effective content is already resolved
|
||||||
|
THEN:
|
||||||
|
- It is not, so the serializer must leave it alone
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.build()
|
||||||
|
|
||||||
|
assert has_prefetched_effective_content(document) is False
|
||||||
|
|
||||||
|
def test_true_with_effective_content_annotation(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document carrying the queryset's effective_content annotation
|
||||||
|
WHEN:
|
||||||
|
- Asking whether its effective content is already resolved
|
||||||
|
THEN:
|
||||||
|
- It is, straight off the annotation
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.build()
|
||||||
|
document.effective_content = "resolved"
|
||||||
|
|
||||||
|
assert has_prefetched_effective_content(document) is True
|
||||||
|
|
||||||
|
def test_true_with_lean_prefetch_attr_even_when_empty(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document the lean content prefetch ran for, finding no versions
|
||||||
|
WHEN:
|
||||||
|
- Asking whether its effective content is already resolved
|
||||||
|
THEN:
|
||||||
|
- It is: an empty prefetch is an answer, not a missing one
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.build()
|
||||||
|
setattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, [])
|
||||||
|
|
||||||
|
assert has_prefetched_effective_content(document) is True
|
||||||
|
|
||||||
|
def test_true_with_metadata_versions_prefetch_cache(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document carrying only the metadata "versions" prefetch
|
||||||
|
WHEN:
|
||||||
|
- Asking whether its effective content is already resolved
|
||||||
|
THEN:
|
||||||
|
- It is, via get_effective_content()'s prefetch-cache branch
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.build()
|
||||||
|
document._prefetched_objects_cache = {"versions": []}
|
||||||
|
|
||||||
|
assert has_prefetched_effective_content(document) is True
|
||||||
|
|
||||||
|
|
||||||
|
def _get_document_queries(
|
||||||
|
ctx: CaptureQueriesContext,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""
|
||||||
|
The queries a list request spends on the documents themselves, i.e.
|
||||||
|
everything but the one-time django_content_type lookup guardian's
|
||||||
|
permission filtering makes. That lookup is process-cached, and the
|
||||||
|
autouse fixture in conftest clears the cache before every test, so it
|
||||||
|
lands in whichever request happens to run first and never repeats --
|
||||||
|
counting it makes a request look like it costs one query more than the
|
||||||
|
identical request after it.
|
||||||
|
"""
|
||||||
|
return [q for q in ctx.captured_queries if '"django_content_type"' not in q["sql"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_effective_content_fallback_queries(
|
||||||
|
ctx: CaptureQueriesContext,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Document.get_effective_content()'s per-instance fallback (no annotation,
|
||||||
|
no prefetch) is a `.values_list("content", flat=True).first()` query --
|
||||||
|
a SELECT of just the content column. Distinct from get_versions()'s own,
|
||||||
|
unrelated per-instance metadata query (id/checksum/added/etc, no
|
||||||
|
content) run to build the "versions" response field, which isn't part
|
||||||
|
of what this test file covers.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
q
|
||||||
|
for q in ctx.captured_queries
|
||||||
|
if q["sql"].startswith('SELECT "documents_document"."content" FROM')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestTrashAndGlobalSearchEffectiveContentIsNeverPerInstance:
|
||||||
|
"""
|
||||||
|
TrashView and GlobalSearchView serialize Document instances with
|
||||||
|
DocumentSerializer too, but build their querysets independently of
|
||||||
|
DocumentViewSet.get_queryset(). TrashView doesn't display content at all,
|
||||||
|
so it keeps the document's own unresolved content; GlobalSearchView
|
||||||
|
annotates effective_content itself, so it shows the latest version's.
|
||||||
|
Neither should ever fall back to a per-instance query.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_trash_list_shows_unresolved_content_with_no_extra_query(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A trashed root document whose own content differs from what a
|
||||||
|
version would have had (also trashed, deletion cascades)
|
||||||
|
WHEN:
|
||||||
|
- Listing trash
|
||||||
|
THEN:
|
||||||
|
- The response shows the document's own content
|
||||||
|
- Nothing ever queries for versions to resolve it
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="own-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="version-content",
|
||||||
|
)
|
||||||
|
root.delete()
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = admin_client.get("/api/trash/")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
[result] = [r for r in response.data["results"] if r["id"] == root.id]
|
||||||
|
assert result["content"] == "own-content"
|
||||||
|
assert _get_effective_content_fallback_queries(ctx) == []
|
||||||
|
|
||||||
|
def test_global_search_db_only_shows_latest_version_content_with_no_extra_query(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document, findable by title, whose own content differs
|
||||||
|
from its latest version's
|
||||||
|
WHEN:
|
||||||
|
- Using the global search endpoint's db_only mode
|
||||||
|
THEN:
|
||||||
|
- The response shows the latest version's content, resolved by
|
||||||
|
GlobalSearchView's own effective_content annotation
|
||||||
|
- There is no per-instance fallback query
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(title="findme", content="own-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = admin_client.get(
|
||||||
|
"/api/search/?query=findme&db_only=true",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
[result] = [d for d in response.data["documents"] if d["id"] == root.id]
|
||||||
|
assert result["content"] == "version-content"
|
||||||
|
assert _get_effective_content_fallback_queries(ctx) == []
|
||||||
@@ -309,6 +309,9 @@ class TestEmailDocumentPermissionBoundary:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
hidden = DocumentFactory(owner=owner)
|
hidden = DocumentFactory(owner=owner)
|
||||||
|
|
||||||
@@ -364,6 +367,27 @@ class TestBulkEditChangePermissionBoundary:
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestBulkDownloadPermissionChecksRootDocument:
|
class TestBulkDownloadPermissionChecksRootDocument:
|
||||||
|
def test_download_requires_global_view_permission(
|
||||||
|
self,
|
||||||
|
rest_api_client,
|
||||||
|
paperless_dirs,
|
||||||
|
_media_settings,
|
||||||
|
):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
root = DocumentFactory(owner=owner)
|
||||||
|
root.source_path.write_bytes(b"%PDF-1.4 test")
|
||||||
|
assign_perm("view_document", requester, root)
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{"documents": [root.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
def test_permission_checked_on_root_not_on_version(
|
def test_permission_checked_on_root_not_on_version(
|
||||||
self,
|
self,
|
||||||
rest_api_client,
|
rest_api_client,
|
||||||
@@ -372,6 +396,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
root = DocumentFactory(owner=owner)
|
root = DocumentFactory(owner=owner)
|
||||||
# a version of root that the requester has NOT been individually granted
|
# a version of root that the requester has NOT been individually granted
|
||||||
@@ -396,6 +423,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
# `stranger` case) can't tell the two apart, since they're denied
|
# `stranger` case) can't tell the two apart, since they're denied
|
||||||
# either way.
|
# either way.
|
||||||
version_only_grantee = User.objects.create_user(username="version_only_grantee")
|
version_only_grantee = User.objects.create_user(username="version_only_grantee")
|
||||||
|
version_only_grantee.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
assign_perm("view_document", version_only_grantee, version)
|
assign_perm("view_document", version_only_grantee, version)
|
||||||
rest_api_client.force_authenticate(user=version_only_grantee)
|
rest_api_client.force_authenticate(user=version_only_grantee)
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
@@ -417,6 +447,9 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="delete_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", requester, doc) # view only, NOT delete
|
assign_perm("view_document", requester, doc) # view only, NOT delete
|
||||||
@@ -435,6 +468,9 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="delete_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("delete_document", requester, doc)
|
assign_perm("delete_document", requester, doc)
|
||||||
@@ -447,6 +483,22 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
|
||||||
|
def test_restore_requires_global_delete_permission(self, rest_api_client):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("delete_document", requester, doc)
|
||||||
|
doc.delete()
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/trash/",
|
||||||
|
{"documents": [doc.pk], "action": "restore"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
||||||
@@ -463,6 +515,9 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
|||||||
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
||||||
owner = User.objects.create_user(username="trash_owner")
|
owner = User.objects.create_user(username="trash_owner")
|
||||||
grantee = User.objects.create_user(username="trash_grantee")
|
grantee = User.objects.create_user(username="trash_grantee")
|
||||||
|
grantee.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
doc.delete() # soft delete
|
doc.delete() # soft delete
|
||||||
assign_perm("view_document", grantee, doc)
|
assign_perm("view_document", grantee, doc)
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ from pathlib import Path
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -48,6 +50,37 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
delay_mock.assert_called_once()
|
delay_mock.assert_called_once()
|
||||||
self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk)
|
self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk)
|
||||||
|
|
||||||
|
@mock.patch("documents.views.build_share_link_bundle.apply_async")
|
||||||
|
def test_create_bundle_requires_global_document_view_permission(
|
||||||
|
self,
|
||||||
|
delay_mock,
|
||||||
|
) -> None:
|
||||||
|
owner = User.objects.create_user(username="document_owner")
|
||||||
|
requester = User.objects.create_user(username="bundle_creator")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="add_sharelinkbundle"),
|
||||||
|
)
|
||||||
|
document = DocumentFactory.create(owner=owner)
|
||||||
|
assign_perm("view_document", requester, document)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
payload = {
|
||||||
|
"document_ids": [document.pk],
|
||||||
|
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||||
|
"expiration_days": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
requester = User.objects.get(pk=requester.pk)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
delay_mock.assert_called_once()
|
||||||
|
|
||||||
def test_create_bundle_rejects_missing_documents(self) -> None:
|
def test_create_bundle_rejects_missing_documents(self) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"document_ids": [9999],
|
"document_ids": [9999],
|
||||||
|
|||||||
@@ -106,6 +106,17 @@ class TestBeforeTaskPublishHandler:
|
|||||||
assert task.task_type == PaperlessTask.TaskType.TRAIN_CLASSIFIER
|
assert task.task_type == PaperlessTask.TaskType.TRAIN_CLASSIFIER
|
||||||
assert task.trigger_source == PaperlessTask.TriggerSource.MANUAL
|
assert task.trigger_source == PaperlessTask.TriggerSource.MANUAL
|
||||||
|
|
||||||
|
# A Celery retry republishes with the same task_id; this must not
|
||||||
|
# raise a duplicate-key IntegrityError, and must leave the original
|
||||||
|
# PENDING record alone.
|
||||||
|
send_publish(
|
||||||
|
"documents.tasks.train_classifier",
|
||||||
|
(),
|
||||||
|
{},
|
||||||
|
headers={"id": task_id},
|
||||||
|
)
|
||||||
|
assert PaperlessTask.objects.filter(task_id=task_id).count() == 1
|
||||||
|
|
||||||
def test_creates_task_for_sanity_check(self) -> None:
|
def test_creates_task_for_sanity_check(self) -> None:
|
||||||
task_id = send_publish("documents.tasks.sanity_check", (), {})
|
task_id = send_publish("documents.tasks.sanity_check", (), {})
|
||||||
task = PaperlessTask.objects.get(task_id=task_id)
|
task = PaperlessTask.objects.get(task_id=task_id)
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
codename__contains="sharelink",
|
codename__contains="sharelink",
|
||||||
)
|
)
|
||||||
self.user.user_permissions.add(*sharelink_permissions)
|
self.user.user_permissions.add(*sharelink_permissions)
|
||||||
|
self.user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
self.user.save()
|
self.user.save()
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
@@ -202,6 +205,9 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
codename__contains="sharelink",
|
codename__contains="sharelink",
|
||||||
)
|
)
|
||||||
self.user.user_permissions.add(*sharelink_permissions)
|
self.user.user_permissions.add(*sharelink_permissions)
|
||||||
|
self.user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
create_response = self.client.post(
|
create_response = self.client.post(
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ from typing import Any
|
|||||||
|
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.db.models import OuterRef
|
from django.db.models import OuterRef
|
||||||
|
from django.db.models import Prefetch
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.db.models import Subquery
|
from django.db.models import Subquery
|
||||||
|
from django.db.models import Window
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.db.models.functions import RowNumber
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
@@ -46,6 +49,68 @@ def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Docume
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LATEST_VERSION_CONTENT_PREFETCH_ATTR = "_latest_version_content_prefetch"
|
||||||
|
|
||||||
|
|
||||||
|
def latest_version_content_prefetch() -> Prefetch:
|
||||||
|
"""
|
||||||
|
A Prefetch for Document.versions scoped to just the newest version's
|
||||||
|
content, for get_effective_content()'s fallback when no SQL annotation
|
||||||
|
is present.
|
||||||
|
|
||||||
|
Deliberately not merged into a metadata-only "versions" prefetch (the one
|
||||||
|
used for the serialized versions list): that one fetches every historical
|
||||||
|
version of every document, and pulling full OCR content for versions
|
||||||
|
nobody will read wastes DB transfer/memory at scale. This one is windowed
|
||||||
|
down to a single row per root, then bounded by Prefetch's own IN-list to
|
||||||
|
whatever page/result set it's attached to -- one cheap bulk query total,
|
||||||
|
not one per document and not one per version.
|
||||||
|
"""
|
||||||
|
return Prefetch(
|
||||||
|
"versions",
|
||||||
|
queryset=(
|
||||||
|
Document.objects.filter(
|
||||||
|
root_document_id__isnull=False,
|
||||||
|
deleted_at__isnull=True,
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
rn=Window(
|
||||||
|
RowNumber(),
|
||||||
|
partition_by=F("root_document_id"),
|
||||||
|
order_by=[
|
||||||
|
F("version_index").desc(nulls_last=True),
|
||||||
|
F("id").desc(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(rn=1)
|
||||||
|
.only("id", "root_document_id", "content")
|
||||||
|
),
|
||||||
|
to_attr=LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_prefetched_effective_content(document: Document) -> bool:
|
||||||
|
"""
|
||||||
|
True if document.get_effective_content() can answer without an extra
|
||||||
|
per-instance query -- an SQL ``effective_content`` annotation, the lean
|
||||||
|
latest_version_content_prefetch(), or the metadata-only "versions"
|
||||||
|
prefetch is already present on the instance.
|
||||||
|
|
||||||
|
Callers that haven't set any of those up (e.g. views that build their
|
||||||
|
own querysets independently of DocumentViewSet.get_queryset(), like
|
||||||
|
TrashView or GlobalSearchView) intentionally don't pay for version-aware
|
||||||
|
content resolution -- see DocumentSerializer.to_representation(), which
|
||||||
|
uses this to decide whether to call get_effective_content() at all.
|
||||||
|
"""
|
||||||
|
if hasattr(document, "effective_content"):
|
||||||
|
return True
|
||||||
|
if getattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, None) is not None:
|
||||||
|
return True
|
||||||
|
prefetched_cache = getattr(document, "_prefetched_objects_cache", None)
|
||||||
|
return isinstance(prefetched_cache, dict) and "versions" in prefetched_cache
|
||||||
|
|
||||||
|
|
||||||
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
||||||
"""
|
"""
|
||||||
Same sorting as versions_newest_first()
|
Same sorting as versions_newest_first()
|
||||||
|
|||||||
+101
-43
@@ -36,7 +36,6 @@ from django.db.migrations.recorder import MigrationRecorder
|
|||||||
from django.db.models import Avg
|
from django.db.models import Avg
|
||||||
from django.db.models import Case
|
from django.db.models import Case
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.db.models import F
|
|
||||||
from django.db.models import IntegerField
|
from django.db.models import IntegerField
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
@@ -137,12 +136,14 @@ from documents.filters import CustomFieldFilterSet
|
|||||||
from documents.filters import DocumentFilterSet
|
from documents.filters import DocumentFilterSet
|
||||||
from documents.filters import DocumentsOrderingFilter
|
from documents.filters import DocumentsOrderingFilter
|
||||||
from documents.filters import DocumentTypeFilterSet
|
from documents.filters import DocumentTypeFilterSet
|
||||||
|
from documents.filters import EffectiveContentFilter
|
||||||
from documents.filters import PaperlessTaskFilterSet
|
from documents.filters import PaperlessTaskFilterSet
|
||||||
from documents.filters import PermittedObjectsFilter
|
from documents.filters import PermittedObjectsFilter
|
||||||
from documents.filters import ShareLinkBundleFilterSet
|
from documents.filters import ShareLinkBundleFilterSet
|
||||||
from documents.filters import ShareLinkFilterSet
|
from documents.filters import ShareLinkFilterSet
|
||||||
from documents.filters import StoragePathFilterSet
|
from documents.filters import StoragePathFilterSet
|
||||||
from documents.filters import TagFilterSet
|
from documents.filters import TagFilterSet
|
||||||
|
from documents.filters import TitleContentFilter
|
||||||
from documents.mail import EmailAttachment
|
from documents.mail import EmailAttachment
|
||||||
from documents.mail import send_email
|
from documents.mail import send_email
|
||||||
from documents.matching import match_correspondents
|
from documents.matching import match_correspondents
|
||||||
@@ -169,6 +170,7 @@ from documents.permissions import AcknowledgeTasksPermissions
|
|||||||
from documents.permissions import PaperlessAdminPermissions
|
from documents.permissions import PaperlessAdminPermissions
|
||||||
from documents.permissions import PaperlessNotePermissions
|
from documents.permissions import PaperlessNotePermissions
|
||||||
from documents.permissions import PaperlessObjectPermissions
|
from documents.permissions import PaperlessObjectPermissions
|
||||||
|
from documents.permissions import TrashPermissions
|
||||||
from documents.permissions import ViewDocumentsPermissions
|
from documents.permissions import ViewDocumentsPermissions
|
||||||
from documents.permissions import annotate_document_count_by_ids
|
from documents.permissions import annotate_document_count_by_ids
|
||||||
from documents.permissions import annotate_document_count_for_related_queryset
|
from documents.permissions import annotate_document_count_for_related_queryset
|
||||||
@@ -179,7 +181,7 @@ from documents.permissions import has_perms_owner_aware
|
|||||||
from documents.permissions import has_system_status_permission
|
from documents.permissions import has_system_status_permission
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.permissions import user_is_unrestricted
|
from documents.permissions import user_is_unrestricted
|
||||||
from documents.plugins.date_parsing import get_date_parser
|
from documents.plugins.date_parsing import get_date_parser
|
||||||
from documents.schema import generate_object_with_permissions_schema
|
from documents.schema import generate_object_with_permissions_schema
|
||||||
@@ -236,6 +238,7 @@ from documents.versioning import annotate_effective_content
|
|||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
from documents.versioning import get_request_version_param
|
from documents.versioning import get_request_version_param
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
|
from documents.versioning import latest_version_content_prefetch
|
||||||
from documents.versioning import resolve_requested_version_for_root
|
from documents.versioning import resolve_requested_version_for_root
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
from paperless import version
|
from paperless import version
|
||||||
@@ -1084,12 +1087,59 @@ class DocumentViewSet(
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_queryset(self):
|
@classmethod
|
||||||
latest_version_content = Subquery(
|
def _content_filter_params(cls) -> tuple[str, ...]:
|
||||||
versions_newest_first(
|
"""
|
||||||
Document.objects.filter(root_document=OuterRef("pk")),
|
Query params whose filtering needs effective_content evaluated in SQL
|
||||||
).values("content")[:1],
|
against every candidate row -- see
|
||||||
|
_needs_effective_content_annotation(). Derived rather than
|
||||||
|
hand-maintained so a new content-filtering param counts automatically.
|
||||||
|
"""
|
||||||
|
params = [
|
||||||
|
name
|
||||||
|
for name, f in DocumentFilterSet.declared_filters.items()
|
||||||
|
if isinstance(f, (TitleContentFilter, EffectiveContentFilter))
|
||||||
|
]
|
||||||
|
if "effective_content" in cls.search_fields:
|
||||||
|
params.append(SearchFilter().search_param)
|
||||||
|
return tuple(params)
|
||||||
|
|
||||||
|
def _needs_effective_content_annotation(self) -> bool:
|
||||||
|
# effective_content is a per-row correlated subquery resolving each
|
||||||
|
# document's latest version. Filtering *on* it forces the database to
|
||||||
|
# evaluate it for every candidate row before reaching the LIMIT, which
|
||||||
|
# the root_document_id self-join makes pathological on MariaDB
|
||||||
|
# specifically once real candidate counts get large; otherwise the
|
||||||
|
# "versions" prefetch + Document.get_effective_content() resolves only
|
||||||
|
# the page that survives pagination. Every param here is deprecated in
|
||||||
|
# favor of the Tantivy-backed search endpoint (see filters.py's
|
||||||
|
# TitleContentFilter/EffectiveContentFilter docs), so pay that cost
|
||||||
|
# only when one is actually used. Blank values don't count, matching
|
||||||
|
# how those filters themselves no-op on them -- an empty `?search=`
|
||||||
|
# applies no predicate.
|
||||||
|
params = self.request.query_params
|
||||||
|
return any(
|
||||||
|
params.get(param, "").strip() for param in self._content_filter_params()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _requested_fields(self) -> list[str] | None:
|
||||||
|
# The sparse-fieldset `fields` param, as DynamicFieldsModelSerializer
|
||||||
|
# wants it: None means "no restriction, serialize everything", which
|
||||||
|
# a blank value means too. get_queryset() and get_serializer() both
|
||||||
|
# branch on this, and they have to read it identically -- a queryset
|
||||||
|
# that skips the content prefetch for a response that still
|
||||||
|
# serializes content reintroduces get_effective_content()'s
|
||||||
|
# per-instance fallback.
|
||||||
|
fields_param = self.request.query_params.get("fields")
|
||||||
|
return fields_param.split(",") if fields_param else None
|
||||||
|
|
||||||
|
def _needs_effective_content_prefetch(self) -> bool:
|
||||||
|
# The prefetch spares get_effective_content() a per-instance fallback
|
||||||
|
# query, but only earns itself when content can reach the response.
|
||||||
|
fields = self._requested_fields()
|
||||||
|
return fields is None or "content" in fields
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
||||||
# be, which forced a GROUP BY aggregate over every matching document
|
# be, which forced a GROUP BY aggregate over every matching document
|
||||||
# before the query could even be sorted or limited.
|
# before the query could even be sorted or limited.
|
||||||
@@ -1109,40 +1159,43 @@ class DocumentViewSet(
|
|||||||
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
||||||
# database to fully sort and dedupe every visible document before
|
# database to fully sort and dedupe every visible document before
|
||||||
# it can apply LIMIT, which is disastrous at scale.
|
# it can apply LIMIT, which is disastrous at scale.
|
||||||
return (
|
prefetches = [
|
||||||
|
Prefetch(
|
||||||
|
"versions",
|
||||||
|
queryset=Document.objects.only(
|
||||||
|
"id",
|
||||||
|
"added",
|
||||||
|
"checksum",
|
||||||
|
"version_label",
|
||||||
|
"root_document_id",
|
||||||
|
"version_index",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"tags",
|
||||||
|
Prefetch(
|
||||||
|
"custom_fields",
|
||||||
|
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||||
|
),
|
||||||
|
# NotesSerializer nests the author, this avoids query per note
|
||||||
|
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||||
|
]
|
||||||
|
if self._needs_effective_content_prefetch():
|
||||||
|
prefetches.append(latest_version_content_prefetch())
|
||||||
|
queryset = (
|
||||||
Document.objects.filter(root_document__isnull=True)
|
Document.objects.filter(root_document__isnull=True)
|
||||||
.order_by("-created", "-id")
|
.order_by("-created", "-id")
|
||||||
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
|
|
||||||
.annotate(num_notes=Coalesce(note_count, 0))
|
.annotate(num_notes=Coalesce(note_count, 0))
|
||||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||||
.prefetch_related(
|
.prefetch_related(*prefetches)
|
||||||
Prefetch(
|
|
||||||
"versions",
|
|
||||||
queryset=Document.objects.only(
|
|
||||||
"id",
|
|
||||||
"added",
|
|
||||||
"checksum",
|
|
||||||
"version_label",
|
|
||||||
"root_document_id",
|
|
||||||
"version_index",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
"tags",
|
|
||||||
Prefetch(
|
|
||||||
"custom_fields",
|
|
||||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
|
||||||
),
|
|
||||||
# NotesSerializer nests the author, this avoids query per note
|
|
||||||
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if self._needs_effective_content_annotation():
|
||||||
|
queryset = annotate_effective_content(queryset)
|
||||||
|
return queryset
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
fields_param = self.request.query_params.get("fields", None)
|
|
||||||
fields = fields_param.split(",") if fields_param else None
|
|
||||||
truncate_content = self.request.query_params.get("truncate_content", "False")
|
truncate_content = self.request.query_params.get("truncate_content", "False")
|
||||||
kwargs.setdefault("context", self.get_serializer_context())
|
kwargs.setdefault("context", self.get_serializer_context())
|
||||||
kwargs.setdefault("fields", fields)
|
kwargs.setdefault("fields", self._requested_fields())
|
||||||
kwargs.setdefault("truncate_content", truncate_content.lower() in ["true", "1"])
|
kwargs.setdefault("truncate_content", truncate_content.lower() in ["true", "1"])
|
||||||
try:
|
try:
|
||||||
full_perms = get_boolean(
|
full_perms = get_boolean(
|
||||||
@@ -2328,7 +2381,6 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
serializer_class = ChatStreamingSerializer
|
serializer_class = ChatStreamingSerializer
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
request.compress_exempt = True
|
|
||||||
ai_config = AIConfig()
|
ai_config = AIConfig()
|
||||||
if not ai_config.ai_enabled:
|
if not ai_config.ai_enabled:
|
||||||
return HttpResponseBadRequest("AI is required for this feature")
|
return HttpResponseBadRequest("AI is required for this feature")
|
||||||
@@ -3468,7 +3520,7 @@ class PostDocumentView(GenericAPIView[Any]):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
class SelectionDataView(GenericAPIView[Any]):
|
class SelectionDataView(GenericAPIView[Any]):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||||
serializer_class = DocumentListSerializer
|
serializer_class = DocumentListSerializer
|
||||||
parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
|
parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
|
||||||
|
|
||||||
@@ -3959,7 +4011,7 @@ class StatisticsView(GenericAPIView[Any]):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||||
serializer_class = BulkDownloadSerializer
|
serializer_class = BulkDownloadSerializer
|
||||||
parser_classes = (parsers.JSONParser,)
|
parser_classes = (parsers.JSONParser,)
|
||||||
|
|
||||||
@@ -4058,7 +4110,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Storag
|
|||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action == "test":
|
if self.action == "test":
|
||||||
# Test action does not require object level permissions
|
# Test action does not require object level permissions
|
||||||
self.permission_classes = (IsAuthenticated,)
|
self.permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||||
return super().get_permissions()
|
return super().get_permissions()
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
@@ -4625,6 +4677,12 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
|||||||
filterset_class = ShareLinkBundleFilterSet
|
filterset_class = ShareLinkBundleFilterSet
|
||||||
ordering_fields = ("created", "expiration", "status")
|
ordering_fields = ("created", "expiration", "status")
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
permissions = super().get_permissions()
|
||||||
|
if self.action == "create":
|
||||||
|
permissions.append(ViewDocumentsPermissions())
|
||||||
|
return permissions
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
super()
|
super()
|
||||||
@@ -4967,12 +5025,12 @@ class BulkEditObjectsView(PassUserMixin):
|
|||||||
qs_owner_update.update(owner=owner)
|
qs_owner_update.update(owner=owner)
|
||||||
|
|
||||||
if "permissions" in serializer.validated_data:
|
if "permissions" in serializer.validated_data:
|
||||||
for obj in qs:
|
set_permissions_for_objects(
|
||||||
set_permissions_for_object(
|
permissions=permissions,
|
||||||
permissions=permissions,
|
model=object_class,
|
||||||
object=obj,
|
pks=qs.values_list("pk", flat=True),
|
||||||
merge=merge,
|
merge=merge,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -5443,7 +5501,7 @@ class SystemStatusView(PassUserMixin):
|
|||||||
|
|
||||||
|
|
||||||
class TrashView(ListModelMixin, PassUserMixin):
|
class TrashView(ListModelMixin, PassUserMixin):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated, TrashPermissions)
|
||||||
serializer_class = TrashSerializer
|
serializer_class = TrashSerializer
|
||||||
|
|
||||||
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,23 @@
|
|||||||
|
from compression_middleware.middleware import CompressionMiddleware
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from paperless import version
|
from paperless import version
|
||||||
|
|
||||||
|
|
||||||
|
class StreamAwareCompressionMiddleware(CompressionMiddleware):
|
||||||
|
"""
|
||||||
|
Bypasses compression for server-sent streams (text/event-stream).
|
||||||
|
|
||||||
|
See https://github.com/friedelwolff/django-compression-middleware/pull/7
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process_response(self, request, response):
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if content_type.startswith("text/event-stream"):
|
||||||
|
return response
|
||||||
|
return super().process_response(request, response)
|
||||||
|
|
||||||
|
|
||||||
class ApiVersionMiddleware:
|
class ApiVersionMiddleware:
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from pathlib import Path
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from compression_middleware.middleware import CompressionMiddleware
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -201,22 +200,10 @@ MIDDLEWARE = [
|
|||||||
"allauth.account.middleware.AccountMiddleware",
|
"allauth.account.middleware.AccountMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Optional to enable compression
|
# Optional to enable compression. The subclass leaves server-sent events
|
||||||
|
# uncompressed; see paperless.middleware.StreamAwareCompressionMiddleware.
|
||||||
if get_bool_from_env("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
|
if get_bool_from_env("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
|
||||||
MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware")
|
MIDDLEWARE.insert(0, "paperless.middleware.StreamAwareCompressionMiddleware")
|
||||||
|
|
||||||
# Workaround to not compress streaming responses (e.g. chat).
|
|
||||||
# See https://github.com/friedelwolff/django-compression-middleware/pull/7
|
|
||||||
original_process_response = CompressionMiddleware.process_response
|
|
||||||
|
|
||||||
|
|
||||||
def patched_process_response(self, request, response):
|
|
||||||
if getattr(request, "compress_exempt", False):
|
|
||||||
return response
|
|
||||||
return original_process_response(self, request, response)
|
|
||||||
|
|
||||||
|
|
||||||
CompressionMiddleware.process_response = patched_process_response
|
|
||||||
|
|
||||||
ROOT_URLCONF = "paperless.urls"
|
ROOT_URLCONF = "paperless.urls"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from django.http import HttpResponse
|
||||||
|
from django.http import StreamingHttpResponse
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from paperless.middleware import StreamAwareCompressionMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamAwareCompressionMiddleware(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.middleware = StreamAwareCompressionMiddleware(lambda request: None)
|
||||||
|
|
||||||
|
def _request(self):
|
||||||
|
return self.factory.get(
|
||||||
|
"/api/documents/chat/",
|
||||||
|
HTTP_ACCEPT_ENCODING="gzip, deflate, br, zstd",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_event_stream_is_not_compressed(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A server-sent event response produced chunk by chunk
|
||||||
|
WHEN:
|
||||||
|
- The compression middleware processes it
|
||||||
|
THEN:
|
||||||
|
- It is passed through unencoded, one wire chunk per source chunk
|
||||||
|
"""
|
||||||
|
chunks = [f"token{i} ".encode() for i in range(40)]
|
||||||
|
response = StreamingHttpResponse(
|
||||||
|
iter(chunks),
|
||||||
|
content_type="text/event-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.middleware.process_response(self._request(), response)
|
||||||
|
|
||||||
|
assert not response.has_header("Content-Encoding")
|
||||||
|
assert list(response.streaming_content) == chunks
|
||||||
|
|
||||||
|
def test_regular_response_is_still_compressed(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An ordinary response large enough to be worth compressing
|
||||||
|
WHEN:
|
||||||
|
- The compression middleware processes it
|
||||||
|
THEN:
|
||||||
|
- It is compressed as before
|
||||||
|
"""
|
||||||
|
response = HttpResponse(b"a" * 5000, content_type="application/json")
|
||||||
|
|
||||||
|
response = self.middleware.process_response(self._request(), response)
|
||||||
|
|
||||||
|
assert response.has_header("Content-Encoding")
|
||||||
@@ -40,7 +40,6 @@ LLM_SYSTEM_PROMPT = (
|
|||||||
|
|
||||||
# openai-python rejects empty keys since 2.34.0, "fake" is the stand-in from
|
# openai-python rejects empty keys since 2.34.0, "fake" is the stand-in from
|
||||||
# llama-index's own OpenAILike docs https://docs.llamaindex.ai/en/stable/api_reference/llms/openai_like/
|
# llama-index's own OpenAILike docs https://docs.llamaindex.ai/en/stable/api_reference/llms/openai_like/
|
||||||
# TODO: remove pending resolution of https://github.com/openai/openai-python/issues/3224
|
|
||||||
PLACEHOLDER_API_KEY: Final = "fake"
|
PLACEHOLDER_API_KEY: Final = "fake"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -854,6 +854,36 @@ class TestAPIProcessedMails(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_bulk_delete_requires_global_delete_permission(self) -> None:
|
||||||
|
owner = User.objects.create_user(username="mail_owner")
|
||||||
|
requester = User.objects.create_user(username="mail_deleter")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="add_processedmail"),
|
||||||
|
)
|
||||||
|
mail = ProcessedMailFactory(owner=owner)
|
||||||
|
assign_perm("delete_processedmail", requester, mail)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}bulk_delete/",
|
||||||
|
data={"mail_ids": [mail.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="delete_processedmail"),
|
||||||
|
)
|
||||||
|
requester = User.objects.get(pk=requester.pk)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}bulk_delete/",
|
||||||
|
data={"mail_ids": [mail.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertFalse(ProcessedMail.objects.filter(pk=mail.pk).exists())
|
||||||
|
|
||||||
def test_bulk_delete_processed_mails_rejects_mixed_batch_atomically(self) -> None:
|
def test_bulk_delete_processed_mails_rejects_mixed_batch_atomically(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from rest_framework import serializers
|
|||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
@@ -44,6 +45,15 @@ from paperless_mail.serialisers import ProcessedMailSerializer
|
|||||||
from paperless_mail.tasks import process_mail_accounts
|
from paperless_mail.tasks import process_mail_accounts
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteProcessedMailPermissions(BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return bool(
|
||||||
|
request.user
|
||||||
|
and request.user.is_authenticated
|
||||||
|
and request.user.has_perm("paperless_mail.delete_processedmail"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
test=extend_schema(
|
test=extend_schema(
|
||||||
operation_id="mail_account_test",
|
operation_id="mail_account_test",
|
||||||
@@ -206,7 +216,11 @@ class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
|
|||||||
|
|
||||||
queryset = ProcessedMail.objects.all().order_by("-processed")
|
queryset = ProcessedMail.objects.all().order_by("-processed")
|
||||||
|
|
||||||
@action(methods=["post"], detail=False)
|
@action(
|
||||||
|
methods=["post"],
|
||||||
|
detail=False,
|
||||||
|
permission_classes=[IsAuthenticated, DeleteProcessedMailPermissions],
|
||||||
|
)
|
||||||
def bulk_delete(self, request):
|
def bulk_delete(self, request):
|
||||||
mail_ids = request.data.get("mail_ids", [])
|
mail_ids = request.data.get("mail_ids", [])
|
||||||
if not isinstance(mail_ids, list) or not all(
|
if not isinstance(mail_ids, list) or not all(
|
||||||
|
|||||||
Reference in New Issue
Block a user