Compare commits

...
28 changed files with 1013 additions and 191 deletions
+105 -73
View File
@@ -1345,7 +1345,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1808</context> <context context-type="linenumber">1865</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1577733187050997705" datatype="html"> <trans-unit id="1577733187050997705" datatype="html">
@@ -2423,7 +2423,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">673</context> <context context-type="linenumber">690</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
@@ -3330,11 +3330,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1422</context> <context context-type="linenumber">1479</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1809</context> <context context-type="linenumber">1866</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3935,7 +3935,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1375</context> <context context-type="linenumber">1432</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -4079,7 +4079,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1862</context> <context context-type="linenumber">1919</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6661109599266152398" datatype="html"> <trans-unit id="6661109599266152398" datatype="html">
@@ -4090,7 +4090,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1863</context> <context context-type="linenumber">1920</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5162686434580248853" datatype="html"> <trans-unit id="5162686434580248853" datatype="html">
@@ -4101,7 +4101,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1864</context> <context context-type="linenumber">1921</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6665634854532231106" datatype="html"> <trans-unit id="6665634854532231106" datatype="html">
@@ -6238,7 +6238,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1379</context> <context context-type="linenumber">1436</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8662,152 +8662,203 @@
<source>Error retrieving metadata</source> <source>Error retrieving metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">440</context> <context context-type="linenumber">442</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2218903673684131427" datatype="html"> <trans-unit id="2218903673684131427" datatype="html">
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source> <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">542,544</context> <context context-type="linenumber">545,547</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1000,1002</context> <context context-type="linenumber">1024,1026</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6357361810318120957" datatype="html"> <trans-unit id="6357361810318120957" datatype="html">
<source>Document was updated</source> <source>Document was updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">668</context> <context context-type="linenumber">685</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5154064822428631306" datatype="html"> <trans-unit id="5154064822428631306" datatype="html">
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source> <source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">669</context> <context context-type="linenumber">686</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8462497568316256794" datatype="html"> <trans-unit id="8462497568316256794" datatype="html">
<source>Reload to discard your local unsaved edits and load the latest remote version.</source> <source>Reload to discard your local unsaved edits and load the latest remote version.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">670</context> <context context-type="linenumber">687</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7967484035994732534" datatype="html"> <trans-unit id="7967484035994732534" datatype="html">
<source>Reload</source> <source>Reload</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">672</context> <context context-type="linenumber">689</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2907037627372942104" datatype="html"> <trans-unit id="2907037627372942104" datatype="html">
<source>Document reloaded with latest changes.</source> <source>Document reloaded with latest changes.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">728</context> <context context-type="linenumber">745</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6435639868943916539" datatype="html"> <trans-unit id="6435639868943916539" datatype="html">
<source>Document reloaded.</source> <source>Document reloaded.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">739</context> <context context-type="linenumber">756</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6142395741265832184" datatype="html"> <trans-unit id="6142395741265832184" datatype="html">
<source>Next document</source> <source>Next document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">841</context> <context context-type="linenumber">858</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="651985345816518480" datatype="html"> <trans-unit id="651985345816518480" datatype="html">
<source>Previous document</source> <source>Previous document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">851</context> <context context-type="linenumber">868</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2885986061416655600" datatype="html"> <trans-unit id="2885986061416655600" datatype="html">
<source>Close document</source> <source>Close document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">859</context> <context context-type="linenumber">876</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">143</context> <context context-type="linenumber">151</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8229691481345614469" datatype="html"> <trans-unit id="8229691481345614469" datatype="html">
<source>Save document</source> <source>Save document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">866</context> <context context-type="linenumber">883</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1784543155727940353" datatype="html"> <trans-unit id="1784543155727940353" datatype="html">
<source>Save and close / next</source> <source>Save and close / next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">875</context> <context context-type="linenumber">892</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7427704425579737895" datatype="html"> <trans-unit id="7427704425579737895" datatype="html">
<source>Error retrieving version content</source> <source>Error retrieving version content</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">983</context> <context context-type="linenumber">1006</context>
</context-group>
</trans-unit>
<trans-unit id="159901853873315050" datatype="html">
<source>Unsaved Changes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1050</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">143</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="5905048729613762185" datatype="html">
<source>You have unsaved changes to the content of this version.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1051</context>
</context-group>
</trans-unit>
<trans-unit id="85184271222513014" datatype="html">
<source>Switching versions will discard them.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1052</context>
</context-group>
</trans-unit>
<trans-unit id="2565707334844767610" datatype="html">
<source>Discard and switch</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1054</context>
</context-group>
</trans-unit>
<trans-unit id="2109314380040637387" datatype="html">
<source>Save and switch</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1056</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3456881259945295697" datatype="html"> <trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source> <source>Error retrieving suggestions.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1043</context> <context context-type="linenumber">1099</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2194092841814123758" datatype="html"> <trans-unit id="2194092841814123758" datatype="html">
<source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source> <source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1255</context> <context context-type="linenumber">1311</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1282</context> <context context-type="linenumber">1339</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6626387786259219838" datatype="html"> <trans-unit id="6626387786259219838" datatype="html">
<source>Error saving document &quot;<x id="PH" equiv-text="this.document().title"/>&quot;</source> <source>Error saving document &quot;<x id="PH" equiv-text="this.document().title"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1288</context> <context context-type="linenumber">1345</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="448882439049417053" datatype="html"> <trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source> <source>Error saving document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1343</context> <context context-type="linenumber">1400</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8410796510716511826" datatype="html"> <trans-unit id="8410796510716511826" datatype="html">
<source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document().title"/>&quot; to the trash?</source> <source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document().title"/>&quot; to the trash?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1376</context> <context context-type="linenumber">1433</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="282586936710748252" datatype="html"> <trans-unit id="282586936710748252" datatype="html">
<source>Documents can be restored prior to permanent deletion.</source> <source>Documents can be restored prior to permanent deletion.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1377</context> <context context-type="linenumber">1434</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8818,14 +8869,14 @@
<source>Error deleting document</source> <source>Error deleting document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1398</context> <context context-type="linenumber">1455</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="619486176823357521" datatype="html"> <trans-unit id="619486176823357521" datatype="html">
<source>Reprocess confirm</source> <source>Reprocess confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1418</context> <context context-type="linenumber">1475</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8836,102 +8887,102 @@
<source>This operation will permanently recreate the archive file for this document.</source> <source>This operation will permanently recreate the archive file for this document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1419</context> <context context-type="linenumber">1476</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="302054111564709516" datatype="html"> <trans-unit id="302054111564709516" datatype="html">
<source>The archive file will be re-generated with the current settings.</source> <source>The archive file will be re-generated with the current settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1420</context> <context context-type="linenumber">1477</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4700389117298802932" datatype="html"> <trans-unit id="4700389117298802932" datatype="html">
<source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source> <source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1433</context> <context context-type="linenumber">1490</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4409560272830824468" datatype="html"> <trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source> <source>Error executing operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1444</context> <context context-type="linenumber">1501</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6030453331794586802" datatype="html"> <trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source> <source>Error downloading document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1508</context> <context context-type="linenumber">1565</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4458954481601077369" datatype="html"> <trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source> <source>Page Fit</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1586</context> <context context-type="linenumber">1643</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4663705961777238777" datatype="html"> <trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source> <source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1829</context> <context context-type="linenumber">1886</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9043972994040261999" datatype="html"> <trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</source> <source>Error executing PDF edit operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1841</context> <context context-type="linenumber">1898</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6172690334763056188" datatype="html"> <trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source> <source>Please enter the current password before attempting to remove it.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1852</context> <context context-type="linenumber">1909</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="968660764814228922" datatype="html"> <trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source> <source>Password removal operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1886</context> <context context-type="linenumber">1943</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2282118435712883014" datatype="html"> <trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source> <source>Error executing password removal operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1900</context> <context context-type="linenumber">1957</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3740891324955700797" datatype="html"> <trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source> <source>Print failed.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1950</context> <context context-type="linenumber">2007</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6457245677384603573" datatype="html"> <trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source> <source>Error loading document for printing.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1959</context> <context context-type="linenumber">2016</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6085793215710522488" datatype="html"> <trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source> <source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2042</context> <context context-type="linenumber">2099</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2048</context> <context context-type="linenumber">2105</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">
@@ -12290,25 +12341,6 @@
<context context-type="linenumber">16</context> <context context-type="linenumber">16</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="159901853873315050" datatype="html">
<source>Unsaved Changes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">135</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">162</context>
</context-group>
</trans-unit>
<trans-unit id="2573823578527613511" datatype="html"> <trans-unit id="2573823578527613511" datatype="html">
<source>You have unsaved changes.</source> <source>You have unsaved changes.</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -12317,7 +12349,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">163</context> <context context-type="linenumber">171</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3305084982600522070" datatype="html"> <trans-unit id="3305084982600522070" datatype="html">
@@ -12457,28 +12489,28 @@
<source>You have unsaved changes to the document</source> <source>You have unsaved changes to the document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">137</context> <context context-type="linenumber">145</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2089045849587358256" datatype="html"> <trans-unit id="2089045849587358256" datatype="html">
<source>Are you sure you want to close this document?</source> <source>Are you sure you want to close this document?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">141</context> <context context-type="linenumber">149</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6755718693176327396" datatype="html"> <trans-unit id="6755718693176327396" datatype="html">
<source>Are you sure you want to close all documents?</source> <source>Are you sure you want to close all documents?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">164</context> <context context-type="linenumber">172</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4215561719980781894" datatype="html"> <trans-unit id="4215561719980781894" datatype="html">
<source>Close documents</source> <source>Close documents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">166</context> <context context-type="linenumber">174</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1206520795340730278" datatype="html"> <trans-unit id="1206520795340730278" datatype="html">
@@ -28,8 +28,9 @@ import { Subject, of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { CustomFieldDataType } from 'src/app/data/custom-field' import { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { DataType } from 'src/app/data/datatype' import { DataType } from 'src/app/data/datatype'
import { Document } from 'src/app/data/document' import { Document, DocumentVersionInfo } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { import {
FILTER_CORRESPONDENT, FILTER_CORRESPONDENT,
@@ -100,13 +101,18 @@ const doc: Document = {
custom_fields: [ custom_fields: [
{ {
field: 0, field: 0,
document: 3,
created: new Date(),
value: 'custom foo bar', value: 'custom foo bar',
}, },
], ] as CustomFieldInstance[],
} }
// Newest first, as the API returns them: 12 is the latest, 3 is the root
const docVersions: DocumentVersionInfo[] = [
{ id: 12, is_root: false },
{ id: 10, is_root: false },
{ id: doc.id, is_root: true },
]
const customFields = [ const customFields = [
{ {
id: 0, id: 0,
@@ -2045,6 +2051,208 @@ describe('DocumentDetailComponent', () => {
expect(saveSpy).toHaveBeenCalled() expect(saveSpy).toHaveBeenCalled()
}) })
it('selectVersion should use the version content as the baseline and ignore stale responses', () => {
initNormally()
const version10Content = new Subject<Document>()
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(version10Content)
.mockReturnValueOnce(of({ content: 'version 12 content' } as Document))
const version10Metadata = new Subject<any>()
jest
.spyOn(documentService, 'getMetadata')
.mockReturnValueOnce(version10Metadata)
.mockReturnValueOnce(of({ lang: 'de' }))
component.selectVersion(10)
component.selectVersion(12)
version10Content.next({ content: 'version 10 content' } as Document)
version10Metadata.next({ lang: 'en' })
expect(component.documentForm.get('content').value).toEqual(
'version 12 content'
)
expect(component.store.value.content).toEqual('version 12 content')
expect(component.metadata().lang).toEqual('de')
expect(
httpTestingController.expectOne(component.previewUrl()).cancelled
).toBeFalsy()
expect(
httpTestingController.match((req) => req.url.includes('version=10'))[0]
?.cancelled
).toBeTruthy()
})
it('should confirm before discarding unsaved content edits when switching versions', () => {
initNormally()
component.document().versions = docVersions
jest
.spyOn(documentService, 'get')
.mockImplementation((id, versionID) =>
of({ content: `version ${versionID} content` } as Document)
)
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
const modalSpy = jest.spyOn(modalService, 'open')
// shared fields carry over between versions, so no confirmation
component.documentForm.get('title').setValue('Edited title')
component.documentForm.get('title').markAsDirty()
component.documentForm.get('content').markAsDirty()
component.onVersionSelected(12)
expect(modalSpy).not.toHaveBeenCalled()
expect(component.selectedVersionId()).toEqual(12)
component.documentForm.get('content').setValue('edited content')
component.documentForm.get('content').markAsDirty()
component.onVersionSelected(12) // already selected, nothing to do
expect(modalSpy).not.toHaveBeenCalled()
component.onVersionSelected(10)
expect(modalSpy).toHaveBeenCalledWith(
ConfirmDialogComponent,
expect.anything()
)
openModal.componentInstance.cancel()
expect(component.selectedVersionId()).toEqual(12)
expect(component.documentForm.get('content').value).toEqual(
'edited content'
)
component.onVersionSelected(10)
openModal.componentInstance.confirmClicked.emit()
expect(component.selectedVersionId()).toEqual(10)
expect(component.documentForm.get('content').value).toEqual(
'version 10 content'
)
expect(component.documentForm.get('content').dirty).toBeFalsy()
expect(component.documentForm.get('title').value).toEqual('Edited title')
})
it('should save unsaved content edits to the current version before switching, and stay if that fails', () => {
initNormally()
component.document().versions = docVersions
component.selectedVersionId.set(12)
jest
.spyOn(documentService, 'get')
.mockReturnValue(of({ content: 'version 10 content' } as Document))
const savedDoc = new Subject<Document>()
const patchSpy = jest
.spyOn(documentService, 'patch')
.mockReturnValueOnce(throwError(() => new Error('failed to save')))
.mockReturnValueOnce(savedDoc)
const modalSpy = jest.spyOn(modalService, 'open')
component.documentForm.get('content').setValue('edited content')
component.documentForm.get('content').markAsDirty()
component.onVersionSelected(10)
let modal: NgbModalRef = modalSpy.mock.results[0].value
const closeSpy = jest.spyOn(modal, 'close')
modal.componentInstance.alternativeClicked.emit()
expect(closeSpy).toHaveBeenCalled()
expect(component.selectedVersionId()).toEqual(12)
expect(component.documentForm.get('content').value).toEqual(
'edited content'
)
component.onVersionSelected(10)
modal = modalSpy.mock.results[1].value
modal.componentInstance.alternativeClicked.emit()
expect(patchSpy).toHaveBeenLastCalledWith(
expect.objectContaining({ content: 'edited content' }),
12
)
component.onVersionSelected(doc.id) // ignored while saving
expect(modalSpy).toHaveBeenCalledTimes(2)
savedDoc.next(doc)
expect(component.selectedVersionId()).toEqual(10)
expect(component.documentForm.get('content').value).toEqual(
'version 10 content'
)
})
it('should switch without confirmation when the selected version was deleted, even while saving', () => {
initNormally()
component.document().versions = docVersions
component.selectedVersionId.set(10)
jest
.spyOn(documentService, 'get')
.mockReturnValue(of({ content: 'version 12 content' } as Document))
const modalSpy = jest.spyOn(modalService, 'open')
component.documentForm.get('content').setValue('edited content')
component.documentForm.get('content').markAsDirty()
component.networkActive.set(true)
// the version dropdown emits this after deleting the selected version
component.onVersionsUpdated(docVersions.filter((v) => v.id !== 10))
component.onVersionSelected(12)
expect(modalSpy).not.toHaveBeenCalled()
expect(component.selectedVersionId()).toEqual(12)
expect(component.documentForm.get('content').value).toEqual(
'version 12 content'
)
})
it('should restore the selected version and its unsaved content when returning to a document', () => {
initNormally()
const openDoc = component.document()
openDoc.versions = docVersions
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
jest
.spyOn(documentService, 'get')
.mockImplementation((id, versionID) =>
of(
(versionID
? { content: `version ${versionID} content` }
: { ...doc, versions: docVersions }) as Document
)
)
component.selectVersion(10)
// an edit that happens to match the latest version's content
component.documentForm.get('content').setValue(doc.content)
openDoc.__changedFields = ['content']
component['loadDocument'](doc.id)
expect(component.selectedVersionId()).toEqual(10)
expect(component.documentForm.get('content').value).toEqual(doc.content)
expect(openDocumentsService.isDirty(openDoc)).toBeTruthy()
const patchSpy = jest
.spyOn(documentService, 'patch')
.mockReturnValue(of(doc))
component.save()
expect(patchSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: doc.content }),
10
)
})
it('should fall back to the latest version when the remembered version no longer exists', () => {
initNormally()
const openDoc = component.document()
openDoc.versions = docVersions
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
jest.spyOn(documentService, 'get').mockImplementation((id, versionID) =>
of(
(versionID
? { content: `version ${versionID} content` }
: {
...doc,
content: 'version 12 content',
versions: docVersions.filter((v) => v.id !== 10),
}) as Document
)
)
component.selectVersion(10)
component['loadDocument'](doc.id)
expect(component.selectedVersionId()).toEqual(12)
expect(component.documentForm.get('content').value).toEqual(
'version 12 content'
)
})
it('createDisabled should return true if the user does not have permission to add the specified data type', () => { it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
currentUserCan = false currentUserCan = false
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy() expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
@@ -98,8 +98,8 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif' import * as UTIF from 'utif'
import { DocumentDetailFieldID } from '../admin/settings/settings.component' import { DocumentDetailFieldID } from '../admin/settings/settings.component'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@@ -304,6 +304,7 @@ export class DocumentDetailComponent
isDirty$: Observable<boolean> isDirty$: Observable<boolean>
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
docChangeNotifier: Subject<any> = new Subject() docChangeNotifier: Subject<any> = new Subject()
versionChangeNotifier: Subject<void> = new Subject()
private incomingUpdateModal: NgbModalRef private incomingUpdateModal: NgbModalRef
private pendingIncomingUpdate: IncomingDocumentUpdate private pendingIncomingUpdate: IncomingDocumentUpdate
private lastLocalSaveModified: string | null = null private lastLocalSaveModified: string | null = null
@@ -417,7 +418,8 @@ export class DocumentDetailComponent
.pipe( .pipe(
first(), first(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier) takeUntil(this.docChangeNotifier),
takeUntil(this.versionChangeNotifier)
) )
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
@@ -533,7 +535,8 @@ export class DocumentDetailComponent
.pipe( .pipe(
first(), first(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier) takeUntil(this.docChangeNotifier),
takeUntil(this.versionChangeNotifier)
) )
.subscribe({ .subscribe({
next: (res) => this.previewText.set(res.toString()), next: (res) => this.previewText.set(res.toString()),
@@ -595,6 +598,13 @@ export class DocumentDetailComponent
openDocument.duplicate_documents = doc.duplicate_documents openDocument.duplicate_documents = doc.duplicate_documents
this.openDocumentService.save() this.openDocumentService.save()
} }
// use server versions
if (openDocument) {
openDocument.versions = doc.versions
if (!openDocument.__changedFields?.includes('content')) {
openDocument.content = doc.content
}
}
let useDoc = openDocument || doc let useDoc = openDocument || doc
if (openDocument && forceRemote) { if (openDocument && forceRemote) {
Object.assign(openDocument, doc) Object.assign(openDocument, doc)
@@ -642,7 +652,14 @@ export class DocumentDetailComponent
this.documentForm.patchValue({ title: titleValue }) this.documentForm.patchValue({ title: titleValue })
this.documentForm.get('title').markAsDirty() this.documentForm.get('title').markAsDirty()
}) })
const keepContentEdits =
useDoc.__selectedVersionId === this.selectedVersionId() &&
!!useDoc.__changedFields?.includes('content')
this.setupDirtyTracking(useDoc, doc) this.setupDirtyTracking(useDoc, doc)
// Maybe load the stored version
if (useDoc.__selectedVersionId) {
this.selectVersion(this.selectedVersionId(), keepContentEdits)
}
}, },
}) })
} }
@@ -903,9 +920,11 @@ export class DocumentDetailComponent
updateComponent(doc: Document) { updateComponent(doc: Document) {
this.document.set(doc) this.document.set(doc)
// Default selected version is the newest version, which the API returns first // Load the selected version, or default to API first (newest)
const versions = doc.versions ?? [] const versions = doc.versions ?? []
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id) const selectedVersion =
versions.find((v) => v.id === doc.__selectedVersionId) ?? versions[0]
this.selectedVersionId.set(selectedVersion?.id ?? doc.id)
this.previewLoaded.set(false) this.previewLoaded.set(false)
this.requiresPassword = false this.requiresPassword = false
this.updateFormForCustomFields() this.updateFormForCustomFields()
@@ -940,8 +959,12 @@ export class DocumentDetailComponent
} }
// Update file preview and download target to a specific version (by document id) // Update file preview and download target to a specific version (by document id)
selectVersion(versionId: number) { selectVersion(versionId: number, keepContentEdits: boolean = false) {
this.versionChangeNotifier.next()
this.selectedVersionId.set(versionId) this.selectedVersionId.set(versionId)
// remember so the version can be restored when returning to the document
this.document().__selectedVersionId = versionId
this.openDocumentService.save()
this.previewLoaded.set(false) this.previewLoaded.set(false)
this.previewUrl.set( this.previewUrl.set(
this.documentsService.getPreviewUrl( this.documentsService.getPreviewUrl(
@@ -963,20 +986,20 @@ export class DocumentDetailComponent
.pipe( .pipe(
first(), first(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier) takeUntil(this.docChangeNotifier),
takeUntil(this.versionChangeNotifier)
) )
.subscribe({ .subscribe({
next: (doc) => { next: (doc) => {
const content = doc?.content ?? '' const content = doc?.content ?? ''
this.document().content = content if (keepContentEdits) {
this.documentForm.patchValue( this.store.next({ ...this.store.value, content })
{ } else {
content, // Update in-place and avoid the debounce wait
}, this.store.value.content = content
{ this.documentForm.patchValue({ content })
emitEvent: false, this.documentForm.get('content').markAsPristine()
} }
)
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
@@ -991,7 +1014,8 @@ export class DocumentDetailComponent
.pipe( .pipe(
first(), first(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier) takeUntil(this.docChangeNotifier),
takeUntil(this.versionChangeNotifier)
) )
.subscribe({ .subscribe({
next: (res) => this.previewText.set(res.toString()), next: (res) => this.previewText.set(res.toString()),
@@ -1005,7 +1029,39 @@ export class DocumentDetailComponent
} }
onVersionSelected(versionId: number) { onVersionSelected(versionId: number) {
this.selectVersion(versionId) if (versionId === this.selectedVersionId()) return
// Bail if the selected version was just deleted.
const selectedVersionExists = this.document()?.versions?.some(
(v) => v.id === this.selectedVersionId()
)
if (this.networkActive() && selectedVersionExists) return
if (
!selectedVersionExists ||
this.documentForm.get('content').value === this.store.value.content
) {
this.selectVersion(versionId)
return
}
// Confirm any unsaved content changes
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Unsaved Changes`
modal.componentInstance.messageBold = $localize`You have unsaved changes to the content of this version.`
modal.componentInstance.message = $localize`Switching versions will discard them.`
modal.componentInstance.btnClass = 'btn-secondary'
modal.componentInstance.btnCaption = $localize`Discard and switch`
modal.componentInstance.alternativeBtnClass = 'btn-primary'
modal.componentInstance.alternativeBtnCaption = $localize`Save and switch`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.close()
this.selectVersion(versionId)
})
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
modal.close()
this.save(false, () => this.selectVersion(versionId))
})
} }
onVersionsUpdated(versions: DocumentVersionInfo[]) { onVersionsUpdated(versions: DocumentVersionInfo[]) {
@@ -1233,7 +1289,7 @@ export class DocumentDetailComponent
return changes return changes
} }
save(close: boolean = false) { save(close: boolean = false, savedCallback: () => void = null) {
this.networkActive.set(true) this.networkActive.set(true)
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change')) ;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
this.documentsService this.documentsService
@@ -1266,6 +1322,7 @@ export class DocumentDetailComponent
this.flushPendingIncomingUpdate() this.flushPendingIncomingUpdate()
} }
this.savedViewService.maybeRefreshDocumentCounts() this.savedViewService.maybeRefreshDocumentCounts()
savedCallback?.()
}, },
error: (error) => { error: (error) => {
this.networkActive.set(false) this.networkActive.set(false)
+1
View File
@@ -167,6 +167,7 @@ export interface Document extends ObjectWithPermissions {
// Frontend only // Frontend only
__changedFields?: string[] __changedFields?: string[]
__selectedVersionId?: number
} }
export interface DocumentVersionInfo { export interface DocumentVersionInfo {
@@ -221,6 +221,25 @@ describe('OpenDocumentsService', () => {
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1) expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
}) })
it('should refresh documents in place and keep unsaved edits', () => {
const openDoc = { ...documents[0] }
subscriptions.push(openDocumentsService.openDocument(openDoc).subscribe())
openDoc.title = 'Unsaved title'
openDocumentsService.setDirty(openDoc, true, { title: openDoc.title })
openDocumentsService.refreshDocument(openDoc.id)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${openDoc.id}/?full_perms=true`
)
.flush({ ...documents[0], tags: [4] })
const refreshed = openDocumentsService.getOpenDocument(openDoc.id)
expect(refreshed).toBe(openDoc)
expect(refreshed.title).toEqual('Unsaved title')
expect(refreshed.tags).toEqual([4])
})
it('should handle error on refresh documents', () => { it('should handle error on refresh documents', () => {
subscriptions.push( subscriptions.push(
openDocumentsService.openDocument(documents[1]).subscribe() openDocumentsService.openDocument(documents[1]).subscribe()
@@ -50,7 +50,15 @@ export class OpenDocumentsService {
if (index > -1) { if (index > -1) {
this.documentService.get(id).subscribe({ this.documentService.get(id).subscribe({
next: (doc) => { next: (doc) => {
this.openDocuments[index] = doc const openDoc = this.openDocuments.find((d) => d.id == id)
if (!openDoc) return
const unsavedEdits = Object.fromEntries(
(openDoc.__changedFields ?? []).map((field) => [
field,
openDoc[field],
])
)
Object.assign(openDoc, doc, unsavedEdits)
this.save() this.save()
}, },
error: () => { error: () => {
+16 -7
View File
@@ -899,17 +899,26 @@ def edit_pdf(
pdf_docs: list[pikepdf.Pdf] = [] pdf_docs: list[pikepdf.Pdf] = []
try: 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: with pikepdf.open(pair.source_doc.source_path) as src:
# prepare output documents # prepare output documents
max_idx = max(op.get("doc", 0) for op in operations)
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)] 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: for op in operations:
dst = pdf_docs[op.get("doc", 0)] dst = pdf_docs[op.get("doc", 0)]
page = src.pages[op["page"] - 1] page = src.pages[op["page"] - 1]
+29 -4
View File
@@ -657,16 +657,41 @@ class ViewDocumentsPermissions(BasePermission):
return request.user.has_perms(self.perms_map.get(request.method, [])) return request.user.has_perms(self.perms_map.get(request.method, []))
class TrashPermissions(BasePermission):
"""Check the global document permission for each trash operation."""
perms_map = {
"OPTIONS": ["documents.view_document"],
"HEAD": ["documents.view_document"],
"GET": ["documents.view_document"],
"POST": ["documents.delete_document"],
}
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated: # pragma: no cover
return False
return request.user.has_perms(self.perms_map.get(request.method, []))
class PaperlessNotePermissions(BasePermission): class PaperlessNotePermissions(BasePermission):
""" """
Permissions class that checks for model permissions for Notes. Permissions class that checks for model permissions for Notes.
""" """
perms_map = { perms_map = {
"OPTIONS": ["documents.view_note"], "OPTIONS": ["documents.view_note", "documents.view_document"],
"GET": ["documents.view_note"], "GET": ["documents.view_note", "documents.view_document"],
"POST": ["documents.add_note"], "POST": [
"DELETE": ["documents.delete_note"], "documents.add_note",
"documents.view_document",
"documents.change_document",
],
"DELETE": [
"documents.delete_note",
"documents.view_document",
"documents.change_document",
],
} }
def has_permission(self, request, view): def has_permission(self, request, view):
+23 -4
View File
@@ -1335,6 +1335,7 @@ class DocumentSerializer(
"root_document", "root_document",
"versions", "versions",
) )
read_only_fields = ("deleted_at",)
list_serializer_class = OwnedObjectListSerializer list_serializer_class = OwnedObjectListSerializer
@@ -1787,6 +1788,12 @@ class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMix
"update_document only allowed with a single output document", "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]) doc = Document.objects.get(id=documents[0])
if doc.page_count: if doc.page_count:
for op in operations: for op in operations:
@@ -2150,6 +2157,12 @@ class BulkEditSerializer(
"update_document only allowed with a single output document", "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 = Document.objects.get(id=document_id)
# doc existence is already validated # doc existence is already validated
if doc.page_count: if doc.page_count:
@@ -2839,10 +2852,14 @@ class ShareLinkSerializer(OwnedObjectSerializer):
return super().create(validated_data) return super().create(validated_data)
def validate_document(self, document): def validate_document(self, document):
if self.user is not None and has_perms_owner_aware( if (
self.user, self.user is not None
"view_document", and self.user.has_perm("documents.view_document")
document, and has_perms_owner_aware(
self.user,
"view_document",
document,
)
): ):
return document return document
raise PermissionDenied( raise PermissionDenied(
@@ -3603,6 +3620,8 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
if "actions" in validated_data: if "actions" in validated_data:
actions = validated_data.pop("actions") actions = validated_data.pop("actions")
for action in actions:
action.pop("id", None)
instance = super().create(validated_data) instance = super().create(validated_data)
@@ -4,6 +4,7 @@ import json
import shutil import shutil
import zipfile import zipfile
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings from django.test import override_settings
from django.utils import timezone from django.utils import timezone
@@ -326,6 +327,9 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
def test_download_insufficient_permissions(self) -> None: def test_download_insufficient_permissions(self) -> None:
user = User.objects.create_user(username="temp_user") user = User.objects.create_user(username="temp_user")
user.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_authenticate(user=user) self.client.force_authenticate(user=user)
self.doc2.owner = self.user self.doc2.owner = self.user
+47 -1
View File
@@ -1084,6 +1084,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
user1 = User.objects.create(username="user1") user1 = User.objects.create(username="user1")
self.client.force_authenticate(user=user1) self.client.force_authenticate(user=user1)
assign_perm("view_document", user1, self.doc2)
response = self.client.post( response = self.client.post(
"/api/documents/selection_data/", "/api/documents/selection_data/",
json.dumps({"documents": [self.doc2.id]}), json.dumps({"documents": [self.doc2.id]}),
@@ -1091,7 +1093,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions")
user1.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
user1 = User.objects.get(pk=user1.pk)
self.client.force_authenticate(user=user1)
response = self.client.post(
"/api/documents/selection_data/",
json.dumps({"documents": [self.doc2.id]}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@mock.patch("documents.serialisers.bulk_edit.set_permissions") @mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions(self, m) -> None: def test_set_permissions(self, m) -> None:
@@ -1636,6 +1649,24 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 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") @mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf(self, m) -> None: def test_edit_pdf(self, m) -> None:
self.setup_mock(m, "edit_pdf") 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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"doc must be an integer", response.content) 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( response = self.client.post(
"/api/documents/edit_pdf/", "/api/documents/edit_pdf/",
json.dumps( json.dumps(
+36
View File
@@ -38,6 +38,42 @@ class TestChatStreamingViewInputValidation(APITestCase):
) )
assert resp.status_code == status.HTTP_400_BAD_REQUEST assert resp.status_code == status.HTTP_400_BAD_REQUEST
def test_answer_is_not_compressed(self) -> None:
"""
GIVEN:
- A client that accepts compressed responses
WHEN:
- It asks the chat endpoint a question
THEN:
- The answer is streamed unencoded, chunk for chunk
The stream compressors buffer, so a compressed answer arrives in one
piece. The view cannot opt out by flagging the request: DRF's request
wrapper proxies reads but keeps writes to itself, so the flag never
reaches the Django request the middleware sees.
"""
chunks = [f"token{i} " for i in range(40)]
with (
mock.patch(
"documents.views.AIConfig",
return_value=self._mock_ai_enabled(),
),
mock.patch(
"documents.views.stream_chat_with_documents",
return_value=iter(chunks),
),
):
resp = self.client.post(
"/api/documents/chat/",
{"q": "What is in my archive?"},
format="json",
HTTP_ACCEPT_ENCODING="gzip, deflate, br, zstd",
)
assert resp.status_code == status.HTTP_200_OK
assert not resp.has_header("Content-Encoding")
assert list(resp.streaming_content) == [c.encode() for c in chunks]
def test_missing_question_is_rejected(self) -> None: def test_missing_question_is_rejected(self) -> None:
with mock.patch( with mock.patch(
"documents.views.AIConfig", "documents.views.AIConfig",
+64
View File
@@ -3615,6 +3615,55 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.assertEqual(response.content, b"Insufficient permissions to delete notes") self.assertEqual(response.content, b"Insufficient permissions to delete notes")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_notes_require_global_document_permissions(self) -> None:
user = User.objects.create_user(username="note_editor")
user.user_permissions.add(
*Permission.objects.filter(
codename__in=["view_note", "add_note", "delete_note"],
),
)
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="notes",
owner=user,
)
note = Note.objects.create(note="Existing", document=doc, user=user)
self.client.force_authenticate(user)
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
user.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
user = User.objects.get(pk=user.pk)
self.client.force_authenticate(user)
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.post(
f"/api/documents/{doc.pk}/notes/",
data={"note": "New"},
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
user.user_permissions.add(
Permission.objects.get(codename="change_document"),
)
user = User.objects.get(pk=user.pk)
self.client.force_authenticate(user)
response = self.client.post(
f"/api/documents/{doc.pk}/notes/",
data={"note": "New"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.delete(
f"/api/documents/{doc.pk}/notes/?id={note.pk}",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_delete_note(self) -> None: def test_delete_note(self) -> None:
""" """
GIVEN: GIVEN:
@@ -3981,6 +4030,21 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
assign_perm("view_document", user1, doc) assign_perm("view_document", user1, doc)
create_resp = self.client.post(
"/api/share_links/",
data={
"document": doc.pk,
"file_version": "original",
},
format="json",
)
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
user1.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
user1 = User.objects.get(pk=user1.pk)
self.client.force_authenticate(user1)
create_resp = self.client.post( create_resp = self.client.post(
"/api/share_links/", "/api/share_links/",
data={ data={
+32
View File
@@ -457,6 +457,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
def test_test_storage_path_requires_document_view_permission(self) -> None: def test_test_storage_path_requires_document_view_permission(self) -> None:
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
unprivileged = User.objects.create_user(username="unprivileged") unprivileged = User.objects.create_user(username="unprivileged")
unprivileged.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
document = Document.objects.create( document = Document.objects.create(
mime_type="application/pdf", mime_type="application/pdf",
owner=owner, owner=owner,
@@ -488,6 +491,23 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
) )
assign_perm("view_document", viewer, document) assign_perm("view_document", viewer, document)
self.client.force_authenticate(user=viewer)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": "path/{{ title }}",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
viewer.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
viewer = User.objects.get(pk=viewer.pk)
self.client.force_authenticate(user=viewer) self.client.force_authenticate(user=viewer)
response = self.client.post( response = self.client.post(
f"{self.ENDPOINT}test/", f"{self.ENDPOINT}test/",
@@ -530,6 +550,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
password="password", password="password",
email="owner@example.com", email="owner@example.com",
) )
owner.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
document = Document.objects.create( document = Document.objects.create(
mime_type="application/pdf", mime_type="application/pdf",
owner=owner, owner=owner,
@@ -605,6 +628,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
checksum="123", checksum="123",
) )
assign_perm("view_document", viewer, document) assign_perm("view_document", viewer, document)
viewer.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_authenticate(user=viewer) self.client.force_authenticate(user=viewer)
response = self.client.post( response = self.client.post(
@@ -692,6 +718,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
) )
document.tags.add(private_tag) document.tags.add(private_tag)
assign_perm("view_document", viewer, document) assign_perm("view_document", viewer, document)
viewer.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_authenticate(user=viewer) self.client.force_authenticate(user=viewer)
response = self.client.post( response = self.client.post(
@@ -745,6 +774,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
value_int=42, value_int=42,
) )
assign_perm("view_document", viewer, document) assign_perm("view_document", viewer, document)
viewer.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_authenticate(user=viewer) self.client.force_authenticate(user=viewer)
response = self.client.post( response = self.client.post(
+10
View File
@@ -69,6 +69,16 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(Document.global_objects.count(), 0) self.assertEqual(Document.global_objects.count(), 0)
def test_trash_list_requires_global_document_view_permission(self) -> None:
user = User.objects.create_user(username="trash_owner")
document = Document.objects.create(title="Owned", owner=user)
document.delete()
self.client.force_authenticate(user)
response = self.client.get("/api/trash/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_trash_api_empty_all(self) -> None: def test_trash_api_empty_all(self) -> None:
""" """
GIVEN: GIVEN:
+42
View File
@@ -194,6 +194,48 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Workflow.objects.count(), 2) self.assertEqual(Workflow.objects.count(), 2)
def test_api_create_workflow_ignores_nested_action_id(self) -> None:
"""
GIVEN:
- An existing workflow action
WHEN:
- API request to create a workflow includes that action's ID
THEN:
- A new action is created without changing the existing action
"""
original_title = self.action.assign_title
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow 2",
"order": 1,
"triggers": [
{
"sources": [DocumentSource.ApiUpload],
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"filter_filename": "*",
},
],
"actions": [
{
"id": self.action.id,
"assign_title": "New Action Title",
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.action.refresh_from_db()
self.assertEqual(self.action.assign_title, original_title)
new_action = Workflow.objects.get(name="Workflow 2").actions.get()
self.assertNotEqual(new_action.id, self.action.id)
self.assertEqual(new_action.assign_title, "New Action Title")
def test_api_create_workflow_nested(self) -> None: def test_api_create_workflow_nested(self) -> None:
""" """
GIVEN: GIVEN:
+11
View File
@@ -1642,6 +1642,17 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_group.assert_not_called() mock_group.assert_not_called()
mock_consume_file.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.bulk_edit.update_document_content_maybe_archive_file.delay")
@mock.patch("documents.tasks.consume_file.apply_async") @mock.patch("documents.tasks.consume_file.apply_async")
@mock.patch("documents.bulk_edit.tempfile.mkdtemp") @mock.patch("documents.bulk_edit.tempfile.mkdtemp")
@@ -309,6 +309,9 @@ class TestEmailDocumentPermissionBoundary:
): ):
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester") requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
hidden = DocumentFactory(owner=owner) hidden = DocumentFactory(owner=owner)
@@ -364,6 +367,27 @@ class TestBulkEditChangePermissionBoundary:
@pytest.mark.django_db @pytest.mark.django_db
class TestBulkDownloadPermissionChecksRootDocument: class TestBulkDownloadPermissionChecksRootDocument:
def test_download_requires_global_view_permission(
self,
rest_api_client,
paperless_dirs,
_media_settings,
):
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
root = DocumentFactory(owner=owner)
root.source_path.write_bytes(b"%PDF-1.4 test")
assign_perm("view_document", requester, root)
rest_api_client.force_authenticate(user=requester)
response = rest_api_client.post(
"/api/documents/bulk_download/",
{"documents": [root.pk]},
format="json",
)
assert response.status_code == HTTPStatus.FORBIDDEN
def test_permission_checked_on_root_not_on_version( def test_permission_checked_on_root_not_on_version(
self, self,
rest_api_client, rest_api_client,
@@ -372,6 +396,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
): ):
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester") requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
root = DocumentFactory(owner=owner) root = DocumentFactory(owner=owner)
# a version of root that the requester has NOT been individually granted # a version of root that the requester has NOT been individually granted
@@ -396,6 +423,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
# `stranger` case) can't tell the two apart, since they're denied # `stranger` case) can't tell the two apart, since they're denied
# either way. # either way.
version_only_grantee = User.objects.create_user(username="version_only_grantee") version_only_grantee = User.objects.create_user(username="version_only_grantee")
version_only_grantee.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
assign_perm("view_document", version_only_grantee, version) assign_perm("view_document", version_only_grantee, version)
rest_api_client.force_authenticate(user=version_only_grantee) rest_api_client.force_authenticate(user=version_only_grantee)
response = rest_api_client.post( response = rest_api_client.post(
@@ -417,6 +447,9 @@ class TestTrashRestorePermissionBoundary:
): ):
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester") requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
Permission.objects.get(codename="delete_document"),
)
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
doc = DocumentFactory(owner=owner) doc = DocumentFactory(owner=owner)
assign_perm("view_document", requester, doc) # view only, NOT delete assign_perm("view_document", requester, doc) # view only, NOT delete
@@ -435,6 +468,9 @@ class TestTrashRestorePermissionBoundary:
): ):
owner = User.objects.create_user(username="owner") owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester") requester = User.objects.create_user(username="requester")
requester.user_permissions.add(
Permission.objects.get(codename="delete_document"),
)
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
doc = DocumentFactory(owner=owner) doc = DocumentFactory(owner=owner)
assign_perm("delete_document", requester, doc) assign_perm("delete_document", requester, doc)
@@ -447,6 +483,22 @@ class TestTrashRestorePermissionBoundary:
) )
assert response.status_code == HTTPStatus.OK assert response.status_code == HTTPStatus.OK
def test_restore_requires_global_delete_permission(self, rest_api_client):
owner = User.objects.create_user(username="owner")
requester = User.objects.create_user(username="requester")
rest_api_client.force_authenticate(user=requester)
doc = DocumentFactory(owner=owner)
assign_perm("delete_document", requester, doc)
doc.delete()
response = rest_api_client.post(
"/api/trash/",
{"documents": [doc.pk], "action": "restore"},
format="json",
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.django_db @pytest.mark.django_db
class TestTrashViewExcludesExplicitlyGrantedDocuments: class TestTrashViewExcludesExplicitlyGrantedDocuments:
@@ -463,6 +515,9 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client): def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
owner = User.objects.create_user(username="trash_owner") owner = User.objects.create_user(username="trash_owner")
grantee = User.objects.create_user(username="trash_grantee") grantee = User.objects.create_user(username="trash_grantee")
grantee.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
doc = DocumentFactory(owner=owner) doc = DocumentFactory(owner=owner)
doc.delete() # soft delete doc.delete() # soft delete
assign_perm("view_document", grantee, doc) assign_perm("view_document", grantee, doc)
@@ -6,8 +6,10 @@ from pathlib import Path
from unittest import mock from unittest import mock
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone from django.utils import timezone
from guardian.shortcuts import assign_perm
from rest_framework import serializers from rest_framework import serializers
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -48,6 +50,37 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
delay_mock.assert_called_once() delay_mock.assert_called_once()
self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk) self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk)
@mock.patch("documents.views.build_share_link_bundle.apply_async")
def test_create_bundle_requires_global_document_view_permission(
self,
delay_mock,
) -> None:
owner = User.objects.create_user(username="document_owner")
requester = User.objects.create_user(username="bundle_creator")
requester.user_permissions.add(
Permission.objects.get(codename="add_sharelinkbundle"),
)
document = DocumentFactory.create(owner=owner)
assign_perm("view_document", requester, document)
self.client.force_authenticate(requester)
payload = {
"document_ids": [document.pk],
"file_version": ShareLink.FileVersion.ARCHIVE,
"expiration_days": 7,
}
response = self.client.post(self.ENDPOINT, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
requester.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
requester = User.objects.get(pk=requester.pk)
self.client.force_authenticate(requester)
response = self.client.post(self.ENDPOINT, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
delay_mock.assert_called_once()
def test_create_bundle_rejects_missing_documents(self) -> None: def test_create_bundle_rejects_missing_documents(self) -> None:
payload = { payload = {
"document_ids": [9999], "document_ids": [9999],
+6
View File
@@ -141,6 +141,9 @@ class TestViews(DirectoriesMixin, TestCase):
codename__contains="sharelink", codename__contains="sharelink",
) )
self.user.user_permissions.add(*sharelink_permissions) self.user.user_permissions.add(*sharelink_permissions)
self.user.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.user.save() self.user.save()
self.client.force_login(self.user) self.client.force_login(self.user)
@@ -202,6 +205,9 @@ class TestViews(DirectoriesMixin, TestCase):
codename__contains="sharelink", codename__contains="sharelink",
) )
self.user.user_permissions.add(*sharelink_permissions) self.user.user_permissions.add(*sharelink_permissions)
self.user.user_permissions.add(
Permission.objects.get(codename="view_document"),
)
self.client.force_login(self.user) self.client.force_login(self.user)
create_response = self.client.post( create_response = self.client.post(
+11 -5
View File
@@ -170,6 +170,7 @@ from documents.permissions import AcknowledgeTasksPermissions
from documents.permissions import PaperlessAdminPermissions from documents.permissions import PaperlessAdminPermissions
from documents.permissions import PaperlessNotePermissions from documents.permissions import PaperlessNotePermissions
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
from documents.permissions import TrashPermissions
from documents.permissions import ViewDocumentsPermissions from documents.permissions import ViewDocumentsPermissions
from documents.permissions import annotate_document_count_by_ids from documents.permissions import annotate_document_count_by_ids
from documents.permissions import annotate_document_count_for_related_queryset from documents.permissions import annotate_document_count_for_related_queryset
@@ -2380,7 +2381,6 @@ class ChatStreamingView(GenericAPIView[Any]):
serializer_class = ChatStreamingSerializer serializer_class = ChatStreamingSerializer
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
request.compress_exempt = True
ai_config = AIConfig() ai_config = AIConfig()
if not ai_config.ai_enabled: if not ai_config.ai_enabled:
return HttpResponseBadRequest("AI is required for this feature") return HttpResponseBadRequest("AI is required for this feature")
@@ -3520,7 +3520,7 @@ class PostDocumentView(GenericAPIView[Any]):
), ),
) )
class SelectionDataView(GenericAPIView[Any]): class SelectionDataView(GenericAPIView[Any]):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
serializer_class = DocumentListSerializer serializer_class = DocumentListSerializer
parser_classes = (parsers.MultiPartParser, parsers.JSONParser) parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
@@ -4011,7 +4011,7 @@ class StatisticsView(GenericAPIView[Any]):
), ),
) )
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]): class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
serializer_class = BulkDownloadSerializer serializer_class = BulkDownloadSerializer
parser_classes = (parsers.JSONParser,) parser_classes = (parsers.JSONParser,)
@@ -4110,7 +4110,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Storag
def get_permissions(self): def get_permissions(self):
if self.action == "test": if self.action == "test":
# Test action does not require object level permissions # Test action does not require object level permissions
self.permission_classes = (IsAuthenticated,) self.permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
return super().get_permissions() return super().get_permissions()
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
@@ -4677,6 +4677,12 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
filterset_class = ShareLinkBundleFilterSet filterset_class = ShareLinkBundleFilterSet
ordering_fields = ("created", "expiration", "status") ordering_fields = ("created", "expiration", "status")
def get_permissions(self):
permissions = super().get_permissions()
if self.action == "create":
permissions.append(ViewDocumentsPermissions())
return permissions
def get_queryset(self): def get_queryset(self):
return ( return (
super() super()
@@ -5495,7 +5501,7 @@ class SystemStatusView(PassUserMixin):
class TrashView(ListModelMixin, PassUserMixin): class TrashView(ListModelMixin, PassUserMixin):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated, TrashPermissions)
serializer_class = TrashSerializer serializer_class = TrashSerializer
class _TrashPermittedObjectsFilter(PermittedObjectsFilter): class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
+55 -55
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-10 19:39+0000\n" "POT-Creation-Date: 2026-09-12 23:18+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@@ -1632,7 +1632,7 @@ msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:525 documents/serialisers.py:882 #: documents/serialisers.py:525 documents/serialisers.py:882
#: documents/serialisers.py:2849 documents/views.py:318 documents/views.py:2694 #: documents/serialisers.py:2854 documents/views.py:319 documents/views.py:2694
#: paperless_mail/serialisers.py:156 #: paperless_mail/serialisers.py:156
msgid "Insufficient permissions." msgid "Insufficient permissions."
msgstr "" msgstr ""
@@ -1641,39 +1641,39 @@ msgstr ""
msgid "Invalid color." msgid "Invalid color."
msgstr "" msgstr ""
#: documents/serialisers.py:2326 #: documents/serialisers.py:2327
#, python-format #, python-format
msgid "File type %(type)s not supported" msgid "File type %(type)s not supported"
msgstr "" msgstr ""
#: documents/serialisers.py:2370 #: documents/serialisers.py:2371
#, python-format #, python-format
msgid "Custom field id must be an integer: %(id)s" msgid "Custom field id must be an integer: %(id)s"
msgstr "" msgstr ""
#: documents/serialisers.py:2377 #: documents/serialisers.py:2378
#, python-format #, python-format
msgid "Custom field with id %(id)s does not exist" msgid "Custom field with id %(id)s does not exist"
msgstr "" msgstr ""
#: documents/serialisers.py:2394 documents/serialisers.py:2404 #: documents/serialisers.py:2395 documents/serialisers.py:2405
msgid "" msgid ""
"Custom fields must be a list of integers or an object mapping ids to values." "Custom fields must be a list of integers or an object mapping ids to values."
msgstr "" msgstr ""
#: documents/serialisers.py:2399 #: documents/serialisers.py:2400
msgid "Some custom fields don't exist or were specified twice." msgid "Some custom fields don't exist or were specified twice."
msgstr "" msgstr ""
#: documents/serialisers.py:2546 #: documents/serialisers.py:2547
msgid "Invalid variable detected." msgid "Invalid variable detected."
msgstr "" msgstr ""
#: documents/serialisers.py:2905 #: documents/serialisers.py:2910
msgid "Duplicate document identifiers are not allowed." msgid "Duplicate document identifiers are not allowed."
msgstr "" msgstr ""
#: documents/serialisers.py:2935 documents/views.py:4701 #: documents/serialisers.py:2940 documents/views.py:4707
#, python-format #, python-format
msgid "Documents not found: %(ids)s" msgid "Documents not found: %(ids)s"
msgstr "" msgstr ""
@@ -1941,19 +1941,19 @@ msgstr ""
msgid "Unable to parse URI {value}" msgid "Unable to parse URI {value}"
msgstr "" msgstr ""
#: documents/views.py:311 documents/views.py:2691 #: documents/views.py:312 documents/views.py:2691
msgid "Invalid more_like_id" msgid "Invalid more_like_id"
msgstr "" msgstr ""
#: documents/views.py:1645 #: documents/views.py:1646
msgid "Invalid AI configuration." msgid "Invalid AI configuration."
msgstr "" msgstr ""
#: documents/views.py:1656 #: documents/views.py:1657
msgid "AI backend request timed out." msgid "AI backend request timed out."
msgstr "" msgstr ""
#: documents/views.py:1668 #: documents/views.py:1669
msgid "AI backend rejected the request. Check logs for details." msgid "AI backend rejected the request. Check logs for details."
msgstr "" msgstr ""
@@ -1961,20 +1961,20 @@ msgstr ""
msgid "Specify only one of text, title_search, query, or more_like_id." msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "" msgstr ""
#: documents/views.py:4714 #: documents/views.py:4720
#, python-format #, python-format
msgid "Insufficient permissions to share document %(id)s." msgid "Insufficient permissions to share document %(id)s."
msgstr "" msgstr ""
#: documents/views.py:4760 #: documents/views.py:4766
msgid "Bundle is already being processed." msgid "Bundle is already being processed."
msgstr "" msgstr ""
#: documents/views.py:4824 #: documents/views.py:4830
msgid "The share link bundle is still being prepared. Please try again later." msgid "The share link bundle is still being prepared. Please try again later."
msgstr "" msgstr ""
#: documents/views.py:4838 #: documents/views.py:4844
msgid "The share link bundle is unavailable." msgid "The share link bundle is unavailable."
msgstr "" msgstr ""
@@ -2258,151 +2258,151 @@ msgstr ""
msgid "paperless application settings" msgid "paperless application settings"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:569 #: paperless/settings/__init__.py:556
msgid "English (US)" msgid "English (US)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:570 #: paperless/settings/__init__.py:557
msgid "Arabic" msgid "Arabic"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:571 #: paperless/settings/__init__.py:558
msgid "Afrikaans" msgid "Afrikaans"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:572 #: paperless/settings/__init__.py:559
msgid "Belarusian" msgid "Belarusian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:573 #: paperless/settings/__init__.py:560
msgid "Bulgarian" msgid "Bulgarian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:574 #: paperless/settings/__init__.py:561
msgid "Catalan" msgid "Catalan"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:575 #: paperless/settings/__init__.py:562
msgid "Czech" msgid "Czech"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:576 #: paperless/settings/__init__.py:563
msgid "Danish" msgid "Danish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:577 #: paperless/settings/__init__.py:564
msgid "German" msgid "German"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:578 #: paperless/settings/__init__.py:565
msgid "Greek" msgid "Greek"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:579 #: paperless/settings/__init__.py:566
msgid "English (GB)" msgid "English (GB)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:580 #: paperless/settings/__init__.py:567
msgid "Spanish" msgid "Spanish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:581 #: paperless/settings/__init__.py:568
msgid "Persian" msgid "Persian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:582 #: paperless/settings/__init__.py:569
msgid "Finnish" msgid "Finnish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:583 #: paperless/settings/__init__.py:570
msgid "French" msgid "French"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:584 #: paperless/settings/__init__.py:571
msgid "Hungarian" msgid "Hungarian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:585 #: paperless/settings/__init__.py:572
msgid "Indonesian" msgid "Indonesian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:586 #: paperless/settings/__init__.py:573
msgid "Italian" msgid "Italian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:587 #: paperless/settings/__init__.py:574
msgid "Japanese" msgid "Japanese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:588 #: paperless/settings/__init__.py:575
msgid "Korean" msgid "Korean"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:589 #: paperless/settings/__init__.py:576
msgid "Luxembourgish" msgid "Luxembourgish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:590 #: paperless/settings/__init__.py:577
msgid "Norwegian" msgid "Norwegian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:591 #: paperless/settings/__init__.py:578
msgid "Dutch" msgid "Dutch"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:592 #: paperless/settings/__init__.py:579
msgid "Polish" msgid "Polish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:593 #: paperless/settings/__init__.py:580
msgid "Portuguese (Brazil)" msgid "Portuguese (Brazil)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:594 #: paperless/settings/__init__.py:581
msgid "Portuguese" msgid "Portuguese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:595 #: paperless/settings/__init__.py:582
msgid "Romanian" msgid "Romanian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:596 #: paperless/settings/__init__.py:583
msgid "Russian" msgid "Russian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:597 #: paperless/settings/__init__.py:584
msgid "Slovak" msgid "Slovak"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:598 #: paperless/settings/__init__.py:585
msgid "Slovenian" msgid "Slovenian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:599 #: paperless/settings/__init__.py:586
msgid "Serbian" msgid "Serbian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:600 #: paperless/settings/__init__.py:587
msgid "Swedish" msgid "Swedish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:601 #: paperless/settings/__init__.py:588
msgid "Turkish" msgid "Turkish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:602 #: paperless/settings/__init__.py:589
msgid "Ukrainian" msgid "Ukrainian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:603 #: paperless/settings/__init__.py:590
msgid "Vietnamese" msgid "Vietnamese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:604 #: paperless/settings/__init__.py:591
msgid "Chinese Simplified" msgid "Chinese Simplified"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:605 #: paperless/settings/__init__.py:592
msgid "Chinese Traditional" msgid "Chinese Traditional"
msgstr "" msgstr ""
+15
View File
@@ -1,8 +1,23 @@
from compression_middleware.middleware import CompressionMiddleware
from django.conf import settings from django.conf import settings
from paperless import version from paperless import version
class StreamAwareCompressionMiddleware(CompressionMiddleware):
"""
Bypasses compression for server-sent streams (text/event-stream).
See https://github.com/friedelwolff/django-compression-middleware/pull/7
"""
def process_response(self, request, response):
content_type = response.headers.get("Content-Type", "")
if content_type.startswith("text/event-stream"):
return response
return super().process_response(request, response)
class ApiVersionMiddleware: class ApiVersionMiddleware:
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
+3 -16
View File
@@ -10,7 +10,6 @@ from pathlib import Path
from typing import Final from typing import Final
from urllib.parse import urlparse from urllib.parse import urlparse
from compression_middleware.middleware import CompressionMiddleware
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -201,22 +200,10 @@ MIDDLEWARE = [
"allauth.account.middleware.AccountMiddleware", "allauth.account.middleware.AccountMiddleware",
] ]
# Optional to enable compression # Optional to enable compression. The subclass leaves server-sent events
# uncompressed; see paperless.middleware.StreamAwareCompressionMiddleware.
if get_bool_from_env("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover if get_bool_from_env("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware") MIDDLEWARE.insert(0, "paperless.middleware.StreamAwareCompressionMiddleware")
# Workaround to not compress streaming responses (e.g. chat).
# See https://github.com/friedelwolff/django-compression-middleware/pull/7
original_process_response = CompressionMiddleware.process_response
def patched_process_response(self, request, response):
if getattr(request, "compress_exempt", False):
return response
return original_process_response(self, request, response)
CompressionMiddleware.process_response = patched_process_response
ROOT_URLCONF = "paperless.urls" ROOT_URLCONF = "paperless.urls"
@@ -0,0 +1,54 @@
from django.http import HttpResponse
from django.http import StreamingHttpResponse
from django.test import RequestFactory
from django.test import TestCase
from paperless.middleware import StreamAwareCompressionMiddleware
class TestStreamAwareCompressionMiddleware(TestCase):
def setUp(self) -> None:
super().setUp()
self.factory = RequestFactory()
self.middleware = StreamAwareCompressionMiddleware(lambda request: None)
def _request(self):
return self.factory.get(
"/api/documents/chat/",
HTTP_ACCEPT_ENCODING="gzip, deflate, br, zstd",
)
def test_event_stream_is_not_compressed(self) -> None:
"""
GIVEN:
- A server-sent event response produced chunk by chunk
WHEN:
- The compression middleware processes it
THEN:
- It is passed through unencoded, one wire chunk per source chunk
"""
chunks = [f"token{i} ".encode() for i in range(40)]
response = StreamingHttpResponse(
iter(chunks),
content_type="text/event-stream",
)
response = self.middleware.process_response(self._request(), response)
assert not response.has_header("Content-Encoding")
assert list(response.streaming_content) == chunks
def test_regular_response_is_still_compressed(self) -> None:
"""
GIVEN:
- An ordinary response large enough to be worth compressing
WHEN:
- The compression middleware processes it
THEN:
- It is compressed as before
"""
response = HttpResponse(b"a" * 5000, content_type="application/json")
response = self.middleware.process_response(self._request(), response)
assert response.has_header("Content-Encoding")
-1
View File
@@ -40,7 +40,6 @@ LLM_SYSTEM_PROMPT = (
# openai-python rejects empty keys since 2.34.0, "fake" is the stand-in from # openai-python rejects empty keys since 2.34.0, "fake" is the stand-in from
# llama-index's own OpenAILike docs https://docs.llamaindex.ai/en/stable/api_reference/llms/openai_like/ # llama-index's own OpenAILike docs https://docs.llamaindex.ai/en/stable/api_reference/llms/openai_like/
# TODO: remove pending resolution of https://github.com/openai/openai-python/issues/3224
PLACEHOLDER_API_KEY: Final = "fake" PLACEHOLDER_API_KEY: Final = "fake"
+30
View File
@@ -854,6 +854,36 @@ class TestAPIProcessedMails(DirectoriesMixin, APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_bulk_delete_requires_global_delete_permission(self) -> None:
owner = User.objects.create_user(username="mail_owner")
requester = User.objects.create_user(username="mail_deleter")
requester.user_permissions.add(
Permission.objects.get(codename="add_processedmail"),
)
mail = ProcessedMailFactory(owner=owner)
assign_perm("delete_processedmail", requester, mail)
self.client.force_authenticate(requester)
response = self.client.post(
f"{self.ENDPOINT}bulk_delete/",
data={"mail_ids": [mail.pk]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
requester.user_permissions.add(
Permission.objects.get(codename="delete_processedmail"),
)
requester = User.objects.get(pk=requester.pk)
self.client.force_authenticate(requester)
response = self.client.post(
f"{self.ENDPOINT}bulk_delete/",
data={"mail_ids": [mail.pk]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(ProcessedMail.objects.filter(pk=mail.pk).exists())
def test_bulk_delete_processed_mails_rejects_mixed_batch_atomically(self) -> None: def test_bulk_delete_processed_mails_rejects_mixed_batch_atomically(self) -> None:
""" """
GIVEN: GIVEN:
+15 -1
View File
@@ -18,6 +18,7 @@ from rest_framework import serializers
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.permissions import BasePermission
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
@@ -44,6 +45,15 @@ from paperless_mail.serialisers import ProcessedMailSerializer
from paperless_mail.tasks import process_mail_accounts from paperless_mail.tasks import process_mail_accounts
class DeleteProcessedMailPermissions(BasePermission):
def has_permission(self, request, view):
return bool(
request.user
and request.user.is_authenticated
and request.user.has_perm("paperless_mail.delete_processedmail"),
)
@extend_schema_view( @extend_schema_view(
test=extend_schema( test=extend_schema(
operation_id="mail_account_test", operation_id="mail_account_test",
@@ -206,7 +216,11 @@ class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
queryset = ProcessedMail.objects.all().order_by("-processed") queryset = ProcessedMail.objects.all().order_by("-processed")
@action(methods=["post"], detail=False) @action(
methods=["post"],
detail=False,
permission_classes=[IsAuthenticated, DeleteProcessedMailPermissions],
)
def bulk_delete(self, request): def bulk_delete(self, request):
mail_ids = request.data.get("mail_ids", []) mail_ids = request.data.get("mail_ids", [])
if not isinstance(mail_ids, list) or not all( if not isinstance(mail_ids, list) or not all(