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