Compare commits

..
Author SHA1 Message Date
stumpylog 2b4f44096d feature: add Tantivy full-text fallback adapter for taxonomy candidates
This brings users without an embedding backend configured to closer
parity with those who do.  Reuse the search backend to locate similar
documents and use them to provide the LLM with the better suggestion pool
to draw from
2026-08-26 11:35:55 -07:00
GitHub Actions 2e7cd14964 Auto translate strings 2026-08-26 04:48:45 +00:00
shamoonandGitHub 5090ffe39e Enhancement: welcome widget visual tweaks (#13794) 2026-08-25 21:47:13 -07:00
GitHub Actions 35d40b51cb Auto translate strings 2026-08-25 21:37:07 +00:00
shamoonandGitHub 78025df405 Performance: fetch note authors with prefetch instead of one query each (#13790) 2026-08-25 14:35:22 -07:00
shamoon e24db7023a Tweak: adjust brand leaf opacity 2026-08-25 10:44:49 -07:00
shamoon f5ddc14588 Fix: also check global change_mailaccount with test 2026-08-25 10:44:49 -07:00
29 changed files with 675 additions and 1378 deletions
+1
View File
@@ -40,6 +40,7 @@ dependencies = [
"djangorestframework~=3.16", "djangorestframework~=3.16",
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar~=2026.7.1", "drf-spectacular-sidecar~=2026.7.1",
"drf-writable-nested~=0.7.1",
"filelock~=3.32.0", "filelock~=3.32.0",
"flower~=2.0.1", "flower~=2.0.1",
"gotenberg-client~=0.14.0", "gotenberg-client~=0.14.0",
+63 -63
View File
@@ -296,11 +296,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">93</context> <context context-type="linenumber">89</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">95</context> <context context-type="linenumber">91</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
@@ -323,11 +323,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">102</context> <context context-type="linenumber">98</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">104</context> <context context-type="linenumber">100</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html</context>
@@ -374,15 +374,15 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">60</context> <context context-type="linenumber">56</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">284</context> <context context-type="linenumber">280</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">286</context> <context context-type="linenumber">282</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5890330709052835856" datatype="html"> <trans-unit id="5890330709052835856" datatype="html">
@@ -727,11 +727,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">320</context> <context context-type="linenumber">316</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">322</context> <context context-type="linenumber">318</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2272120016352772836" datatype="html"> <trans-unit id="2272120016352772836" datatype="html">
@@ -1142,11 +1142,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">244</context> <context context-type="linenumber">240</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">246</context> <context context-type="linenumber">242</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context> <context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
@@ -1711,7 +1711,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">215</context> <context context-type="linenumber">211</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context> <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
@@ -1801,15 +1801,15 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">162</context> <context context-type="linenumber">164</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">274</context> <context context-type="linenumber">276</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">304</context> <context context-type="linenumber">306</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6904866445262015585" datatype="html"> <trans-unit id="6904866445262015585" datatype="html">
@@ -1820,11 +1820,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">307</context> <context context-type="linenumber">303</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">309</context> <context context-type="linenumber">305</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8492095365580052820" datatype="html"> <trans-unit id="8492095365580052820" datatype="html">
@@ -2601,11 +2601,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">268</context> <context context-type="linenumber">264</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">270</context> <context context-type="linenumber">266</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3818027200170621545" datatype="html"> <trans-unit id="3818027200170621545" datatype="html">
@@ -2962,11 +2962,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">298</context> <context context-type="linenumber">294</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">300</context> <context context-type="linenumber">296</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4569276013106377105" datatype="html"> <trans-unit id="4569276013106377105" datatype="html">
@@ -3275,104 +3275,104 @@
<source>by Paperless-ngx</source> <source>by Paperless-ngx</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">27,28</context> <context context-type="linenumber">23,24</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7228136119811576789" datatype="html"> <trans-unit id="7228136119811576789" datatype="html">
<source>User menu</source> <source>User menu</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">44</context> <context context-type="linenumber">40</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2448391510242468907" datatype="html"> <trans-unit id="2448391510242468907" datatype="html">
<source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source> <source>Logged in as <x id="INTERPOLATION" equiv-text="{{this.settingsService.displayName}}"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">52,53</context> <context context-type="linenumber">48,49</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2127032578120864096" datatype="html"> <trans-unit id="2127032578120864096" datatype="html">
<source>My Profile</source> <source>My Profile</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">56</context> <context context-type="linenumber">52</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3797778920049399855" datatype="html"> <trans-unit id="3797778920049399855" datatype="html">
<source>Logout</source> <source>Logout</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">63</context> <context context-type="linenumber">59</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4895326106573044490" datatype="html"> <trans-unit id="4895326106573044490" datatype="html">
<source>Documentation</source> <source>Documentation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">68</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">328,329</context> <context context-type="linenumber">324,325</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">331</context> <context context-type="linenumber">327</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="472206565520537964" datatype="html"> <trans-unit id="472206565520537964" datatype="html">
<source>Saved views</source> <source>Saved views</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">112,113</context> <context context-type="linenumber">108,109</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">142,143</context> <context context-type="linenumber">138,139</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6988090220128974198" datatype="html"> <trans-unit id="6988090220128974198" datatype="html">
<source>Open documents</source> <source>Open documents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">151,152</context> <context context-type="linenumber">147,148</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5687256342387781369" datatype="html"> <trans-unit id="5687256342387781369" datatype="html">
<source>Close all</source> <source>Close all</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">173</context> <context context-type="linenumber">169</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">175</context> <context context-type="linenumber">171</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3897348120591552265" datatype="html"> <trans-unit id="3897348120591552265" datatype="html">
<source>Manage</source> <source>Manage</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">184,185</context> <context context-type="linenumber">180,181</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8008131619909556709" datatype="html"> <trans-unit id="8008131619909556709" datatype="html">
<source>Attributes</source> <source>Attributes</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">192</context> <context context-type="linenumber">188</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">194</context> <context context-type="linenumber">190</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7437910965833684826" datatype="html"> <trans-unit id="7437910965833684826" datatype="html">
<source>Correspondents</source> <source>Correspondents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">220</context> <context context-type="linenumber">216</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
@@ -3391,7 +3391,7 @@
<source>Document types</source> <source>Document types</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">225</context> <context context-type="linenumber">221</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
@@ -3402,7 +3402,7 @@
<source>Storage paths</source> <source>Storage paths</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">230</context> <context context-type="linenumber">226</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
@@ -3413,7 +3413,7 @@
<source>Custom fields</source> <source>Custom fields</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">235</context> <context context-type="linenumber">231</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.html</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
@@ -3432,11 +3432,11 @@
<source>Workflows</source> <source>Workflows</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">253</context> <context context-type="linenumber">249</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">255</context> <context context-type="linenumber">251</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
@@ -3447,99 +3447,99 @@
<source>Mail</source> <source>Mail</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">261</context> <context context-type="linenumber">257</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">263</context> <context context-type="linenumber">259</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7844706011418789951" datatype="html"> <trans-unit id="7844706011418789951" datatype="html">
<source>Administration</source> <source>Administration</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">278,279</context> <context context-type="linenumber">274,275</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3008420115644088420" datatype="html"> <trans-unit id="3008420115644088420" datatype="html">
<source>Configuration</source> <source>Configuration</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">291</context> <context context-type="linenumber">287</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">293</context> <context context-type="linenumber">289</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1534029177398918729" datatype="html"> <trans-unit id="1534029177398918729" datatype="html">
<source>GitHub</source> <source>GitHub</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">338</context> <context context-type="linenumber">334</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4112664765954374539" datatype="html"> <trans-unit id="4112664765954374539" datatype="html">
<source>is available.</source> <source>is available.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">348</context> <context context-type="linenumber">344</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1175891574282637937" datatype="html"> <trans-unit id="1175891574282637937" datatype="html">
<source>Click to view.</source> <source>Click to view.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">348</context> <context context-type="linenumber">344</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9811291095862612" datatype="html"> <trans-unit id="9811291095862612" datatype="html">
<source>Paperless-ngx can automatically check for updates</source> <source>Paperless-ngx can automatically check for updates</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">352</context> <context context-type="linenumber">348</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="894819944961861800" datatype="html"> <trans-unit id="894819944961861800" datatype="html">
<source> How does this work? </source> <source> How does this work? </source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">360</context> <context context-type="linenumber">356</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="509090351011426949" datatype="html"> <trans-unit id="509090351011426949" datatype="html">
<source>Update available</source> <source>Update available</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">372</context> <context context-type="linenumber">368</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1329827712962827905" datatype="html"> <trans-unit id="1329827712962827905" datatype="html">
<source>Configure update checking</source> <source>Configure update checking</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
<context context-type="linenumber">377</context> <context context-type="linenumber">373</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1542489069631984294" datatype="html"> <trans-unit id="1542489069631984294" datatype="html">
<source>Sidebar views updated</source> <source>Sidebar views updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">425</context> <context context-type="linenumber">427</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3547923076537026828" datatype="html"> <trans-unit id="3547923076537026828" datatype="html">
<source>Error updating sidebar views</source> <source>Error updating sidebar views</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">428</context> <context context-type="linenumber">430</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2526035785704676448" datatype="html"> <trans-unit id="2526035785704676448" datatype="html">
<source>An error occurred while saving update checking settings.</source> <source>An error occurred while saving update checking settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">449</context> <context context-type="linenumber">451</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4580988005648117665" datatype="html"> <trans-unit id="4580988005648117665" datatype="html">
@@ -8021,35 +8021,35 @@
<source>Paperless-ngx is running!</source> <source>Paperless-ngx is running!</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
<context context-type="linenumber">2</context> <context context-type="linenumber">6</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3326049540711826572" datatype="html"> <trans-unit id="3326049540711826572" datatype="html">
<source>You&apos;re ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</source> <source>You&apos;re ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
<context context-type="linenumber">3,4</context> <context context-type="linenumber">8,9</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4474647174688421179" datatype="html"> <trans-unit id="4474647174688421179" datatype="html">
<source>More detail on how to use and configure Paperless-ngx is always available in the <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://docs.paperless-ngx.com&quot; target=&quot;_blank&quot;&gt;"/>documentation<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a&gt;"/>.</source> <source>More detail on how to use and configure Paperless-ngx is always available in the <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://docs.paperless-ngx.com&quot; target=&quot;_blank&quot;&gt;"/>documentation<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a&gt;"/>.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
<context context-type="linenumber">4,6</context> <context context-type="linenumber">9,10</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4294899532887357745" datatype="html"> <trans-unit id="4294899532887357745" datatype="html">
<source>Thanks for being a part of the Paperless-ngx community!</source> <source>Thanks for being a part of the Paperless-ngx community!</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
<context context-type="linenumber">7,8</context> <context context-type="linenumber">13,14</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1415832194529539652" datatype="html"> <trans-unit id="1415832194529539652" datatype="html">
<source>Start the tour</source> <source>Start the tour</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/widgets/welcome-widget/welcome-widget.component.html</context>
<context context-type="linenumber">8</context> <context context-type="linenumber">14</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7822640317427130239" datatype="html"> <trans-unit id="7822640317427130239" datatype="html">
@@ -10,16 +10,12 @@
tourAnchor="tour.intro"> tourAnchor="tour.intro">
@if (!hasCustomBranding) { @if (!hasCustomBranding) {
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo> <pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
<svg class="brand-mark brand-mark-slim d-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor"> <pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
</svg>
} @else { } @else {
@if (customAppLogo) { @if (customAppLogo) {
<img class="brand-logo" [src]="customAppLogo" alt="" /> <img class="brand-logo" [src]="customAppLogo" alt="" />
} @else { } @else {
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor"> <pngx-brand-mark class="brand-mark"></pngx-brand-mark>
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
</svg>
} }
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled"> <div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
<span class="brand-title text-truncate">{{ appTitle }}</span> <span class="brand-title text-truncate">{{ appTitle }}</span>
@@ -417,7 +417,7 @@ main {
:host ::ng-deep .navbar-official-logo { :host ::ng-deep .navbar-official-logo {
.leaf { .leaf {
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important; fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 85%, var(--bs-primary)) !important;
} }
.text { .text {
@@ -45,6 +45,7 @@ import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component' import { ChatComponent } from '../chat/chat/chat.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component' import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
@@ -61,6 +62,7 @@ const SCROLL_THRESHOLD = 16
imports: [ imports: [
GlobalSearchComponent, GlobalSearchComponent,
LogoComponent, LogoComponent,
BrandMarkComponent,
DocumentTitlePipe, DocumentTitlePipe,
IfPermissionsDirective, IfPermissionsDirective,
ToastsDropdownComponent, ToastsDropdownComponent,
@@ -0,0 +1,3 @@
<svg class="w-100 h-100 d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" fill="currentColor" aria-hidden="true">
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 545 B

@@ -0,0 +1,14 @@
import { Component, input } from '@angular/core'
@Component({
selector: 'pngx-brand-mark',
templateUrl: './brand-mark.component.html',
host: {
'[style.width]': 'width()',
'[style.height]': 'height()',
},
})
export class BrandMarkComponent {
readonly width = input<string>(null)
readonly height = input<string>(null)
}
@@ -1,10 +1,18 @@
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)"> <ngb-alert class="welcome-widget bg-light shadow-sm pe-3 position-relative overflow-hidden" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4> <pngx-brand-mark class="welcome-watermark d-none d-md-block position-absolute top-50 translate-middle-y text-primary pe-none" width="26rem" height="26rem"></pngx-brand-mark>
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p> <div class="d-flex align-items-start gap-3 gap-md-4 position-relative">
<p i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p> <pngx-brand-mark class="d-none d-sm-block flex-shrink-0 text-primary" width="3rem" height="3rem"></pngx-brand-mark>
<hr> <div class="flex-grow-1">
<div class="d-flex align-items-end"> <h4 class="alert-heading text-primary fw-semibold"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
<p class="lead fs-6 m-0"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p> <div class="welcome-copy text-dark">
<button class="btn btn-primary ms-auto flex-shrink-0" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> &rarr;</button> <p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
<p class="mb-0" i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
</div>
<hr class="opacity-100">
<div class="d-flex flex-column flex-sm-row align-items-sm-center gap-3">
<p class="m-0 text-secondary"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
<button class="btn btn-primary ms-sm-auto flex-shrink-0 align-self-start align-self-sm-auto" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> &rarr;</button>
</div>
</div>
</div> </div>
</ngb-alert> </ngb-alert>
@@ -0,0 +1,16 @@
.welcome-widget {
border-color: color-mix(in srgb, var(--bs-primary) 50%, transparent);
hr {
border-top-color: color-mix(in srgb, var(--bs-primary) 25%, transparent);
}
}
.welcome-copy {
max-width: 68ch;
}
.welcome-watermark {
right: -6rem;
opacity: .07;
}
@@ -1,12 +1,13 @@
import { Component, EventEmitter, Output, inject } from '@angular/core' import { Component, EventEmitter, Output, inject } from '@angular/core'
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap' import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
import { TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { BrandMarkComponent } from '../../../common/logo/brand-mark/brand-mark.component'
@Component({ @Component({
selector: 'pngx-welcome-widget', selector: 'pngx-welcome-widget',
templateUrl: './welcome-widget.component.html', templateUrl: './welcome-widget.component.html',
styleUrls: ['./welcome-widget.component.scss'], styleUrls: ['./welcome-widget.component.scss'],
imports: [NgbAlertModule], imports: [NgbAlertModule, BrandMarkComponent],
}) })
export class WelcomeWidgetComponent { export class WelcomeWidgetComponent {
readonly tourService = inject(TourService) readonly tourService = inject(TourService)
+29 -46
View File
@@ -27,7 +27,7 @@ from documents.models import DocumentType
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import set_permissions_for_objects from documents.permissions import set_permissions_for_object
from documents.plugins.helpers import DocumentsStatusManager from documents.plugins.helpers import DocumentsStatusManager
from documents.tasks import bulk_update_documents from documents.tasks import bulk_update_documents
from documents.tasks import consume_file from documents.tasks import consume_file
@@ -305,49 +305,33 @@ def modify_custom_fields(
else [(field, None) for field in add_custom_fields] else [(field, None) for field in add_custom_fields]
) )
custom_fields_by_id: dict[int, CustomField] = { custom_fields = CustomField.objects.filter(
cf.id: cf id__in=[int(field) for field, _ in add_custom_fields],
for cf in CustomField.objects.filter( ).distinct()
id__in=[int(field) for field, _ in add_custom_fields],
)
}
# Deferred, not `.only()`: these objects get cached onto the FK
# descriptor of newly-created CustomFieldInstance rows below, and
# downstream post_save receivers (e.g. the filename-generation signal)
# touch other Document fields -- `.only("pk")` would just turn that into
# a deferred-field reload per document, trading one N+1 for another.
# `content` is the one field guaranteed to be both large (full OCR text)
# and unused by anything this function or its receivers touch.
docs_by_id: dict[int, Document] = {
doc.id: doc
for doc in Document.objects.filter(id__in=affected_docs).defer("content")
}
for field_id, value in add_custom_fields: for field_id, value in add_custom_fields:
custom_field = custom_fields_by_id[field_id]
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
custom_field.data_type
]
for doc_id in affected_docs: for doc_id in affected_docs:
defaults = {value_field: value} defaults = {}
if ( custom_field = custom_fields.get(id=field_id)
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK if custom_field:
and value value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
and doc_id in value custom_field.data_type
): ]
# Prevent self-linking defaults[value_field] = value
continue if (
# Pass the already-resolved objects, not bare ids: this caches custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
# them on the FK descriptor of any newly-created instance, so a and value
# later `.field`/`.document` access (e.g. auditlog's post_save and doc_id in value
# receiver calling `str(instance)`, which touches `.field.name`) ):
# doesn't trigger its own per-instance re-fetch. # Prevent self-linking
continue
CustomFieldInstance.objects.update_or_create( CustomFieldInstance.objects.update_or_create(
document=docs_by_id[doc_id], document_id=doc_id,
field=custom_field, field_id=field_id,
defaults=defaults, defaults=defaults,
) )
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK: if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
reflect_doclinks(docs_by_id[doc_id], custom_field, value) doc = Document.objects.get(id=doc_id)
reflect_doclinks(doc, custom_field, value)
# For doc link fields that are being removed, remove symmetrical links # For doc link fields that are being removed, remove symmetrical links
for doclink_being_removed_instance in CustomFieldInstance.objects.filter( for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
@@ -355,10 +339,12 @@ def modify_custom_fields(
field__id__in=remove_custom_fields, field__id__in=remove_custom_fields,
field__data_type=CustomField.FieldDataType.DOCUMENTLINK, field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
value_document_ids__isnull=False, value_document_ids__isnull=False,
).select_related("field"): ):
for target_doc_id in doclink_being_removed_instance.value: for target_doc_id in doclink_being_removed_instance.value:
remove_doclink( remove_doclink(
document=docs_by_id[doclink_being_removed_instance.document_id], document=Document.objects.get(
id=doclink_being_removed_instance.document.id,
),
field=doclink_being_removed_instance.field, field=doclink_being_removed_instance.field,
target_doc_id=target_doc_id, target_doc_id=target_doc_id,
) )
@@ -444,13 +430,10 @@ def set_permissions(
else: else:
qs.update(owner=owner) qs.update(owner=owner)
for doc in qs:
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
set_permissions_for_objects(
permissions=set_permissions,
model=Document,
pks=affected_docs,
merge=merge,
)
bulk_update_documents.apply_async( bulk_update_documents.apply_async(
kwargs={"document_ids": affected_docs}, kwargs={"document_ids": affected_docs},
+1 -1
View File
@@ -129,7 +129,7 @@ class DocumentMetadataOverrides:
) )
overrides.custom_fields = { overrides.custom_fields = {
custom_field.field.id: custom_field.value custom_field.field.id: custom_field.value
for custom_field in doc.custom_fields.select_related("field").all() for custom_field in doc.custom_fields.all()
} }
groups_with_perms = get_groups_with_perms( groups_with_perms = get_groups_with_perms(
-176
View File
@@ -173,182 +173,6 @@ def set_permissions_for_object(
) )
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
"""
Resolves `codenames` to Permission rows, raising like the single-object
assign_perm() this bulk path replaces does (via a `.get()` internally)
if any codename doesn't exist -- e.g. a client-supplied action name that
was never validated (BulkEditObjectsSerializer._validate_permissions
calls validate_set_permissions() only for its side-effecting id checks
and discards the filtered dict it returns, so an unrecognized action key
reaches this function as-is). A plain `.filter()` with no existence
check would otherwise silently build zero rows and no-op instead of
reporting the bad input.
"""
permission_objs = list(
Permission.objects.filter(content_type=ctype, codename__in=codenames),
)
missing = codenames - {p.codename for p in permission_objs}
if missing:
raise Permission.DoesNotExist(
f"Permission matching query does not exist for codename(s): "
f"{', '.join(sorted(missing))}",
)
return permission_objs
# Target number of permission rows to build in Python before handing them to
# bulk_create -- keeps peak memory bounded for a large "apply to all" call,
# independent of bulk_create's own batch_size (which only caps the size of
# each INSERT statement, not how many row objects exist in memory at once).
_PERMISSION_ROW_CHUNK_SIZE = 5000
def _apply_bulk_permission_entry(
*,
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
identity_model: type[User] | type[Group],
identity_field: str,
ids: list[int],
codename: str,
permission_objs: list[Permission],
ctype: ContentType,
object_pks: list[str],
merge: bool,
) -> None:
# Only the ids are needed to build permission rows (via `<field>_id=`),
# so avoid fetching full User/Group rows for identities that may not
# even end up being granted anything new.
add_ids = set(
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
)
if not merge:
existing_ids = set(
perm_model.objects.filter(
content_type=ctype,
object_pk__in=object_pks,
permission__codename=codename,
).values_list(f"{identity_field}_id", flat=True),
)
remove_ids = existing_ids - add_ids
if remove_ids:
perm_model.objects.filter(
content_type=ctype,
object_pk__in=object_pks,
permission__codename=codename,
**{f"{identity_field}_id__in": remove_ids},
).delete()
if not add_ids:
return
rows_per_pk = len(permission_objs) * len(add_ids)
pks_per_chunk = max(1, _PERMISSION_ROW_CHUNK_SIZE // rows_per_pk)
for start in range(0, len(object_pks), pks_per_chunk):
pk_chunk = object_pks[start : start + pks_per_chunk]
rows = [
perm_model(
content_type=ctype,
object_pk=pk,
permission=permission_obj,
**{f"{identity_field}_id": identity_id},
)
for permission_obj in permission_objs
for pk in pk_chunk
for identity_id in add_ids
]
# ignore_conflicts skips only rows that already exist as an exact
# (identity, permission, object) match -- the same de-dup the
# underlying (user|group, permission, object_pk) unique constraint
# already enforces for the single-object assign_perm() this
# replaces, so it doesn't change what counts as "already granted".
# batch_size caps how many rows go into a single INSERT so a huge
# chunk doesn't build one enormous statement.
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
def set_permissions_for_objects(
permissions: dict,
model: type[Model],
pks: QuerySet | list,
*,
merge: bool = False,
) -> None:
"""
Bulk equivalent of set_permissions_for_object: applies the same
permission changes to every object identified by `pks` at once.
Takes a model + pks (rather than model instances) deliberately -- the
permission rows built below only ever need `pk`, `content_type`, and
identity ids, so callers shouldn't have to fetch full rows (with every
other field) just to hand them to this function.
Deliberately does not use guardian's queryset/list-aware assign_perm:
passing a list as the object routes to bulk_assign_perm, which skips
creating a direct permission row for anyone who already has the
permission via ANY group membership (it checks
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
unlike the single-object assign_perm this replaces, which always
ensures a direct row via get_or_create regardless of group-derived
access. Losing that guarantee would mean a later revocation of the
group's grant silently strips access an admin explicitly asked to be
direct. Bulk-creating rows straight against the permission models
instead (see _apply_bulk_permission_entry) preserves the original
always-create-a-direct-row semantics while still batching every object
and every identity into one query per action, rather than one query per
(object, user) pair.
"""
object_pks = [str(pk) for pk in pks]
if not object_pks:
return
model_name = model.__name__.lower()
ctype = ContentType.objects.get_for_model(model)
for action, entry in permissions.items():
codename = f"{action}_{model_name}"
implied_codenames = {codename}
if action == "change":
# change gives view too
implied_codenames.add(f"view_{model_name}")
# Resolved once per action (not once per users/groups branch) and
# shared between both below -- also where an unrecognized action
# name (see _resolve_permissions) is caught.
permission_objs = (
_resolve_permissions(implied_codenames, ctype)
if "users" in entry or "groups" in entry
else []
)
if "users" in entry:
_apply_bulk_permission_entry(
perm_model=UserObjectPermission,
identity_model=User,
identity_field="user",
ids=entry["users"],
codename=codename,
permission_objs=permission_objs,
ctype=ctype,
object_pks=object_pks,
merge=merge,
)
if "groups" in entry:
_apply_bulk_permission_entry(
perm_model=GroupObjectPermission,
identity_model=Group,
identity_field="group",
ids=entry["groups"],
codename=codename,
permission_objs=permission_objs,
ctype=ctype,
object_pks=object_pks,
merge=merge,
)
def permitted_object_ids( def permitted_object_ids(
user: User | None, user: User | None,
model: type[Model], model: type[Model],
+13 -236
View File
@@ -1,10 +1,8 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
import math import math
import re import re
from collections.abc import Iterable
from datetime import datetime from datetime import datetime
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
@@ -26,7 +24,6 @@ from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.core.validators import integer_validator from django.core.validators import integer_validator
from django.db import DataError
from django.db.models import Count from django.db.models import Count
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Lower from django.db.models.functions import Lower
@@ -40,12 +37,12 @@ from django.utils.timezone import make_aware
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from drf_spectacular.utils import extend_schema_serializer from drf_spectacular.utils import extend_schema_serializer
from drf_writable_nested.serializers import NestedUpdateMixin
from guardian.core import ObjectPermissionChecker from guardian.core import ObjectPermissionChecker
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
from guardian.utils import get_group_obj_perms_model from guardian.utils import get_group_obj_perms_model
from guardian.utils import get_user_obj_perms_model from guardian.utils import get_user_obj_perms_model
from rest_framework import fields from rest_framework import fields
from rest_framework import relations
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
@@ -745,100 +742,22 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
return super().validate(attrs) return super().validate(attrs)
class _BatchingManyRelatedField(serializers.ManyRelatedField): class CorrespondentField(serializers.PrimaryKeyRelatedField[Correspondent]):
"""
`ManyRelatedField.to_internal_value` resolves each id in the submitted
list with its own `child_relation.to_internal_value()` call -- one query
per item on every PATCH/PUT that sets a `many=True` relation field.
Batch-resolve them instead, falling back to the child relation's normal
(query-per-item) validation for anything that isn't a plausible int pk,
so bad input still gets the usual DRF validation error rather than being
silently dropped.
"""
@staticmethod
def _normalize_pk(item) -> int | None:
# Excludes bool: DRF's own PrimaryKeyRelatedField rejects it too
# (True == 1 would otherwise silently match pk 1).
if isinstance(item, bool):
return None
try:
return int(item)
except (TypeError, ValueError):
return None
def to_internal_value(self, data):
if isinstance(data, str) or not hasattr(data, "__iter__"):
self.fail("not_a_list", input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail("empty")
item_pks = [(item, self._normalize_pk(item)) for item in data]
candidate_pks = {pk for _, pk in item_pks if pk is not None}
# Django's IntegerFieldOverflow guard (-> EmptyResultSet, i.e. no
# match) only covers exact/gt/gte/lt/lte lookups, not `in` -- an
# out-of-range int in `pk__in=` reaches the DB driver as-is and
# raises OverflowError (SQLite) / DataError (Postgres) instead of
# cleanly matching nothing. The per-item `exact`-lookup fallback
# below IS covered, so on that failure just skip the batch and let
# every item resolve individually -- each still costs one query,
# but reports the normal validation error instead of a raw 500.
try:
resolved_by_pk = {
obj.pk: obj
for obj in self.child_relation.get_queryset().filter(
pk__in=candidate_pks,
)
}
except (OverflowError, DataError):
resolved_by_pk = {}
result = []
for item, pk in item_pks:
obj = resolved_by_pk.get(pk) if pk is not None else None
result.append(
obj if obj is not None else self.child_relation.to_internal_value(item),
)
return result
class BatchResolvingPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
"""
A PrimaryKeyRelatedField whose `many=True` form (a DRF ManyRelatedField)
resolves all submitted ids with one batched query instead of one query
per id. Subclasses only need to implement `get_queryset()` as usual --
only `TagsField` is used with `many=True` today, but this is the base
for all four so the fix isn't tag-specific: if a future PR puts
`many=True` on correspondent/document_type/storage_path, it inherits the
same batching instead of reintroducing this as a new bug to rediscover.
"""
@classmethod
def many_init(cls, *args, **kwargs):
list_kwargs = {"child_relation": cls(*args, **kwargs)}
for key, value in kwargs.items():
if key in relations.MANY_RELATION_KWARGS:
list_kwargs[key] = value
return _BatchingManyRelatedField(**list_kwargs)
class CorrespondentField(BatchResolvingPrimaryKeyRelatedField[Correspondent]):
def get_queryset(self): def get_queryset(self):
return Correspondent.objects.all() return Correspondent.objects.all()
class TagsField(BatchResolvingPrimaryKeyRelatedField[Tag]): class TagsField(serializers.PrimaryKeyRelatedField[Tag]):
def get_queryset(self): def get_queryset(self):
return Tag.objects.all() return Tag.objects.all()
class DocumentTypeField(BatchResolvingPrimaryKeyRelatedField[DocumentType]): class DocumentTypeField(serializers.PrimaryKeyRelatedField[DocumentType]):
def get_queryset(self): def get_queryset(self):
return DocumentType.objects.all() return DocumentType.objects.all()
class StoragePathField(BatchResolvingPrimaryKeyRelatedField[StoragePath]): class StoragePathField(serializers.PrimaryKeyRelatedField[StoragePath]):
def get_queryset(self): def get_queryset(self):
return StoragePath.objects.all() return StoragePath.objects.all()
@@ -957,106 +876,8 @@ def validate_documentlink_targets(user, doc_ids):
) )
# A CustomField lookup cache scoped to a single field/serializer instance
# only helps within that one instance's own validation pass. Several call
# sites, though, build more than one CustomFieldInstanceSerializer (or its
# CustomFieldInstanceListSerializer/field) for the same request and pass
# each of them `context=self.context` -- the *same* dict object, not a
# copy -- e.g. bulk-edit's _validate_custom_field_values() constructing a
# fresh CustomFieldInstanceSerializer per submitted field. That context
# dict is already request-scoped (DRF builds it fresh per request via
# get_serializer_context()), so stashing the resolved CustomField objects
# there -- rather than in some new global/thread-local cache -- lets every
# one of those separately-instantiated serializers reuse them for free
# while staying entirely within DRF's existing, already-request-scoped
# machinery.
_CUSTOM_FIELD_CONTEXT_CACHE_KEY = "_custom_field_lookup_cache"
class _CachingCustomFieldPrimaryKeyField(serializers.PrimaryKeyRelatedField):
"""
Resolves CustomField ids with as few queries as possible: a per-instance
cache for repeat lookups on this exact field instance, backed by a
shared cache on the serializer context (see _CUSTOM_FIELD_CONTEXT_CACHE_KEY
above) so later, separately-instantiated fields for the same request
reuse what was already resolved instead of re-querying.
"""
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._cache: dict[int, CustomField] = {}
def _shared_cache(self) -> dict[int, CustomField]:
return self.context.setdefault(_CUSTOM_FIELD_CONTEXT_CACHE_KEY, {})
@staticmethod
def _normalize_pk(data: Any) -> int | None:
"""
Returns `data` coerced to the int a valid CustomField pk would be,
or None if `data` isn't a plausible pk (wrong type, unhashable,
non-numeric, or a bool -- DRF itself rejects bools as pks since
`True == 1` would otherwise silently match). None tells callers to
leave `data` alone and let `super().to_internal_value()` report the
normal validation error instead of touching the cache/queryset with
it directly.
"""
if isinstance(data, bool):
return None
try:
return int(data)
except (TypeError, ValueError):
return None
def prefetch(self, ids: Iterable[Any]) -> None:
shared_cache = self._shared_cache()
candidates = {pk for i in ids if (pk := self._normalize_pk(i)) is not None}
missing = {
i for i in candidates if i not in self._cache and i not in shared_cache
}
if missing:
for obj in self.get_queryset().filter(pk__in=missing):
shared_cache[obj.pk] = obj
for i in candidates:
obj = shared_cache.get(i)
if obj is not None:
self._cache[i] = obj
def to_internal_value(self, data: Any) -> CustomField:
pk = self._normalize_pk(data)
if pk is None:
return super().to_internal_value(data)
if pk in self._cache:
return self._cache[pk]
shared_cache = self._shared_cache()
if pk in shared_cache:
obj = shared_cache[pk]
self._cache[pk] = obj
return obj
obj: CustomField = super().to_internal_value(data)
self._cache[obj.pk] = obj
shared_cache[obj.pk] = obj
return obj
class CustomFieldInstanceListSerializer(serializers.ListSerializer):
def to_internal_value(self, data: Any) -> list[Any]:
if isinstance(data, list):
field_ids = []
for item in data:
if not isinstance(item, dict) or "field" not in item:
continue
try:
hash(item["field"])
except TypeError:
continue
field_ids.append(item["field"])
if field_ids:
self.child.fields["field"].prefetch(field_ids)
return super().to_internal_value(data)
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]): class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
field = _CachingCustomFieldPrimaryKeyField(queryset=CustomField.objects.all()) field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
value = ReadWriteSerializerMethodField(allow_null=True) value = ReadWriteSerializerMethodField(allow_null=True)
def create(self, validated_data): def create(self, validated_data):
@@ -1157,7 +978,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
class Meta: class Meta:
model = CustomFieldInstance model = CustomFieldInstance
list_serializer_class = CustomFieldInstanceListSerializer
fields = [ fields = [
"value", "value",
"field", "field",
@@ -1223,6 +1043,7 @@ class DocumentVersionInfoSerializer(serializers.Serializer[_DocumentVersionInfo]
) )
class DocumentSerializer( class DocumentSerializer(
OwnedObjectSerializer, OwnedObjectSerializer,
NestedUpdateMixin,
DocumentUpdateFieldsModelSerializer, DocumentUpdateFieldsModelSerializer,
): ):
correspondent = CorrespondentField(allow_null=True) correspondent = CorrespondentField(allow_null=True)
@@ -1437,60 +1258,16 @@ class DocumentSerializer(
if tag not in inbox_tags_not_being_added if tag not in inbox_tags_not_being_added
] ]
custom_fields_data = validated_data.pop("custom_fields", None) if settings.AUDIT_LOG_ENABLED:
with set_actor(self.user):
actor_context = ( super().update(instance, validated_data)
set_actor(self.user) else:
if settings.AUDIT_LOG_ENABLED
else contextlib.nullcontext()
)
with actor_context:
super().update(instance, validated_data) super().update(instance, validated_data)
if custom_fields_data is not None:
self._sync_custom_fields(instance, custom_fields_data)
# hard delete custom field instances that were soft deleted
CustomFieldInstance.deleted_objects.filter(document=instance).delete()
return instance return instance
def _sync_custom_fields(
self,
instance: Document,
custom_fields_data: list[dict],
) -> None:
"""
Create/update a CustomFieldInstance for every (field, value) pair in
custom_fields_data, then hard-delete any of the document's existing
instances whose field wasn't included.
Replaces drf-writable-nested's generic
update_or_create_reverse_relations()/delete_reverse_relations_if_need():
that machinery always matched submitted items by an instance "id"
this client payload never sends, so its own pk-matching never did
anything for this field -- the real upsert semantics were always
CustomFieldInstanceSerializer.create()'s update_or_create() below.
On a partial (PATCH) update, DRF skips the "value" field's required
check on the first validation pass because it's absent entirely from
the payload item, not merely null. drf-writable-nested happened to
re-enforce that check itself, by re-validating each item against a
freshly built, non-partial child serializer before saving. Reproduce
that specific guarantee explicitly here, since CustomFieldInstance's
"value" is not optional.
"""
for item in custom_fields_data:
if "value" not in item:
raise serializers.ValidationError(
{"custom_fields": [{"value": ["This field is required."]}]},
)
kept_field_ids: set[int] = set()
serializer = CustomFieldInstanceSerializer()
for item in custom_fields_data:
kept_field_ids.add(item["field"].pk)
serializer.create({**item, "document": instance})
CustomFieldInstance.objects.filter(document=instance).exclude(
field_id__in=kept_field_ids,
).hard_delete()
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
self.truncate_content = kwargs.pop("truncate_content", False) self.truncate_content = kwargs.pop("truncate_content", False)
@@ -5,9 +5,7 @@ from unittest.mock import ANY
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import connection
from django.test import override_settings from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -15,9 +13,6 @@ from rest_framework.test import APITestCase
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.serialisers import CustomFieldInstanceSerializer
from documents.serialisers import DocumentSerializer
from documents.tests.factories import DocumentFactory
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
@@ -535,137 +530,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
doc.refresh_from_db() doc.refresh_from_db()
self.assertEqual(len(doc.custom_fields.all()), 10) self.assertEqual(len(doc.custom_fields.all()), 10)
def test_document_serializer_custom_fields_validation_batches_field_lookup(
self,
) -> None:
"""
GIVEN:
- A document is being validated with several custom field values
at once (as happens on every PATCH/PUT/POST)
WHEN:
- The serializer is validated
THEN:
- The referenced CustomField objects are resolved with a single
query, not one query per custom field
"""
doc = DocumentFactory(mime_type="application/pdf")
custom_fields = [
CustomField.objects.create(
name=f"Test Custom Field {i}",
data_type=CustomField.FieldDataType.STRING,
)
for i in range(5)
]
serializer = DocumentSerializer(
doc,
data={
"custom_fields": [
{"field": custom_field.id, "value": "test value"}
for custom_field in custom_fields
],
},
partial=True,
)
with CaptureQueriesContext(connection) as ctx:
self.assertTrue(serializer.is_valid(), serializer.errors)
custom_field_lookups = [
query
for query in ctx.captured_queries
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
in query["sql"]
]
self.assertEqual(
len(custom_field_lookups),
1,
"Expected a single batched query to resolve the custom fields, "
f"got {len(custom_field_lookups)}: {custom_field_lookups}",
)
def test_custom_field_lookup_reuses_shared_context_cache(self) -> None:
"""
GIVEN:
- A CustomField has already been resolved once, by a serializer
sharing a given `context` dict
WHEN:
- A second, separately-instantiated CustomFieldInstanceSerializer
validates the same field id, sharing that same context
(this mirrors what real call sites do, e.g. bulk-edit's
validate_custom_fields()/_validate_custom_field_values()
constructing a fresh CustomFieldInstanceSerializer per
submitted field, all sharing the outer serializer's context)
THEN:
- No additional query is issued to resolve the CustomField
"""
custom_field = CustomField.objects.create(
name="Test Custom Field",
data_type=CustomField.FieldDataType.STRING,
)
context: dict = {}
first_pass = CustomFieldInstanceSerializer(
data={"field": custom_field.id, "value": "a"},
context=context,
)
self.assertTrue(first_pass.is_valid(), first_pass.errors)
second_pass = CustomFieldInstanceSerializer(
data={"field": custom_field.id, "value": "b"},
context=context,
)
with CaptureQueriesContext(connection) as ctx:
self.assertTrue(second_pass.is_valid(), second_pass.errors)
custom_field_lookups = [
query
for query in ctx.captured_queries
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
in query["sql"]
]
self.assertEqual(
len(custom_field_lookups),
0,
"Expected the second, separately-instantiated serializer to reuse "
f"the already-resolved CustomField, got: {custom_field_lookups}",
)
def test_custom_field_validation_rejects_malformed_field_value(self) -> None:
"""
GIVEN:
- A document is being validated with a malformed custom_fields
entry whose "field" value is neither a valid CustomField id
nor a type DRF's own PrimaryKeyRelatedField can safely reject
on its own (unhashable, or a non-numeric scalar)
WHEN:
- The serializer is validated
THEN:
- A normal validation error is raised, not an unhandled
TypeError/ValueError escaping past DRF's validation layer
"""
doc = DocumentFactory(mime_type="application/pdf")
bad_field_values = {
"unhashable-list": [],
"unhashable-dict": {},
"non-numeric-scalar": "abc",
}
for case_id, bad_field_value in bad_field_values.items():
with self.subTest(case_id):
serializer = DocumentSerializer(
doc,
data={
"custom_fields": [
{"field": bad_field_value, "value": "test value"},
],
},
partial=True,
)
self.assertFalse(serializer.is_valid())
self.assertIn("custom_fields", serializer.errors)
def test_change_custom_field_instance_value(self) -> None: def test_change_custom_field_instance_value(self) -> None:
""" """
GIVEN: GIVEN:
@@ -796,72 +660,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
assert _cf_4 is not None assert _cf_4 is not None
self.assertEqual(_cf_4.value, date_value) self.assertEqual(_cf_4.value, date_value)
def test_delete_custom_field_instance_is_hard_deleted_not_soft_deleted(
self,
) -> None:
"""
GIVEN:
- A document has two custom field instances
WHEN:
- A PATCH request updates custom_fields to omit one of them
THEN:
- The omitted instance is immediately hard-deleted: it is gone
from both the default manager and the soft-deleted manager,
not left in a soft-deleted, not-yet-purged state
"""
doc = Document.objects.create(
title="WOW",
content="the content",
checksum="123-hard-delete",
mime_type="application/pdf",
)
kept_field = CustomField.objects.create(
name="Kept Field",
data_type=CustomField.FieldDataType.STRING,
)
removed_field = CustomField.objects.create(
name="Removed Field",
data_type=CustomField.FieldDataType.STRING,
)
resp = self.client.patch(
f"/api/documents/{doc.id}/",
data={
"custom_fields": [
{"field": kept_field.id, "value": "keep me"},
{"field": removed_field.id, "value": "remove me"},
],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(CustomFieldInstance.objects.count(), 2)
resp = self.client.patch(
f"/api/documents/{doc.id}/",
data={
"custom_fields": [
{"field": kept_field.id, "value": "keep me"},
],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(CustomFieldInstance.objects.count(), 1)
self.assertEqual(
CustomFieldInstance.deleted_objects.filter(field=removed_field).count(),
0,
"Removed custom field instance should be hard-deleted, not left "
"soft-deleted",
)
self.assertEqual(
CustomFieldInstance.global_objects.filter(field=removed_field).count(),
0,
"Removed custom field instance should not exist at all, even in "
"the all-rows manager",
)
def test_custom_field_validation(self) -> None: def test_custom_field_validation(self) -> None:
""" """
GIVEN: GIVEN:
@@ -1553,81 +1351,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
results = response.data["results"] results = response.data["results"]
self.assertEqual(results[0]["document_count"], 0) self.assertEqual(results[0]["document_count"], 0)
def test_document_update_custom_fields_sync_query_count(self) -> None:
"""
GIVEN:
- A document already has 3 custom field instances attached
WHEN:
- A PATCH request updates 2 of them, adds 1 new one, and omits
the 3rd (which should be deleted)
THEN:
- The omitted custom field is removed via exactly one
delete-shaped query, not drf-writable-nested's old two-step
soft-delete-then-hard-delete-after pattern (two delete
passes)
"""
doc = Document.objects.create(
title="WOW",
content="the content",
checksum="123-sync-count",
mime_type="application/pdf",
)
fields = [
CustomField.objects.create(
name=f"Sync Field {i}",
data_type=CustomField.FieldDataType.STRING,
)
for i in range(4)
]
# Attach the first 3 up front; the 4th is added in the PATCH below,
# and the 3rd is omitted (so it should be deleted).
for field in fields[:3]:
CustomFieldInstance.objects.create(
document=doc,
field=field,
value_text="initial",
)
self.assertEqual(CustomFieldInstance.objects.count(), 3)
with CaptureQueriesContext(connection) as ctx:
resp = self.client.patch(
f"/api/documents/{doc.id}/",
data={
"custom_fields": [
{"field": fields[0].id, "value": "updated 0"},
{"field": fields[1].id, "value": "updated 1"},
{"field": fields[3].id, "value": "new 3"},
],
},
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
delete_queries = [
q
for q in ctx.captured_queries
if 'DELETE FROM "documents_customfieldinstance"' in q["sql"]
or (
'UPDATE "documents_customfieldinstance"' in q["sql"]
and '"deleted_at"' in q["sql"]
and '"deleted_at" = NULL' not in q["sql"]
)
]
self.assertEqual(
len(delete_queries),
1,
"Expected exactly one delete/soft-delete-marking query for the "
f"omitted custom field, got {len(delete_queries)}: {delete_queries}",
)
self.assertEqual(CustomFieldInstance.objects.count(), 3)
doc.refresh_from_db()
values = {cfi.field_id: cfi.value for cfi in doc.custom_fields.all()}
self.assertEqual(values[fields[0].id], "updated 0")
self.assertEqual(values[fields[1].id], "updated 1")
self.assertEqual(values[fields[3].id], "new 3")
self.assertNotIn(fields[2].id, values)
def test_patch_document_invalid_date_custom_field_returns_validation_error( def test_patch_document_invalid_date_custom_field_returns_validation_error(
self, self,
) -> None: ) -> None:
-79
View File
@@ -1,6 +1,5 @@
import datetime import datetime
import json import json
import re
import shutil import shutil
import tempfile import tempfile
import uuid import uuid
@@ -22,9 +21,7 @@ from django.core import mail
from django.core.cache import cache from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import DataError from django.db import DataError
from django.db import connection
from django.test import override_settings from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from django.utils import timezone from django.utils import timezone
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from rest_framework import status from rest_framework import status
@@ -255,82 +252,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
doc.refresh_from_db() doc.refresh_from_db()
self.assertEqual(doc.created, date(2023, 6, 28)) self.assertEqual(doc.created, date(2023, 6, 28))
def test_document_update_tags_batches_tag_lookup(self) -> None:
"""
GIVEN:
- A document is being updated with several tags at once
WHEN:
- API PATCH request is made setting the document's tags
THEN:
- The referenced Tag objects are resolved with a single batched
query, not one query per tag
"""
doc = Document.objects.create(
title="none",
checksum="123",
mime_type="application/pdf",
)
tags = [TagFactory() for _ in range(8)]
with CaptureQueriesContext(connection) as ctx:
response = self.client.patch(
f"/api/documents/{doc.pk}/",
{"tags": [t.id for t in tags]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Match `"documents_tag"."id" = <literal>` (a single-row WHERE lookup)
# but not the same substring appearing as a JOIN's ON condition
# (`"documents_tag"."id" = "documents_document_tags"."tag_id"`),
# which is a legitimate, unrelated response-serialization query.
single_tag_lookup_re = re.compile(r'"documents_tag"\."id" = \d')
single_tag_lookups = [
q for q in ctx.captured_queries if single_tag_lookup_re.search(q["sql"])
]
self.assertEqual(
len(single_tag_lookups),
0,
"Expected tags to be resolved with a batched query, not "
f"per-tag lookups, got: {single_tag_lookups}",
)
doc.refresh_from_db()
self.assertCountEqual(
doc.tags.values_list("id", flat=True),
[t.id for t in tags],
)
def test_document_update_tags_rejects_out_of_range_id(self) -> None:
"""
GIVEN:
- A document is being updated with a tag id too large for the
database's integer column
WHEN:
- API PATCH request is made setting the document's tags
THEN:
- A normal 400 validation error is returned, not an unhandled
OverflowError/DataError escaping as a 500
Django's IntegerFieldOverflow guard converts an out-of-range int
into a clean "no match" for exact/gt/gte/lt/lte lookups, but not for
`in` -- the batched tag resolution uses `pk__in=`, so this has to be
guarded explicitly rather than relying on Django to do it.
"""
doc = Document.objects.create(
title="none",
checksum="123",
mime_type="application/pdf",
)
response = self.client.patch(
f"/api/documents/{doc.pk}/",
{"tags": [99999999999999999999999999999]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_document_update_legacy_created_format(self) -> None: def test_document_update_legacy_created_format(self) -> None:
""" """
GIVEN: GIVEN:
-58
View File
@@ -2,15 +2,10 @@ import datetime
import json import json
from unittest import mock from unittest import mock
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import connection
from django.test import override_settings from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -820,59 +815,6 @@ class TestBulkEditObjects(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(StoragePath.objects.count(), 0) self.assertEqual(StoragePath.objects.count(), 0)
def test_bulk_objects_set_permissions_query_count_independent_of_object_count(
self,
) -> None:
"""
GIVEN:
- Many tags are being bulk-edited to set permissions at once
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions
operation over a small batch vs. a much larger one
THEN:
- The number of queries issued is the same either way -- each
user/group is applied across all tags with one batched call,
not one call per (tag, user) pair
"""
group1 = Group.objects.create(name="perm-group")
permissions = {
"view": {"users": [self.user1.id, self.user2.id], "groups": [group1.id]},
"change": {"users": [self.user1.id], "groups": [group1.id]},
}
def run_with_n_tags(n: int) -> int:
tags = [Tag.objects.create(name=f"perm-tag-{n}-{i}") for i in range(n)]
with CaptureQueriesContext(connection) as ctx:
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [t.id for t in tags],
"object_type": "tags",
"operation": "set_permissions",
"permissions": permissions,
"merge": False,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for tag in tags:
self.assertEqual(get_users_with_perms(tag).count(), 2)
self.assertEqual(get_groups_with_perms(tag).count(), 1)
return len(ctx.captured_queries)
small_batch_queries = run_with_n_tags(5)
large_batch_queries = run_with_n_tags(50)
self.assertEqual(
small_batch_queries,
large_batch_queries,
"Expected the same query count regardless of tag count, got "
f"{small_batch_queries} queries for 5 tags vs. "
f"{large_batch_queries} for 50",
)
def test_bulk_objects_delete_all_filtered(self) -> None: def test_bulk_objects_delete_all_filtered(self) -> None:
""" """
GIVEN: GIVEN:
-224
View File
@@ -5,11 +5,8 @@ from unittest import mock
import pikepdf import pikepdf
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import connection
from django.test import TestCase from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from guardian.shortcuts import get_groups_with_perms from guardian.shortcuts import get_groups_with_perms
from guardian.shortcuts import get_users_with_perms from guardian.shortcuts import get_users_with_perms
@@ -22,7 +19,6 @@ from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.permissions import set_permissions_for_objects
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
@@ -348,100 +344,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
assert _cf_3 is not None assert _cf_3 is not None
self.assertNotIn(self.doc3.id, _cf_3.value) self.assertNotIn(self.doc3.id, _cf_3.value)
def test_modify_custom_fields_batches_field_lookup(self) -> None:
"""
GIVEN:
- Several documents are being bulk-edited to add several custom
fields at once
WHEN:
- modify_custom_fields runs
THEN:
- Each CustomField is resolved with one batched query total, not
once per (field, document) pair
"""
docs = [
Document.objects.create(checksum=f"batch-{i}", title=f"batch-{i}")
for i in range(6)
]
fields = [
CustomField.objects.create(
name=f"Batch Field {i}",
data_type=CustomField.FieldDataType.STRING,
)
for i in range(4)
]
with CaptureQueriesContext(connection) as ctx:
bulk_edit.modify_custom_fields(
[doc.id for doc in docs],
add_custom_fields=[field.id for field in fields],
remove_custom_fields=[],
)
field_lookups = [
q
for q in ctx.captured_queries
if 'FROM "documents_customfield"' in q["sql"]
]
self.assertEqual(
len(field_lookups),
1,
"Expected a single batched query to resolve the custom fields, "
f"got {len(field_lookups)}: {field_lookups}",
)
for doc in docs:
self.assertEqual(doc.custom_fields.count(), len(fields))
def test_modify_custom_fields_batches_document_lookup_for_documentlink(
self,
) -> None:
"""
GIVEN:
- Several documents are being bulk-edited to add a DOCUMENTLINK
custom field at once
WHEN:
- modify_custom_fields runs
THEN:
- The Document rows needed to reflect the symmetrical links are
resolved with one batched query total, not once per document
"""
docs = [
Document.objects.create(checksum=f"link-{i}", title=f"link-{i}")
for i in range(6)
]
target = Document.objects.create(checksum="link-target", title="link-target")
doclink_field = CustomField.objects.create(
name="Related",
data_type=CustomField.FieldDataType.DOCUMENTLINK,
)
with CaptureQueriesContext(connection) as ctx:
bulk_edit.modify_custom_fields(
[doc.id for doc in docs],
add_custom_fields={doclink_field.id: [target.id]},
remove_custom_fields=[],
)
single_document_lookups = [
q
for q in ctx.captured_queries
if 'FROM "documents_document"' in q["sql"]
and '"documents_document"."id" = ' in q["sql"]
]
self.assertEqual(
len(single_document_lookups),
0,
"Expected document rows to come from a batched query, not "
f"per-document lookups, got: {single_document_lookups}",
)
for doc in docs:
self.assertEqual(
doc.custom_fields.get(field=doclink_field).value,
[target.id],
)
def test_modify_custom_fields_doclink_self_link(self) -> None: def test_modify_custom_fields_doclink_self_link(self) -> None:
""" """
GIVEN: GIVEN:
@@ -608,132 +510,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
) )
self.assertEqual(groups_with_perms.count(), 2) self.assertEqual(groups_with_perms.count(), 2)
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
def test_set_permissions_query_count_independent_of_document_count(
self,
m,
) -> None:
"""
GIVEN:
- Many documents are being bulk-edited to set permissions at once
WHEN:
- set_permissions runs over a small batch vs. a much larger one
THEN:
- The number of queries issued is the same either way -- each
user/group is applied across all documents with one batched
call, not one call per (document, user) pair
"""
permissions = {
"view": {
"users": [self.user1.id, self.user2.id],
"groups": [self.group2.id],
},
"change": {
"users": [self.user1.id],
"groups": [self.group2.id],
},
}
def run_with_n_documents(n: int) -> int:
docs = [
Document.objects.create(checksum=f"perm-{n}-{i}", title=f"perm-{n}-{i}")
for i in range(n)
]
with CaptureQueriesContext(connection) as ctx:
bulk_edit.set_permissions(
[doc.id for doc in docs],
set_permissions=permissions,
owner=self.owner,
merge=False,
)
for doc in docs:
self.assertEqual(get_users_with_perms(doc).count(), 2)
self.assertEqual(get_groups_with_perms(doc).count(), 1)
return len(ctx.captured_queries)
small_batch_queries = run_with_n_documents(5)
large_batch_queries = run_with_n_documents(50)
self.assertEqual(
small_batch_queries,
large_batch_queries,
"Expected the same query count regardless of document count, got "
f"{small_batch_queries} queries for 5 documents vs. "
f"{large_batch_queries} for 50",
)
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
def test_set_permissions_grants_direct_perm_even_if_already_granted_via_group(
self,
m,
) -> None:
"""
GIVEN:
- A user already has view access to a document via group
membership, with no direct grant of their own
WHEN:
- set_permissions explicitly grants that same user direct view
access via bulk_edit
THEN:
- A direct permission grant is created for the user, not skipped
because they already have equivalent access via the group
Regression test: guardian's queryset-aware assign_perm() (routed to
when the target is a list/queryset) skips creating a direct row for
anyone whose ObjectPermissionChecker.has_perm() already returns True
-- which includes group-derived access. The single-object assign_perm
this bulk path replaces has no such check; it always ensures a
direct row via get_or_create. Losing that guarantee would mean
revoking the group's grant later silently strips access that was
supposed to be explicit.
"""
self.doc1.owner = self.user1
self.doc1.save()
self.user1.groups.add(self.group1)
assign_perm("view_document", self.group1, self.doc1)
bulk_edit.set_permissions(
[self.doc1.id],
set_permissions={
"view": {"users": [self.user1.id], "groups": []},
},
merge=True,
)
direct_users = get_users_with_perms(
self.doc1,
only_with_perms_in=["view_document"],
with_group_users=False,
)
self.assertIn(self.user1, direct_users)
def test_set_permissions_for_objects_raises_for_unknown_action(self) -> None:
"""
GIVEN:
- An unrecognized permission action name with users to grant it
to
WHEN:
- set_permissions_for_objects is called
THEN:
- Permission.DoesNotExist is raised, not a silent no-op
Regression test: the endpoint that calls this
(BulkEditObjectPermissionsView) never actually validates action
names against the raw client-supplied permissions dict --
BulkEditObjectsSerializer._validate_permissions calls
validate_set_permissions() only for its side-effecting user/group id
checks and discards the filtered dict it returns -- so a bogus
action key reaches this function as-is. Resolving the Permission via
a bare `.filter()` (which returns empty instead of raising) would
silently drop the grant and report success.
"""
with self.assertRaises(Permission.DoesNotExist):
set_permissions_for_objects(
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
Document,
[self.doc1.pk],
)
@mock.patch("documents.models.Document.delete") @mock.patch("documents.models.Document.delete")
def test_delete_documents_old_uuid_field(self, m) -> None: def test_delete_documents_old_uuid_field(self, m) -> None:
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1") m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
-58
View File
@@ -1,58 +0,0 @@
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from documents.data_models import DocumentMetadataOverrides
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.tests.factories import DocumentFactory
from documents.tests.utils import DirectoriesMixin
class TestDocumentMetadataOverridesFromDocument(DirectoriesMixin, TestCase):
def test_from_document_batches_custom_field_lookup_after_refresh_from_db(
self,
) -> None:
"""
GIVEN:
- A document has several custom field values
- The document instance has just been refreshed from the database,
which drops any prefetched related objects (as
send_websocket_document_updated does before building overrides)
WHEN:
- DocumentMetadataOverrides.from_document() reads the document's
custom field values
THEN:
- The referenced CustomField objects are resolved with a single
query, not one query per custom field
"""
doc = DocumentFactory(mime_type="application/pdf")
for i in range(5):
CustomFieldInstance.objects.create(
document=doc,
field=CustomField.objects.create(
name=f"Test Custom Field {i}",
data_type=CustomField.FieldDataType.STRING,
),
value_text="value",
)
doc.refresh_from_db()
with CaptureQueriesContext(connection) as ctx:
overrides = DocumentMetadataOverrides.from_document(doc)
self.assertEqual(len(overrides.custom_fields), 5)
unbatched_field_lookups = [
query
for query in ctx.captured_queries
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
in query["sql"]
]
self.assertEqual(
unbatched_field_lookups,
[],
"Expected CustomField data to come from the CustomFieldInstance "
"join, not a separate per-instance lookup, "
f"got: {unbatched_field_lookups}",
)
+9 -8
View File
@@ -178,7 +178,7 @@ from documents.permissions import has_perms_owner_aware
from documents.permissions import has_system_status_permission from documents.permissions import has_system_status_permission
from documents.permissions import permitted_document_ids from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids from documents.permissions import permitted_object_ids
from documents.permissions import set_permissions_for_objects from documents.permissions import set_permissions_for_object
from documents.plugins.date_parsing import get_date_parser from documents.plugins.date_parsing import get_date_parser
from documents.schema import generate_object_with_permissions_schema from documents.schema import generate_object_with_permissions_schema
from documents.search import SearchHit from documents.search import SearchHit
@@ -1133,7 +1133,8 @@ class DocumentViewSet(
"custom_fields", "custom_fields",
queryset=CustomFieldInstance.objects.select_related("field"), queryset=CustomFieldInstance.objects.select_related("field"),
), ),
"notes", # NotesSerializer nests the author, this avoids query per note
Prefetch("notes", queryset=Note.objects.select_related("user")),
) )
) )
@@ -4917,12 +4918,12 @@ class BulkEditObjectsView(PassUserMixin):
qs_owner_update.update(owner=owner) qs_owner_update.update(owner=owner)
if "permissions" in serializer.validated_data: if "permissions" in serializer.validated_data:
set_permissions_for_objects( for obj in qs:
permissions=permissions, set_permissions_for_object(
model=object_class, permissions=permissions,
pks=qs.values_list("pk", flat=True), object=obj,
merge=merge, merge=merge,
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning(
+11 -11
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-08-24 21:43+0000\n" "POT-Creation-Date: 2026-08-25 21:36+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"
@@ -1580,7 +1580,7 @@ msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:523 documents/serialisers.py:875 #: documents/serialisers.py:523 documents/serialisers.py:875
#: documents/serialisers.py:2827 documents/views.py:311 documents/views.py:2612 #: documents/serialisers.py:2827 documents/views.py:311 documents/views.py:2613
#: paperless_mail/serialisers.py:155 #: paperless_mail/serialisers.py:155
msgid "Insufficient permissions." msgid "Insufficient permissions."
msgstr "" msgstr ""
@@ -1621,7 +1621,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed." msgid "Duplicate document identifiers are not allowed."
msgstr "" msgstr ""
#: documents/serialisers.py:2913 documents/views.py:4606 #: documents/serialisers.py:2913 documents/views.py:4607
#, python-format #, python-format
msgid "Documents not found: %(ids)s" msgid "Documents not found: %(ids)s"
msgstr "" msgstr ""
@@ -1889,36 +1889,36 @@ msgstr ""
msgid "Unable to parse URI {value}" msgid "Unable to parse URI {value}"
msgstr "" msgstr ""
#: documents/views.py:304 documents/views.py:2609 #: documents/views.py:304 documents/views.py:2610
msgid "Invalid more_like_id" msgid "Invalid more_like_id"
msgstr "" msgstr ""
#: documents/views.py:1586 #: documents/views.py:1587
msgid "Invalid AI configuration." msgid "Invalid AI configuration."
msgstr "" msgstr ""
#: documents/views.py:1597 #: documents/views.py:1598
msgid "AI backend request timed out." msgid "AI backend request timed out."
msgstr "" msgstr ""
#: documents/views.py:2434 documents/views.py:2755 #: documents/views.py:2435 documents/views.py:2756
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:4619 #: documents/views.py:4620
#, 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:4665 #: documents/views.py:4666
msgid "Bundle is already being processed." msgid "Bundle is already being processed."
msgstr "" msgstr ""
#: documents/views.py:4726 #: documents/views.py:4727
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:4736 #: documents/views.py:4737
msgid "The share link bundle is unavailable." msgid "The share link bundle is unavailable."
msgstr "" msgstr ""
+81 -52
View File
@@ -5,13 +5,12 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from documents.models import Document from documents.models import Document
from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import permitted_object_ids
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import TaxonomyChoiceDict from paperless_ai.base_model import TaxonomyChoiceDict
from paperless_ai.client import AIClient from paperless_ai.client import AIClient
from paperless_ai.db import db_connection_released from paperless_ai.db import db_connection_released
from paperless_ai.indexing import _node_document_ids
from paperless_ai.indexing import retrieve_similar_nodes from paperless_ai.indexing import retrieve_similar_nodes
from paperless_ai.indexing import truncate_content from paperless_ai.indexing import truncate_content
from paperless_ai.prompts.context import ClassificationPromptContext from paperless_ai.prompts.context import ClassificationPromptContext
@@ -19,7 +18,9 @@ from paperless_ai.prompts.context import LocalizationPromptContext
from paperless_ai.prompts.context import RagContextPromptContext from paperless_ai.prompts.context import RagContextPromptContext
from paperless_ai.prompts.render import render_prompt from paperless_ai.prompts.render import render_prompt
from paperless_ai.taxonomy import AssignedMetadata from paperless_ai.taxonomy import AssignedMetadata
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidates from paperless_ai.taxonomy import TaxonomyCandidates
from paperless_ai.taxonomy import _node_document_weights
from paperless_ai.taxonomy import build_taxonomy_candidates from paperless_ai.taxonomy import build_taxonomy_candidates
from paperless_ai.taxonomy import empty_taxonomy_candidates from paperless_ai.taxonomy import empty_taxonomy_candidates
from paperless_ai.taxonomy import format_taxonomy_for_prompt from paperless_ai.taxonomy import format_taxonomy_for_prompt
@@ -39,6 +40,35 @@ logger = logging.getLogger("paperless_ai.rag_classifier")
TAXONOMY_CANDIDATE_TOP_K = 15 TAXONOMY_CANDIDATE_TOP_K = 15
def _fulltext_similar_documents(
document: Document,
user: User | None,
top_k: int,
) -> list[SimilarDocument]:
"""Rank-based fallback when no embedding backend is configured. Uses
Tantivy's "More Like This" (term-overlap similarity) instead of vector
similarity - cruder, but far better than no candidates at all.
more_like_this_ids returns only a ranked ID list, no scores, so weight is
synthesized from rank (descending from top_k) rather than claiming a
similarity magnitude that doesn't exist. A superuser is normalized to
``None`` before calling, since the backend's permission filter has no
superuser short-circuit of its own.
"""
from documents.search import get_backend
search_user = None if user is not None and user.is_superuser else user
backend = get_backend()
similar_ids = backend.more_like_this_ids(
document.pk,
user=search_user,
limit=top_k,
)
return [
SimilarDocument(document_id=doc_id, weight=float(top_k - rank))
for rank, doc_id in enumerate(similar_ids)
]
def get_language_name(language_code: str) -> str: def get_language_name(language_code: str) -> str:
normalized_language_code = language_code.lower() normalized_language_code = language_code.lower()
for code, name in settings.LANGUAGES: for code, name in settings.LANGUAGES:
@@ -131,44 +161,52 @@ def get_taxonomy_context(
user: User | None = None, user: User | None = None,
max_docs: int = 5, max_docs: int = 5,
) -> tuple[TaxonomyCandidates, AssignedMetadata, str]: ) -> tuple[TaxonomyCandidates, AssignedMetadata, str]:
"""One retrieval feeds both taxonomy candidates and RAG text context. """One retrieval feeds both taxonomy candidates and RAG text context. Uses
On any retrieval failure, degrades to empty candidates/context rather than vector similarity when an embedding backend is configured, otherwise
propagating the exception - a vector-store outage should not block falls back to Tantivy full-text "More Like This" similarity - see
classification, only its RAG-assisted enrichment. _fulltext_similar_documents. On any retrieval failure, degrades to empty
candidates/context rather than propagating the exception - neither a
vector-store outage nor a search-index issue should block classification,
only its context-assisted enrichment.
""" """
assigned = get_assigned_metadata(document, user) assigned = get_assigned_metadata(document, user)
ai_config = AIConfig()
try: try:
# None means "no restriction" to retrieve_similar_nodes. A superuser if ai_config.llm_embedding_backend:
# (like no user at all) can see every document, so skip materializing # None means "no restriction" to retrieve_similar_nodes. A superuser
# every visible pk into a Python list and passing it through as an IN # (like no user at all) can see every document, so skip materializing
# filter: for a large library that is a wasted quadratic scan in the # every visible pk into a Python list and passing it through as an IN
# vector store at best, and past ~32,763 documents a hard # filter: for a large library that is a wasted quadratic scan in the
# sqlite3.OperationalError (SQLite's bound-parameter limit) at worst. # vector store at best, and past ~32,763 documents a hard
# get_objects_for_user_owner_aware() would return every Document for a # sqlite3.OperationalError (SQLite's bound-parameter limit) at worst.
# superuser anyway (guardian's own with_superuser shortcut), so this # permitted_object_ids() has its own superuser shortcut that would
# changes nothing about which documents are considered -- only how we # return every Document's id anyway, so this changes nothing about
# get there. # which documents are considered -- only how we get there.
visible_document_ids = ( visible_document_ids = (
None None
if user is None or user.is_superuser if user is None or user.is_superuser
else list( else list(permitted_object_ids(user, Document, "view_document"))
get_objects_for_user_owner_aware( )
user, nodes = retrieve_similar_nodes(
"view_document", document,
Document, top_k=TAXONOMY_CANDIDATE_TOP_K,
).values_list("pk", flat=True), document_ids=visible_document_ids,
)
similar_documents = _node_document_weights(nodes)
else:
# See _fulltext_similar_documents: it applies its own permission
# filter via `user`, so no visible-document-id list is needed here.
similar_documents = _fulltext_similar_documents(
document,
user,
top_k=TAXONOMY_CANDIDATE_TOP_K,
) )
)
nodes = retrieve_similar_nodes(
document,
top_k=TAXONOMY_CANDIDATE_TOP_K,
document_ids=visible_document_ids,
)
candidates = build_taxonomy_candidates(nodes, user) candidates = build_taxonomy_candidates(similar_documents, user)
similar_doc_ids = [s["document_id"] for s in similar_documents]
similar_docs = list( similar_docs = list(
Document.objects.filter(pk__in=_node_document_ids(nodes))[:max_docs], Document.objects.filter(pk__in=similar_doc_ids)[:max_docs],
) )
context_blocks = [] context_blocks = []
for similar in similar_docs: for similar in similar_docs:
@@ -177,8 +215,8 @@ def get_taxonomy_context(
context_blocks.append(f"TITLE: {title}\n{text}") context_blocks.append(f"TITLE: {title}\n{text}")
except Exception: except Exception:
logger.exception( logger.exception(
"Failed to retrieve RAG neighbours for document %s; continuing " "Failed to retrieve similar-document context for document %s; "
"without taxonomy candidates or similar-document context.", "continuing without taxonomy candidates or similar-document context.",
document.pk, document.pk,
) )
return empty_taxonomy_candidates(), assigned, "" return empty_taxonomy_candidates(), assigned, ""
@@ -261,23 +299,14 @@ def get_ai_document_classification(
) -> ClassificationSuggestions: ) -> ClassificationSuggestions:
ai_config = AIConfig() ai_config = AIConfig()
if ai_config.llm_embedding_backend: candidates, assigned, context = get_taxonomy_context(document, user)
candidates, assigned, context = get_taxonomy_context(document, user) prompt = build_prompt_with_rag(
prompt = build_prompt_with_rag( document,
document, ai_config,
ai_config, candidates=candidates,
candidates=candidates, assigned=assigned,
assigned=assigned, context=context,
context=context, )
)
else:
candidates = empty_taxonomy_candidates()
prompt = build_prompt_without_rag(
document,
ai_config,
candidates=candidates,
assigned=get_assigned_metadata(document, user),
)
client = AIClient() client = AIClient()
# Hand the pooled DB connection back while the (slow) LLM query runs so it # Hand the pooled DB connection back while the (slow) LLM query runs so it
+27 -14
View File
@@ -33,6 +33,11 @@ class TaxonomyCandidate(TypedDict):
weight: float weight: float
class SimilarDocument(TypedDict):
document_id: int
weight: float
class TaxonomyCandidates(TypedDict): class TaxonomyCandidates(TypedDict):
tags: list[TaxonomyCandidate] tags: list[TaxonomyCandidate]
document_types: list[TaxonomyCandidate] document_types: list[TaxonomyCandidate]
@@ -105,10 +110,10 @@ def get_assigned_metadata(document: Document, user: User | None) -> AssignedMeta
) )
def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]: def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument]:
"""document_id -> that node's similarity score, summed if a document_id """Sum each node's similarity score into its document_id (a document can
appears more than once across the retrieved nodes (e.g. multiple chunks appear via multiple chunks/nodes) and return one SimilarDocument per
of the same source document).""" distinct document_id."""
weights: dict[int, float] = defaultdict(float) weights: dict[int, float] = defaultdict(float)
for node in nodes: for node in nodes:
document_id = node.metadata.get("document_id") document_id = node.metadata.get("document_id")
@@ -121,7 +126,10 @@ def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]:
weights[int(document_id)] += float(node.score or 0.0) weights[int(document_id)] += float(node.score or 0.0)
except (TypeError, ValueError): # pragma: no cover except (TypeError, ValueError): # pragma: no cover
continue continue
return weights return [
SimilarDocument(document_id=document_id, weight=weight)
for document_id, weight in weights.items()
]
def _visible_ranked_candidates( def _visible_ranked_candidates(
@@ -157,21 +165,26 @@ def _visible_ranked_candidates(
def build_taxonomy_candidates( def build_taxonomy_candidates(
nodes: list["NodeWithScore"], similar_documents: list[SimilarDocument],
user: User | None, user: User | None,
) -> TaxonomyCandidates: ) -> TaxonomyCandidates:
"""Resolve each neighbour node's document_id to a live Document, read its """Resolve each similar document's id to a live Document, read its
*current* tags/type/correspondent/storage_path via the ORM (never the *current* tags/type/correspondent/storage_path via the ORM (never any
possibly-stale names cached in vector-index node metadata), weight each possibly-stale names an adapter's source might have cached), weight each
distinct taxonomy object by aggregate neighbour similarity, permission-filter distinct taxonomy object by aggregate similarity weight, permission-filter
against what ``user`` can see, and return each category ranked by weight against what ``user`` can see, and return each category ranked by weight
and capped. and capped. ``similar_documents`` may come from either the vector-RAG
adapter or the full-text fallback adapter - both produce this same shape.
""" """
if not similar_documents:
document_weights = _node_document_weights(nodes)
if not document_weights:
return empty_taxonomy_candidates() return empty_taxonomy_candidates()
# Both adapters guarantee at most one SimilarDocument per document_id, so
# this never silently drops a duplicate's weight.
document_weights: dict[int, float] = {
s["document_id"]: s["weight"] for s in similar_documents
}
# Only .tags.all() needs prefetching (a reverse M2M, one extra query for # Only .tags.all() needs prefetching (a reverse M2M, one extra query for
# the whole batch). document_type/correspondent/storage_path are read # the whole batch). document_type/correspondent/storage_path are read
# below via their *_id columns (neighbour.document_type_id, etc.), which # below via their *_id columns (neighbour.document_type_id, etc.), which
+256 -22
View File
@@ -1,3 +1,4 @@
from collections.abc import Generator
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
from unittest.mock import patch from unittest.mock import patch
@@ -7,10 +8,13 @@ import pytest_mock
from django.test import override_settings from django.test import override_settings
from documents.models import Document from documents.models import Document
from documents.search import TantivyBackend
from documents.tests.factories import DocumentFactory from documents.tests.factories import DocumentFactory
from documents.tests.factories import TagFactory from documents.tests.factories import TagFactory
from documents.tests.factories import UserFactory from documents.tests.factories import UserFactory
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless_ai.ai_classifier import TAXONOMY_CANDIDATE_TOP_K
from paperless_ai.ai_classifier import _fulltext_similar_documents
from paperless_ai.ai_classifier import _restrict_to_shown_candidates from paperless_ai.ai_classifier import _restrict_to_shown_candidates
from paperless_ai.ai_classifier import build_localization_prompt from paperless_ai.ai_classifier import build_localization_prompt
from paperless_ai.ai_classifier import build_prompt_with_rag from paperless_ai.ai_classifier import build_prompt_with_rag
@@ -20,6 +24,7 @@ from paperless_ai.ai_classifier import get_language_name
from paperless_ai.ai_classifier import get_taxonomy_context from paperless_ai.ai_classifier import get_taxonomy_context
from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import TaxonomyChoiceDict from paperless_ai.base_model import TaxonomyChoiceDict
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidate from paperless_ai.taxonomy import TaxonomyCandidate
from paperless_ai.taxonomy import TaxonomyCandidates from paperless_ai.taxonomy import TaxonomyCandidates
from paperless_ai.taxonomy import empty_taxonomy_candidates from paperless_ai.taxonomy import empty_taxonomy_candidates
@@ -204,12 +209,10 @@ def test_use_rag_if_configured(
@pytest.mark.django_db @pytest.mark.django_db
@patch("paperless_ai.client.AIClient.run_llm_query") @patch("paperless_ai.client.AIClient.run_llm_query")
@patch("paperless_ai.ai_classifier.build_prompt_without_rag") @patch("paperless_ai.ai_classifier.build_prompt_with_rag")
@patch("paperless_ai.ai_classifier.AIConfig")
@override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model") @override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model")
def test_use_without_rag_if_not_configured( def test_use_rag_prompt_even_without_embedding_backend(
mock_ai_config, mock_build_prompt_with_rag,
mock_build_prompt_without_rag,
mock_run_llm_query, mock_run_llm_query,
mock_document, mock_document,
): ):
@@ -219,13 +222,13 @@ def test_use_without_rag_if_not_configured(
WHEN: WHEN:
- get_ai_document_classification() is called - get_ai_document_classification() is called
THEN: THEN:
- The non-RAG prompt builder is used - The RAG-context prompt builder is still used (fed by the full-text
fallback's context/candidates instead of the vector store's)
""" """
mock_ai_config.return_value.llm_embedding_backend = None mock_build_prompt_with_rag.return_value = "Prompt with RAG"
mock_build_prompt_without_rag.return_value = "Prompt without RAG"
mock_run_llm_query.return_value = NESTED_SUGGESTIONS mock_run_llm_query.return_value = NESTED_SUGGESTIONS
get_ai_document_classification(mock_document) get_ai_document_classification(mock_document)
mock_build_prompt_without_rag.assert_called_once() mock_build_prompt_with_rag.assert_called_once()
@pytest.mark.django_db @pytest.mark.django_db
@@ -303,6 +306,7 @@ def test_build_localization_prompt_preserves_unicode_characters():
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_assembles_rag_text_and_candidates(): def test_get_taxonomy_context_assembles_rag_text_and_candidates():
""" """
GIVEN: GIVEN:
@@ -344,6 +348,7 @@ def test_get_taxonomy_context_assembles_rag_text_and_candidates():
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_no_similar_docs(): def test_get_taxonomy_context_no_similar_docs():
""" """
GIVEN: GIVEN:
@@ -367,6 +372,67 @@ def test_get_taxonomy_context_no_similar_docs():
} }
@pytest.mark.django_db
def test_get_taxonomy_context_uses_fulltext_fallback_when_no_embedding_backend(
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- No LLM embedding backend is configured (the default test settings)
WHEN:
- get_taxonomy_context() is called
THEN:
- _fulltext_similar_documents() is called with the document, the user
and TAXONOMY_CANDIDATE_TOP_K
- retrieve_similar_nodes() (the vector path) is never called
"""
document = DocumentFactory.create(content="Some content")
mock_fulltext = mocker.patch(
"paperless_ai.ai_classifier._fulltext_similar_documents",
return_value=[],
)
mock_retrieve = mocker.patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
get_taxonomy_context(document, user=None)
mock_fulltext.assert_called_once_with(
document,
None,
top_k=TAXONOMY_CANDIDATE_TOP_K,
)
mock_retrieve.assert_not_called()
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_get_taxonomy_context_uses_vector_path_when_embedding_backend_configured(
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- An LLM embedding backend is configured
WHEN:
- get_taxonomy_context() is called
THEN:
- retrieve_similar_nodes() (the vector path) is called
- _fulltext_similar_documents() (the no-embedding-backend fallback)
is never called
"""
document = DocumentFactory.create(content="Some content")
mock_retrieve = mocker.patch(
"paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[],
)
mock_fulltext = mocker.patch(
"paperless_ai.ai_classifier._fulltext_similar_documents",
)
get_taxonomy_context(document, user=None)
mock_retrieve.assert_called_once()
mock_fulltext.assert_not_called()
class TestGetTaxonomyContextVisibility: class TestGetTaxonomyContextVisibility:
"""get_taxonomy_context must not materialize every visible document id """get_taxonomy_context must not materialize every visible document id
for a user who can already see the whole library: a superuser (like no for a user who can already see the whole library: a superuser (like no
@@ -379,6 +445,7 @@ class TestGetTaxonomyContextVisibility:
""" """
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_skips_permission_lookup_for_superuser( def test_skips_permission_lookup_for_superuser(
self, self,
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
@@ -397,17 +464,18 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes", "paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[], return_value=[],
) )
mock_get_objects = mocker.patch( mock_permitted = mocker.patch(
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware", "paperless_ai.ai_classifier.permitted_object_ids",
) )
user = UserFactory.create(is_superuser=True) user = UserFactory.create(is_superuser=True)
get_taxonomy_context(document, user) get_taxonomy_context(document, user)
mock_get_objects.assert_not_called() mock_permitted.assert_not_called()
assert mock_retrieve.call_args.kwargs["document_ids"] is None assert mock_retrieve.call_args.kwargs["document_ids"] is None
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_skips_permission_lookup_when_no_user( def test_skips_permission_lookup_when_no_user(
self, self,
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
@@ -426,16 +494,17 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes", "paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[], return_value=[],
) )
mock_get_objects = mocker.patch( mock_permitted = mocker.patch(
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware", "paperless_ai.ai_classifier.permitted_object_ids",
) )
get_taxonomy_context(document, None) get_taxonomy_context(document, None)
mock_get_objects.assert_not_called() mock_permitted.assert_not_called()
assert mock_retrieve.call_args.kwargs["document_ids"] is None assert mock_retrieve.call_args.kwargs["document_ids"] is None
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
def test_restricts_to_visible_documents_for_non_superuser( def test_restricts_to_visible_documents_for_non_superuser(
self, self,
mocker: pytest_mock.MockerFixture, mocker: pytest_mock.MockerFixture,
@@ -446,7 +515,7 @@ class TestGetTaxonomyContextVisibility:
WHEN: WHEN:
- get_taxonomy_context() is called - get_taxonomy_context() is called
THEN: THEN:
- The user's visible document ids are looked up and passed to - The user's permitted document ids are looked up and passed to
retrieve_similar_nodes() as a restriction retrieve_similar_nodes() as a restriction
""" """
document = DocumentFactory.create(content="Some content") document = DocumentFactory.create(content="Some content")
@@ -454,21 +523,185 @@ class TestGetTaxonomyContextVisibility:
"paperless_ai.ai_classifier.retrieve_similar_nodes", "paperless_ai.ai_classifier.retrieve_similar_nodes",
return_value=[], return_value=[],
) )
mock_queryset = mocker.MagicMock() mock_permitted = mocker.patch(
mock_queryset.values_list.return_value = [1, 2, 3] "paperless_ai.ai_classifier.permitted_object_ids",
mock_get_objects = mocker.patch( return_value=[1, 2, 3],
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
return_value=mock_queryset,
) )
user = UserFactory.create(is_superuser=False) user = UserFactory.create(is_superuser=False)
get_taxonomy_context(document, user) get_taxonomy_context(document, user)
mock_get_objects.assert_called_once_with(user, "view_document", Document) mock_permitted.assert_called_once_with(user, Document, "view_document")
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3] assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
@pytest.mark.django_db @pytest.mark.django_db
class TestFulltextSimilarDocuments:
"""_fulltext_similar_documents is the no-embedding-backend fallback: it
asks the Tantivy full-text index for "More Like This" neighbours instead
of the vector store, and synthesizes a rank-based weight since Tantivy's
more_like_this_ids returns only an ordered id list, no scores.
"""
@pytest.fixture
def fulltext_backend(
self,
mocker: pytest_mock.MockerFixture,
) -> Generator[TantivyBackend, None, None]:
"""An in-memory Tantivy backend, wired up as the module-level
singleton _fulltext_similar_documents resolves via get_backend()."""
backend = TantivyBackend(path=None)
backend.open()
mocker.patch("documents.search.get_backend", return_value=backend)
try:
yield backend
finally:
backend.close()
def test_ranks_by_rank_based_weight_descending(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document and two similar documents indexed in Tantivy
WHEN:
- _fulltext_similar_documents() is called
THEN:
- Each result's weight reflects its rank (first result weighted
higher than the second), not a raw similarity score
"""
source = DocumentFactory.create(content="quarterly financial report details")
first = DocumentFactory.create(content="quarterly financial report details")
second = DocumentFactory.create(content="financial report")
for doc in (source, first, second):
fulltext_backend.add_or_update(doc)
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert len(result) == 2
weight_by_id = {s["document_id"]: s["weight"] for s in result}
assert weight_by_id[first.pk] > weight_by_id[second.pk]
def test_excludes_source_document(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document indexed in Tantivy with no other documents
WHEN:
- _fulltext_similar_documents() is called
THEN:
- An empty list is returned - the source document is never its
own similar document
"""
source = DocumentFactory.create(content="unique unrelated content")
fulltext_backend.add_or_update(source)
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert result == []
def test_empty_index_returns_empty_list(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A document that has never been indexed (fresh/empty Tantivy index)
WHEN:
- _fulltext_similar_documents() is called
THEN:
- An empty list is returned rather than raising
"""
source = DocumentFactory.create(content="never indexed")
result = _fulltext_similar_documents(source, user=None, top_k=5)
assert result == []
def test_respects_top_k_limit(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document and four similar documents indexed
WHEN:
- _fulltext_similar_documents() is called with top_k=2
THEN:
- At most 2 results are returned
"""
source = DocumentFactory.create(content="shared overlapping keyword text")
for _ in range(4):
fulltext_backend.add_or_update(
DocumentFactory.create(content="shared overlapping keyword text"),
)
result = _fulltext_similar_documents(source, user=None, top_k=2)
assert len(result) <= 2
def test_result_shape_is_similar_document(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document and one similar document indexed
WHEN:
- _fulltext_similar_documents() is called
THEN:
- Each result is a SimilarDocument (document_id + weight only)
"""
source = DocumentFactory.create(content="shared content phrase")
other = DocumentFactory.create(content="shared content phrase")
fulltext_backend.add_or_update(source)
fulltext_backend.add_or_update(other)
result = _fulltext_similar_documents(source, user=None, top_k=5)
# rank 0 (the only/best result) with top_k=5 -> weight = top_k - rank = 5.0,
# per the "first result gets top_k, the last gets 1" formula.
assert result == [SimilarDocument(document_id=other.pk, weight=5.0)]
def test_superuser_sees_other_users_documents(
self,
fulltext_backend: TantivyBackend,
) -> None:
"""
GIVEN:
- A source document owned by one user and a similar document
owned by a different user, with no sharing between them
WHEN:
- _fulltext_similar_documents() is called with a superuser
THEN:
- The other user's document is still returned as a similar
document - a superuser must not be narrowed by the backend's
owner-based permission filter
"""
owner = UserFactory.create()
other_owner = UserFactory.create()
superuser = UserFactory.create(is_superuser=True)
source = DocumentFactory.create(
content="shared content phrase",
owner=owner,
)
other = DocumentFactory.create(
content="shared content phrase",
owner=other_owner,
)
fulltext_backend.add_or_update(source)
fulltext_backend.add_or_update(other)
result = _fulltext_similar_documents(source, user=superuser, top_k=5)
assert [s["document_id"] for s in result] == [other.pk]
@pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes") @patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve): def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve):
""" """
@@ -495,6 +728,7 @@ def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrie
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
@patch("paperless_ai.ai_classifier.build_taxonomy_candidates") @patch("paperless_ai.ai_classifier.build_taxonomy_candidates")
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes") @patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints( def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints(
+33 -31
View File
@@ -1,5 +1,4 @@
import json import json
from types import SimpleNamespace
import pytest import pytest
import pytest_mock import pytest_mock
@@ -11,6 +10,7 @@ from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory from documents.tests.factories import TagFactory
from documents.tests.factories import UserFactory from documents.tests.factories import UserFactory
from paperless_ai.taxonomy import AssignedMetadata from paperless_ai.taxonomy import AssignedMetadata
from paperless_ai.taxonomy import SimilarDocument
from paperless_ai.taxonomy import TaxonomyCandidates from paperless_ai.taxonomy import TaxonomyCandidates
from paperless_ai.taxonomy import build_taxonomy_candidates from paperless_ai.taxonomy import build_taxonomy_candidates
from paperless_ai.taxonomy import format_taxonomy_for_prompt from paperless_ai.taxonomy import format_taxonomy_for_prompt
@@ -132,9 +132,8 @@ class TestGetAssignedMetadata:
assert result["tags"] == ["Owned By Someone Else"] assert result["tags"] == ["Owned By Someone Else"]
def make_node(document_id: int, score: float) -> SimpleNamespace: def make_similar(document_id: int, weight: float) -> SimilarDocument:
"""A stand-in for NodeWithScore: only ``.metadata``/``.score`` are read.""" return SimilarDocument(document_id=document_id, weight=weight)
return SimpleNamespace(metadata={"document_id": str(document_id)}, score=score)
@pytest.mark.django_db @pytest.mark.django_db
@@ -170,9 +169,9 @@ class TestBuildTaxonomyCandidates:
doc_a.tags.add(tag) doc_a.tags.add(tag)
doc_b = DocumentFactory.create() doc_b = DocumentFactory.create()
doc_b.tags.add(tag) doc_b.tags.add(tag)
nodes = [make_node(doc_a.pk, 0.9), make_node(doc_b.pk, 0.4)] similar_documents = [make_similar(doc_a.pk, 0.9), make_similar(doc_b.pk, 0.4)]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["tags"]) == 1 assert len(result["tags"]) == 1
assert result["tags"][0]["id"] == tag.pk assert result["tags"][0]["id"] == tag.pk
@@ -197,9 +196,9 @@ class TestBuildTaxonomyCandidates:
document.tags.add(tag) document.tags.add(tag)
tag.name = "New Name" tag.name = "New Name"
tag.save() tag.save()
nodes = [make_node(document.pk, 0.5)] similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert result["tags"][0]["name"] == "New Name" assert result["tags"][0]["name"] == "New Name"
@@ -219,9 +218,9 @@ class TestBuildTaxonomyCandidates:
document = DocumentFactory.create() document = DocumentFactory.create()
document.tags.add(tag) document.tags.add(tag)
tag.delete() tag.delete()
nodes = [make_node(document.pk, 0.5)] similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert result["tags"] == [] assert result["tags"] == []
@@ -240,9 +239,12 @@ class TestBuildTaxonomyCandidates:
strong_doc.tags.add(strong_tag) strong_doc.tags.add(strong_tag)
weak_doc = DocumentFactory.create() weak_doc = DocumentFactory.create()
weak_doc.tags.add(weak_tag) weak_doc.tags.add(weak_tag)
nodes = [make_node(strong_doc.pk, 0.9), make_node(weak_doc.pk, 0.1)] similar_documents = [
make_similar(strong_doc.pk, 0.9),
make_similar(weak_doc.pk, 0.1),
]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"] assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"]
@@ -258,9 +260,9 @@ class TestBuildTaxonomyCandidates:
document = DocumentFactory.create() document = DocumentFactory.create()
for i in range(15): for i in range(15):
document.tags.add(TagFactory.create(name=f"Tag{i}")) document.tags.add(TagFactory.create(name=f"Tag{i}"))
nodes = [make_node(document.pk, 0.5)] similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["tags"]) == 10 assert len(result["tags"]) == 10
@@ -274,12 +276,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 correspondents are returned - Only 5 correspondents are returned
""" """
correspondents = CorrespondentFactory.create_batch(7) correspondents = CorrespondentFactory.create_batch(7)
nodes = [ similar_documents = [
make_node(DocumentFactory.create(correspondent=c).pk, 0.5) make_similar(DocumentFactory.create(correspondent=c).pk, 0.5)
for c in correspondents for c in correspondents
] ]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["correspondents"]) == 5 assert len(result["correspondents"]) == 5
@@ -294,9 +296,9 @@ class TestBuildTaxonomyCandidates:
""" """
document_type = DocumentTypeFactory.create(name="Invoice") document_type = DocumentTypeFactory.create(name="Invoice")
document = DocumentFactory.create(document_type=document_type) document = DocumentFactory.create(document_type=document_type)
nodes = [make_node(document.pk, 0.5)] similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["document_types"]) == 1 assert len(result["document_types"]) == 1
assert result["document_types"][0]["id"] == document_type.pk assert result["document_types"][0]["id"] == document_type.pk
@@ -312,12 +314,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 document_types are returned - Only 5 document_types are returned
""" """
document_types = DocumentTypeFactory.create_batch(7) document_types = DocumentTypeFactory.create_batch(7)
nodes = [ similar_documents = [
make_node(DocumentFactory.create(document_type=dt).pk, 0.5) make_similar(DocumentFactory.create(document_type=dt).pk, 0.5)
for dt in document_types for dt in document_types
] ]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["document_types"]) == 5 assert len(result["document_types"]) == 5
@@ -332,9 +334,9 @@ class TestBuildTaxonomyCandidates:
""" """
storage_path = StoragePathFactory.create(name="Invoices") storage_path = StoragePathFactory.create(name="Invoices")
document = DocumentFactory.create(storage_path=storage_path) document = DocumentFactory.create(storage_path=storage_path)
nodes = [make_node(document.pk, 0.5)] similar_documents = [make_similar(document.pk, 0.5)]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["storage_paths"]) == 1 assert len(result["storage_paths"]) == 1
assert result["storage_paths"][0]["id"] == storage_path.pk assert result["storage_paths"][0]["id"] == storage_path.pk
@@ -350,12 +352,12 @@ class TestBuildTaxonomyCandidates:
- Only 5 storage_paths are returned - Only 5 storage_paths are returned
""" """
storage_paths = StoragePathFactory.create_batch(7) storage_paths = StoragePathFactory.create_batch(7)
nodes = [ similar_documents = [
make_node(DocumentFactory.create(storage_path=sp).pk, 0.5) make_similar(DocumentFactory.create(storage_path=sp).pk, 0.5)
for sp in storage_paths for sp in storage_paths
] ]
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert len(result["storage_paths"]) == 5 assert len(result["storage_paths"]) == 5
@@ -375,14 +377,14 @@ class TestBuildTaxonomyCandidates:
tag = TagFactory.create(name="Restricted") tag = TagFactory.create(name="Restricted")
document = DocumentFactory.create() document = DocumentFactory.create()
document.tags.add(tag) document.tags.add(tag)
nodes = [make_node(document.pk, 0.5)] similar_documents = [make_similar(document.pk, 0.5)]
user = UserFactory.create() user = UserFactory.create()
mocker.patch( mocker.patch(
"documents.permissions.permitted_object_ids", "documents.permissions.permitted_object_ids",
return_value=[], # user cannot see this tag return_value=[], # user cannot see this tag
) )
result = build_taxonomy_candidates(nodes, user=user) result = build_taxonomy_candidates(similar_documents, user=user)
assert result["tags"] == [] assert result["tags"] == []
@@ -412,10 +414,10 @@ class TestBuildTaxonomyCandidates:
tag.save() tag.save()
document = DocumentFactory.create() document = DocumentFactory.create()
document.tags.add(tag) document.tags.add(tag)
nodes = [make_node(document.pk, 0.5)] similar_documents = [make_similar(document.pk, 0.5)]
spy = mocker.patch("documents.permissions.permitted_object_ids") spy = mocker.patch("documents.permissions.permitted_object_ids")
result = build_taxonomy_candidates(nodes, user=None) result = build_taxonomy_candidates(similar_documents, user=None)
assert result["tags"][0]["name"] == "Owned" assert result["tags"][0]["name"] == "Owned"
spy.assert_not_called() spy.assert_not_called()
+75
View File
@@ -253,6 +253,81 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["success"], True) self.assertEqual(response.data["success"], True)
def test_mail_account_test_existing_no_global_perms(self) -> None:
"""
GIVEN:
- Existing account without an owner
- User without any mail account permissions
WHEN:
- API call is made to test the account by id
THEN:
- API returns forbidden
"""
account = MailAccountFactory(
username="admin",
password="secret",
imap_server="server.example.com",
imap_port=443,
owner=None,
)
user = User.objects.create_user(username="no_perms")
self.client.force_authenticate(user=user)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"id": account.pk,
"imap_server": "server.example.com",
"imap_port": 443,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "******",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content.decode(), "Insufficient permissions")
def test_mail_account_test_existing_object_perms_only(self) -> None:
"""
GIVEN:
- Existing account owned by another user
- User with an object level grant but no global change permission
WHEN:
- API call is made to test the account by id
THEN:
- API returns forbidden
"""
owner = User.objects.create_user(username="account_owner")
account = MailAccountFactory(
username="admin",
password="secret",
imap_server="server.example.com",
imap_port=443,
owner=owner,
)
user = User.objects.create_user(username="object_perms_only")
assign_perm("change_mailaccount", user, account)
self.client.force_authenticate(user=user)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"id": account.pk,
"imap_server": "server.example.com",
"imap_port": 443,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "******",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_mail_account_test_existing_nonexistent_id_forbidden(self) -> None: def test_mail_account_test_existing_nonexistent_id_forbidden(self) -> None:
response = self.client.post( response = self.client.post(
f"{self.ENDPOINT}test/", f"{self.ENDPOINT}test/",
+3 -1
View File
@@ -2195,7 +2195,9 @@ class TestMailAccountTestView(APITestCase):
password="testpassword", password="testpassword",
) )
self.user.user_permissions.add( self.user.user_permissions.add(
*Permission.objects.filter(codename__in=["add_mailaccount"]), *Permission.objects.filter(
codename__in=["add_mailaccount", "change_mailaccount"],
),
) )
self.user.save() self.user.save()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
+3 -1
View File
@@ -106,7 +106,9 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
except (TypeError, ValueError, MailAccount.DoesNotExist): except (TypeError, ValueError, MailAccount.DoesNotExist):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
if not has_perms_owner_aware( if not request.user.has_perms(
["paperless_mail.change_mailaccount"],
) or not has_perms_owner_aware(
request.user, request.user,
"change_mailaccount", "change_mailaccount",
existing_account, existing_account,
Generated
+14 -4
View File
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'darwin'", "python_full_version >= '3.15' and sys_platform == 'darwin'",
"python_full_version >= '3.15' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'", "python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')", "(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
"python_full_version < '3.12' and sys_platform == 'darwin'", "python_full_version < '3.12' and sys_platform == 'darwin'",
"python_full_version < '3.12' and sys_platform == 'linux'", "python_full_version < '3.12' and sys_platform == 'linux'",
@@ -1175,6 +1175,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/d08f5c79f7643dbff4512605c28b75481966ed6c8cc9b397c9dd2ee91cd1/drf_spectacular_sidecar-2026.7.1-py3-none-any.whl", hash = "sha256:bc6d50c9b64660e45e09296d39553b3e759eedd825fc41d631ee5b3f88e0c5de", size = 2617384, upload-time = "2026-07-01T13:39:03.787Z" }, { url = "https://files.pythonhosted.org/packages/9d/76/d08f5c79f7643dbff4512605c28b75481966ed6c8cc9b397c9dd2ee91cd1/drf_spectacular_sidecar-2026.7.1-py3-none-any.whl", hash = "sha256:bc6d50c9b64660e45e09296d39553b3e759eedd825fc41d631ee5b3f88e0c5de", size = 2617384, upload-time = "2026-07-01T13:39:03.787Z" },
] ]
[[package]]
name = "drf-writable-nested"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/57/df87d92fbfc3f0f2ef1a49c47f2a83389a4a13b7acf62b8bf7b223627d82/drf_writable_nested-0.7.2-py3-none-any.whl", hash = "sha256:4a3d2737c1cbfafa690e30236b169112e5b23cfe3d288f3992b0651a1b828c4d", size = 10570, upload-time = "2025-03-10T19:59:05.482Z" },
]
[[package]] [[package]]
name = "execnet" name = "execnet"
version = "2.1.2" version = "2.1.2"
@@ -2887,6 +2895,7 @@ dependencies = [
{ name = "djangorestframework" }, { name = "djangorestframework" },
{ name = "drf-spectacular" }, { name = "drf-spectacular" },
{ name = "drf-spectacular-sidecar" }, { name = "drf-spectacular-sidecar" },
{ name = "drf-writable-nested" },
{ name = "filelock" }, { name = "filelock" },
{ name = "flower" }, { name = "flower" },
{ name = "gotenberg-client" }, { name = "gotenberg-client" },
@@ -3036,6 +3045,7 @@ requires-dist = [
{ name = "djangorestframework", specifier = "~=3.16" }, { name = "djangorestframework", specifier = "~=3.16" },
{ name = "drf-spectacular", specifier = "~=0.30" }, { name = "drf-spectacular", specifier = "~=0.30" },
{ name = "drf-spectacular-sidecar", specifier = "~=2026.7.1" }, { name = "drf-spectacular-sidecar", specifier = "~=2026.7.1" },
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
{ name = "filelock", specifier = "~=3.32.0" }, { name = "filelock", specifier = "~=3.32.0" },
{ name = "flower", specifier = "~=2.0.1" }, { name = "flower", specifier = "~=2.0.1" },
{ name = "gotenberg-client", specifier = "~=0.14.0" }, { name = "gotenberg-client", specifier = "~=0.14.0" },
@@ -5004,10 +5014,10 @@ version = "2.13.0+cpu"
source = { registry = "https://download.pytorch.org/whl/cpu" } source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'linux'", "python_full_version >= '3.15' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')", "(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
"python_full_version < '3.12' and sys_platform == 'linux'", "python_full_version < '3.12' and sys_platform == 'linux'",
] ]