mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-27 13:13:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b4f44096d | ||
|
|
2e7cd14964 | ||
|
|
5090ffe39e | ||
|
|
35d40b51cb | ||
|
|
78025df405 | ||
|
|
e24db7023a | ||
|
|
f5ddc14588 |
@@ -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
@@ -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'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'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="<a href="https://docs.paperless-ngx.com" target="_blank">"/>documentation<x id="CLOSE_LINK" ctype="x-a" equiv-text="</a>"/>.</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="<a href="https://docs.paperless-ngx.com" target="_blank">"/>documentation<x id="CLOSE_LINK" ctype="x-a" equiv-text="</a>"/>.</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)
|
||||||
|
}
|
||||||
+16
-8
@@ -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> →</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> →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ngb-alert>
|
</ngb-alert>
|
||||||
|
|||||||
+16
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -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
@@ -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},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}",
|
|
||||||
)
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user