mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-13 05:07:59 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08754e9d0f | ||
|
|
72ea38ab12 | ||
|
|
4421d4fe58 | ||
|
|
4d64632f70 | ||
|
|
26094bc863 | ||
|
|
9dbad4de09 | ||
|
|
4a54935b3d | ||
|
|
d53c9070ba | ||
|
|
f885833a38 | ||
|
|
197c80ea68 | ||
|
|
a0c9500b6a | ||
|
|
df8e95cbd4 | ||
|
|
95944a553d | ||
|
|
2256cb3d38 | ||
|
|
9a8163fbbb | ||
|
|
d5f9605daf |
+105
-73
@@ -1345,7 +1345,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1808</context>
|
||||
<context context-type="linenumber">1865</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1577733187050997705" datatype="html">
|
||||
@@ -2423,7 +2423,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">673</context>
|
||||
<context context-type="linenumber">690</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
@@ -3330,11 +3330,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -3935,7 +3935,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1375</context>
|
||||
<context context-type="linenumber">1432</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -4079,7 +4079,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1862</context>
|
||||
<context context-type="linenumber">1919</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6661109599266152398" datatype="html">
|
||||
@@ -4090,7 +4090,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1863</context>
|
||||
<context context-type="linenumber">1920</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5162686434580248853" datatype="html">
|
||||
@@ -4101,7 +4101,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1864</context>
|
||||
<context context-type="linenumber">1921</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6665634854532231106" datatype="html">
|
||||
@@ -6238,7 +6238,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1379</context>
|
||||
<context context-type="linenumber">1436</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -8662,152 +8662,203 @@
|
||||
<source>Error retrieving metadata</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2218903673684131427" datatype="html">
|
||||
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">542,544</context>
|
||||
<context context-type="linenumber">545,547</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1000,1002</context>
|
||||
<context context-type="linenumber">1024,1026</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6357361810318120957" datatype="html">
|
||||
<source>Document was updated</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="5154064822428631306" datatype="html">
|
||||
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="8462497568316256794" datatype="html">
|
||||
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="7967484035994732534" datatype="html">
|
||||
<source>Reload</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2907037627372942104" datatype="html">
|
||||
<source>Document reloaded with latest changes.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="6435639868943916539" datatype="html">
|
||||
<source>Document reloaded.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="6142395741265832184" datatype="html">
|
||||
<source>Next document</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="651985345816518480" datatype="html">
|
||||
<source>Previous document</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2885986061416655600" datatype="html">
|
||||
<source>Close document</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="8229691481345614469" datatype="html">
|
||||
<source>Save document</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="1784543155727940353" datatype="html">
|
||||
<source>Save and close / next</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="7427704425579737895" datatype="html">
|
||||
<source>Error retrieving version content</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="3456881259945295697" datatype="html">
|
||||
<source>Error retrieving suggestions.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2194092841814123758" datatype="html">
|
||||
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="6626387786259219838" datatype="html">
|
||||
<source>Error saving document "<x id="PH" equiv-text="this.document().title"/>"</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="448882439049417053" datatype="html">
|
||||
<source>Error saving document</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="282586936710748252" datatype="html">
|
||||
<source>Documents can be restored prior to permanent deletion.</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -8818,14 +8869,14 @@
|
||||
<source>Error deleting document</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="619486176823357521" datatype="html">
|
||||
<source>Reprocess confirm</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -8836,102 +8887,102 @@
|
||||
<source>This operation will permanently recreate the archive file for this document.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="302054111564709516" datatype="html">
|
||||
<source>The archive file will be re-generated with the current settings.</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="4700389117298802932" datatype="html">
|
||||
<source>Reprocess operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1433</context>
|
||||
<context context-type="linenumber">1490</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4409560272830824468" datatype="html">
|
||||
<source>Error executing operation</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="6030453331794586802" datatype="html">
|
||||
<source>Error downloading document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1508</context>
|
||||
<context context-type="linenumber">1565</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4458954481601077369" datatype="html">
|
||||
<source>Page Fit</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1586</context>
|
||||
<context context-type="linenumber">1643</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="9043972994040261999" datatype="html">
|
||||
<source>Error executing PDF edit operation</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1841</context>
|
||||
<context context-type="linenumber">1898</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6172690334763056188" datatype="html">
|
||||
<source>Please enter the current password before attempting to remove it.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1852</context>
|
||||
<context context-type="linenumber">1909</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2282118435712883014" datatype="html">
|
||||
<source>Error executing password removal operation</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1900</context>
|
||||
<context context-type="linenumber">1957</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3740891324955700797" datatype="html">
|
||||
<source>Print failed.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1950</context>
|
||||
<context context-type="linenumber">2007</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6457245677384603573" datatype="html">
|
||||
<source>Error loading document for printing.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1959</context>
|
||||
<context context-type="linenumber">2016</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6085793215710522488" datatype="html">
|
||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2042</context>
|
||||
<context context-type="linenumber">2099</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2048</context>
|
||||
<context context-type="linenumber">2105</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4958946940233632319" datatype="html">
|
||||
@@ -12290,25 +12341,6 @@
|
||||
<context context-type="linenumber">16</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/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">
|
||||
<source>You have unsaved changes.</source>
|
||||
<context-group purpose="location">
|
||||
@@ -12317,7 +12349,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="3305084982600522070" datatype="html">
|
||||
@@ -12457,28 +12489,28 @@
|
||||
<source>You have unsaved changes to the document</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2089045849587358256" datatype="html">
|
||||
<source>Are you sure you want to close this document?</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="6755718693176327396" datatype="html">
|
||||
<source>Are you sure you want to close all documents?</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="4215561719980781894" datatype="html">
|
||||
<source>Close documents</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="1206520795340730278" datatype="html">
|
||||
|
||||
@@ -28,8 +28,9 @@ import { Subject, of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
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 { Document } from 'src/app/data/document'
|
||||
import { Document, DocumentVersionInfo } from 'src/app/data/document'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import {
|
||||
FILTER_CORRESPONDENT,
|
||||
@@ -100,13 +101,18 @@ const doc: Document = {
|
||||
custom_fields: [
|
||||
{
|
||||
field: 0,
|
||||
document: 3,
|
||||
created: new Date(),
|
||||
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 = [
|
||||
{
|
||||
id: 0,
|
||||
@@ -2045,6 +2051,208 @@ describe('DocumentDetailComponent', () => {
|
||||
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', () => {
|
||||
currentUserCan = false
|
||||
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 { DocumentDetailFieldID } from '../admin/settings/settings.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 { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.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 { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -304,6 +304,7 @@ export class DocumentDetailComponent
|
||||
isDirty$: Observable<boolean>
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
docChangeNotifier: Subject<any> = new Subject()
|
||||
versionChangeNotifier: Subject<void> = new Subject()
|
||||
private incomingUpdateModal: NgbModalRef
|
||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||
private lastLocalSaveModified: string | null = null
|
||||
@@ -417,7 +418,8 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
@@ -533,7 +535,8 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => this.previewText.set(res.toString()),
|
||||
@@ -595,6 +598,13 @@ export class DocumentDetailComponent
|
||||
openDocument.duplicate_documents = doc.duplicate_documents
|
||||
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
|
||||
if (openDocument && forceRemote) {
|
||||
Object.assign(openDocument, doc)
|
||||
@@ -642,7 +652,14 @@ export class DocumentDetailComponent
|
||||
this.documentForm.patchValue({ title: titleValue })
|
||||
this.documentForm.get('title').markAsDirty()
|
||||
})
|
||||
const keepContentEdits =
|
||||
useDoc.__selectedVersionId === this.selectedVersionId() &&
|
||||
!!useDoc.__changedFields?.includes('content')
|
||||
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) {
|
||||
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 ?? []
|
||||
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.requiresPassword = false
|
||||
this.updateFormForCustomFields()
|
||||
@@ -940,8 +959,12 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
// 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)
|
||||
// remember so the version can be restored when returning to the document
|
||||
this.document().__selectedVersionId = versionId
|
||||
this.openDocumentService.save()
|
||||
this.previewLoaded.set(false)
|
||||
this.previewUrl.set(
|
||||
this.documentsService.getPreviewUrl(
|
||||
@@ -963,20 +986,20 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
const content = doc?.content ?? ''
|
||||
this.document().content = content
|
||||
this.documentForm.patchValue(
|
||||
{
|
||||
content,
|
||||
},
|
||||
{
|
||||
emitEvent: false,
|
||||
}
|
||||
)
|
||||
if (keepContentEdits) {
|
||||
this.store.next({ ...this.store.value, content })
|
||||
} else {
|
||||
// Update in-place and avoid the debounce wait
|
||||
this.store.value.content = content
|
||||
this.documentForm.patchValue({ content })
|
||||
this.documentForm.get('content').markAsPristine()
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
@@ -991,7 +1014,8 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
takeUntil(this.versionChangeNotifier)
|
||||
)
|
||||
.subscribe({
|
||||
next: (res) => this.previewText.set(res.toString()),
|
||||
@@ -1005,7 +1029,39 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
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[]) {
|
||||
@@ -1233,7 +1289,7 @@ export class DocumentDetailComponent
|
||||
return changes
|
||||
}
|
||||
|
||||
save(close: boolean = false) {
|
||||
save(close: boolean = false, savedCallback: () => void = null) {
|
||||
this.networkActive.set(true)
|
||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||
this.documentsService
|
||||
@@ -1266,6 +1322,7 @@ export class DocumentDetailComponent
|
||||
this.flushPendingIncomingUpdate()
|
||||
}
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
savedCallback?.()
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive.set(false)
|
||||
|
||||
@@ -167,6 +167,7 @@ export interface Document extends ObjectWithPermissions {
|
||||
|
||||
// Frontend only
|
||||
__changedFields?: string[]
|
||||
__selectedVersionId?: number
|
||||
}
|
||||
|
||||
export interface DocumentVersionInfo {
|
||||
|
||||
@@ -221,6 +221,25 @@ describe('OpenDocumentsService', () => {
|
||||
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', () => {
|
||||
subscriptions.push(
|
||||
openDocumentsService.openDocument(documents[1]).subscribe()
|
||||
|
||||
@@ -50,7 +50,15 @@ export class OpenDocumentsService {
|
||||
if (index > -1) {
|
||||
this.documentService.get(id).subscribe({
|
||||
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()
|
||||
},
|
||||
error: () => {
|
||||
|
||||
+23
-11
@@ -28,7 +28,7 @@ from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import StoragePath
|
||||
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.tasks import bulk_update_documents
|
||||
from documents.tasks import consume_file
|
||||
@@ -433,10 +433,13 @@ def set_permissions(
|
||||
else:
|
||||
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))
|
||||
set_permissions_for_objects(
|
||||
permissions=set_permissions,
|
||||
model=Document,
|
||||
pks=affected_docs,
|
||||
merge=merge,
|
||||
)
|
||||
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": affected_docs},
|
||||
@@ -896,17 +899,26 @@ def edit_pdf(
|
||||
pdf_docs: list[pikepdf.Pdf] = []
|
||||
|
||||
try:
|
||||
if not operations:
|
||||
raise ValueError("Output document index is out of bounds")
|
||||
|
||||
max_idx = max(op.get("doc", 0) for op in operations)
|
||||
if update_document and max_idx > 0:
|
||||
logger.error(
|
||||
"Update requested but multiple output documents specified",
|
||||
)
|
||||
raise ValueError("Multiple output documents specified")
|
||||
|
||||
if any(
|
||||
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
|
||||
for op in operations
|
||||
):
|
||||
raise ValueError("Output document index is out of bounds")
|
||||
|
||||
with pikepdf.open(pair.source_doc.source_path) as src:
|
||||
# prepare output documents
|
||||
max_idx = max(op.get("doc", 0) for op in operations)
|
||||
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
|
||||
|
||||
if update_document and len(pdf_docs) > 1:
|
||||
logger.error(
|
||||
"Update requested but multiple output documents specified",
|
||||
)
|
||||
raise ValueError("Multiple output documents specified")
|
||||
|
||||
for op in operations:
|
||||
dst = pdf_docs[op.get("doc", 0)]
|
||||
page = src.pages[op["page"] - 1]
|
||||
|
||||
@@ -375,6 +375,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
If the queryset already annotated ``effective_content``, that value is used.
|
||||
"""
|
||||
# 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 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:
|
||||
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_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(
|
||||
user: User | None,
|
||||
model: type[Model],
|
||||
@@ -484,16 +657,41 @@ class ViewDocumentsPermissions(BasePermission):
|
||||
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):
|
||||
"""
|
||||
Permissions class that checks for model permissions for Notes.
|
||||
"""
|
||||
|
||||
perms_map = {
|
||||
"OPTIONS": ["documents.view_note"],
|
||||
"GET": ["documents.view_note"],
|
||||
"POST": ["documents.add_note"],
|
||||
"DELETE": ["documents.delete_note"],
|
||||
"OPTIONS": ["documents.view_note", "documents.view_document"],
|
||||
"GET": ["documents.view_note", "documents.view_document"],
|
||||
"POST": [
|
||||
"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):
|
||||
|
||||
@@ -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.validators import uri_validator
|
||||
from documents.validators import url_validator
|
||||
from documents.versioning import has_prefetched_effective_content
|
||||
from documents.versioning import sort_versions_newest_first
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1152,8 +1153,14 @@ class DocumentSerializer(
|
||||
|
||||
def to_representation(self, instance):
|
||||
doc = super().to_representation(instance)
|
||||
if "content" in self.fields and hasattr(instance, "effective_content"):
|
||||
doc["content"] = getattr(instance, "effective_content") or ""
|
||||
if "content" in self.fields and has_prefetched_effective_content(instance):
|
||||
# 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:
|
||||
doc["content"] = doc.get("content")[0:550]
|
||||
return doc
|
||||
@@ -1328,6 +1335,7 @@ class DocumentSerializer(
|
||||
"root_document",
|
||||
"versions",
|
||||
)
|
||||
read_only_fields = ("deleted_at",)
|
||||
list_serializer_class = OwnedObjectListSerializer
|
||||
|
||||
|
||||
@@ -1780,6 +1788,12 @@ class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMix
|
||||
"update_document only allowed with a single output document",
|
||||
)
|
||||
|
||||
if any(
|
||||
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
|
||||
for op in operations
|
||||
):
|
||||
raise serializers.ValidationError("doc index is out of bounds")
|
||||
|
||||
doc = Document.objects.get(id=documents[0])
|
||||
if doc.page_count:
|
||||
for op in operations:
|
||||
@@ -2143,6 +2157,12 @@ class BulkEditSerializer(
|
||||
"update_document only allowed with a single output document",
|
||||
)
|
||||
|
||||
if any(
|
||||
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(parameters["operations"])
|
||||
for op in parameters["operations"]
|
||||
):
|
||||
raise serializers.ValidationError("doc index is out of bounds")
|
||||
|
||||
doc = Document.objects.get(id=document_id)
|
||||
# doc existence is already validated
|
||||
if doc.page_count:
|
||||
@@ -2832,10 +2852,14 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
||||
return super().create(validated_data)
|
||||
|
||||
def validate_document(self, document):
|
||||
if self.user is not None and has_perms_owner_aware(
|
||||
self.user,
|
||||
"view_document",
|
||||
document,
|
||||
if (
|
||||
self.user is not None
|
||||
and self.user.has_perm("documents.view_document")
|
||||
and has_perms_owner_aware(
|
||||
self.user,
|
||||
"view_document",
|
||||
document,
|
||||
)
|
||||
):
|
||||
return document
|
||||
raise PermissionDenied(
|
||||
@@ -3596,6 +3620,8 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
||||
|
||||
if "actions" in validated_data:
|
||||
actions = validated_data.pop("actions")
|
||||
for action in actions:
|
||||
action.pop("id", None)
|
||||
|
||||
instance = super().create(validated_data)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
@@ -326,6 +327,9 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
|
||||
def test_download_insufficient_permissions(self) -> None:
|
||||
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.doc2.owner = self.user
|
||||
|
||||
@@ -1084,6 +1084,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
user1 = User.objects.create(username="user1")
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
assign_perm("view_document", user1, self.doc2)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/selection_data/",
|
||||
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.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")
|
||||
def test_set_permissions(self, m) -> None:
|
||||
@@ -1636,6 +1649,24 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_legacy_bulk_edit_rejects_out_of_bounds_pdf_doc_index(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"method": "edit_pdf",
|
||||
"parameters": {
|
||||
"operations": [{"page": 1, "doc": 2**32}],
|
||||
},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"doc index is out of bounds", response.content)
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||
def test_edit_pdf(self, m) -> None:
|
||||
self.setup_mock(m, "edit_pdf")
|
||||
@@ -1738,6 +1769,21 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"doc must be an integer", response.content)
|
||||
|
||||
for doc_index in (-1, 2**32):
|
||||
with self.subTest(doc_index=doc_index):
|
||||
response = self.client.post(
|
||||
"/api/documents/edit_pdf/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc2.id],
|
||||
"operations": [{"page": 1, "doc": doc_index}],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(b"doc index is out of bounds", response.content)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/edit_pdf/",
|
||||
json.dumps(
|
||||
|
||||
@@ -38,6 +38,42 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
||||
)
|
||||
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:
|
||||
with mock.patch(
|
||||
"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.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:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -3981,6 +4030,21 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
|
||||
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(
|
||||
"/api/share_links/",
|
||||
data={
|
||||
|
||||
@@ -2,10 +2,15 @@ import datetime
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test import override_settings
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
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.test import APITestCase
|
||||
|
||||
@@ -452,6 +457,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
||||
owner = User.objects.create_user(username="owner")
|
||||
unprivileged = User.objects.create_user(username="unprivileged")
|
||||
unprivileged.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
document = Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
owner=owner,
|
||||
@@ -483,6 +491,23 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
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)
|
||||
response = self.client.post(
|
||||
f"{self.ENDPOINT}test/",
|
||||
@@ -525,6 +550,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
password="password",
|
||||
email="owner@example.com",
|
||||
)
|
||||
owner.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
document = Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
owner=owner,
|
||||
@@ -600,6 +628,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
checksum="123",
|
||||
)
|
||||
assign_perm("view_document", viewer, document)
|
||||
viewer.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=viewer)
|
||||
response = self.client.post(
|
||||
@@ -687,6 +718,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
document.tags.add(private_tag)
|
||||
assign_perm("view_document", viewer, document)
|
||||
viewer.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=viewer)
|
||||
response = self.client.post(
|
||||
@@ -740,6 +774,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||
value_int=42,
|
||||
)
|
||||
assign_perm("view_document", viewer, document)
|
||||
viewer.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=viewer)
|
||||
response = self.client.post(
|
||||
@@ -842,6 +879,66 @@ class TestBulkEditObjects(APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -69,6 +69,16 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -194,6 +194,48 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -5,8 +5,11 @@ from unittest import mock
|
||||
|
||||
import pikepdf
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_groups_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 StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
|
||||
@@ -515,6 +519,178 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
)
|
||||
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")
|
||||
def test_delete_documents_old_uuid_field(self, m) -> None:
|
||||
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
||||
@@ -1466,6 +1642,17 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
mock_group.assert_not_called()
|
||||
mock_consume_file.assert_not_called()
|
||||
|
||||
@mock.patch("pikepdf.open")
|
||||
def test_edit_pdf_rejects_out_of_bounds_output_index(self, mock_open) -> None:
|
||||
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
||||
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
|
||||
bulk_edit.edit_pdf(
|
||||
[self.doc2.id],
|
||||
[{"page": 1, "doc": 2**32}],
|
||||
)
|
||||
|
||||
mock_open.assert_not_called()
|
||||
|
||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||
|
||||
@@ -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")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
hidden = DocumentFactory(owner=owner)
|
||||
|
||||
@@ -364,6 +367,27 @@ class TestBulkEditChangePermissionBoundary:
|
||||
|
||||
@pytest.mark.django_db
|
||||
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(
|
||||
self,
|
||||
rest_api_client,
|
||||
@@ -372,6 +396,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
||||
):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
root = DocumentFactory(owner=owner)
|
||||
# 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
|
||||
# either way.
|
||||
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)
|
||||
rest_api_client.force_authenticate(user=version_only_grantee)
|
||||
response = rest_api_client.post(
|
||||
@@ -417,6 +447,9 @@ class TestTrashRestorePermissionBoundary:
|
||||
):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="delete_document"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
doc = DocumentFactory(owner=owner)
|
||||
assign_perm("view_document", requester, doc) # view only, NOT delete
|
||||
@@ -435,6 +468,9 @@ class TestTrashRestorePermissionBoundary:
|
||||
):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
requester = User.objects.create_user(username="requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="delete_document"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
doc = DocumentFactory(owner=owner)
|
||||
assign_perm("delete_document", requester, doc)
|
||||
@@ -447,6 +483,22 @@ class TestTrashRestorePermissionBoundary:
|
||||
)
|
||||
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
|
||||
class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
||||
@@ -463,6 +515,9 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
||||
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
||||
owner = User.objects.create_user(username="trash_owner")
|
||||
grantee = User.objects.create_user(username="trash_grantee")
|
||||
grantee.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
doc = DocumentFactory(owner=owner)
|
||||
doc.delete() # soft delete
|
||||
assign_perm("view_document", grantee, doc)
|
||||
|
||||
@@ -6,8 +6,10 @@ from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import serializers
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -48,6 +50,37 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
delay_mock.assert_called_once()
|
||||
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:
|
||||
payload = {
|
||||
"document_ids": [9999],
|
||||
|
||||
@@ -141,6 +141,9 @@ class TestViews(DirectoriesMixin, TestCase):
|
||||
codename__contains="sharelink",
|
||||
)
|
||||
self.user.user_permissions.add(*sharelink_permissions)
|
||||
self.user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
self.user.save()
|
||||
|
||||
self.client.force_login(self.user)
|
||||
@@ -202,6 +205,9 @@ class TestViews(DirectoriesMixin, TestCase):
|
||||
codename__contains="sharelink",
|
||||
)
|
||||
self.user.user_permissions.add(*sharelink_permissions)
|
||||
self.user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
create_response = self.client.post(
|
||||
|
||||
@@ -7,9 +7,12 @@ from typing import Any
|
||||
|
||||
from django.db.models import F
|
||||
from django.db.models import OuterRef
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import QuerySet
|
||||
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 RowNumber
|
||||
|
||||
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]:
|
||||
"""
|
||||
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 Case
|
||||
from django.db.models import Count
|
||||
from django.db.models import F
|
||||
from django.db.models import IntegerField
|
||||
from django.db.models import Max
|
||||
from django.db.models import Model
|
||||
@@ -137,12 +136,14 @@ from documents.filters import CustomFieldFilterSet
|
||||
from documents.filters import DocumentFilterSet
|
||||
from documents.filters import DocumentsOrderingFilter
|
||||
from documents.filters import DocumentTypeFilterSet
|
||||
from documents.filters import EffectiveContentFilter
|
||||
from documents.filters import PaperlessTaskFilterSet
|
||||
from documents.filters import PermittedObjectsFilter
|
||||
from documents.filters import ShareLinkBundleFilterSet
|
||||
from documents.filters import ShareLinkFilterSet
|
||||
from documents.filters import StoragePathFilterSet
|
||||
from documents.filters import TagFilterSet
|
||||
from documents.filters import TitleContentFilter
|
||||
from documents.mail import EmailAttachment
|
||||
from documents.mail import send_email
|
||||
from documents.matching import match_correspondents
|
||||
@@ -169,6 +170,7 @@ from documents.permissions import AcknowledgeTasksPermissions
|
||||
from documents.permissions import PaperlessAdminPermissions
|
||||
from documents.permissions import PaperlessNotePermissions
|
||||
from documents.permissions import PaperlessObjectPermissions
|
||||
from documents.permissions import TrashPermissions
|
||||
from documents.permissions import ViewDocumentsPermissions
|
||||
from documents.permissions import annotate_document_count_by_ids
|
||||
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 permitted_document_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.plugins.date_parsing import get_date_parser
|
||||
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_request_version_param
|
||||
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 versions_newest_first
|
||||
from paperless import version
|
||||
@@ -1084,12 +1087,59 @@ class DocumentViewSet(
|
||||
],
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
latest_version_content = Subquery(
|
||||
versions_newest_first(
|
||||
Document.objects.filter(root_document=OuterRef("pk")),
|
||||
).values("content")[:1],
|
||||
@classmethod
|
||||
def _content_filter_params(cls) -> tuple[str, ...]:
|
||||
"""
|
||||
Query params whose filtering needs effective_content evaluated in SQL
|
||||
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
|
||||
# be, which forced a GROUP BY aggregate over every matching document
|
||||
# before the query could even be sorted or limited.
|
||||
@@ -1109,40 +1159,43 @@ class DocumentViewSet(
|
||||
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
||||
# database to fully sort and dedupe every visible document before
|
||||
# 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)
|
||||
.order_by("-created", "-id")
|
||||
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
|
||||
.annotate(num_notes=Coalesce(note_count, 0))
|
||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||
.prefetch_related(
|
||||
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")),
|
||||
)
|
||||
.prefetch_related(*prefetches)
|
||||
)
|
||||
if self._needs_effective_content_annotation():
|
||||
queryset = annotate_effective_content(queryset)
|
||||
return queryset
|
||||
|
||||
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")
|
||||
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"])
|
||||
try:
|
||||
full_perms = get_boolean(
|
||||
@@ -2328,7 +2381,6 @@ class ChatStreamingView(GenericAPIView[Any]):
|
||||
serializer_class = ChatStreamingSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
request.compress_exempt = True
|
||||
ai_config = AIConfig()
|
||||
if not ai_config.ai_enabled:
|
||||
return HttpResponseBadRequest("AI is required for this feature")
|
||||
@@ -3468,7 +3520,7 @@ class PostDocumentView(GenericAPIView[Any]):
|
||||
),
|
||||
)
|
||||
class SelectionDataView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||
serializer_class = DocumentListSerializer
|
||||
parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
|
||||
|
||||
@@ -3959,7 +4011,7 @@ class StatisticsView(GenericAPIView[Any]):
|
||||
),
|
||||
)
|
||||
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||
serializer_class = BulkDownloadSerializer
|
||||
parser_classes = (parsers.JSONParser,)
|
||||
|
||||
@@ -4058,7 +4110,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Storag
|
||||
def get_permissions(self):
|
||||
if self.action == "test":
|
||||
# Test action does not require object level permissions
|
||||
self.permission_classes = (IsAuthenticated,)
|
||||
self.permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||
return super().get_permissions()
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
@@ -4625,6 +4677,12 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||
filterset_class = ShareLinkBundleFilterSet
|
||||
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):
|
||||
return (
|
||||
super()
|
||||
@@ -4967,12 +5025,12 @@ class BulkEditObjectsView(PassUserMixin):
|
||||
qs_owner_update.update(owner=owner)
|
||||
|
||||
if "permissions" in serializer.validated_data:
|
||||
for obj in qs:
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=obj,
|
||||
merge=merge,
|
||||
)
|
||||
set_permissions_for_objects(
|
||||
permissions=permissions,
|
||||
model=object_class,
|
||||
pks=qs.values_list("pk", flat=True),
|
||||
merge=merge,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -5443,7 +5501,7 @@ class SystemStatusView(PassUserMixin):
|
||||
|
||||
|
||||
class TrashView(ListModelMixin, PassUserMixin):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
permission_classes = (IsAuthenticated, TrashPermissions)
|
||||
serializer_class = TrashSerializer
|
||||
|
||||
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 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:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
@@ -10,7 +10,6 @@ from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from compression_middleware.middleware import CompressionMiddleware
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dotenv import load_dotenv
|
||||
@@ -201,22 +200,10 @@ MIDDLEWARE = [
|
||||
"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
|
||||
MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware")
|
||||
|
||||
# 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
|
||||
MIDDLEWARE.insert(0, "paperless.middleware.StreamAwareCompressionMiddleware")
|
||||
|
||||
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
|
||||
# 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"
|
||||
|
||||
|
||||
|
||||
@@ -854,6 +854,36 @@ class TestAPIProcessedMails(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -18,6 +18,7 @@ from rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
@@ -44,6 +45,15 @@ from paperless_mail.serialisers import ProcessedMailSerializer
|
||||
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(
|
||||
test=extend_schema(
|
||||
operation_id="mail_account_test",
|
||||
@@ -206,7 +216,11 @@ class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
|
||||
|
||||
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):
|
||||
mail_ids = request.data.get("mail_ids", [])
|
||||
if not isinstance(mail_ids, list) or not all(
|
||||
|
||||
Reference in New Issue
Block a user