Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 1efaaef8a3 Chore(deps): Bump the uv group across 1 directory with 8 updates
---
updated-dependencies:
- dependency-name: torch
  dependency-version: 2.13.0
  dependency-type: direct:production
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: cryptography
  dependency-version: 48.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: pi-heif
  dependency-version: 1.3.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: pyjwt
  dependency-version: 2.13.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: setuptools
  dependency-version: 83.0.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: tornado
  dependency-version: 6.5.7
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 14:15:22 +00:00
136 changed files with 25071 additions and 26838 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ dependencies = [
"sqlite-vec==0.1.9",
"tantivy~=0.26.0",
"tika-client~=0.11.0",
"torch~=2.12.0",
"torch~=2.13.0",
"watchfiles>=1.1.1",
"whitenoise~=6.11",
"zxing-cpp~=3.0.0",
+68 -87
View File
@@ -1317,7 +1317,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">237</context>
<context context-type="linenumber">236</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -1615,7 +1615,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">206</context>
<context context-type="linenumber">205</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
@@ -1646,7 +1646,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">246</context>
<context context-type="linenumber">245</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
@@ -1677,7 +1677,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">255</context>
<context context-type="linenumber">254</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
@@ -1716,7 +1716,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">219</context>
<context context-type="linenumber">218</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
@@ -2065,7 +2065,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">264</context>
<context context-type="linenumber">263</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -2236,11 +2236,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">76</context>
<context context-type="linenumber">72</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">147</context>
<context context-type="linenumber">143</context>
</context-group>
</trans-unit>
<trans-unit id="3278307631146748151" datatype="html">
@@ -3477,11 +3477,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">82</context>
<context context-type="linenumber">78</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">153</context>
<context context-type="linenumber">149</context>
</context-group>
</trans-unit>
<trans-unit id="searchResults.noResults" datatype="html">
@@ -3833,7 +3833,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">79</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="3188389494264426470" datatype="html">
@@ -4067,7 +4067,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">273</context>
<context context-type="linenumber">272</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -5906,7 +5906,7 @@
<source>Create</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">54</context>
<context context-type="linenumber">56</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dialog/share-links-dialog.component.html</context>
@@ -5921,14 +5921,14 @@
<source>Apply</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">60</context>
<context context-type="linenumber">62</context>
</context-group>
</trans-unit>
<trans-unit id="7780041345210191160" datatype="html">
<source>Click again to exclude items.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.html</context>
<context context-type="linenumber">73</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="7593728289020204896" datatype="html">
@@ -5943,7 +5943,7 @@
<source>Open <x id="PH" equiv-text="this.title"/> filter</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">828</context>
<context context-type="linenumber">824</context>
</context-group>
</trans-unit>
<trans-unit id="7005745151564974365" datatype="html">
@@ -7400,11 +7400,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">29</context>
<context context-type="linenumber">25</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">318</context>
<context context-type="linenumber">317</context>
</context-group>
</trans-unit>
<trans-unit id="78870852467682010" datatype="html">
@@ -7415,11 +7415,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">96</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">358</context>
<context context-type="linenumber">357</context>
</context-group>
</trans-unit>
<trans-unit id="157572966557284263" datatype="html">
@@ -7430,11 +7430,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">106</context>
<context context-type="linenumber">102</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">365</context>
<context context-type="linenumber">364</context>
</context-group>
</trans-unit>
<trans-unit id="883965278435032344" datatype="html">
@@ -7452,7 +7452,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">386</context>
<context context-type="linenumber">385</context>
</context-group>
</trans-unit>
<trans-unit id="3542042671420335679" datatype="html">
@@ -7463,7 +7463,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">386</context>
<context context-type="linenumber">385</context>
</context-group>
</trans-unit>
<trans-unit id="872092479747931526" datatype="html">
@@ -7713,7 +7713,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">73</context>
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="2336375155355449543" datatype="html">
@@ -7767,7 +7767,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">216</context>
<context context-type="linenumber">215</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
@@ -8739,28 +8739,28 @@
<source>Custom fields updated.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1025</context>
<context context-type="linenumber">1024</context>
</context-group>
</trans-unit>
<trans-unit id="3873496751167944011" datatype="html">
<source>Error updating custom fields.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1034</context>
<context context-type="linenumber">1033</context>
</context-group>
</trans-unit>
<trans-unit id="6144801143088984138" datatype="html">
<source>Share link bundle creation requested.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1082</context>
<context context-type="linenumber">1073</context>
</context-group>
</trans-unit>
<trans-unit id="46019676931295023" datatype="html">
<source>Share link bundle creation is not available yet.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1089</context>
<context context-type="linenumber">1080</context>
</context-group>
</trans-unit>
<trans-unit id="6307402210351946694" datatype="html">
@@ -8785,108 +8785,89 @@
<context context-type="linenumber">73,78</context>
</context-group>
</trans-unit>
<trans-unit id="7575628227893972164" datatype="html">
<source><x id="INTERPOLATION" equiv-text="document().title | documentTitle }}"/> thumbnail</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">6</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">8</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">6</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">8</context>
</context-group>
</trans-unit>
<trans-unit id="2784168796433474565" datatype="html">
<source>Filter by tag</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">40</context>
<context context-type="linenumber">36</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">334</context>
<context context-type="linenumber">333</context>
</context-group>
</trans-unit>
<trans-unit id="106713086593101376" datatype="html">
<source>View notes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">95</context>
<context context-type="linenumber">91</context>
</context-group>
</trans-unit>
<trans-unit id="3727324658595204357" datatype="html">
<source>Created: <x id="INTERPOLATION" equiv-text="{{ document().created | customDate }}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">119,120</context>
<context context-type="linenumber">115,116</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">80,81</context>
<context context-type="linenumber">76,77</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">95,96</context>
<context context-type="linenumber">91,92</context>
</context-group>
</trans-unit>
<trans-unit id="2030261243264601523" datatype="html">
<source>Added: <x id="INTERPOLATION" equiv-text="{{ document().added | customDate }}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">120,121</context>
<context context-type="linenumber">116,117</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">81,82</context>
<context context-type="linenumber">77,78</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">96,97</context>
<context context-type="linenumber">92,93</context>
</context-group>
</trans-unit>
<trans-unit id="4235671847487610290" datatype="html">
<source>Modified: <x id="INTERPOLATION" equiv-text="{{ document().modified | customDate }}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">121,122</context>
<context context-type="linenumber">117,118</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">82,83</context>
<context context-type="linenumber">78,79</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">97,98</context>
<context context-type="linenumber">93,94</context>
</context-group>
</trans-unit>
<trans-unit id="197162226430950645" datatype="html">
<source>{VAR_PLURAL, plural, =1 {1 page} other {<x id="INTERPOLATION"/> pages}}</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">138</context>
<context context-type="linenumber">134</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">110</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="5739581984228459958" datatype="html">
<source>Shared</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">148</context>
<context context-type="linenumber">144</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">129</context>
<context context-type="linenumber">125</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -8901,35 +8882,35 @@
<source>Score:</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
<context context-type="linenumber">153</context>
<context context-type="linenumber">149</context>
</context-group>
</trans-unit>
<trans-unit id="3661756380991326939" datatype="html">
<source>Toggle tag filter</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">24</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="4648526799630820486" datatype="html">
<source>Toggle correspondent filter</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">47</context>
<context context-type="linenumber">43</context>
</context-group>
</trans-unit>
<trans-unit id="5319701482646590642" datatype="html">
<source>Toggle document type filter</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">63</context>
<context context-type="linenumber">59</context>
</context-group>
</trans-unit>
<trans-unit id="8950368321707344185" datatype="html">
<source>Toggle storage path filter</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.html</context>
<context context-type="linenumber">70</context>
<context context-type="linenumber">66</context>
</context-group>
</trans-unit>
<trans-unit id="3797570084942068182" datatype="html">
@@ -9078,14 +9059,14 @@
<source>Sort by ASN</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">193</context>
<context context-type="linenumber">192</context>
</context-group>
</trans-unit>
<trans-unit id="7517688192215738656" datatype="html">
<source>ASN</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">197</context>
<context context-type="linenumber">196</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
@@ -9104,28 +9085,28 @@
<source>Sort by correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">202</context>
<context context-type="linenumber">201</context>
</context-group>
</trans-unit>
<trans-unit id="2066713941761361709" datatype="html">
<source>Sort by title</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">211</context>
<context context-type="linenumber">210</context>
</context-group>
</trans-unit>
<trans-unit id="6232673011753681091" datatype="html">
<source>Sort by owner</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">224</context>
<context context-type="linenumber">223</context>
</context-group>
</trans-unit>
<trans-unit id="3715596725146409911" datatype="html">
<source>Owner</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">228</context>
<context context-type="linenumber">227</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -9140,49 +9121,49 @@
<source>Sort by notes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">233</context>
<context context-type="linenumber">232</context>
</context-group>
</trans-unit>
<trans-unit id="5499001829734502606" datatype="html">
<source>Sort by document type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">242</context>
<context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="6213829731736042759" datatype="html">
<source>Sort by storage path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">251</context>
<context context-type="linenumber">250</context>
</context-group>
</trans-unit>
<trans-unit id="3406167410329973166" datatype="html">
<source>Sort by created date</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">260</context>
<context context-type="linenumber">259</context>
</context-group>
</trans-unit>
<trans-unit id="3769035778779263084" datatype="html">
<source>Sort by added date</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">269</context>
<context context-type="linenumber">268</context>
</context-group>
</trans-unit>
<trans-unit id="4874754501044009042" datatype="html">
<source>Sort by number of pages</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">278</context>
<context context-type="linenumber">277</context>
</context-group>
</trans-unit>
<trans-unit id="3817498941817715969" datatype="html">
<source>Pages</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">282</context>
<context context-type="linenumber">281</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context>
@@ -9201,28 +9182,28 @@
<source> Shared </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">285,287</context>
<context context-type="linenumber">284,286</context>
</context-group>
</trans-unit>
<trans-unit id="5083658411133224968" datatype="html">
<source>Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">292,293</context>
<context context-type="linenumber">291,292</context>
</context-group>
</trans-unit>
<trans-unit id="2179847500064178686" datatype="html">
<source>Edit document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">326</context>
<context context-type="linenumber">325</context>
</context-group>
</trans-unit>
<trans-unit id="3420321797707163677" datatype="html">
<source>Preview document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">327</context>
<context context-type="linenumber">326</context>
</context-group>
</trans-unit>
<trans-unit id="4512084577073831437" datatype="html">
@@ -34,28 +34,30 @@
</div>
@if (selectionModel.items) {
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
<div *cdkVirtualFor="let item of filteredItems; trackBy: trackByItem; let i = index">
<pngx-toggleable-dropdown-button
[item]="item"
[hideCount]="hideCount(item)"
[opacifyCount]="!editing"
[state]="selectionModel.get(item.id)"
[count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)"
[disabled]="disabled">
</pngx-toggleable-dropdown-button>
<div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
@if (allowSelectNone || item.id) {
<pngx-toggleable-dropdown-button
[item]="item"
[hideCount]="hideCount(item)"
[opacifyCount]="!editing"
[state]="selectionModel.get(item.id)"
[count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)"
[disabled]="disabled">
</pngx-toggleable-dropdown-button>
}
</div>
</cdk-virtual-scroll-viewport>
}
@if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined) {
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button>
}
@if (filteredItems.length > 0) {
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
@@ -265,9 +265,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement
)
expect(component.buttonsViewport.getRenderedRange().end).toEqual(
items.length
) // all selectable items shown
expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
component.filterText = 'Tag2'
fixture.detectChanges()
@@ -280,29 +278,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(component.filterText).toHaveLength(0)
})
it('should omit disallowed null items from the virtual scroll viewport', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
await wait(100)
fixture.detectChanges()
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
expect(component.filteredItems).toEqual(items)
expect(component.scrollViewportHeight).toEqual(
items.length * component.FILTERABLE_BUTTON_HEIGHT_PX
)
expect(
component.buttonsViewport.elementRef.nativeElement.querySelectorAll(
'.cdk-virtual-scroll-content-wrapper > div'
)
).toHaveLength(items.length)
})
it('should toggle & close on enter inside filter field if 1 item remains', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
@@ -661,6 +661,7 @@ export class FilterableDropdownSelectionModel {
imports: [
ClearableBadgeComponent,
ToggleableDropdownButtonComponent,
FilterPipe,
FormsModule,
ReactiveFormsModule,
NgxBootstrapIconsModule,
@@ -800,17 +801,12 @@ export class FilterableDropdownComponent
private keyboardIndex: number
public get filteredItems(): MatchingModel[] {
return this.filterPipe
.transform(this.items, this.filterText, 'name')
.filter((item) => this.allowSelectNone || Boolean(item.id))
}
public get scrollViewportHeight(): number {
return Math.min(
this.filteredItems.length * this.FILTERABLE_BUTTON_HEIGHT_PX,
400
)
const filteredLength = this.filterPipe.transform(
this.items,
this.filterText
).length
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
}
constructor() {
@@ -882,7 +878,7 @@ export class FilterableDropdownComponent
}
listFilterEnter(): void {
const filtered = this.filteredItems
let filtered = this.filterPipe.transform(this.items, this.filterText)
if (filtered.length == 1) {
this.selectionModel.toggle(filtered[0].id)
setTimeout(() => {
@@ -138,18 +138,6 @@ describe('PngxPdfViewerComponent', () => {
expect(applyScaleSpy).toHaveBeenCalled()
})
it('does not reset the viewer when it is already on the requested page', async () => {
await initComponent()
const viewer = (component as any).pdfViewer as PDFViewer
const currentPageSpy = jest.spyOn(viewer, 'currentPageNumber', 'set')
component.page = viewer.currentPageNumber
;(component as any).applyViewerState()
expect(currentPageSpy).not.toHaveBeenCalled()
})
it('dispatches find when search query changes after render', async () => {
await initComponent()
@@ -256,9 +256,7 @@ export class PngxPdfViewerComponent
Math.max(Math.trunc(this.page), 1),
this.pdfViewer.pagesCount
)
if (nextPage !== this.pdfViewer.currentPageNumber) {
this.pdfViewer.currentPageNumber = nextPage
}
this.pdfViewer.currentPageNumber = nextPage
}
if (this.page === this.lastViewerPage) {
this.lastViewerPage = undefined
@@ -103,13 +103,13 @@
class="btn btn-sm btn-outline-primary"
id="dropdownSend"
ngbDropdownToggle
[disabled]="disabled || !canSendSelection"
[disabled]="disabled || !list.hasSelection || list.allSelected"
>
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
</div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="list.allSelected">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
</button>
<button ngbDropdownItem (click)="manageShareLinkBundles()">
@@ -117,7 +117,7 @@
</button>
<div class="dropdown-divider"></div>
@if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
<button ngbDropdownItem (click)="emailSelected()" [disabled]="list.allSelected">
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
</button>
}
@@ -208,50 +208,6 @@ describe('BulkEditorComponent', () => {
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
})
it('should allow sending an all-filtered selection that fits on the current page', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(5)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(true)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(false)
})
it('should prevent sending an all-filtered selection spanning multiple pages', () => {
jest
.spyOn(documentListViewService, 'hasSelection', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'allSelected', 'get')
.mockReturnValue(true)
jest
.spyOn(documentListViewService, 'selectedCount', 'get')
.mockReturnValue(6)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([1, 2, 3, 4, 5]))
fixture.detectChanges()
expect(component.canSendSelection).toBe(false)
expect(
fixture.debugElement.query(By.css('#dropdownSend')).nativeElement.disabled
).toBe(true)
})
it('should apply selection data to correspondents menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
@@ -1641,7 +1597,6 @@ describe('BulkEditorComponent', () => {
expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.selection).toEqual({ documents: [3, 4] })
expect(modal.componentInstance.selectionCount).toEqual(2)
expect(modal.componentInstance.documents).toEqual([3, 4])
modal.componentInstance.failed.emit()
@@ -1020,7 +1020,6 @@ export class BulkEditorComponent
)
dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => {
this.toastService.showInfo($localize`Custom fields updated.`)
this.list.reload()
@@ -1041,14 +1040,6 @@ export class BulkEditorComponent
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
}
public get canSendSelection(): boolean {
return (
this.list.hasSelection &&
(!this.list.allSelected ||
this.list.selectedCount === this.list.selected.size)
)
}
createShareLinkBundle() {
const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
backdrop: 'static',
@@ -1,9 +1,9 @@
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i8n>{
documentCount,
documents.length,
plural,
=1 {Set custom fields for 1 document} other {Set custom fields for {{documentCount}} documents}
=1 {Set custom fields for 1 document} other {Set custom fields for {{documents.length}} documents}
}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
@@ -42,20 +42,6 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
expect(component.form.contains('2')).toBeTruthy()
})
it('should render the document count for a filtered selection', () => {
component.selection = {
all: true,
filters: { title__icontains: 'invoice' },
}
component.selectionCount = 42
fixture.detectChanges()
expect(component.documents).toEqual([])
expect(
fixture.nativeElement.querySelector('.modal-title').textContent
).toContain('Set custom fields for 42 documents')
})
it('should emit succeeded event and close modal on successful save', () => {
const editSpy = jest
.spyOn(documentService, 'bulkEdit')
@@ -81,14 +81,8 @@ export class CustomFieldsBulkEditDialogComponent {
public selection: DocumentSelectionQuery = { documents: [] }
public selectionCount: number
public get documents(): number[] {
return this.selection.documents ?? []
}
public get documentCount(): number {
return this.selectionCount ?? this.documents.length
return this.selection.documents
}
initForm() {
@@ -2,11 +2,7 @@
<div class="row g-0">
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
@if (document()) {
@if (priority()) {
<img [ngSrc]="getThumbUrl()" fill priority class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
} @else {
<img [ngSrc]="getThumbUrl()" fill class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()" alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
}
<img [ngSrc]="getThumbUrl()" fill class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
<div class="border-end border-bottom bg-light document-card-check">
<div class="form-check">
@@ -94,15 +94,6 @@ describe('DocumentCardLargeComponent', () => {
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
})
it('should prioritize the thumbnail when requested', () => {
fixture.componentRef.setInput('priority', true)
fixture.detectChanges()
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('eager')
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
})
it('should trim content', () => {
expect(component.contentTrimmed).toHaveLength(503) // includes ...
})
@@ -66,7 +66,6 @@ export class DocumentCardLargeComponent
private documentService = inject(DocumentService)
settingsService = inject(SettingsService)
readonly selected = input(false)
readonly priority = input(false)
readonly displayFields = input<string[]>(
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
)
@@ -2,11 +2,7 @@
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
@if (document()) {
@if (priority()) {
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill priority alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
} @else {
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill alt="{{ document().title | documentTitle }} thumbnail" i18n-alt>
}
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill>
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
<div class="form-check">
@@ -67,15 +67,6 @@ describe('DocumentCardSmallComponent', () => {
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
})
it('should prioritize the thumbnail when requested', () => {
fixture.componentRef.setInput('priority', true)
fixture.detectChanges()
const thumbnail: HTMLImageElement =
fixture.nativeElement.querySelector('img.doc-img')
expect(thumbnail.getAttribute('loading')).toEqual('eager')
expect(thumbnail.getAttribute('fetchpriority')).toEqual('high')
})
it('should display a document, limit tags to 5', () => {
expect(fixture.nativeElement.textContent).toContain('Document 10')
expect(
@@ -66,7 +66,6 @@ export class DocumentCardSmallComponent
private documentService = inject(DocumentService)
settingsService = inject(SettingsService)
readonly selected = input(false)
readonly priority = input(false)
readonly document = input<Document>(undefined)
readonly displayFields = input<string[]>(
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
@@ -164,10 +164,9 @@
} @else {
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
<div>
@for (d of list.documents; track d.id; let i = $index) {
@for (d of list.documents; track d.id) {
<pngx-document-card-large
[selected]="list.isSelected(d)"
[priority]="i < 2"
(toggleSelected)="toggleSelected(d, $event)"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
@@ -399,10 +398,9 @@
}
@if (list.displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards">
@for (d of list.documents; track d.id; let i = $index) {
@for (d of list.documents; track d.id) {
<pngx-document-card-small class="p-0"
[selected]="list.isSelected(d)"
[priority]="i < 6"
(toggleSelected)="toggleSelected(d, $event)"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,9 +1,9 @@
from datetime import UTC
from datetime import datetime
from typing import Any
from django.conf import settings
from django.core.cache import cache
from rest_framework.request import Request
from documents.caching import CACHE_5_MINUTES
from documents.caching import CACHE_50_MINUTES
@@ -117,7 +117,7 @@ def preview_last_modified(request, pk: int) -> datetime | None:
return doc.modified
def thumbnail_etag(request: Request, pk: int) -> str | None:
def thumbnail_etag(request: Any, pk: int) -> str | None:
"""
Thumbnails are version-dependent, so use the effective document checksum as
the ETag to invalidate cache when the latest version changes.
@@ -128,7 +128,7 @@ def thumbnail_etag(request: Request, pk: int) -> str | None:
return doc.checksum
def thumbnail_last_modified(request: Request, pk: int) -> datetime | None:
def thumbnail_last_modified(request: Any, pk: int) -> datetime | None:
"""
Returns the filesystem last modified either from cache or from filesystem.
Cache should be (slightly?) faster than filesystem
-5
View File
@@ -621,11 +621,6 @@ class ConsumerPlugin(
else:
original_document.save()
# Adding a version changes the effective document, so update root modified
Document.objects.filter(pk=root_doc.pk).update(
modified=timezone.now(),
)
# Create a log entry for the version addition, if enabled
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import ( # type: ignore[import-untyped]
-2
View File
@@ -1024,8 +1024,6 @@ class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
"""
def filter_queryset(self, request, queryset, view):
if request.user.is_superuser:
return queryset
objects_with_perms = super().filter_queryset(request, queryset, view)
objects_owned = queryset.filter(owner=request.user)
objects_unowned = queryset.filter(owner__isnull=True)
@@ -1,73 +0,0 @@
# Generated by Django 5.2.16 on 2026-07-19 20:33
import django.core.validators
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0021_widen_workflow_integer_fields"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="document",
name="checksum",
field=models.CharField(
db_index=True,
editable=False,
help_text="The checksum of the original document.",
max_length=64,
verbose_name="checksum",
),
),
migrations.AlterField(
model_name="document",
name="page_count",
field=models.PositiveIntegerField(
db_index=True,
help_text="The number of pages of the document.",
null=True,
validators=[django.core.validators.MinValueValidator(1)],
verbose_name="page count",
),
),
migrations.AddIndex(
model_name="customfieldinstance",
index=models.Index(
fields=["field", "value_date"],
name="documents_c_field_i_30b51d_idx",
),
),
migrations.AddIndex(
model_name="customfieldinstance",
index=models.Index(
fields=["field", "value_int"],
name="documents_c_field_i_d25ab0_idx",
),
),
migrations.AddIndex(
model_name="customfieldinstance",
index=models.Index(
fields=["field", "value_float"],
name="documents_c_field_i_c35174_idx",
),
),
migrations.AddIndex(
model_name="customfieldinstance",
index=models.Index(
fields=["field", "value_monetary_amount"],
name="documents_c_field_i_53ce4e_idx",
),
),
migrations.AddIndex(
model_name="document",
index=models.Index(
fields=["owner", "created"],
name="documents_d_owner_i_ec6ab3_idx",
),
),
]
+1 -11
View File
@@ -217,7 +217,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
_("checksum"),
max_length=64,
editable=False,
db_index=True,
help_text=_("The checksum of the original document."),
)
@@ -235,7 +234,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
blank=False,
null=True,
unique=False,
db_index=True,
db_index=False,
validators=[MinValueValidator(1)],
help_text=_(
"The number of pages of the document.",
@@ -339,9 +338,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
ordering = ("-created",)
verbose_name = _("document")
verbose_name_plural = _("documents")
indexes = [
models.Index(fields=["owner", "created"]),
]
constraints = [
models.UniqueConstraint(
fields=["root_document", "version_index"],
@@ -1194,12 +1190,6 @@ class CustomFieldInstance(SoftDeleteModel):
ordering = ("created",)
verbose_name = _("custom field instance")
verbose_name_plural = _("custom field instances")
indexes = [
models.Index(fields=["field", "value_date"]),
models.Index(fields=["field", "value_int"]),
models.Index(fields=["field", "value_float"]),
models.Index(fields=["field", "value_monetary_amount"]),
]
constraints = [
models.UniqueConstraint(
fields=["document", "field"],
+34 -87
View File
@@ -11,7 +11,6 @@ from enum import StrEnum
from itertools import islice
from typing import TYPE_CHECKING
from typing import Final
from typing import NamedTuple
from typing import Self
from typing import TypedDict
from typing import TypeVar
@@ -22,7 +21,6 @@ import tantivy
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.utils.timezone import get_current_timezone
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
from documents.search._query import build_permission_filter
@@ -47,8 +45,6 @@ if TYPE_CHECKING:
from pathlib import Path
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.db.models import QuerySet
from documents.models import Document
@@ -63,18 +59,6 @@ _LOCK_BACKOFF_CAP: Final[float] = 10.0 # seconds
T = TypeVar("T")
class ViewerGrant(NamedTuple):
"""Direct user and group view grants for a single document.
Named fields (rather than a bare 2-tuple) so ``viewer_ids`` and
``viewer_group_ids`` can't be silently transposed at a call site — both
are ``list[int]``, so a positional swap would type-check cleanly.
"""
viewer_ids: list[int]
viewer_group_ids: list[int]
class SearchMode(StrEnum):
QUERY = "query"
TEXT = "text"
@@ -383,7 +367,7 @@ class TantivyBackend:
) -> tantivy.Query:
"""Wrap a query with a permission filter if the user is not a superuser."""
if user is not None:
permission_filter = self._build_permission_filter(user)
permission_filter = build_permission_filter(self._schema, user)
return tantivy.Query.boolean_query(
[
(tantivy.Occur.Must, query),
@@ -392,21 +376,11 @@ class TantivyBackend:
)
return query
def _build_permission_filter(self, user: AbstractUser) -> tantivy.Query:
"""Build a filter using the user's current group memberships."""
group_ids = user.groups.values_list("pk", flat=True)
return build_permission_filter(
self._schema,
user,
viewer_group_ids=group_ids,
)
def _build_tantivy_doc(
self,
document: Document,
effective_content: str | None = None,
viewer_ids: list[int] | None = None,
viewer_group_ids: list[int] | None = None,
) -> tantivy.Document:
"""Build a tantivy Document from a Django Document instance.
@@ -523,26 +497,10 @@ class TantivyBackend:
users_with_perms = get_users_with_perms(
document,
only_with_perms_in=["view_document"],
with_group_users=False,
)
viewer_ids = list(
cast("QuerySet[User]", users_with_perms).values_list("id", flat=True),
)
viewer_ids = [int(u.id) for u in users_with_perms]
for viewer_id in viewer_ids:
doc.add_unsigned("viewer_id", viewer_id)
if viewer_group_ids is None:
groups_with_perms = get_groups_with_perms(
document,
only_with_perms_in=["view_document"],
)
viewer_group_ids = list(
cast("QuerySet[Group]", groups_with_perms).values_list(
"id",
flat=True,
),
)
for viewer_group_id in viewer_group_ids:
doc.add_unsigned("viewer_group_id", viewer_group_id)
# Autocomplete words
text_sources = [document.title, content]
@@ -855,7 +813,7 @@ class TantivyBackend:
# Intersect with permission filter so autocomplete words from
# invisible documents don't leak to other users.
if user is not None and not user.is_superuser:
permission_query = self._build_permission_filter(user)
permission_query = build_permission_filter(self._schema, user)
matches = searcher.terms_with_prefix(
"autocomplete_word",
@@ -957,7 +915,7 @@ class TantivyBackend:
def rebuild(
self,
documents: QuerySet[Document],
iter_wrapper: IterWrapper[tuple[Document, ViewerGrant]] = identity,
iter_wrapper: IterWrapper[tuple[Document, list[int]]] = identity,
writer_heap_bytes: int = 512_000_000,
) -> None:
"""
@@ -970,8 +928,8 @@ class TantivyBackend:
documents: QuerySet of Document instances to index
iter_wrapper: Optional wrapper function for progress tracking
(e.g., progress bar). Wraps an iterable of
``(document, (viewer_ids, viewer_group_ids))`` pairs and should yield
each unchanged, advancing one step per document.
``(document, viewer_ids)`` pairs and should yield each pair
unchanged, advancing one step per document.
writer_heap_bytes: Tantivy writer memory budget (split across the
writer's threads). Larger values buffer more docs in RAM before
flushing a segment, deferring merge work; they do not avoid it.
@@ -995,14 +953,11 @@ class TantivyBackend:
documents_stream = _DocumentViewerStream(documents, chunk_size=1000)
try:
writer = new_index.writer(heap_size=writer_heap_bytes)
for document, (viewer_ids, viewer_group_ids) in iter_wrapper(
documents_stream,
):
for document, viewer_ids in iter_wrapper(documents_stream):
doc = self._build_tantivy_doc(
document,
document.get_effective_content(),
viewer_ids=viewer_ids,
viewer_group_ids=viewer_group_ids,
)
writer.add_document(doc)
writer.commit()
@@ -1023,26 +978,19 @@ def chunked(iterable, size):
yield chunk
_EMPTY_VIEWER_GRANT: Final[ViewerGrant] = ViewerGrant(
viewer_ids=[],
viewer_group_ids=[],
)
class _DocumentViewerStream:
"""Yield document permission data while batch-loading grants.
"""Yield (document, viewer_ids) pairs while batch-loading viewer ids.
Viewer permissions are fetched in batches (see
``_bulk_get_viewer_permissions``), but documents are yielded individually so a
Viewer permissions are fetched one SQL query per chunk (see
``_bulk_get_viewer_ids``), but documents are yielded individually so a
progress bar wrapped around this stream advances per document rather than
jumping a whole chunk at a time. ``__len__`` lets the progress helper still
discover the total (it inspects ``QuerySet``/``Sized``).
The viewer and group ids travel with each document in the yielded pair
rather than through a separate mutable attribute, so the pairing survives
regardless of how ``iter_wrapper`` consumes the stream (buffering,
batching, etc.) there is no reliance on the caller advancing this
generator in lock-step.
The viewer ids travel with each document in the yielded pair rather than
through a separate mutable attribute, so the pairing survives regardless
of how ``iter_wrapper`` consumes the stream (buffering, batching, etc.)
there is no reliance on the caller advancing this generator in lock-step.
"""
def __init__(self, documents: QuerySet[Document], *, chunk_size: int) -> None:
@@ -1052,25 +1000,25 @@ class _DocumentViewerStream:
def __len__(self) -> int:
return self._documents.count()
def __iter__(self) -> Iterator[tuple[Document, ViewerGrant]]:
def __iter__(self) -> Iterator[tuple[Document, list[int]]]:
# iterator(chunk_size=…) streams from a server-side cursor instead of
# materialising the whole queryset in memory; since Django 4.1 it still
# honours prefetch_related, running the prefetches one batch at a time.
documents = self._documents.iterator(chunk_size=self._chunk_size)
for chunk in chunked(documents, self._chunk_size):
grants_by_pk = _bulk_get_viewer_permissions([doc.pk for doc in chunk])
viewer_ids_by_pk = _bulk_get_viewer_ids([doc.pk for doc in chunk])
for doc in chunk:
yield doc, grants_by_pk.get(doc.pk, _EMPTY_VIEWER_GRANT)
yield doc, viewer_ids_by_pk.get(doc.pk, [])
def _bulk_get_viewer_permissions(
doc_pks: Sequence[int],
) -> dict[int, ViewerGrant]:
"""Fetch direct user and group view grants for a batch of documents, keyed by pk.
def _bulk_get_viewer_ids(doc_pks: Sequence[int]) -> dict[int, list[int]]:
"""Fetch all view_document permissions for a batch of documents in one query.
Group grants remain group IDs in the index so permission checks use the
requesting user's current memberships. Expanding groups to user IDs here
would leave stale access behind after a user is removed from a group.
Mirrors get_users_with_perms(doc, only_with_perms_in=["view_document"])
(with_group_users defaults to True there): a user counts as a viewer if
they hold the permission directly, OR via membership in a group that
holds the permission. Missing the group case would silently drop search
access for group-only viewers.
"""
from collections import defaultdict
@@ -1084,7 +1032,6 @@ def _bulk_get_viewer_permissions(
str_pks = [str(pk) for pk in doc_pks]
viewer_map: dict[int, set[int]] = defaultdict(set)
viewer_group_map: dict[int, set[int]] = defaultdict(set)
# Fold the permission lookup into the query via a join on codename instead
# of a separate Permission.objects.get(), which would otherwise run once per
@@ -1098,22 +1045,22 @@ def _bulk_get_viewer_permissions(
for object_pk, user_id in user_qs:
viewer_map[int(object_pk)].add(user_id)
# User.groups has related_query_name="user", so group__user__id joins
# through the group membership m2m to the member users' ids in the same
# single-query fashion as user_qs above (values_list compiles to one SQL
# JOIN; no per-row Python-side lookups follow, so select_related /
# prefetch_related do not apply here).
group_qs = GroupObjectPermission.objects.filter(
content_type=ct,
permission__content_type=ct,
permission__codename="view_document",
object_pk__in=str_pks,
).values_list("object_pk", "group_id")
for object_pk, group_id in group_qs:
viewer_group_map[int(object_pk)].add(group_id)
).values_list("object_pk", "group__user__id")
for object_pk, user_id in group_qs:
if user_id is not None:
viewer_map[int(object_pk)].add(user_id)
return {
object_pk: ViewerGrant(
viewer_ids=list(viewer_map.get(object_pk, ())),
viewer_group_ids=list(viewer_group_map.get(object_pk, ())),
)
for object_pk in viewer_map.keys() | viewer_group_map.keys()
}
return {object_pk: list(user_ids) for object_pk, user_ids in viewer_map.items()}
# Module-level singleton with proper thread safety
+1 -11
View File
@@ -20,7 +20,6 @@ from documents.search._translate import SearchQueryError
from documents.search._translate import translate_query
if TYPE_CHECKING:
from collections.abc import Iterable
from datetime import tzinfo
from django.contrib.auth.base_user import AbstractBaseUser
@@ -115,7 +114,6 @@ def normalize_query(query: str) -> str:
def build_permission_filter(
schema: tantivy.Schema,
user: AbstractBaseUser,
viewer_group_ids: Iterable[int] = (),
) -> tantivy.Query:
"""
Build a query filter for user document permissions.
@@ -125,12 +123,10 @@ def build_permission_filter(
- Public documents (no owner) are visible to all users
- Private documents are visible to their owner
- Documents explicitly shared with the user are visible
- Documents shared with one of the user's current groups are visible
Args:
schema: Tantivy schema for field validation
user: User to check permissions for
viewer_group_ids: Current group memberships for the user
Returns:
Tantivy query that filters results to visible documents
@@ -144,13 +140,7 @@ def build_permission_filter(
)
owned = tantivy.Query.term_query(schema, "owner_id", user.pk)
shared = tantivy.Query.term_query(schema, "viewer_id", user.pk)
group_shared = [
tantivy.Query.term_query(schema, "viewer_group_id", group_id)
for group_id in viewer_group_ids
]
return tantivy.Query.disjunction_max_query(
[no_owner, owned, shared, *group_shared],
)
return tantivy.Query.disjunction_max_query([no_owner, owned, shared])
DEFAULT_SEARCH_FIELDS = [
-1
View File
@@ -102,7 +102,6 @@ def build_schema() -> tantivy.Schema:
"tag_id",
"owner_id",
"viewer_id",
"viewer_group_id",
):
sb.add_unsigned_field(field, stored=False, indexed=True, fast=True)
-92
View File
@@ -1419,30 +1419,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.views.bulk_edit.merge")
def test_merge_and_delete_requires_change_permission(self, m) -> None:
self.setup_mock(m, "merge")
user = User.objects.create_user(username="no-change")
user.user_permissions.add(
Permission.objects.get(codename="add_document"),
Permission.objects.get(codename="delete_document"),
)
self.client.force_authenticate(user=user)
response = self.client.post(
"/api/documents/merge/",
json.dumps(
{
"documents": [self.doc2.id, self.doc3.id],
"delete_originals": True,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge")
def test_merge_invalid_parameters(self, m) -> None:
self.setup_mock(m, "merge")
@@ -1692,74 +1668,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_edit_pdf_update_requires_change_permission(self, m) -> None:
self.setup_mock(m, "edit_pdf")
user = User.objects.create_user(username="no-change")
self.client.force_authenticate(user=user)
response = self.client.post(
"/api/documents/edit_pdf/",
json.dumps(
{
"documents": [self.doc2.id],
"operations": [{"page": 1}],
"update_document": True,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
m.assert_not_called()
@mock.patch("documents.views.bulk_edit.remove_password")
@mock.patch("documents.views.bulk_edit.edit_pdf")
def test_delete_original_requires_delete_permission(
self,
edit_pdf_mock,
remove_password_mock,
) -> None:
self.setup_mock(edit_pdf_mock, "edit_pdf")
self.setup_mock(remove_password_mock, "remove_password")
user = User.objects.create_user(username="no-delete")
user.user_permissions.add(
Permission.objects.get(codename="add_document"),
Permission.objects.get(codename="change_document"),
)
self.client.force_authenticate(user=user)
cases = [
(
"/api/documents/edit_pdf/",
{
"documents": [self.doc2.id],
"operations": [{"page": 1}],
"delete_original": True,
},
edit_pdf_mock,
),
(
"/api/documents/remove_password/",
{
"documents": [self.doc2.id],
"password": "secret",
"delete_original": True,
},
remove_password_mock,
),
]
for endpoint, payload, operation_mock in cases:
with self.subTest(endpoint=endpoint):
response = self.client.post(
endpoint,
json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
operation_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.remove_password")
def test_remove_password(self, m) -> None:
self.setup_mock(m, "remove_password")
@@ -1,6 +1,5 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from unittest import TestCase
from unittest import mock
@@ -11,7 +10,6 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
@@ -139,8 +137,6 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
root_document=root,
content="v2-content",
)
original_modified = timezone.now() - datetime.timedelta(days=1)
Document.objects.filter(pk=root.pk).update(modified=original_modified)
with mock.patch("documents.search.get_backend"):
resp = self.client.delete(f"/api/documents/{root.id}/versions/{v2.id}/")
@@ -150,7 +146,6 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(resp.data["current_version_id"], v1.id)
root.refresh_from_db()
self.assertEqual(root.content, "root-content")
self.assertGreater(root.modified, original_modified)
with mock.patch("documents.search.get_backend"):
resp = self.client.delete(f"/api/documents/{root.id}/versions/{v1.id}/")
@@ -331,8 +326,6 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
root_document=root,
version_label="old",
)
original_modified = timezone.now() - datetime.timedelta(days=1)
Document.objects.filter(pk=root.pk).update(modified=original_modified)
resp = self.client.patch(
f"/api/documents/{root.id}/versions/{version.id}/",
@@ -346,8 +339,6 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(resp.data["version_label"], "Label 1")
self.assertEqual(resp.data["id"], version.id)
self.assertFalse(resp.data["is_root"])
root.refresh_from_db()
self.assertGreater(root.modified, original_modified)
def test_update_version_label_clears_on_blank(self) -> None:
root = Document.objects.create(
@@ -669,26 +660,6 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
def test_update_version_requires_global_change_permission(self) -> None:
user = User.objects.create_user(username="add-only")
user.user_permissions.add(Permission.objects.get(codename="add_document"))
root = Document.objects.create(
title="root",
checksum="root",
mime_type="application/pdf",
)
self.client.force_authenticate(user=user)
with mock.patch("documents.views.consume_file") as consume_mock:
resp = self.client.post(
f"/api/documents/{root.id}/update_version/",
{"document": self._make_pdf_upload()},
format="multipart",
)
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
consume_mock.apply_async.assert_not_called()
def test_update_version_returns_404_for_missing_document(self) -> None:
resp = self.client.post(
"/api/documents/9999/update_version/",
@@ -131,10 +131,6 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
self.client.get("/api/saved_views/").status_code,
status.HTTP_403_FORBIDDEN,
)
self.assertEqual(
self.client.get("/api/search/autocomplete/?term=test").status_code,
status.HTTP_403_FORBIDDEN,
)
def test_api_sufficient_permissions(self) -> None:
user = User.objects.create_user(username="test")
-25
View File
@@ -832,7 +832,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
"""
u1 = User.objects.create_user("user1")
u2 = User.objects.create_user("user2")
u1.user_permissions.add(Permission.objects.get(codename="view_document"))
self.client.force_authenticate(user=u1)
@@ -879,30 +878,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, ["applebaum", "apples", "appletini"])
def test_search_autocomplete_group_revocation_is_immediate(self) -> None:
user = User.objects.create_user("group-user")
owner = User.objects.create_user("document-owner")
group = Group.objects.create(name="temporary-viewers")
user.user_permissions.add(Permission.objects.get(codename="view_document"))
user.groups.add(group)
document = Document.objects.create(
title="private",
content="canarysecretautocomplete",
checksum="group-revocation",
owner=owner,
)
assign_perm("view_document", group, document)
get_backend().add_or_update(document)
self.client.force_authenticate(user=user)
response = self.client.get("/api/search/autocomplete/?term=canarysecret")
self.assertEqual(response.data, ["canarysecretautocomplete"])
user.groups.remove(group)
response = self.client.get("/api/search/autocomplete/?term=canarysecret")
self.assertEqual(response.data, [])
def test_search_autocomplete_field_name_match(self) -> None:
"""
GIVEN:
-4
View File
@@ -767,8 +767,6 @@ class TestConsumer(
root_doc.archive_serial_number = 42
root_doc.save()
original_modified = timezone.now() - datetime.timedelta(days=1)
Document.objects.filter(pk=root_doc.pk).update(modified=original_modified)
actor = User.objects.create_user(
username="actor",
email="actor@example.com",
@@ -820,8 +818,6 @@ class TestConsumer(
self.assertIsNone(version.archive_serial_number)
self.assertEqual(version.original_filename, version_file.name)
self.assertTrue(bool(version.content))
root_doc.refresh_from_db()
self.assertGreater(root_doc.modified, original_modified)
@override_settings(AUDIT_LOG_ENABLED=True)
@mock.patch("documents.consumer.load_classifier")
@@ -1,12 +1,8 @@
from datetime import timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING
from typing import cast
from unittest import mock
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
from documents.conditionals import metadata_etag
@@ -17,9 +13,6 @@ from documents.models import Document
from documents.tests.utils import DirectoriesMixin
from documents.versioning import resolve_effective_document_by_pk
if TYPE_CHECKING:
from rest_framework.request import Request
class TestConditionals(DirectoriesMixin, TestCase):
def test_metadata_etag_uses_latest_version_for_root_request(self) -> None:
@@ -36,18 +29,14 @@ class TestConditionals(DirectoriesMixin, TestCase):
mime_type="application/pdf",
root_document=root,
)
request = cast("Request", SimpleNamespace(query_params={}))
request = SimpleNamespace(query_params={})
self.assertEqual(
metadata_etag(request, root.id),
f"{latest.checksum}:{latest.modified.isoformat()}",
)
# preview_etag/thumbnail_etag resolve the same (pk, request) and should
# reuse metadata_etag's cached resolution instead of re-querying.
with CaptureQueriesContext(connection) as ctx:
self.assertEqual(preview_etag(request, root.id), latest.archive_checksum)
self.assertEqual(thumbnail_etag(request, root.id), latest.checksum)
self.assertEqual(len(ctx.captured_queries), 0)
self.assertEqual(preview_etag(request, root.id), latest.archive_checksum)
self.assertEqual(thumbnail_etag(request, root.id), latest.checksum)
def test_metadata_etag_changes_when_document_modified_changes(self) -> None:
doc = Document.objects.create(
@@ -55,20 +44,15 @@ class TestConditionals(DirectoriesMixin, TestCase):
checksum="same-checksum",
mime_type="application/pdf",
)
# Each call simulates a separate incoming HTTP request, so it gets its
# own request object -- the per-request resolution cache must not leak
# a stale result across genuinely different requests.
original_etag = metadata_etag(
cast("Request", SimpleNamespace(query_params={})),
doc.id,
)
request = SimpleNamespace(query_params={})
original_etag = metadata_etag(request, doc.id)
new_modified = timezone.now() + timedelta(seconds=5)
Document.objects.filter(id=doc.id).update(modified=new_modified)
second_request = cast("Request", SimpleNamespace(query_params={}))
self.assertNotEqual(metadata_etag(second_request, doc.id), original_etag)
self.assertNotEqual(metadata_etag(request, doc.id), original_etag)
self.assertEqual(
metadata_etag(second_request, doc.id),
metadata_etag(request, doc.id),
f"{doc.checksum}:{new_modified.isoformat()}",
)
@@ -92,13 +76,9 @@ class TestConditionals(DirectoriesMixin, TestCase):
root_document=other_root,
)
invalid_request = cast(
"Request",
SimpleNamespace(query_params={"version": "not-a-number"}),
)
unrelated_request = cast(
"Request",
SimpleNamespace(query_params={"version": str(other_version.id)}),
invalid_request = SimpleNamespace(query_params={"version": "not-a-number"})
unrelated_request = SimpleNamespace(
query_params={"version": str(other_version.id)},
)
self.assertIsNone(
@@ -125,7 +105,7 @@ class TestConditionals(DirectoriesMixin, TestCase):
latest.thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
latest.thumbnail_path.write_bytes(b"thumb")
request = cast("Request", SimpleNamespace(query_params={}))
request = SimpleNamespace(query_params={})
with mock.patch(
"documents.conditionals.get_thumbnail_modified_key",
return_value="thumb-modified-key",
-63
View File
@@ -2938,69 +2938,6 @@ class TestWorkflows(
self.assertFalse(doc.tags.filter(pk=self.t1.pk).exists())
self.assertTrue(doc.tags.filter(pk=self.t2.pk).exists())
def test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment(
self,
) -> None:
"""
GIVEN:
- A document updated workflow filtered on a tag
- One assignment action assigns a storage path, a second (later-ordered)
assignment action adds a tag
WHEN:
- The document is updated and the workflow is triggered
THEN:
- Both the tag and the storage path are persisted
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
trigger.filter_has_tags.add(self.t1)
assign_storage_path = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
assign_storage_path=self.sp,
order=0,
)
assign_tag = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
order=1,
)
assign_tag.assign_tags.add(self.t2)
assign_tag.save()
workflow = Workflow.objects.create(
name="Workflow assign storage path then tag",
order=0,
)
workflow.triggers.add(trigger)
workflow.actions.add(assign_storage_path, assign_tag)
workflow.save()
doc = Document.objects.create(
title="sample test",
mime_type="application/pdf",
checksum="assign-tag-and-storage-path",
original_filename="sample.pdf",
)
generated = generate_unique_filename(doc)
destination = (settings.ORIGINALS_DIR / generated).resolve()
create_source_path_directory(destination)
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
doc.refresh_from_db()
doc.tags.set([self.t1])
superuser = User.objects.create_superuser("superuser")
self.client.force_authenticate(user=superuser)
self.client.patch(
f"/api/documents/{doc.id}/",
{"title": "user update to trigger workflow"},
format="json",
)
doc.refresh_from_db()
self.assertEqual(doc.storage_path, self.sp)
self.assertTrue(doc.tags.filter(pk=self.t2.pk).exists())
def test_removal_action_document_updated_removeall(self) -> None:
"""
GIVEN:
+11 -34
View File
@@ -8,7 +8,7 @@ from typing import Any
from documents.models import Document
if TYPE_CHECKING:
from rest_framework.request import Request
from django.http import HttpRequest
class VersionResolutionError(StrEnum):
@@ -26,7 +26,7 @@ def _document_manager(*, include_deleted: bool) -> Any:
return Document.global_objects if include_deleted else Document.objects
def get_request_version_param(request: Request) -> str | None:
def get_request_version_param(request: HttpRequest) -> str | None:
if hasattr(request, "query_params"):
return request.query_params.get("version")
return None
@@ -57,7 +57,7 @@ def get_latest_version_for_root(
def resolve_requested_version_for_root(
root_doc: Document,
request: Request,
request: Any,
*,
include_deleted: bool = False,
) -> VersionResolution:
@@ -86,7 +86,7 @@ def resolve_requested_version_for_root(
def resolve_effective_document(
request_doc: Document,
request: Request,
request: Any,
*,
include_deleted: bool = False,
) -> VersionResolution:
@@ -107,41 +107,18 @@ def resolve_effective_document(
return VersionResolution(document=request_doc)
_EFFECTIVE_DOCUMENT_CACHE_ATTR = "_effective_document_resolution_cache"
def resolve_effective_document_by_pk(
pk: int,
request: Request,
request: Any,
*,
include_deleted: bool = False,
) -> VersionResolution:
# Django's `condition()` decorator (used for ETag/Last-Modified) invokes the
# etag_func and last_modified_func separately, and the view itself may resolve
# again -- all against the same request. Cache per-request so a single thumb/
# metadata/preview request doesn't redo this resolution multiple times.
cache = getattr(request, _EFFECTIVE_DOCUMENT_CACHE_ATTR, None)
if cache is None:
cache = {}
setattr(request, _EFFECTIVE_DOCUMENT_CACHE_ATTR, cache)
key = (pk, include_deleted)
if key in cache:
return cache[key]
manager = _document_manager(include_deleted=include_deleted)
request_doc = manager.only("id", "root_document_id").filter(pk=pk).first()
if request_doc is None:
resolution = VersionResolution(
document=None,
error=VersionResolutionError.NOT_FOUND,
)
else:
resolution = resolve_effective_document(
request_doc,
request,
include_deleted=include_deleted,
)
cache[key] = resolution
return resolution
return VersionResolution(document=None, error=VersionResolutionError.NOT_FOUND)
return resolve_effective_document(
request_doc,
request,
include_deleted=include_deleted,
)
+7 -30
View File
@@ -1040,23 +1040,12 @@ class DocumentViewSet(
.order_by("-id")
.values("content")[:1],
)
# A correlated subquery avoids the LEFT JOIN + Count() this used to
# be, which forced a GROUP BY aggregate over every matching document
# before the query could even be sorted or limited.
note_count = Subquery(
Note.objects.filter(document=OuterRef("pk"))
.order_by()
.values("document")
.annotate(count=Count("pk"))
.values("count"),
output_field=IntegerField(),
)
return (
Document.objects.filter(root_document__isnull=True)
.distinct()
.order_by("-created", "-id")
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
.annotate(num_notes=Coalesce(note_count, 0))
.annotate(num_notes=Count("notes"))
.select_related("correspondent", "storage_path", "document_type", "owner")
.prefetch_related(
Prefetch(
@@ -1947,13 +1936,10 @@ class DocumentViewSet(
"root_document",
).get(pk=pk)
root_doc = get_root_document(request_doc)
if request.user is not None and (
not request.user.has_perm("documents.change_document")
or not has_perms_owner_aware(
request.user,
"change_document",
root_doc,
)
if request.user is not None and not has_perms_owner_aware(
request.user,
"change_document",
root_doc,
):
return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist:
@@ -2071,8 +2057,6 @@ class DocumentViewSet(
_backend.remove(version_doc.pk)
version_doc_id = version_doc.id
version_doc.delete()
root_doc.modified = timezone.now()
Document.objects.filter(pk=root_doc.pk).update(modified=root_doc.modified)
_backend.add_or_update(root_doc)
if settings.AUDIT_LOG_ENABLED:
actor = (
@@ -2152,8 +2136,6 @@ class DocumentViewSet(
old_label = version_doc.version_label
version_doc.version_label = serializer.validated_data["version_label"]
version_doc.save(update_fields=["version_label"])
root_doc.modified = timezone.now()
Document.objects.filter(pk=root_doc.pk).update(modified=root_doc.modified)
if settings.AUDIT_LOG_ENABLED and old_label != version_doc.version_label:
actor = (
@@ -2759,7 +2741,7 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
)
or (method == bulk_edit.edit_pdf and parameters.get("update_document"))
):
has_perms = has_perms and user_is_owner_of_all_documents
has_perms = user_is_owner_of_all_documents
# check global add permissions for methods that create documents
if (
@@ -2784,11 +2766,6 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
method in [bulk_edit.merge, bulk_edit.split]
and parameters.get("delete_originals")
)
or (
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
and parameters.get("delete_original")
and not parameters.get("update_document")
)
)
and not user.has_perm("documents.delete_document")
):
@@ -3389,7 +3366,7 @@ class SelectionDataView(GenericAPIView[Any]):
),
)
class SearchAutoCompleteView(GenericAPIView[Any]):
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
permission_classes = (IsAuthenticated,)
def get(self, request, format=None):
user = self.request.user if hasattr(self.request, "user") else None
+1 -5
View File
@@ -24,11 +24,7 @@ def apply_assignment_to_document(
action: WorkflowAction, annotated with 'has_assign_*' boolean fields
"""
if action.has_assign_tags:
# Apply to a freshly-fetched instance rather than the shared `document`.
# Document.tags.add() fires an m2m_changed signal that ultimately calls
# instance.refresh_from_db(), which would discard any other unsaved
# assignment fields (e.g. storage_path) already staged on `document`.
Document.objects.get(pk=document.pk).add_nested_tags(action.assign_tags.all())
document.add_nested_tags(action.assign_tags.all())
if action.assign_correspondent:
document.correspondent = action.assign_correspondent
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More