From d6485268587ede1c13ef97f67ad2072ba8a944cc Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:43:37 -0700 Subject: [PATCH 01/68] Fix: fix v3 favicon file (#14014) --- .../static/paperless/img/favicon.ico | Bin 64603 -> 16540 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/paperless/static/paperless/img/favicon.ico b/src/paperless/static/paperless/img/favicon.ico index 89206e1f9e2b0b596a99c609189b13a81594bd7e..ee801cb3b346fcc6a69ab2ab3367df75bc8633d2 100644 GIT binary patch literal 16540 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 R@b7WUf8`z3_oLG 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 From 3899e0f0d664df665489baa13e4cc4a25c07aaae Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:51:53 -0700 Subject: [PATCH 02/68] Fix: correct add version actor parity (#14016) --- src/documents/tests/test_api_document_versions.py | 1 + src/documents/views.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/documents/tests/test_api_document_versions.py b/src/documents/tests/test_api_document_versions.py index ae14fb4cc..0ca39b6d0 100644 --- a/src/documents/tests/test_api_document_versions.py +++ b/src/documents/tests/test_api_document_versions.py @@ -598,6 +598,7 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase): self.assertEqual(input_doc.root_document_id, root.id) self.assertEqual(input_doc.source, DocumentSource.ApiUpload) self.assertEqual(overrides.version_label, "New Version") + self.assertEqual(overrides.owner_id, self.user.id) self.assertEqual(overrides.actor_id, self.user.id) def test_update_version_with_version_pk_normalizes_to_root(self) -> None: diff --git a/src/documents/views.py b/src/documents/views.py index 4d0a16c4a..fadd160c4 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -2091,6 +2091,7 @@ class DocumentViewSet( if version_label: overrides.version_label = version_label.strip() if request.user is not None: + overrides.owner_id = request.user.id overrides.actor_id = request.user.id async_task = consume_file.apply_async( From 340118ad516fba502a6098b5c7744ae4c5e4df42 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:53:12 +0000 Subject: [PATCH 03/68] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 9de00aa9a..80b4e9a3b 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-04 14:16+0000\n" +"POT-Creation-Date: 2026-09-06 22:52+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1628,7 +1628,7 @@ msgid "workflow runs" msgstr "" #: documents/serialisers.py:524 documents/serialisers.py:878 -#: documents/serialisers.py:2838 documents/views.py:314 documents/views.py:2623 +#: documents/serialisers.py:2838 documents/views.py:314 documents/views.py:2624 #: paperless_mail/serialisers.py:156 msgid "Insufficient permissions." msgstr "" @@ -1669,7 +1669,7 @@ msgstr "" msgid "Duplicate document identifiers are not allowed." msgstr "" -#: documents/serialisers.py:2924 documents/views.py:4624 +#: documents/serialisers.py:2924 documents/views.py:4625 #, python-format msgid "Documents not found: %(ids)s" msgstr "" @@ -1937,7 +1937,7 @@ msgstr "" msgid "Unable to parse URI {value}" msgstr "" -#: documents/views.py:307 documents/views.py:2620 +#: documents/views.py:307 documents/views.py:2621 msgid "Invalid more_like_id" msgstr "" @@ -1949,24 +1949,24 @@ msgstr "" msgid "AI backend request timed out." msgstr "" -#: documents/views.py:2445 documents/views.py:2766 +#: documents/views.py:2446 documents/views.py:2767 msgid "Specify only one of text, title_search, query, or more_like_id." msgstr "" -#: documents/views.py:4637 +#: documents/views.py:4638 #, python-format msgid "Insufficient permissions to share document %(id)s." msgstr "" -#: documents/views.py:4683 +#: documents/views.py:4684 msgid "Bundle is already being processed." msgstr "" -#: documents/views.py:4747 +#: documents/views.py:4748 msgid "The share link bundle is still being prepared. Please try again later." msgstr "" -#: documents/views.py:4761 +#: documents/views.py:4762 msgid "The share link bundle is unavailable." msgstr "" From ec70e4f42324ba4706b5a2b582cbb87bf45b1fe3 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:12:45 -0700 Subject: [PATCH 04/68] Fix: more v3 icons cleanup (#14017) --- src-ui/angular.json | 4 +++- src-ui/src/apple-touch-icon.png | Bin 6272 -> 7779 bytes src-ui/src/assets/logo-dark-notext.svg | 3 --- src-ui/src/assets/logo-dark.svg | 4 ---- src-ui/src/assets/logo-notext.svg | 3 --- src-ui/src/assets/logo-white-notext.svg | 3 --- src-ui/src/assets/logo.svg | 4 ---- src-ui/src/favicon.ico | Bin 111014 -> 0 bytes src-ui/src/icon-192.png | Bin 0 -> 7798 bytes src-ui/src/icon-512-maskable.png | Bin 0 -> 22504 bytes src-ui/src/icon-512.png | Bin 0 -> 8421 bytes src-ui/src/index.html | 1 - src-ui/src/manifest.webmanifest | 16 ++++++++++++---- 13 files changed, 15 insertions(+), 23 deletions(-) delete mode 100644 src-ui/src/assets/logo-dark-notext.svg delete mode 100644 src-ui/src/assets/logo-dark.svg delete mode 100644 src-ui/src/assets/logo-notext.svg delete mode 100644 src-ui/src/assets/logo-white-notext.svg delete mode 100644 src-ui/src/assets/logo.svg delete mode 100644 src-ui/src/favicon.ico create mode 100644 src-ui/src/icon-192.png create mode 100644 src-ui/src/icon-512-maskable.png create mode 100644 src-ui/src/icon-512.png diff --git a/src-ui/angular.json b/src-ui/angular.json index 9e715a809..c3801947d 100644 --- a/src-ui/angular.json +++ b/src-ui/angular.json @@ -71,8 +71,10 @@ "tsConfig": "tsconfig.app.json", "localize": true, "assets": [ - "src/favicon.ico", "src/apple-touch-icon.png", + "src/icon-192.png", + "src/icon-512.png", + "src/icon-512-maskable.png", "src/assets", "src/manifest.webmanifest", { diff --git a/src-ui/src/apple-touch-icon.png b/src-ui/src/apple-touch-icon.png index 46388a873d15b42999b0a173c8d33a224b80ca51..99bb19aba38d195257e0d46577c179fd1048b166 100644 GIT binary patch literal 7779 zcmb_>%sEMBCKIWlCXbCljsXAwuoV?#G+%h*e}oQtX+~ssdPEFggGjVGBU{PvnKjUkCs|f+PLEkw!T3|HuDLx>Gbt0{|3D ziZYVgUceJ$TWIn^ikOrQGR+>ad$FxJZ#dnWCV_OThi*!3ICO-c3ObV^9@Vt4+=x4o#HYD%Vfe zpt)kgw^io1T>V(mz$^{3P}ODYusl>0S9LPHm0)*UL00TBwFWM4Y z035mgPp5dM<9jsBs4*Jd8kL~FU!$X<6W+c1hMZHdV=X6TKaz@2ulQb~(lsGS)0%>s zpEv#*Vg?XJ`GlROl>zZ-5Rtzrrs%P+`N(`S3-I|4cSApNuxn?{C>Gd}a%$Vw-e#S% zKu?Rf)<)isPc)s1*wZ0a@-YwBUaiLK>NYkl-;+U0PXS&7WXL9|+}+$q2OcXEKe%f7 zoc>XgRQ*OcmuwJD4??7x>wIQ3(AJ{0hH^-;=zY14N}f$tmURE-cI$>_jU*;E09cks zT0Xd(JmnBd^Ht>7HcqHyubW;R9dvJ_U>C% zfUvJ{aa|hdSYy(%bfjj+m!V}eJ%Shcnyp=wga&6R7c1>ULk& zPfpGy^;qOCvS_ET;^#>Qb4_)FaRp5=^O8Fy2q6q*0zk*A-zMjj-V7&NA=t@8nX@el9NgK;YqLQ5ih@@&;#9IxgDb~wZ$uOw zmHpC(c7>FjV%h#O1M^8TNO-@)v^bFNtmM>n8|_BZ`~1^J{cbEVkz55dHa`Jjaz4+J zCfNH}N=HSF%%JS!>-UXjJ!$@iAJC@X#*1?!0Bgn&ju2F5O8@uyujn=!#K$2Q`iInq z0Bi_0|LWxFZlDlHCJ~YR807t;<#sHh|JANjivRD2hW&Wk@bWmIY8#Hl4kCreWb!Mx zhm(^cS)A%lqlVCwBrqt0jIB|YCsf<|l_TJU;j>#ubV+OHmkBR{KXfCO^B!^5CKBok zuDE4RXHE;tPcJge0(|1NRW(vK*-uhy;@=GGM_rK!We6Ehawrbv^;}6Mjz%+R#=k07 zwNLsnQf2?uzO2C5$!#eS@|?mVI5<8?oOCLbZl_BTIz>e#wMq7WuZ~K767I$UktF4u z7|#{!s!W4Db41S190j}QCLnqX8uRj`4bPhjz?aV>tLI1o>_lt}L}pyjS1Xo>Ms9z0R=U1gWs0u=8M4jUK5=p-DUS=$>CtWeCqM5yaJdbPp)U<4BtGsVvxxqRD&HAB z9@t2g{^pC}ilcq1R?B4HRm5ZW5feqLpXACTc5}&>bM(!)nm&Mm^dwi$epa*v;EKtW zV=s{|LkF2FNt>?khn1z*_i$~f$_T)KXPzs;{p{}PvbS7)cDGiUM*!NdpkVX3FN_7} z+r(O36l%HJakjo0Ifa^*K}3a}o(pJc8Ms9>2`WT|vHvp&|5}|C+&i(Zwm)WSB<$AR zt%zXyeHx_UTOco|>?V`k&Li!y0uZ(=@)SuwiO^3ys$~+>9Wed)Ro~QlL=<&5y=?!r zmgD&lSOQlq3r1yLF}oS9?S1x_QYa}Y+##Mgrk4#r7JGAlpEQ!(Ry!6J(r1TTW>c2P z$w8?N5vl;^jL*#UC%&U*$HSy}MLjgoujp2kfIyzM`5rJVmr9kl#zd9-On5PV2OQ^Qa7+)OGu42wDQInZlCh!kev&{9lm8MmXjSA{{C|-x#(uk|H+%P zQ2HS$ljC$QY_>T1ch+UvR(mo-VF8CA@Q$N|w^7>MTti@R`1=P7daN^d3J^ka0}HdU zD8FyE9dH$NO(%&xe7ko!7!#ZHt?>7ww{rLT7-0E~-v2zjn!?dl_#wFN%>tgAEw+cm zb4bI*NsCt*c*|nTzg-bl%MZh$4zjfn1HJBsUL!ZEy|3-mpCn@bL9c=j*uD6+Wt2mF zX#qk2KS^pG84G258$JI$d9Xnb)L*79Z{iq=+e{Q>1PWh&6tE7zsROzJ_Sazr z8xb|dqf~JnoFuL@6znr@&F|U^fj2OVPoEgPjqpw6+uy%08p4J@sRZ0mFXcT~Yw~dH zn7Yr_)p4gk>=RJE*G(K`uyd^6(teRax6y5}M*b@iOAxcu!imOO1wtCE?xF`$H3f#HkoP5F$B%;Tw5F&>B)TaZh2{WARpMmw&P>`cZP^=L zFbKgRI4E~A=rW47Eqqk#*(YE6`>+5E)kFe<1ZrWtiC=8}zoPC+0h@62yX|ZIJuwmy#Ik$qp|G zxqrlhWn`>o?baj>XpwyM7y%)W>rN+Eg#;{|~m!r4$ zh|r&VHmRtYGiB~w{{X#-4Ch<yFZb#SymIrMBGwa z9N%)eR`K!M2xES65}Qa`&tWF&>7buMH$*&&n|2aSjgQN`wqK#L2jH-SSt0BROlb}Y zkrvhLIU^V+K-F_d%HA+`8L)-Lb;mUzyb+Eg2bX)*CBoo2isbIwd5X!)BsH-3%`&Sh zw`pa%ts$vy898%ff^9M}IEvPMtbwQ1R%ytcqbIMHcVShIC$D0Ve1-pWAnK!#b?q7I z$T;y3c(*F`jD5AF;HaZ3O^(D4*G6glXlKc_rBL6;*C!11wLY{J2)uo!e7sCvSWO*- zto+*bf<*6+_uF2sRFuw!Bi0+S=AIhScvWqLQuYbwN(J=zW``FRV)Io_tPV~uYG(@Z z_;Tzs4~s%%iBCk}cKvd3i40_V$84WnMHyJ=>*qA zlqE0cpArlC>sNcFy{_M@I-HMy2O?H}^&K6j`ugJ5DWQej-8td&u##t^kH)m3!w0@z z$qPE@zEA#EflAXMjY=FG31fMpmjkDbcR_i8AYPK7Ai+oBJ%-u%?=S2?*_uIvSKhePqftY?5xG^1W8M%{#}$SKusqAVb;5DkCQtVjN~Y z%1yG)x9iu3Pd2n9BZv`Ap9^Mr7Ze5TxZ1(m1k;yGjul$zwwJ$`t+j_3Jr_j%{ODQc z;@;%5D^ljxSa;tZOsI1PO06yaEl`opv3`&1*s6!gwLr^}!AEPPXu|}3TV5XUc(&PIzuoc^PK##!M)ZAntUcl2lr4{1EkCeFH-fNVfzAcg!__jrCtT{UWKj%9mI_Q zIjg;ivkH3cn_^^JO1+Zfv2iieC?Bt6JgK&S=0A{{56Nht`=mq@KJ$lM#5=uWku^ zM#5gmFCKk|lwtmXj!s7|Y$$vmC`d|#r|2Y2b;c?W!-j|^nstX~Jv#aF@aLYe2qmNH zW``u$Xd!0qHJ|zRubcIGKc<7($ zX*>ZIwJ@b=NiAL9UwC`bzDsgtW^^X8_qCK;M$>K3%9!)5)zV9pMm=sM*e@=Z z*0}q(j_#i?xsJ9cCOCT&ujt$=l`j6ew&#{Hz!e=z=7;_yWv_9;I}0hSaPh6^f?cLB zYm^}RsdSk*5J}4SA(X4y_WP%dWvyh<@(MdEip+_xNzTfIE9eS#ff=#(}(r{xAWB(mQ+=`Oc-XU7v7xh_at<5YXIyNW-HLyfTSa`|nx#RI} zF(NjZL&4r>W|wiw6;Py(QIX_+hYQ_%qc>^5(!F$nc{i{n&TV!&1a<^YV2BY6shm4H zgWQ9jKEzt)YU3oW`~TBp=@W7yoc$`M(czyfDd$JL z{O$H{_EBt;^_90)e_C|hD9=WF0)$NlrS>3uOiFH$(IOIvf;~(*&byK2xfmeI7@I{s zRpX@@aCI$Sv90?alI^0{Ds9E(_H;j@)3YLE;|Go?EuXkKy$o8l6I*<>44}5`<-Hoipz-vPxOM z{M!GPl`6pc71EY76tL6}_p_;M0s_gPQV?j8MB0Z}YNi)<^HsWl4<&v#q8?@HA|=8;&y` z#G=Wac_R{sm2Gru9QhhzPR*-%s8bxq+tpDZ_#;R)~?DZ$zXuLaEqDaOYvgD!T7Us1J z=bbDc)|&RFWTQCJ((UCJo#ZrqJ467wqauKh<(YKrB|O|xaXza(AC%`+un|o6Vokk; z^TAs~g%+_KbGjuN`zWxry_;DCtgR$s1l{m^l&Ar#QLiBR*&D}(TO)z64p;; zH|5WN4HRPGRw7o-sWe*ncXxTVqZdE=h$Ks!IuM<&CE+UpRfK*Z3_G}W^ZF~RB)vEt z?1Dk@sgfpU3D2zL5Mv|$#|gTDYj_%Cf2xaR{K0H{5#Xl zw@cp~Dy2I&3ntE^kd_BsuT52XKe09UF(>M07Qdl(0B_o763(=96!4z8FJ>MPsgP3O z0x3$WsOuhC{8OQD6J?6$ci$(RlOf7lV29sdP%rE@OjgXeBVoQF6lG(NDDwlHRgQ_! zPTJ^y9Q7#hRwDyp6XRRtkxDkrtTvpF%zirdQ0n4W(|04#(B3BuM45;%3yZKVk!_SliFk;| z)SElPu?=JDHQ)YfO#6{&3To@Qs{Ba?Z>7vHChUmK`dPfPUVWTA9X&iISADo#;xkpm z8^pGmh?2&4vSx@E^38D*bd8+td~_%qgi_V+YY0LZebM@3*7-iQt21(qbzJ;fS91ng zp;U=^<=4cQak}h-{A|Lsvf1G0{wyv=$@y^ung;yRLI4hM_20hq8F2dx9^NQ-gWZkh zl89&ZA#ixZ_kG%?&le1uyE#o5W7uyz!RzVeGi7Dp%16^f`O%p@%(C(}LT%zP$0F;W zqj+FGa(E660j1JY>Gi2Zk2rD$NQS3n;q{e@ih^Qh{u{bu)A|4o@?JWR{xafilZ}WS zfNCNF^>H^fjfVbgq+P*pLQ+dj$9+9z_Dyo?ZBbo`%wkopL=Mgs1qBNV4{YYoBm%y}37xOKhr&7suAc(zW2G^CGOHhbvMJ0vk?I>?ylFKj8{n zSiS!(8V<0VuRl6Gl{@Q0Do%rLHNS=%p|_v+@m+97)o&Y}GHlZZ=wb8mLmG?dr$T-G z5~`43WNzK)f41;HZK(o2oO3<)B(Y)IpxSxi21W<7%qn~4KBDZrs!;@MR*LXde!zx{ z87*kWb*-In(-8Y`)3aS&-{T7FtL+t)|8*$!VodMuepK}KpTPuNOD!gsX2wrG%V7TW zRYuO_RVGh+TU-4P*du5nCfuN>*)SN_H=~0w^$qH5%LOm-GVa5(1?*p> zp*2QAdaet=z7&{16m#4~HK_r+p1u&HqvwKxadtt6wS+illB-DQF@uwu0PYH(MF6$1+_@xENmYz9y2 ziZUq6w@za2dAM&dA+r39ezA~}XBTJA1(^<3D#6EK`|`>3d?r*r=*0_HgnTkUAuXZus$B7x&bm^6LMH*_bZJ49(a``^r5BNpKh5 z-_>mrE_-iLN3&nyOF-Hb1Iu25DNb2RGDCLwl#>16t&qnnX8pbRgddq6os&QK z<0q2_)wHMe8Kw`%lzDhG%szev?_RJ=Ub1ebI8z%qJ1++m>ovHC@!-g09B#-OVLmN| z1|R;*A)ALW`jS43r}o;m9$aF-;X0Q4RlPj2NnOOF>eJ{zs+IF>wu==Z3eO1Yv^f?jHrE`bWC1@~ff2DvWPY*w0$1%H5_uCzhmw;#mE%PAHbK3tJ(-AmeP) zqK|~zr`B%~IrCUCR$cRh4{jaCAxPDScaA~IC(9iBrRo==?wNTw8`lsG)E~O8a{uBD zF-VE@ZU&_bjhW-xI00-ohs)pgFxIIDhPy!!(kpvS7B9Ewgi|7P3#Y|fYwag3_3@qOU ziuWd+b@#S9o;8#Vy~6pybuNgTfjCjktUfcjK#Wjxx@-8R&Of0C|M04W1trB8d~^qz zp<&@>9m-d8eOUG%dH~A?kK7IJ(n036((U@kcshqTo&n*mNT#dgaE{wr_3 z3(q)cD|xAD+TW)a5*NOJ0a8fGQ6>@yQFCAsYY_bch%M4HYV2Rux0l2T+5PVoJ&KEy W@2w48c253R>{XOilc|w13;rJsjryek literal 6272 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 diff --git a/src-ui/src/assets/logo-dark-notext.svg b/src-ui/src/assets/logo-dark-notext.svg deleted file mode 100644 index 3c5fe1777..000000000 --- a/src-ui/src/assets/logo-dark-notext.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src-ui/src/assets/logo-dark.svg b/src-ui/src/assets/logo-dark.svg deleted file mode 100644 index a512fe8c6..000000000 --- a/src-ui/src/assets/logo-dark.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src-ui/src/assets/logo-notext.svg b/src-ui/src/assets/logo-notext.svg deleted file mode 100644 index 900e47c82..000000000 --- a/src-ui/src/assets/logo-notext.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src-ui/src/assets/logo-white-notext.svg b/src-ui/src/assets/logo-white-notext.svg deleted file mode 100644 index 8db327394..000000000 --- a/src-ui/src/assets/logo-white-notext.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src-ui/src/assets/logo.svg b/src-ui/src/assets/logo.svg deleted file mode 100644 index a512fe8c6..000000000 --- a/src-ui/src/assets/logo.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src-ui/src/favicon.ico b/src-ui/src/favicon.ico deleted file mode 100644 index cb57d8b2b1e8b91c9aba08f0b59caa0f4f8808dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 111014 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 G=>Gv_RmYtG literal 0 HcmV?d00001 diff --git a/src-ui/src/icon-512-maskable.png b/src-ui/src/icon-512-maskable.png new file mode 100644 index 0000000000000000000000000000000000000000..cfc6892816f083ab28cdcb83ea58bec0b4300d09 GIT binary patch literal 22504 zcmeEu^@_&p+_IIxqNe4eY(Iz1Lp(U29F4w&pW3lDi}z5Qt3W`4b%w2m(ApKtu$< zucP;)7r-yDyUw%6pt1pm4dBBg8$%Ua4Gj<<@Js{(huVYiu7?0WcYz;ZY8K?b{sIAI z-T1%H*Z<@ZNkW1^3LupyiZ9=TcjgHFsckM$``j(RrH8)|k+9JUShRgCutR5F$?(Hu zn=)G_swAjsXr2gUS>V$setu3BJ+JncQ+CSn05)$S60ITHG?kGtbsm9`j-5;KSvb8U z?-N5ebC$i7uv7wp@hB8PU@hWyzoKet+W&mQ>%k)e)l*#m1CL@a3j~G}@@KHI!$A-t z76lOM;Xe;>I3Yd}NXBr3n*KktAfU2;r_1mtfEItK=64aXTo0uHxsv_!z_O?ahJbGA zODRA7=e=+c+~MC1+#;a>y{{PN6$M@a%)tYh-1yJr_u6m}DkwoShVq}?uz(it{_~)K z3Iawkm3Vv(zup!cAnwF}9w1EAETBD$TwUvbj&^+%g8w}V81H{?<$oFM|Cg*R3I)c- z-ZpBruZiD;IT8h{!9gr0vFU_>g7^eiu(vNPnXlBJzw${wyTTIXpmrViE@{`3P6(?cq6ysud#UqE*;u1*aQ|@RLNl(#bVR;Nf>0K+OM=wzi zaDyiI4H6vmloUTaWt3K290nIfq~3U4F6YiM5Y4PF{yI#NyYHQh@4P2)gAVMD01K z+F8bfWeT>oWTs);#eTo+D=aYfnC{YM|6;Y$ zcdJps{E!}!2#glE|DEc3v=R95NZ$G!5~lRqG17|MYsIE}RC$CS+CE5x$Eg6K zpr(_)zOf}b5iQ)>FL|rjflKqZOS|PpJmU=zE|vzR*GSGjO#uJfruo zl%VviYT{)DCv&il?KA~TY$JYb0>X6vHS{T}5-_DUd|v-Bsj!9bC9ZJ!LO%V`<31DI z>Iil`UMH*;R5DrrO8RD5sl7q#GZw)1Dk#v(T~FAi?Befp6onJL**J$>q$g z;AAEv+p>|BA*|0A&TC)zAX9&@uxtgKOT!%JP_ z{au^^_Ose&o9W>c%09vTNIhVzVX0A12nZ5MEuwSIGtUj9R13l5swz{!Y$1)>uWoLW)-dTS-76vM&t4>?V? z`4gx3*HvD8FV8bQX9_e0#H5Mm3=C|T2nkOq^Zwf&b1)xlsiU)f3kLChI&#Zmw@99I zA&EKW(0zCkaEdZYeu5E@{b4qFU_!xhoWVpfIwVm+y?FZ5cA>sobuhVM*NOC|_vcnag;PpM0M@KT3 z@n5wFLU*$4I@g4cdztxDD2K?m3{`?aEc$O_-B{pocDz169Pby~t~m2np7V~zp(u^C zl-uvS3CzHg?Tl326rlDe4|Ry}C|p0F2K2D{Jv+>@1!9LbZ%;8I{U@)#x^nGA7#UkX zgVu($OyPqm0Y4Q2c!n(MkhDtiUfH(H=mmqmM4bS(I(IQK$Qt#^TZ)SA0c(}s4uHTy zWipkKAjkk^mvCRw`mbhQb{Ote=jDvwp#(|p#ljk+vGp<6IG*c(-qLnD1?W)|w2Ki! zRIh;YxVVQo>^wM-2+FCS%+!P>7#=w(DXM&RoKz%L;JhbC1o+4l(IL04-C74o-pIma z1Jz25aOOzL8JomZRKje0dqU5qU^U3_yA|`rzaBIz>Gt-%ys0z28=}>3fuWWL`?xwzHD#=|FAVDLMLv3qSd?4u0FYufSkGC(u)7WF56&OzOykyC*^`GG2Of4&` zKmjX_vG;MG_4~trAUWJo4Jn!Lc@BD}AK50VObTz~3*H7!DTd#-3AO#k?0zVGL?bB{ zm&#wow|Ex<>Ku=X6MYI=%$a5p2AKXiSnT(p@v>Ze{?nG~!!>lc91L8l4yu68{DLp+ zvqES9wZ->#PYh676`}C*NpAF&j7b_65xxB(y&&p%f!!|w`mh|N?`&kc;V!3Op#|t6 zS>-cEK>W_sQ0fI@2d6Hpfm(>z5{RkI;14bAYJ&c^6q6=p12*7d2!PRd;b(Wjc+I>P znHc$$g9OZi-{~giLaG;WwRIUxP_C%zteELI*h{kWKJp+O5EQ$j)DLWMxE}a$1=msS zvT|~ZIsVw_9;4Xrs02)!%ID7*xv9juIY9R>TVBUi26D>JK!H7wwQgAZ=x-Y>Y|4zp zBdgB~Jq;z;xOD#vQj5cjP47R(gMf?i9(x-E7?mFU9t6gP(GBU?HNR5CjXMyHf6ELK zdu!<@R}SjYI1{S?43Gt#oH7U;MI0}GzJS5PY_7Yi+K5B@WTm|2sy_}nGk;FV7iEv%#oI%6M1x?h031OJI(&KH+UW4TPEK$- z&g1jgGU!3Mwht$9ZV93Gk09n7q!qOPzVsGRavE-r*Sv?3S%zb)YHn->nYX#W@f;+M zS40KymS!j#;B;I3zFF+x)+|;l;Jng$g9Mh#DU`h0Y2}n$>DV*>O&Fresfl~7#1ai0 zhJ{8>2^5%2E7Gz;7;*WW>GBWk+V%QOkNr?eQN;yMp<)q=1hE)Omv3t^gUUxAtO9yo zG>~D36}Nec?zuw9&^JlIoWPSYRZ0h*=Kzxz-Oj?#;-i-XXrD~gCD@hF^ifU-jlslF zC#SvMuD(E<8N4cE^qKlILQghDQJ@O=SpSZu6wz3^=j9k{_Y5f%1ij0qto(gJaiAF| z!GEKYRXYLLt)8eqpndMMS~z33yd9$h(R1A9{bvJI61dw)^V&7%BnGGbH!t%Q)koPw zh>*a9GYv_0z`1>bn#IuB3dLN8%4hdpKm|KG=Iw7wlt22%BadI)FGfXhtcRrna6Q~tdz3&0G=(fK4SpmneZS(oJSIDeW@v zhu_3*WG83L3CBx?N4>6`(4K8}yJ-+=@HW+rjUot=7X}+!d+64-=(p1U9sTaGT)Xp^ z?cB*Rjoi{RJTJN|d<*YePyifBBHCtHlQ7@CIwL;EMj_hQ4+rkR`1P!xLL5hqL6iUE zHo=`F$(B(0fh#@dt37B6(m9OU{qUBdK_(iHUOVJgFQ8p|IM1j6TC$5+ZN6z(Uw&qj zD~?N|_H#`}bfjAGT71!Fnu`FTvT3xwQ@R$TqRpRnW!NU0Cz2nI*tkpqZ2kbakD=K| zBw##8T9GzP=AH9OnZq%DkCUpY%oQtLAjA^iO3-1Y(1jSg)poZbNeA{x=H;M$U$S$izm<0U$$@nVOs8VUI|hC^Tn!#Uw=Qy-Rjvab&#z~MSLj=D`_+Y zR_I80Hct6T87%e-cYfccCyw1b-pNP$?*{Xi1oadKR$zrsZ&|2_SS;||NZeLgceYQb zZv}Cdiy-g=>IHxZ1>j=5cajewM22)-v$cw4%>McQvJNHG=l-*p(h(${uCg*Rkal_EyS7f>}CFO zi~HZU%5RRZ%zZCHi|6|updyCvecbX$=O(xwSfRsv8{$mC!z&n*kM!5eJU^h3x>PRs z`5vEwb@Gm+AXp*%B}az>N&_;5FL|&Kg_xDBLTGWHF6q^z6-71w`UGtLKO+Po4z;#D zrb^5Sl)BG0`k>QOQ9wZRjSaB+G+wurQE3pQaE2i66x$-|bS|*}?l5XrnSxtKLQ-j1 zM~6L$z|N}$FacY|(GZqJ^1yPt$b>-OL-W>m3GruYvoZEbYr`HObq&p!_;%`~M7ZM& zKv#=UQG1p}OYq1Px(2;4K@81I6r7sLsg9?^QX(0!Y`>)b43}vFH#9c(Y>m>j7M?PN z{yk_OO^5W+CGigd_~?71sLn7aFKhQNYLy-*!2NM9zJKrqu7)@6Xq1n>#PMg_LAC7U z)3yZUSQYQuJcyUFrx(vxcXI~pysNHLi~*hrn~2>QrZNo|jIWzKC9LXcYt|AUKQzIn ziYa@Z2tv_}J>jBsjY8E`!D46R{1t$pxv2L{wAAInTOlDi&D2pBjtyS>-_J82m;$6) zjQz}(M)b%uYtmdm*kAT}=H261$L_HKD5uBC7u$?n2WEJD&dM9xz-mSzVDugdn;%iV zB1<`sIn(*JkI(*A9mj%!f*q2_AT<&)$_n>Al(?Ua?fdvY0**v{aT}Ky<9Vd{Q_M-O z&~k5eYvK}HeYOw&kVtl5^7;QGCF*Q_5lufRraCvv5yMV%sTT% zpZ_Yordg@vL4-m){;&QFZLdR|nEUs$0ARarp4*B3>0VeD{knZ;#6Bs+4~XBld4Hmb zShTZIi_D?j0SGzXtvV;`0;7mm;^?eWtnJ-Jf{kM*&E%C?F5q~zciE9dPhOyoj$r3a zKMoc@<#Y1Orwu?~lziEY)l^fB%R7DpE*SWPegZ(AB!F6tRnRx9mAQYw=@V!lAn2p) zz&)}rtZWQQ9e220DiB?!2FQ?!&I@a*z&9i%Q>nbCP)zVm#6<`-t-TgZe4XU)kbD)n zLUv;_#MPNolHCpuG*Ce376^ZhpXg-5Yk++BMcw3qp1;S5yQ6G^T}cVP!a8WOSnQoiMK%kZPZ-zr6woiIQnq2zUuh&BB&5dkmEIGBpl&C2dIK)e z8NR|krD45uVcD8D0wdzxOid@2k4!5k5`$zZ2j#_sJ>S-JW>5#h^nwGJ_$tN(m5oW-SY)Zkl|@a zDt?||nU2m*m93|xoa_%}gR>>u9e`UfD}LAW1x^xE!YtW-DHy6s~ym|N~;mgl( zkcbi}1&>Gn?SsnIPf^<&lG1?ppkZ+o&b)Fk;T+qMGKMo>k{jA_@nJFXkXQMJ7mcJ6_|3n{k6TCfH5@1cW zw0-{?CDI*Jv815ro}jrRX!R?WM5tA?H_xQ2Zx9yq6FFE-YMA4l^t@<6g_*we?4B>DwJdyxqx25VIi-x z6L9Xg{9XIkT1I{S@+hgit18=kgdEnlN7YgE$!u5(-?i z33fk(xweZP6wt4uPyI65s#c?-w4xKqQq@9jon##pekf68-BNht%n+h5ir;tC8WrLD zg8O`Fjrq9elNG_A<0~XRcqqp?n`iAq&fInG=+`Uu!A!b4_gB@<(RVtlRqzcCDlfl!nQOO@&X zm|F6)tE>*6s}BK0!c7JT{^zrpmH^&uzJN9KCxzAL84(8;*9JsEw)ZLc5u1|rPdz}a%8GH_l?-tcY%NcWqPdaEX$?6$RF=OFvqn_r@6L<{@z$#SpV)e zcj>)d&Wu+c1^+^Xg8$L8c0{wXM=$6lcO0ZJlG#!W3180IgDbmX(+}-;3jAZ}Pl<*g zi zWV-qT)6r4&hCO@v>4sR$ZG^y;?~$j)Kv%3WU_oYebXP{iAnpc(Ne&CGrnT!kiyI4K zHB2Uo%YXjl=q)F@8LkI?kdAJlEC8~H+Tb8&%1^cMjeT6XgiFGh@r3BC@N~k`;t^Hc zZO^j>#n_nC-9?kwhxtI1`l#h&7s3ut-5r^-0P7j1pT)@PZhfVBaR2^`$f>%h_Rf8+ z_isKh*x>z@{pksZE8)#ENI#jsdkXHyk{90Uv{mZFO=y+z&&_yvI?{G014#ttIUtE3 z`U@YLdRh#`CPCknnt+&0pG6UYJ$0!o_pqP(K7Dm|J1`YE*grrDn?Qx_mee`25+CZ} zDKB;P%y8x^U!o01zB{S5nPXdz6p3dk0=VJDD)+l`()jrJu2LnYXh{dUzx~M3Er$4o z4+^gye@>q*Muf;t;RCGj_lEXAtnebR?iZAWJ9QjJ3Q+IG(|%jeZWTh85ar$;pwHG3*5DsG#K+@hF4zB`wC`~YbM zZ<(`E&z^%KRR~zn;x@+JTt4EAXkT~!_vwEMh*)B&ecVRXbcz^3Qxp|^(a9FRIA#`&P4P-! zqDxloQowT6$N(7bdwg*_mjr1!U&BgLxA$mfv*}5>w1HbOG09vvd>QFxR&zXRuQL`s z8dhesJc5D=o$@ft^_NJGi^GFl3XiG&c%)NWZ6bRUBkj?52Drkti0O6jNkS}X@5sX{ zo5i37a_8=+x5S+K5JJ57RWvs|KLE<@OsmhJHUggmbZwNI`J)Yzg>OZ?e_DA^Wq)zH zYeV7IJ)-*tdy78mMlpbZ7Q}uOrXWI~4qwklo6~Aq7A@?>_^zoFHy)wEPNyUDxH%bJ zTavd?fa@(gkn_hfF!!E*eCdH*;-!reiy>I-6!&6|Jn3S?Ge|c6in2f-h~-}~wLZx@ zft3GE#*NL4?;n}_siHF!{csZ9Zw$pR5vL?tVeEo{ov^nYK-&kJ(_y&Km>fnN#shPB z6hdLO)IS14l!b1d;WAk>1KTJHUIA;qz1OOkr4PYxh4}cde2F!UH!ePOt?fDa0JiMYft#B!sXwmsJ-vhrLa_sCZ-#kR&+tl!!!xl#m{zM=^2_XXCBZ6FxFLPyi}#T?1mYQ=n)D3~KUs~5{ORMtd}ljg`-mLvif zLyo}z=xalYYac|^e*eMRWt~NbP#{30xD~{+41TiVEyDRgElQ#|z+bWau=0S2+I|oR z2py^kCKM8luO0{MnmHsOB+z9J0qg&vIhs89eo*4gS(4j8<7>>8=W!UK%T zs-L6}L_vycOA-e2BJ68(x<#dWz^*KmR#6rgtz z#=mN!diMaLc%7vP>?i|LmD=0FiQi%B6bNecISrECLWPC(-Fh`Lps8{b!71WJk_Nng zl1DOz4!?HAF)jufW!Y^mtPf4@Nso;dkjU__$>GQg-Uv@80AOuA(0KYJDG&H$l}GZtEKoDsF7cSlq`YZfRid_GsjfYIUmj znlH*QqOe%%i_caS!GT|_oiD_a24jBSM#oE5;2}P;MHfh1WuMBLL~eS$=LvPc39-CJ zzDTnrv?_gjrRc@3{PtI!SPEvR91BY- zmB}uNW@6h&db|HCBj-)*kDa+QmENSsjiQ{2t}%j|OQ~UU=if7DHUtDE8-SqmICLY^ z*^g8O9i4CK?vwH>XH3tGq~R?TpRUo+V2jsR)l=t^2gl*xr*A;l$&fFz;e*h7f=1K4 zL!>k9=enGON$Vlsbbg7Dw0Cr>^HBk_xX*=feZ8fMV#;6YC$-oTA?S zkN^m_nAl_)PVB2IaM^bw8w@1hRS)H1lpkn;EUF!os6u1i#@&Aud_6$IpMsl#hbiQ) zNP%Pl;=pcrgM?&Qzb{#A{_@l3wjF`pS75 zmfP;224vWN(=PIbdy&FNw-XMn=f;n9 zig4dqE%aD8C6%2wVKpAk^FKCO0f~PCY=y1oUK%%|$@VLz5LZ09Y>p0fsI}|qvR`QT z;74IS6lLKxm2YK0!_Z1(i$V~p>P*;{N-cs)k_C-?Z8hin8R z@3a4a_rzcyYb!`Sz;XS@~(o+Wf2yNHb zdrqsMmEzbrbG~KIg2f8+XP@oGqY?zU<+Z%1ZAQQ%`g{*Qtcnz09Xl_m32nOTpx2OuD=NZ%Lg`3NGovE~)3;W}hz(ePx zMtjd~jpR{^3LuEEp!_`d1e$!P=Bjmg!Dq2LUUQAt#qan?TYVT3z#0FEIVQLI_Ve4z zPx0~84NE!vxmrm|-{Nn?DreaO1W1jTRKoAO#O=d0hZ}G~CRdu~_~9wC;Jb7d2 z?*rqyb#60m%*J$c)vV7&#DlA*7E#$)*mb(w!|8L1X*9boomP1O+S^Hrd4DG9V9#!F z3j>m@>DGA}**`D{1ave$-W;#wOUq4}C!6@10g&Til0rWyt+KXDJpf(j|$2_${u$9n{MdHoH7K# zC=A4fXMCTb1a{@A+%pHYTiS4u73D!oGhP|F1AL#W(9_qxVbKoQ0QR|ycb(Y~wf!}@M{wt1Jnv|^ zg!6)cGM3FC%8ICFn}N=d<)NhVuGH(SqaEKji;QQ~V8ZKwrrs?*x~Ah^VsENo3Tp(e z{66k3p5yNh&oV~;CVz=7yG}yzj9Z&)Xh4oDatGTdQ9>?b_rd#T9joH*;+G;oNUshThzUC6sQ&-huq5(Cck@x_fc! zrMXQaQt)KPfQ=0D*|X!vdS|oH1bL%wqKnB=^3>_waVM6rcVUmsL6<^=+_~}b$pYM` zGMbsqf6Y`hR6TXZyGS9dKvE>QeJvuWlBAN;ed}YFC#dA}?<0vJT!4su@T|-V*ytNO zC8qJ4MYV(&M{Iu@WPg9Z()-KFK|$oH%T!i_1SOAWAFxQfNy5i+g_LQGdDikgJAhL) zZf{P5%{6hF1Gt5UqEPHgffi%)RyX2NR^zKL4d_3AmUHylXb+C$nWLGUnmSC*d7`}m zGt_K+`Y_S|c+FGi_AW8}t-5Cvqen2RKP zE460ndB;?Gqm8Ra7I+C6(BU^@Nb!CGsTvBdE<>6AN)7(wE5D<(dC$9#jjEw3CYL8W zqSik@6_j$ku?&#-eE&TcT8Y6S?qrV|_YqVY8ED58FKCWa%4KE{xhQWeRPJXU_UlBQ>u>TQ~Rv zyVal*5AmF1mg1hNL=LdM=^QUoQ45P&E{|Xw|NXq}=xm|*1?l;MP1JMrK+Crn{NX1k zaWIy%Dq`A{*r{t?paH*;03Ihn2GMsh;=V@dWW+8!jW6)yYe4P4DTlg~l)HRJ=$Y=> zJcxca*kpWhDkHu!;>rRfqJV6Pn#NT6ps5yC6LF#+N^HAo*9!oW{1VN6F74c8o0HXo z{zZwpWd>B` zaO6Icsk6QnxOXi;gQ9r@)eh6fbqT6LaKO*+(U3Anz0Nj^6`V5uIsN&!NK0$Aa}Fao zg`&&J;3*1|nub#j7#PEc?#AF8fsO*z+i?A(2npip~4`hK%FX*Dv6DZvI1 zy?RfThdbrrpCFbmJ!NZauy=^Vm5^Sh#I@tJr<}JqKErhZ0;Rta zdof_pQY~d=cH@!TNUOK440ML8l;fe1oik~Ta3%o+fjvnukvP*dDo|7qamP=lS#GS` z1 zWvXq$GdJTE#~<1HT-|K>vFSGhJ95napm~k^VriwvvTCm&_}Ei*aX#Z#Y&(ZOLAbSE zsPFEDl!kmB5^Z-6O(r5)#n)Cq7k)Uz5A{;1Ab~=2oSWHs2MrmPE{?|1&zOu^C zoWykZCWiy+_HdpeDmX;9_X9j+tE2`mITB9jz00feSRNkT$L<+zR!-)jk2EGQ5xxbO zzR|Hn0db(xMgS1v$q+3)3t_SKfC8;?aUxn;I(xg;i5#OVu7lsGiIwmmP1?f&i5!h4 z5T@CpvX#8vG|}mmy}gIRsrcpdDqoF^Db?GzKQ!I?toCWdJ~gcyC*=2XS9;4fAS7XB z7ZT%#$9DY^OA84^t9vhbZ1mzHmvaD3#eIh_v*%0f7f}J&;NKBv8mcvbm6rhV4Q)^?+@UVB4FQp_H^-rDWU(BCPRJonsUze70ji{yB zVAmFk-pg`cMecC}rC8$DPO&i~tu*HE-meXnzu&F6Dp5o=GH!8l3u|d^`&1vvFB;m9 z6x8&YNInDL#<$I320fvfE`>r&)c{9GRvNo>KQC>}M>cE-EHuC35tM`+C zuBtgqo^sjMn@st=rUgg_5K)0AA=f|wGb9Y~bI84<4fQR=bzxXBou*chl zIA13Ia+CcWhT7A>6FWFHrR#T)Q<6|w^K-kF7jqwnzgdbIG%t%Q_NXn8#V#b_UYtCD zK1otq$=Jej-|#(&s~)XhEO?M5GPAXSt|aEMA2`Kx4v@-)ADeYhaVigwS@!9wL(sp$0Q+FjPv{!T3O zkbxMaOswi2cXSEo<-?xJHk|h1y1pdIv-v%=e!Fm|(CD*e6`+Uv7oAlH!V$YU{0kjT zt7`yXujG!b!WNo2eL{R}Mk7S;cIndL44h^5&2_j1WgkHq-_}cd7tfyVQ&qZ+Y{IXI z`(%hi|2hzUyg~5n-sXH>n+@Oztk^gmAeTqCIN3S7q{tpx0sz~;B^zye9PaL0!&ecxU7iK>^de~`mVM9*^KuMK`XiL+(Q_b-QA}8BFJYKI zSFn#CSNJnKOrrgY&-l=*%amD`tysK#>Uh*S?;-T>?2NSq4z{%-@}rfUC&3$bmv3Q^ zNw@?1M|Etp#EeR^(U=)1)fTn9P`FXM63-!7czOR|3P#XdNSH~HN9BGOuNi=B7k4ho zzaQCFrg`&FxnLOrX|HB~<7IkQr{Ef)23-|_F7Y+bjb!5IC=`II#E;-{(8_VS zZmf>=e37zfz`4MVX}R!}X|Oh zLJ&x7L1AvR(d|i65a)|foZf8Tiqb;gUG6Q<$*+G~9c$>G4-Mp(dP;3_3pZ=+%DHEe zciwzgv_5SYBE~`y2a&04x32kEr_KErPdd_+zM|;crp6-!X^0$VqpI?KTjd<>`F)Al z&_Sf>#3y-U9bJ!yKUiwLVN6B6glMRo`}=?l7*@vAT`tlH95htoJFyaPE!lkd!$#}M z;H0Tylkptrc=#8FkS5$+C`(Psv zsCl54$p~N$(FAXA$1>7+hC1}`*yLuvsyhBB)w+vz$cV(>Vx7BpX|yxZPwB~4?M;PF z;Nq7?x15js+4I8r(uC@*qYT)t-HafqlOYgGvL(T4!;;p>%7V&`euFZ9oz7EvKEZn; zt%zbs;Bz2^d!i%pbiiren|UTXDu?s0s-JUHojt#T;=90FNOS#60sNq7s@#*#X9Xjj zYZH*MKh*P_&^53 z>RP!CnEbx-+akhAXv5ejo&tD?h}Exy!ai42q{d96d&0vO)bUSh;mmV`nJw-x=VaPP(Y4}pFw7R6=(``)aQ;(MXrGT82KvnY zT%dVvNIzutp8kfl5>h{v4)w@%@>JmWR2*dW!(b17JU3v*Bd6E3>Gu)d9MqfvyN3|7dZ;t%T!U%>3jgXVVe0eTzMsrz_E}vzaf=%dzYwr|tA|)4&}b!D?jC z#wY!qy!9So-gstPoQ{3&6ZdVS(DZK^;H?IUfU^sr)0hHk*2Ai%%X>inNE0}>0PSFoAJCgebA0U#UuBI zlIiNFs;b`TRdq{wXEo%7gtJ|~TcdX}^sg4TG>Gm6rSGHCTXXeL1g)Y%{7T$o1 zQ3t@p^~$d?{sD~)Y@uE<+UjCu{1Pn&<+F|Y^OrVfWi&VV6SLi`PTXFVV-sIVVszNt z4TOm%^A}fcT&SGZPJiv+x(`oWP_OlPlW%sgOD5d?nr>Fmb_Wk|U_P}1s~>Hl^dv>Z z=1txID6OEi&nID`er8HP+qS8)lxJ~)|#$22&uYz>WU$0`|>(3|7w_NC5 zOOv_H%bjhHTWO`54+7ntlZ4S{3Yt3|Y;tBTv^;fU*G{96aO8js|R}vs<`BbAM}Xn@}TFcTUw2IaP9YCzdAdU z`qw6qm*1z`a83;Yt^3Ph(F!3%(M=`p5fJmB77QWdH(TqXcna(9?#(XK*iztphgE zd88sO@6TT!?DxB4D0rsPgBRoe@Of`!RdJj7j^a!8yETamLIDfKU{vHcM|!DuIV%nh3LQ=h<9=J1BTs(l%Z>G%V2(mW z-UwE|DuwuCndA~es=uP?62h?&fz-%uSxJQ^b=P0Y#o^KqTz(xH=%W2Y;3L>kpL6QZY#E^v@b9*@AvMkbV_>AA{Hk8_URF0`q>m;!n}O;{Fx!b(lEHh&1z zx(q*MYF!kvUmIq1$(>HaRq?)VU&l2)LmE*JmB6&;$Yzcx-^Bjz(uwdM%3tg4`vl@X z#TU`SG;&lD`)f+?;nAPKToMH*X1o#eR%YH6z@=;h$;)<4)2A&Q`3$bYs#S{QD29v8hN zf#gIiT$H63Y2lpBkMHZ4rPBo(QdVGv4NBsZ-JZP6a(>0=6QYz&2?EjSTz?n9$_MGy zXmY8KX3|j&_PvLzy+^CC?M*_9SCG$`*11=BUi?wwN#2h0t&a3db8_WNNu-yVyo9OV z8!xakcC=fC<)w62r)X}(-}EaN)5DVVf-M;iuKMEnw~K2IcoYSXS$c4IdNKTRUNs7# zTu!U|Uv?9&UZrUUj%npA7kt>fbIO^*-`=-tSL;tkl;~o}nU`&@XB_1<_krn#i+)#= zkpYQ%p=D%X(QwJw`rT>{dy8?F>{iS&ifZ+^I8(^)k9_XL{;t2LF8@vPr>Ge~)y$XS zJ|$qg%0hc|>E?t>@BXc9^nLBwk*9&3IjmEAu|E(A2ilYpA7^aIFXkz$s;MWG`K1_u zeh^C99W$|H^y-sTcjh@l5Y%EN-$T;9C+QEy7@2!2(&nQ{FVh>UvR|Vf9{_EL?Uf*> zfbS+vFN9`PrqeN~hPqy$E<2`w%D6ZniD|WDR~7n9GjWA>P4()iQPidXG4GxpBM88= zR8zM`{5{+7yffZMF;^Z`;-u=HGE&a=4L5gpci;U~GqiT~+H-S_bY(G;@QH}BW(4TJ zu)T%{h$QBKR=F3KE16%THb-j88tJAkO?Tqe^N~NBGZ=A4>LZDLv44g$7$N)_hQ<$1 zTxPNz@8@VkKw`&xd}1N4V=i=joc8B8oL)X3UHZ~3CEwP1ad+*5oZXxJRw_@=HqAt> zACW+N-sN?mXWscj@T)x&ayPiQnz!XS)77@*RBToMZ$&u$4Mcjp006U#I&?7X1b)X3l@;YUNk>sN2(5*h@bRnw@RtdyB$G8d+7;p4e4EIF3q*l z$ebwt`*jOPZ>Y}!ghQ}fDyFZcjmh0v4K61sG5T(gtR`r-d5jcjxV8C%E0#w&+cugn z`;q}kwRtOB#hYHn$qi4QVoxI8cu;PCO*oxs=6F+n#j)c!RS6}PRC77AV{)rxXgm#M zT`hHk_%|yuba`FEXC`ifP0%pWBFN#8-#U6`y)kA9qt9wN6CPd) zDfRr$1h^mysLw#mO2c_VoQ6VS0e8&)!>j~`cq-ALgG}795Z{N5@pPe3*^l>tW}_OF z-C?jo`u;=x@FU5lQ+rkouWBCmLjV1lWK^IsYLw645L;ai)!2NHDCWuWgT6&;Tr)s6 z*Nm)~R{s&HJR8uOYXjVu2~jNx(D{5%v+;3kb=-6|LY~{9uB_ZEy(J?>jZY)?#x$CH z0lQx%U|9Fawe^T=w(%XQq!&_7QQ~`ASjhajeR9ckqfh||RV|wAed&2T2Oadf+|Yn2 z7>U7pKQ`)ojhmpUs5*E~3jXg}$)BvQ{qK_E1#A4afa24Qj|qQH4SvtaF@zaOhR#o> zjk*-{U(Bs!T1Kh8P+81}seFnXxh1DO{lJ<2Xb*WTEe<5Y%^jReS}rBT4szdwwg;SS zmI#vP_r%PRCn!lX7c8H|mBxPx0KR7<@GW4widL6;pOd6dZ{eBx=Vi`fmy|?bubS}k zm00x!aEQHFnhpKi?+Nr~i`xHwz+3k1&^#l@BVYlVp2L-||EO|}xqk$gfAPw)`y)-_ zF3$E3Qx(OaXJhSS0Ef?c$7uRA`MNipC>taO{zw_8i!VInjZj*RGoDWz@yp=YDEu+^ zGDxd*lfADvjQvE;&5UlM?9H3H7*UoQrFF2L5iUqv`%CZ=gNKi$lo_zo*HY56YUGW2o+ zY{*MRr%8i!S$~Q0qaAE^K^_$x_P!E4!IU*F0LLIi`ty<2+{j9}L{8bGz}%{rTuw#O zf`Bstg4A5MN7678hN@+~c&g;{(ty%`Z&v#F%9sDX+URuc#t1C?h~S`Y7SZ#Omq6Vh zD7frG2n*D2;J3V*&)e=l_}#Acu;O=|;pArSXiV{i=^INeFU9MxBLKSXiRL2N+XO76 zTINI-@6wC!X-L)@Gpp2WB`ByA3iOz86-$&CyKR_@O%2p^nhKSA+-E#&WJ%tMQ!(#O ze@NHBi^tt^2U}3ld9&od4pLz?GsQy+BvR=cBt3}zu`ZtZFxFoy+)M`P_~igU^R zO}HQO<)j+{s-qgE=D9=&FmEapLVYMt;u8yhtL8j25BoBARwZEfJ+Ua5Y8EdG4QZ z_s993ZzeN2@0@Suob$dmtBYqbuNrS3sB0Fe@8~61)mb^|_{xN>e!ivlMKk!Lyg!3o zebT$mtuo`lkAPF^eg_OC-IZ_c&&9Nju_kNIR66r_7o2xPgLs%}jL}VXoOB|=Gu?P* zI;TC&(iLSPcTH%`vqQaQV~9X3XqgJDA6H4NJ2o$OJI$)s3Ni;Q9>EckLYtg6%9VhV z9#yA1lt&vG2sFu^UualcAJqD(a@NGdd`Or8Rb^>W#bRJQ}%@>(} z@{gb*|DZQGNd9F0nXl4oZmxqeE-Zly6{iTSL}~1|-=@=?g_mObbH-Nz;{lDdj_>C@ zyyshk(SyGd$Y_h&j#yUZk6ctTe7jQ}utt)dvlOEpD$j52`t1vKfabmPsebFB|F&x$ z7~OEId9MC1Mt&QuF(QhT^bZAb!%N7&GM&Z`7Mm2XWM`(r#4w%;`l$khJLKbBr*!w4rZV@T{-sZ!d!O3U)1s49n~II z(W`Tb!#f=!`b@cRfU3oYEN4Z`Jy=H~taD?dPu0=qnV##e#$Nr@ob*qd%ZI}UG@k|! zjNl_)HgWp;x7d#@Fj~KCM*>fb5>e45s5-o4M~;d3q;9w(o@byS@r9dEs9!^xzy3^a zJRe+g-g5H^2XE|Fzy7%*Ek1f7oI*eRyMqfFRBI`F2gbSUFjqKl8RL>T;zlG#HT?P{ zD18)EJhTbe!aT15V+pBq!D`wwUNKy%Q@q^`{e;0Xb!%Ak(~G4xc{mC5SP&Wl^$8r^ z$~PC{%R_1t{kLbb-Wzs-B*^*#weS>Y+s#=Ds`9N09(XgwMgKNI2XI$;nUno?56odge*s!i66Cg=TQ*8znTJ^E>uE$?6B zx#+?k+L|3t$~pq%4wONSNVhR3`BO?2?zLlb(m|Gj@gQVRq#8g;h}kun5y9*se4G|# zbef^fyIx4W-lFjn6CsvvJANo+DsNotat5-sfsn-gVddx?D&nbsV+RH}(A&U)1`Hl9 zD87Yl_YR!-vYVLHx@He!_Lp;h1U!n)-tIrvdU#EE*Jcke%&n1?>?8B7hUxx3toaTlKE;urR=- zfdY^LeWO>FKT>n#)YRPAQG;~@6<3%S=00&lhp`~wy&Kxh`jX&*)tVEpNcamLF}Hf- zJ_xS1(8-=Nh1w5?MKh{B%3=)g*&8N=^{`#@YSw!2wiv3CFQs{8!O0$1Y7ddR=22O)_sD9u;_ZY_s2M{#s?#MT*>U-yESkNSACm8 zwM@t1_kATItCOvLo>zM%mM(HW)c4n#2kTXgJ|>XU$&d#<2Bj$L;HG1GaH0V5n#I=X z}8 zZh*`QP~-_aItY&nxrYU5(gKkA4cEfnvV(7EH!r@;)Ce1x)0j@5yfjVoBa{Yx_{zv` zVat_DRaZsx>_Eqy8VFXeE#!j^VwRglK}tEi_IUa)!Ogl&c-(BMqphFmM38|!%fx`A z-Q$Y}sVvG=5erhP!WaQ7hzwyBvlaR&S0fjO3%g~?xz0w2c{-9eE((cB%BuTFYhXA1 z-gla{9R?x~ao9{47fcck$0G*KOI5&izIMkKR+*?X=RCF^Y)?wv=eohLFf%P))qk}n z@JqPr2-jnUJDv6rum zfmBt084WFmf`$WJzy8)Gc&M2Mc?F5a&xk1$?7I)Hy6-%w2)=q^;sgwtGH;V}V9+0e!wL~s2x=S3p)+?6LxOUO;d3ZST zEhiL6RosbKnCXEhsQ?$?egHVaQ`j%!K%0Q`q=}zN3iB1-CvmwZ+N&&+D`j9QZonpiY15FF^9mq zzaLKH!Ms6?wuNV&d*Yd(oB?QnC-1&<%b^B(;so#pckB@Fp_>M9*#D)Q1pe$hpd1KO zV$>lF4fLNc%HBX23xMM*;zkjWSpfQtK~vak;9CRRdE3E|BJoKG9-%0h6+D6mj{BoFf$XYvt>r^3I775rAWm9 literal 0 HcmV?d00001 diff --git a/src-ui/src/icon-512.png b/src-ui/src/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..9be707cf6bd92eb0e01b080466d22c6635bb74ba GIT binary patch literal 8421 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 literal 0 HcmV?d00001 diff --git a/src-ui/src/index.html b/src-ui/src/index.html index de85328f3..88fec2a89 100644 --- a/src-ui/src/index.html +++ b/src-ui/src/index.html @@ -8,7 +8,6 @@ - diff --git a/src-ui/src/manifest.webmanifest b/src-ui/src/manifest.webmanifest index 4a3132454..f6a5b69de 100644 --- a/src-ui/src/manifest.webmanifest +++ b/src-ui/src/manifest.webmanifest @@ -4,12 +4,20 @@ "display": "standalone", "icons": [ { - "src": "favicon.ico", - "sizes": "256x256" + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png" }, { - "src": "assets/logo-notext.svg", - "sizes": "any" + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ], "name": "Paperless-ngx", From 43fd109bc660de629358b59d3eca75c4f4286234 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:27:44 -0700 Subject: [PATCH 05/68] Refactor issue comment for clarity and conciseness --- .github/workflows/issue-bot.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/issue-bot.yml b/.github/workflows/issue-bot.yml index cf2602110..69b98baef 100644 --- a/.github/workflows/issue-bot.yml +++ b/.github/workflows/issue-bot.yml @@ -72,11 +72,9 @@ jobs: 'You are welcome to open a new issue that describes the problem you observed in your own words.' : 'This issue was automatically closed because it was not opened using our bug report form. ' + 'Issues have to be created through the form so that the details we need to investigate are included.\n\n' + - `If the problem is still there, please [open a new issue](${newIssue}) using the form — that is all it takes ` + - 'to get it looked at, and no other action is needed here.\n\n' + + `If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n' + 'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' + - `contributions are a violation of our [Code of Conduct](${codeOfConduct}), and such reports must describe the ` + - `behavior you observed only, without code analysis or suggested fixes. See our [contributing guidelines](${contributing}).`; + `contributions are a violation of our [Code of Conduct](${codeOfConduct}).`; await github.rest.issues.createComment({ ...common, body }); await github.rest.issues.addLabels({ ...common, labels: ['ai'] }); From 4b1434f876cd0e6ecfb279a9a2a2a1dbe0b63e57 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:48:34 -0700 Subject: [PATCH 06/68] Fix: catch some frontend failed object retrievals (#14023) --- .../mail-rule-edit-dialog.component.spec.ts | 38 ++++++++++++++++- .../mail-rule-edit-dialog.component.ts | 37 +++++++++++++++-- .../user-edit-dialog.component.spec.ts | 17 ++++++++ .../user-edit-dialog.component.ts | 10 ++++- .../workflow-edit-dialog.component.spec.ts | 41 ++++++++++++++++++- .../workflow-edit-dialog.component.ts | 41 ++++++++++++++++--- .../permissions-group.component.spec.ts | 18 +++++++- .../permissions-group.component.ts | 12 +++++- .../permissions-user.component.spec.ts | 18 +++++++- .../permissions-user.component.ts | 12 +++++- .../permissions-dialog.component.spec.ts | 20 ++++++++- .../permissions-dialog.component.ts | 12 +++++- 12 files changed, 253 insertions(+), 23 deletions(-) diff --git a/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts index a818c752b..b57a83355 100644 --- a/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.spec.ts @@ -4,7 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgSelectModule } from '@ng-select/ng-select' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' import { MailAction, MailMetadataCorrespondentOption, @@ -15,6 +15,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { MailAccountService } from 'src/app/services/rest/mail-account.service' import { SettingsService } from 'src/app/services/settings.service' +import { ToastService } from 'src/app/services/toast.service' import { CheckComponent } from '../../input/check/check.component' import { NumberComponent } from '../../input/number/number.component' import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component' @@ -81,6 +82,41 @@ describe('MailRuleEditDialogComponent', () => { fixture.detectChanges() }) + it('should use empty related object lists when retrieval fails', () => { + const failed = () => throwError(() => new Error('Forbidden')) + const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError') + jest + .spyOn(TestBed.inject(MailAccountService), 'listAll') + .mockReturnValue(failed()) + jest + .spyOn(TestBed.inject(CorrespondentService), 'listAll') + .mockReturnValue(failed()) + jest + .spyOn(TestBed.inject(DocumentTypeService), 'listAll') + .mockReturnValue(failed()) + + const failedFixture = TestBed.createComponent(MailRuleEditDialogComponent) + const failedComponent = failedFixture.componentInstance + + expect(failedComponent.accounts()).toEqual([]) + expect(failedComponent.correspondents()).toEqual([]) + expect(failedComponent.documentTypes()).toEqual([]) + expect(() => failedFixture.detectChanges()).not.toThrow() + expect(toastSpy).toHaveBeenCalledTimes(3) + expect(toastSpy).toHaveBeenCalledWith( + 'Error retrieving mail accounts', + expect.any(Error) + ) + expect(toastSpy).toHaveBeenCalledWith( + 'Error retrieving correspondents', + expect.any(Error) + ) + expect(toastSpy).toHaveBeenCalledWith( + 'Error retrieving document types', + expect.any(Error) + ) + }) + it('should support create and edit modes', () => { component.dialogMode.set(EditDialogMode.CREATE) const createTitleSpy = jest.spyOn(component, 'getCreateTitle') diff --git a/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts index a123c097c..7731324c4 100644 --- a/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts @@ -6,7 +6,7 @@ import { FormsModule, ReactiveFormsModule, } from '@angular/forms' -import { map } from 'rxjs' +import { catchError, map, of } from 'rxjs' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { Correspondent } from 'src/app/data/correspondent' import { DocumentType } from 'src/app/data/document-type' @@ -26,6 +26,7 @@ import { MailAccountService } from 'src/app/services/rest/mail-account.service' import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' +import { ToastService } from 'src/app/services/toast.service' import { CheckComponent } from '../../input/check/check.component' import { NumberComponent } from '../../input/number/number.component' import { SelectComponent } from '../../input/select/select.component' @@ -158,17 +159,45 @@ export class MailRuleEditDialogComponent extends EditDialogComponent { private readonly accountService = inject(MailAccountService) private readonly correspondentService = inject(CorrespondentService) private readonly documentTypeService = inject(DocumentTypeService) + private readonly toastService = inject(ToastService) readonly accounts = toSignal( - this.accountService.listAll().pipe(map((result) => result.results)), + this.accountService.listAll().pipe( + map((result) => result.results), + catchError((error) => { + this.toastService.showError( + $localize`Error retrieving mail accounts`, + error + ) + return of([]) + }) + ), { initialValue: undefined as MailAccount[] } ) readonly correspondents = toSignal( - this.correspondentService.listAll().pipe(map((result) => result.results)), + this.correspondentService.listAll().pipe( + map((result) => result.results), + catchError((error) => { + this.toastService.showError( + $localize`Error retrieving correspondents`, + error + ) + return of([]) + }) + ), { initialValue: undefined as Correspondent[] } ) readonly documentTypes = toSignal( - this.documentTypeService.listAll().pipe(map((result) => result.results)), + this.documentTypeService.listAll().pipe( + map((result) => result.results), + catchError((error) => { + this.toastService.showError( + $localize`Error retrieving document types`, + error + ) + return of([]) + }) + ), { initialValue: undefined as DocumentType[] } ) diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts index f50bf830e..f63a30657 100644 --- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts @@ -81,6 +81,23 @@ describe('UserEditDialogComponent', () => { fixture.detectChanges() }) + it('should use an empty group list when retrieval fails', () => { + const toastSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(TestBed.inject(GroupService), 'listAll') + .mockReturnValue(throwError(() => new Error('Forbidden'))) + + const failedFixture = TestBed.createComponent(UserEditDialogComponent) + const failedComponent = failedFixture.componentInstance + + expect(failedComponent.groups()).toEqual([]) + expect(() => failedFixture.detectChanges()).not.toThrow() + expect(toastSpy).toHaveBeenCalledWith( + 'Error retrieving groups', + expect.any(Error) + ) + }) + it('should support create and edit modes', () => { component.dialogMode.set(EditDialogMode.CREATE) const createTitleSpy = jest.spyOn(component, 'getCreateTitle') diff --git a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts index 426fc67e2..cd9577545 100644 --- a/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts @@ -6,7 +6,7 @@ import { FormsModule, ReactiveFormsModule, } from '@angular/forms' -import { first, map } from 'rxjs' +import { catchError, first, map, of } from 'rxjs' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { Group } from 'src/app/data/group' import { User } from 'src/app/data/user' @@ -42,7 +42,13 @@ export class UserEditDialogComponent private readonly groupsService = inject(GroupService) readonly groups = toSignal( - this.groupsService.listAll().pipe(map((result) => result.results)), + this.groupsService.listAll().pipe( + map((result) => result.results), + catchError((error) => { + this.toastService.showError($localize`Error retrieving groups`, error) + return of([]) + }) + ), { initialValue: undefined as Group[] } ) readonly passwordIsSet = signal(false) diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts index 3c4d8f790..a3c4bd64c 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts @@ -11,7 +11,7 @@ import { } from '@angular/forms' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgSelectModule } from '@ng-select/ng-select' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component' import { CustomFieldDataType } from 'src/app/data/custom-field' import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query' @@ -39,6 +39,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { SettingsService } from 'src/app/services/settings.service' +import { ToastService } from 'src/app/services/toast.service' import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { NumberComponent } from '../../input/number/number.component' @@ -206,6 +207,44 @@ describe('WorkflowEditDialogComponent', () => { settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai) } + it('should use empty related object lists when access is forbidden', () => { + const forbidden = () => throwError(() => new Error('Forbidden')) + const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError') + jest + .spyOn(TestBed.inject(CorrespondentService), 'listAll') + .mockReturnValue(forbidden()) + jest + .spyOn(TestBed.inject(DocumentTypeService), 'listAll') + .mockReturnValue(forbidden()) + jest + .spyOn(TestBed.inject(StoragePathService), 'listAll') + .mockReturnValue(forbidden()) + jest + .spyOn(TestBed.inject(MailRuleService), 'listAll') + .mockReturnValue(forbidden()) + jest + .spyOn(TestBed.inject(CustomFieldsService), 'listAll') + .mockReturnValue(forbidden()) + + const forbiddenFixture = TestBed.createComponent( + WorkflowEditDialogComponent + ) + const forbiddenComponent = forbiddenFixture.componentInstance + + expect(forbiddenComponent.correspondents()).toEqual([]) + expect(forbiddenComponent.documentTypes()).toEqual([]) + expect(forbiddenComponent.storagePaths()).toEqual([]) + expect(forbiddenComponent.mailRules()).toEqual([]) + expect(forbiddenComponent.customFields()).toEqual([]) + expect(forbiddenComponent.dateCustomFields()).toEqual([]) + expect(() => forbiddenFixture.detectChanges()).not.toThrow() + expect(toastSpy).toHaveBeenCalledTimes(1) + expect(toastSpy).toHaveBeenCalledWith( + 'Some workflow options could not be loaded.', + expect.any(Error) + ) + }) + it('should support create and edit modes, support adding triggers and actions on new workflow', () => { component.dialogMode.set(EditDialogMode.CREATE) const createTitleSpy = jest.spyOn(component, 'getCreateTitle') diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 76f9ac2af..a89c87cf9 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -16,7 +16,7 @@ import { } from '@angular/forms' import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' -import { Subscription, map, takeUntil } from 'rxjs' +import { Subscription, catchError, map, of, takeUntil } from 'rxjs' import { Correspondent } from 'src/app/data/correspondent' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { DocumentType } from 'src/app/data/document-type' @@ -48,6 +48,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { UserService } from 'src/app/services/rest/user.service' import { WorkflowService } from 'src/app/services/rest/workflow.service' import { SettingsService } from 'src/app/services/settings.service' +import { ToastService } from 'src/app/services/toast.service' import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { @@ -512,26 +513,43 @@ export class WorkflowEditDialogComponent private readonly storagePathService = inject(StoragePathService) private readonly mailRuleService = inject(MailRuleService) private readonly customFieldsService = inject(CustomFieldsService) + private readonly toastService = inject(ToastService) + private relatedObjectLoadErrorShown = false readonly templates = signal(undefined) readonly correspondents = toSignal( - this.correspondentService.listAll().pipe(map((result) => result.results)), + this.correspondentService.listAll().pipe( + map((result) => result.results), + catchError((error) => this.handleRelatedObjectLoadError(error)) + ), { initialValue: undefined as Correspondent[] } ) readonly documentTypes = toSignal( - this.documentTypeService.listAll().pipe(map((result) => result.results)), + this.documentTypeService.listAll().pipe( + map((result) => result.results), + catchError((error) => this.handleRelatedObjectLoadError(error)) + ), { initialValue: undefined as DocumentType[] } ) readonly storagePaths = toSignal( - this.storagePathService.listAll().pipe(map((result) => result.results)), + this.storagePathService.listAll().pipe( + map((result) => result.results), + catchError((error) => this.handleRelatedObjectLoadError(error)) + ), { initialValue: undefined as StoragePath[] } ) readonly mailRules = toSignal( - this.mailRuleService.listAll().pipe(map((result) => result.results)), + this.mailRuleService.listAll().pipe( + map((result) => result.results), + catchError((error) => this.handleRelatedObjectLoadError(error)) + ), { initialValue: undefined as MailRule[] } ) readonly customFields = toSignal( - this.customFieldsService.listAll().pipe(map((result) => result.results)), + this.customFieldsService.listAll().pipe( + map((result) => result.results), + catchError((error) => this.handleRelatedObjectLoadError(error)) + ), { initialValue: undefined as CustomField[] } ) readonly dateCustomFields = computed(() => @@ -545,6 +563,17 @@ export class WorkflowEditDialogComponent SETTINGS_KEYS.AI_ENABLED ) + private handleRelatedObjectLoadError(error) { + if (!this.relatedObjectLoadErrorShown) { + this.relatedObjectLoadErrorShown = true + this.toastService.showError( + $localize`Some workflow options could not be loaded.`, + error + ) + } + return of([]) + } + expandedItem: number = null private readonly triggerFilterOptionsMap = new WeakMap< diff --git a/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.spec.ts b/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.spec.ts index 0bfed7b37..6a43d5cd3 100644 --- a/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.spec.ts +++ b/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.spec.ts @@ -7,8 +7,9 @@ import { ReactiveFormsModule, } from '@angular/forms' import { NgSelectModule } from '@ng-select/ng-select' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' import { GroupService } from 'src/app/services/rest/group.service' +import { ToastService } from 'src/app/services/toast.service' import { PermissionsGroupComponent } from './permissions-group.component' describe('PermissionsGroupComponent', () => { @@ -60,4 +61,19 @@ describe('PermissionsGroupComponent', () => { expect(component.value).toEqual({ id: 2, name: 'Group 2' }) expect(groupServiceSpy).toHaveBeenCalled() }) + + it('should use an empty group list when retrieval fails', () => { + const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError') + groupServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden'))) + + const failedFixture = TestBed.createComponent(PermissionsGroupComponent) + const failedComponent = failedFixture.componentInstance + + expect(failedComponent.groups()).toEqual([]) + expect(() => failedFixture.detectChanges()).not.toThrow() + expect(toastSpy).toHaveBeenCalledWith( + 'Error retrieving groups', + expect.any(Error) + ) + }) }) diff --git a/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.ts b/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.ts index 7367a9ac0..60fd8ace3 100644 --- a/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.ts +++ b/src-ui/src/app/components/common/input/permissions/permissions-group/permissions-group.component.ts @@ -6,9 +6,10 @@ import { ReactiveFormsModule, } from '@angular/forms' import { NgSelectComponent } from '@ng-select/ng-select' -import { map } from 'rxjs/operators' +import { catchError, map, of } from 'rxjs' import { Group } from 'src/app/data/group' import { GroupService } from 'src/app/services/rest/group.service' +import { ToastService } from 'src/app/services/toast.service' import { AbstractInputComponent } from '../../abstract-input' @Component({ @@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input' }) export class PermissionsGroupComponent extends AbstractInputComponent { private readonly groupService = inject(GroupService) + private readonly toastService = inject(ToastService) readonly groups = toSignal( - this.groupService.listAll().pipe(map((result) => result.results)), + this.groupService.listAll().pipe( + map((result) => result.results), + catchError((error) => { + this.toastService.showError($localize`Error retrieving groups`, error) + return of([]) + }) + ), { initialValue: undefined as Group[] } ) } diff --git a/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.spec.ts b/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.spec.ts index 0d7a525ae..b1b4e83d2 100644 --- a/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.spec.ts +++ b/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.spec.ts @@ -7,8 +7,9 @@ import { ReactiveFormsModule, } from '@angular/forms' import { NgSelectModule } from '@ng-select/ng-select' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' import { UserService } from 'src/app/services/rest/user.service' +import { ToastService } from 'src/app/services/toast.service' import { PermissionsUserComponent } from './permissions-user.component' describe('PermissionsUserComponent', () => { @@ -60,4 +61,19 @@ describe('PermissionsUserComponent', () => { expect(component.value).toEqual({ id: 2, name: 'User 2' }) expect(userServiceSpy).toHaveBeenCalled() }) + + it('should use an empty user list when retrieval fails', () => { + const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError') + userServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden'))) + + const failedFixture = TestBed.createComponent(PermissionsUserComponent) + const failedComponent = failedFixture.componentInstance + + expect(failedComponent.users()).toEqual([]) + expect(() => failedFixture.detectChanges()).not.toThrow() + expect(toastSpy).toHaveBeenCalledWith( + 'Error retrieving users', + expect.any(Error) + ) + }) }) diff --git a/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.ts b/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.ts index f63ebecc4..cf0ee884b 100644 --- a/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.ts +++ b/src-ui/src/app/components/common/input/permissions/permissions-user/permissions-user.component.ts @@ -6,9 +6,10 @@ import { ReactiveFormsModule, } from '@angular/forms' import { NgSelectComponent } from '@ng-select/ng-select' -import { map } from 'rxjs/operators' +import { catchError, map, of } from 'rxjs' import { User } from 'src/app/data/user' import { UserService } from 'src/app/services/rest/user.service' +import { ToastService } from 'src/app/services/toast.service' import { AbstractInputComponent } from '../../abstract-input' @Component({ @@ -26,8 +27,15 @@ import { AbstractInputComponent } from '../../abstract-input' }) export class PermissionsUserComponent extends AbstractInputComponent { private readonly userService = inject(UserService) + private readonly toastService = inject(ToastService) readonly users = toSignal( - this.userService.listAll().pipe(map((result) => result.results)), + this.userService.listAll().pipe( + map((result) => result.results), + catchError((error) => { + this.toastService.showError($localize`Error retrieving users`, error) + return of([]) + }) + ), { initialValue: undefined as User[] } ) } diff --git a/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.spec.ts b/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.spec.ts index 004b2df0b..44da05b88 100644 --- a/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.spec.ts @@ -4,8 +4,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgSelectModule } from '@ng-select/ng-select' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' import { UserService } from 'src/app/services/rest/user.service' +import { ToastService } from 'src/app/services/toast.service' import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component' import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component' import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component' @@ -77,6 +78,23 @@ describe('PermissionsDialogComponent', () => { fixture.detectChanges() }) + it('should use an empty user list when retrieval fails', () => { + const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError') + jest + .spyOn(TestBed.inject(UserService), 'listAll') + .mockReturnValue(throwError(() => new Error('Forbidden'))) + + const failedFixture = TestBed.createComponent(PermissionsDialogComponent) + const failedComponent = failedFixture.componentInstance + + expect(failedComponent.users()).toEqual([]) + expect(() => failedFixture.detectChanges()).not.toThrow() + expect(toastSpy).toHaveBeenCalledWith( + 'Error retrieving users', + expect.any(Error) + ) + }) + it('should return permissions', () => { expect(component.permissions).toEqual({ owner: null, diff --git a/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.ts b/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.ts index 1194886fd..89138bb92 100644 --- a/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.ts +++ b/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.ts @@ -14,10 +14,11 @@ import { ReactiveFormsModule, } from '@angular/forms' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' -import { map } from 'rxjs' +import { catchError, map, of } from 'rxjs' import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' import { User } from 'src/app/data/user' import { UserService } from 'src/app/services/rest/user.service' +import { ToastService } from 'src/app/services/toast.service' import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component' import { SwitchComponent } from '../input/switch/switch.component' @@ -35,9 +36,16 @@ import { SwitchComponent } from '../input/switch/switch.component' export class PermissionsDialogComponent { activeModal = inject(NgbActiveModal) private userService = inject(UserService) + private toastService = inject(ToastService) readonly users = toSignal( - this.userService.listAll().pipe(map((r) => r.results)), + this.userService.listAll().pipe( + map((r) => r.results), + catchError((error) => { + this.toastService.showError($localize`Error retrieving users`, error) + return of([]) + }) + ), { initialValue: undefined as User[] } ) readonly title = signal($localize`Set permissions`) From a415d1bf74d4ecf6fabb293fabdc51e4f32f464b Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:50:11 +0000 Subject: [PATCH 07/68] Auto translate strings --- src-ui/messages.xlf | 203 ++++++++++++++++++++++++++------------------ 1 file changed, 122 insertions(+), 81 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index e0f550a84..f623a700c 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -1618,7 +1618,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 173 + 174 src/app/components/document-detail/document-detail.component.html @@ -1653,7 +1653,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 177 + 178 src/app/components/document-detail/document-detail.component.html @@ -1688,7 +1688,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 181 + 182 src/app/components/document-detail/document-detail.component.html @@ -1723,7 +1723,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 169 + 170 src/app/components/common/input/tags/tags.component.ts @@ -1772,6 +1772,14 @@ src/app/components/admin/users-groups/users-groups.component.ts 75 + + src/app/components/common/input/permissions/permissions-user/permissions-user.component.ts + 35 + + + src/app/components/common/permissions-dialog/permissions-dialog.component.ts + 45 + Error retrieving groups @@ -1783,6 +1791,14 @@ src/app/components/admin/users-groups/users-groups.component.ts 89 + + src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts + 48 + + + src/app/components/common/input/permissions/permissions-group/permissions-group.component.ts + 35 + Settings were saved successfully. @@ -2099,7 +2115,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 96 + 97 src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html @@ -2414,7 +2430,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 81 + 82 @@ -2474,7 +2490,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 123 + 124 @@ -2485,7 +2501,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 85 + 86 @@ -2496,7 +2512,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 77 + 78 @@ -2713,7 +2729,7 @@ src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 88 + 89 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -4348,7 +4364,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 92 + 93 src/app/components/document-list/document-list.component.html @@ -5047,158 +5063,183 @@ Only process attachments src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 39 + 40 src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 50 + 51 Process all files, including 'inline' attachments src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 43 + 44 Process message as .eml src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 54 + 55 Process message as .eml and attachments separately src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 58 + 59 System default src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 65 + 66 Text, then HTML src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 69 + 70 HTML, then text src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 73 + 74 HTML only src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 77 + 78 Text only src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 81 + 82 Move to specified folder src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 92 + 93 Mark as read, don't process read mails src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 96 + 97 Flag the mail, don't process flagged mails src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 100 + 101 Tag the mail with specified tag, don't process tagged mails src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 104 + 105 Use subject as title src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 111 + 112 Use attachment filename as title src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 115 + 116 Do not assign title from this rule src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 119 + 120 Do not assign a correspondent src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 126 + 127 Use mail address src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 130 + 131 Use name (or mail address if not available) src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 134 + 135 Use correspondent selected below src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 138 + 139 + + + + Error retrieving mail accounts + + src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts + 169 + + + src/app/components/manage/mail/mail.component.ts + 110 + + + + Error retrieving correspondents + + src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts + 182 + + + + Error retrieving document types + + src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts + 195 Create new mail rule src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 183 + 212 Edit mail rule src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts - 187 + 216 @@ -5338,7 +5379,7 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 138 + 139 src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html @@ -5448,32 +5489,32 @@ Create new user account src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 68 + 74 Edit user account src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 72 + 78 Totp deactivated src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 129 + 135 Totp deactivation failed src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 132 + 138 src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts - 137 + 143 @@ -6030,14 +6071,14 @@ Consume Folder src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 73 + 74 Modified src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 100 + 101 src/app/data/document.ts @@ -6048,63 +6089,63 @@ Custom Field src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 104 + 105 Consumption Started src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 111 + 112 Document Added src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 115 + 116 Document Updated src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 119 + 120 Assignment src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 130 + 131 Removal src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 134 + 135 Webhook src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 142 + 143 Password removal src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 146 + 147 Move to trash src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 150 + 151 src/app/components/document-detail/document-detail.component.ts @@ -6119,7 +6160,7 @@ Remote OCR src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 154 + 155 src/app/data/paperless-config.ts @@ -6130,14 +6171,14 @@ Apply AI suggestions src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 158 + 159 Title src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 165 + 166 src/app/components/document-detail/document-detail.component.html @@ -6164,112 +6205,119 @@ Created date src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 185 + 186 Has any of these tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 254 + 255 Has all of these tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 261 + 262 Does not have these tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 268 + 269 Has any of these correspondents src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 275 + 276 Has correspondent src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 283 + 284 Does not have correspondents src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 291 + 292 Has document type src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 299 + 300 Has any of these document types src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 307 + 308 Does not have document types src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 315 + 316 Has storage path src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 323 + 324 Has any of these storage paths src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 331 + 332 Does not have storage paths src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 339 + 340 Matches custom field query src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 347 + 348 + + + + Some workflow options could not be loaded. + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 570 Create new workflow src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 563 + 592 Edit workflow src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 567 + 596 @@ -6822,28 +6870,28 @@ Set permissions src/app/components/common/permissions-dialog/permissions-dialog.component.ts - 43 + 51 Edit permissions for src/app/components/common/permissions-dialog/permissions-dialog.component.ts - 54 + 62 Existing owner, user and group permissions will be merged with these settings. src/app/components/common/permissions-dialog/permissions-dialog.component.ts - 93 + 101 Any and all existing owner, user and group permissions will be replaced. src/app/components/common/permissions-dialog/permissions-dialog.component.ts - 94 + 102 @@ -10737,13 +10785,6 @@ 183,184 - - Error retrieving mail accounts - - src/app/components/manage/mail/mail.component.ts - 110 - - Error retrieving mail rules From f287a4cb8c79d48cd839e40e6848ed015ba00666 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:13:15 +0000 Subject: [PATCH 08/68] Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates (#13988) * Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates Bumps the utilities-minor group with 11 updates in the / directory: | Package | From | To | | --- | --- | --- | | [django-guardian](https://github.com/django-guardian/django-guardian) | `3.3.3` | `3.4.0` | | [django-treenode](https://github.com/fabiocaccamo/django-treenode) | `0.24.0` | `0.25.0` | | [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) | `2026.7.1` | `2026.8.1` | | [imap-tools](https://github.com/ikvk/imap_tools) | `1.14.0` | `1.15.0` | | [ocrmypdf](https://github.com/ocrmypdf/OCRmyPDF) | `17.10.0` | `17.11.0` | | [prek](https://github.com/j178/prek) | `0.4.11` | `0.5.0` | | [faker](https://github.com/joke2k/faker) | `40.36.0` | `40.37.0` | | [pytest-django](https://github.com/pytest-dev/pytest-django) | `4.12.0` | `4.14.0` | | [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) | `16.4` | `16.6` | | [time-machine](https://github.com/adamchainz/time-machine) | `3.2.0` | `3.5.0` | | [types-pygments](https://github.com/python/typeshed) | `2.20.0.20260408` | `2.21.0.20260819` | Updates `django-guardian` from 3.3.3 to 3.4.0 - [Release notes](https://github.com/django-guardian/django-guardian/releases) - [Commits](https://github.com/django-guardian/django-guardian/compare/3.3.3...3.4.0) Updates `django-treenode` from 0.24.0 to 0.25.0 - [Release notes](https://github.com/fabiocaccamo/django-treenode/releases) - [Changelog](https://github.com/fabiocaccamo/django-treenode/blob/main/CHANGELOG.md) - [Commits](https://github.com/fabiocaccamo/django-treenode/compare/0.24.0...0.25.0) Updates `drf-spectacular-sidecar` from 2026.7.1 to 2026.8.1 - [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2026.7.1...2026.8.1) Updates `imap-tools` from 1.14.0 to 1.15.0 - [Release notes](https://github.com/ikvk/imap_tools/releases) - [Changelog](https://github.com/ikvk/imap_tools/blob/master/docs/release_notes.rst) - [Commits](https://github.com/ikvk/imap_tools/compare/v1.14.0...v1.15.0) Updates `ocrmypdf` from 17.10.0 to 17.11.0 - [Release notes](https://github.com/ocrmypdf/OCRmyPDF/releases) - [Commits](https://github.com/ocrmypdf/OCRmyPDF/compare/v17.10.0...v17.11.0) Updates `prek` from 0.4.11 to 0.5.0 - [Release notes](https://github.com/j178/prek/releases) - [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md) - [Commits](https://github.com/j178/prek/compare/v0.4.11...v0.5.0) Updates `faker` from 40.36.0 to 40.37.0 - [Release notes](https://github.com/joke2k/faker/releases) - [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md) - [Commits](https://github.com/joke2k/faker/compare/v40.36.0...v40.37.0) Updates `pytest-django` from 4.12.0 to 4.14.0 - [Release notes](https://github.com/pytest-dev/pytest-django/releases) - [Changelog](https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst) - [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.12.0...v4.14.0) Updates `pytest-rerunfailures` from 16.4 to 16.6 - [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst) - [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/16.4...16.6) Updates `time-machine` from 3.2.0 to 3.5.0 - [Changelog](https://github.com/adamchainz/time-machine/blob/main/docs/changelog.rst) - [Commits](https://github.com/adamchainz/time-machine/compare/3.2.0...3.5.0) Updates `types-pygments` from 2.20.0.20260408 to 2.21.0.20260819 - [Commits](https://github.com/python/typeshed/commits) --- updated-dependencies: - dependency-name: django-guardian dependency-version: 3.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: django-treenode dependency-version: 0.25.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: drf-spectacular-sidecar dependency-version: 2026.8.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: faker dependency-version: 40.37.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: imap-tools dependency-version: 1.15.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: ocrmypdf dependency-version: 17.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: prek dependency-version: 0.5.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: pytest-django dependency-version: 4.14.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: pytest-rerunfailures dependency-version: '16.6' dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: time-machine dependency-version: 3.5.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: types-pygments dependency-version: 2.21.0.20260819 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor ... Signed-off-by: dependabot[bot] * Handles pytest-django * Formatting --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com> --- pyproject.toml | 14 +- src/documents/tests/conftest.py | 6 +- src/documents/tests/date_parsing/conftest.py | 2 +- src/documents/tests/export/test_sinks.py | 6 +- src/documents/tests/search/conftest.py | 4 +- src/documents/tests/search/test_schema.py | 13 +- src/documents/tests/test_checks.py | 4 +- .../tests/test_management_consumer.py | 12 +- src/documents/tests/test_parsers.py | 4 +- src/documents/tests/test_workflows.py | 4 +- src/paperless/tests/parsers/conftest.py | 14 +- .../tests/parsers/test_mail_parser.py | 4 +- .../tests/parsers/test_remote_parser.py | 12 +- .../tests/parsers/test_tika_parser.py | 12 +- src/paperless/tests/test_adapter.py | 16 +- src/paperless/tests/test_checks.py | 28 +-- src/paperless/tests/test_views.py | 6 +- src/paperless_ai/tests/conftest.py | 4 +- src/paperless_ai/tests/test_index_locking.py | 4 +- uv.lock | 210 ++++++++---------- 20 files changed, 181 insertions(+), 198 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8364aacf0..199d3f2cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,21 +32,21 @@ dependencies = [ "django-cors-headers~=4.9.0", "django-extensions~=4.1", "django-filter~=25.1", - "django-guardian~=3.3.3", + "django-guardian>=3.3.3,<3.5", "django-multiselectfield~=1.0.1", "django-rich~=2.2.0", "django-soft-delete~=1.0.18", "django-treenode>=0.24", "djangorestframework~=3.16", "drf-spectacular~=0.30", - "drf-spectacular-sidecar~=2026.7.1", + "drf-spectacular-sidecar>=2026.7.1,<2026.9", "drf-writable-nested~=0.7.1", "filelock~=3.32.0", "flower>=2.0.1,<2.2", "gotenberg-client[httpx]~=1.0", "httpx-oauth~=0.17", "ijson>=3.5.1", - "imap-tools~=1.14.0", + "imap-tools>=1.14,<1.16", "jinja2~=3.1.6", "langdetect~=1.0.9", "llama-index-core>=0.14.23", @@ -56,7 +56,7 @@ dependencies = [ "llama-index-llms-ollama>=0.9.1", "llama-index-llms-openai-like>=0.7.1", "nltk~=3.10.0", - "ocrmypdf>=17.7,<17.11", + "ocrmypdf>=17.7,<17.12", "openai>=2.48", "pathvalidate~=3.3.1", "pdf2image~=1.17.0", @@ -103,17 +103,17 @@ docs = [ "zensical>=0.0.51", ] lint = [ - "prek~=0.4.11", + "prek>=0.4.11,<0.6", "ruff~=0.16.1", ] testing = [ "daphne", "factory-boy~=3.3.1", - "faker~=40.36.0", + "faker>=40.36,<40.38", "imagehash", "pytest~=9.1.1", "pytest-cov~=7.1.0", - "pytest-django~=4.12.0", + "pytest-django>=4.12,<4.15", "pytest-env~=1.7.0", "pytest-httpx", "pytest-mock~=3.15.1", diff --git a/src/documents/tests/conftest.py b/src/documents/tests/conftest.py index 07c20c944..99f53d614 100644 --- a/src/documents/tests/conftest.py +++ b/src/documents/tests/conftest.py @@ -10,7 +10,7 @@ import pytest from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from guardian.shortcuts import clear_ct_cache -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings from rest_framework.test import APIClient from documents.tests.factories import DocumentFactory @@ -100,7 +100,7 @@ def sample_doc( @pytest.fixture() def _search_index( tmp_path: Path, - settings: SettingsWrapper, + settings: Settings, ) -> Generator[None, None, None]: """Create a temp index directory and point INDEX_DIR at it. @@ -118,7 +118,7 @@ def _search_index( @pytest.fixture() -def settings_timezone(settings: SettingsWrapper) -> zoneinfo.ZoneInfo: +def settings_timezone(settings: Settings) -> zoneinfo.ZoneInfo: return zoneinfo.ZoneInfo(settings.TIME_ZONE) diff --git a/src/documents/tests/date_parsing/conftest.py b/src/documents/tests/date_parsing/conftest.py index 3cc1bab52..a1b8ef6e1 100644 --- a/src/documents/tests/date_parsing/conftest.py +++ b/src/documents/tests/date_parsing/conftest.py @@ -70,7 +70,7 @@ def clear_lru_cache() -> Generator[None, None, None]: @pytest.fixture -def mock_date_parser_settings(settings: pytest_django.fixtures.SettingsWrapper) -> Any: +def mock_date_parser_settings(settings: pytest_django.fixtures.Settings) -> Any: """ Override Django settings for the duration of date parser tests. """ diff --git a/src/documents/tests/export/test_sinks.py b/src/documents/tests/export/test_sinks.py index 78c2936d0..7c66b8fa5 100644 --- a/src/documents/tests/export/test_sinks.py +++ b/src/documents/tests/export/test_sinks.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest import pytest_mock -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings from documents.export.sinks import DirectoryExportSink from documents.export.sinks import ExportSink @@ -242,7 +242,7 @@ class TestZipExportSink: self, tmp_path: Path, source_file: Path, - settings: SettingsWrapper, + settings: Settings, ) -> None: scratch_dir = tmp_path / "scratch" settings.SCRATCH_DIR = scratch_dir @@ -261,7 +261,7 @@ class TestZipExportSink: def test_abort_after_manifest_written_cleans_up_pending_tmp( self, tmp_path: Path, - settings: SettingsWrapper, + settings: Settings, ) -> None: scratch_dir = tmp_path / "scratch" settings.SCRATCH_DIR = scratch_dir diff --git a/src/documents/tests/search/conftest.py b/src/documents/tests/search/conftest.py index 93c560043..4f9216df7 100644 --- a/src/documents/tests/search/conftest.py +++ b/src/documents/tests/search/conftest.py @@ -15,11 +15,11 @@ if TYPE_CHECKING: from collections.abc import Generator from pathlib import Path - from pytest_django.fixtures import SettingsWrapper + from pytest_django.fixtures import Settings @pytest.fixture -def index_dir(tmp_path: Path, settings: SettingsWrapper) -> Path: +def index_dir(tmp_path: Path, settings: Settings) -> Path: path = tmp_path / "index" path.mkdir() settings.INDEX_DIR = path diff --git a/src/documents/tests/search/test_schema.py b/src/documents/tests/search/test_schema.py index 7219df580..780e3e06d 100644 --- a/src/documents/tests/search/test_schema.py +++ b/src/documents/tests/search/test_schema.py @@ -11,7 +11,8 @@ from documents.search._schema import needs_rebuild if TYPE_CHECKING: from pathlib import Path - from pytest_django.fixtures import SettingsWrapper + from pytest_django.fixtures import Settings + pytestmark = pytest.mark.search @@ -25,7 +26,7 @@ class TestNeedsRebuild: def test_returns_false_when_version_and_language_match( self, index_dir: Path, - settings: SettingsWrapper, + settings: Settings, ) -> None: settings.SEARCH_LANGUAGE = "en" (index_dir / ".index_settings.json").write_text( @@ -36,7 +37,7 @@ class TestNeedsRebuild: def test_returns_true_on_schema_version_mismatch( self, index_dir: Path, - settings: SettingsWrapper, + settings: Settings, ) -> None: settings.SEARCH_LANGUAGE = None (index_dir / ".index_settings.json").write_text( @@ -47,7 +48,7 @@ class TestNeedsRebuild: def test_returns_true_when_version_is_not_an_integer( self, index_dir: Path, - settings: SettingsWrapper, + settings: Settings, ) -> None: settings.SEARCH_LANGUAGE = None (index_dir / ".index_settings.json").write_text( @@ -58,7 +59,7 @@ class TestNeedsRebuild: def test_returns_true_when_language_key_missing( self, index_dir: Path, - settings: SettingsWrapper, + settings: Settings, ) -> None: settings.SEARCH_LANGUAGE = "en" (index_dir / ".index_settings.json").write_text( @@ -69,7 +70,7 @@ class TestNeedsRebuild: def test_returns_true_when_language_differs( self, index_dir: Path, - settings: SettingsWrapper, + settings: Settings, ) -> None: settings.SEARCH_LANGUAGE = "de" (index_dir / ".index_settings.json").write_text( diff --git a/src/documents/tests/test_checks.py b/src/documents/tests/test_checks.py index 6915b299f..a21a4e572 100644 --- a/src/documents/tests/test_checks.py +++ b/src/documents/tests/test_checks.py @@ -1,7 +1,7 @@ import pytest from django.core.checks import Error from django.core.checks import Warning -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings from pytest_mock import MockerFixture from documents.checks import filename_format_check @@ -47,7 +47,7 @@ class TestFilenameFormatCheck: ) def test_warns_on_old_style_format( self, - settings: SettingsWrapper, + settings: Settings, filename_format: str, expected_hint: str, ) -> None: diff --git a/src/documents/tests/test_management_consumer.py b/src/documents/tests/test_management_consumer.py index 0c2c8f585..46a7c957a 100644 --- a/src/documents/tests/test_management_consumer.py +++ b/src/documents/tests/test_management_consumer.py @@ -43,7 +43,7 @@ if TYPE_CHECKING: from collections.abc import Generator from unittest.mock import MagicMock - from pytest_django.fixtures import SettingsWrapper + from pytest_django.fixtures import Settings from pytest_mock import MockerFixture @@ -605,7 +605,7 @@ class TestCommandValidation: def test_raises_for_missing_consumption_dir( self, - settings: SettingsWrapper, + settings: Settings, ) -> None: """Test command raises error when directory is not provided.""" settings.CONSUMPTION_DIR = None @@ -639,7 +639,7 @@ class TestCommandOneshot: scratch_dir: Path, sample_pdf: Path, mock_consume_file_delay: MagicMock, - settings: SettingsWrapper, + settings: Settings, ) -> None: """Test oneshot mode processes existing files.""" target = consumption_dir / "document.pdf" @@ -659,7 +659,7 @@ class TestCommandOneshot: scratch_dir: Path, sample_pdf: Path, mock_consume_file_delay: MagicMock, - settings: SettingsWrapper, + settings: Settings, ) -> None: """Test oneshot mode processes files recursively.""" subdir = consumption_dir / "subdir" @@ -681,7 +681,7 @@ class TestCommandOneshot: consumption_dir: Path, scratch_dir: Path, mock_consume_file_delay: MagicMock, - settings: SettingsWrapper, + settings: Settings, ) -> None: """Test oneshot mode ignores unsupported file extensions.""" target = consumption_dir / "document.xyz" @@ -1256,7 +1256,7 @@ class TestProcessExistingFilesQueued: consumption_dir: Path, sample_pdf: Path, mock_consume_file_delay: MagicMock, - settings: SettingsWrapper, + settings: Settings, ) -> None: """The set returned seeds the rescan's queued set, avoiding re-queue.""" target = consumption_dir / "document.pdf" diff --git a/src/documents/tests/test_parsers.py b/src/documents/tests/test_parsers.py index fc7e84a71..5f3893112 100644 --- a/src/documents/tests/test_parsers.py +++ b/src/documents/tests/test_parsers.py @@ -1,7 +1,7 @@ from collections.abc import Generator import pytest -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings from documents.parsers import get_default_file_extension from documents.parsers import get_supported_file_extensions @@ -14,7 +14,7 @@ from paperless.parsers.tika import TikaDocumentParser @pytest.fixture() -def _tika_registry(settings: SettingsWrapper) -> Generator[None, None, None]: +def _tika_registry(settings: Settings) -> Generator[None, None, None]: """ Rebuild the parser registry with Tika enabled for the duration of the test, then reset on exit so other tests see the default (Tika-disabled) diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 6376f6fc9..62bb61846 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -23,6 +23,7 @@ from guardian.shortcuts import get_users_with_perms from httpx import ConnectError from httpx import HTTPError from httpx import HTTPStatusError +from pytest_django.fixtures import Settings from pytest_httpx import HTTPXMock from rest_framework.test import APIClient from rest_framework.test import APITestCase @@ -38,7 +39,6 @@ from paperless_ai.exceptions import LLMTimeoutError if TYPE_CHECKING: from django.db.models import QuerySet -from pytest_django.fixtures import SettingsWrapper from documents import tasks from documents.data_models import ConsumableDocument @@ -5356,7 +5356,7 @@ class TestDateWorkflowLocalization( def test_document_consumption_workflow_localization( self, tmp_path: Path, - settings: SettingsWrapper, + settings: Settings, title_template: str, expected_title: str, ) -> None: diff --git a/src/paperless/tests/parsers/conftest.py b/src/paperless/tests/parsers/conftest.py index 0ae8c3865..687944f71 100644 --- a/src/paperless/tests/parsers/conftest.py +++ b/src/paperless/tests/parsers/conftest.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from pytest_django.fixtures import SettingsWrapper + from pytest_django.fixtures import Settings from pytest_mock import MockerFixture #: Type for the ``make_tesseract_parser`` fixture factory. @@ -131,9 +131,9 @@ def empty_remote_ocr_app_config(mocker: MockerFixture) -> MagicMock: @pytest.fixture() def azure_settings( - settings: SettingsWrapper, + settings: Settings, empty_remote_ocr_app_config: MagicMock, -) -> SettingsWrapper: +) -> Settings: """Configure Django settings for a valid Azure AI OCR engine. Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and @@ -142,7 +142,7 @@ def azure_settings( Returns ------- - SettingsWrapper + Settings The modified settings object (for chaining further overrides). """ settings.REMOTE_OCR_ENGINE = "azureai" @@ -153,14 +153,14 @@ def azure_settings( @pytest.fixture() def no_engine_settings( - settings: SettingsWrapper, + settings: Settings, empty_remote_ocr_app_config: MagicMock, -) -> SettingsWrapper: +) -> Settings: """Configure Django settings with no remote engine configured. Returns ------- - SettingsWrapper + Settings The modified settings object. """ settings.REMOTE_OCR_ENGINE = None diff --git a/src/paperless/tests/parsers/test_mail_parser.py b/src/paperless/tests/parsers/test_mail_parser.py index 6408299d9..5ef3d2eb1 100644 --- a/src/paperless/tests/parsers/test_mail_parser.py +++ b/src/paperless/tests/parsers/test_mail_parser.py @@ -7,7 +7,7 @@ import httpx import pytest from django.test.html import parse_html from django.utils import timezone -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings from pytest_httpx import HTTPXMock from pytest_mock import MockerFixture @@ -428,7 +428,7 @@ class TestTikaHtmlParse: def test_tika_parse_unreachable( self, - settings: SettingsWrapper, + settings: Settings, mail_parser: MailDocumentParser, ) -> None: """ diff --git a/src/paperless/tests/parsers/test_remote_parser.py b/src/paperless/tests/parsers/test_remote_parser.py index e55bc5de7..1988008e7 100644 --- a/src/paperless/tests/parsers/test_remote_parser.py +++ b/src/paperless/tests/parsers/test_remote_parser.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path - from pytest_django.fixtures import SettingsWrapper + from pytest_django.fixtures import Settings from pytest_mock import MockerFixture @@ -67,7 +67,7 @@ def make_azure_mock() -> Callable[[str], Mock]: @pytest.fixture() def azure_client( - azure_settings: SettingsWrapper, + azure_settings: Settings, make_azure_mock: Callable[[str], Mock], mocker: MockerFixture, ) -> Mock: @@ -83,7 +83,7 @@ def azure_client( @pytest.fixture() def failing_azure_client( - azure_settings: SettingsWrapper, + azure_settings: Settings, mocker: MockerFixture, ) -> Mock: """Patch the Azure DI client to raise RuntimeError on every call. @@ -199,7 +199,7 @@ class TestRemoteParserScore: def test_score_returns_none_when_api_key_missing( self, - no_engine_settings: SettingsWrapper, + no_engine_settings: Settings, ) -> None: no_engine_settings.REMOTE_OCR_ENGINE = "azureai" no_engine_settings.REMOTE_OCR_ENDPOINT = ( @@ -210,7 +210,7 @@ class TestRemoteParserScore: def test_score_returns_none_when_endpoint_missing( self, - no_engine_settings: SettingsWrapper, + no_engine_settings: Settings, ) -> None: no_engine_settings.REMOTE_OCR_ENGINE = "azureai" no_engine_settings.REMOTE_OCR_API_KEY = "key" @@ -231,7 +231,7 @@ class TestRemoteParserScore: @pytest.mark.django_db def test_score_uses_app_config_when_env_unset( self, - settings: SettingsWrapper, + settings: Settings, ) -> None: """The app config alone is enough to activate the parser.""" settings.REMOTE_OCR_ENGINE = None diff --git a/src/paperless/tests/parsers/test_tika_parser.py b/src/paperless/tests/parsers/test_tika_parser.py index 50ee75ae0..f03f757c6 100644 --- a/src/paperless/tests/parsers/test_tika_parser.py +++ b/src/paperless/tests/parsers/test_tika_parser.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest from httpx import codes -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings from pytest_httpx import HTTPXMock from documents.parsers import ParseError @@ -27,7 +27,7 @@ class TestTikaParserRegistryInterface: def test_score_returns_none_when_tika_disabled( self, - settings: SettingsWrapper, + settings: Settings, ) -> None: settings.TIKA_ENABLED = False result = TikaDocumentParser.score( @@ -38,7 +38,7 @@ class TestTikaParserRegistryInterface: def test_score_returns_int_when_tika_enabled( self, - settings: SettingsWrapper, + settings: Settings, ) -> None: settings.TIKA_ENABLED = True result = TikaDocumentParser.score( @@ -49,7 +49,7 @@ class TestTikaParserRegistryInterface: def test_score_returns_none_for_unsupported_mime( self, - settings: SettingsWrapper, + settings: Settings, ) -> None: settings.TIKA_ENABLED = True result = TikaDocumentParser.score("application/pdf", "doc.pdf") @@ -90,7 +90,7 @@ class TestTikaParser: def test_parse( self, httpx_mock: HTTPXMock, - settings: SettingsWrapper, + settings: Settings, tika_parser: TikaDocumentParser, sample_odt_file: Path, ) -> None: @@ -179,7 +179,7 @@ class TestTikaParser: setting_value: str, expected_form_value: str, httpx_mock: HTTPXMock, - settings: SettingsWrapper, + settings: Settings, sample_odt_file: Path, ) -> None: """ diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py index 680abdd77..e8b2bc1a6 100644 --- a/src/paperless/tests/test_adapter.py +++ b/src/paperless/tests/test_adapter.py @@ -10,7 +10,7 @@ from django.contrib.auth.models import User from django.forms import ValidationError from django.http import HttpRequest from django.urls import reverse -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings from pytest_mock import MockerFixture from rest_framework.authtoken.models import Token @@ -19,7 +19,7 @@ from paperless.adapter import DrfTokenStrategy @pytest.mark.django_db class TestCustomAccountAdapter: - def test_is_open_for_signup(self, settings: SettingsWrapper) -> None: + def test_is_open_for_signup(self, settings: Settings) -> None: adapter = get_adapter() # With no accounts, signups should be allowed @@ -33,7 +33,7 @@ class TestCustomAccountAdapter: settings.ACCOUNT_ALLOW_SIGNUPS = False assert not adapter.is_open_for_signup(None) - def test_is_safe_url(self, settings: SettingsWrapper) -> None: + def test_is_safe_url(self, settings: Settings) -> None: request = HttpRequest() request.get_host = lambda: "example.com" with context.request_context(request): @@ -55,7 +55,7 @@ class TestCustomAccountAdapter: def test_pre_authenticate( self, - settings: SettingsWrapper, + settings: Settings, mocker: MockerFixture, ) -> None: mocker.patch("allauth.core.internal.ratelimit.consume", return_value=True) @@ -70,7 +70,7 @@ class TestCustomAccountAdapter: with pytest.raises(ValidationError): adapter.pre_authenticate(request) - def test_get_reset_password_from_key_url(self, settings: SettingsWrapper) -> None: + def test_get_reset_password_from_key_url(self, settings: Settings) -> None: request = HttpRequest() request.get_host = lambda: "foo.org" with context.request_context(request): @@ -87,7 +87,7 @@ class TestCustomAccountAdapter: def test_save_user_adds_groups( self, - settings: SettingsWrapper, + settings: Settings, mocker: MockerFixture, ) -> None: settings.ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"] @@ -130,7 +130,7 @@ class TestCustomAccountAdapter: class TestCustomSocialAccountAdapter: @pytest.mark.django_db - def test_is_open_for_signup(self, settings: SettingsWrapper) -> None: + def test_is_open_for_signup(self, settings: Settings) -> None: adapter = get_social_adapter() settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True @@ -146,7 +146,7 @@ class TestCustomSocialAccountAdapter: @pytest.mark.django_db def test_save_user_adds_groups( self, - settings: SettingsWrapper, + settings: Settings, mocker: MockerFixture, ) -> None: settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"] diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py index b85618a21..94325ae95 100644 --- a/src/paperless/tests/test_checks.py +++ b/src/paperless/tests/test_checks.py @@ -8,7 +8,7 @@ import pytest from django.core.checks import ERROR from django.core.checks import Error from django.core.checks import Warning -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings from pytest_mock import MockerFixture from paperless.checks import audit_log_check @@ -31,7 +31,7 @@ class PaperlessTestDirs: # TODO: consolidate with documents/tests/conftest.py PaperlessDirs/paperless_dirs # once the paperless and documents test suites are ready to share fixtures. @pytest.fixture() -def directories(tmp_path: Path, settings: SettingsWrapper) -> PaperlessTestDirs: +def directories(tmp_path: Path, settings: Settings) -> PaperlessTestDirs: data_dir = tmp_path / "data" media_dir = tmp_path / "media" consumption_dir = tmp_path / "consumption" @@ -54,7 +54,7 @@ class TestChecks: def test_binaries(self) -> None: assert binaries_check(None) == [] - def test_binaries_fail(self, settings: SettingsWrapper) -> None: + def test_binaries_fail(self, settings: Settings) -> None: settings.CONVERT_BINARY = "uuuhh" assert len(binaries_check(None)) == 1 @@ -62,7 +62,7 @@ class TestChecks: def test_paths_check(self) -> None: assert paths_check(None) == [] - def test_paths_check_dont_exist(self, settings: SettingsWrapper) -> None: + def test_paths_check_dont_exist(self, settings: Settings) -> None: settings.MEDIA_ROOT = Path("uuh") settings.DATA_DIR = Path("whatever") settings.CONSUMPTION_DIR = Path("idontcare") @@ -89,11 +89,11 @@ class TestChecks: for msg in msgs: assert msg.msg.endswith("is not writeable") - def test_debug_disabled(self, settings: SettingsWrapper) -> None: + def test_debug_disabled(self, settings: Settings) -> None: settings.DEBUG = False assert debug_mode_check(None) == [] - def test_debug_enabled(self, settings: SettingsWrapper) -> None: + def test_debug_enabled(self, settings: Settings) -> None: settings.DEBUG = True assert len(debug_mode_check(None)) == 1 @@ -150,7 +150,7 @@ class TestOcrSettingsChecks: ) def test_invalid_setting_produces_one_error( self, - settings: SettingsWrapper, + settings: Settings, setting: str, value: str, expected_msg: str, @@ -173,7 +173,7 @@ class TestOcrSettingsChecks: class TestTimezoneSettingsChecks: - def test_invalid_timezone(self, settings: SettingsWrapper) -> None: + def test_invalid_timezone(self, settings: Settings) -> None: """ GIVEN: - Default settings @@ -192,7 +192,7 @@ class TestTimezoneSettingsChecks: class TestEmailCertSettingsChecks: - def test_not_valid_file(self, settings: SettingsWrapper) -> None: + def test_not_valid_file(self, settings: Settings) -> None: """ GIVEN: - Default settings @@ -215,7 +215,7 @@ class TestEmailCertSettingsChecks: class TestAuditLogChecks: def test_was_enabled_once( self, - settings: SettingsWrapper, + settings: Settings, mocker: MockerFixture, ) -> None: """ @@ -634,7 +634,7 @@ class TestTesseractChecks: def test_default_language(self) -> None: check_default_language_available(None) - def test_no_language(self, settings: SettingsWrapper) -> None: + def test_no_language(self, settings: Settings) -> None: settings.OCR_LANGUAGE = "" @@ -649,7 +649,7 @@ class TestTesseractChecks: def test_invalid_language( self, - settings: SettingsWrapper, + settings: Settings, mocker: MockerFixture, ) -> None: @@ -668,7 +668,7 @@ class TestTesseractChecks: def test_multi_part_language( self, - settings: SettingsWrapper, + settings: Settings, mocker: MockerFixture, ) -> None: """ @@ -692,7 +692,7 @@ class TestTesseractChecks: def test_multi_part_language_bad_format( self, - settings: SettingsWrapper, + settings: Settings, mocker: MockerFixture, ) -> None: """ diff --git a/src/paperless/tests/test_views.py b/src/paperless/tests/test_views.py index 35e0fd910..5d3d8d9e8 100644 --- a/src/paperless/tests/test_views.py +++ b/src/paperless/tests/test_views.py @@ -1,13 +1,13 @@ from pathlib import Path from django.test import Client -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings def test_favicon_view( client: Client, tmp_path: Path, - settings: SettingsWrapper, + settings: Settings, ) -> None: favicon_path = tmp_path / "paperless" / "img" / "favicon.ico" favicon_path.parent.mkdir(parents=True) @@ -24,7 +24,7 @@ def test_favicon_view( def test_favicon_view_missing_file( client: Client, tmp_path: Path, - settings: SettingsWrapper, + settings: Settings, ) -> None: settings.STATIC_ROOT = tmp_path response = client.get("/favicon.ico") diff --git a/src/paperless_ai/tests/conftest.py b/src/paperless_ai/tests/conftest.py index ac9292ada..ea6d5c6ed 100644 --- a/src/paperless_ai/tests/conftest.py +++ b/src/paperless_ai/tests/conftest.py @@ -3,11 +3,11 @@ from pathlib import Path import pytest import pytest_mock from llama_index.core.base.embeddings.base import BaseEmbedding -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings @pytest.fixture -def temp_llm_index_dir(tmp_path: Path, settings: SettingsWrapper) -> Path: +def temp_llm_index_dir(tmp_path: Path, settings: Settings) -> Path: settings.LLM_INDEX_DIR = tmp_path settings.LLM_INDEX_LOCK = tmp_path / "index.lock" settings.LLM_INDEX_RWLOCK = tmp_path / "llmindex.rwlock.db" diff --git a/src/paperless_ai/tests/test_index_locking.py b/src/paperless_ai/tests/test_index_locking.py index 53ac5a9a6..2268b00bd 100644 --- a/src/paperless_ai/tests/test_index_locking.py +++ b/src/paperless_ai/tests/test_index_locking.py @@ -8,7 +8,7 @@ import pytest from django.conf import settings from filelock import ReadWriteLock from llama_index.core.schema import TextNode -from pytest_django.fixtures import SettingsWrapper +from pytest_django.fixtures import Settings from paperless_ai import indexing from paperless_ai.vector_store import PaperlessSqliteVecVectorStore @@ -69,7 +69,7 @@ class TestCompactionLock: def test_compaction_skips_when_a_reader_holds_the_lock( self, temp_llm_index_dir: Path, - settings: SettingsWrapper, + settings: Settings, caplog: pytest.LogCaptureFixture, ) -> None: _seed_bloated_index(temp_llm_index_dir) diff --git a/uv.lock b/uv.lock index 68aa1b849..7382b308e 100644 --- a/uv.lock +++ b/uv.lock @@ -1012,15 +1012,15 @@ wheels = [ [[package]] name = "django-guardian" -version = "3.3.3" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/27/2924c6ebb788d5b76ca9f28ba4da82a2d828e56bb0f9b973dd523e797da4/django_guardian-3.3.3.tar.gz", hash = "sha256:fa9be851e5e7df4fccd0b0d7a171042cdbfaf0412a9aa54c56361d642eaccff1", size = 111083, upload-time = "2026-07-22T23:35:21.784Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/c7/0a2cea15a432bd97fffa50b0a01be6018e30b9cdb9775ce33c41f6994fb5/django_guardian-3.4.0.tar.gz", hash = "sha256:f7bd2d673c018e28970c6f98a5bdeeaaf294b9ca7726586695cff01f15db7ef0", size = 111700, upload-time = "2026-08-28T10:04:07.701Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/e4/68a8224d9746f51e39e5f2442c240424c1545f63a60e28dc63735104a2cd/django_guardian-3.3.3-py3-none-any.whl", hash = "sha256:490c04c81564a15470a95cd39a5844c5898adefa4334eed6a70aa7bb41a0b7e3", size = 147566, upload-time = "2026-07-22T23:35:20.158Z" }, + { url = "https://files.pythonhosted.org/packages/32/6d/518729624af9e29a7ea7f4b429ddca56fd6af3462c835b0e42b494680474/django_guardian-3.4.0-py3-none-any.whl", hash = "sha256:73380fcf6815b1de15dea9cbfc931d20afdae30852228c9be2f286e0e84950c8", size = 148072, upload-time = "2026-08-28T10:04:06.056Z" }, ] [[package]] @@ -1095,11 +1095,11 @@ wheels = [ [[package]] name = "django-treenode" -version = "0.24.0" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/8b/ff8bca02000c1d211e8eb29111fe45fd725992fc53511384b57824225333/django_treenode-0.24.0.tar.gz", hash = "sha256:7ad2198620c285ebaa9a75d0bdcb9164c5e88c084f4c3e8f765ed0fa44cbd0d6", size = 28447, upload-time = "2026-04-15T19:52:28.502Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2b/09f04b44b3583a911ae4f5d0e3360a23d529704c9e9115fce2520b7d4420/django_treenode-0.25.0.tar.gz", hash = "sha256:d14d0eb3571c227ce11147210d62a8f87b568bbb30fa76a5be9b9e6d514157c3", size = 29951, upload-time = "2026-08-26T00:20:13.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/5a/c0a6c96d33bc86827ef13397fb4d4d83a49d067961a4225702148b20ced9/django_treenode-0.24.0-py3-none-any.whl", hash = "sha256:ed9c14fb3eac31e8279f11debc3d024e82e570be6624b3d99dbd94459b6278b5", size = 22089, upload-time = "2026-04-15T19:52:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ac/2140e8c20e2a47457ec23302273527d3430bf5c09edb8940d74c0725c138/django_treenode-0.25.0-py3-none-any.whl", hash = "sha256:f47caab5440420870b6287602b3ee401ca354416d44b24ab97579be5ea658927", size = 22343, upload-time = "2026-08-26T00:20:12.078Z" }, ] [[package]] @@ -1153,14 +1153,14 @@ wheels = [ [[package]] name = "drf-spectacular-sidecar" -version = "2026.7.1" +version = "2026.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/51/9e038d14bf51a0bd051e8bcb690287349c908fa7ba69021d9f3e5d5ac51f/drf_spectacular_sidecar-2026.7.1.tar.gz", hash = "sha256:40113c4066c7bc3ef15a7ce1c40cda227a907a9986748024a813a9e0595eba25", size = 2593211, upload-time = "2026-07-01T13:39:06.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/82/eb18e2108b8ceaa50a01d2cdb35ab5ce198c18f062448b2d29d6e89aabeb/drf_spectacular_sidecar-2026.8.1.tar.gz", hash = "sha256:9d9ee678e5ec70cdda2c7ed95cafcdb6141664dc3e11def8a8599c8ad36cf6f5", size = 2603635, upload-time = "2026-08-01T12:09:03.088Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/d08f5c79f7643dbff4512605c28b75481966ed6c8cc9b397c9dd2ee91cd1/drf_spectacular_sidecar-2026.7.1-py3-none-any.whl", hash = "sha256:bc6d50c9b64660e45e09296d39553b3e759eedd825fc41d631ee5b3f88e0c5de", size = 2617384, upload-time = "2026-07-01T13:39:03.787Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5b/5ea443b8c24b4a4815a6467a158c4511dfe8bed83f8861a27bdb29c4fc47/drf_spectacular_sidecar-2026.8.1-py3-none-any.whl", hash = "sha256:6b8784d3e8c6bd9aa740b14e874640697ab1bc67f5232d74fdd9ef84eaea6bc3", size = 2626283, upload-time = "2026-08-01T12:09:01.705Z" }, ] [[package]] @@ -1194,11 +1194,11 @@ wheels = [ [[package]] name = "faker" -version = "40.36.0" +version = "40.37.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/d2/026af1e002bbc6df534d1f8262b18ec79a974f928e9290bfbfdfe7c7b2af/faker-40.36.0.tar.gz", hash = "sha256:754048c76c03afa7de83eee8f4bcee3cf668cbb7d995f54a4e9678db7f110308", size = 2025903, upload-time = "2026-07-24T21:11:33.088Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/fb/35acc76128d5ee8983d940da871cc8eba876e7b0e9e6bd402ecd7104dcdc/faker-40.37.0.tar.gz", hash = "sha256:a92dff7f310e61fb544c61720e15edb2e7448bc33d15a321a99e9ab7b94abf54", size = 2025920, upload-time = "2026-08-21T16:33:16.261Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/9a/b947ed175ce9a0dcb070ccf3607f0ce8720cfb5ed1a36166a150b2acd5af/faker-40.36.0-py3-none-any.whl", hash = "sha256:82b9497d9cfe017048075bcf969298a74b1b6e39f5e4dad1211085d1133f7b62", size = 2062829, upload-time = "2026-07-24T21:11:31.37Z" }, + { url = "https://files.pythonhosted.org/packages/3e/22/bf589b6b2c7527047f55450358fbe5961aeaadf168d6b51a1a1c67da497f/faker-40.37.0-py3-none-any.whl", hash = "sha256:ddbafa55c94d5b69c08ced3a7f202614204a02e07ba6548c729b8d18acc0b490", size = 2062853, upload-time = "2026-08-21T16:33:14.516Z" }, ] [[package]] @@ -1850,11 +1850,11 @@ wheels = [ [[package]] name = "imap-tools" -version = "1.14.0" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/66/d52b6c7740aa81678404a9039668ab2b4887f46055290f232229a2d5b612/imap_tools-1.14.0.tar.gz", hash = "sha256:d730a35763c09ca02be45b93c8d5d638d9f098876c95841c7ce4d337ede403da", size = 48268, upload-time = "2026-07-24T06:23:43.804Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/4e/acf9a7d59932548bc749fe0f5ae852e6da00b640303ed72786f697944b69/imap_tools-1.15.0.tar.gz", hash = "sha256:a90f948549ab000d96caf17c90e63b114f8788ba4a0d3f9274a96c0e108c3805", size = 48497, upload-time = "2026-08-06T10:25:26.567Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/eb/8567f7542aaee43575002915a959e8891d2d9f3642d1bc5d32410d871bb5/imap_tools-1.14.0-py3-none-any.whl", hash = "sha256:eab3506e701eba44269bc04760b8b29a96c93b173553057180fe40a084d65426", size = 36410, upload-time = "2026-07-24T06:23:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/50/eb/a4ad6ec962af2806c3484adb1869c24a04d8ef8c2034f2dd2b0f48459b0a/imap_tools-1.15.0-py3-none-any.whl", hash = "sha256:7bb0003c8bf124b80968c7bef786e4577d8365e9afb6551dc15b13161b688bdc", size = 36512, upload-time = "2026-08-06T10:25:24.377Z" }, ] [[package]] @@ -2802,7 +2802,7 @@ wheels = [ [[package]] name = "ocrmypdf" -version = "17.10.0" +version = "17.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fpdf2" }, @@ -2819,9 +2819,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "uharfbuzz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/2e/96c9912ad50fe3e186f0d3580d06d27ac2300fcc178d7457794f4bcaf3b8/ocrmypdf-17.10.0.tar.gz", hash = "sha256:3e80a22e7ca9a746034e990414c9f18791f168800f3ede92101504f45be6129c", size = 7499394, upload-time = "2026-08-05T00:25:50.595Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/9a/f7d7c943e07b0a28cb0958229ac480f3c51b4169ad9df030477f103b298a/ocrmypdf-17.11.0.tar.gz", hash = "sha256:8e41cbba23bba9ce20bc557576211e1829d1beaef00183bacd227b4b9482af87", size = 7544564, upload-time = "2026-08-28T22:15:37.774Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/6b/29a4c5f4e67d16bff32f706bf338d443761fe4933b9be4067e958db04aaa/ocrmypdf-17.10.0-py3-none-any.whl", hash = "sha256:34ba1b595ecacc94b6dc3c9d4fa51953de63082cd16cf8595251bd72120b930a", size = 523170, upload-time = "2026-08-05T00:25:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/5a996c67cf44ecd3dfb6154202c7fecefb71b5ed52ec00b6a4de46a77e15/ocrmypdf-17.11.0-py3-none-any.whl", hash = "sha256:56cc82e49e22ba0fe77585d7fe788ab951b58ff1e7f75025eafa917e37efb2a9", size = 534921, upload-time = "2026-08-28T22:15:35.815Z" }, ] [[package]] @@ -3036,14 +3036,14 @@ requires-dist = [ { name = "django-cors-headers", specifier = "~=4.9.0" }, { name = "django-extensions", specifier = "~=4.1" }, { name = "django-filter", specifier = "~=25.1" }, - { name = "django-guardian", specifier = "~=3.3.3" }, + { name = "django-guardian", specifier = ">=3.3.3,<3.5.0" }, { name = "django-multiselectfield", specifier = "~=1.0.1" }, { name = "django-rich", specifier = "~=2.2.0" }, { name = "django-soft-delete", specifier = "~=1.0.18" }, { name = "django-treenode", specifier = ">=0.24" }, { name = "djangorestframework", specifier = "~=3.16" }, { name = "drf-spectacular", specifier = "~=0.30" }, - { name = "drf-spectacular-sidecar", specifier = "~=2026.7.1" }, + { name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.9.0" }, { name = "drf-writable-nested", specifier = "~=0.7.1" }, { name = "filelock", specifier = "~=3.32.0" }, { name = "flower", specifier = ">=2.0.1,<2.2" }, @@ -3051,7 +3051,7 @@ requires-dist = [ { name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" }, { name = "httpx-oauth", specifier = "~=0.17" }, { name = "ijson", specifier = ">=3.5.1" }, - { name = "imap-tools", specifier = "~=1.14.0" }, + { name = "imap-tools", specifier = ">=1.14,<1.16" }, { name = "jinja2", specifier = "~=3.1.6" }, { name = "langdetect", specifier = "~=1.0.9" }, { name = "llama-index-core", specifier = ">=0.14.23" }, @@ -3062,7 +3062,7 @@ requires-dist = [ { name = "llama-index-llms-openai-like", specifier = ">=0.7.1" }, { name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" }, { name = "nltk", specifier = "~=3.10.0" }, - { name = "ocrmypdf", specifier = ">=17.7,<17.11" }, + { name = "ocrmypdf", specifier = ">=17.7,<17.12" }, { name = "openai", specifier = ">=2.48" }, { name = "pathvalidate", specifier = "~=3.3.1" }, { name = "pdf2image", specifier = "~=1.17.0" }, @@ -3098,12 +3098,12 @@ provides-extras = ["mariadb", "postgres", "webserver"] dev = [ { name = "daphne" }, { name = "factory-boy", specifier = "~=3.3.1" }, - { name = "faker", specifier = "~=40.36.0" }, + { name = "faker", specifier = ">=40.36,<40.38" }, { name = "imagehash" }, - { name = "prek", specifier = "~=0.4.11" }, + { name = "prek", specifier = ">=0.4.11,<0.6.0" }, { name = "pytest", specifier = "~=9.1.1" }, { name = "pytest-cov", specifier = "~=7.1.0" }, - { name = "pytest-django", specifier = "~=4.12.0" }, + { name = "pytest-django", specifier = ">=4.12,<4.15" }, { name = "pytest-env", specifier = "~=1.7.0" }, { name = "pytest-httpx" }, { name = "pytest-mock", specifier = "~=3.15.1" }, @@ -3116,17 +3116,17 @@ dev = [ ] docs = [{ name = "zensical", specifier = ">=0.0.51" }] lint = [ - { name = "prek", specifier = "~=0.4.11" }, + { name = "prek", specifier = ">=0.4.11,<0.6.0" }, { name = "ruff", specifier = "~=0.16.1" }, ] testing = [ { name = "daphne" }, { name = "factory-boy", specifier = "~=3.3.1" }, - { name = "faker", specifier = "~=40.36.0" }, + { name = "faker", specifier = ">=40.36,<40.38" }, { name = "imagehash" }, { name = "pytest", specifier = "~=9.1.1" }, { name = "pytest-cov", specifier = "~=7.1.0" }, - { name = "pytest-django", specifier = "~=4.12.0" }, + { name = "pytest-django", specifier = ">=4.12,<4.15" }, { name = "pytest-env", specifier = "~=1.7.0" }, { name = "pytest-httpx" }, { name = "pytest-mock", specifier = "~=3.15.1" }, @@ -3376,23 +3376,23 @@ wheels = [ [[package]] name = "prek" -version = "0.4.11" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/1a/73b6dae5ce7e997cb35a69bfe1d25a798e85fa3d2eabf95324563f461b30/prek-0.4.11.tar.gz", hash = "sha256:4a14cb9bbae850605ae3904fbdbb12f0e00c12455efaa2266da8fb8e5c0350d7", size = 516254, upload-time = "2026-07-24T17:05:35.107Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/c8/ad3efcaf7007e4796f9a7482b4a3d84402c1535109695cba76fcb87cb03d/prek-0.5.0.tar.gz", hash = "sha256:8df015db60c1e9a30b4a266e65fa14c4d41d2b2b3b90879d76a5d8970dcce64d", size = 544033, upload-time = "2026-08-27T03:50:33.079Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/d7/a00b2de492a80e99b1698e72c1d196ac3ed544dc7ee0ada261ac066e78e0/prek-0.4.11-py3-none-linux_armv6l.whl", hash = "sha256:3830cb7cc47e837888b8b464ecb21355a69235cfacef0fd89101e17f09345d63", size = 5770511, upload-time = "2026-07-24T17:05:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/c1/1e/f97c74defcd5d5645888cb99fcdd9b8b48cc7247cf31e64414d106d56d66/prek-0.4.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:45facadf9c2332b28e6ab2744312ae0275a93ab5a437da8bcc53a8c7260cb4b0", size = 6118049, upload-time = "2026-07-24T17:05:14.451Z" }, - { url = "https://files.pythonhosted.org/packages/d9/92/8367d26421ee6fe6019a63928fb0ed31179cd0d6199879c524f89ef4c95c/prek-0.4.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f8a194b1d00d24dff8baff691c99e2668339da2da3482b4ee99c1a0f2409378", size = 5601478, upload-time = "2026-07-24T17:05:15.81Z" }, - { url = "https://files.pythonhosted.org/packages/e1/6f/7617c9b87afaadede4167720aae7ef12d7e51167db903bc8fbac0cadef7b/prek-0.4.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:21d93e6d76bf3d7a9bb70c6ac86ef372adaee50068c45749e6b9e1c2ac4ec939", size = 5932071, upload-time = "2026-07-24T17:05:17.217Z" }, - { url = "https://files.pythonhosted.org/packages/ef/41/6796a4011b04212333259064aa885a71d03d9581ba9bff52db3ce58d1f06/prek-0.4.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fb07cde2d2156efa6980122b3f13dc88f20c84b933c6205675d0f1e7be2cde8", size = 5677617, upload-time = "2026-07-24T17:05:18.658Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/3e339901f8460b6073313619b4fd9bf4135e48430695c53640b83ace88ea/prek-0.4.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b7f5e446d2aca739bd18b380578e9c78bb4c086299abc3e167d51c47840c56f", size = 6106370, upload-time = "2026-07-24T17:05:20.219Z" }, - { url = "https://files.pythonhosted.org/packages/8e/4e/94e24b5c1910ec15692ecaf33d0d8ef0d02a02a8b5e40d3c976396880cba/prek-0.4.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7059d640e595d098600e2d97af961f46292b4112670edbe632de84f6828389e", size = 6884342, upload-time = "2026-07-24T17:05:21.587Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7c/fc0daa033dcafe74990c00af2da1e16922790b9c1b8da7e8eebf1123838b/prek-0.4.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a4df33998fcac878bce3b2c624e1caeb9609f3d562c640702c1234ed815daf", size = 6331365, upload-time = "2026-07-24T17:05:22.87Z" }, - { url = "https://files.pythonhosted.org/packages/d7/36/49f152b8f539930e9685cff0509695ce4054abcecc22f4c16c4e1e5c23d0/prek-0.4.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866991f387527c5f880ce2cc3ddea9b33cc5b986881f8c7f92524cf0969c1350", size = 5939075, upload-time = "2026-07-24T17:05:24.249Z" }, - { url = "https://files.pythonhosted.org/packages/4f/fe/7b097af9161edae7ddedcc9f5cda4a5f31346a492ce3f92583d96c46f628/prek-0.4.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:247e5d8740e137ebdf24fa96f01a95b2d2f1892e956a1ab00c7b1474a59ebcc0", size = 5799029, upload-time = "2026-07-24T17:05:25.652Z" }, - { url = "https://files.pythonhosted.org/packages/49/63/dc955ff99e1002d3cd375b21467c87046bc41deddfce86d898240757b151/prek-0.4.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:603ba9f2fd9d666dddb3ae190a25a5c55091b843cde90ed52e0a1116f50d4062", size = 5651211, upload-time = "2026-07-24T17:05:27.027Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b8/bf2139ec25eefb5afef43afac7620c5181f2c2661f220598beacb1771207/prek-0.4.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f2682ece3c5fc7201106c4fdd84b0587ccea4b8a2ecefa3e94d3074d2841f1df", size = 5954784, upload-time = "2026-07-24T17:05:28.391Z" }, - { url = "https://files.pythonhosted.org/packages/fc/29/3fe5990aee1bd7c4d50a03358ad867c5e912d9510aafb83a359c7347e5fa/prek-0.4.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:22721d30394192931fecc80d6fc6dd47e8ddf8db8b9693805aba8ec0f50087ec", size = 6448916, upload-time = "2026-07-24T17:05:29.837Z" }, + { url = "https://files.pythonhosted.org/packages/89/83/a5b36db04d5ed8c461b9fec216193a52eee5be37170a9387022f832312b6/prek-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:df794a883e347ef78cb74522a143dbd8cc2a66e765ef328f48b615f37098015e", size = 5593469, upload-time = "2026-08-27T03:50:00.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b5/cd6cab203693a1c45d5a3bae3b8eb56174d595da8af6d6e1595c795f909a/prek-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:19d6fd892e112cf7dc300735a7ac8e596813ff1c11649ba2d2b9dd6fdfd6c96e", size = 5980270, upload-time = "2026-08-27T03:50:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b6/bbf5134c7b1360aad3f62c0f0391a7c6403b3500d16df25848089ad957c3/prek-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:313e2535483c37bba884ab034f478c174de7d3b9627081f2eedd16ac2c03bcc3", size = 5521835, upload-time = "2026-08-27T03:50:04.609Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/7c79e9de5603fdc54d2101f0982f1af5e5ffa1a45c0bcbdbe5f985ed6b90/prek-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1e9b34caeb465f1fa7cbb5d872d4611560eaff877162c4ad80a608838b0819a3", size = 5822039, upload-time = "2026-08-27T03:50:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/68c21ec26f773b2370b132d69fe325589db08865fbe8b3df7d8ea8d8fc49/prek-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:561c2d028c7679fe8969937aa9ab74d96d6f7c748b8abf98edf6dc031dfbbdc9", size = 5510802, upload-time = "2026-08-27T03:50:09.2Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/ce6db8d998cb3e48bf747b7390e1ec87bb817ec500cfa5791af3a0d2eded/prek-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:310579ceb4acbc5646df9d663ec38a57b47258a903780089cfacfbf0dc9d49f1", size = 5969141, upload-time = "2026-08-27T03:50:11.211Z" }, + { url = "https://files.pythonhosted.org/packages/5a/10/35e4c8fc4534d3b98c1bef2bfd559ba2aeedc9e540fcd4091fd4d91543b6/prek-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da38e9c1d227773728c05d2f644704f3d3ace67f6ea58e41d406ebb596c3a135", size = 6731634, upload-time = "2026-08-27T03:50:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fc/433b55d10e928de8ab2a9b83dfdbe8cba936fafc6e0a5c65b85f4007a8ea/prek-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b845f25f481a7df56f39dddcba35576a10d78aa71e09e4f88bcccce0729ebfb2", size = 6203980, upload-time = "2026-08-27T03:50:15.158Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b8/b5cdd27d6da0d754abcf9cc06a092f476bd9242f0e8e19e3dfd1a2d1b315/prek-0.5.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:93b4086a1b69c24a967b5c80608cfc4a111cc2b0f69bc1d1f278eb4c23ad430c", size = 5827384, upload-time = "2026-08-27T03:50:16.898Z" }, + { url = "https://files.pythonhosted.org/packages/18/e5/1d8fd614dfe04aa9378b33fb23f7285da582ca090c321973da8466a754d5/prek-0.5.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3299eca7269582665255f4c8f3ab38bc64e4d16ab2e9ce5e3d20cf65bc9f40ee", size = 5589195, upload-time = "2026-08-27T03:50:18.857Z" }, + { url = "https://files.pythonhosted.org/packages/6c/89/e758cefe423cd1422e882325baef65af2bf2b6a903c32d657323a993d394/prek-0.5.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:005c20b3df988f240bfa50518777d5babeab50cf774a8917dba1705567e1f561", size = 5491009, upload-time = "2026-08-27T03:50:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/ea/90/6b30d898a2610113378b78b82f1d700bcdb79993ba92b85f492a02859f53/prek-0.5.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:a1a46d8de94d7c7c58b027a3622763861f1c76b01bafe9dc6d8b408713ad3946", size = 5826007, upload-time = "2026-08-27T03:50:23.1Z" }, + { url = "https://files.pythonhosted.org/packages/48/23/3fd00ff6d844b1756b95fc913730c6ec2f772f36558693ff3c4cfb2d3071/prek-0.5.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e2aad66e0111cab24a03af2f67fca737c3d8f7b1188cf9b8994bbbf7b52f53b8", size = 6319092, upload-time = "2026-08-27T03:50:25.41Z" }, ] [[package]] @@ -3854,14 +3854,14 @@ wheels = [ [[package]] name = "pytest-django" -version = "4.12.0" +version = "4.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, + { url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" }, ] [[package]] @@ -3904,15 +3904,15 @@ wheels = [ [[package]] name = "pytest-rerunfailures" -version = "16.4" +version = "16.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/60/a90ca1cc6cffcb97b4260ed0ad2b7934b999d7c48abe4ea0840344862a3b/pytest_rerunfailures-16.4.tar.gz", hash = "sha256:8222d17c37eb7b9e4d6fc96a3c724ff4e1a5c97a5cc7cbb2c19e9282cfd21a11", size = 36635, upload-time = "2026-07-01T06:30:56.813Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/0114e45d4b2fcd5f6297dac655c067b47de28be4d33e088f200f0f2c4c28/pytest_rerunfailures-16.6.tar.gz", hash = "sha256:29dbfee46f542073c888e0ed4e81c51e15b9096f49a299eb1a759629c601684a", size = 42806, upload-time = "2026-08-17T07:11:00.447Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/93/3cdcc4033444e822e01b573414b03fd37fd5533070c750477b8f5fa5224b/pytest_rerunfailures-16.4-py3-none-any.whl", hash = "sha256:f69b5beb39622c90d1e44bd945d826eff6db545dcf0b68f52b7e4ad15eaf6d6c", size = 16955, upload-time = "2026-07-01T06:30:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/1e994889673d7a0da11651f17ef789b6c83bfe349f29f871873dd3802445/pytest_rerunfailures-16.6-py3-none-any.whl", hash = "sha256:6af2d1ebd6e5cb79666ac408942cd6a0672a49fefd8523664770718560795e13", size = 19137, upload-time = "2026-08-17T07:10:59.121Z" }, ] [[package]] @@ -4861,58 +4861,52 @@ wheels = [ [[package]] name = "time-machine" -version = "3.2.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/1a/d9c82e780ac6c41102d50b30c3fb3c25a3b8dd46f807147462d1f4429171/time_machine-3.5.0.tar.gz", hash = "sha256:bc193985b43f15394cfded976efaf9068c3078a2135f42f81c874ba684720eae", size = 25692, upload-time = "2026-08-25T00:18:32.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/e1/03aae5fbaa53859f665094af696338fc7cae733d926a024af69982712350/time_machine-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c188a9dda9fcf975022f1b325b466651b96a4dfc223c523ed7ed8d979f9bf3e8", size = 19143, upload-time = "2025-12-17T23:31:44.258Z" }, - { url = "https://files.pythonhosted.org/packages/75/8f/98cb17bebb52b22ff4ec26984dd44280f9c71353c3bae0640a470e6683e5/time_machine-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17245f1cc2dd13f9d63a174be59bb2684a9e5e0a112ab707e37be92068cd655f", size = 15273, upload-time = "2025-12-17T23:31:45.246Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2f/ca11e4a7897234bb9331fcc5f4ed4714481ba4012370cc79a0ae8c42ea0a/time_machine-3.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9bd1de1996e76efd36ae15970206c5089fb3728356794455bd5cd8d392b5537", size = 31049, upload-time = "2025-12-17T23:31:46.613Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/d17d83a59943094e6b6c6a3743caaf6811b12203c3e07a30cc7bcc2ab7ee/time_machine-3.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98493cd50e8b7f941eab69b9e18e697ad69db1a0ec1959f78f3d7b0387107e5c", size = 32632, upload-time = "2025-12-17T23:31:47.72Z" }, - { url = "https://files.pythonhosted.org/packages/71/50/d60576d047a0dfb5638cdfb335e9c3deb6e8528544fa0b3966a8480f72b7/time_machine-3.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31f2a33d595d9f91eb9bc7f157f0dc5721f5789f4c4a9e8b852cdedb2a7d9b16", size = 34289, upload-time = "2025-12-17T23:31:48.913Z" }, - { url = "https://files.pythonhosted.org/packages/fa/fe/4afa602dbdebddde6d0ea4a7fe849e49b9bb85dc3fb415725a87ccb4b471/time_machine-3.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9f78ac4213c10fbc44283edd1a29cfb7d3382484f4361783ddc057292aaa1889", size = 33175, upload-time = "2025-12-17T23:31:50.611Z" }, - { url = "https://files.pythonhosted.org/packages/0d/87/c152e23977c1d7d7c94eb3ed3ea45cc55971796205125c6fdff40db2c60f/time_machine-3.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c1326b09e947b360926d529a96d1d9e126ce120359b63b506ecdc6ee20755c23", size = 31170, upload-time = "2025-12-17T23:31:51.645Z" }, - { url = "https://files.pythonhosted.org/packages/80/af/54acf51d0f3ade3b51eab73df6192937c9a938753ef5456dff65eb8630be/time_machine-3.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9f2949f03d15264cc15c38918a2cda8966001f0f4ebe190cbfd9c56d91aed8ac", size = 32292, upload-time = "2025-12-17T23:31:52.803Z" }, - { url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" }, - { url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" }, - { url = "https://files.pythonhosted.org/packages/4b/26/b5ca19da6f25ea905b3e10a0ea95d697c1aeba0404803a43c68f1af253e6/time_machine-3.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97da3e971e505cb637079fb07ab0bcd36e33279f8ecac888ff131f45ef1e4d8d", size = 34579, upload-time = "2025-12-17T23:32:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/79/ca/6ac7ad5f10ea18cc1d9de49716ba38c32132c7b64532430d92ef240c116b/time_machine-3.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cdda6dee4966e38aeb487309bb414c6cb23a81fc500291c77a8fcd3098832e7", size = 35961, upload-time = "2025-12-17T23:32:02.521Z" }, - { url = "https://files.pythonhosted.org/packages/33/67/390dd958bed395ab32d79a9fe61fe111825c0dd4ded54dbba7e867f171e6/time_machine-3.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d9efd302a6998bcc8baa4d84f259f8a4081105bd3d7f7af7f1d0abd3b1c8aa", size = 34668, upload-time = "2025-12-17T23:32:03.585Z" }, - { url = "https://files.pythonhosted.org/packages/da/57/c88fff034a4e9538b3ae7c68c9cfb283670b14d17522c5a8bc17d29f9a4b/time_machine-3.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3a0b0a33971f14145853c9bd95a6ab0353cf7e0019fa2a7aa1ae9fddfe8eab50", size = 32891, upload-time = "2025-12-17T23:32:04.656Z" }, - { url = "https://files.pythonhosted.org/packages/2d/70/ebbb76022dba0fec8f9156540fc647e4beae1680c787c01b1b6200e56d70/time_machine-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2d0be9e5f22c38082d247a2cdcd8a936504e9db60b7b3606855fb39f299e9548", size = 34080, upload-time = "2025-12-17T23:32:06.146Z" }, - { url = "https://files.pythonhosted.org/packages/ee/cd/43ad5efc88298af3c59b66769cea7f055567a85071579ed40536188530c1/time_machine-3.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c421a8eb85a4418a7675a41bf8660224318c46cc62e4751c8f1ceca752059090", size = 19318, upload-time = "2025-12-17T23:32:10.518Z" }, - { url = "https://files.pythonhosted.org/packages/b0/f6/084010ef7f4a3f38b5a4900923d7c85b29e797655c4f6ee4ce54d903cca8/time_machine-3.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4e758f7727d0058c4950c66b58200c187072122d6f7a98b610530a4233ea7b", size = 15390, upload-time = "2025-12-17T23:32:11.625Z" }, - { url = "https://files.pythonhosted.org/packages/25/aa/1cabb74134f492270dc6860cb7865859bf40ecf828be65972827646e91ad/time_machine-3.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:154bd3f75c81f70218b2585cc12b60762fb2665c507eec5ec5037d8756d9b4e0", size = 33115, upload-time = "2025-12-17T23:32:13.219Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/78c5d7dfa366924eb4dbfcc3fc917c39a4280ca234b12819cc1f16c03d88/time_machine-3.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50cfe5ebea422c896ad8d278af9648412b7533b8ea6adeeee698a3fd9b1d3b7", size = 34705, upload-time = "2025-12-17T23:32:14.29Z" }, - { url = "https://files.pythonhosted.org/packages/86/93/d5e877c24541f674c6869ff6e9c56833369796010190252e92c9d7ae5f0f/time_machine-3.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636576501724bd6a9124e69d86e5aef263479e89ef739c5db361469f0463a0a1", size = 36104, upload-time = "2025-12-17T23:32:15.354Z" }, - { url = "https://files.pythonhosted.org/packages/22/1c/d4bae72f388f67efc9609f89b012e434bb19d9549c7a7b47d6c7d9e5c55d/time_machine-3.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40e6f40c57197fcf7ec32d2c563f4df0a82c42cdcc3cab27f688e98f6060df10", size = 34765, upload-time = "2025-12-17T23:32:16.434Z" }, - { url = "https://files.pythonhosted.org/packages/1d/c3/ac378cf301d527d8dfad2f0db6bad0dfb1ab73212eaa56d6b96ee5d9d20b/time_machine-3.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a1bcf0b846bbfc19a79bc19e3fa04d8c7b1e8101c1b70340ffdb689cd801ea53", size = 33010, upload-time = "2025-12-17T23:32:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/06/35/7ce897319accda7a6970b288a9a8c52d25227342a7508505a2b3d235b649/time_machine-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae55a56c179f4fe7a62575ad5148b6ed82f6c7e5cf2f9a9ec65f2f5b067db5f5", size = 34185, upload-time = "2025-12-17T23:32:18.566Z" }, - { url = "https://files.pythonhosted.org/packages/67/e7/487f0ba5fe6c58186a5e1af2a118dfa2c160fedb37ef53a7e972d410408e/time_machine-3.2.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:59d71545e62525a4b85b6de9ab5c02ee3c61110fd7f636139914a2335dcbfc9c", size = 20000, upload-time = "2025-12-17T23:32:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/e1/17/eb2c0054c8d44dd42df84ccd434539249a9c7d0b8eb53f799be2102500ab/time_machine-3.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:999672c621c35362bc28e03ca0c7df21500195540773c25993421fd8d6cc5003", size = 15657, upload-time = "2025-12-17T23:32:24.125Z" }, - { url = "https://files.pythonhosted.org/packages/43/21/93443b5d1dd850f8bb9442e90d817a9033dcce6bfbdd3aabbb9786251c80/time_machine-3.2.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5faf7397f0580c7b9d67288522c8d7863e85f0cffadc0f1fccdb2c3dfce5783e", size = 39216, upload-time = "2025-12-17T23:32:25.542Z" }, - { url = "https://files.pythonhosted.org/packages/9f/9e/18544cf8acc72bb1dc03762231c82ecc259733f4bb6770a7bbe5cd138603/time_machine-3.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3dd886ec49f1fa5a00e844f5947e5c0f98ce574750c24b7424c6f77fc1c3e87", size = 40764, upload-time = "2025-12-17T23:32:26.643Z" }, - { url = "https://files.pythonhosted.org/packages/27/f7/9fe9ce2795636a3a7467307af6bdf38bb613ddb701a8a5cd50ec713beb5e/time_machine-3.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0ecd96bc7bbe450acaaabe569d84e81688f1be8ad58d1470e42371d145fb53", size = 43526, upload-time = "2025-12-17T23:32:27.693Z" }, - { url = "https://files.pythonhosted.org/packages/03/c1/a93e975ba9dec22e87ec92d18c28e67d36bd536f9119ffa439b2892b0c9c/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:158220e946c1c4fb8265773a0282c88c35a7e3bb5d78e3561214e3b3231166f3", size = 41727, upload-time = "2025-12-17T23:32:28.985Z" }, - { url = "https://files.pythonhosted.org/packages/5f/fb/e3633e5a6bbed1c76bb2e9810dabc2f8467532ffcd29b9aed404b473061a/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c1aee29bc54356f248d5d7dfdd131e12ca825e850a08c0ebdb022266d073013", size = 38952, upload-time = "2025-12-17T23:32:30.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/3d/02e9fb2526b3d6b1b45bc8e4d912d95d1cd699d1a3f6df985817d37a0600/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ed2224f09d25b1c2fc98683613aca12f90f682a427eabb68fc824d27014e4a", size = 39829, upload-time = "2025-12-17T23:32:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" }, - { url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" }, - { url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" }, - { url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" }, - { url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" }, - { url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" }, - { url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" }, - { url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" }, - { url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" }, - { url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" }, - { url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/00/c4/e19d1646c1b879a90b8291279a927c7e9c961371128b1f0528c30421da76/time_machine-3.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43448a1f2e9916e1a1f7cef0260decaad9071e9afe8ed8a4493e54e358c72802", size = 25285, upload-time = "2026-08-25T00:17:33.517Z" }, + { url = "https://files.pythonhosted.org/packages/62/ac/7fa71d6f9f77b65f77e6906ee6fa4fa4cf4ab7ff2995be8fd9491533f5a5/time_machine-3.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5999f1854a4bcc021fca7c16bfbb7bcf8bd912691aad22af02a8745becc65fd", size = 25695, upload-time = "2026-08-25T00:17:34.43Z" }, + { url = "https://files.pythonhosted.org/packages/45/96/e915a95dfb3bd5314c6878c7d3a73b6a41a573133b7556daa09f1778c494/time_machine-3.5.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ba76cc7d5ad70babda28e064d47b8887adc47825acf5eba164527a937a104b29", size = 53446, upload-time = "2026-08-25T00:17:35.381Z" }, + { url = "https://files.pythonhosted.org/packages/da/b6/4566a277ce38ffd9a0b9aa7ba69cc489272a43f5e37e2f8bd10e6db7763d/time_machine-3.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5382acf9caa9c9bd1e1ef86f987cd7f91f5b70907baf16943d41b326ac0891c", size = 54681, upload-time = "2026-08-25T00:17:36.476Z" }, + { url = "https://files.pythonhosted.org/packages/12/24/8ac65ef5e961e1033a6498cc50e4cc51be6ece08e9fb7946857512e2998e/time_machine-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cfa05cfaaf9c394bd976c3a437d358e668236794fc4295e4f7ffb91bc5999c0", size = 53489, upload-time = "2026-08-25T00:17:37.439Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f6/dd28591ebdca5ebad418c2c47c473ecac271eae53f05afc612660f4fa35d/time_machine-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d200c02eb95d7d766f2942dfc5e70a888b0beea3e92ed363bc255f01d2b6b6b9", size = 52823, upload-time = "2026-08-25T00:17:38.597Z" }, + { url = "https://files.pythonhosted.org/packages/ba/fe/7719438b331df5a4b44740da046ad4919f2d19cf6ec8872425823368b404/time_machine-3.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:60baf021c396bc07b3403f96bbc06a2ed977dd42e1d942fc29edd6575ff6c601", size = 25708, upload-time = "2026-08-25T00:17:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/68/e6/8acacd366d94a19f02011ce2af4821315c59af8b868220543096c1a70375/time_machine-3.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdfd042a4b71fd2687eb16070db3d24e652c147d132a66e1756ac932f2f2bfdb", size = 25888, upload-time = "2026-08-25T00:17:42.783Z" }, + { url = "https://files.pythonhosted.org/packages/62/20/1cbce3bc36438730c945d6a3990f9de0ab07af1ad1cf9b6a399d0887e8ed/time_machine-3.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2b0ec43f3f8eb0edb92637684cdc4309fa9ea09f8f78e5daec20c7d5c4bc0b2e", size = 57769, upload-time = "2026-08-25T00:17:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/a3dd6e0b8543cb559705af629fb73bbef753a8db31f7abe891d9c95ed7db/time_machine-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1962b3f01d979e3fe7d8ce1883bb841ef7f39be227776755e2f1f38d6d50b1dc", size = 58524, upload-time = "2026-08-25T00:17:44.724Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5d/0e6dc3fc6c055d35e140a862e1e214833edd1ae565ea63e1249e7ed841b0/time_machine-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7523bc19dfd77243ff20361685dd8b1fe319389f7bce758cadc72fd51e4f9471", size = 57199, upload-time = "2026-08-25T00:17:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/09/ac/79a13f1b16a40597e52214160ee79fbef2db00f39c8fb16dd3f9842d2ebf/time_machine-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8dd66efe1fe4740d1e789a3dc4121b593e0eaa1c3b9b75d6047033f9cc2263bc", size = 56917, upload-time = "2026-08-25T00:17:46.863Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/61669d9a3c95291de10486d36b24ca8af50dce17e8d624f9887208f484d1/time_machine-3.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f01f0e0fa67ea501c4ea3bf5983234612fb5036ac32ddab092b09ead37715c7c", size = 25707, upload-time = "2026-08-25T00:17:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a4/3f5a4286768dff89e2de8c9ebcb8765962f2ce00196865954f48132435ea/time_machine-3.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:479abe218c4bc03e1acd1914f095a2351ca658db17e69e56b194f5fe2337b216", size = 25878, upload-time = "2026-08-25T00:17:50.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ee/1cf35afb752519cd38a1aaf74617cde56c8417a6d235d26c9a650c74138f/time_machine-3.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a625e2fd1e3b0dcc1479f559866a068a8c48c3ebe7a2c2093daef8e924ff1a7e", size = 57713, upload-time = "2026-08-25T00:17:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/d9e3a30cad31fbbe17b81971984ae83121cf968e7583e36b8cf92f31e534/time_machine-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5624a6c3256182aaf1d15defcff782d6b0a12c8b2554191981bb59fe00954a", size = 58443, upload-time = "2026-08-25T00:17:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/63/37/82035b221f3bd657f8ca1eb3e28a5a7865338d35331ccc06a0569b87c39a/time_machine-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:27a2a399d500f03fafe185b4313d372098901bb66b839abf9fb1b981ffb85f44", size = 57122, upload-time = "2026-08-25T00:17:54.116Z" }, + { url = "https://files.pythonhosted.org/packages/93/ae/196922dd5af6b0b4ef650c02343f1276e4be5d98b2f83dfada5f79047cab/time_machine-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5e0c69a871a6a03daf491421590671652fd299803c218a37af9551c15dc3e68", size = 56842, upload-time = "2026-08-25T00:17:55.219Z" }, + { url = "https://files.pythonhosted.org/packages/97/e4/f100f18682c0a4c2cdff7d90dbad96fc91d6629a2459502c2781e451c6f6/time_machine-3.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:408f1cd20c5e5546ad71c25398b4b8ca2cc698e9efa52cbf1a21160ca5607505", size = 25765, upload-time = "2026-08-25T00:17:58.187Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0a/8a0c8cf3414b11f3c195df9c020bf393fab2e26f9a9973f16ae94e73d509/time_machine-3.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:92726ff3240d30d67b054f307dbe9257026dd092ce742d2a59a1c74317d194f4", size = 25889, upload-time = "2026-08-25T00:17:59.142Z" }, + { url = "https://files.pythonhosted.org/packages/43/cd/84c4c7f51d0881182e348ea0e30a329bc284bb24d680d29e0123bbd35a16/time_machine-3.5.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5dfab29d9d54bb073d5a7c4ed155d56964f6afb7726ead00003aee9f77d5e561", size = 57752, upload-time = "2026-08-25T00:18:00.111Z" }, + { url = "https://files.pythonhosted.org/packages/09/b6/a72ab775f6656cc746b6f2cdbbf7740772f7805fc3d99acde6be3112cd63/time_machine-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac0ef9d1dd64de23ba8b716c2321137c18d4b3d4229b33b494a889a47d82b35", size = 58547, upload-time = "2026-08-25T00:18:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5c/bf25aab5688a29bfde2682d44d2a6ba6627d7ff9b26acb6893f1f71f4dfa/time_machine-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6ba9a85d7b2a25e758337e3aebbd489f1578baad6ef3ad7b50e2e6ef47c52d26", size = 57183, upload-time = "2026-08-25T00:18:02.169Z" }, + { url = "https://files.pythonhosted.org/packages/97/3c/e4ae4520b49d684bac5a0404d7ae072e46d71f0a859e7abe43141db4eb65/time_machine-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d50d894be50d74fbcee13e53e20ced6c35336519efb1d20fa96ec97dd25e8063", size = 56895, upload-time = "2026-08-25T00:18:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/344e0174a1d923c2b8611a03546c432e0c52d785a6fc780a32f18e619353/time_machine-3.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d7f4145552b9e9c32548a5f56cb0e57cf760fb189651812d42dcddd9ea38d7c5", size = 26468, upload-time = "2026-08-25T00:18:06.281Z" }, + { url = "https://files.pythonhosted.org/packages/97/31/428d23f87a53a00d15cf0079756e74ca675bc43f318987b288e0edc0baa7/time_machine-3.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2f2234fd57263fd60cd47e87afff24b4b41ff64bcf62a88fbdff9df96884e10f", size = 26709, upload-time = "2026-08-25T00:18:07.41Z" }, + { url = "https://files.pythonhosted.org/packages/0d/61/b88cc24cb437fca0971ca738841440148a841e37bf46c51f236de8ba4fb7/time_machine-3.5.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9c01bdf00862e5f13554b45e75f9103a0e0ec9cc43c80d3e6283b23dfccbe79", size = 69081, upload-time = "2026-08-25T00:18:08.552Z" }, + { url = "https://files.pythonhosted.org/packages/64/7d/a4f6b7121f16cd0668f549c8896c2033361909431ddca5b504866809aa59/time_machine-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad1ca1aba7f0b52958646ef9f261123c04526218fcc85a041cc00caa7a0c510c", size = 71319, upload-time = "2026-08-25T00:18:09.799Z" }, + { url = "https://files.pythonhosted.org/packages/70/cc/96b68c16638936390f3afd939faee61d0e9a565813874e912b426c24d91c/time_machine-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:94c4baf87208eb5c1161dd6dbae6d1770c412c8a5a91c079f433912e050272d7", size = 69637, upload-time = "2026-08-25T00:18:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/97/b3/5da9984647c59d50128b0e7d5f03f73c273645eef9fed9dd42531da4183c/time_machine-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a610fa8487da78d4769dc77d04395cb7eaa8863a6907d58d0f142adba1c6b70f", size = 67643, upload-time = "2026-08-25T00:18:11.853Z" }, + { url = "https://files.pythonhosted.org/packages/67/99/dfe5115f8e26838c8e7f3cbe89ef1a1af58df3fb44a6603dcaaca1cc686c/time_machine-3.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:ed8ad24ea16263f2aa5fd65e172489e31dd18c27da9f9c5aacf0f13a7b1f33ae", size = 25767, upload-time = "2026-08-25T00:18:15.429Z" }, + { url = "https://files.pythonhosted.org/packages/7e/79/efc28c06a78bdaffb165fd6e6389dc9afe6b5edfdec4f415e7fd32b252e3/time_machine-3.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:618a3690c5d30c0c815f589c3f2625825d62b4e94f67b390c6f434dd489eae6d", size = 25893, upload-time = "2026-08-25T00:18:16.514Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ad/9c35bdde5bc64d0c73c2dee8edd89cfc799d1edf6498afa4acfdf171624c/time_machine-3.5.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:435549f1010a1344f032d842d51d2e1d62f7d24bf4108590590b5989ca336da6", size = 57938, upload-time = "2026-08-25T00:18:17.54Z" }, + { url = "https://files.pythonhosted.org/packages/93/99/0f6d87fdd9f63a5caf29b0e0b51b29160f148ce08663730e5436453aa309/time_machine-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f137ca62841c1b13d333dbda3e1c20d29c598aa1c0efb051a3301af3281902c", size = 58723, upload-time = "2026-08-25T00:18:18.577Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5b/e911dc4fb4313a0eb99f1ddc9f453adb7951a34d988abc5589426a91174e/time_machine-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e9488dc2a733c6b433f4a73ed09aa855512680713a8148580a9471aad1df100d", size = 57398, upload-time = "2026-08-25T00:18:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/2c349d5f93c0d35133ff392c0922588b1db55a29aa48cedeb168f7384b4c/time_machine-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e9d89725cd7cc1ef6dc0560397fa0e820aecdb9ceb7741b0c60108ea3cc7fc1a", size = 57091, upload-time = "2026-08-25T00:18:20.813Z" }, + { url = "https://files.pythonhosted.org/packages/3f/94/c319fdd99f9cf9f6a4ed3026bdaf0cc0617de0eca46aed7dc3b6abe2e087/time_machine-3.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:0e1a72dcf0acc644209ddf048502229c404ad4e44a4b41ce85045902dc2becbc", size = 26471, upload-time = "2026-08-25T00:18:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/12acfc2661560d0c5ec134331571b4835cf23a16f2848d2627191303913f/time_machine-3.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:98c28f9a2f7bd63ade6eeb4e0c2f1bee85862747f9ea5bf59d81584689db19d1", size = 26717, upload-time = "2026-08-25T00:18:25.101Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/23668168666e6b835caa979848b4addb2176b031ffbe1be3e42401ec699d/time_machine-3.5.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3dce82621d4f8782f2b8893b4152b58dc43254cee1ac69229c36621709b11036", size = 69104, upload-time = "2026-08-25T00:18:26.154Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ef/cec9cf58e5c6b91789b1d6c053fb230391896761fdfed4bd1f0690efe22a/time_machine-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bba5d2f7f34aa491521c1d1c13f66802aedc58b10826166753f4b824f8a9c8a3", size = 71339, upload-time = "2026-08-25T00:18:27.391Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d3/41391aa07e6f519b44a5df0c6cb4a0bba05ab22151a1dcbc7dc9c18a028b/time_machine-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4810f9be2dd8c9484a57e949c3ad23cbb6edf4c0e75ca6a10f5935b7d7c2cd0", size = 69674, upload-time = "2026-08-25T00:18:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/df/e3/f948ca31e142cebbe1efd81d361bcac7e9db4de441a6705fc0e109856f34/time_machine-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a48f9ff297700a9036d6fc2a597d97ea2a2bbbd735b45a22fee9ebd3b6b7a9f", size = 67650, upload-time = "2026-08-25T00:18:29.54Z" }, ] [[package]] @@ -5202,15 +5196,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/fb/3b1644bc509bbf4390f2df086d3cd38a5e43882f963268b637a193a23ee5/types_dateparser-1.4.2.20260813-py3-none-any.whl", hash = "sha256:2f5b3daabde0bdcabce3b186281f948434a24bdfdf72323b111c928f7c310798", size = 25567, upload-time = "2026-08-13T03:56:31.094Z" }, ] -[[package]] -name = "types-docutils" -version = "0.22.3.20251115" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/d7/576ec24bf61a280f571e1f22284793adc321610b9bcfba1bf468cf7b334f/types_docutils-0.22.3.20251115.tar.gz", hash = "sha256:0f79ea6a7bd4d12d56c9f824a0090ffae0ea4204203eb0006392906850913e16", size = 56828, upload-time = "2025-11-15T02:59:57.371Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/01/61ac9eb38f1f978b47443dc6fd2e0a3b0f647c2da741ddad30771f1b2b6f/types_docutils-0.22.3.20251115-py3-none-any.whl", hash = "sha256:c6e53715b65395d00a75a3a8a74e352c669bc63959e65a207dffaa22f4a2ad6e", size = 91951, upload-time = "2025-11-15T02:59:56.413Z" }, -] - [[package]] name = "types-html5lib" version = "1.1.11.20251117" @@ -5234,14 +5219,11 @@ wheels = [ [[package]] name = "types-pygments" -version = "2.20.0.20260408" +version = "2.21.0.20260819" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "types-docutils" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/89/4b443128fa540c54a8f7ecdeec225aab4818534167c4a2d133099dc00fa6/types_pygments-2.20.0.20260408.tar.gz", hash = "sha256:e8a56a3ab1aee7f4ed8f1876d2f62c96e0f41ede52405a7d30c888f3989d8f00", size = 21115, upload-time = "2026-04-08T04:34:24.29Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/2b/b3e929c39fb056f0e44560acbe53a7c3f28f428af5362d043642d80b31ec/types_pygments-2.21.0.20260819.tar.gz", hash = "sha256:68e0cb27115b08b681c843e30a153b5d9850c048e1158623ada36ccd4cf7e89b", size = 21509, upload-time = "2026-08-19T02:47:15.827Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/d8/30924b38eef70caef6b05af5440c84d7673cea2a042e206f404c8100a88d/types_pygments-2.20.0.20260408-py3-none-any.whl", hash = "sha256:6d347d5967b5f0654b659a8b8461a870b207b7e60cd4d646bbc047f6a8db8e1e", size = 29055, upload-time = "2026-04-08T04:34:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a5/10a2de06b53a36f4334391fcf292b51877cbc71d3f6eac353e7b87a0a17e/types_pygments-2.21.0.20260819-py3-none-any.whl", hash = "sha256:85e216770d616db6a7e96fb70c2de9d9f283b746c37edb7768ce645bdbcaddb2", size = 29099, upload-time = "2026-08-19T02:47:14.81Z" }, ] [[package]] From d65de00ca149814a4d149b989598a316ff630d63 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:27:33 -0700 Subject: [PATCH 09/68] Fix: correct setting ai_enabled to false via UI (#13987) --- src/documents/tests/test_api_app_config.py | 22 ++++++++++++- src/paperless/config.py | 28 ++++++++++++---- ...ter_applicationconfiguration_ai_enabled.py | 28 ++++++++++++++++ src/paperless/models.py | 1 - src/paperless/tests/test_config_precedence.py | 32 +++++++++++++++++++ src/paperless/views.py | 7 +++- 6 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 src/paperless/migrations/0016_alter_applicationconfiguration_ai_enabled.py create mode 100644 src/paperless/tests/test_config_precedence.py diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index c3fb416c2..b98c0d7da 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -76,7 +76,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): "remote_ocr_api_key": None, "remote_ocr_endpoint": None, "remote_ocr_mode": None, - "ai_enabled": False, + "ai_enabled": None, "llm_embedding_backend": None, "llm_embedding_model": None, "llm_embedding_endpoint": None, @@ -949,6 +949,26 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): ) mock_update.assert_called_once() + @override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND=None) + def test_external_ai_setting_triggers_index_update(self) -> None: + config = ApplicationConfiguration.objects.first() + assert config is not None + config.ai_enabled = None + config.llm_embedding_backend = None + config.save() + + with ( + patch("documents.tasks.llmindex_index.apply_async") as mock_update, + patch("paperless.views.llm_index_exists", return_value=False), + ): + self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps({"llm_embedding_backend": "openai-like"}), + content_type="application/json", + ) + + mock_update.assert_called_once() + def test_update_llm_embedding_chunk_size_triggers_rebuild(self) -> None: config = ApplicationConfiguration.objects.first() assert config is not None diff --git a/src/paperless/config.py b/src/paperless/config.py index d99d9e837..aabdfabd0 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -133,21 +133,27 @@ class BarcodeConfig(BaseConfig): app_config = self._get_config_instance() self.barcodes_enabled = ( - app_config.barcodes_enabled or settings.CONSUMER_ENABLE_BARCODES + app_config.barcodes_enabled + if app_config.barcodes_enabled is not None + else settings.CONSUMER_ENABLE_BARCODES ) self.barcode_enable_tiff_support = ( app_config.barcode_enable_tiff_support - or settings.CONSUMER_BARCODE_TIFF_SUPPORT + if app_config.barcode_enable_tiff_support is not None + else settings.CONSUMER_BARCODE_TIFF_SUPPORT ) self.barcode_string = ( app_config.barcode_string or settings.CONSUMER_BARCODE_STRING ) self.barcode_retain_split_pages = ( app_config.barcode_retain_split_pages - or settings.CONSUMER_BARCODE_RETAIN_SPLIT_PAGES + if app_config.barcode_retain_split_pages is not None + else settings.CONSUMER_BARCODE_RETAIN_SPLIT_PAGES ) self.barcode_enable_asn = ( - app_config.barcode_enable_asn or settings.CONSUMER_ENABLE_ASN_BARCODE + app_config.barcode_enable_asn + if app_config.barcode_enable_asn is not None + else settings.CONSUMER_ENABLE_ASN_BARCODE ) self.barcode_asn_prefix = ( app_config.barcode_asn_prefix or settings.CONSUMER_ASN_BARCODE_PREFIX @@ -160,13 +166,17 @@ class BarcodeConfig(BaseConfig): app_config.barcode_max_pages or settings.CONSUMER_BARCODE_MAX_PAGES ) self.barcode_enable_tag = ( - app_config.barcode_enable_tag or settings.CONSUMER_ENABLE_TAG_BARCODE + app_config.barcode_enable_tag + if app_config.barcode_enable_tag is not None + else settings.CONSUMER_ENABLE_TAG_BARCODE ) self.barcode_tag_mapping = ( app_config.barcode_tag_mapping or settings.CONSUMER_TAG_BARCODE_MAPPING ) self.barcode_tag_split = ( - app_config.barcode_tag_split or settings.CONSUMER_TAG_BARCODE_SPLIT + app_config.barcode_tag_split + if app_config.barcode_tag_split is not None + else settings.CONSUMER_TAG_BARCODE_SPLIT ) @@ -248,7 +258,11 @@ class AIConfig(BaseConfig): def __post_init__(self) -> None: app_config = self._get_config_instance() - self.ai_enabled = app_config.ai_enabled or settings.AI_ENABLED + self.ai_enabled = ( + app_config.ai_enabled + if app_config.ai_enabled is not None + else settings.AI_ENABLED + ) self.llm_embedding_backend = ( app_config.llm_embedding_backend or settings.LLM_EMBEDDING_BACKEND ) diff --git a/src/paperless/migrations/0016_alter_applicationconfiguration_ai_enabled.py b/src/paperless/migrations/0016_alter_applicationconfiguration_ai_enabled.py new file mode 100644 index 000000000..1b61f9692 --- /dev/null +++ b/src/paperless/migrations/0016_alter_applicationconfiguration_ai_enabled.py @@ -0,0 +1,28 @@ +from django.db import migrations +from django.db import models + + +def normalize_ai_enabled(apps, schema_editor): + application_configuration = apps.get_model( + "paperless", + "ApplicationConfiguration", + ) + application_configuration.objects.filter(ai_enabled=False).update(ai_enabled=None) + + +class Migration(migrations.Migration): + dependencies = [ + ("paperless", "0015_applicationconfiguration_remote_ocr_mode"), + ] + + operations = [ + migrations.AlterField( + model_name="applicationconfiguration", + name="ai_enabled", + field=models.BooleanField( + null=True, + verbose_name="Enables AI features", + ), + ), + migrations.RunPython(normalize_ai_enabled, migrations.RunPython.noop), + ] diff --git a/src/paperless/models.py b/src/paperless/models.py index 73eaabfee..64fc45fc0 100644 --- a/src/paperless/models.py +++ b/src/paperless/models.py @@ -348,7 +348,6 @@ class ApplicationConfiguration(AbstractSingletonModel): ai_enabled = models.BooleanField( verbose_name=_("Enables AI features"), null=True, - default=False, ) llm_embedding_backend = models.CharField( diff --git a/src/paperless/tests/test_config_precedence.py b/src/paperless/tests/test_config_precedence.py new file mode 100644 index 000000000..2cc9aecf3 --- /dev/null +++ b/src/paperless/tests/test_config_precedence.py @@ -0,0 +1,32 @@ +from django.test import TestCase +from django.test import override_settings + +from paperless.config import AIConfig +from paperless.config import BarcodeConfig +from paperless.models import ApplicationConfiguration + + +class TestBooleanConfigPrecedence(TestCase): + @override_settings(CONSUMER_ENABLE_BARCODES=True) + def test_database_false_overrides_barcode_environment_setting(self) -> None: + config, _ = ApplicationConfiguration.objects.get_or_create() + config.barcodes_enabled = False + config.save() + + self.assertFalse(BarcodeConfig().barcodes_enabled) + + @override_settings(AI_ENABLED=True) + def test_database_false_overrides_ai_environment_setting(self) -> None: + config, _ = ApplicationConfiguration.objects.get_or_create() + config.ai_enabled = False + config.save() + + self.assertFalse(AIConfig().ai_enabled) + + @override_settings(AI_ENABLED=True) + def test_null_ai_setting_uses_environment_setting(self) -> None: + config, _ = ApplicationConfiguration.objects.get_or_create() + config.ai_enabled = None + config.save() + + self.assertTrue(AIConfig().ai_enabled) diff --git a/src/paperless/views.py b/src/paperless/views.py index 2e3fb4d82..ba1e6738d 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -443,8 +443,13 @@ class ApplicationConfigurationViewSet(ModelViewSet[ApplicationConfiguration]): new_llm_embedding_backend = ( new_instance.llm_embedding_backend or settings.LLM_EMBEDDING_BACKEND ) + new_ai_enabled = ( + new_instance.ai_enabled + if new_instance.ai_enabled is not None + else settings.AI_ENABLED + ) new_ai_index_enabled = bool( - new_instance.ai_enabled and new_llm_embedding_backend, + new_ai_enabled and new_llm_embedding_backend, ) new_llm_embedding_chunk_size = ( new_instance.llm_embedding_chunk_size or settings.LLM_EMBEDDING_CHUNK_SIZE From 05905287b3477849bfd5299318de0a89ff8e7a34 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:29:01 +0000 Subject: [PATCH 10/68] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 80b4e9a3b..101cbd114 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-06 22:52+0000\n" +"POT-Creation-Date: 2026-09-07 20:28+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -2202,51 +2202,51 @@ msgstr "" msgid "Enables AI features" msgstr "" -#: paperless/models.py:355 +#: paperless/models.py:354 msgid "Sets the LLM embedding backend" msgstr "" -#: paperless/models.py:363 +#: paperless/models.py:362 msgid "Sets the LLM embedding model" msgstr "" -#: paperless/models.py:370 +#: paperless/models.py:369 msgid "Sets the LLM embedding endpoint, optional" msgstr "" -#: paperless/models.py:377 +#: paperless/models.py:376 msgid "Sets the LLM embedding chunk size" msgstr "" -#: paperless/models.py:383 +#: paperless/models.py:382 msgid "Sets the LLM context size" msgstr "" -#: paperless/models.py:389 +#: paperless/models.py:388 msgid "Sets the LLM backend" msgstr "" -#: paperless/models.py:397 +#: paperless/models.py:396 msgid "Sets the LLM model" msgstr "" -#: paperless/models.py:404 +#: paperless/models.py:403 msgid "Sets the LLM API key" msgstr "" -#: paperless/models.py:411 +#: paperless/models.py:410 msgid "Sets the LLM endpoint, optional" msgstr "" -#: paperless/models.py:418 +#: paperless/models.py:417 msgid "Sets the LLM output language" msgstr "" -#: paperless/models.py:425 +#: paperless/models.py:424 msgid "Sets the LLM timeout in seconds" msgstr "" -#: paperless/models.py:431 +#: paperless/models.py:430 msgid "paperless application settings" msgstr "" From 9a47b20d2a5889fbe589e365eadf34792722121a Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:46:49 -0700 Subject: [PATCH 11/68] Enhancement: duplicates filter (#13994) --- .../filter-editor.component.html | 4 +- .../filter-editor.component.spec.ts | 76 +++++++++++ .../filter-editor/filter-editor.component.ts | 63 ++++++++- src-ui/src/app/data/filter-rule-type.ts | 8 ++ src-ui/src/app/utils/query-params.spec.ts | 23 ++++ src/documents/filters.py | 40 ++++++ ...lter_document_archive_checksum_and_more.py | 86 ++++++++++++ src/documents/models.py | 2 + src/documents/tests/test_api_bulk_edit.py | 38 ++++++ src/documents/tests/test_api_documents.py | 122 ++++++++++++++++++ src/documents/views.py | 1 + 11 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 src/documents/migrations/0026_alter_document_archive_checksum_and_more.py diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html index 492b2a4e4..a5e8897f7 100644 --- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html @@ -10,7 +10,7 @@ } - @if (textFilterTarget === 'asn') { + @if (textFilterTarget === 'asn' || textFilterTarget === 'duplicates') { { expect(component.textFilterTarget).toEqual('mime-type') // TEXT_FILTER_TARGET_MIME_TYPE }) + it('should ingest filter rules for documents with duplicates', () => { + component.filterRules = [ + { + rule_type: FILTER_HAS_DUPLICATES, + value: 'true', + }, + ] + fixture.detectChanges() + + expect(component.textFilterTarget).toEqual('duplicates') + expect(component.textFilterModifier).toEqual('has-duplicates') + expect(component.textFilterInputDisabled).toBeTruthy() + }) + + it('should ingest filter rules for documents without duplicates', () => { + component.filterRules = [ + { + rule_type: FILTER_HAS_DUPLICATES, + value: 'false', + }, + ] + + expect(component.textFilterTarget).toEqual('duplicates') + expect(component.textFilterModifier).toEqual('does-not-have-duplicates') + expect(component.filterRules).toEqual([ + { + rule_type: FILTER_HAS_DUPLICATES, + value: 'false', + }, + ]) + }) + it('should ingest text filter rules for fulltext query', () => { expect(component.textFilter).toEqual(null) component.filterRules = [ @@ -1390,6 +1423,33 @@ describe('FilterEditorComponent', () => { ]) }) + it('should convert duplicate target input to the correct filter rule', () => { + const textFieldTargetDropdown = fixture.debugElement.queryAll( + By.directive(NgbDropdownItem) + )[5] + textFieldTargetDropdown.triggerEventHandler('click') + fixture.detectChanges() + + expect(component.textFilterTarget).toEqual('duplicates') + expect(component.filterRules).toEqual([ + { + rule_type: FILTER_HAS_DUPLICATES, + value: 'true', + }, + ]) + + const textFieldModifierSelect = fixture.debugElement.query(By.css('select')) + textFieldModifierSelect.nativeElement.value = 'does-not-have-duplicates' + textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change')) + fixture.detectChanges() + expect(component.filterRules).toEqual([ + { + rule_type: FILTER_HAS_DUPLICATES, + value: 'false', + }, + ]) + }) + it('should convert user input to correct filter rules on full text query', () => { component.textFilterInput.nativeElement.value = 'foo' component.textFilterInput.nativeElement.dispatchEvent(new Event('input')) @@ -2178,6 +2238,22 @@ describe('FilterEditorComponent', () => { ] expect(component.generateFilterName()).toEqual('Without any tag') + component.filterRules = [ + { + rule_type: FILTER_HAS_DUPLICATES, + value: 'true', + }, + ] + expect(component.generateFilterName()).toEqual('With duplicates') + + component.filterRules = [ + { + rule_type: FILTER_HAS_DUPLICATES, + value: 'false', + }, + ] + expect(component.generateFilterName()).toEqual('Without duplicates') + component.filterRules = [ { rule_type: FILTER_CUSTOM_FIELDS_QUERY, diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts index 16bf3cccb..ed08cee1d 100644 --- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts @@ -65,6 +65,7 @@ import { FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_DOCUMENT_TYPE_ANY, + FILTER_HAS_DUPLICATES, FILTER_HAS_STORAGE_PATH_ANY, FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ANY, @@ -129,12 +130,15 @@ const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query' const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike' const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields' const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type' +const TEXT_FILTER_TARGET_DUPLICATES = 'duplicates' const TEXT_FILTER_MODIFIER_EQUALS = 'equals' const TEXT_FILTER_MODIFIER_NULL = 'is null' const TEXT_FILTER_MODIFIER_NOTNULL = 'not null' const TEXT_FILTER_MODIFIER_GT = 'greater' const TEXT_FILTER_MODIFIER_LT = 'less' +const TEXT_FILTER_MODIFIER_HAS_DUPLICATES = 'has-duplicates' +const TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES = 'does-not-have-duplicates' const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g @@ -205,6 +209,7 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [ id: TEXT_FILTER_TARGET_FULLTEXT_QUERY, name: $localize`Advanced search`, }, + { id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` }, ] const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = { @@ -241,6 +246,17 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [ }, ] +const DUPLICATES_FILTER_MODIFIER_OPTIONS = [ + { + id: TEXT_FILTER_MODIFIER_HAS_DUPLICATES, + label: $localize`exist`, + }, + { + id: TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES, + label: $localize`do not exist`, + }, +] + @Component({ selector: 'pngx-filter-editor', templateUrl: './filter-editor.component.html', @@ -320,6 +336,12 @@ export class FilterEditorComponent if (rule.value == 'false') { return $localize`Without any tag` } + break + + case FILTER_HAS_DUPLICATES: + return rule.value == 'false' + ? $localize`Without duplicates` + : $localize`With duplicates` case FILTER_CUSTOM_FIELDS_QUERY: return $localize`Custom fields query` @@ -390,7 +412,9 @@ export class FilterEditorComponent public textFilterModifier: string get textFilterModifiers() { - return DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS + return this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES + ? DUPLICATES_FILTER_MODIFIER_OPTIONS + : DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS } get textFilterModifierIsNull(): boolean { @@ -399,6 +423,13 @@ export class FilterEditorComponent ) } + get textFilterInputDisabled(): boolean { + return ( + this.textFilterModifierIsNull || + this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES + ) + } + tagSelectionModel = new FilterableDropdownSelectionModel(true) correspondentSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel() @@ -444,6 +475,7 @@ export class FilterEditorComponent this.customFieldQueriesModel.clear(false) this._textFilter = null this._moreLikeId = null + this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT this.dateAddedTo = null this.dateAddedFrom = null this.dateCreatedTo = null @@ -477,6 +509,13 @@ export class FilterEditorComponent this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE this._textFilter = rule.value break + case FILTER_HAS_DUPLICATES: + this.textFilterTarget = TEXT_FILTER_TARGET_DUPLICATES + this.textFilterModifier = + rule.value == 'false' || rule.value == '0' + ? TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES + : TEXT_FILTER_MODIFIER_HAS_DUPLICATES + break case FILTER_FULLTEXT_QUERY: let allQueryArgs = rule.value.split(',') let textQueryArgs = [] @@ -800,6 +839,14 @@ export class FilterEditorComponent value: this._textFilter.trim(), }) } + if (this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES) { + filterRules.push({ + rule_type: FILTER_HAS_DUPLICATES, + value: ( + this.textFilterModifier == TEXT_FILTER_MODIFIER_HAS_DUPLICATES + ).toString(), + }) + } if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) { filterRules.push({ rule_type: FILTER_SIMPLE_TITLE, @@ -1163,7 +1210,7 @@ export class FilterEditorComponent } get textFilter() { - return this.textFilterModifierIsNull ? '' : this._textFilter + return this.textFilterInputDisabled ? '' : this._textFilter } set textFilter(value) { @@ -1363,12 +1410,24 @@ export class FilterEditorComponent this._textFilter = '' } this.textFilterTarget = target + if (target == TEXT_FILTER_TARGET_DUPLICATES) { + this._textFilter = '' + this.textFilterModifier = TEXT_FILTER_MODIFIER_HAS_DUPLICATES + } else if ( + [ + TEXT_FILTER_MODIFIER_HAS_DUPLICATES, + TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES, + ].includes(this.textFilterModifier) + ) { + this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS + } this.textFilterInput.nativeElement.focus() this.updateRules() } textFilterModifierChange() { if ( + this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES || this.textFilterModifierIsNull || ([ TEXT_FILTER_MODIFIER_EQUALS, diff --git a/src-ui/src/app/data/filter-rule-type.ts b/src-ui/src/app/data/filter-rule-type.ts index 6330eb44c..5feaf3867 100644 --- a/src-ui/src/app/data/filter-rule-type.ts +++ b/src-ui/src/app/data/filter-rule-type.ts @@ -49,6 +49,7 @@ export const FILTER_MODIFIED_AFTER = 16 export const FILTER_TITLE_CONTENT = 19 // Deprecated in favor of Tantivy-backed `text` filtervar. Keep for now for existing saved views export const FILTER_SIMPLE_TITLE = 48 export const FILTER_SIMPLE_TEXT = 49 +export const FILTER_HAS_DUPLICATES = 50 export const FILTER_FULLTEXT_QUERY = 20 export const FILTER_FULLTEXT_MORELIKE = 21 @@ -382,6 +383,13 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [ datatype: 'string', multi: false, }, + { + id: FILTER_HAS_DUPLICATES, + filtervar: 'has_duplicates', + datatype: 'boolean', + multi: false, + default: true, + }, ] export interface FilterRuleType { diff --git a/src-ui/src/app/utils/query-params.spec.ts b/src-ui/src/app/utils/query-params.spec.ts index 7fd8f6808..7137ed53b 100644 --- a/src-ui/src/app/utils/query-params.spec.ts +++ b/src-ui/src/app/utils/query-params.spec.ts @@ -7,6 +7,7 @@ import { FILTER_HAS_ANY_TAG, FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ANY, + FILTER_HAS_DUPLICATES, FILTER_HAS_TAGS_ALL, FILTER_SIMPLE_TEXT, FILTER_SIMPLE_TITLE, @@ -132,6 +133,16 @@ describe('QueryParams Utils', () => { is_tagged: 0, }) + params = queryParamsFromFilterRules([ + { + rule_type: FILTER_HAS_DUPLICATES, + value: 'false', + }, + ]) + expect(params).toEqual({ + has_duplicates: 0, + }) + params = queryParamsFromFilterRules([ { rule_type: FILTER_TITLE_CONTENT, @@ -247,6 +258,18 @@ describe('QueryParams Utils', () => { }, ]) + rules = filterRulesFromQueryParams( + convertToParamMap({ + has_duplicates: 'true', + }) + ) + expect(rules).toEqual([ + { + rule_type: FILTER_HAS_DUPLICATES, + value: 'true', + }, + ]) + rules = filterRulesFromQueryParams( convertToParamMap({ correspondent__isnull: '1', diff --git a/src/documents/filters.py b/src/documents/filters.py index 40ae8978b..4c2aa1ecf 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -25,6 +25,7 @@ from django.db.models import Sum from django.db.models import Value from django.db.models import When from django.db.models.functions import Cast +from django.db.models.functions import NullIf from django.utils.translation import gettext_lazy as _ from django_filters import DateFilter from django_filters.rest_framework import BooleanFilter @@ -50,6 +51,7 @@ from documents.models import ShareLink from documents.models import ShareLinkBundle from documents.models import StoragePath from documents.models import Tag +from documents.permissions import permitted_document_ids from documents.permissions import permitted_object_ids if TYPE_CHECKING: @@ -793,6 +795,12 @@ class CustomFieldQueryFilter(Filter): class DocumentFilterSet(FilterSet): + has_duplicates = BooleanFilter(method="filter_has_duplicates") + + def __init__(self, *args: Any, user: Any = None, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._user = user + is_tagged = BooleanFilter( label="Is tagged", field_name="tags", @@ -852,6 +860,38 @@ class DocumentFilterSet(FilterSet): mime_type = MimeTypeFilter() + def filter_has_duplicates(self, queryset, name, value): + if value is None: + return queryset + + user = ( + self._user + if self._user is not None + else getattr(self.request, "user", None) + ) + queryset = queryset.alias( + nonempty_archive_checksum=NullIf("archive_checksum", Value("")), + ) + + visible_root_documents = Document.global_objects.filter( + root_document__isnull=True, + pk__in=permitted_document_ids( + user, + include_deleted=True, + ), + ).exclude(pk=OuterRef("pk")) + # see serialisers._get_viewable_duplicates(). + matching_duplicates = visible_root_documents.filter( + Q(checksum=OuterRef("checksum")) + | Q(checksum=OuterRef("nonempty_archive_checksum")) + | Q(archive_checksum=OuterRef("checksum")) + | Q(archive_checksum=OuterRef("nonempty_archive_checksum")), + ) + + return queryset.alias( + has_visible_duplicates=Exists(matching_duplicates), + ).filter(has_visible_duplicates=value) + # Backwards compatibility created__date__gt = DateFilter(field_name="created", lookup_expr="gt") created__date__gte = DateFilter(field_name="created", lookup_expr="gte") diff --git a/src/documents/migrations/0026_alter_document_archive_checksum_and_more.py b/src/documents/migrations/0026_alter_document_archive_checksum_and_more.py new file mode 100644 index 000000000..ea08e437c --- /dev/null +++ b/src/documents/migrations/0026_alter_document_archive_checksum_and_more.py @@ -0,0 +1,86 @@ +# Generated by Django 5.2.16 on 2026-09-05 16:29 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0025_workflowaction_apply_ai_suggestions"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="archive_checksum", + field=models.CharField( + blank=True, + db_index=True, + editable=False, + help_text="The checksum of the archived document.", + max_length=64, + null=True, + verbose_name="archive checksum", + ), + ), + migrations.AlterField( + model_name="savedviewfilterrule", + name="rule_type", + field=models.PositiveSmallIntegerField( + choices=[ + (0, "title contains"), + (1, "content contains"), + (2, "ASN is"), + (3, "correspondent is"), + (4, "document type is"), + (5, "is in inbox"), + (6, "has tag"), + (7, "has any tag"), + (8, "created before"), + (9, "created after"), + (10, "created year is"), + (11, "created month is"), + (12, "created day is"), + (13, "added before"), + (14, "added after"), + (15, "modified before"), + (16, "modified after"), + (17, "does not have tag"), + (18, "does not have ASN"), + (19, "title or content contains"), + (20, "fulltext query"), + (21, "more like this"), + (22, "has tags in"), + (23, "ASN greater than"), + (24, "ASN less than"), + (25, "storage path is"), + (26, "has correspondent in"), + (27, "does not have correspondent in"), + (28, "has document type in"), + (29, "does not have document type in"), + (30, "has storage path in"), + (31, "does not have storage path in"), + (32, "owner is"), + (33, "has owner in"), + (34, "does not have owner"), + (35, "does not have owner in"), + (36, "has custom field value"), + (37, "is shared by me"), + (38, "has custom fields"), + (39, "has custom field in"), + (40, "does not have custom field in"), + (41, "does not have custom field"), + (42, "custom fields query"), + (43, "created to"), + (44, "created from"), + (45, "added to"), + (46, "added from"), + (47, "mime type is"), + (48, "simple title search"), + (49, "simple text search"), + (50, "has duplicates"), + ], + verbose_name="rule type", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 8dc3090f2..0cc4decda 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -227,6 +227,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager- editable=False, blank=True, null=True, + db_index=True, help_text=_("The checksum of the archived document."), ) @@ -706,6 +707,7 @@ class SavedViewFilterRule(models.Model): (47, _("mime type is")), (48, _("simple title search")), (49, _("simple text search")), + (50, _("has duplicates")), ] saved_view = models.ForeignKey( diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index cf447501c..2f34b2425 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -717,6 +717,44 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(args[0], [self.doc2.id]) self.assertEqual(kwargs["storage_path"], self.sp1.id) + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") + def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None: + self.setup_mock(m, "set_storage_path") + user = User.objects.create_user(username="duplicate-owner") + user.user_permissions.add( + Permission.objects.get(codename="change_document"), + ) + first_duplicate = Document.objects.create( + checksum="owned-duplicate", + title="First duplicate", + owner=user, + ) + second_duplicate = Document.objects.create( + checksum="owned-duplicate", + title="Second duplicate", + owner=user, + ) + self.client.force_authenticate(user=user) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "all": True, + "filters": {"has_duplicates": True}, + "method": "set_storage_path", + "parameters": {"storage_path": self.sp1.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + args, kwargs = m.call_args + self.assertCountEqual(args[0], [first_duplicate.id, second_duplicate.id]) + self.assertEqual(kwargs["storage_path"], self.sp1.id) + @mock.patch("documents.search.get_backend") @mock.patch("documents.serialisers.bulk_edit.set_storage_path") def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters( diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index b67b87772..2aceb3fc9 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -981,6 +981,128 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase): self.assertEqual(len(results), 1) self.assertEqual(results[0]["id"], doc.id) + def test_has_duplicates_filter(self) -> None: + original_match = Document.objects.create( + title="original match", + checksum="same-original", + ) + second_original_match = Document.objects.create( + title="second original match", + checksum="same-original", + ) + archive_match = Document.objects.create( + title="archive match", + checksum="archive-source", + archive_checksum="same-archive", + ) + original_to_archive_match = Document.objects.create( + title="original to archive match", + checksum="same-archive", + ) + first_archive_match = Document.objects.create( + title="first archive match", + checksum="first-archive-source", + archive_checksum="same-archive-only", + ) + second_archive_match = Document.objects.create( + title="second archive match", + checksum="second-archive-source", + archive_checksum="same-archive-only", + ) + first_empty_archive = Document.objects.create( + title="first empty archive", + checksum="first-empty-archive", + archive_checksum="", + ) + second_empty_archive = Document.objects.create( + title="second empty archive", + checksum="second-empty-archive", + archive_checksum="", + ) + unique = Document.objects.create(title="unique", checksum="unique") + version_root = Document.objects.create( + title="version root", + checksum="version-root", + ) + Document.objects.create( + title="version", + checksum=unique.checksum, + root_document=version_root, + version_index=1, + ) + trash_match = Document.objects.create( + title="trash match", + checksum="trash-match", + ) + trashed_duplicate = Document.objects.create( + title="trashed duplicate", + checksum="trash-match", + ) + trashed_duplicate.delete() + + response = self.client.get("/api/documents/?has_duplicates=true") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertCountEqual( + [document["id"] for document in response.data["results"]], + [ + original_match.id, + second_original_match.id, + archive_match.id, + original_to_archive_match.id, + first_archive_match.id, + second_archive_match.id, + trash_match.id, + ], + ) + + response = self.client.get("/api/documents/?has_duplicates=false") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertCountEqual( + [document["id"] for document in response.data["results"]], + [ + unique.id, + version_root.id, + first_empty_archive.id, + second_empty_archive.id, + ], + ) + + response = self.client.get(f"/api/documents/{first_empty_archive.id}/") + self.assertEqual(response.data["duplicate_documents"], []) + + def test_has_duplicates_filter_respects_document_permissions(self) -> None: + owner = User.objects.create_user(username="duplicate-owner") + requester = User.objects.create_user(username="duplicate-requester") + requester.user_permissions.add( + Permission.objects.get(codename="view_document"), + ) + visible_document = Document.objects.create( + title="visible document", + checksum="permission-match", + owner=requester, + ) + hidden_duplicate = Document.objects.create( + title="hidden duplicate", + checksum="permission-match", + owner=owner, + ) + self.client.force_authenticate(user=requester) + + response = self.client.get("/api/documents/?has_duplicates=true") + self.assertNotIn( + visible_document.id, + [document["id"] for document in response.data["results"]], + ) + + assign_perm("view_document", requester, hidden_duplicate) + response = self.client.get("/api/documents/?has_duplicates=true") + self.assertIn( + visible_document.id, + [document["id"] for document in response.data["results"]], + ) + def test_custom_fields_icontains_filter_no_duplicates(self) -> None: """ GIVEN: diff --git a/src/documents/views.py b/src/documents/views.py index fadd160c4..0d256115a 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -2816,6 +2816,7 @@ class DocumentSelectionMixin: filtered_documents = DocumentFilterSet( data=orm_filters, queryset=permitted_documents, + user=user, ).qs.distinct() # tantivy-filtered docs (if search params provided) search_filtered_ids = self._get_search_document_ids( From 3f5f4f3ed45fe6fb8d1cc61596049a6d3d9202c7 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:48:12 +0000 Subject: [PATCH 12/68] Auto translate strings --- src-ui/messages.xlf | 89 ++- src/locale/en_US/LC_MESSAGES/django.po | 770 +++++++++++++------------ 2 files changed, 449 insertions(+), 410 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index f623a700c..ce76005d4 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -1080,7 +1080,7 @@ src/app/components/document-list/filter-editor/filter-editor.component.ts - 206 + 210 @@ -6190,7 +6190,7 @@ src/app/components/document-list/filter-editor/filter-editor.component.ts - 197 + 201 src/app/data/document.ts @@ -9794,7 +9794,7 @@ src/app/components/document-list/filter-editor/filter-editor.component.ts - 202 + 206 src/app/data/document.ts @@ -9997,63 +9997,84 @@ Title & content src/app/components/document-list/filter-editor/filter-editor.component.ts - 200 + 204 File type src/app/components/document-list/filter-editor/filter-editor.component.ts - 203 + 207 + + + + Duplicates + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 212 Custom fields (Deprecated) src/app/components/document-list/filter-editor/filter-editor.component.ts - 213 + 218 More like src/app/components/document-list/filter-editor/filter-editor.component.ts - 218 + 223 equals src/app/components/document-list/filter-editor/filter-editor.component.ts - 224 + 229 is empty src/app/components/document-list/filter-editor/filter-editor.component.ts - 228 + 233 is not empty src/app/components/document-list/filter-editor/filter-editor.component.ts - 232 + 237 greater than src/app/components/document-list/filter-editor/filter-editor.component.ts - 236 + 241 less than src/app/components/document-list/filter-editor/filter-editor.component.ts - 240 + 245 + + + + exist + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 252 + + + + do not exist + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 256 @@ -10062,14 +10083,14 @@ )?.name"/> src/app/components/document-list/filter-editor/filter-editor.component.ts - 281,285 + 297,301 Without correspondent src/app/components/document-list/filter-editor/filter-editor.component.ts - 287 + 303 @@ -10078,14 +10099,14 @@ )?.name"/> src/app/components/document-list/filter-editor/filter-editor.component.ts - 293,297 + 309,313 Without document type src/app/components/document-list/filter-editor/filter-editor.component.ts - 299 + 315 @@ -10094,77 +10115,91 @@ )?.name"/> src/app/components/document-list/filter-editor/filter-editor.component.ts - 305,309 + 321,325 Without storage path src/app/components/document-list/filter-editor/filter-editor.component.ts - 311 + 327 Tag: src/app/components/document-list/filter-editor/filter-editor.component.ts - 315,317 + 331,333 Without any tag src/app/components/document-list/filter-editor/filter-editor.component.ts - 321 + 337 + + + + Without duplicates + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 343 + + + + With duplicates + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 344 Custom fields query src/app/components/document-list/filter-editor/filter-editor.component.ts - 325 + 347 Title: src/app/components/document-list/filter-editor/filter-editor.component.ts - 329 + 351 Title & content: src/app/components/document-list/filter-editor/filter-editor.component.ts - 333 + 355 ASN: src/app/components/document-list/filter-editor/filter-editor.component.ts - 336 + 358 Owner: src/app/components/document-list/filter-editor/filter-editor.component.ts - 339 + 361 Owner not in: src/app/components/document-list/filter-editor/filter-editor.component.ts - 342 + 364 Without an owner src/app/components/document-list/filter-editor/filter-editor.component.ts - 345 + 367 diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 101cbd114..55cd331a1 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-07 20:28+0000\n" +"POT-Creation-Date: 2026-09-07 20:47+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -21,67 +21,67 @@ msgstr "" msgid "Documents" msgstr "" -#: documents/filters.py:471 +#: documents/filters.py:473 msgid "Value must be valid JSON." msgstr "" -#: documents/filters.py:490 +#: documents/filters.py:492 msgid "Invalid custom field query expression" msgstr "" -#: documents/filters.py:500 +#: documents/filters.py:502 msgid "Invalid expression list. Must be nonempty." msgstr "" -#: documents/filters.py:521 +#: documents/filters.py:523 msgid "Invalid logical operator {op!r}" msgstr "" -#: documents/filters.py:535 +#: documents/filters.py:537 msgid "Maximum number of query conditions exceeded." msgstr "" -#: documents/filters.py:599 +#: documents/filters.py:601 msgid "{name!r} is not a valid custom field." msgstr "" -#: documents/filters.py:636 +#: documents/filters.py:638 msgid "{data_type} does not support query expr {expr!r}." msgstr "" -#: documents/filters.py:755 documents/models.py:136 +#: documents/filters.py:757 documents/models.py:136 msgid "Maximum nesting depth exceeded." msgstr "" -#: documents/filters.py:1079 +#: documents/filters.py:1119 msgid "Custom field not found" msgstr "" -#: documents/models.py:39 documents/models.py:996 documents/models.py:1044 +#: documents/models.py:39 documents/models.py:998 documents/models.py:1046 msgid "owner" msgstr "" -#: documents/models.py:56 documents/models.py:1342 +#: documents/models.py:56 documents/models.py:1344 msgid "None" msgstr "" -#: documents/models.py:57 documents/models.py:1343 +#: documents/models.py:57 documents/models.py:1345 msgid "Any word" msgstr "" -#: documents/models.py:58 documents/models.py:1344 +#: documents/models.py:58 documents/models.py:1346 msgid "All words" msgstr "" -#: documents/models.py:59 documents/models.py:1345 +#: documents/models.py:59 documents/models.py:1347 msgid "Exact match" msgstr "" -#: documents/models.py:60 documents/models.py:1346 +#: documents/models.py:60 documents/models.py:1348 msgid "Regular expression" msgstr "" -#: documents/models.py:61 documents/models.py:1347 +#: documents/models.py:61 documents/models.py:1349 msgid "Fuzzy word" msgstr "" @@ -89,20 +89,20 @@ msgstr "" msgid "Automatic" msgstr "" -#: documents/models.py:65 documents/models.py:610 documents/models.py:1968 +#: documents/models.py:65 documents/models.py:611 documents/models.py:1970 #: paperless_mail/models.py:23 paperless_mail/models.py:143 msgid "name" msgstr "" -#: documents/models.py:67 documents/models.py:1411 +#: documents/models.py:67 documents/models.py:1413 msgid "match" msgstr "" -#: documents/models.py:70 documents/models.py:1414 +#: documents/models.py:70 documents/models.py:1416 msgid "matching algorithm" msgstr "" -#: documents/models.py:75 documents/models.py:1419 +#: documents/models.py:75 documents/models.py:1421 msgid "is insensitive" msgstr "" @@ -168,7 +168,7 @@ msgstr "" msgid "title" msgstr "" -#: documents/models.py:190 documents/models.py:910 +#: documents/models.py:190 documents/models.py:912 msgid "content" msgstr "" @@ -194,1436 +194,1440 @@ msgstr "" msgid "archive checksum" msgstr "" -#: documents/models.py:230 +#: documents/models.py:231 msgid "The checksum of the archived document." msgstr "" -#: documents/models.py:234 +#: documents/models.py:235 msgid "page count" msgstr "" -#: documents/models.py:241 +#: documents/models.py:242 msgid "The number of pages of the document." msgstr "" -#: documents/models.py:246 documents/models.py:916 documents/models.py:954 -#: documents/models.py:1016 documents/models.py:1144 documents/models.py:1203 +#: documents/models.py:247 documents/models.py:918 documents/models.py:956 +#: documents/models.py:1018 documents/models.py:1146 documents/models.py:1205 msgid "created" msgstr "" -#: documents/models.py:252 +#: documents/models.py:253 msgid "modified" msgstr "" -#: documents/models.py:259 +#: documents/models.py:260 msgid "added" msgstr "" -#: documents/models.py:266 +#: documents/models.py:267 msgid "filename" msgstr "" -#: documents/models.py:272 +#: documents/models.py:273 msgid "Current filename in storage" msgstr "" -#: documents/models.py:276 +#: documents/models.py:277 msgid "archive filename" msgstr "" -#: documents/models.py:282 +#: documents/models.py:283 msgid "Current archive filename in storage" msgstr "" -#: documents/models.py:286 +#: documents/models.py:287 msgid "original filename" msgstr "" -#: documents/models.py:292 +#: documents/models.py:293 msgid "The original name of the file when it was uploaded" msgstr "" -#: documents/models.py:299 +#: documents/models.py:300 msgid "archive serial number" msgstr "" -#: documents/models.py:309 +#: documents/models.py:310 msgid "The position of this document in your physical document archive." msgstr "" -#: documents/models.py:319 +#: documents/models.py:320 msgid "root document for this version" msgstr "" -#: documents/models.py:323 +#: documents/models.py:324 msgid "version index" msgstr "" -#: documents/models.py:327 +#: documents/models.py:328 msgid "Index of this version within the root document." msgstr "" -#: documents/models.py:331 +#: documents/models.py:332 msgid "version label" msgstr "" -#: documents/models.py:335 +#: documents/models.py:336 msgid "Optional short label for a document version." msgstr "" -#: documents/models.py:340 documents/models.py:927 documents/models.py:981 -#: documents/models.py:2011 +#: documents/models.py:341 documents/models.py:929 documents/models.py:983 +#: documents/models.py:2013 msgid "document" msgstr "" -#: documents/models.py:341 documents/models.py:1087 +#: documents/models.py:342 documents/models.py:1089 msgid "documents" msgstr "" -#: documents/models.py:529 documents/models.py:950 +#: documents/models.py:530 documents/models.py:952 msgid "Archive" msgstr "" -#: documents/models.py:530 +#: documents/models.py:531 msgid "Bank" msgstr "" -#: documents/models.py:531 +#: documents/models.py:532 msgid "Basket" msgstr "" -#: documents/models.py:532 +#: documents/models.py:533 msgid "Bell" msgstr "" -#: documents/models.py:533 +#: documents/models.py:534 msgid "Bookmark" msgstr "" -#: documents/models.py:534 +#: documents/models.py:535 msgid "Boxes" msgstr "" -#: documents/models.py:535 +#: documents/models.py:536 msgid "Briefcase" msgstr "" -#: documents/models.py:536 +#: documents/models.py:537 msgid "Building" msgstr "" -#: documents/models.py:537 +#: documents/models.py:538 msgid "Calculator" msgstr "" -#: documents/models.py:538 +#: documents/models.py:539 msgid "Calendar" msgstr "" -#: documents/models.py:539 +#: documents/models.py:540 msgid "Camera" msgstr "" -#: documents/models.py:540 +#: documents/models.py:541 msgid "Checklist" msgstr "" -#: documents/models.py:541 +#: documents/models.py:542 msgid "Cash" msgstr "" -#: documents/models.py:542 +#: documents/models.py:543 msgid "Chat" msgstr "" -#: documents/models.py:543 +#: documents/models.py:544 msgid "Check" msgstr "" -#: documents/models.py:544 +#: documents/models.py:545 msgid "Clipboard" msgstr "" -#: documents/models.py:545 +#: documents/models.py:546 msgid "Clock" msgstr "" -#: documents/models.py:546 +#: documents/models.py:547 msgid "Credit card" msgstr "" -#: documents/models.py:547 +#: documents/models.py:548 msgid "Download" msgstr "" -#: documents/models.py:548 +#: documents/models.py:549 msgid "Envelope" msgstr "" -#: documents/models.py:549 +#: documents/models.py:550 msgid "Warning" msgstr "" -#: documents/models.py:550 +#: documents/models.py:551 msgid "File" msgstr "" -#: documents/models.py:551 +#: documents/models.py:552 msgid "Checked file" msgstr "" -#: documents/models.py:552 +#: documents/models.py:553 msgid "Locked file" msgstr "" -#: documents/models.py:553 +#: documents/models.py:554 msgid "Medical file" msgstr "" -#: documents/models.py:554 +#: documents/models.py:555 msgid "Person file" msgstr "" -#: documents/models.py:557 +#: documents/models.py:558 msgid "Spreadsheet" msgstr "" -#: documents/models.py:559 +#: documents/models.py:560 msgid "Text file" msgstr "" -#: documents/models.py:560 +#: documents/models.py:561 msgid "Files" msgstr "" -#: documents/models.py:561 +#: documents/models.py:562 msgid "Folder" msgstr "" -#: documents/models.py:562 paperless_mail/admin.py:58 +#: documents/models.py:563 paperless_mail/admin.py:58 msgid "Filter" msgstr "" -#: documents/models.py:563 +#: documents/models.py:564 msgid "Gear" msgstr "" -#: documents/models.py:564 +#: documents/models.py:565 msgid "Globe" msgstr "" -#: documents/models.py:565 +#: documents/models.py:566 msgid "Hash" msgstr "" -#: documents/models.py:566 +#: documents/models.py:567 msgid "Heart" msgstr "" -#: documents/models.py:567 +#: documents/models.py:568 msgid "House" msgstr "" -#: documents/models.py:568 +#: documents/models.py:569 msgid "Inbox" msgstr "" -#: documents/models.py:569 +#: documents/models.py:570 msgid "Journals" msgstr "" -#: documents/models.py:570 +#: documents/models.py:571 msgid "Task list" msgstr "" -#: documents/models.py:571 +#: documents/models.py:572 msgid "Newspaper" msgstr "" -#: documents/models.py:572 +#: documents/models.py:573 msgid "Attachment" msgstr "" -#: documents/models.py:573 +#: documents/models.py:574 msgid "People" msgstr "" -#: documents/models.py:574 +#: documents/models.py:575 msgid "Person" msgstr "" -#: documents/models.py:575 +#: documents/models.py:576 msgid "Printer" msgstr "" -#: documents/models.py:576 +#: documents/models.py:577 msgid "Receipt" msgstr "" -#: documents/models.py:577 +#: documents/models.py:578 msgid "Safe" msgstr "" -#: documents/models.py:578 +#: documents/models.py:579 msgid "Search" msgstr "" -#: documents/models.py:579 +#: documents/models.py:580 msgid "Send" msgstr "" -#: documents/models.py:580 +#: documents/models.py:581 msgid "Shop" msgstr "" -#: documents/models.py:581 +#: documents/models.py:582 msgid "Stack" msgstr "" -#: documents/models.py:582 +#: documents/models.py:583 msgid "Stars" msgstr "" -#: documents/models.py:583 +#: documents/models.py:584 msgid "Tag" msgstr "" -#: documents/models.py:584 documents/models.py:599 documents/models.py:1689 +#: documents/models.py:585 documents/models.py:600 documents/models.py:1691 msgid "Tags" msgstr "" -#: documents/models.py:585 +#: documents/models.py:586 msgid "Telephone" msgstr "" -#: documents/models.py:586 +#: documents/models.py:587 msgid "Truck" msgstr "" -#: documents/models.py:587 +#: documents/models.py:588 msgid "Barcode" msgstr "" -#: documents/models.py:588 +#: documents/models.py:589 msgid "Wallet" msgstr "" -#: documents/models.py:591 +#: documents/models.py:592 msgid "Table" msgstr "" -#: documents/models.py:592 +#: documents/models.py:593 msgid "Small Cards" msgstr "" -#: documents/models.py:593 +#: documents/models.py:594 msgid "Large Cards" msgstr "" -#: documents/models.py:596 documents/models.py:1688 +#: documents/models.py:597 documents/models.py:1690 msgid "Title" msgstr "" -#: documents/models.py:597 documents/models.py:826 documents/models.py:1363 +#: documents/models.py:598 documents/models.py:828 documents/models.py:1365 msgid "Created" msgstr "" -#: documents/models.py:598 documents/models.py:1362 +#: documents/models.py:599 documents/models.py:1364 msgid "Added" msgstr "" -#: documents/models.py:600 documents/models.py:1690 +#: documents/models.py:601 documents/models.py:1692 msgid "Correspondent" msgstr "" -#: documents/models.py:601 +#: documents/models.py:602 msgid "Document Type" msgstr "" -#: documents/models.py:602 +#: documents/models.py:603 msgid "Storage Path" msgstr "" -#: documents/models.py:603 +#: documents/models.py:604 msgid "Note" msgstr "" -#: documents/models.py:604 +#: documents/models.py:605 msgid "Owner" msgstr "" -#: documents/models.py:605 +#: documents/models.py:606 msgid "Shared" msgstr "" -#: documents/models.py:606 +#: documents/models.py:607 msgid "ASN" msgstr "" -#: documents/models.py:607 +#: documents/models.py:608 msgid "Pages" msgstr "" -#: documents/models.py:613 +#: documents/models.py:614 msgid "icon" msgstr "" -#: documents/models.py:620 +#: documents/models.py:621 msgid "sort field" msgstr "" -#: documents/models.py:625 +#: documents/models.py:626 msgid "sort reverse" msgstr "" -#: documents/models.py:628 +#: documents/models.py:629 msgid "View page size" msgstr "" -#: documents/models.py:636 +#: documents/models.py:637 msgid "View display mode" msgstr "" -#: documents/models.py:643 +#: documents/models.py:644 msgid "Document display fields" msgstr "" -#: documents/models.py:650 documents/models.py:715 +#: documents/models.py:651 documents/models.py:717 msgid "saved view" msgstr "" -#: documents/models.py:651 +#: documents/models.py:652 msgid "saved views" msgstr "" -#: documents/models.py:659 +#: documents/models.py:660 msgid "title contains" msgstr "" -#: documents/models.py:660 +#: documents/models.py:661 msgid "content contains" msgstr "" -#: documents/models.py:661 +#: documents/models.py:662 msgid "ASN is" msgstr "" -#: documents/models.py:662 +#: documents/models.py:663 msgid "correspondent is" msgstr "" -#: documents/models.py:663 +#: documents/models.py:664 msgid "document type is" msgstr "" -#: documents/models.py:664 +#: documents/models.py:665 msgid "is in inbox" msgstr "" -#: documents/models.py:665 +#: documents/models.py:666 msgid "has tag" msgstr "" -#: documents/models.py:666 +#: documents/models.py:667 msgid "has any tag" msgstr "" -#: documents/models.py:667 +#: documents/models.py:668 msgid "created before" msgstr "" -#: documents/models.py:668 +#: documents/models.py:669 msgid "created after" msgstr "" -#: documents/models.py:669 +#: documents/models.py:670 msgid "created year is" msgstr "" -#: documents/models.py:670 +#: documents/models.py:671 msgid "created month is" msgstr "" -#: documents/models.py:671 +#: documents/models.py:672 msgid "created day is" msgstr "" -#: documents/models.py:672 +#: documents/models.py:673 msgid "added before" msgstr "" -#: documents/models.py:673 +#: documents/models.py:674 msgid "added after" msgstr "" -#: documents/models.py:674 +#: documents/models.py:675 msgid "modified before" msgstr "" -#: documents/models.py:675 +#: documents/models.py:676 msgid "modified after" msgstr "" -#: documents/models.py:676 +#: documents/models.py:677 msgid "does not have tag" msgstr "" -#: documents/models.py:677 +#: documents/models.py:678 msgid "does not have ASN" msgstr "" -#: documents/models.py:678 +#: documents/models.py:679 msgid "title or content contains" msgstr "" -#: documents/models.py:679 +#: documents/models.py:680 msgid "fulltext query" msgstr "" -#: documents/models.py:680 +#: documents/models.py:681 msgid "more like this" msgstr "" -#: documents/models.py:681 +#: documents/models.py:682 msgid "has tags in" msgstr "" -#: documents/models.py:682 +#: documents/models.py:683 msgid "ASN greater than" msgstr "" -#: documents/models.py:683 +#: documents/models.py:684 msgid "ASN less than" msgstr "" -#: documents/models.py:684 +#: documents/models.py:685 msgid "storage path is" msgstr "" -#: documents/models.py:685 +#: documents/models.py:686 msgid "has correspondent in" msgstr "" -#: documents/models.py:686 +#: documents/models.py:687 msgid "does not have correspondent in" msgstr "" -#: documents/models.py:687 +#: documents/models.py:688 msgid "has document type in" msgstr "" -#: documents/models.py:688 +#: documents/models.py:689 msgid "does not have document type in" msgstr "" -#: documents/models.py:689 +#: documents/models.py:690 msgid "has storage path in" msgstr "" -#: documents/models.py:690 +#: documents/models.py:691 msgid "does not have storage path in" msgstr "" -#: documents/models.py:691 +#: documents/models.py:692 msgid "owner is" msgstr "" -#: documents/models.py:692 +#: documents/models.py:693 msgid "has owner in" msgstr "" -#: documents/models.py:693 +#: documents/models.py:694 msgid "does not have owner" msgstr "" -#: documents/models.py:694 +#: documents/models.py:695 msgid "does not have owner in" msgstr "" -#: documents/models.py:695 +#: documents/models.py:696 msgid "has custom field value" msgstr "" -#: documents/models.py:696 +#: documents/models.py:697 msgid "is shared by me" msgstr "" -#: documents/models.py:697 +#: documents/models.py:698 msgid "has custom fields" msgstr "" -#: documents/models.py:698 +#: documents/models.py:699 msgid "has custom field in" msgstr "" -#: documents/models.py:699 +#: documents/models.py:700 msgid "does not have custom field in" msgstr "" -#: documents/models.py:700 +#: documents/models.py:701 msgid "does not have custom field" msgstr "" -#: documents/models.py:701 +#: documents/models.py:702 msgid "custom fields query" msgstr "" -#: documents/models.py:702 +#: documents/models.py:703 msgid "created to" msgstr "" -#: documents/models.py:703 +#: documents/models.py:704 msgid "created from" msgstr "" -#: documents/models.py:704 +#: documents/models.py:705 msgid "added to" msgstr "" -#: documents/models.py:705 +#: documents/models.py:706 msgid "added from" msgstr "" -#: documents/models.py:706 +#: documents/models.py:707 msgid "mime type is" msgstr "" -#: documents/models.py:707 +#: documents/models.py:708 msgid "simple title search" msgstr "" -#: documents/models.py:708 +#: documents/models.py:709 msgid "simple text search" msgstr "" -#: documents/models.py:718 -msgid "rule type" +#: documents/models.py:710 +msgid "has duplicates" msgstr "" #: documents/models.py:720 +msgid "rule type" +msgstr "" + +#: documents/models.py:722 msgid "value" msgstr "" -#: documents/models.py:723 +#: documents/models.py:725 msgid "filter rule" msgstr "" -#: documents/models.py:724 +#: documents/models.py:726 msgid "filter rules" msgstr "" -#: documents/models.py:754 documents/models.py:1010 +#: documents/models.py:756 documents/models.py:1012 msgid "Pending" msgstr "" -#: documents/models.py:755 documents/models.py:833 +#: documents/models.py:757 documents/models.py:835 msgid "Started" msgstr "" -#: documents/models.py:756 +#: documents/models.py:758 msgid "Success" msgstr "" -#: documents/models.py:757 +#: documents/models.py:759 msgid "Failure" msgstr "" -#: documents/models.py:758 +#: documents/models.py:760 msgid "Revoked" msgstr "" -#: documents/models.py:761 +#: documents/models.py:763 msgid "Consume File" msgstr "" -#: documents/models.py:762 +#: documents/models.py:764 msgid "Train Classifier" msgstr "" -#: documents/models.py:763 +#: documents/models.py:765 msgid "Sanity Check" msgstr "" -#: documents/models.py:764 +#: documents/models.py:766 msgid "Index Optimize" msgstr "" -#: documents/models.py:765 documents/models.py:1358 +#: documents/models.py:767 documents/models.py:1360 msgid "Mail Fetch" msgstr "" -#: documents/models.py:766 +#: documents/models.py:768 msgid "LLM Index" msgstr "" -#: documents/models.py:767 +#: documents/models.py:769 msgid "Empty Trash" msgstr "" -#: documents/models.py:768 +#: documents/models.py:770 msgid "Check Workflows" msgstr "" -#: documents/models.py:769 +#: documents/models.py:771 msgid "Bulk Update" msgstr "" -#: documents/models.py:770 +#: documents/models.py:772 msgid "Reprocess Document" msgstr "" -#: documents/models.py:771 +#: documents/models.py:773 msgid "Build Share Link" msgstr "" -#: documents/models.py:772 +#: documents/models.py:774 msgid "Bulk Delete" msgstr "" -#: documents/models.py:773 +#: documents/models.py:775 msgid "Apply AI Suggestions" msgstr "" -#: documents/models.py:782 documents/models.py:1353 +#: documents/models.py:784 documents/models.py:1355 msgid "Scheduled" msgstr "" -#: documents/models.py:783 documents/models.py:1359 +#: documents/models.py:785 documents/models.py:1361 msgid "Web UI" msgstr "" -#: documents/models.py:784 +#: documents/models.py:786 msgid "API Upload" msgstr "" -#: documents/models.py:785 +#: documents/models.py:787 msgid "Folder Consume" msgstr "" -#: documents/models.py:786 +#: documents/models.py:788 msgid "Email Consume" msgstr "" -#: documents/models.py:787 +#: documents/models.py:789 msgid "System" msgstr "" -#: documents/models.py:788 +#: documents/models.py:790 msgid "Manual" msgstr "" -#: documents/models.py:794 +#: documents/models.py:796 msgid "Task ID" msgstr "" -#: documents/models.py:795 +#: documents/models.py:797 msgid "Celery task ID" msgstr "" -#: documents/models.py:801 +#: documents/models.py:803 msgid "Task Type" msgstr "" -#: documents/models.py:802 +#: documents/models.py:804 msgid "The kind of work being performed" msgstr "" -#: documents/models.py:809 +#: documents/models.py:811 msgid "Trigger Source" msgstr "" -#: documents/models.py:810 +#: documents/models.py:812 msgid "What initiated this task" msgstr "" -#: documents/models.py:819 +#: documents/models.py:821 msgid "Status" msgstr "" -#: documents/models.py:839 +#: documents/models.py:841 msgid "Completed" msgstr "" -#: documents/models.py:847 +#: documents/models.py:849 msgid "Duration (seconds)" msgstr "" -#: documents/models.py:848 +#: documents/models.py:850 msgid "Elapsed time from start to completion" msgstr "" -#: documents/models.py:854 +#: documents/models.py:856 msgid "Wait Time (seconds)" msgstr "" -#: documents/models.py:855 +#: documents/models.py:857 msgid "Time from task creation to worker pickup" msgstr "" -#: documents/models.py:862 +#: documents/models.py:864 msgid "Input Data" msgstr "" -#: documents/models.py:863 +#: documents/models.py:865 msgid "Structured input parameters for the task" msgstr "" -#: documents/models.py:869 +#: documents/models.py:871 msgid "Result Data" msgstr "" -#: documents/models.py:870 +#: documents/models.py:872 msgid "Structured result data from task execution" msgstr "" -#: documents/models.py:876 +#: documents/models.py:878 msgid "Acknowledged" msgstr "" -#: documents/models.py:881 +#: documents/models.py:883 msgid "Task" msgstr "" -#: documents/models.py:882 +#: documents/models.py:884 msgid "Tasks" msgstr "" -#: documents/models.py:912 +#: documents/models.py:914 msgid "Note for the document" msgstr "" -#: documents/models.py:936 +#: documents/models.py:938 msgid "user" msgstr "" -#: documents/models.py:941 +#: documents/models.py:943 msgid "note" msgstr "" -#: documents/models.py:942 +#: documents/models.py:944 msgid "notes" msgstr "" -#: documents/models.py:951 +#: documents/models.py:953 msgid "Original" msgstr "" -#: documents/models.py:962 documents/models.py:1024 paperless_mail/models.py:75 +#: documents/models.py:964 documents/models.py:1026 paperless_mail/models.py:75 msgid "expiration" msgstr "" -#: documents/models.py:969 documents/models.py:1031 +#: documents/models.py:971 documents/models.py:1033 msgid "slug" msgstr "" -#: documents/models.py:1001 +#: documents/models.py:1003 msgid "share link" msgstr "" -#: documents/models.py:1002 +#: documents/models.py:1004 msgid "share links" msgstr "" -#: documents/models.py:1011 +#: documents/models.py:1013 msgid "Processing" msgstr "" -#: documents/models.py:1012 +#: documents/models.py:1014 msgid "Ready" msgstr "" -#: documents/models.py:1013 +#: documents/models.py:1015 msgid "Failed" msgstr "" -#: documents/models.py:1060 +#: documents/models.py:1062 msgid "size (bytes)" msgstr "" -#: documents/models.py:1066 +#: documents/models.py:1068 msgid "last error" msgstr "" -#: documents/models.py:1073 +#: documents/models.py:1075 msgid "file path" msgstr "" -#: documents/models.py:1079 +#: documents/models.py:1081 msgid "built at" msgstr "" -#: documents/models.py:1092 +#: documents/models.py:1094 msgid "share link bundle" msgstr "" -#: documents/models.py:1093 +#: documents/models.py:1095 msgid "share link bundles" msgstr "" -#: documents/models.py:1096 +#: documents/models.py:1098 #, python-format msgid "Share link bundle %(slug)s" msgstr "" -#: documents/models.py:1132 +#: documents/models.py:1134 msgid "String" msgstr "" -#: documents/models.py:1133 +#: documents/models.py:1135 msgid "URL" msgstr "" -#: documents/models.py:1134 +#: documents/models.py:1136 msgid "Date" msgstr "" -#: documents/models.py:1135 +#: documents/models.py:1137 msgid "Boolean" msgstr "" -#: documents/models.py:1136 +#: documents/models.py:1138 msgid "Integer" msgstr "" -#: documents/models.py:1137 +#: documents/models.py:1139 msgid "Float" msgstr "" -#: documents/models.py:1138 +#: documents/models.py:1140 msgid "Monetary" msgstr "" -#: documents/models.py:1139 +#: documents/models.py:1141 msgid "Document Link" msgstr "" -#: documents/models.py:1140 +#: documents/models.py:1142 msgid "Select" msgstr "" -#: documents/models.py:1141 +#: documents/models.py:1143 msgid "Long Text" msgstr "" -#: documents/models.py:1153 +#: documents/models.py:1155 msgid "data type" msgstr "" -#: documents/models.py:1160 +#: documents/models.py:1162 msgid "extra data" msgstr "" -#: documents/models.py:1164 +#: documents/models.py:1166 msgid "Extra data for the custom field, such as select options" msgstr "" -#: documents/models.py:1170 +#: documents/models.py:1172 msgid "custom field" msgstr "" -#: documents/models.py:1171 +#: documents/models.py:1173 msgid "custom fields" msgstr "" -#: documents/models.py:1271 +#: documents/models.py:1273 msgid "custom field instance" msgstr "" -#: documents/models.py:1272 +#: documents/models.py:1274 msgid "custom field instances" msgstr "" -#: documents/models.py:1350 +#: documents/models.py:1352 msgid "Consumption Started" msgstr "" -#: documents/models.py:1351 +#: documents/models.py:1353 msgid "Document Added" msgstr "" -#: documents/models.py:1352 +#: documents/models.py:1354 msgid "Document Updated" msgstr "" -#: documents/models.py:1356 +#: documents/models.py:1358 msgid "Consume Folder" msgstr "" -#: documents/models.py:1357 +#: documents/models.py:1359 msgid "Api Upload" msgstr "" -#: documents/models.py:1364 +#: documents/models.py:1366 msgid "Modified" msgstr "" -#: documents/models.py:1365 +#: documents/models.py:1367 msgid "Custom Field" msgstr "" -#: documents/models.py:1368 +#: documents/models.py:1370 msgid "Workflow Trigger Type" msgstr "" -#: documents/models.py:1380 +#: documents/models.py:1382 msgid "filter path" msgstr "" -#: documents/models.py:1385 +#: documents/models.py:1387 msgid "" "Only consume documents with a path that matches this if specified. Wildcards " "specified as * are allowed. Case insensitive." msgstr "" -#: documents/models.py:1392 +#: documents/models.py:1394 msgid "filter filename" msgstr "" -#: documents/models.py:1397 paperless_mail/models.py:200 +#: documents/models.py:1399 paperless_mail/models.py:200 msgid "" "Only consume documents which entirely match this filename if specified. " "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." msgstr "" -#: documents/models.py:1408 +#: documents/models.py:1410 msgid "filter documents from this mail rule" msgstr "" -#: documents/models.py:1424 +#: documents/models.py:1426 msgid "has these tag(s)" msgstr "" -#: documents/models.py:1431 +#: documents/models.py:1433 msgid "has all of these tag(s)" msgstr "" -#: documents/models.py:1438 +#: documents/models.py:1440 msgid "does not have these tag(s)" msgstr "" -#: documents/models.py:1446 +#: documents/models.py:1448 msgid "has this document type" msgstr "" -#: documents/models.py:1453 +#: documents/models.py:1455 msgid "has one of these document types" msgstr "" -#: documents/models.py:1460 +#: documents/models.py:1462 msgid "does not have these document type(s)" msgstr "" -#: documents/models.py:1468 +#: documents/models.py:1470 msgid "has this correspondent" msgstr "" -#: documents/models.py:1475 +#: documents/models.py:1477 msgid "does not have these correspondent(s)" msgstr "" -#: documents/models.py:1482 +#: documents/models.py:1484 msgid "has one of these correspondents" msgstr "" -#: documents/models.py:1490 +#: documents/models.py:1492 msgid "has this storage path" msgstr "" -#: documents/models.py:1497 +#: documents/models.py:1499 msgid "has one of these storage paths" msgstr "" -#: documents/models.py:1504 +#: documents/models.py:1506 msgid "does not have these storage path(s)" msgstr "" -#: documents/models.py:1508 +#: documents/models.py:1510 msgid "filter custom field query" msgstr "" -#: documents/models.py:1511 +#: documents/models.py:1513 msgid "JSON-encoded custom field query expression." msgstr "" -#: documents/models.py:1515 +#: documents/models.py:1517 msgid "schedule offset days" msgstr "" -#: documents/models.py:1518 +#: documents/models.py:1520 msgid "The number of days to offset the schedule trigger by." msgstr "" -#: documents/models.py:1523 +#: documents/models.py:1525 msgid "schedule is recurring" msgstr "" -#: documents/models.py:1526 +#: documents/models.py:1528 msgid "If the schedule should be recurring." msgstr "" -#: documents/models.py:1531 +#: documents/models.py:1533 msgid "schedule recurring delay in days" msgstr "" -#: documents/models.py:1535 +#: documents/models.py:1537 msgid "The number of days between recurring schedule triggers." msgstr "" -#: documents/models.py:1540 +#: documents/models.py:1542 msgid "schedule date field" msgstr "" -#: documents/models.py:1545 +#: documents/models.py:1547 msgid "The field to check for a schedule trigger." msgstr "" -#: documents/models.py:1554 +#: documents/models.py:1556 msgid "schedule date custom field" msgstr "" -#: documents/models.py:1558 +#: documents/models.py:1560 msgid "workflow trigger" msgstr "" -#: documents/models.py:1559 +#: documents/models.py:1561 msgid "workflow triggers" msgstr "" -#: documents/models.py:1567 +#: documents/models.py:1569 msgid "email subject" msgstr "" -#: documents/models.py:1571 +#: documents/models.py:1573 msgid "" "The subject of the email, can include some placeholders, see documentation." msgstr "" -#: documents/models.py:1577 +#: documents/models.py:1579 msgid "email body" msgstr "" -#: documents/models.py:1580 +#: documents/models.py:1582 msgid "" "The body (message) of the email, can include some placeholders, see " "documentation." msgstr "" -#: documents/models.py:1586 +#: documents/models.py:1588 msgid "emails to" msgstr "" -#: documents/models.py:1589 +#: documents/models.py:1591 msgid "The destination email addresses, comma separated." msgstr "" -#: documents/models.py:1595 +#: documents/models.py:1597 msgid "include document in email" msgstr "" -#: documents/models.py:1606 +#: documents/models.py:1608 msgid "webhook url" msgstr "" -#: documents/models.py:1609 +#: documents/models.py:1611 msgid "The destination URL for the notification." msgstr "" -#: documents/models.py:1614 +#: documents/models.py:1616 msgid "use parameters" msgstr "" -#: documents/models.py:1619 +#: documents/models.py:1621 msgid "send as JSON" msgstr "" -#: documents/models.py:1623 +#: documents/models.py:1625 msgid "webhook parameters" msgstr "" -#: documents/models.py:1626 +#: documents/models.py:1628 msgid "The parameters to send with the webhook URL if body not used." msgstr "" -#: documents/models.py:1630 +#: documents/models.py:1632 msgid "webhook body" msgstr "" -#: documents/models.py:1633 +#: documents/models.py:1635 msgid "The body to send with the webhook URL if parameters not used." msgstr "" -#: documents/models.py:1637 +#: documents/models.py:1639 msgid "webhook headers" msgstr "" -#: documents/models.py:1640 +#: documents/models.py:1642 msgid "The headers to send with the webhook URL." msgstr "" -#: documents/models.py:1645 +#: documents/models.py:1647 msgid "include document in webhook" msgstr "" -#: documents/models.py:1656 +#: documents/models.py:1658 msgid "Assignment" msgstr "" -#: documents/models.py:1660 +#: documents/models.py:1662 msgid "Removal" msgstr "" -#: documents/models.py:1664 documents/templates/account/password_reset.html:15 +#: documents/models.py:1666 documents/templates/account/password_reset.html:15 msgid "Email" msgstr "" -#: documents/models.py:1668 +#: documents/models.py:1670 msgid "Webhook" msgstr "" -#: documents/models.py:1672 +#: documents/models.py:1674 msgid "Password removal" msgstr "" -#: documents/models.py:1676 +#: documents/models.py:1678 msgid "Move to trash" msgstr "" -#: documents/models.py:1680 +#: documents/models.py:1682 msgid "Remote OCR" msgstr "" -#: documents/models.py:1684 +#: documents/models.py:1686 msgid "Apply AI suggestions" msgstr "" -#: documents/models.py:1691 +#: documents/models.py:1693 msgid "Document type" msgstr "" -#: documents/models.py:1692 +#: documents/models.py:1694 msgid "Storage path" msgstr "" -#: documents/models.py:1693 +#: documents/models.py:1695 msgid "Created date" msgstr "" -#: documents/models.py:1696 +#: documents/models.py:1698 msgid "Workflow Action Type" msgstr "" -#: documents/models.py:1701 documents/models.py:1970 +#: documents/models.py:1703 documents/models.py:1972 #: paperless_mail/models.py:145 msgid "order" msgstr "" -#: documents/models.py:1704 +#: documents/models.py:1706 msgid "assign title" msgstr "" -#: documents/models.py:1708 +#: documents/models.py:1710 msgid "Assign a document title, must be a Jinja2 template, see documentation." msgstr "" -#: documents/models.py:1716 paperless_mail/models.py:274 +#: documents/models.py:1718 paperless_mail/models.py:274 msgid "assign this tag" msgstr "" -#: documents/models.py:1725 paperless_mail/models.py:282 +#: documents/models.py:1727 paperless_mail/models.py:282 msgid "assign this document type" msgstr "" -#: documents/models.py:1734 paperless_mail/models.py:296 +#: documents/models.py:1736 paperless_mail/models.py:296 msgid "assign this correspondent" msgstr "" -#: documents/models.py:1743 +#: documents/models.py:1745 msgid "assign this storage path" msgstr "" -#: documents/models.py:1752 +#: documents/models.py:1754 msgid "assign this owner" msgstr "" -#: documents/models.py:1759 +#: documents/models.py:1761 msgid "grant view permissions to these users" msgstr "" -#: documents/models.py:1766 +#: documents/models.py:1768 msgid "grant view permissions to these groups" msgstr "" -#: documents/models.py:1773 +#: documents/models.py:1775 msgid "grant change permissions to these users" msgstr "" -#: documents/models.py:1780 +#: documents/models.py:1782 msgid "grant change permissions to these groups" msgstr "" -#: documents/models.py:1787 +#: documents/models.py:1789 msgid "assign these custom fields" msgstr "" -#: documents/models.py:1791 +#: documents/models.py:1793 msgid "custom field values" msgstr "" -#: documents/models.py:1795 +#: documents/models.py:1797 msgid "Optional values to assign to the custom fields." msgstr "" -#: documents/models.py:1804 +#: documents/models.py:1806 msgid "remove these tag(s)" msgstr "" -#: documents/models.py:1809 +#: documents/models.py:1811 msgid "remove all tags" msgstr "" -#: documents/models.py:1816 +#: documents/models.py:1818 msgid "remove these document type(s)" msgstr "" -#: documents/models.py:1821 +#: documents/models.py:1823 msgid "remove all document types" msgstr "" -#: documents/models.py:1828 +#: documents/models.py:1830 msgid "remove these correspondent(s)" msgstr "" -#: documents/models.py:1833 +#: documents/models.py:1835 msgid "remove all correspondents" msgstr "" -#: documents/models.py:1840 +#: documents/models.py:1842 msgid "remove these storage path(s)" msgstr "" -#: documents/models.py:1845 +#: documents/models.py:1847 msgid "remove all storage paths" msgstr "" -#: documents/models.py:1852 +#: documents/models.py:1854 msgid "remove these owner(s)" msgstr "" -#: documents/models.py:1857 +#: documents/models.py:1859 msgid "remove all owners" msgstr "" -#: documents/models.py:1864 +#: documents/models.py:1866 msgid "remove view permissions for these users" msgstr "" -#: documents/models.py:1871 +#: documents/models.py:1873 msgid "remove view permissions for these groups" msgstr "" -#: documents/models.py:1878 +#: documents/models.py:1880 msgid "remove change permissions for these users" msgstr "" -#: documents/models.py:1885 +#: documents/models.py:1887 msgid "remove change permissions for these groups" msgstr "" -#: documents/models.py:1890 +#: documents/models.py:1892 msgid "remove all permissions" msgstr "" -#: documents/models.py:1897 +#: documents/models.py:1899 msgid "remove these custom fields" msgstr "" -#: documents/models.py:1902 +#: documents/models.py:1904 msgid "remove all custom fields" msgstr "" -#: documents/models.py:1911 +#: documents/models.py:1913 msgid "email" msgstr "" -#: documents/models.py:1920 +#: documents/models.py:1922 msgid "webhook" msgstr "" -#: documents/models.py:1924 +#: documents/models.py:1926 msgid "passwords" msgstr "" -#: documents/models.py:1928 +#: documents/models.py:1930 msgid "" "Passwords to try when removing PDF protection. Separate with commas or new " "lines." msgstr "" -#: documents/models.py:1933 +#: documents/models.py:1935 msgid "AI suggestion fields" msgstr "" -#: documents/models.py:1937 +#: documents/models.py:1939 msgid "Which of the AI-suggested fields to apply to the document." msgstr "" -#: documents/models.py:1942 +#: documents/models.py:1944 msgid "create missing objects" msgstr "" -#: documents/models.py:1945 +#: documents/models.py:1947 msgid "" "Create suggested tags, correspondents, document types and storage paths that " "do not already exist instead of skipping them." msgstr "" -#: documents/models.py:1951 +#: documents/models.py:1953 msgid "overwrite existing values" msgstr "" -#: documents/models.py:1954 +#: documents/models.py:1956 msgid "" "Apply suggestions even if the document already has a value for that field. " "Tags are always added to, never replaced." msgstr "" -#: documents/models.py:1960 +#: documents/models.py:1962 msgid "workflow action" msgstr "" -#: documents/models.py:1961 +#: documents/models.py:1963 msgid "workflow actions" msgstr "" -#: documents/models.py:1976 +#: documents/models.py:1978 msgid "triggers" msgstr "" -#: documents/models.py:1983 +#: documents/models.py:1985 msgid "actions" msgstr "" -#: documents/models.py:1986 paperless_mail/models.py:154 +#: documents/models.py:1988 paperless_mail/models.py:154 msgid "enabled" msgstr "" -#: documents/models.py:1997 +#: documents/models.py:1999 msgid "workflow" msgstr "" -#: documents/models.py:2001 +#: documents/models.py:2003 msgid "workflow trigger type" msgstr "" -#: documents/models.py:2015 +#: documents/models.py:2017 msgid "date run" msgstr "" -#: documents/models.py:2021 +#: documents/models.py:2023 msgid "workflow run" msgstr "" -#: documents/models.py:2022 +#: documents/models.py:2024 msgid "workflow runs" msgstr "" @@ -1669,7 +1673,7 @@ msgstr "" msgid "Duplicate document identifiers are not allowed." msgstr "" -#: documents/serialisers.py:2924 documents/views.py:4625 +#: documents/serialisers.py:2924 documents/views.py:4626 #, python-format msgid "Documents not found: %(ids)s" msgstr "" @@ -1953,20 +1957,20 @@ msgstr "" msgid "Specify only one of text, title_search, query, or more_like_id." msgstr "" -#: documents/views.py:4638 +#: documents/views.py:4639 #, python-format msgid "Insufficient permissions to share document %(id)s." msgstr "" -#: documents/views.py:4684 +#: documents/views.py:4685 msgid "Bundle is already being processed." msgstr "" -#: documents/views.py:4748 +#: documents/views.py:4749 msgid "The share link bundle is still being prepared. Please try again later." msgstr "" -#: documents/views.py:4762 +#: documents/views.py:4763 msgid "The share link bundle is unavailable." msgstr "" From d52cc1b6099a77cb5efe718e19b63e61223e1635 Mon Sep 17 00:00:00 2001 From: "Berk D. Demir" <11135+bdd@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:08:34 -0700 Subject: [PATCH 13/68] Fix: Use PAPERLESS_REDIS_PREFIX for Celery result backend keys (#14015) PR #12741 sets the result backend for Celery to Redis, but forget to carryover transport option `global_keyprefix`. This resulted keys with prefix `celery-task-meta-` prefix to be created. This fix unbreaks strict Redis ACLs that allow a single prefix. --- src/paperless/settings/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 6b400963b..a0dc0e585 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -701,6 +701,9 @@ CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True CELERY_BROKER_TRANSPORT_OPTIONS = { "global_keyprefix": _REDIS_KEY_PREFIX, } +CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = { + "global_keyprefix": _REDIS_KEY_PREFIX, +} CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TIME_LIMIT: Final[int] = get_int_from_env("PAPERLESS_WORKER_TIMEOUT", 1800) From f5ff18326d2d2ec9c01506c406bd289c0bb7cc3c Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:43:13 -0700 Subject: [PATCH 14/68] Performance: resolve index-write permissions and effective content in bulk (#13869) * fix(search): resolve index-write permissions and effective content in bulk Add WriteBatch.add_or_update_ids() and use it in bulk_update_documents and trash restore, cutting index writes from ~8 queries per document to a constant handful per batch * Always these new ones with xdist, try a better condition --- src/documents/search/_backend.py | 40 +++++ src/documents/tasks.py | 8 +- src/documents/tests/search/test_backend.py | 187 +++++++++++++++++++++ src/documents/views.py | 3 +- 4 files changed, 233 insertions(+), 5 deletions(-) diff --git a/src/documents/search/_backend.py b/src/documents/search/_backend.py index b783cfd2d..e5bae979e 100644 --- a/src/documents/search/_backend.py +++ b/src/documents/search/_backend.py @@ -284,6 +284,46 @@ class WriteBatch: tantivy.Query.term_query(self._backend._schema, "id", doc_id), ) + def add_or_update_ids(self, ids: Sequence[int]) -> None: + """ + Add or update multiple documents in the batch by primary key. + + Unlike calling ``add_or_update()`` once per document, this resolves + viewer permissions and effective (versioned) content in bulk against + the ids as a whole, instead of once per document -- see + ``_DocumentViewerStream`` and ``annotate_effective_content``. Use + this whenever more than one document is being written in the same + batch. + + An id with no matching document (e.g. deleted between the caller + collecting ids and the batch running) is silently skipped, matching + ``add_or_update()``'s existing single-document deferred-task behavior + rather than erroring or leaving a stale index entry. + + Args: + ids: Primary keys of Document instances to index + """ + from documents.models import Document + from documents.versioning import annotate_effective_content + + ids = list(ids) + if not ids: + return + + queryset = annotate_effective_content( + Document.objects.filter(pk__in=ids) + .select_related("correspondent", "document_type", "storage_path", "owner") + .prefetch_related("tags", "notes__user", "custom_fields__field"), + ) + for document, grant in _DocumentViewerStream(queryset, chunk_size=1000): + self.remove(document.pk) + doc = self._backend._build_tantivy_doc( + document, + viewer_ids=grant.viewer_ids, + viewer_group_ids=grant.viewer_group_ids, + ) + self._writer.add_document(doc) + class TantivyBackend: """ diff --git a/src/documents/tasks.py b/src/documents/tasks.py index ca53468eb..33f7d4f90 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -312,7 +312,10 @@ def bulk_update_documents(document_ids) -> None: from documents.search import get_backend document_ids = list(document_ids) - # Annotated so indexing below doesn't query the versions of each document + # Annotated so the signal handlers below (e.g. matching) don't query the + # versions of each document. Indexing re-queries and re-annotates its own + # copy via add_or_update_ids() below, after these signals (and any + # workflow they trigger) have had a chance to mutate the documents. documents = annotate_effective_content( Document.objects.filter(id__in=document_ids), ) @@ -328,8 +331,7 @@ def bulk_update_documents(document_ids) -> None: post_save.send(Document, instance=doc, created=False) with get_backend().batch_update() as batch: - for doc in documents: - batch.add_or_update(doc) + batch.add_or_update_ids(document_ids) ai_config = AIConfig() if ai_config.llm_index_enabled: diff --git a/src/documents/tests/search/test_backend.py b/src/documents/tests/search/test_backend.py index 21fed8f02..60b5d62da 100644 --- a/src/documents/tests/search/test_backend.py +++ b/src/documents/tests/search/test_backend.py @@ -4,6 +4,8 @@ from pathlib import Path import pytest from django.contrib.auth.models import Group from django.contrib.auth.models import User +from django.db import connection +from django.test.utils import CaptureQueriesContext from guardian.shortcuts import assign_perm from pytest_mock import MockerFixture @@ -102,6 +104,191 @@ class TestWriteBatch: assert len(backend.search_ids("indexable", user=None)) == 1 +class TestAddOrUpdateIds: + """Test WriteBatch.add_or_update_ids(), the bulk id-based upsert path. + + Unlike add_or_update() called once per document, this resolves viewer + permissions and effective (versioned) content in bulk against the ids as + a whole, so it must produce identical indexed output to the per-document + path while issuing a constant number of queries regardless of batch size. + """ + + def test_missing_id_is_skipped_not_errored( + self, + backend: TantivyBackend, + ) -> None: + doc = Document.objects.create( + title="doc", + content="present", + checksum="EXIST1", + pk=1, + ) + missing_pk = 999 + + with backend.batch_update() as batch: + batch.add_or_update_ids([doc.pk, missing_pk]) + + assert backend.search_ids("present", user=None) == [doc.pk] + + def test_query_count_does_not_scale_with_batch_size( + self, + backend: TantivyBackend, + ) -> None: + """Each query count must stay far below N, not merely match between + two runs -- an exact-equality assertion between two measurements is + at the mercy of incidental process-level caches (e.g. Django's + ContentType.objects.get_for_model) warming on whichever run happens + first, which makes counts differ by a query for reasons unrelated to + batch size. A generous fixed bound sidesteps that: the old + per-document path issued roughly 8 queries per document, so 50 + documents under a bound this low proves the fix regardless of cache + state. + """ + max_queries_for_any_batch_size = 15 + + small_docs = [ + Document.objects.create( + title="doc", + content=f"unique{i}", + checksum=f"SMALL{i}", + pk=i, + ) + for i in range(1, 3) + ] + with CaptureQueriesContext(connection) as ctx_small: + with backend.batch_update() as batch: + batch.add_or_update_ids([d.pk for d in small_docs]) + assert len(ctx_small.captured_queries) <= max_queries_for_any_batch_size + + large_docs = [ + Document.objects.create( + title="doc", + content=f"unique{i}", + checksum=f"LARGE{i}", + pk=i, + ) + for i in range(100, 150) + ] + with CaptureQueriesContext(connection) as ctx_large: + with backend.batch_update() as batch: + batch.add_or_update_ids([d.pk for d in large_docs]) + assert len(ctx_large.captured_queries) <= max_queries_for_any_batch_size + + for doc in large_docs: + assert backend.search_ids(f"unique{doc.pk}", user=None) == [doc.pk] + + def test_resolves_direct_user_grant_in_bulk( + self, + backend: TantivyBackend, + ) -> None: + owner = UserFactory() + user = UserFactory() + doc = Document.objects.create( + title="doc", + checksum="PERM1", + pk=1, + owner=owner, + ) + assign_perm("view_document", user, doc) + + with backend.batch_update() as batch: + batch.add_or_update_ids([doc.pk]) + + assert backend.search_ids("doc", user=user) == [doc.pk] + other = UserFactory() + assert backend.search_ids("doc", user=other) == [] + + def test_resolves_group_grant_in_bulk(self, backend: TantivyBackend) -> None: + owner = UserFactory() + group = Group.objects.create(name="reviewers") + user = UserFactory() + user.groups.add(group) + doc = Document.objects.create( + title="doc", + checksum="GPERM1", + pk=1, + owner=owner, + ) + assign_perm("view_document", group, doc) + + with backend.batch_update() as batch: + batch.add_or_update_ids([doc.pk]) + + assert backend.search_ids("doc", user=user) == [doc.pk] + other = UserFactory() + assert backend.search_ids("doc", user=other) == [] + + def test_indexes_notes_and_custom_fields(self, backend: TantivyBackend) -> None: + note_author = UserFactory(username="noter") + field = CustomField.objects.create( + name="Invoice Number", + data_type=CustomField.FieldDataType.STRING, + ) + doc = Document.objects.create(title="doc", checksum="RICH1", pk=1) + Note.objects.create(document=doc, note="Reviewed", user=note_author) + CustomFieldInstance.objects.create( + document=doc, + field=field, + value_text="INV-42", + ) + + with backend.batch_update() as batch: + batch.add_or_update_ids([doc.pk]) + + assert backend.search_ids("notes.user:noter", user=None) == [doc.pk] + assert backend.search_ids("custom_fields.value:INV-42", user=None) == [ + doc.pk, + ] + + def test_uses_effective_content_for_versioned_documents( + self, + backend: TantivyBackend, + ) -> None: + root = Document.objects.create( + title="Statement", + content="stale text", + checksum="ROOT1", + pk=1, + ) + Document.objects.create( + title="Statement", + content="latest version text", + checksum="VER1", + pk=2, + root_document=root, + version_index=1, + ) + + with backend.batch_update() as batch: + batch.add_or_update_ids([root.pk]) + + assert backend.search_ids("latest", user=None) == [root.pk] + assert backend.search_ids("stale", user=None) == [] + + def test_reindexes_documents_already_in_the_index( + self, + backend: TantivyBackend, + ) -> None: + """add_or_update_ids must upsert, matching add_or_update's behaviour.""" + doc = Document.objects.create( + title="doc", + content="original", + checksum="UP1", + pk=1, + ) + backend.add_or_update(doc) + assert backend.search_ids("original", user=None) == [doc.pk] + + doc.content = "updated" + doc.save() + + with backend.batch_update() as batch: + batch.add_or_update_ids([doc.pk]) + + assert backend.search_ids("original", user=None) == [] + assert backend.search_ids("updated", user=None) == [doc.pk] + + class TestSearch: """Test search query parsing and matching via search_ids.""" diff --git a/src/documents/views.py b/src/documents/views.py index 0d256115a..4cf775bd6 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -5469,8 +5469,7 @@ class TrashView(ListModelMixin, PassUserMixin): from documents.search import get_backend with get_backend().batch_update() as batch: - for doc in restored: - batch.add_or_update(doc) + batch.add_or_update_ids([doc.pk for doc in restored]) elif action == "empty": if doc_ids is None: doc_ids = [doc.id for doc in docs] From 937feb1befdbc5712a612e9be44a926b0b6fc84c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:56:37 -0700 Subject: [PATCH 15/68] Change: skip documents with empty content in apply AI suggestions WF (#13985) --- docs/usage.md | 3 ++- src/documents/tests/test_workflows.py | 33 +++++++++++++++++++++++++++ src/documents/workflows/ai.py | 10 ++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/usage.md b/docs/usage.md index 805e15718..004fd5505 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -684,7 +684,8 @@ It requires [AI features](configuration.md#ai) to be enabled. You can specify: never replace the document's existing tags. The action works with every trigger **except Consumption Started**, because suggestions are made from -the document's text, which does not exist until after the document has been processed. +the document's text, which does not exist until after the document has been processed. Documents whose +processed text is empty or contains only whitespace are skipped. Because the query to the AI service is slow, the action is queued and runs in the background rather than as part of the workflow run itself. The document is updated once the suggestions come back. diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 62bb61846..45a3703d2 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -5711,6 +5711,39 @@ class TestApplyAISuggestionsWorkflowAction( self.assertEqual(changed, []) self.assertIn("AI is not enabled", "".join(cm.output)) + def test_document_without_content_does_nothing(self) -> None: + """ + GIVEN: + - A document whose OCR content is empty or whitespace-only + WHEN: + - AI suggestions are applied by a workflow + THEN: + - The classifier is not called and the document is left unchanged + """ + action = self.make_action(ai_overwrite_existing=True) + + for content in ("", " \n\t"): + with self.subTest(content=content): + self.doc.content = content + self.doc.save(update_fields=["content"]) + + with ( + mock.patch( + "documents.workflows.ai.get_ai_document_classification", + ) as get_classification, + self.assertLogs( + "paperless.workflows.ai", + level="WARNING", + ) as cm, + ): + changed = apply_ai_suggestions_to_document(action, self.doc) + + self.assertEqual(changed, []) + get_classification.assert_not_called() + self.assertIn("has no content", "".join(cm.output)) + self.doc.refresh_from_db() + self.assertEqual(self.doc.title, "original.pdf") + def test_invalid_configuration_leaves_document_untouched(self) -> None: """ GIVEN: diff --git a/src/documents/workflows/ai.py b/src/documents/workflows/ai.py index 23ffbd71c..f73017ee9 100644 --- a/src/documents/workflows/ai.py +++ b/src/documents/workflows/ai.py @@ -138,6 +138,16 @@ def apply_ai_suggestions_to_document( ) return [] + if not document.content.strip(): + logger.warning( + "Document %s has no content, skipping AI suggestions for workflow " + "action %s", + document.pk, + action.pk, + extra={"group": logging_group}, + ) + return [] + # Workflows run without a user, so we use the document owner owner = document.owner From f197d09b3e287d93f73ea40028046ff0f36930bb Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:04:13 -0700 Subject: [PATCH 16/68] Enhancement: allow disabling auto-suggestions for inbox documents (#13946) --- docs/advanced_usage.md | 4 ++- docs/usage.md | 2 ++ .../admin/settings/settings.component.html | 6 ++++ .../admin/settings/settings.component.spec.ts | 2 +- .../admin/settings/settings.component.ts | 8 +++++ .../document-detail.component.spec.ts | 29 +++++++++++++++++++ .../document-detail.component.ts | 8 +++++ src-ui/src/app/data/ui-settings.ts | 7 +++++ 8 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index 29c427fb0..661c2d704 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -138,7 +138,9 @@ for suggested generation and embedding models. With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type, storage path and dates by sending the document to the LLM. This is **opt-in per request** and surfaces through the "Suggest" control on the document detail page, alongside the -classic classifier-based suggestions — it does not disable them. Suggestion output +classic classifier-based suggestions — it does not disable them. Suggestions are requested +automatically when you open a document that carries an inbox tag unless "Automatically request +suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output language can be steered with [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) (otherwise it follows the user's UI language). diff --git a/docs/usage.md b/docs/usage.md index 004fd5505..6bee3c9c3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -317,6 +317,8 @@ a "document already exists" message. Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user. +Suggestions are requested automatically when you open a document that still has an inbox tag. To only request them by pressing the "Suggest" button instead, turn off "Automatically request suggestions for inbox documents" under Settings > Documents. + ## AI Features Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration. diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index 569815d35..305bb8a96 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -237,6 +237,12 @@ +
+
+ +
+
+
diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 7d24063b4..7b65281d0 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -267,7 +267,7 @@ describe('SettingsComponent', () => { expect(toastErrorSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled() - expect(setSpy).toHaveBeenCalledTimes(32) + expect(setSpy).toHaveBeenCalledTimes(33) // succeed storeSpy.mockReturnValueOnce(of(true)) diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index b9409fe06..218f6683c 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -168,6 +168,7 @@ export class SettingsComponent pdfEditorDefaultEditMode: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null), + documentEditingAutoSuggest: new FormControl(null), documentDetailsHiddenFields: new FormControl([]), searchDbOnly: new FormControl(null), searchLink: new FormControl(null), @@ -368,6 +369,9 @@ export class SettingsComponent documentEditingOverlayThumbnail: this.settings.get( SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL ), + documentEditingAutoSuggest: this.settings.get( + SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST + ), documentDetailsHiddenFields: this.settings.get( SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS ), @@ -565,6 +569,10 @@ export class SettingsComponent SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, this.settingsForm.value.documentEditingOverlayThumbnail ) + this.settings.set( + SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, + this.settingsForm.value.documentEditingAutoSuggest + ) this.settings.set( SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, this.settingsForm.value.documentDetailsHiddenFields diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index bb75f8d07..58322cfd5 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1473,6 +1473,35 @@ describe('DocumentDetailComponent', () => { }) }) + it('should not automatically get suggestions if auto-suggest is disabled', () => { + settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false) + const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions') + suggestionsSpy.mockReturnValue(of({ tags: [42] })) + initNormally() + expect(suggestionsSpy).not.toHaveBeenCalled() + + // still available on demand + component.getSuggestions() + expect(suggestionsSpy).toHaveBeenCalled() + }) + + it('should not automatically get AI suggestions if auto-suggest is disabled', () => { + settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false) + const getSetting = settingsService.get.bind(settingsService) + jest + .spyOn(settingsService, 'get') + .mockImplementation((key) => + key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key) + ) + const aiSuggestionsSpy = jest.spyOn(documentService, 'getAiSuggestions') + aiSuggestionsSpy.mockReturnValue(of({ tags: [42] })) + initNormally() + expect(aiSuggestionsSpy).not.toHaveBeenCalled() + + component.getSuggestions() + expect(aiSuggestionsSpy).toHaveBeenCalled() + }) + it('should reset the suggestions loading state if the document changes mid-request', () => { const getSetting = settingsService.get.bind(settingsService) jest diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 18478ea40..f1f3188d0 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -237,6 +237,9 @@ export class DocumentDetailComponent this.settings.getSignal( SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL ) + private readonly autoSuggestSetting = this.settings.getSignal( + SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST + ) private readonly hiddenFieldsSetting = this.settings.getSignal< DocumentDetailFieldID[] >(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS) @@ -357,6 +360,10 @@ export class DocumentDetailComponent return this.aiEnabledSetting() } + get autoSuggest(): boolean { + return this.autoSuggestSetting() + } + get archiveContentRenderType(): ContentRenderType { const hasArchiveVersion = this.metadata()?.has_archive_version ?? @@ -904,6 +911,7 @@ export class DocumentDetailComponent this.updateFormForCustomFields() this.loadMetadataForSelectedVersion() if ( + this.autoSuggest && this.permissionsService.currentUserHasObjectPermissions( PermissionAction.Change, doc diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index 4739051fa..959a30669 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -84,6 +84,8 @@ export const SETTINGS_KEYS = { 'general-settings:document-editing:remove-inbox-tags', DOCUMENT_EDITING_OVERLAY_THUMBNAIL: 'general-settings:document-editing:overlay-thumbnail', + DOCUMENT_EDITING_AUTO_SUGGEST: + 'general-settings:document-editing:auto-suggest', DOCUMENT_DETAILS_HIDDEN_FIELDS: 'general-settings:document-details:hidden-fields', SEARCH_DB_ONLY: 'general-settings:search:db-only', @@ -300,6 +302,11 @@ export const SETTINGS: UiSetting[] = [ type: 'boolean', default: true, }, + { + key: SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, + type: 'boolean', + default: true, + }, { key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, type: 'array', From 4d5897ec80e8d9863273ec2c41836777a708a186 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:05:44 +0000 Subject: [PATCH 17/68] Auto translate strings --- src-ui/messages.xlf | 188 ++++++++++++++++++++++++-------------------- 1 file changed, 101 insertions(+), 87 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index ce76005d4..fa690d2d1 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -535,7 +535,7 @@ src/app/components/admin/settings/settings.component.html - 401,402 + 407,408 src/app/components/common/confirm-dialog/confirm-dialog.component.ts @@ -614,7 +614,7 @@ src/app/components/admin/settings/settings.component.html - 402,403 + 408,409 src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html @@ -1226,53 +1226,67 @@ 236 + + Automatically request suggestions for inbox documents + + src/app/components/admin/settings/settings.component.html + 242 + + + + If un-checked, suggestions must be requested via the Suggest button. + + src/app/components/admin/settings/settings.component.html + 242 + + Show document thumbnail during loading src/app/components/admin/settings/settings.component.html - 242 + 248 Built-in fields to show: src/app/components/admin/settings/settings.component.html - 248,249 + 254,255 Uncheck fields to hide them on the document details page. src/app/components/admin/settings/settings.component.html - 260,261 + 266,267 Bulk editing src/app/components/admin/settings/settings.component.html - 266,267 + 272,273 Show confirmation dialogs src/app/components/admin/settings/settings.component.html - 269 + 275 Apply on close src/app/components/admin/settings/settings.component.html - 270 + 276 PDF Editor src/app/components/admin/settings/settings.component.html - 274,275 + 280,281 src/app/components/document-detail/document-detail.component.html @@ -1280,21 +1294,21 @@ src/app/components/document-detail/document-detail.component.ts - 1800 + 1808 Default editing mode src/app/components/admin/settings/settings.component.html - 277,278 + 283,284 Create new document(s) src/app/components/admin/settings/settings.component.html - 281,282 + 287,288 src/app/components/common/pdf-editor/pdf-editor.component.html @@ -1305,7 +1319,7 @@ Add document version src/app/components/admin/settings/settings.component.html - 282,283 + 288,289 src/app/components/common/pdf-editor/pdf-editor.component.html @@ -1316,7 +1330,7 @@ Notes src/app/components/admin/settings/settings.component.html - 287,288 + 293,294 src/app/components/document-list/document-list.component.html @@ -1335,14 +1349,14 @@ Enable notes src/app/components/admin/settings/settings.component.html - 290 + 296 Permissions src/app/components/admin/settings/settings.component.html - 299,300 + 305,306 src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html @@ -1397,28 +1411,28 @@ Default Permissions src/app/components/admin/settings/settings.component.html - 302,304 + 308,310 Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI. These settings do not apply to documents. src/app/components/admin/settings/settings.component.html - 307 + 313 Default Owner src/app/components/admin/settings/settings.component.html - 313,314 + 319,320 Objects without an owner can be viewed and edited by all users src/app/components/admin/settings/settings.component.html - 317,318 + 323,324 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -1429,18 +1443,18 @@ Default View Permissions src/app/components/admin/settings/settings.component.html - 322,323 + 328,329 Users: src/app/components/admin/settings/settings.component.html - 327,328 + 333,334 src/app/components/admin/settings/settings.component.html - 354,355 + 360,361 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1471,11 +1485,11 @@ Groups: src/app/components/admin/settings/settings.component.html - 337,338 + 343,344 src/app/components/admin/settings/settings.component.html - 364,365 + 370,371 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1506,14 +1520,14 @@ Default Edit Permissions src/app/components/admin/settings/settings.component.html - 349,350 + 355,356 Edit permissions also grant viewing permissions src/app/components/admin/settings/settings.component.html - 373,374 + 379,380 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1532,7 +1546,7 @@ Notifications src/app/components/admin/settings/settings.component.html - 381,382 + 387,388 src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html @@ -1547,42 +1561,42 @@ Document processing src/app/components/admin/settings/settings.component.html - 384,386 + 390,392 Show notifications when new documents are detected src/app/components/admin/settings/settings.component.html - 388 + 394 Show notifications when document processing completes successfully src/app/components/admin/settings/settings.component.html - 389 + 395 Show notifications when document processing fails src/app/components/admin/settings/settings.component.html - 390 + 396 Suppress notifications on dashboard src/app/components/admin/settings/settings.component.html - 391 + 397 This will suppress all messages about document processing status on the dashboard. src/app/components/admin/settings/settings.component.html - 391 + 397 @@ -1766,7 +1780,7 @@ Error retrieving users src/app/components/admin/settings/settings.component.ts - 255 + 256 src/app/components/admin/users-groups/users-groups.component.ts @@ -1785,7 +1799,7 @@ Error retrieving groups src/app/components/admin/settings/settings.component.ts - 274 + 275 src/app/components/admin/users-groups/users-groups.component.ts @@ -1804,28 +1818,28 @@ Settings were saved successfully. src/app/components/admin/settings/settings.component.ts - 591 + 599 Settings were saved successfully. Reload is required to apply some changes. src/app/components/admin/settings/settings.component.ts - 595 + 603 Reload now src/app/components/admin/settings/settings.component.ts - 596 + 604 An error occurred while saving settings. src/app/components/admin/settings/settings.component.ts - 606 + 614 src/app/components/app-frame/app-frame.component.ts @@ -2293,7 +2307,7 @@ src/app/components/document-detail/document-detail.component.ts - 666 + 673 src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html @@ -3200,11 +3214,11 @@ src/app/components/document-detail/document-detail.component.ts - 1414 + 1422 src/app/components/document-detail/document-detail.component.ts - 1801 + 1809 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -3846,7 +3860,7 @@ src/app/components/document-detail/document-detail.component.ts - 1367 + 1375 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -3990,7 +4004,7 @@ src/app/components/document-detail/document-detail.component.ts - 1854 + 1862 @@ -4001,7 +4015,7 @@ src/app/components/document-detail/document-detail.component.ts - 1855 + 1863 @@ -4012,7 +4026,7 @@ src/app/components/document-detail/document-detail.component.ts - 1856 + 1864 @@ -6149,7 +6163,7 @@ src/app/components/document-detail/document-detail.component.ts - 1371 + 1379 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -8573,81 +8587,81 @@ Error retrieving metadata src/app/components/document-detail/document-detail.component.ts - 433 + 440 An error occurred loading content: src/app/components/document-detail/document-detail.component.ts - 535,537 + 542,544 src/app/components/document-detail/document-detail.component.ts - 992,994 + 1000,1002 Document was updated src/app/components/document-detail/document-detail.component.ts - 661 + 668 Document was updated at . src/app/components/document-detail/document-detail.component.ts - 662 + 669 Reload to discard your local unsaved edits and load the latest remote version. src/app/components/document-detail/document-detail.component.ts - 663 + 670 Reload src/app/components/document-detail/document-detail.component.ts - 665 + 672 Document reloaded with latest changes. src/app/components/document-detail/document-detail.component.ts - 721 + 728 Document reloaded. src/app/components/document-detail/document-detail.component.ts - 732 + 739 Next document src/app/components/document-detail/document-detail.component.ts - 834 + 841 Previous document src/app/components/document-detail/document-detail.component.ts - 844 + 851 Close document src/app/components/document-detail/document-detail.component.ts - 852 + 859 src/app/services/open-documents.service.ts @@ -8658,67 +8672,67 @@ Save document src/app/components/document-detail/document-detail.component.ts - 859 + 866 Save and close / next src/app/components/document-detail/document-detail.component.ts - 868 + 875 Error retrieving version content src/app/components/document-detail/document-detail.component.ts - 975 + 983 Error retrieving suggestions. src/app/components/document-detail/document-detail.component.ts - 1035 + 1043 Document "" saved successfully. src/app/components/document-detail/document-detail.component.ts - 1247 + 1255 src/app/components/document-detail/document-detail.component.ts - 1274 + 1282 Error saving document "" src/app/components/document-detail/document-detail.component.ts - 1280 + 1288 Error saving document src/app/components/document-detail/document-detail.component.ts - 1335 + 1343 Do you really want to move the document "" to the trash? src/app/components/document-detail/document-detail.component.ts - 1368 + 1376 Documents can be restored prior to permanent deletion. src/app/components/document-detail/document-detail.component.ts - 1369 + 1377 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -8729,14 +8743,14 @@ Error deleting document src/app/components/document-detail/document-detail.component.ts - 1390 + 1398 Reprocess confirm src/app/components/document-detail/document-detail.component.ts - 1410 + 1418 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -8747,102 +8761,102 @@ This operation will permanently recreate the archive file for this document. src/app/components/document-detail/document-detail.component.ts - 1411 + 1419 The archive file will be re-generated with the current settings. src/app/components/document-detail/document-detail.component.ts - 1412 + 1420 Reprocess operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1425 + 1433 Error executing operation src/app/components/document-detail/document-detail.component.ts - 1436 + 1444 Error downloading document src/app/components/document-detail/document-detail.component.ts - 1500 + 1508 Page Fit src/app/components/document-detail/document-detail.component.ts - 1578 + 1586 PDF edit operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1821 + 1829 Error executing PDF edit operation src/app/components/document-detail/document-detail.component.ts - 1833 + 1841 Please enter the current password before attempting to remove it. src/app/components/document-detail/document-detail.component.ts - 1844 + 1852 Password removal operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1878 + 1886 Error executing password removal operation src/app/components/document-detail/document-detail.component.ts - 1892 + 1900 Print failed. src/app/components/document-detail/document-detail.component.ts - 1942 + 1950 Error loading document for printing. src/app/components/document-detail/document-detail.component.ts - 1951 + 1959 An error occurred loading tiff: src/app/components/document-detail/document-detail.component.ts - 2034 + 2042 src/app/components/document-detail/document-detail.component.ts - 2040 + 2048 From 0132c7bd6efe28f44a255b6f057fa9fa672e3907 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:59:41 -0700 Subject: [PATCH 18/68] Fix pr-bot timing --- .github/workflows/issue-bot.yml | 2 +- .github/workflows/pr-bot.yml | 25 +++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-bot.yml b/.github/workflows/issue-bot.yml index 69b98baef..1950f59b6 100644 --- a/.github/workflows/issue-bot.yml +++ b/.github/workflows/issue-bot.yml @@ -72,7 +72,7 @@ jobs: 'You are welcome to open a new issue that describes the problem you observed in your own words.' : 'This issue was automatically closed because it was not opened using our bug report form. ' + 'Issues have to be created through the form so that the details we need to investigate are included.\n\n' + - `If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n' + + `If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` + 'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' + `contributions are a violation of our [Code of Conduct](${codeOfConduct}).`; diff --git a/.github/workflows/pr-bot.yml b/.github/workflows/pr-bot.yml index 6223c6db8..f7d69039d 100644 --- a/.github/workflows/pr-bot.yml +++ b/.github/workflows/pr-bot.yml @@ -25,6 +25,10 @@ jobs: pr-bot: name: Automated PR Bot runs-on: ubuntu-latest + # Runs after Anti-slop so the welcome comment can see whether the PR was closed + # instead of racing it. Still runs if that job fails, so labeling is not lost. + needs: Anti-slop + if: ${{ !cancelled() }} permissions: contents: read pull-requests: write @@ -99,8 +103,25 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const pr = context.payload.pull_request; - const user = pr.user.login; + const user = context.payload.pull_request.user.login; + + // Re-read the PR: Anti-slop may have closed and labeled it after the webhook + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + + if (pr.state === 'closed') { + core.info('Skipping comment: PR is already closed'); + return; + } + + const labels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name)); + if (labels.includes('ai')) { + core.info('Skipping comment: PR is labeled ai'); + return; + } const { data: members } = await github.rest.orgs.listMembers({ org: 'paperless-ngx', From 7813375123b3a161c7929c1c3176646f583d3f06 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:39:22 -0700 Subject: [PATCH 19/68] Enhancement (QoL): surface externally-set options in Config UI (#13989) --- .../admin/config/config.component.html | 21 +++++++++++--- .../admin/config/config.component.spec.ts | 13 +++++++++ .../admin/config/config.component.ts | 12 +++++++- src-ui/src/app/data/paperless-config.ts | 1 + src/documents/tests/test_api_app_config.py | 29 ++++++++++++++++++- src/paperless/serialisers.py | 8 +++++ 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src-ui/src/app/components/admin/config/config.component.html b/src-ui/src/app/components/admin/config/config.component.html index e516ba941..31e9aecfa 100644 --- a/src-ui/src/app/components/admin/config/config.component.html +++ b/src-ui/src/app/components/admin/config/config.component.html @@ -23,17 +23,30 @@
-
+
{{option.title}}
+ @if (isExternallyConfigured(option.config_key)) { + @if (isSet(option.key)) { + Overrides external + } @else { + Set externally + } + } @if (isSet(option.key)) { - + @if (isExternallyConfigured(option.config_key)) { + + } @else { + + } }
diff --git a/src-ui/src/app/components/admin/config/config.component.spec.ts b/src-ui/src/app/components/admin/config/config.component.spec.ts index a99171f93..650761c7d 100644 --- a/src-ui/src/app/components/admin/config/config.component.spec.ts +++ b/src-ui/src/app/components/admin/config/config.component.spec.ts @@ -163,6 +163,19 @@ describe('ConfigComponent', () => { expect(component.configForm.get('barcodes_enabled').value).toBeNull() }) + it('should identify externally configured options', () => { + component.externallyConfiguredVariables = new Set([ + 'PAPERLESS_OCR_LANGUAGE', + ]) + + expect( + component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE') + ).toBeTruthy() + expect( + component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE') + ).toBeFalsy() + }) + it('should group options into sections within a category, or not', () => { const sections = component.getCategorySections(ConfigCategory.OCR) expect(sections).toEqual([null, ConfigSection.RemoteOCR]) diff --git a/src-ui/src/app/components/admin/config/config.component.ts b/src-ui/src/app/components/admin/config/config.component.ts index 0e3f432e2..570eb7d55 100644 --- a/src-ui/src/app/components/admin/config/config.component.ts +++ b/src-ui/src/app/components/admin/config/config.component.ts @@ -69,6 +69,7 @@ export class ConfigComponent public configForm = new FormGroup({}) public errors = {} + public externallyConfiguredVariables = new Set() get optionCategories(): string[] { return Object.values(ConfigCategory) @@ -152,6 +153,9 @@ export class ConfigComponent } private initialize(config: PaperlessConfig) { + this.externallyConfiguredVariables = new Set( + config.externally_configured_variables ?? [] + ) if (!this.store) { this.store = new BehaviorSubject(config) @@ -162,7 +166,9 @@ export class ConfigComponent this.configForm.patchValue(state, { emitEvent: false }) }) - this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable()) + this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), { + excludeKeys: ['externally_configured_variables'], + }) } this.configForm.patchValue(config) @@ -227,6 +233,10 @@ export class ConfigComponent return this.configForm.get(key).value != null } + public isExternallyConfigured(configKey: string): boolean { + return this.externallyConfiguredVariables.has(configKey) + } + public resetOption(key: string) { this.configForm.get(key).setValue(null) } diff --git a/src-ui/src/app/data/paperless-config.ts b/src-ui/src/app/data/paperless-config.ts index 92af2cec5..690d7aa4e 100644 --- a/src-ui/src/app/data/paperless-config.ts +++ b/src-ui/src/app/data/paperless-config.ts @@ -422,6 +422,7 @@ export const PaperlessConfigOptions: ConfigOption[] = [ ] export interface PaperlessConfig extends ObjectWithId { + externally_configured_variables: string[] output_type: OutputTypeConfig pages: number language: string diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index b98c0d7da..0ca6f4412 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -35,7 +35,8 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): THEN: - Existing config """ - response = self.client.get(self.ENDPOINT, format="json") + with patch.dict("os.environ", {}, clear=True): + response = self.client.get(self.ENDPOINT, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -45,6 +46,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): response.data[0], { "id": 1, + "externally_configured_variables": [], "output_type": None, "pages": None, "language": None, @@ -91,6 +93,31 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): }, ) + def test_api_get_config_reports_external_configuration_without_values(self) -> None: + with patch.dict( + "os.environ", + { + "PAPERLESS_OCR_LANGUAGE": "eng", + "PAPERLESS_REMOTE_OCR_API_KEY": "secret-value", + "PAPERLESS_FUTURE_SETTING": "future-value", + "UNRELATED_SETTING": "unrelated-value", + }, + clear=True, + ): + response = self.client.get(self.ENDPOINT, format="json") + + self.assertCountEqual( + response.data[0]["externally_configured_variables"], + [ + "PAPERLESS_FUTURE_SETTING", + "PAPERLESS_OCR_LANGUAGE", + "PAPERLESS_REMOTE_OCR_API_KEY", + ], + ) + self.assertNotContains(response, "secret-value") + self.assertNotContains(response, "future-value") + self.assertNotContains(response, "UNRELATED_SETTING") + def test_api_get_ui_settings_with_config(self) -> None: """ GIVEN: diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 38baf1be5..f7cfc3d45 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -1,4 +1,5 @@ import logging +import os from io import BytesIO import magic @@ -212,6 +213,7 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer[Use class ApplicationConfigurationSerializer( serializers.ModelSerializer[ApplicationConfiguration], ): + externally_configured_variables = serializers.SerializerMethodField() user_args = serializers.JSONField(binary=True, allow_null=True) barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True) llm_api_key = ObfuscatedPasswordField( @@ -227,6 +229,12 @@ class ApplicationConfigurationSerializer( OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key") + def get_externally_configured_variables( + self, + instance: ApplicationConfiguration, + ) -> list[str]: + return sorted(name for name in os.environ if name.startswith("PAPERLESS_")) + def run_validation(self, data): # Empty strings treated as None to avoid unexpected behavior if "user_args" in data and data["user_args"] == "": From e9141366bbc82793e38a9bb612b297ba8a1830d0 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:40:59 +0000 Subject: [PATCH 20/68] Auto translate strings --- src-ui/messages.xlf | 50 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index fa690d2d1..c85af9c32 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -501,15 +501,43 @@ 30 + + This value overrides , which is set outside Paperless. + + src/app/components/admin/config/config.component.html + 35 + + + + is set outside Paperless. Enter a value here to override it. + + src/app/components/admin/config/config.component.html + 37 + + + + Use the externally configured value + + src/app/components/admin/config/config.component.html + 42 + + + + Reset to external + + src/app/components/admin/config/config.component.html + 43 + + Reset src/app/components/admin/config/config.component.html - 34 + 46 src/app/components/admin/config/config.component.html - 35 + 47 src/app/components/admin/settings/settings.component.html @@ -520,7 +548,7 @@ Enable src/app/components/admin/config/config.component.html - 43 + 56 src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html @@ -531,7 +559,7 @@ Cancel src/app/components/admin/config/config.component.html - 67,68 + 80,81 src/app/components/admin/settings/settings.component.html @@ -610,7 +638,7 @@ Save src/app/components/admin/config/config.component.html - 70,71 + 83,84 src/app/components/admin/settings/settings.component.html @@ -681,42 +709,42 @@ Error retrieving config src/app/components/admin/config/config.component.ts - 117 + 118 Invalid JSON src/app/components/admin/config/config.component.ts - 143 + 144 Configuration updated src/app/components/admin/config/config.component.ts - 187 + 193 An error occurred updating configuration src/app/components/admin/config/config.component.ts - 192 + 198 File successfully updated src/app/components/admin/config/config.component.ts - 214 + 220 An error occurred uploading file src/app/components/admin/config/config.component.ts - 219 + 225 From 73e777a48c52829a34674549df9b0dc9579283f4 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:30:58 -0700 Subject: [PATCH 21/68] Fix: skip nested TagSerializer construction when a tag has no children (#14039) TagSerializer.get_children() built a full nested TagSerializer(many=True) for every tag, even when it had zero children, likely the common case for most tags and maybe even most installs. Constructing a DRF ModelSerializer isn't free (field introspection, deepcopy of declared fields, i18n lookups all re-run per instantiation), so this scaled GET /api/tags/ linearly with tag count in pure Python overhead, unrelated to SQL query count. --- src/documents/serialisers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index f84ee9922..94504fcd0 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -674,6 +674,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer): ordering = ordering or (Lower("name"),) children = children.order_by(*ordering) + if not children: + return [] + serializer = TagSerializer( children, many=True, From 714885d7a58735493807aae88b21299a0d1e7be0 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:32:41 +0000 Subject: [PATCH 22/68] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 55cd331a1..81d6b1a08 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-07 20:47+0000\n" +"POT-Creation-Date: 2026-09-08 15:31+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1631,49 +1631,49 @@ msgstr "" msgid "workflow runs" msgstr "" -#: documents/serialisers.py:524 documents/serialisers.py:878 -#: documents/serialisers.py:2838 documents/views.py:314 documents/views.py:2624 +#: documents/serialisers.py:524 documents/serialisers.py:881 +#: documents/serialisers.py:2841 documents/views.py:314 documents/views.py:2624 #: paperless_mail/serialisers.py:156 msgid "Insufficient permissions." msgstr "" -#: documents/serialisers.py:714 +#: documents/serialisers.py:717 msgid "Invalid color." msgstr "" -#: documents/serialisers.py:2315 +#: documents/serialisers.py:2318 #, python-format msgid "File type %(type)s not supported" msgstr "" -#: documents/serialisers.py:2359 +#: documents/serialisers.py:2362 #, python-format msgid "Custom field id must be an integer: %(id)s" msgstr "" -#: documents/serialisers.py:2366 +#: documents/serialisers.py:2369 #, python-format msgid "Custom field with id %(id)s does not exist" msgstr "" -#: documents/serialisers.py:2383 documents/serialisers.py:2393 +#: documents/serialisers.py:2386 documents/serialisers.py:2396 msgid "" "Custom fields must be a list of integers or an object mapping ids to values." msgstr "" -#: documents/serialisers.py:2388 +#: documents/serialisers.py:2391 msgid "Some custom fields don't exist or were specified twice." msgstr "" -#: documents/serialisers.py:2535 +#: documents/serialisers.py:2538 msgid "Invalid variable detected." msgstr "" -#: documents/serialisers.py:2894 +#: documents/serialisers.py:2897 msgid "Duplicate document identifiers are not allowed." msgstr "" -#: documents/serialisers.py:2924 documents/views.py:4626 +#: documents/serialisers.py:2927 documents/views.py:4626 #, python-format msgid "Documents not found: %(ids)s" msgstr "" From 5194f47291b89bb192d3c4cee5c4688f7900d67e Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:55:45 -0700 Subject: [PATCH 23/68] Performance: ensure version-aware content filters on querysets (#13792) --- src/documents/filters.py | 24 ++-- .../tests/test_api_document_versions.py | 117 ++++++++++++++---- src/documents/tests/test_api_search.py | 23 ++++ src/documents/versioning.py | 9 +- src/documents/views.py | 10 +- 5 files changed, 138 insertions(+), 45 deletions(-) diff --git a/src/documents/filters.py b/src/documents/filters.py index 4c2aa1ecf..5702a966d 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -12,7 +12,6 @@ from typing import TYPE_CHECKING from typing import Any from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError from django.db.models import Case from django.db.models import CharField from django.db.models import Count @@ -53,6 +52,7 @@ from documents.models import StoragePath from documents.models import Tag from documents.permissions import permitted_document_ids from documents.permissions import permitted_object_ids +from documents.versioning import annotate_effective_content if TYPE_CHECKING: from collections.abc import Callable @@ -182,14 +182,9 @@ class TitleContentFilter(Filter): logger.warning( "Deprecated document filter parameter 'title_content' used; use `text` instead.", ) - try: - return qs.filter( - Q(title__icontains=value) | Q(effective_content__icontains=value), - ) - except FieldError: - return qs.filter( - Q(title__icontains=value) | Q(content__icontains=value), - ) + return annotate_effective_content(qs).filter( + Q(title__icontains=value) | Q(effective_content__icontains=value), + ) else: return qs @@ -200,14 +195,9 @@ class EffectiveContentFilter(Filter): value = value.strip() if isinstance(value, str) else value if not value: return qs - try: - return qs.filter( - **{f"effective_content__{self.lookup_expr}": value}, - ) - except FieldError: - return qs.filter( - **{f"content__{self.lookup_expr}": value}, - ) + return annotate_effective_content(qs).filter( + **{f"effective_content__{self.lookup_expr}": value}, + ) @extend_schema_field(serializers.BooleanField) diff --git a/src/documents/tests/test_api_document_versions.py b/src/documents/tests/test_api_document_versions.py index 0ca39b6d0..f8edd8815 100644 --- a/src/documents/tests/test_api_document_versions.py +++ b/src/documents/tests/test_api_document_versions.py @@ -2,14 +2,12 @@ from __future__ import annotations import datetime from typing import TYPE_CHECKING -from unittest import TestCase from unittest import mock from auditlog.models import LogEntry # type: ignore[import-untyped] from django.contrib.auth.models import Permission 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.test import TestCase as DjangoTestCase from django.utils import timezone @@ -22,6 +20,7 @@ from documents.filters import TitleContentFilter from documents.models import Document from documents.tests.utils import DirectoriesMixin from documents.tests.utils import read_streaming_response +from documents.versioning import annotate_effective_content from documents.views import DocumentSelectionMixin if TYPE_CHECKING: @@ -892,32 +891,104 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase): ) -class TestVersionAwareFilters(TestCase): - def test_title_content_filter_falls_back_to_content(self) -> None: - queryset = mock.Mock() - fallback_queryset = mock.Mock() - queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset] +class TestVersionAwareFilters(DjangoTestCase): + """ + The filters annotate effective_content themselves rather than relying on + the caller's queryset carrying it, so they stay version-aware on a plain + Document queryset (e.g. the bulk-edit "select all matching" path). + """ - result = TitleContentFilter().filter(queryset, " latest ") + def setUp(self) -> None: + super().setUp() + self.root = Document.objects.create( + title="root", + checksum="root", + mime_type="application/pdf", + content="superseded-content", + ) + Document.objects.create( + title="version", + checksum="version", + mime_type="application/pdf", + root_document=self.root, + version_index=1, + content="latest-content", + ) + self.unversioned = Document.objects.create( + title="unversioned", + checksum="unversioned", + mime_type="application/pdf", + content="latest-content", + ) - self.assertIs(result, fallback_queryset) - self.assertEqual(queryset.filter.call_count, 2) - - def test_effective_content_filter_falls_back_to_content_lookup(self) -> None: - queryset = mock.Mock() - fallback_queryset = mock.Mock() - queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset] - - result = EffectiveContentFilter(lookup_expr="icontains").filter( - queryset, + def test_title_content_filter_matches_latest_version_content(self) -> None: + result = TitleContentFilter().filter( + Document.objects.filter(root_document__isnull=True), " latest ", ) - self.assertIs(result, fallback_queryset) - first_kwargs = queryset.filter.call_args_list[0].kwargs - second_kwargs = queryset.filter.call_args_list[1].kwargs - self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"}) - self.assertEqual(second_kwargs, {"content__icontains": "latest"}) + self.assertCountEqual( + [doc.id for doc in result], + [self.root.id, self.unversioned.id], + ) + + def test_effective_content_filter_matches_latest_version_content(self) -> None: + result = EffectiveContentFilter(lookup_expr="icontains").filter( + Document.objects.filter(root_document__isnull=True), + " latest ", + ) + + self.assertCountEqual( + [doc.id for doc in result], + [self.root.id, self.unversioned.id], + ) + + def test_effective_content_filter_ignores_superseded_content(self) -> None: + result = EffectiveContentFilter(lookup_expr="icontains").filter( + Document.objects.filter(root_document__isnull=True), + "superseded", + ) + + self.assertEqual(list(result), []) + + def test_filters_reuse_an_existing_annotation(self) -> None: + """ + Annotating twice under the same alias is an error, so an already + annotated queryset (the search path) has to be left alone. + """ + annotated = annotate_effective_content( + Document.objects.filter(root_document__isnull=True), + ) + self.assertIs(annotate_effective_content(annotated), annotated) + + result = EffectiveContentFilter(lookup_expr="icontains").filter( + annotated, + "latest", + ) + + self.assertCountEqual( + [doc.id for doc in result], + [self.root.id, self.unversioned.id], + ) + + def test_bulk_selection_does_not_match_superseded_content(self) -> None: + """ + Bulk edit's "select all matching" builds its own queryset, so before + the filters annotated for themselves it matched the root document's + superseded content -- selecting documents the list view, filtered by + the same term, does not show. + """ + user = User.objects.create_superuser(username="bulk_selection") + + selected = DocumentSelectionMixin()._resolve_document_ids( + user=user, + validated_data={ + "all": True, + "filters": {"content__icontains": "superseded"}, + }, + ) + + self.assertEqual(selected, []) def test_effective_content_filter_returns_input_for_empty_values(self) -> None: queryset = mock.Mock() diff --git a/src/documents/tests/test_api_search.py b/src/documents/tests/test_api_search.py index f4ebf6d97..7828457c3 100644 --- a/src/documents/tests/test_api_search.py +++ b/src/documents/tests/test_api_search.py @@ -1947,6 +1947,29 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase): self.assertEqual(len(response.data["documents"]), 1) self.assertEqual(response.data["documents"][0]["id"], title_match.id) + def test_global_search_returns_latest_version_content(self) -> None: + root = Document.objects.create( + title="bank statement", + content="superseded content", + checksum="GSV1", + pk=23, + ) + Document.objects.create( + title="bank statement v2", + content="latest content", + checksum="GSV2", + pk=24, + root_document=root, + version_index=1, + ) + + self.client.force_authenticate(self.user) + + response = self.client.get("/api/search/?query=bank&db_only=true") + self.assertEqual(response.status_code, status.HTTP_200_OK) + returned = {doc["id"]: doc["content"] for doc in response.data["documents"]} + self.assertEqual(returned.get(root.id), "latest content") + def test_global_search_filters_owned_mail_objects(self) -> None: user1 = User.objects.create_user("mail-search-user") user2 = User.objects.create_user("other-mail-search-user") diff --git a/src/documents/versioning.py b/src/documents/versioning.py index bba2495c8..758391a0b 100644 --- a/src/documents/versioning.py +++ b/src/documents/versioning.py @@ -27,10 +27,13 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]: def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]: """ - Annotates documents with the content of their newest version, falling back - to their own, so get_effective_content() can answer from the row rather - than querying for the versions of each document + Annotates documents with the content of their newest version unless the + queryset already carries the annotation, falling back to their own, so + get_effective_content() can answer from the row rather than querying for + the versions of each document. """ + if "effective_content" in documents.query.annotations: + return documents return documents.annotate( effective_content=Coalesce( Subquery( diff --git a/src/documents/views.py b/src/documents/views.py index 4cf775bd6..a65c048e3 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -232,6 +232,7 @@ from documents.tasks import train_classifier from documents.tasks import update_document_parent_tags from documents.utils import get_boolean from documents.versioning import VersionResolutionError +from documents.versioning import annotate_effective_content from documents.versioning import get_latest_version_for_root from documents.versioning import get_request_version_param from documents.versioning import get_root_document @@ -3632,8 +3633,13 @@ class GlobalSearchView(PassUserMixin): OBJECT_LIMIT = 3 docs = [] if request.user.has_perm("documents.view_document"): - all_docs = Document.objects.filter( - id__in=permitted_document_ids(request.user), + # Never more than OBJECT_LIMIT rows come back here, so annotating + # is cheap -- and without it these results show the root + # document's superseded content. + all_docs = annotate_effective_content( + Document.objects.filter( + id__in=permitted_document_ids(request.user), + ), ) if db_only: docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT] From b989b741401ad6a3dafbc565be2a28f08df6258e Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:57:05 +0000 Subject: [PATCH 24/68] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 40 +++++++++++++------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 81d6b1a08..b9f785cc1 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-08 15:31+0000\n" +"POT-Creation-Date: 2026-09-08 15:56+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -21,39 +21,39 @@ msgstr "" msgid "Documents" msgstr "" -#: documents/filters.py:473 +#: documents/filters.py:463 msgid "Value must be valid JSON." msgstr "" -#: documents/filters.py:492 +#: documents/filters.py:482 msgid "Invalid custom field query expression" msgstr "" -#: documents/filters.py:502 +#: documents/filters.py:492 msgid "Invalid expression list. Must be nonempty." msgstr "" -#: documents/filters.py:523 +#: documents/filters.py:513 msgid "Invalid logical operator {op!r}" msgstr "" -#: documents/filters.py:537 +#: documents/filters.py:527 msgid "Maximum number of query conditions exceeded." msgstr "" -#: documents/filters.py:601 +#: documents/filters.py:591 msgid "{name!r} is not a valid custom field." msgstr "" -#: documents/filters.py:638 +#: documents/filters.py:628 msgid "{data_type} does not support query expr {expr!r}." msgstr "" -#: documents/filters.py:757 documents/models.py:136 +#: documents/filters.py:747 documents/models.py:136 msgid "Maximum nesting depth exceeded." msgstr "" -#: documents/filters.py:1119 +#: documents/filters.py:1109 msgid "Custom field not found" msgstr "" @@ -1632,7 +1632,7 @@ msgid "workflow runs" msgstr "" #: documents/serialisers.py:524 documents/serialisers.py:881 -#: documents/serialisers.py:2841 documents/views.py:314 documents/views.py:2624 +#: documents/serialisers.py:2841 documents/views.py:315 documents/views.py:2625 #: paperless_mail/serialisers.py:156 msgid "Insufficient permissions." msgstr "" @@ -1673,7 +1673,7 @@ msgstr "" msgid "Duplicate document identifiers are not allowed." msgstr "" -#: documents/serialisers.py:2927 documents/views.py:4626 +#: documents/serialisers.py:2927 documents/views.py:4632 #, python-format msgid "Documents not found: %(ids)s" msgstr "" @@ -1941,36 +1941,36 @@ msgstr "" msgid "Unable to parse URI {value}" msgstr "" -#: documents/views.py:307 documents/views.py:2621 +#: documents/views.py:308 documents/views.py:2622 msgid "Invalid more_like_id" msgstr "" -#: documents/views.py:1591 +#: documents/views.py:1592 msgid "Invalid AI configuration." msgstr "" -#: documents/views.py:1602 +#: documents/views.py:1603 msgid "AI backend request timed out." msgstr "" -#: documents/views.py:2446 documents/views.py:2767 +#: documents/views.py:2447 documents/views.py:2768 msgid "Specify only one of text, title_search, query, or more_like_id." msgstr "" -#: documents/views.py:4639 +#: documents/views.py:4645 #, python-format msgid "Insufficient permissions to share document %(id)s." msgstr "" -#: documents/views.py:4685 +#: documents/views.py:4691 msgid "Bundle is already being processed." msgstr "" -#: documents/views.py:4749 +#: documents/views.py:4755 msgid "The share link bundle is still being prepared. Please try again later." msgstr "" -#: documents/views.py:4763 +#: documents/views.py:4769 msgid "The share link bundle is unavailable." msgstr "" From c40922440b1ac941f161d18ef57174c7f528f240 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:49:49 -0700 Subject: [PATCH 25/68] Fix: prevent overlapping mail-account processing runs (#14046) process_mail_accounts had no guard against a scheduled run still being in progress when the next one fires. Skip a run outright if another MAIL_FETCH task is already PENDING/STARTED. --- src/paperless_mail/tasks.py | 24 +++- .../test_process_mail_accounts_overlap.py | 134 ++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/paperless_mail/tests/test_process_mail_accounts_overlap.py diff --git a/src/paperless_mail/tasks.py b/src/paperless_mail/tasks.py index df1f30d91..fce516e32 100644 --- a/src/paperless_mail/tasks.py +++ b/src/paperless_mail/tasks.py @@ -1,7 +1,9 @@ import logging +from celery import Task from celery import shared_task +from documents.models import PaperlessTask from paperless_mail.mail import MailAccountHandler from paperless_mail.mail import MailError from paperless_mail.models import MailAccount @@ -10,8 +12,26 @@ from paperless_mail.models import MailRule logger = logging.getLogger("paperless.mail.tasks") -@shared_task -def process_mail_accounts(account_ids: list[int] | None = None) -> str: +@shared_task(bind=True) +def process_mail_accounts(self: Task, account_ids: list[int] | None = None) -> str: + # A scheduled check can still be running (or queued) when the next one + # ProcessedMail dedup only records a message once its + # handling has finished, so an overlapping run can still pick up the same + # not-yet-recorded message. Skip outright rather than race it. + other_mail_fetch_running = ( + PaperlessTask.objects.filter( + task_type=PaperlessTask.TaskType.MAIL_FETCH, + status__in=[PaperlessTask.Status.PENDING, PaperlessTask.Status.STARTED], + ) + .exclude(task_id=self.request.id) + .exists() + ) + if other_mail_fetch_running: + logger.info( + "Mail account processing is already running; skipping this run.", + ) + return "Skipped: mail account processing already in progress." + total_new_documents = 0 accounts = ( MailAccount.objects.filter(pk__in=account_ids) diff --git a/src/paperless_mail/tests/test_process_mail_accounts_overlap.py b/src/paperless_mail/tests/test_process_mail_accounts_overlap.py new file mode 100644 index 000000000..4ee60ed09 --- /dev/null +++ b/src/paperless_mail/tests/test_process_mail_accounts_overlap.py @@ -0,0 +1,134 @@ +from typing import Final + +import pytest +import pytest_mock + +from documents.models import PaperlessTask +from documents.tests.factories import PaperlessTaskFactory +from paperless_mail import tasks +from paperless_mail.tests.factories import MailAccountFactory +from paperless_mail.tests.factories import MailRuleFactory + +NO_DOCUMENTS_ADDED: Final = "No new documents were added." +SKIPPED: Final = "Skipped: mail account processing already in progress." + + +@pytest.mark.django_db +@pytest.mark.usefixtures("account_with_rule") +class TestProcessMailAccountsOverlap: + @pytest.fixture + def account_with_rule(self) -> None: + """An enabled mail account with a single enabled rule.""" + account = MailAccountFactory.create() + MailRuleFactory.create(account=account, enabled=True) + + @pytest.mark.parametrize( + ("status", "expected_result", "expected_call_count"), + [ + pytest.param( + PaperlessTask.Status.PENDING, + SKIPPED, + 0, + id="pending-task-blocks", + ), + pytest.param( + PaperlessTask.Status.STARTED, + SKIPPED, + 0, + id="started-task-blocks", + ), + pytest.param( + PaperlessTask.Status.SUCCESS, + NO_DOCUMENTS_ADDED, + 1, + id="finished-task-does-not-block", + ), + ], + ) + def test_skips_only_while_another_mail_fetch_task_runs( + self, + mocker: pytest_mock.MockerFixture, + status: PaperlessTask.Status, + expected_result: str, + expected_call_count: int, + ) -> None: + """ + GIVEN: + - An enabled mail account with a rule + - Another mail fetch task row in the given status + WHEN: + - Mail accounts are processed + THEN: + - Processing is skipped only if that other task is pending or running + """ + PaperlessTaskFactory.create( + task_type=PaperlessTask.TaskType.MAIL_FETCH, + trigger_source=PaperlessTask.TriggerSource.SCHEDULED, + status=status, + ) + + mocked_handle = mocker.patch.object( + tasks.MailAccountHandler, + "handle_mail_account", + return_value=0, + ) + + result = tasks.process_mail_accounts() + + assert mocked_handle.call_count == expected_call_count + assert result == expected_result + + def test_runs_when_no_other_mail_fetch_task_exists( + self, + mocker: pytest_mock.MockerFixture, + ) -> None: + """ + GIVEN: + - An enabled mail account with a rule + - No other mail fetch task rows + WHEN: + - Mail accounts are processed + THEN: + - The account is handled + """ + mocked_handle = mocker.patch.object( + tasks.MailAccountHandler, + "handle_mail_account", + return_value=0, + ) + + result = tasks.process_mail_accounts() + + mocked_handle.assert_called_once() + assert result == NO_DOCUMENTS_ADDED + + def test_does_not_skip_due_to_its_own_task_row( + self, + mocker: pytest_mock.MockerFixture, + ) -> None: + """ + GIVEN: + - An enabled mail account with a rule + - A running mail fetch task row belonging to this very task + WHEN: + - Mail accounts are processed under that task id + THEN: + - The task does not skip itself and handles the account + """ + PaperlessTaskFactory.create( + task_id="self-task-id", + task_type=PaperlessTask.TaskType.MAIL_FETCH, + trigger_source=PaperlessTask.TriggerSource.SCHEDULED, + status=PaperlessTask.Status.STARTED, + ) + + mocked_handle = mocker.patch.object( + tasks.MailAccountHandler, + "handle_mail_account", + return_value=0, + ) + + result = tasks.process_mail_accounts.apply(task_id="self-task-id").result + + mocked_handle.assert_called_once() + assert result == NO_DOCUMENTS_ADDED From 43a8d7d412b5caacee3f29a257ae3671a23d639a Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:15:15 -0700 Subject: [PATCH 26/68] Enhancement: add Tantivy full-text fallback adapter for taxonomy candidates (#13820) This brings users without an embedding backend configured to closer parity with those who do. Reuse the search backend to locate similar documents and use them to provide the LLM with the better suggestion pool to draw from --- src/paperless_ai/ai_classifier.py | 144 +++++--- src/paperless_ai/indexing.py | 17 - src/paperless_ai/taxonomy.py | 45 ++- src/paperless_ai/tests/test_ai_classifier.py | 328 +++++++++++++++++-- src/paperless_ai/tests/test_ai_indexing.py | 8 +- src/paperless_ai/tests/test_taxonomy.py | 64 ++-- 6 files changed, 470 insertions(+), 136 deletions(-) diff --git a/src/paperless_ai/ai_classifier.py b/src/paperless_ai/ai_classifier.py index 934736f40..a1911e6c1 100644 --- a/src/paperless_ai/ai_classifier.py +++ b/src/paperless_ai/ai_classifier.py @@ -4,21 +4,24 @@ from django.conf import settings from django.contrib.auth.models import User from documents.models import Document -from documents.permissions import get_objects_for_user_owner_aware +from documents.permissions import permitted_object_ids +from documents.permissions import restrict_queryset_to_visible +from documents.permissions import user_is_unrestricted from paperless.config import AIConfig from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import TaxonomyChoiceDict from paperless_ai.base_model import classification_suggestions_to_model from paperless_ai.client import AIClient from paperless_ai.db import db_connection_released -from paperless_ai.indexing import _node_document_ids from paperless_ai.indexing import retrieve_similar_nodes from paperless_ai.indexing import truncate_content from paperless_ai.prompts.context import ClassificationPromptContext from paperless_ai.prompts.context import LocalizationPromptContext from paperless_ai.prompts.context import RagContextPromptContext from paperless_ai.prompts.render import render_prompt +from paperless_ai.taxonomy import SimilarDocument from paperless_ai.taxonomy import TaxonomyCandidates +from paperless_ai.taxonomy import _node_document_weights from paperless_ai.taxonomy import build_taxonomy_candidates from paperless_ai.taxonomy import empty_taxonomy_candidates from paperless_ai.taxonomy import format_taxonomy_for_prompt @@ -37,6 +40,48 @@ logger = logging.getLogger("paperless_ai.rag_classifier") TAXONOMY_CANDIDATE_TOP_K = 15 +def _fulltext_similar_documents( + document: Document, + user: User | None, + top_k: int, +) -> list[SimilarDocument]: + """Rank-based fallback when no embedding backend is configured. Uses + Tantivy's "More Like This" (term-overlap similarity) instead of vector + similarity - cruder, but far better than no candidates at all. + more_like_this_ids returns only a ranked ID list, no scores, so weight is + synthesized from rank (descending from top_k) rather than claiming a + similarity magnitude that doesn't exist. An unrestricted user (none, or an + active superuser - see user_is_unrestricted) is normalized to ``None`` + before calling, since the backend's permission filter has no superuser + short-circuit of its own. Results are re-checked with + restrict_queryset_to_visible() since Tantivy's indexed permission fields + lag the DB via async reindexing. + """ + from documents.search import get_backend + + unrestricted = user_is_unrestricted(user) + search_user = None if unrestricted else user + backend = get_backend() + similar_ids = backend.more_like_this_ids( + document.pk, + user=search_user, + limit=top_k, + ) + if not unrestricted: + allowed_ids = set( + restrict_queryset_to_visible( + Document.objects.filter(pk__in=similar_ids), + user, + "view_document", + ).values_list("pk", flat=True), + ) + similar_ids = [doc_id for doc_id in similar_ids if doc_id in allowed_ids] + return [ + SimilarDocument(document_id=doc_id, weight=float(top_k - rank)) + for rank, doc_id in enumerate(similar_ids) + ] + + def get_language_name(language_code: str) -> str: normalized_language_code = language_code.lower() for code, name in settings.LANGUAGES: @@ -136,43 +181,52 @@ def get_taxonomy_context( user: User | None = None, max_docs: int = 5, ) -> tuple[TaxonomyCandidates, str]: - """One retrieval feeds both taxonomy candidates and RAG text context. - On any retrieval failure, degrades to empty candidates/context rather than - propagating the exception - a vector-store outage should not block - classification, only its RAG-assisted enrichment. + """One retrieval feeds both taxonomy candidates and RAG text context. Uses + vector similarity when an embedding backend is configured, otherwise + falls back to Tantivy full-text "More Like This" similarity - see + _fulltext_similar_documents. On any retrieval failure, degrades to empty + candidates/context rather than propagating the exception - neither a + vector-store outage nor a search-index issue should block classification, + only its context-assisted enrichment. """ + ai_config = AIConfig() try: - # None means "no restriction" to retrieve_similar_nodes. A superuser - # (like no user at all) can see every document, so skip materializing - # every visible pk into a Python list and passing it through as an IN - # filter: for a large library that is a wasted quadratic scan in the - # vector store at best, and past ~32,763 documents a hard - # sqlite3.OperationalError (SQLite's bound-parameter limit) at worst. - # get_objects_for_user_owner_aware() would return every Document for a - # superuser anyway (guardian's own with_superuser shortcut), so this - # changes nothing about which documents are considered -- only how we - # get there. - visible_document_ids = ( - None - if user is None or user.is_superuser - else list( - get_objects_for_user_owner_aware( - user, - "view_document", - Document, - ).values_list("pk", flat=True), + if ai_config.llm_embedding_backend: + # None means "no restriction" to retrieve_similar_nodes. An + # unrestricted user (no user at all, or an active superuser -- see + # user_is_unrestricted) can see every document, so skip + # materializing every visible pk into a Python list and passing it + # through as an IN filter: for a large library that is a wasted + # quadratic scan in the vector store at best, and past ~32,763 + # documents a hard sqlite3.OperationalError (SQLite's + # bound-parameter limit) at worst. + # permitted_object_ids() has its own superuser shortcut that would + # return every Document's id anyway, so this changes nothing about + # which documents are considered -- only how we get there. + visible_document_ids = ( + None + if user_is_unrestricted(user) + else list(permitted_object_ids(user, Document, "view_document")) + ) + nodes = retrieve_similar_nodes( + document, + top_k=TAXONOMY_CANDIDATE_TOP_K, + document_ids=visible_document_ids, + ) + similar_documents = _node_document_weights(nodes) + else: + # See _fulltext_similar_documents: it applies its own permission + # filter via `user`, so no visible-document-id list is needed here. + similar_documents = _fulltext_similar_documents( + document, + user, + top_k=TAXONOMY_CANDIDATE_TOP_K, ) - ) - nodes = retrieve_similar_nodes( - document, - top_k=TAXONOMY_CANDIDATE_TOP_K, - document_ids=visible_document_ids, - ) - candidates = build_taxonomy_candidates(nodes, user) + candidates = build_taxonomy_candidates(similar_documents, user) - # ``nodes`` are already ordered by descending vector similarity; don't lose it. - similar_document_ids = list(dict.fromkeys(_node_document_ids(nodes))) + # similar_documents is already ordered by descending weight; don't lose it. + similar_document_ids = [s["document_id"] for s in similar_documents] similar_documents_by_id = Document.objects.in_bulk(similar_document_ids) similar_docs = [ similar_documents_by_id[document_id] @@ -186,8 +240,8 @@ def get_taxonomy_context( context_blocks.append(f"TITLE: {title}\n{text}") except Exception: logger.exception( - "Failed to retrieve RAG neighbours for document %s; continuing " - "without taxonomy candidates or similar-document context.", + "Failed to retrieve similar-document context for document %s; " + "continuing without taxonomy candidates or similar-document context.", document.pk, ) return empty_taxonomy_candidates(), "" @@ -241,17 +295,13 @@ def get_ai_document_classification( ) -> ClassificationSuggestions: ai_config = AIConfig() - if ai_config.llm_embedding_backend: - candidates, context = get_taxonomy_context(document, user) - prompt = build_prompt_with_rag( - document, - ai_config, - candidates=candidates, - context=context, - ) - else: - candidates = empty_taxonomy_candidates() - prompt = build_prompt_without_rag(document, ai_config, candidates=candidates) + candidates, context = get_taxonomy_context(document, user) + prompt = build_prompt_with_rag( + document, + ai_config, + candidates=candidates, + context=context, + ) client = AIClient() # Hand the pooled DB connection back while the (slow) LLM query runs so it diff --git a/src/paperless_ai/indexing.py b/src/paperless_ai/indexing.py index af20669e5..07c20b526 100644 --- a/src/paperless_ai/indexing.py +++ b/src/paperless_ai/indexing.py @@ -721,20 +721,3 @@ def retrieve_similar_nodes( continue filtered.append(node) return filtered - - -def _node_document_ids(nodes: list["NodeWithScore"]) -> list[int]: - document_ids: list[int] = [] - for node in nodes: - document_id = node.metadata.get("document_id") - if document_id is None: # pragma: no cover - # See the matching guard in retrieve_similar_nodes() above. - continue - try: - document_ids.append(int(document_id)) - except ValueError: # pragma: no cover - logger.warning( - "Skipping LLM index result with invalid document_id %r.", - document_id, - ) - return document_ids diff --git a/src/paperless_ai/taxonomy.py b/src/paperless_ai/taxonomy.py index 79f07a7e1..4e3f39271 100644 --- a/src/paperless_ai/taxonomy.py +++ b/src/paperless_ai/taxonomy.py @@ -31,6 +31,11 @@ class TaxonomyCandidate(TypedDict): weight: float +class SimilarDocument(TypedDict): + document_id: int + weight: float + + class TaxonomyCandidates(TypedDict): tags: list[TaxonomyCandidate] document_types: list[TaxonomyCandidate] @@ -49,10 +54,10 @@ def empty_taxonomy_candidates() -> TaxonomyCandidates: ) -def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]: - """document_id -> that node's similarity score, summed if a document_id - appears more than once across the retrieved nodes (e.g. multiple chunks - of the same source document).""" +def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument]: + """Sum each node's similarity score into its document_id (a document can + appear via multiple chunks/nodes) and return one SimilarDocument per + distinct document_id.""" weights: dict[int, float] = defaultdict(float) for node in nodes: document_id = node.metadata.get("document_id") @@ -65,7 +70,14 @@ def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]: weights[int(document_id)] += float(node.score or 0.0) except (TypeError, ValueError): # pragma: no cover continue - return weights + return sorted( + ( + SimilarDocument(document_id=document_id, weight=weight) + for document_id, weight in weights.items() + ), + key=lambda similar: similar["weight"], + reverse=True, + ) def _visible_ranked_candidates( @@ -101,21 +113,26 @@ def _visible_ranked_candidates( def build_taxonomy_candidates( - nodes: list["NodeWithScore"], + similar_documents: list[SimilarDocument], user: User | None, ) -> TaxonomyCandidates: - """Resolve each neighbour node's document_id to a live Document, read its - *current* tags/type/correspondent/storage_path via the ORM (never the - possibly-stale names cached in vector-index node metadata), weight each - distinct taxonomy object by aggregate neighbour similarity, permission-filter + """Resolve each similar document's id to a live Document, read its + *current* tags/type/correspondent/storage_path via the ORM (never any + possibly-stale names an adapter's source might have cached), weight each + distinct taxonomy object by aggregate similarity weight, permission-filter against what ``user`` can see, and return each category ranked by weight - and capped. + and capped. ``similar_documents`` may come from either the vector-RAG + adapter or the full-text fallback adapter - both produce this same shape. """ - - document_weights = _node_document_weights(nodes) - if not document_weights: + if not similar_documents: return empty_taxonomy_candidates() + # Both adapters guarantee at most one SimilarDocument per document_id, so + # this never silently drops a duplicate's weight. + document_weights: dict[int, float] = { + s["document_id"]: s["weight"] for s in similar_documents + } + # Only .tags.all() needs prefetching (a reverse M2M, one extra query for # the whole batch). document_type/correspondent/storage_path are read # below via their *_id columns (neighbour.document_type_id, etc.), which diff --git a/src/paperless_ai/tests/test_ai_classifier.py b/src/paperless_ai/tests/test_ai_classifier.py index 6edfb38be..10342a305 100644 --- a/src/paperless_ai/tests/test_ai_classifier.py +++ b/src/paperless_ai/tests/test_ai_classifier.py @@ -1,4 +1,5 @@ import datetime +from collections.abc import Generator from types import SimpleNamespace from unittest.mock import MagicMock from unittest.mock import patch @@ -6,18 +7,24 @@ from unittest.mock import patch import pytest import pytest_mock from django.test import override_settings +from guardian.shortcuts import assign_perm +from guardian.shortcuts import remove_perm from documents.models import Document +from documents.search import TantivyBackend from documents.tests.factories import DocumentFactory from documents.tests.factories import TagFactory from documents.tests.factories import UserFactory from paperless.config import AIConfig +from paperless_ai.ai_classifier import TAXONOMY_CANDIDATE_TOP_K +from paperless_ai.ai_classifier import _fulltext_similar_documents from paperless_ai.ai_classifier import build_localization_prompt from paperless_ai.ai_classifier import build_prompt_with_rag from paperless_ai.ai_classifier import build_prompt_without_rag from paperless_ai.ai_classifier import get_ai_document_classification from paperless_ai.ai_classifier import get_language_name from paperless_ai.ai_classifier import get_taxonomy_context +from paperless_ai.taxonomy import SimilarDocument from paperless_ai.taxonomy import TaxonomyCandidate from paperless_ai.taxonomy import TaxonomyCandidates @@ -220,12 +227,10 @@ def test_use_rag_if_configured( @pytest.mark.django_db @patch("paperless_ai.client.AIClient.run_llm_query") -@patch("paperless_ai.ai_classifier.build_prompt_without_rag") -@patch("paperless_ai.ai_classifier.AIConfig") +@patch("paperless_ai.ai_classifier.build_prompt_with_rag") @override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model") -def test_use_without_rag_if_not_configured( - mock_ai_config, - mock_build_prompt_without_rag, +def test_use_rag_prompt_even_without_embedding_backend( + mock_build_prompt_with_rag, mock_run_llm_query, mock_document, ): @@ -235,13 +240,13 @@ def test_use_without_rag_if_not_configured( WHEN: - get_ai_document_classification() is called THEN: - - The non-RAG prompt builder is used + - The RAG-context prompt builder is still used (fed by the full-text + fallback's context/candidates instead of the vector store's) """ - mock_ai_config.return_value.llm_embedding_backend = None - mock_build_prompt_without_rag.return_value = "Prompt without RAG" + mock_build_prompt_with_rag.return_value = "Prompt with RAG" mock_run_llm_query.return_value = NESTED_SUGGESTIONS get_ai_document_classification(mock_document) - mock_build_prompt_without_rag.assert_called_once() + mock_build_prompt_with_rag.assert_called_once() @pytest.mark.django_db @@ -320,6 +325,7 @@ def test_build_localization_prompt_preserves_unicode_characters(): @pytest.mark.django_db +@override_settings(LLM_EMBEDDING_BACKEND="huggingface") def test_get_taxonomy_context_assembles_rag_text_and_candidates(): """ GIVEN: @@ -354,6 +360,7 @@ def test_get_taxonomy_context_assembles_rag_text_and_candidates(): @pytest.mark.django_db +@override_settings(LLM_EMBEDDING_BACKEND="huggingface") def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents(): """ GIVEN: @@ -424,6 +431,7 @@ def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents( @pytest.mark.django_db +@override_settings(LLM_EMBEDDING_BACKEND="huggingface") def test_get_taxonomy_context_no_similar_docs(): """ GIVEN: @@ -447,6 +455,67 @@ def test_get_taxonomy_context_no_similar_docs(): } +@pytest.mark.django_db +def test_get_taxonomy_context_uses_fulltext_fallback_when_no_embedding_backend( + mocker: pytest_mock.MockerFixture, +) -> None: + """ + GIVEN: + - No LLM embedding backend is configured (the default test settings) + WHEN: + - get_taxonomy_context() is called + THEN: + - _fulltext_similar_documents() is called with the document, the user + and TAXONOMY_CANDIDATE_TOP_K + - retrieve_similar_nodes() (the vector path) is never called + """ + document = DocumentFactory.create(content="Some content") + mock_fulltext = mocker.patch( + "paperless_ai.ai_classifier._fulltext_similar_documents", + return_value=[], + ) + mock_retrieve = mocker.patch("paperless_ai.ai_classifier.retrieve_similar_nodes") + + get_taxonomy_context(document, user=None) + + mock_fulltext.assert_called_once_with( + document, + None, + top_k=TAXONOMY_CANDIDATE_TOP_K, + ) + mock_retrieve.assert_not_called() + + +@pytest.mark.django_db +@override_settings(LLM_EMBEDDING_BACKEND="huggingface") +def test_get_taxonomy_context_uses_vector_path_when_embedding_backend_configured( + mocker: pytest_mock.MockerFixture, +) -> None: + """ + GIVEN: + - An LLM embedding backend is configured + WHEN: + - get_taxonomy_context() is called + THEN: + - retrieve_similar_nodes() (the vector path) is called + - _fulltext_similar_documents() (the no-embedding-backend fallback) + is never called + """ + document = DocumentFactory.create(content="Some content") + mock_retrieve = mocker.patch( + "paperless_ai.ai_classifier.retrieve_similar_nodes", + return_value=[], + ) + mock_fulltext = mocker.patch( + "paperless_ai.ai_classifier._fulltext_similar_documents", + ) + + get_taxonomy_context(document, user=None) + + mock_retrieve.assert_called_once() + mock_fulltext.assert_not_called() + + class TestGetTaxonomyContextVisibility: """get_taxonomy_context must not materialize every visible document id for a user who can already see the whole library: a superuser (like no @@ -459,6 +528,7 @@ class TestGetTaxonomyContextVisibility: """ @pytest.mark.django_db + @override_settings(LLM_EMBEDDING_BACKEND="huggingface") def test_skips_permission_lookup_for_superuser( self, mocker: pytest_mock.MockerFixture, @@ -477,17 +547,18 @@ class TestGetTaxonomyContextVisibility: "paperless_ai.ai_classifier.retrieve_similar_nodes", return_value=[], ) - mock_get_objects = mocker.patch( - "paperless_ai.ai_classifier.get_objects_for_user_owner_aware", + mock_permitted = mocker.patch( + "paperless_ai.ai_classifier.permitted_object_ids", ) user = UserFactory.create(is_superuser=True) get_taxonomy_context(document, user) - mock_get_objects.assert_not_called() + mock_permitted.assert_not_called() assert mock_retrieve.call_args.kwargs["document_ids"] is None @pytest.mark.django_db + @override_settings(LLM_EMBEDDING_BACKEND="huggingface") def test_skips_permission_lookup_when_no_user( self, mocker: pytest_mock.MockerFixture, @@ -506,16 +577,17 @@ class TestGetTaxonomyContextVisibility: "paperless_ai.ai_classifier.retrieve_similar_nodes", return_value=[], ) - mock_get_objects = mocker.patch( - "paperless_ai.ai_classifier.get_objects_for_user_owner_aware", + mock_permitted = mocker.patch( + "paperless_ai.ai_classifier.permitted_object_ids", ) get_taxonomy_context(document, None) - mock_get_objects.assert_not_called() + mock_permitted.assert_not_called() assert mock_retrieve.call_args.kwargs["document_ids"] is None @pytest.mark.django_db + @override_settings(LLM_EMBEDDING_BACKEND="huggingface") def test_restricts_to_visible_documents_for_non_superuser( self, mocker: pytest_mock.MockerFixture, @@ -526,7 +598,7 @@ class TestGetTaxonomyContextVisibility: WHEN: - get_taxonomy_context() is called THEN: - - The user's visible document ids are looked up and passed to + - The user's permitted document ids are looked up and passed to retrieve_similar_nodes() as a restriction """ document = DocumentFactory.create(content="Some content") @@ -534,21 +606,232 @@ class TestGetTaxonomyContextVisibility: "paperless_ai.ai_classifier.retrieve_similar_nodes", return_value=[], ) - mock_queryset = mocker.MagicMock() - mock_queryset.values_list.return_value = [1, 2, 3] - mock_get_objects = mocker.patch( - "paperless_ai.ai_classifier.get_objects_for_user_owner_aware", - return_value=mock_queryset, + mock_permitted = mocker.patch( + "paperless_ai.ai_classifier.permitted_object_ids", + return_value=[1, 2, 3], ) user = UserFactory.create(is_superuser=False) get_taxonomy_context(document, user) - mock_get_objects.assert_called_once_with(user, "view_document", Document) + mock_permitted.assert_called_once_with(user, Document, "view_document") assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3] @pytest.mark.django_db +class TestFulltextSimilarDocuments: + """_fulltext_similar_documents is the no-embedding-backend fallback: it + asks the Tantivy full-text index for "More Like This" neighbours instead + of the vector store, and synthesizes a rank-based weight since Tantivy's + more_like_this_ids returns only an ordered id list, no scores. + """ + + @pytest.fixture + def fulltext_backend( + self, + mocker: pytest_mock.MockerFixture, + ) -> Generator[TantivyBackend, None, None]: + """An in-memory Tantivy backend, wired up as the module-level + singleton _fulltext_similar_documents resolves via get_backend().""" + backend = TantivyBackend(path=None) + backend.open() + mocker.patch("documents.search.get_backend", return_value=backend) + try: + yield backend + finally: + backend.close() + + def test_ranks_by_rank_based_weight_descending( + self, + fulltext_backend: TantivyBackend, + ) -> None: + """ + GIVEN: + - A source document and two similar documents indexed in Tantivy + WHEN: + - _fulltext_similar_documents() is called + THEN: + - Each result's weight reflects its rank (first result weighted + higher than the second), not a raw similarity score + """ + source = DocumentFactory.create(content="quarterly financial report details") + first = DocumentFactory.create(content="quarterly financial report details") + second = DocumentFactory.create(content="financial report") + for doc in (source, first, second): + fulltext_backend.add_or_update(doc) + + result = _fulltext_similar_documents(source, user=None, top_k=5) + + assert len(result) == 2 + weight_by_id = {s["document_id"]: s["weight"] for s in result} + assert weight_by_id[first.pk] > weight_by_id[second.pk] + + def test_excludes_source_document( + self, + fulltext_backend: TantivyBackend, + ) -> None: + """ + GIVEN: + - A source document indexed in Tantivy with no other documents + WHEN: + - _fulltext_similar_documents() is called + THEN: + - An empty list is returned - the source document is never its + own similar document + """ + source = DocumentFactory.create(content="unique unrelated content") + fulltext_backend.add_or_update(source) + + result = _fulltext_similar_documents(source, user=None, top_k=5) + + assert result == [] + + def test_empty_index_returns_empty_list( + self, + fulltext_backend: TantivyBackend, + ) -> None: + """ + GIVEN: + - A document that has never been indexed (fresh/empty Tantivy index) + WHEN: + - _fulltext_similar_documents() is called + THEN: + - An empty list is returned rather than raising + """ + source = DocumentFactory.create(content="never indexed") + + result = _fulltext_similar_documents(source, user=None, top_k=5) + + assert result == [] + + def test_respects_top_k_limit( + self, + fulltext_backend: TantivyBackend, + ) -> None: + """ + GIVEN: + - A source document and four similar documents indexed + WHEN: + - _fulltext_similar_documents() is called with top_k=2 + THEN: + - At most 2 results are returned + """ + source = DocumentFactory.create(content="shared overlapping keyword text") + fulltext_backend.add_or_update(source) + for _ in range(4): + fulltext_backend.add_or_update( + DocumentFactory.create(content="shared overlapping keyword text"), + ) + + result = _fulltext_similar_documents(source, user=None, top_k=2) + + assert len(result) == 2 + + def test_result_shape_is_similar_document( + self, + fulltext_backend: TantivyBackend, + ) -> None: + """ + GIVEN: + - A source document and one similar document indexed + WHEN: + - _fulltext_similar_documents() is called + THEN: + - Each result is a SimilarDocument (document_id + weight only) + """ + source = DocumentFactory.create(content="shared content phrase") + other = DocumentFactory.create(content="shared content phrase") + fulltext_backend.add_or_update(source) + fulltext_backend.add_or_update(other) + + result = _fulltext_similar_documents(source, user=None, top_k=5) + + # rank 0 (the only/best result) with top_k=5 -> weight = top_k - rank = 5.0, + # per the "first result gets top_k, the last gets 1" formula. + assert result == [SimilarDocument(document_id=other.pk, weight=5.0)] + + def test_superuser_sees_other_users_documents( + self, + fulltext_backend: TantivyBackend, + ) -> None: + """ + GIVEN: + - A source document owned by one user and a similar document + owned by a different user, with no sharing between them + WHEN: + - _fulltext_similar_documents() is called with a superuser + THEN: + - The other user's document is still returned as a similar + document - a superuser must not be narrowed by the backend's + owner-based permission filter + """ + owner = UserFactory.create() + other_owner = UserFactory.create() + superuser = UserFactory.create(is_superuser=True) + source = DocumentFactory.create( + content="shared content phrase", + owner=owner, + ) + other = DocumentFactory.create( + content="shared content phrase", + owner=other_owner, + ) + fulltext_backend.add_or_update(source) + fulltext_backend.add_or_update(other) + + result = _fulltext_similar_documents(source, user=superuser, top_k=5) + + assert [s["document_id"] for s in result] == [other.pk] + + def test_excludes_stale_permitted_document_for_regular_user( + self, + fulltext_backend: TantivyBackend, + ) -> None: + """ + GIVEN: + - A regular (non-superuser) user + - A similar document the user is permitted to view, and another + similar document indexed while the user still had view + permission but which has since had that permission revoked in + the database, i.e. the Tantivy index has stale permission data + WHEN: + - _fulltext_similar_documents() is called with that user + THEN: + - Only the still-permitted document is returned - the DB + re-check via restrict_queryset_to_visible() must catch the + document Tantivy's stale index still thinks is visible + """ + owner = UserFactory.create() + viewer = UserFactory.create(is_superuser=False) + source = DocumentFactory.create( + content="shared content phrase", + owner=owner, + ) + permitted = DocumentFactory.create( + content="shared content phrase", + owner=owner, + ) + now_private = DocumentFactory.create( + content="shared content phrase", + owner=owner, + ) + assign_perm("view_document", viewer, permitted) + assign_perm("view_document", viewer, now_private) + fulltext_backend.add_or_update(source) + fulltext_backend.add_or_update(permitted) + fulltext_backend.add_or_update(now_private) + + # Revoke access after indexing, without reindexing: the index still + # carries viewer as a permitted viewer for `now_private`. + remove_perm("view_document", viewer, now_private) + + result = _fulltext_similar_documents(source, user=viewer, top_k=5) + + assert [s["document_id"] for s in result] == [permitted.pk] + + +@pytest.mark.django_db +@override_settings(LLM_EMBEDDING_BACKEND="huggingface") @patch("paperless_ai.ai_classifier.retrieve_similar_nodes") def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve): """ @@ -575,6 +858,7 @@ def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrie @pytest.mark.django_db +@override_settings(LLM_EMBEDDING_BACKEND="huggingface") @patch("paperless_ai.ai_classifier.build_taxonomy_candidates") @patch("paperless_ai.ai_classifier.retrieve_similar_nodes") def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints( diff --git a/src/paperless_ai/tests/test_ai_indexing.py b/src/paperless_ai/tests/test_ai_indexing.py index 659eaa2f5..3866e6345 100644 --- a/src/paperless_ai/tests/test_ai_indexing.py +++ b/src/paperless_ai/tests/test_ai_indexing.py @@ -1188,9 +1188,7 @@ class TestRetrieveSimilarNodesAgainstRealIndex: nodes = indexing.retrieve_similar_nodes(a, document_ids=[b.id]) - assert all( - document_id == b.id for document_id in indexing._node_document_ids(nodes) - ) + assert all(int(node.metadata["document_id"]) == b.id for node in nodes) def test_excludes_self( self, @@ -1212,7 +1210,7 @@ class TestRetrieveSimilarNodesAgainstRealIndex: nodes = indexing.retrieve_similar_nodes(a, top_k=5) - assert set(indexing._node_document_ids(nodes)) == {b.id} + assert {int(node.metadata["document_id"]) for node in nodes} == {b.id} def test_excludes_self_with_multiple_chunks( self, @@ -1235,4 +1233,4 @@ class TestRetrieveSimilarNodesAgainstRealIndex: nodes = indexing.retrieve_similar_nodes(a, top_k=3) - assert set(indexing._node_document_ids(nodes)) == {b.id} + assert {int(node.metadata["document_id"]) for node in nodes} == {b.id} diff --git a/src/paperless_ai/tests/test_taxonomy.py b/src/paperless_ai/tests/test_taxonomy.py index e313015f9..18d81135f 100644 --- a/src/paperless_ai/tests/test_taxonomy.py +++ b/src/paperless_ai/tests/test_taxonomy.py @@ -1,5 +1,4 @@ import json -from types import SimpleNamespace import pytest import pytest_mock @@ -10,14 +9,14 @@ from documents.tests.factories import DocumentTypeFactory from documents.tests.factories import StoragePathFactory from documents.tests.factories import TagFactory from documents.tests.factories import UserFactory +from paperless_ai.taxonomy import SimilarDocument from paperless_ai.taxonomy import TaxonomyCandidates from paperless_ai.taxonomy import build_taxonomy_candidates from paperless_ai.taxonomy import format_taxonomy_for_prompt -def make_node(document_id: int, score: float) -> SimpleNamespace: - """A stand-in for NodeWithScore: only ``.metadata``/``.score`` are read.""" - return SimpleNamespace(metadata={"document_id": str(document_id)}, score=score) +def make_similar(document_id: int, weight: float) -> SimilarDocument: + return SimilarDocument(document_id=document_id, weight=weight) @pytest.mark.django_db @@ -53,9 +52,9 @@ class TestBuildTaxonomyCandidates: doc_a.tags.add(tag) doc_b = DocumentFactory.create() doc_b.tags.add(tag) - nodes = [make_node(doc_a.pk, 0.9), make_node(doc_b.pk, 0.4)] + similar_documents = [make_similar(doc_a.pk, 0.9), make_similar(doc_b.pk, 0.4)] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert len(result["tags"]) == 1 assert result["tags"][0]["id"] == tag.pk @@ -80,9 +79,9 @@ class TestBuildTaxonomyCandidates: document.tags.add(tag) tag.name = "New Name" tag.save() - nodes = [make_node(document.pk, 0.5)] + similar_documents = [make_similar(document.pk, 0.5)] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert result["tags"][0]["name"] == "New Name" @@ -102,9 +101,9 @@ class TestBuildTaxonomyCandidates: document = DocumentFactory.create() document.tags.add(tag) tag.delete() - nodes = [make_node(document.pk, 0.5)] + similar_documents = [make_similar(document.pk, 0.5)] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert result["tags"] == [] @@ -123,9 +122,12 @@ class TestBuildTaxonomyCandidates: strong_doc.tags.add(strong_tag) weak_doc = DocumentFactory.create() weak_doc.tags.add(weak_tag) - nodes = [make_node(strong_doc.pk, 0.9), make_node(weak_doc.pk, 0.1)] + similar_documents = [ + make_similar(strong_doc.pk, 0.9), + make_similar(weak_doc.pk, 0.1), + ] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"] @@ -141,9 +143,9 @@ class TestBuildTaxonomyCandidates: document = DocumentFactory.create() for i in range(15): document.tags.add(TagFactory.create(name=f"Tag{i}")) - nodes = [make_node(document.pk, 0.5)] + similar_documents = [make_similar(document.pk, 0.5)] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert len(result["tags"]) == 10 @@ -157,12 +159,12 @@ class TestBuildTaxonomyCandidates: - Only 5 correspondents are returned """ correspondents = CorrespondentFactory.create_batch(7) - nodes = [ - make_node(DocumentFactory.create(correspondent=c).pk, 0.5) + similar_documents = [ + make_similar(DocumentFactory.create(correspondent=c).pk, 0.5) for c in correspondents ] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert len(result["correspondents"]) == 5 @@ -177,9 +179,9 @@ class TestBuildTaxonomyCandidates: """ document_type = DocumentTypeFactory.create(name="Invoice") document = DocumentFactory.create(document_type=document_type) - nodes = [make_node(document.pk, 0.5)] + similar_documents = [make_similar(document.pk, 0.5)] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert len(result["document_types"]) == 1 assert result["document_types"][0]["id"] == document_type.pk @@ -195,12 +197,12 @@ class TestBuildTaxonomyCandidates: - Only 5 document_types are returned """ document_types = DocumentTypeFactory.create_batch(7) - nodes = [ - make_node(DocumentFactory.create(document_type=dt).pk, 0.5) + similar_documents = [ + make_similar(DocumentFactory.create(document_type=dt).pk, 0.5) for dt in document_types ] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert len(result["document_types"]) == 5 @@ -215,9 +217,9 @@ class TestBuildTaxonomyCandidates: """ storage_path = StoragePathFactory.create(name="Invoices") document = DocumentFactory.create(storage_path=storage_path) - nodes = [make_node(document.pk, 0.5)] + similar_documents = [make_similar(document.pk, 0.5)] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert len(result["storage_paths"]) == 1 assert result["storage_paths"][0]["id"] == storage_path.pk @@ -233,12 +235,12 @@ class TestBuildTaxonomyCandidates: - Only 5 storage_paths are returned """ storage_paths = StoragePathFactory.create_batch(7) - nodes = [ - make_node(DocumentFactory.create(storage_path=sp).pk, 0.5) + similar_documents = [ + make_similar(DocumentFactory.create(storage_path=sp).pk, 0.5) for sp in storage_paths ] - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert len(result["storage_paths"]) == 5 @@ -258,14 +260,14 @@ class TestBuildTaxonomyCandidates: tag = TagFactory.create(name="Restricted") document = DocumentFactory.create() document.tags.add(tag) - nodes = [make_node(document.pk, 0.5)] + similar_documents = [make_similar(document.pk, 0.5)] user = UserFactory.create() mocker.patch( "documents.permissions.permitted_object_ids", return_value=[], # user cannot see this tag ) - result = build_taxonomy_candidates(nodes, user=user) + result = build_taxonomy_candidates(similar_documents, user=user) assert result["tags"] == [] @@ -295,10 +297,10 @@ class TestBuildTaxonomyCandidates: tag.save() document = DocumentFactory.create() document.tags.add(tag) - nodes = [make_node(document.pk, 0.5)] + similar_documents = [make_similar(document.pk, 0.5)] spy = mocker.patch("documents.permissions.permitted_object_ids") - result = build_taxonomy_candidates(nodes, user=None) + result = build_taxonomy_candidates(similar_documents, user=None) assert result["tags"][0]["name"] == "Owned" spy.assert_not_called() From 8d1bc5dd24dd6bd5ed5a702e84b8e2c140b16671 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:48:59 -0700 Subject: [PATCH 27/68] Fix: prevent orphaned versions from bulk delete (#14030) --- src/documents/bulk_edit.py | 3 +- src/documents/models.py | 12 ++++- src/documents/tests/test_api_trash.py | 62 ++++++++++++++++++++++ src/documents/tests/test_bulk_edit.py | 5 ++ src/documents/tests/test_document_model.py | 6 ++- src/documents/views.py | 15 +++++- 6 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 4053ee8cb..79a79e6cf 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import tempfile +import uuid from pathlib import Path from typing import TYPE_CHECKING from typing import Literal @@ -379,7 +380,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]: ) delete_ids = list({*doc_ids, *version_ids}) - Document.objects.filter(id__in=delete_ids).delete() + Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4()) from documents.search import get_backend diff --git a/src/documents/models.py b/src/documents/models.py index 0cc4decda..521f43b6a 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1,4 +1,5 @@ import datetime +import uuid from pathlib import Path from typing import Final @@ -514,13 +515,20 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager- def delete( self, *args, + transaction_id=None, **kwargs, ): - # If deleting a root document, move all its versions to trash as well. + # Versions must share the root's transaction ID so they are restored + # together by django-softdelete. + if transaction_id is None: + transaction_id = uuid.uuid4() if self.root_document_id is None: - Document.objects.filter(root_document=self).delete() + Document.objects.filter(root_document=self).delete( + transaction_id=transaction_id, + ) return super().delete( *args, + transaction_id=transaction_id, **kwargs, ) diff --git a/src/documents/tests/test_api_trash.py b/src/documents/tests/test_api_trash.py index 420b7727d..1d55f61aa 100644 --- a/src/documents/tests/test_api_trash.py +++ b/src/documents/tests/test_api_trash.py @@ -207,3 +207,65 @@ class TestTrashAPI(DirectoriesMixin, APITestCase): ) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("have not yet been deleted", resp.data["documents"][0]) + + def _make_versioned_document(self) -> tuple[Document, list[Document]]: + root = Document.objects.create( + title="root", + content="root-content", + checksum="root", + mime_type="application/pdf", + ) + versions = [ + Document.objects.create( + title=f"v{index}", + content=f"v{index}-content", + checksum=f"v{index}", + mime_type="application/pdf", + root_document=root, + version_index=index, + ) + for index in range(1, 3) + ] + return root, versions + + def test_api_trash_restore_document_restores_its_versions(self) -> None: + """ + GIVEN: + - Existing document with two versions + WHEN: + - API request to delete the document + - API request to restore it from the trash + THEN: + - Only the document itself is listed in the trash + - A version cannot be restored without its root + - The document is restored together with all of its versions + """ + root, versions = self._make_versioned_document() + + self.client.force_login(user=self.user) + self.client.delete(f"/api/documents/{root.pk}/") + self.assertEqual(Document.deleted_objects.count(), 3) + + resp = self.client.get("/api/trash/") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(resp.data["count"], 1) + self.assertEqual(resp.data["results"][0]["id"], root.pk) + + # A version cannot be restored while its root remains in the trash. + resp = self.client.post( + "/api/trash/", + {"action": "restore", "documents": [versions[0].pk]}, + ) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("Restore the root document", resp.data["documents"][0]) + + resp = self.client.post( + "/api/trash/", + {"action": "restore", "documents": [root.pk]}, + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + self.assertEqual(Document.deleted_objects.count(), 0) + self.assertCountEqual( + Document.objects.filter(root_document=root).values_list("id", flat=True), + [version.pk for version in versions], + ) diff --git a/src/documents/tests/test_bulk_edit.py b/src/documents/tests/test_bulk_edit.py index 6063a9bbc..1315dd84b 100644 --- a/src/documents/tests/test_bulk_edit.py +++ b/src/documents/tests/test_bulk_edit.py @@ -392,6 +392,11 @@ class TestBulkEdit(DirectoriesMixin, TestCase): self.assertFalse(Document.objects.filter(id=self.doc1.id).exists()) self.assertFalse(Document.objects.filter(id=version.id).exists()) + Document.deleted_objects.get(id=self.doc1.id).restore(strict=False) + + self.assertTrue(Document.objects.filter(id=self.doc1.id).exists()) + self.assertTrue(Document.objects.filter(id=version.id).exists()) + def test_delete_version_document_keeps_root(self) -> None: version = Document.objects.create( checksum="A-v1", diff --git a/src/documents/tests/test_document_model.py b/src/documents/tests/test_document_model.py index 7102db238..a6fb0b3f6 100644 --- a/src/documents/tests/test_document_model.py +++ b/src/documents/tests/test_document_model.py @@ -110,7 +110,7 @@ class TestDocument(TestCase): checksum="checksum", mime_type="application/pdf", ) - Document.objects.create( + version = Document.objects.create( root_document=root, correspondent=root.correspondent, title="Version", @@ -124,6 +124,10 @@ class TestDocument(TestCase): self.assertEqual(Document.objects.count(), 0) self.assertEqual(Document.deleted_objects.count(), 2) + root.restore(strict=False) + + self.assertTrue(Document.objects.filter(pk=version.pk).exists()) + def test_file_name(self) -> None: doc = Document( mime_type="application/pdf", diff --git a/src/documents/views.py b/src/documents/views.py index a65c048e3..3296ca753 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -5437,7 +5437,10 @@ class TrashView(ListModelMixin, PassUserMixin): model = Document - queryset = Document.deleted_objects.all() + # A version is listed separately only when its root is not in the trash. + queryset = Document.deleted_objects.exclude( + root_document_id__in=Document.deleted_objects.values("id"), + ) def get(self, request: Request, format: str | None = None) -> Response: self.serializer_class = DocumentSerializer @@ -5468,7 +5471,15 @@ class TrashView(ListModelMixin, PassUserMixin): return HttpResponseForbidden("Insufficient permissions") action = serializer.validated_data.get("action") if action == "restore": - restored = list(Document.deleted_objects.filter(id__in=doc_ids)) + restored = list(self.get_queryset().filter(id__in=doc_ids)) + if len(restored) != len(doc_ids): + raise ValidationError( + { + "documents": [ + "Restore the root document instead of one of its versions.", + ], + }, + ) for doc in restored: doc.restore(strict=False) if restored: From 7d67a10a35ac3c3bb080b5bb7e631a9813a70306 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:50:33 +0000 Subject: [PATCH 28/68] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 784 ++++++++++++------------- 1 file changed, 392 insertions(+), 392 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index b9f785cc1..0654a552f 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-08 15:56+0000\n" +"POT-Creation-Date: 2026-09-09 15:49+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -49,7 +49,7 @@ msgstr "" msgid "{data_type} does not support query expr {expr!r}." msgstr "" -#: documents/filters.py:747 documents/models.py:136 +#: documents/filters.py:747 documents/models.py:137 msgid "Maximum nesting depth exceeded." msgstr "" @@ -57,1577 +57,1577 @@ msgstr "" msgid "Custom field not found" msgstr "" -#: documents/models.py:39 documents/models.py:998 documents/models.py:1046 +#: documents/models.py:40 documents/models.py:1006 documents/models.py:1054 msgid "owner" msgstr "" -#: documents/models.py:56 documents/models.py:1344 +#: documents/models.py:57 documents/models.py:1352 msgid "None" msgstr "" -#: documents/models.py:57 documents/models.py:1345 +#: documents/models.py:58 documents/models.py:1353 msgid "Any word" msgstr "" -#: documents/models.py:58 documents/models.py:1346 +#: documents/models.py:59 documents/models.py:1354 msgid "All words" msgstr "" -#: documents/models.py:59 documents/models.py:1347 +#: documents/models.py:60 documents/models.py:1355 msgid "Exact match" msgstr "" -#: documents/models.py:60 documents/models.py:1348 +#: documents/models.py:61 documents/models.py:1356 msgid "Regular expression" msgstr "" -#: documents/models.py:61 documents/models.py:1349 +#: documents/models.py:62 documents/models.py:1357 msgid "Fuzzy word" msgstr "" -#: documents/models.py:62 +#: documents/models.py:63 msgid "Automatic" msgstr "" -#: documents/models.py:65 documents/models.py:611 documents/models.py:1970 +#: documents/models.py:66 documents/models.py:619 documents/models.py:1978 #: paperless_mail/models.py:23 paperless_mail/models.py:143 msgid "name" msgstr "" -#: documents/models.py:67 documents/models.py:1413 +#: documents/models.py:68 documents/models.py:1421 msgid "match" msgstr "" -#: documents/models.py:70 documents/models.py:1416 +#: documents/models.py:71 documents/models.py:1424 msgid "matching algorithm" msgstr "" -#: documents/models.py:75 documents/models.py:1421 +#: documents/models.py:76 documents/models.py:1429 msgid "is insensitive" msgstr "" -#: documents/models.py:98 documents/models.py:166 +#: documents/models.py:99 documents/models.py:167 msgid "correspondent" msgstr "" -#: documents/models.py:99 +#: documents/models.py:100 msgid "correspondents" msgstr "" -#: documents/models.py:103 +#: documents/models.py:104 msgid "color" msgstr "" -#: documents/models.py:108 +#: documents/models.py:109 msgid "is inbox tag" msgstr "" -#: documents/models.py:111 +#: documents/models.py:112 msgid "" "Marks this tag as an inbox tag: All newly consumed documents will be tagged " "with inbox tags." msgstr "" -#: documents/models.py:117 +#: documents/models.py:118 msgid "tag" msgstr "" -#: documents/models.py:118 documents/models.py:213 +#: documents/models.py:119 documents/models.py:214 msgid "tags" msgstr "" -#: documents/models.py:124 +#: documents/models.py:125 msgid "Cannot set itself as parent." msgstr "" -#: documents/models.py:126 +#: documents/models.py:127 msgid "Cannot set parent to a descendant." msgstr "" -#: documents/models.py:143 documents/models.py:186 +#: documents/models.py:144 documents/models.py:187 msgid "document type" msgstr "" -#: documents/models.py:144 +#: documents/models.py:145 msgid "document types" msgstr "" -#: documents/models.py:149 +#: documents/models.py:150 msgid "path" msgstr "" -#: documents/models.py:153 documents/models.py:175 +#: documents/models.py:154 documents/models.py:176 msgid "storage path" msgstr "" -#: documents/models.py:154 +#: documents/models.py:155 msgid "storage paths" msgstr "" -#: documents/models.py:178 +#: documents/models.py:179 msgid "title" msgstr "" -#: documents/models.py:190 documents/models.py:912 +#: documents/models.py:191 documents/models.py:920 msgid "content" msgstr "" -#: documents/models.py:193 +#: documents/models.py:194 msgid "" "The raw, text-only data of the document. This field is primarily used for " "searching." msgstr "" -#: documents/models.py:207 +#: documents/models.py:208 msgid "mime type" msgstr "" -#: documents/models.py:217 +#: documents/models.py:218 msgid "checksum" msgstr "" -#: documents/models.py:221 +#: documents/models.py:222 msgid "The checksum of the original document." msgstr "" -#: documents/models.py:225 +#: documents/models.py:226 msgid "archive checksum" msgstr "" -#: documents/models.py:231 +#: documents/models.py:232 msgid "The checksum of the archived document." msgstr "" -#: documents/models.py:235 +#: documents/models.py:236 msgid "page count" msgstr "" -#: documents/models.py:242 +#: documents/models.py:243 msgid "The number of pages of the document." msgstr "" -#: documents/models.py:247 documents/models.py:918 documents/models.py:956 -#: documents/models.py:1018 documents/models.py:1146 documents/models.py:1205 +#: documents/models.py:248 documents/models.py:926 documents/models.py:964 +#: documents/models.py:1026 documents/models.py:1154 documents/models.py:1213 msgid "created" msgstr "" -#: documents/models.py:253 +#: documents/models.py:254 msgid "modified" msgstr "" -#: documents/models.py:260 +#: documents/models.py:261 msgid "added" msgstr "" -#: documents/models.py:267 +#: documents/models.py:268 msgid "filename" msgstr "" -#: documents/models.py:273 +#: documents/models.py:274 msgid "Current filename in storage" msgstr "" -#: documents/models.py:277 +#: documents/models.py:278 msgid "archive filename" msgstr "" -#: documents/models.py:283 +#: documents/models.py:284 msgid "Current archive filename in storage" msgstr "" -#: documents/models.py:287 +#: documents/models.py:288 msgid "original filename" msgstr "" -#: documents/models.py:293 +#: documents/models.py:294 msgid "The original name of the file when it was uploaded" msgstr "" -#: documents/models.py:300 +#: documents/models.py:301 msgid "archive serial number" msgstr "" -#: documents/models.py:310 +#: documents/models.py:311 msgid "The position of this document in your physical document archive." msgstr "" -#: documents/models.py:320 +#: documents/models.py:321 msgid "root document for this version" msgstr "" -#: documents/models.py:324 +#: documents/models.py:325 msgid "version index" msgstr "" -#: documents/models.py:328 +#: documents/models.py:329 msgid "Index of this version within the root document." msgstr "" -#: documents/models.py:332 +#: documents/models.py:333 msgid "version label" msgstr "" -#: documents/models.py:336 +#: documents/models.py:337 msgid "Optional short label for a document version." msgstr "" -#: documents/models.py:341 documents/models.py:929 documents/models.py:983 -#: documents/models.py:2013 +#: documents/models.py:342 documents/models.py:937 documents/models.py:991 +#: documents/models.py:2021 msgid "document" msgstr "" -#: documents/models.py:342 documents/models.py:1089 +#: documents/models.py:343 documents/models.py:1097 msgid "documents" msgstr "" -#: documents/models.py:530 documents/models.py:952 +#: documents/models.py:538 documents/models.py:960 msgid "Archive" msgstr "" -#: documents/models.py:531 +#: documents/models.py:539 msgid "Bank" msgstr "" -#: documents/models.py:532 +#: documents/models.py:540 msgid "Basket" msgstr "" -#: documents/models.py:533 +#: documents/models.py:541 msgid "Bell" msgstr "" -#: documents/models.py:534 +#: documents/models.py:542 msgid "Bookmark" msgstr "" -#: documents/models.py:535 +#: documents/models.py:543 msgid "Boxes" msgstr "" -#: documents/models.py:536 +#: documents/models.py:544 msgid "Briefcase" msgstr "" -#: documents/models.py:537 +#: documents/models.py:545 msgid "Building" msgstr "" -#: documents/models.py:538 +#: documents/models.py:546 msgid "Calculator" msgstr "" -#: documents/models.py:539 +#: documents/models.py:547 msgid "Calendar" msgstr "" -#: documents/models.py:540 +#: documents/models.py:548 msgid "Camera" msgstr "" -#: documents/models.py:541 +#: documents/models.py:549 msgid "Checklist" msgstr "" -#: documents/models.py:542 +#: documents/models.py:550 msgid "Cash" msgstr "" -#: documents/models.py:543 +#: documents/models.py:551 msgid "Chat" msgstr "" -#: documents/models.py:544 +#: documents/models.py:552 msgid "Check" msgstr "" -#: documents/models.py:545 +#: documents/models.py:553 msgid "Clipboard" msgstr "" -#: documents/models.py:546 +#: documents/models.py:554 msgid "Clock" msgstr "" -#: documents/models.py:547 +#: documents/models.py:555 msgid "Credit card" msgstr "" -#: documents/models.py:548 +#: documents/models.py:556 msgid "Download" msgstr "" -#: documents/models.py:549 +#: documents/models.py:557 msgid "Envelope" msgstr "" -#: documents/models.py:550 +#: documents/models.py:558 msgid "Warning" msgstr "" -#: documents/models.py:551 +#: documents/models.py:559 msgid "File" msgstr "" -#: documents/models.py:552 +#: documents/models.py:560 msgid "Checked file" msgstr "" -#: documents/models.py:553 +#: documents/models.py:561 msgid "Locked file" msgstr "" -#: documents/models.py:554 +#: documents/models.py:562 msgid "Medical file" msgstr "" -#: documents/models.py:555 +#: documents/models.py:563 msgid "Person file" msgstr "" -#: documents/models.py:558 +#: documents/models.py:566 msgid "Spreadsheet" msgstr "" -#: documents/models.py:560 +#: documents/models.py:568 msgid "Text file" msgstr "" -#: documents/models.py:561 +#: documents/models.py:569 msgid "Files" msgstr "" -#: documents/models.py:562 +#: documents/models.py:570 msgid "Folder" msgstr "" -#: documents/models.py:563 paperless_mail/admin.py:58 +#: documents/models.py:571 paperless_mail/admin.py:58 msgid "Filter" msgstr "" -#: documents/models.py:564 +#: documents/models.py:572 msgid "Gear" msgstr "" -#: documents/models.py:565 +#: documents/models.py:573 msgid "Globe" msgstr "" -#: documents/models.py:566 +#: documents/models.py:574 msgid "Hash" msgstr "" -#: documents/models.py:567 +#: documents/models.py:575 msgid "Heart" msgstr "" -#: documents/models.py:568 +#: documents/models.py:576 msgid "House" msgstr "" -#: documents/models.py:569 +#: documents/models.py:577 msgid "Inbox" msgstr "" -#: documents/models.py:570 +#: documents/models.py:578 msgid "Journals" msgstr "" -#: documents/models.py:571 +#: documents/models.py:579 msgid "Task list" msgstr "" -#: documents/models.py:572 +#: documents/models.py:580 msgid "Newspaper" msgstr "" -#: documents/models.py:573 +#: documents/models.py:581 msgid "Attachment" msgstr "" -#: documents/models.py:574 +#: documents/models.py:582 msgid "People" msgstr "" -#: documents/models.py:575 +#: documents/models.py:583 msgid "Person" msgstr "" -#: documents/models.py:576 +#: documents/models.py:584 msgid "Printer" msgstr "" -#: documents/models.py:577 +#: documents/models.py:585 msgid "Receipt" msgstr "" -#: documents/models.py:578 +#: documents/models.py:586 msgid "Safe" msgstr "" -#: documents/models.py:579 +#: documents/models.py:587 msgid "Search" msgstr "" -#: documents/models.py:580 +#: documents/models.py:588 msgid "Send" msgstr "" -#: documents/models.py:581 +#: documents/models.py:589 msgid "Shop" msgstr "" -#: documents/models.py:582 +#: documents/models.py:590 msgid "Stack" msgstr "" -#: documents/models.py:583 +#: documents/models.py:591 msgid "Stars" msgstr "" -#: documents/models.py:584 +#: documents/models.py:592 msgid "Tag" msgstr "" -#: documents/models.py:585 documents/models.py:600 documents/models.py:1691 +#: documents/models.py:593 documents/models.py:608 documents/models.py:1699 msgid "Tags" msgstr "" -#: documents/models.py:586 +#: documents/models.py:594 msgid "Telephone" msgstr "" -#: documents/models.py:587 +#: documents/models.py:595 msgid "Truck" msgstr "" -#: documents/models.py:588 +#: documents/models.py:596 msgid "Barcode" msgstr "" -#: documents/models.py:589 +#: documents/models.py:597 msgid "Wallet" msgstr "" -#: documents/models.py:592 +#: documents/models.py:600 msgid "Table" msgstr "" -#: documents/models.py:593 +#: documents/models.py:601 msgid "Small Cards" msgstr "" -#: documents/models.py:594 +#: documents/models.py:602 msgid "Large Cards" msgstr "" -#: documents/models.py:597 documents/models.py:1690 +#: documents/models.py:605 documents/models.py:1698 msgid "Title" msgstr "" -#: documents/models.py:598 documents/models.py:828 documents/models.py:1365 +#: documents/models.py:606 documents/models.py:836 documents/models.py:1373 msgid "Created" msgstr "" -#: documents/models.py:599 documents/models.py:1364 +#: documents/models.py:607 documents/models.py:1372 msgid "Added" msgstr "" -#: documents/models.py:601 documents/models.py:1692 +#: documents/models.py:609 documents/models.py:1700 msgid "Correspondent" msgstr "" -#: documents/models.py:602 +#: documents/models.py:610 msgid "Document Type" msgstr "" -#: documents/models.py:603 +#: documents/models.py:611 msgid "Storage Path" msgstr "" -#: documents/models.py:604 +#: documents/models.py:612 msgid "Note" msgstr "" -#: documents/models.py:605 +#: documents/models.py:613 msgid "Owner" msgstr "" -#: documents/models.py:606 +#: documents/models.py:614 msgid "Shared" msgstr "" -#: documents/models.py:607 +#: documents/models.py:615 msgid "ASN" msgstr "" -#: documents/models.py:608 +#: documents/models.py:616 msgid "Pages" msgstr "" -#: documents/models.py:614 +#: documents/models.py:622 msgid "icon" msgstr "" -#: documents/models.py:621 +#: documents/models.py:629 msgid "sort field" msgstr "" -#: documents/models.py:626 +#: documents/models.py:634 msgid "sort reverse" msgstr "" -#: documents/models.py:629 +#: documents/models.py:637 msgid "View page size" msgstr "" -#: documents/models.py:637 +#: documents/models.py:645 msgid "View display mode" msgstr "" -#: documents/models.py:644 +#: documents/models.py:652 msgid "Document display fields" msgstr "" -#: documents/models.py:651 documents/models.py:717 +#: documents/models.py:659 documents/models.py:725 msgid "saved view" msgstr "" -#: documents/models.py:652 +#: documents/models.py:660 msgid "saved views" msgstr "" -#: documents/models.py:660 +#: documents/models.py:668 msgid "title contains" msgstr "" -#: documents/models.py:661 +#: documents/models.py:669 msgid "content contains" msgstr "" -#: documents/models.py:662 +#: documents/models.py:670 msgid "ASN is" msgstr "" -#: documents/models.py:663 +#: documents/models.py:671 msgid "correspondent is" msgstr "" -#: documents/models.py:664 +#: documents/models.py:672 msgid "document type is" msgstr "" -#: documents/models.py:665 +#: documents/models.py:673 msgid "is in inbox" msgstr "" -#: documents/models.py:666 +#: documents/models.py:674 msgid "has tag" msgstr "" -#: documents/models.py:667 +#: documents/models.py:675 msgid "has any tag" msgstr "" -#: documents/models.py:668 +#: documents/models.py:676 msgid "created before" msgstr "" -#: documents/models.py:669 +#: documents/models.py:677 msgid "created after" msgstr "" -#: documents/models.py:670 +#: documents/models.py:678 msgid "created year is" msgstr "" -#: documents/models.py:671 +#: documents/models.py:679 msgid "created month is" msgstr "" -#: documents/models.py:672 +#: documents/models.py:680 msgid "created day is" msgstr "" -#: documents/models.py:673 +#: documents/models.py:681 msgid "added before" msgstr "" -#: documents/models.py:674 +#: documents/models.py:682 msgid "added after" msgstr "" -#: documents/models.py:675 +#: documents/models.py:683 msgid "modified before" msgstr "" -#: documents/models.py:676 +#: documents/models.py:684 msgid "modified after" msgstr "" -#: documents/models.py:677 +#: documents/models.py:685 msgid "does not have tag" msgstr "" -#: documents/models.py:678 +#: documents/models.py:686 msgid "does not have ASN" msgstr "" -#: documents/models.py:679 +#: documents/models.py:687 msgid "title or content contains" msgstr "" -#: documents/models.py:680 +#: documents/models.py:688 msgid "fulltext query" msgstr "" -#: documents/models.py:681 +#: documents/models.py:689 msgid "more like this" msgstr "" -#: documents/models.py:682 +#: documents/models.py:690 msgid "has tags in" msgstr "" -#: documents/models.py:683 +#: documents/models.py:691 msgid "ASN greater than" msgstr "" -#: documents/models.py:684 +#: documents/models.py:692 msgid "ASN less than" msgstr "" -#: documents/models.py:685 +#: documents/models.py:693 msgid "storage path is" msgstr "" -#: documents/models.py:686 +#: documents/models.py:694 msgid "has correspondent in" msgstr "" -#: documents/models.py:687 +#: documents/models.py:695 msgid "does not have correspondent in" msgstr "" -#: documents/models.py:688 +#: documents/models.py:696 msgid "has document type in" msgstr "" -#: documents/models.py:689 +#: documents/models.py:697 msgid "does not have document type in" msgstr "" -#: documents/models.py:690 +#: documents/models.py:698 msgid "has storage path in" msgstr "" -#: documents/models.py:691 +#: documents/models.py:699 msgid "does not have storage path in" msgstr "" -#: documents/models.py:692 +#: documents/models.py:700 msgid "owner is" msgstr "" -#: documents/models.py:693 +#: documents/models.py:701 msgid "has owner in" msgstr "" -#: documents/models.py:694 +#: documents/models.py:702 msgid "does not have owner" msgstr "" -#: documents/models.py:695 +#: documents/models.py:703 msgid "does not have owner in" msgstr "" -#: documents/models.py:696 +#: documents/models.py:704 msgid "has custom field value" msgstr "" -#: documents/models.py:697 +#: documents/models.py:705 msgid "is shared by me" msgstr "" -#: documents/models.py:698 +#: documents/models.py:706 msgid "has custom fields" msgstr "" -#: documents/models.py:699 +#: documents/models.py:707 msgid "has custom field in" msgstr "" -#: documents/models.py:700 +#: documents/models.py:708 msgid "does not have custom field in" msgstr "" -#: documents/models.py:701 +#: documents/models.py:709 msgid "does not have custom field" msgstr "" -#: documents/models.py:702 +#: documents/models.py:710 msgid "custom fields query" msgstr "" -#: documents/models.py:703 +#: documents/models.py:711 msgid "created to" msgstr "" -#: documents/models.py:704 +#: documents/models.py:712 msgid "created from" msgstr "" -#: documents/models.py:705 +#: documents/models.py:713 msgid "added to" msgstr "" -#: documents/models.py:706 +#: documents/models.py:714 msgid "added from" msgstr "" -#: documents/models.py:707 +#: documents/models.py:715 msgid "mime type is" msgstr "" -#: documents/models.py:708 +#: documents/models.py:716 msgid "simple title search" msgstr "" -#: documents/models.py:709 +#: documents/models.py:717 msgid "simple text search" msgstr "" -#: documents/models.py:710 +#: documents/models.py:718 msgid "has duplicates" msgstr "" -#: documents/models.py:720 +#: documents/models.py:728 msgid "rule type" msgstr "" -#: documents/models.py:722 +#: documents/models.py:730 msgid "value" msgstr "" -#: documents/models.py:725 +#: documents/models.py:733 msgid "filter rule" msgstr "" -#: documents/models.py:726 +#: documents/models.py:734 msgid "filter rules" msgstr "" -#: documents/models.py:756 documents/models.py:1012 +#: documents/models.py:764 documents/models.py:1020 msgid "Pending" msgstr "" -#: documents/models.py:757 documents/models.py:835 +#: documents/models.py:765 documents/models.py:843 msgid "Started" msgstr "" -#: documents/models.py:758 +#: documents/models.py:766 msgid "Success" msgstr "" -#: documents/models.py:759 +#: documents/models.py:767 msgid "Failure" msgstr "" -#: documents/models.py:760 +#: documents/models.py:768 msgid "Revoked" msgstr "" -#: documents/models.py:763 +#: documents/models.py:771 msgid "Consume File" msgstr "" -#: documents/models.py:764 +#: documents/models.py:772 msgid "Train Classifier" msgstr "" -#: documents/models.py:765 +#: documents/models.py:773 msgid "Sanity Check" msgstr "" -#: documents/models.py:766 +#: documents/models.py:774 msgid "Index Optimize" msgstr "" -#: documents/models.py:767 documents/models.py:1360 +#: documents/models.py:775 documents/models.py:1368 msgid "Mail Fetch" msgstr "" -#: documents/models.py:768 +#: documents/models.py:776 msgid "LLM Index" msgstr "" -#: documents/models.py:769 +#: documents/models.py:777 msgid "Empty Trash" msgstr "" -#: documents/models.py:770 +#: documents/models.py:778 msgid "Check Workflows" msgstr "" -#: documents/models.py:771 +#: documents/models.py:779 msgid "Bulk Update" msgstr "" -#: documents/models.py:772 +#: documents/models.py:780 msgid "Reprocess Document" msgstr "" -#: documents/models.py:773 +#: documents/models.py:781 msgid "Build Share Link" msgstr "" -#: documents/models.py:774 +#: documents/models.py:782 msgid "Bulk Delete" msgstr "" -#: documents/models.py:775 +#: documents/models.py:783 msgid "Apply AI Suggestions" msgstr "" -#: documents/models.py:784 documents/models.py:1355 +#: documents/models.py:792 documents/models.py:1363 msgid "Scheduled" msgstr "" -#: documents/models.py:785 documents/models.py:1361 +#: documents/models.py:793 documents/models.py:1369 msgid "Web UI" msgstr "" -#: documents/models.py:786 +#: documents/models.py:794 msgid "API Upload" msgstr "" -#: documents/models.py:787 +#: documents/models.py:795 msgid "Folder Consume" msgstr "" -#: documents/models.py:788 +#: documents/models.py:796 msgid "Email Consume" msgstr "" -#: documents/models.py:789 +#: documents/models.py:797 msgid "System" msgstr "" -#: documents/models.py:790 +#: documents/models.py:798 msgid "Manual" msgstr "" -#: documents/models.py:796 +#: documents/models.py:804 msgid "Task ID" msgstr "" -#: documents/models.py:797 +#: documents/models.py:805 msgid "Celery task ID" msgstr "" -#: documents/models.py:803 +#: documents/models.py:811 msgid "Task Type" msgstr "" -#: documents/models.py:804 +#: documents/models.py:812 msgid "The kind of work being performed" msgstr "" -#: documents/models.py:811 +#: documents/models.py:819 msgid "Trigger Source" msgstr "" -#: documents/models.py:812 +#: documents/models.py:820 msgid "What initiated this task" msgstr "" -#: documents/models.py:821 +#: documents/models.py:829 msgid "Status" msgstr "" -#: documents/models.py:841 +#: documents/models.py:849 msgid "Completed" msgstr "" -#: documents/models.py:849 +#: documents/models.py:857 msgid "Duration (seconds)" msgstr "" -#: documents/models.py:850 +#: documents/models.py:858 msgid "Elapsed time from start to completion" msgstr "" -#: documents/models.py:856 +#: documents/models.py:864 msgid "Wait Time (seconds)" msgstr "" -#: documents/models.py:857 +#: documents/models.py:865 msgid "Time from task creation to worker pickup" msgstr "" -#: documents/models.py:864 +#: documents/models.py:872 msgid "Input Data" msgstr "" -#: documents/models.py:865 +#: documents/models.py:873 msgid "Structured input parameters for the task" msgstr "" -#: documents/models.py:871 +#: documents/models.py:879 msgid "Result Data" msgstr "" -#: documents/models.py:872 +#: documents/models.py:880 msgid "Structured result data from task execution" msgstr "" -#: documents/models.py:878 +#: documents/models.py:886 msgid "Acknowledged" msgstr "" -#: documents/models.py:883 +#: documents/models.py:891 msgid "Task" msgstr "" -#: documents/models.py:884 +#: documents/models.py:892 msgid "Tasks" msgstr "" -#: documents/models.py:914 +#: documents/models.py:922 msgid "Note for the document" msgstr "" -#: documents/models.py:938 +#: documents/models.py:946 msgid "user" msgstr "" -#: documents/models.py:943 +#: documents/models.py:951 msgid "note" msgstr "" -#: documents/models.py:944 +#: documents/models.py:952 msgid "notes" msgstr "" -#: documents/models.py:953 +#: documents/models.py:961 msgid "Original" msgstr "" -#: documents/models.py:964 documents/models.py:1026 paperless_mail/models.py:75 +#: documents/models.py:972 documents/models.py:1034 paperless_mail/models.py:75 msgid "expiration" msgstr "" -#: documents/models.py:971 documents/models.py:1033 +#: documents/models.py:979 documents/models.py:1041 msgid "slug" msgstr "" -#: documents/models.py:1003 +#: documents/models.py:1011 msgid "share link" msgstr "" -#: documents/models.py:1004 +#: documents/models.py:1012 msgid "share links" msgstr "" -#: documents/models.py:1013 +#: documents/models.py:1021 msgid "Processing" msgstr "" -#: documents/models.py:1014 +#: documents/models.py:1022 msgid "Ready" msgstr "" -#: documents/models.py:1015 +#: documents/models.py:1023 msgid "Failed" msgstr "" -#: documents/models.py:1062 +#: documents/models.py:1070 msgid "size (bytes)" msgstr "" -#: documents/models.py:1068 +#: documents/models.py:1076 msgid "last error" msgstr "" -#: documents/models.py:1075 +#: documents/models.py:1083 msgid "file path" msgstr "" -#: documents/models.py:1081 +#: documents/models.py:1089 msgid "built at" msgstr "" -#: documents/models.py:1094 +#: documents/models.py:1102 msgid "share link bundle" msgstr "" -#: documents/models.py:1095 +#: documents/models.py:1103 msgid "share link bundles" msgstr "" -#: documents/models.py:1098 +#: documents/models.py:1106 #, python-format msgid "Share link bundle %(slug)s" msgstr "" -#: documents/models.py:1134 +#: documents/models.py:1142 msgid "String" msgstr "" -#: documents/models.py:1135 +#: documents/models.py:1143 msgid "URL" msgstr "" -#: documents/models.py:1136 +#: documents/models.py:1144 msgid "Date" msgstr "" -#: documents/models.py:1137 +#: documents/models.py:1145 msgid "Boolean" msgstr "" -#: documents/models.py:1138 +#: documents/models.py:1146 msgid "Integer" msgstr "" -#: documents/models.py:1139 +#: documents/models.py:1147 msgid "Float" msgstr "" -#: documents/models.py:1140 +#: documents/models.py:1148 msgid "Monetary" msgstr "" -#: documents/models.py:1141 +#: documents/models.py:1149 msgid "Document Link" msgstr "" -#: documents/models.py:1142 +#: documents/models.py:1150 msgid "Select" msgstr "" -#: documents/models.py:1143 +#: documents/models.py:1151 msgid "Long Text" msgstr "" -#: documents/models.py:1155 +#: documents/models.py:1163 msgid "data type" msgstr "" -#: documents/models.py:1162 +#: documents/models.py:1170 msgid "extra data" msgstr "" -#: documents/models.py:1166 +#: documents/models.py:1174 msgid "Extra data for the custom field, such as select options" msgstr "" -#: documents/models.py:1172 +#: documents/models.py:1180 msgid "custom field" msgstr "" -#: documents/models.py:1173 +#: documents/models.py:1181 msgid "custom fields" msgstr "" -#: documents/models.py:1273 +#: documents/models.py:1281 msgid "custom field instance" msgstr "" -#: documents/models.py:1274 +#: documents/models.py:1282 msgid "custom field instances" msgstr "" -#: documents/models.py:1352 +#: documents/models.py:1360 msgid "Consumption Started" msgstr "" -#: documents/models.py:1353 +#: documents/models.py:1361 msgid "Document Added" msgstr "" -#: documents/models.py:1354 +#: documents/models.py:1362 msgid "Document Updated" msgstr "" -#: documents/models.py:1358 +#: documents/models.py:1366 msgid "Consume Folder" msgstr "" -#: documents/models.py:1359 +#: documents/models.py:1367 msgid "Api Upload" msgstr "" -#: documents/models.py:1366 +#: documents/models.py:1374 msgid "Modified" msgstr "" -#: documents/models.py:1367 +#: documents/models.py:1375 msgid "Custom Field" msgstr "" -#: documents/models.py:1370 +#: documents/models.py:1378 msgid "Workflow Trigger Type" msgstr "" -#: documents/models.py:1382 +#: documents/models.py:1390 msgid "filter path" msgstr "" -#: documents/models.py:1387 +#: documents/models.py:1395 msgid "" "Only consume documents with a path that matches this if specified. Wildcards " "specified as * are allowed. Case insensitive." msgstr "" -#: documents/models.py:1394 +#: documents/models.py:1402 msgid "filter filename" msgstr "" -#: documents/models.py:1399 paperless_mail/models.py:200 +#: documents/models.py:1407 paperless_mail/models.py:200 msgid "" "Only consume documents which entirely match this filename if specified. " "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." msgstr "" -#: documents/models.py:1410 +#: documents/models.py:1418 msgid "filter documents from this mail rule" msgstr "" -#: documents/models.py:1426 +#: documents/models.py:1434 msgid "has these tag(s)" msgstr "" -#: documents/models.py:1433 +#: documents/models.py:1441 msgid "has all of these tag(s)" msgstr "" -#: documents/models.py:1440 +#: documents/models.py:1448 msgid "does not have these tag(s)" msgstr "" -#: documents/models.py:1448 +#: documents/models.py:1456 msgid "has this document type" msgstr "" -#: documents/models.py:1455 +#: documents/models.py:1463 msgid "has one of these document types" msgstr "" -#: documents/models.py:1462 +#: documents/models.py:1470 msgid "does not have these document type(s)" msgstr "" -#: documents/models.py:1470 +#: documents/models.py:1478 msgid "has this correspondent" msgstr "" -#: documents/models.py:1477 +#: documents/models.py:1485 msgid "does not have these correspondent(s)" msgstr "" -#: documents/models.py:1484 +#: documents/models.py:1492 msgid "has one of these correspondents" msgstr "" -#: documents/models.py:1492 +#: documents/models.py:1500 msgid "has this storage path" msgstr "" -#: documents/models.py:1499 +#: documents/models.py:1507 msgid "has one of these storage paths" msgstr "" -#: documents/models.py:1506 +#: documents/models.py:1514 msgid "does not have these storage path(s)" msgstr "" -#: documents/models.py:1510 +#: documents/models.py:1518 msgid "filter custom field query" msgstr "" -#: documents/models.py:1513 +#: documents/models.py:1521 msgid "JSON-encoded custom field query expression." msgstr "" -#: documents/models.py:1517 +#: documents/models.py:1525 msgid "schedule offset days" msgstr "" -#: documents/models.py:1520 +#: documents/models.py:1528 msgid "The number of days to offset the schedule trigger by." msgstr "" -#: documents/models.py:1525 +#: documents/models.py:1533 msgid "schedule is recurring" msgstr "" -#: documents/models.py:1528 +#: documents/models.py:1536 msgid "If the schedule should be recurring." msgstr "" -#: documents/models.py:1533 +#: documents/models.py:1541 msgid "schedule recurring delay in days" msgstr "" -#: documents/models.py:1537 +#: documents/models.py:1545 msgid "The number of days between recurring schedule triggers." msgstr "" -#: documents/models.py:1542 +#: documents/models.py:1550 msgid "schedule date field" msgstr "" -#: documents/models.py:1547 +#: documents/models.py:1555 msgid "The field to check for a schedule trigger." msgstr "" -#: documents/models.py:1556 +#: documents/models.py:1564 msgid "schedule date custom field" msgstr "" -#: documents/models.py:1560 +#: documents/models.py:1568 msgid "workflow trigger" msgstr "" -#: documents/models.py:1561 +#: documents/models.py:1569 msgid "workflow triggers" msgstr "" -#: documents/models.py:1569 +#: documents/models.py:1577 msgid "email subject" msgstr "" -#: documents/models.py:1573 +#: documents/models.py:1581 msgid "" "The subject of the email, can include some placeholders, see documentation." msgstr "" -#: documents/models.py:1579 +#: documents/models.py:1587 msgid "email body" msgstr "" -#: documents/models.py:1582 +#: documents/models.py:1590 msgid "" "The body (message) of the email, can include some placeholders, see " "documentation." msgstr "" -#: documents/models.py:1588 +#: documents/models.py:1596 msgid "emails to" msgstr "" -#: documents/models.py:1591 +#: documents/models.py:1599 msgid "The destination email addresses, comma separated." msgstr "" -#: documents/models.py:1597 +#: documents/models.py:1605 msgid "include document in email" msgstr "" -#: documents/models.py:1608 +#: documents/models.py:1616 msgid "webhook url" msgstr "" -#: documents/models.py:1611 +#: documents/models.py:1619 msgid "The destination URL for the notification." msgstr "" -#: documents/models.py:1616 +#: documents/models.py:1624 msgid "use parameters" msgstr "" -#: documents/models.py:1621 +#: documents/models.py:1629 msgid "send as JSON" msgstr "" -#: documents/models.py:1625 +#: documents/models.py:1633 msgid "webhook parameters" msgstr "" -#: documents/models.py:1628 +#: documents/models.py:1636 msgid "The parameters to send with the webhook URL if body not used." msgstr "" -#: documents/models.py:1632 +#: documents/models.py:1640 msgid "webhook body" msgstr "" -#: documents/models.py:1635 +#: documents/models.py:1643 msgid "The body to send with the webhook URL if parameters not used." msgstr "" -#: documents/models.py:1639 +#: documents/models.py:1647 msgid "webhook headers" msgstr "" -#: documents/models.py:1642 +#: documents/models.py:1650 msgid "The headers to send with the webhook URL." msgstr "" -#: documents/models.py:1647 +#: documents/models.py:1655 msgid "include document in webhook" msgstr "" -#: documents/models.py:1658 +#: documents/models.py:1666 msgid "Assignment" msgstr "" -#: documents/models.py:1662 +#: documents/models.py:1670 msgid "Removal" msgstr "" -#: documents/models.py:1666 documents/templates/account/password_reset.html:15 +#: documents/models.py:1674 documents/templates/account/password_reset.html:15 msgid "Email" msgstr "" -#: documents/models.py:1670 +#: documents/models.py:1678 msgid "Webhook" msgstr "" -#: documents/models.py:1674 +#: documents/models.py:1682 msgid "Password removal" msgstr "" -#: documents/models.py:1678 +#: documents/models.py:1686 msgid "Move to trash" msgstr "" -#: documents/models.py:1682 +#: documents/models.py:1690 msgid "Remote OCR" msgstr "" -#: documents/models.py:1686 +#: documents/models.py:1694 msgid "Apply AI suggestions" msgstr "" -#: documents/models.py:1693 +#: documents/models.py:1701 msgid "Document type" msgstr "" -#: documents/models.py:1694 +#: documents/models.py:1702 msgid "Storage path" msgstr "" -#: documents/models.py:1695 +#: documents/models.py:1703 msgid "Created date" msgstr "" -#: documents/models.py:1698 +#: documents/models.py:1706 msgid "Workflow Action Type" msgstr "" -#: documents/models.py:1703 documents/models.py:1972 +#: documents/models.py:1711 documents/models.py:1980 #: paperless_mail/models.py:145 msgid "order" msgstr "" -#: documents/models.py:1706 +#: documents/models.py:1714 msgid "assign title" msgstr "" -#: documents/models.py:1710 +#: documents/models.py:1718 msgid "Assign a document title, must be a Jinja2 template, see documentation." msgstr "" -#: documents/models.py:1718 paperless_mail/models.py:274 +#: documents/models.py:1726 paperless_mail/models.py:274 msgid "assign this tag" msgstr "" -#: documents/models.py:1727 paperless_mail/models.py:282 +#: documents/models.py:1735 paperless_mail/models.py:282 msgid "assign this document type" msgstr "" -#: documents/models.py:1736 paperless_mail/models.py:296 +#: documents/models.py:1744 paperless_mail/models.py:296 msgid "assign this correspondent" msgstr "" -#: documents/models.py:1745 +#: documents/models.py:1753 msgid "assign this storage path" msgstr "" -#: documents/models.py:1754 +#: documents/models.py:1762 msgid "assign this owner" msgstr "" -#: documents/models.py:1761 +#: documents/models.py:1769 msgid "grant view permissions to these users" msgstr "" -#: documents/models.py:1768 +#: documents/models.py:1776 msgid "grant view permissions to these groups" msgstr "" -#: documents/models.py:1775 +#: documents/models.py:1783 msgid "grant change permissions to these users" msgstr "" -#: documents/models.py:1782 +#: documents/models.py:1790 msgid "grant change permissions to these groups" msgstr "" -#: documents/models.py:1789 +#: documents/models.py:1797 msgid "assign these custom fields" msgstr "" -#: documents/models.py:1793 +#: documents/models.py:1801 msgid "custom field values" msgstr "" -#: documents/models.py:1797 +#: documents/models.py:1805 msgid "Optional values to assign to the custom fields." msgstr "" -#: documents/models.py:1806 +#: documents/models.py:1814 msgid "remove these tag(s)" msgstr "" -#: documents/models.py:1811 +#: documents/models.py:1819 msgid "remove all tags" msgstr "" -#: documents/models.py:1818 +#: documents/models.py:1826 msgid "remove these document type(s)" msgstr "" -#: documents/models.py:1823 +#: documents/models.py:1831 msgid "remove all document types" msgstr "" -#: documents/models.py:1830 +#: documents/models.py:1838 msgid "remove these correspondent(s)" msgstr "" -#: documents/models.py:1835 +#: documents/models.py:1843 msgid "remove all correspondents" msgstr "" -#: documents/models.py:1842 +#: documents/models.py:1850 msgid "remove these storage path(s)" msgstr "" -#: documents/models.py:1847 +#: documents/models.py:1855 msgid "remove all storage paths" msgstr "" -#: documents/models.py:1854 +#: documents/models.py:1862 msgid "remove these owner(s)" msgstr "" -#: documents/models.py:1859 +#: documents/models.py:1867 msgid "remove all owners" msgstr "" -#: documents/models.py:1866 +#: documents/models.py:1874 msgid "remove view permissions for these users" msgstr "" -#: documents/models.py:1873 +#: documents/models.py:1881 msgid "remove view permissions for these groups" msgstr "" -#: documents/models.py:1880 +#: documents/models.py:1888 msgid "remove change permissions for these users" msgstr "" -#: documents/models.py:1887 +#: documents/models.py:1895 msgid "remove change permissions for these groups" msgstr "" -#: documents/models.py:1892 +#: documents/models.py:1900 msgid "remove all permissions" msgstr "" -#: documents/models.py:1899 +#: documents/models.py:1907 msgid "remove these custom fields" msgstr "" -#: documents/models.py:1904 +#: documents/models.py:1912 msgid "remove all custom fields" msgstr "" -#: documents/models.py:1913 +#: documents/models.py:1921 msgid "email" msgstr "" -#: documents/models.py:1922 +#: documents/models.py:1930 msgid "webhook" msgstr "" -#: documents/models.py:1926 +#: documents/models.py:1934 msgid "passwords" msgstr "" -#: documents/models.py:1930 +#: documents/models.py:1938 msgid "" "Passwords to try when removing PDF protection. Separate with commas or new " "lines." msgstr "" -#: documents/models.py:1935 +#: documents/models.py:1943 msgid "AI suggestion fields" msgstr "" -#: documents/models.py:1939 +#: documents/models.py:1947 msgid "Which of the AI-suggested fields to apply to the document." msgstr "" -#: documents/models.py:1944 +#: documents/models.py:1952 msgid "create missing objects" msgstr "" -#: documents/models.py:1947 +#: documents/models.py:1955 msgid "" "Create suggested tags, correspondents, document types and storage paths that " "do not already exist instead of skipping them." msgstr "" -#: documents/models.py:1953 +#: documents/models.py:1961 msgid "overwrite existing values" msgstr "" -#: documents/models.py:1956 +#: documents/models.py:1964 msgid "" "Apply suggestions even if the document already has a value for that field. " "Tags are always added to, never replaced." msgstr "" -#: documents/models.py:1962 +#: documents/models.py:1970 msgid "workflow action" msgstr "" -#: documents/models.py:1963 +#: documents/models.py:1971 msgid "workflow actions" msgstr "" -#: documents/models.py:1978 +#: documents/models.py:1986 msgid "triggers" msgstr "" -#: documents/models.py:1985 +#: documents/models.py:1993 msgid "actions" msgstr "" -#: documents/models.py:1988 paperless_mail/models.py:154 +#: documents/models.py:1996 paperless_mail/models.py:154 msgid "enabled" msgstr "" -#: documents/models.py:1999 +#: documents/models.py:2007 msgid "workflow" msgstr "" -#: documents/models.py:2003 +#: documents/models.py:2011 msgid "workflow trigger type" msgstr "" -#: documents/models.py:2017 +#: documents/models.py:2025 msgid "date run" msgstr "" -#: documents/models.py:2023 +#: documents/models.py:2031 msgid "workflow run" msgstr "" -#: documents/models.py:2024 +#: documents/models.py:2032 msgid "workflow runs" msgstr "" From bf716ebfd1f29f39b505dbd90d2986f56dbe09f7 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:02:43 -0700 Subject: [PATCH 29/68] Fixhancement: better LLM errors (#14031) --- src/documents/tests/test_views.py | 33 ++++++++++++++++++ src/documents/views.py | 17 ++++++++++ src/paperless_ai/client.py | 22 ++++++++++-- src/paperless_ai/exceptions.py | 4 +++ src/paperless_ai/tests/test_client.py | 48 +++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 3 deletions(-) diff --git a/src/documents/tests/test_views.py b/src/documents/tests/test_views.py index 282203866..7e01b273d 100644 --- a/src/documents/tests/test_views.py +++ b/src/documents/tests/test_views.py @@ -32,6 +32,7 @@ from documents.signals.handlers import update_llm_suggestions_cache from documents.tests.utils import DirectoriesMixin from documents.tests.utils import read_streaming_response from paperless.models import ApplicationConfiguration +from paperless_ai.exceptions import LLMProviderError from paperless_ai.exceptions import LLMTimeoutError @@ -737,6 +738,38 @@ class TestAISuggestions(DirectoriesMixin, TestCase): get_llm_suggestion_cache(self.document.pk, backend="openai-like"), ) + @patch("documents.views.get_ai_document_classification") + @override_settings( + AI_ENABLED=True, + LLM_BACKEND="openai-like", + ) + def test_ai_suggestions_with_llm_provider_error( + self, + mock_get_ai_classification, + ) -> None: + mock_get_ai_classification.side_effect = LLMProviderError( + "confidential provider response", + ) + + self.client.force_login(user=self.user) + response = self.client.get( + f"/api/documents/{self.document.pk}/ai_suggestions/", + ) + + self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) + self.assertEqual( + response.json(), + { + "ai": [ + "AI backend rejected the request. Check logs for details.", + ], + }, + ) + self.assertNotIn("confidential provider response", response.content.decode()) + self.assertIsNone( + get_llm_suggestion_cache(self.document.pk, backend="openai-like"), + ) + @patch("documents.views.get_ai_document_classification") @override_settings( AI_ENABLED=True, diff --git a/src/documents/views.py b/src/documents/views.py index 3296ca753..00b166516 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -252,6 +252,7 @@ from paperless.views import StandardPagination from paperless_ai.ai_classifier import get_ai_document_classification from paperless_ai.ai_classifier import get_llm_output_language from paperless_ai.chat import stream_chat_with_documents +from paperless_ai.exceptions import LLMProviderError from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.matching import extract_unmatched_names from paperless_ai.matching import match_correspondents_by_name @@ -1603,6 +1604,22 @@ class DocumentViewSet( {"ai": [_("AI backend request timed out.")]}, status=status.HTTP_503_SERVICE_UNAVAILABLE, ) + except LLMProviderError: + logger.exception( + "AI backend rejected the request for document %s", + doc.pk, + ) + return Response( + { + "ai": [ + _( + "AI backend rejected the request. " + "Check logs for details.", + ), + ], + }, + status=status.HTTP_502_BAD_GATEWAY, + ) set_llm_suggestions_cache( doc.pk, llm_suggestions, diff --git a/src/paperless_ai/client.py b/src/paperless_ai/client.py index b14f4a726..8c2a9abf4 100644 --- a/src/paperless_ai/client.py +++ b/src/paperless_ai/client.py @@ -22,6 +22,7 @@ from paperless.network import validate_outbound_http_url from paperless_ai.base_model import ClassificationSuggestions from paperless_ai.base_model import DocumentClassifierSchema from paperless_ai.base_model import model_to_classification_suggestions +from paperless_ai.exceptions import LLMProviderError from paperless_ai.exceptions import LLMTimeoutError logger = logging.getLogger("paperless_ai.client") @@ -132,7 +133,7 @@ class AIClient: from llama_index.core.llms import ChatMessage if self.settings.llm_backend == LLMBackend.OLLAMA: - with self._normalize_timeouts(): + with self._normalize_errors(): result = self.llm.chat( [ChatMessage(role="user", content=prompt)], format=DocumentClassifierSchema.model_json_schema(), @@ -153,7 +154,7 @@ class AIClient: content=f"{prompt}\n\n" f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.", ) - with self._normalize_timeouts(): + with self._normalize_errors(): result = self.llm.chat_with_tools( tools=[tool], user_msg=user_msg, @@ -173,7 +174,7 @@ class AIClient: ) @contextmanager - def _normalize_timeouts(self) -> Iterator[None]: + def _normalize_errors(self) -> Iterator[None]: try: yield except httpx.TimeoutException as exc: @@ -181,8 +182,23 @@ class AIClient: except Exception as exc: if self._is_openai_timeout(exc): raise LLMTimeoutError from exc + if self._is_provider_error(exc): + raise LLMProviderError from exc raise + def _is_provider_error(self, exc: Exception) -> bool: + if self.settings.llm_backend == LLMBackend.OLLAMA: + from ollama import ResponseError + + return isinstance(exc, ResponseError) + + if self.settings.llm_backend == LLMBackend.OPENAI_LIKE: + from openai import APIStatusError + + return isinstance(exc, APIStatusError) + + return False + def _is_openai_timeout(self, exc: Exception) -> bool: if self.settings.llm_backend != LLMBackend.OPENAI_LIKE: return False diff --git a/src/paperless_ai/exceptions.py b/src/paperless_ai/exceptions.py index 48ac1db5d..ab3415e85 100644 --- a/src/paperless_ai/exceptions.py +++ b/src/paperless_ai/exceptions.py @@ -1,2 +1,6 @@ class LLMTimeoutError(Exception): pass + + +class LLMProviderError(Exception): + """The LLM backend rejected the request.""" diff --git a/src/paperless_ai/tests/test_client.py b/src/paperless_ai/tests/test_client.py index 7568e90b1..79bb6ad44 100644 --- a/src/paperless_ai/tests/test_client.py +++ b/src/paperless_ai/tests/test_client.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from unittest.mock import patch import httpx +import ollama import openai import pytest from llama_index.core.llms.llm import ToolSelection @@ -11,6 +12,7 @@ from llama_index.core.llms.llm import ToolSelection from paperless_ai.client import LLM_SYSTEM_PROMPT from paperless_ai.client import PLACEHOLDER_API_KEY from paperless_ai.client import AIClient +from paperless_ai.exceptions import LLMProviderError from paperless_ai.exceptions import LLMTimeoutError @@ -214,6 +216,52 @@ def test_run_llm_query_openai_timeout_raises_local_error( client.run_llm_query("test_prompt") +def test_run_llm_query_openai_status_error_raises_provider_error( + mock_ai_config, + mock_openai_llm, +): + mock_ai_config.llm_backend = "openai-like" + mock_ai_config.llm_model = "test_model" + mock_ai_config.llm_endpoint = "http://test-url" + + request = httpx.Request("POST", "http://test-url/v1/chat/completions") + body = {"error": {"message": "Thinking mode does not support this tool_choice"}} + mock_openai_llm.return_value.chat_with_tools.side_effect = openai.BadRequestError( + "Error code: 400", + response=httpx.Response(400, request=request, json=body), + body=body, + ) + + client = AIClient() + + with pytest.raises(LLMProviderError) as exc_info: + client.run_llm_query("test_prompt") + assert str(exc_info.value) == "" + assert isinstance(exc_info.value.__cause__, openai.BadRequestError) + + +def test_run_llm_query_ollama_response_error_raises_provider_error( + mock_ai_config, + mock_ollama_llm, +): + mock_ai_config.llm_backend = "ollama" + mock_ai_config.llm_model = "test_model" + mock_ai_config.llm_endpoint = "http://test-url" + + response_error = ollama.ResponseError( + "confidential provider response", + status_code=400, + ) + mock_ollama_llm.return_value.chat.side_effect = response_error + + client = AIClient() + + with pytest.raises(LLMProviderError) as exc_info: + client.run_llm_query("test_prompt") + assert str(exc_info.value) == "" + assert exc_info.value.__cause__ is response_error + + def test_run_llm_query_httpx_timeout_raises_local_error( mock_ai_config, mock_ollama_llm, From aff0f9cf41320c40a52e323ad1338037351f59aa Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:04:18 +0000 Subject: [PATCH 30/68] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 0654a552f..0a766f170 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-09 15:49+0000\n" +"POT-Creation-Date: 2026-09-09 16:03+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1632,7 +1632,7 @@ msgid "workflow runs" msgstr "" #: documents/serialisers.py:524 documents/serialisers.py:881 -#: documents/serialisers.py:2841 documents/views.py:315 documents/views.py:2625 +#: documents/serialisers.py:2841 documents/views.py:316 documents/views.py:2642 #: paperless_mail/serialisers.py:156 msgid "Insufficient permissions." msgstr "" @@ -1673,7 +1673,7 @@ msgstr "" msgid "Duplicate document identifiers are not allowed." msgstr "" -#: documents/serialisers.py:2927 documents/views.py:4632 +#: documents/serialisers.py:2927 documents/views.py:4649 #, python-format msgid "Documents not found: %(ids)s" msgstr "" @@ -1941,36 +1941,40 @@ msgstr "" msgid "Unable to parse URI {value}" msgstr "" -#: documents/views.py:308 documents/views.py:2622 +#: documents/views.py:309 documents/views.py:2639 msgid "Invalid more_like_id" msgstr "" -#: documents/views.py:1592 +#: documents/views.py:1593 msgid "Invalid AI configuration." msgstr "" -#: documents/views.py:1603 +#: documents/views.py:1604 msgid "AI backend request timed out." msgstr "" -#: documents/views.py:2447 documents/views.py:2768 +#: documents/views.py:1616 +msgid "AI backend rejected the request. Check logs for details." +msgstr "" + +#: documents/views.py:2464 documents/views.py:2785 msgid "Specify only one of text, title_search, query, or more_like_id." msgstr "" -#: documents/views.py:4645 +#: documents/views.py:4662 #, python-format msgid "Insufficient permissions to share document %(id)s." msgstr "" -#: documents/views.py:4691 +#: documents/views.py:4708 msgid "Bundle is already being processed." msgstr "" -#: documents/views.py:4755 +#: documents/views.py:4772 msgid "The share link bundle is still being prepared. Please try again later." msgstr "" -#: documents/views.py:4769 +#: documents/views.py:4786 msgid "The share link bundle is unavailable." msgstr "" From 310628699d2097678232365424cf4974c16a79d0 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:24:17 -0700 Subject: [PATCH 31/68] Fix: If a file remains stable but has zero size after the stability window has passed, drop it from tracking (#14047) A slow writer might create a zero byte file, then take longer than the window to finish the write. We would then queue a zero byte file for consumption and race the writer to most likely fail due to still being empty. Instead, drop zero size files at yeild time. The slow writer may or may not finish, but if it does, there will be a Change.modified event fired again --- .../management/commands/document_consumer.py | 9 ++ .../tests/test_management_consumer.py | 112 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py index cc6535487..7390e3f5c 100644 --- a/src/documents/management/commands/document_consumer.py +++ b/src/documents/management/commands/document_consumer.py @@ -156,6 +156,15 @@ class FileStabilityTracker: logger.debug(f"File disappeared during stability check: {path}") continue + # Stable, but empty: some scanners create a zero byte placeholder + # and only write the page some time later. Consuming it now can + # only fail so drop it and let the writer's next event + # (or the periodic rescan) bring it back once it has content + if not tracked.last_size: + to_remove.append(path) + logger.debug("Ignoring stable but empty file: %s", path) + continue + # File is stable, we can return it to_yield.append(path) logger.info(f"File is stable: {path}") diff --git a/src/documents/tests/test_management_consumer.py b/src/documents/tests/test_management_consumer.py index 46a7c957a..eaf4f826c 100644 --- a/src/documents/tests/test_management_consumer.py +++ b/src/documents/tests/test_management_consumer.py @@ -136,6 +136,23 @@ def wait_for_mock_call( return False +def sleep_past_stability( + owner: FileStabilityTracker | ConsumerThread, + *, + windows: float = 1.5, +) -> None: + """ + Block until a tracked file's stability window has certainly elapsed. + + Args: + owner: The tracker, or the consumer thread running one, whose + configured stability delay sets the wait. + windows: How many stability windows to wait, giving slop for a slow + or loaded test runner. + """ + sleep(owner.stability_delay * windows) + + class TestTrackedFile: """Tests for the TrackedFile dataclass.""" @@ -261,6 +278,56 @@ class TestFileStabilityTracker: assert len(stable) == 0 assert stability_tracker.pending_count == 1 + def test_get_stable_files_skips_empty_file( + self, + stability_tracker: FileStabilityTracker, + tmp_path: Path, + ) -> None: + """ + GIVEN: + - A zero byte file, tracked and past its stability delay + WHEN: + - Stable files are collected + THEN: + - The file is not yielded for consumption + - The file is dropped from tracking rather than held, so an + abandoned placeholder does not keep the watch loop awake + """ + empty = tmp_path / "scan.pdf" + empty.write_bytes(b"") + stability_tracker.track(empty, Change.added) + sleep_past_stability(stability_tracker) + + stable = list(stability_tracker.get_stable_files()) + + assert stable == [] + assert stability_tracker.pending_count == 0 + + def test_empty_file_is_yielded_once_content_arrives( + self, + stability_tracker: FileStabilityTracker, + tmp_path: Path, + ) -> None: + """ + GIVEN: + - A zero byte file which was dropped from tracking while empty + WHEN: + - The writer fills the file and a new event re-tracks it + THEN: + - The file is yielded for consumption once it is stable + """ + target = tmp_path / "scan.pdf" + target.write_bytes(b"") + stability_tracker.track(target, Change.added) + sleep_past_stability(stability_tracker) + assert list(stability_tracker.get_stable_files()) == [] + + target.write_bytes(b"%PDF-1.4 content") + stability_tracker.track(target, Change.modified) + sleep_past_stability(stability_tracker) + + assert list(stability_tracker.get_stable_files()) == [target] + def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None: """Test deleted file is not returned during stability check.""" tracker = FileStabilityTracker(stability_delay=0.1) @@ -879,6 +946,51 @@ class TestCommandWatch: mock_consume_file_delay.apply_async.assert_called() + def test_scanner_placeholder_is_not_consumed_while_empty( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + ) -> None: + """ + GIVEN: + - A scanner which creates a zero byte placeholder and only writes + the page some time later (GH discussion #13969) + WHEN: + - The placeholder sits untouched well past the stability delay + - The scanner then writes the real content + THEN: + - The empty placeholder is never queued, as it could only fail + with "Unsupported mime type inode/x-empty" + - The file is queued exactly once, when the content lands + """ + thread = start_consumer(stability_delay=0.2) + + target = consumption_dir / "scan.pdf" + target.write_bytes(b"") # the scanner's placeholder + + # Well past the stability delay: the old behaviour queued it here. + sleep_past_stability(thread, windows=5) + if thread.exception: + raise thread.exception + assert mock_consume_file_delay.apply_async.call_count == 0 + + shutil.copy(sample_pdf, target) # the scanner finishes the page + + assert wait_for_mock_call( + mock_consume_file_delay.apply_async, + timeout_s=5.0, + ) + if thread.exception: + raise thread.exception + + assert mock_consume_file_delay.apply_async.call_count == 1 + queued_doc = mock_consume_file_delay.apply_async.call_args.kwargs["kwargs"][ + "input_doc" + ] + assert queued_doc.original_file.name == "scan.pdf" + def test_ignores_macos_files( self, consumption_dir: Path, From 3e56dace7353f0363d49452e2c2e6db988ae55ee Mon Sep 17 00:00:00 2001 From: Philipp Defner Date: Wed, 9 Sep 2026 23:40:24 +0100 Subject: [PATCH 32/68] Enhancement: Improve matching for correspondents, storage path and labels by removing bias + adding minimum match threshold (#12164) --- docs/configuration.md | 9 ++ src/documents/classifier.py | 94 +++++++++++----- src/documents/tests/test_classifier.py | 145 +++++++++++++++++++++++++ src/paperless/settings/__init__.py | 7 ++ 4 files changed, 227 insertions(+), 28 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 37a552f1a..e045af077 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1200,6 +1200,15 @@ still perform some basic text pre-processing before matching. Defaults to true, enabling the feature. +#### [`PAPERLESS_CLASSIFIER_MATCH_THRESHOLD=`](#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD) {#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD} + +: Sets the minimum confidence score (0.0-1.0) required for the automatic +classifier to assign a correspondent, document type, or storage path to a +document. Predictions below this threshold are discarded and the field is +left unassigned, preventing low-confidence guesses from being applied. + + Defaults to 0.6. + #### [`PAPERLESS_DATE_PARSER_LANGUAGES=`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES} : Specifies which language Paperless should use when parsing dates from documents. diff --git a/src/documents/classifier.py b/src/documents/classifier.py index a5c272062..cfed4725c 100644 --- a/src/documents/classifier.py +++ b/src/documents/classifier.py @@ -34,6 +34,27 @@ from paperless.signed_pickle import signed_pickle_loads logger = logging.getLogger("paperless.classifier") + +def _predict_with_threshold(classifier, X, threshold: float) -> int | None: + """ + Return the predicted class id, or None if: + - the prediction is -1 (no match), or + - the winning class probability is below the configured threshold. + + Using predict_proba() instead of predict() lets us apply a minimum-confidence + cutoff so that uncertain predictions are discarded rather than assigned. + """ + probas = classifier.predict_proba(X)[0] + best_idx = int(probas.argmax()) + best_class = int(classifier.classes_[best_idx]) + + if best_class == -1: + return None + if threshold > 0.0 and probas[best_idx] < threshold: + return None + return best_class + + ADVANCED_TEXT_PROCESSING_ENABLED = ( settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED ) @@ -102,7 +123,8 @@ class DocumentClassifier: # v8 - Added storage path classifier # v9 - Changed from hashing to time/ids for re-train check # v10 - HMAC-signed model file - FORMAT_VERSION = 10 + # v11 - Use sample_weight for balanced training; predict_proba with threshold + FORMAT_VERSION = 11 HMAC_SIZE = 32 # SHA-256 digest length @@ -324,6 +346,13 @@ class DocumentClassifier: from sklearn.preprocessing import LabelBinarizer from sklearn.preprocessing import MultiLabelBinarizer + # MLPClassifier does not support class_weight directly + # (https://github.com/scikit-learn/scikit-learn/issues/9113), so we use + # compute_sample_weight to balance classes during training and prevent + # over-represented correspondents from dominating predictions. + # https://scikit-learn.org/stable/modules/generated/sklearn.utils.class_weight.compute_sample_weight.html + from sklearn.utils.class_weight import compute_sample_weight + # Step 2: vectorize data logger.debug("Vectorizing data...") notify("Vectorizing document content...") @@ -369,7 +398,7 @@ class DocumentClassifier: self.tags_binarizer = MultiLabelBinarizer() labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags) - self.tags_classifier = MLPClassifier(tol=0.01) + self.tags_classifier = MLPClassifier(tol=0.01, random_state=0) self.tags_classifier.fit(data_vectorized, labels_tags_vectorized) else: self.tags_classifier = None @@ -380,8 +409,12 @@ class DocumentClassifier: notify( f"Training correspondent classifier ({num_correspondents} correspondent(s))...", ) - self.correspondent_classifier = MLPClassifier(tol=0.01) - self.correspondent_classifier.fit(data_vectorized, labels_correspondent) + self.correspondent_classifier = MLPClassifier(tol=0.01, random_state=0) + self.correspondent_classifier.fit( + data_vectorized, + labels_correspondent, + sample_weight=compute_sample_weight("balanced", labels_correspondent), + ) else: self.correspondent_classifier = None logger.debug( @@ -393,8 +426,12 @@ class DocumentClassifier: notify( f"Training document type classifier ({num_document_types} type(s))...", ) - self.document_type_classifier = MLPClassifier(tol=0.01) - self.document_type_classifier.fit(data_vectorized, labels_document_type) + self.document_type_classifier = MLPClassifier(tol=0.01, random_state=0) + self.document_type_classifier.fit( + data_vectorized, + labels_document_type, + sample_weight=compute_sample_weight("balanced", labels_document_type), + ) else: self.document_type_classifier = None logger.debug( @@ -406,10 +443,11 @@ class DocumentClassifier: "Training storage paths classifier...", ) notify(f"Training storage path classifier ({num_storage_paths} path(s))...") - self.storage_path_classifier = MLPClassifier(tol=0.01) + self.storage_path_classifier = MLPClassifier(tol=0.01, random_state=0) self.storage_path_classifier.fit( data_vectorized, labels_storage_path, + sample_weight=compute_sample_weight("balanced", labels_storage_path), ) else: self.storage_path_classifier = None @@ -546,24 +584,24 @@ class DocumentClassifier: def predict_correspondent(self, content: str) -> int | None: if self.correspondent_classifier: X = self._vectorize(content) - correspondent_id = self.correspondent_classifier.predict(X) - if correspondent_id != -1: - return correspondent_id - else: - return None - else: - return None + predicted_id = _predict_with_threshold( + self.correspondent_classifier, + X, + settings.CLASSIFIER_MATCH_THRESHOLD, + ) + return predicted_id + return None def predict_document_type(self, content: str) -> int | None: if self.document_type_classifier: X = self._vectorize(content) - document_type_id = self.document_type_classifier.predict(X) - if document_type_id != -1: - return document_type_id - else: - return None - else: - return None + predicted_id = _predict_with_threshold( + self.document_type_classifier, + X, + settings.CLASSIFIER_MATCH_THRESHOLD, + ) + return predicted_id + return None def predict_tags(self, content: str) -> list[int]: from sklearn.utils.multiclass import type_of_target @@ -589,10 +627,10 @@ class DocumentClassifier: def predict_storage_path(self, content: str) -> int | None: if self.storage_path_classifier: X = self._vectorize(content) - storage_path_id = self.storage_path_classifier.predict(X) - if storage_path_id != -1: - return storage_path_id - else: - return None - else: - return None + predicted_id = _predict_with_threshold( + self.storage_path_classifier, + X, + settings.CLASSIFIER_MATCH_THRESHOLD, + ) + return predicted_id + return None diff --git a/src/documents/tests/test_classifier.py b/src/documents/tests/test_classifier.py index 12dfea4d8..0e09ff074 100644 --- a/src/documents/tests/test_classifier.py +++ b/src/documents/tests/test_classifier.py @@ -3,6 +3,7 @@ import warnings from pathlib import Path from unittest import mock +import numpy as np import pytest from django.conf import settings from django.test import TestCase @@ -11,6 +12,7 @@ from django.test import override_settings from documents.classifier import ClassifierModelCorruptError from documents.classifier import DocumentClassifier from documents.classifier import IncompatibleClassifierVersionError +from documents.classifier import _predict_with_threshold from documents.classifier import load_classifier from documents.models import Correspondent from documents.models import Document @@ -625,6 +627,103 @@ class TestClassifier(DirectoriesMixin, TestCase): self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk) self.assertIsNone(self.classifier.predict_storage_path(doc2.content)) + def test_predict_rejects_prediction_below_match_threshold(self) -> None: + """ + GIVEN: + - Classifiers trained against test data with confident predictions + WHEN: + - CLASSIFIER_MATCH_THRESHOLD exceeds the model's confidence + THEN: + - Every predict_* method discards the match in favor of no match + """ + c1 = Correspondent.objects.create( + name="c1", + matching_algorithm=Correspondent.MATCH_AUTO, + ) + dt1 = DocumentType.objects.create( + name="dt1", + matching_algorithm=DocumentType.MATCH_AUTO, + ) + sp1 = StoragePath.objects.create( + name="sp1", + matching_algorithm=StoragePath.MATCH_AUTO, + ) + + doc1 = Document.objects.create( + title="doc1", + content="this is a document from c1", + correspondent=c1, + document_type=dt1, + storage_path=sp1, + checksum="A", + ) + Document.objects.create( + title="doc2", + content="this is a document from no one", + checksum="B", + ) + + self.classifier.train() + + predictors = { + "correspondent": self.classifier.predict_correspondent, + "document_type": self.classifier.predict_document_type, + "storage_path": self.classifier.predict_storage_path, + } + # No real prediction can reach a confidence this high, so this + # isolates the threshold check from the model's actual output. + with override_settings(CLASSIFIER_MATCH_THRESHOLD=0.999999): + for name, predict in predictors.items(): + with self.subTest(field=name): + self.assertIsNone(predict(doc1.content)) + + def test_train_uses_balanced_sample_weight(self) -> None: + """ + GIVEN: + - A training set with correspondents, document types and storage paths + WHEN: + - The classifier is trained + THEN: + - Each MLP classifier is fit with balanced sample weights, so that + over-represented classes don't dominate predictions + """ + c1 = Correspondent.objects.create( + name="c1", + matching_algorithm=Correspondent.MATCH_AUTO, + ) + dt1 = DocumentType.objects.create( + name="dt1", + matching_algorithm=DocumentType.MATCH_AUTO, + ) + sp1 = StoragePath.objects.create( + name="sp1", + matching_algorithm=StoragePath.MATCH_AUTO, + ) + + Document.objects.create( + title="doc1", + content="this is a document from c1", + correspondent=c1, + document_type=dt1, + storage_path=sp1, + checksum="A", + ) + Document.objects.create( + title="doc2", + content="this is a document from no one", + checksum="B", + ) + + with mock.patch( + "sklearn.utils.class_weight.compute_sample_weight", + return_value=None, + ) as mocked_compute_sample_weight: + self.classifier.train() + + self.assertEqual(mocked_compute_sample_weight.call_count, 3) + for call in mocked_compute_sample_weight.call_args_list: + self.assertEqual(call.args[0], "balanced") + def test_one_tag_predict(self) -> None: t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12) @@ -810,6 +909,52 @@ class TestClassifier(DirectoriesMixin, TestCase): load_classifier(raise_exception=True) +class _StubProbaClassifier: + """ + A fake scikit-learn classifier exposing just enough of the API for + `_predict_with_threshold`: `classes_` and `predict_proba`. + """ + + def __init__(self, classes: list[int], probabilities: list[float]) -> None: + self.classes_ = np.array(classes) + self._probabilities = np.array([probabilities]) + + def predict_proba(self, X) -> np.ndarray: + return self._probabilities + + +@pytest.mark.parametrize( + ("classes", "probabilities", "threshold", "expected"), + [ + # confident prediction above the threshold is returned + ([-1, 3], [0.1, 0.9], 0.6, 3), + # prediction below the threshold is discarded + ([-1, 3], [0.45, 0.55], 0.6, None), + # boundary: exactly at the threshold is accepted, not discarded + ([-1, 3], [0.4, 0.6], 0.6, 3), + # the winning class is the "no match" pseudo-class, regardless of its + # own confidence + ([-1, 3], [0.99, 0.01], 0.0, None), + # threshold of 0.0 disables the confidence check entirely + ([-1, 3], [0.45, 0.55], 0.0, 3), + ], +) +def test_predict_with_threshold(classes, probabilities, threshold, expected) -> None: + classifier = _StubProbaClassifier(classes, probabilities) + result = _predict_with_threshold(classifier, X=None, threshold=threshold) + assert result == expected + + +def test_classifier_match_threshold_default() -> None: + """ + GIVEN: + - No PAPERLESS_CLASSIFIER_MATCH_THRESHOLD environment variable is set + THEN: + - The classifier match threshold defaults to 0.6 + """ + assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.6 + + def test_preprocess_content() -> None: """ GIVEN: diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index a0dc0e585..14f2fb9ac 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -96,6 +96,13 @@ MODEL_FILE = get_path_from_env( "PAPERLESS_MODEL_FILE", DATA_DIR / "classification_model.pickle", ) + +# Minimum confidence (0.0-1.0) for the ML classifier to assign a correspondent, +# document type, or storage path. 0.0 disables the threshold. +CLASSIFIER_MATCH_THRESHOLD: Final[float] = get_float_from_env( + "PAPERLESS_CLASSIFIER_MATCH_THRESHOLD", + 0.6, +) LLM_INDEX_DIR = DATA_DIR / "llm_index" LLM_INDEX_LOCK = LLM_INDEX_DIR / "index.lock" # Cross-process read/write lock guarding the LLM index compaction/migration From 1c9681962588a441b2a3139efd426e4520e95aad Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:41:48 +0000 Subject: [PATCH 33/68] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 76 +++++++++++++------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 0a766f170..5327ac8be 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-09 16:03+0000\n" +"POT-Creation-Date: 2026-09-09 22:41+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -2258,151 +2258,151 @@ msgstr "" msgid "paperless application settings" msgstr "" -#: paperless/settings/__init__.py:562 +#: paperless/settings/__init__.py:569 msgid "English (US)" msgstr "" -#: paperless/settings/__init__.py:563 +#: paperless/settings/__init__.py:570 msgid "Arabic" msgstr "" -#: paperless/settings/__init__.py:564 +#: paperless/settings/__init__.py:571 msgid "Afrikaans" msgstr "" -#: paperless/settings/__init__.py:565 +#: paperless/settings/__init__.py:572 msgid "Belarusian" msgstr "" -#: paperless/settings/__init__.py:566 +#: paperless/settings/__init__.py:573 msgid "Bulgarian" msgstr "" -#: paperless/settings/__init__.py:567 +#: paperless/settings/__init__.py:574 msgid "Catalan" msgstr "" -#: paperless/settings/__init__.py:568 +#: paperless/settings/__init__.py:575 msgid "Czech" msgstr "" -#: paperless/settings/__init__.py:569 +#: paperless/settings/__init__.py:576 msgid "Danish" msgstr "" -#: paperless/settings/__init__.py:570 +#: paperless/settings/__init__.py:577 msgid "German" msgstr "" -#: paperless/settings/__init__.py:571 +#: paperless/settings/__init__.py:578 msgid "Greek" msgstr "" -#: paperless/settings/__init__.py:572 +#: paperless/settings/__init__.py:579 msgid "English (GB)" msgstr "" -#: paperless/settings/__init__.py:573 +#: paperless/settings/__init__.py:580 msgid "Spanish" msgstr "" -#: paperless/settings/__init__.py:574 +#: paperless/settings/__init__.py:581 msgid "Persian" msgstr "" -#: paperless/settings/__init__.py:575 +#: paperless/settings/__init__.py:582 msgid "Finnish" msgstr "" -#: paperless/settings/__init__.py:576 +#: paperless/settings/__init__.py:583 msgid "French" msgstr "" -#: paperless/settings/__init__.py:577 +#: paperless/settings/__init__.py:584 msgid "Hungarian" msgstr "" -#: paperless/settings/__init__.py:578 +#: paperless/settings/__init__.py:585 msgid "Indonesian" msgstr "" -#: paperless/settings/__init__.py:579 +#: paperless/settings/__init__.py:586 msgid "Italian" msgstr "" -#: paperless/settings/__init__.py:580 +#: paperless/settings/__init__.py:587 msgid "Japanese" msgstr "" -#: paperless/settings/__init__.py:581 +#: paperless/settings/__init__.py:588 msgid "Korean" msgstr "" -#: paperless/settings/__init__.py:582 +#: paperless/settings/__init__.py:589 msgid "Luxembourgish" msgstr "" -#: paperless/settings/__init__.py:583 +#: paperless/settings/__init__.py:590 msgid "Norwegian" msgstr "" -#: paperless/settings/__init__.py:584 +#: paperless/settings/__init__.py:591 msgid "Dutch" msgstr "" -#: paperless/settings/__init__.py:585 +#: paperless/settings/__init__.py:592 msgid "Polish" msgstr "" -#: paperless/settings/__init__.py:586 +#: paperless/settings/__init__.py:593 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings/__init__.py:587 +#: paperless/settings/__init__.py:594 msgid "Portuguese" msgstr "" -#: paperless/settings/__init__.py:588 +#: paperless/settings/__init__.py:595 msgid "Romanian" msgstr "" -#: paperless/settings/__init__.py:589 +#: paperless/settings/__init__.py:596 msgid "Russian" msgstr "" -#: paperless/settings/__init__.py:590 +#: paperless/settings/__init__.py:597 msgid "Slovak" msgstr "" -#: paperless/settings/__init__.py:591 +#: paperless/settings/__init__.py:598 msgid "Slovenian" msgstr "" -#: paperless/settings/__init__.py:592 +#: paperless/settings/__init__.py:599 msgid "Serbian" msgstr "" -#: paperless/settings/__init__.py:593 +#: paperless/settings/__init__.py:600 msgid "Swedish" msgstr "" -#: paperless/settings/__init__.py:594 +#: paperless/settings/__init__.py:601 msgid "Turkish" msgstr "" -#: paperless/settings/__init__.py:595 +#: paperless/settings/__init__.py:602 msgid "Ukrainian" msgstr "" -#: paperless/settings/__init__.py:596 +#: paperless/settings/__init__.py:603 msgid "Vietnamese" msgstr "" -#: paperless/settings/__init__.py:597 +#: paperless/settings/__init__.py:604 msgid "Chinese Simplified" msgstr "" -#: paperless/settings/__init__.py:598 +#: paperless/settings/__init__.py:605 msgid "Chinese Traditional" msgstr "" From 60709b8319fa636b0e24a520fd004785566412a5 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:47:07 -0700 Subject: [PATCH 34/68] Performance: cut redundant per-document lookups in bulk `modify_custom_fields` (#13807) * Perf: batch the repeated lookups in modify_custom_fields `modify_custom_fields()` re-resolved the same objects inside its per-document loop: `custom_fields.get(id=field_id)` re-ran a CustomField query for every document, and doc link fields called `Document.objects.get(id=doc_id)` a second time for a document that was already known. Resolve both up front with `in_bulk()` and hand the resolved objects to `update_or_create()` rather than bare ids. Passing the objects also populates the FK cache on the newly created instance, so auditlog's post_save receiver touching `.document`/`.field` no longer costs a reload per row. The document map defers `content`, the one field here that is both large and unused. The symmetrical-link removal pass and `remove_doclink()` get `select_related()` for the same auditlog reason. Measured over 50 documents, sqlite, audit log enabled: before after add 3 string fields 1502 1054 update 1 string field 451 403 add doc link 851 653 remove doc link 604 354 `update_or_create()` is kept as-is. Dropping it for a hand-rolled get-or-construct loop removes a further ~4 statements per row, but those are the SAVEPOINT/RELEASE pairs of its `transaction.atomic()`, and the `select_for_update()` and IntegrityError fallback that go with them. The (document, field) unique constraint depends on that when two bulk edits overlap, and the wall clock did not move to pay for it (331 ms vs 310 ms for the string case above). Also normalises the field ids to int once at the top so the old dict API, whose keys may arrive as strings, indexes the resolved map correctly. The `if custom_field:` branch it replaces was dead: `.get()` raises DoesNotExist, it never returns None. Co-Authored-By: Claude Opus 5 * Fixes the comment --------- Co-authored-by: Claude Opus 5 --- src/documents/bulk_edit.py | 75 ++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 79a79e6cf..363193d48 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -299,53 +299,55 @@ def modify_custom_fields( ) -> Literal["OK"]: qs = Document.objects.filter(id__in=doc_ids).only("pk") affected_docs = list(qs.values_list("pk", flat=True)) - # Ensure add_custom_fields is a list of tuples, supports old API + # Ensure add_custom_fields is a list of (int, value) tuples, supports old API add_custom_fields = ( - add_custom_fields.items() + [(int(field), value) for field, value in add_custom_fields.items()] if isinstance(add_custom_fields, dict) - else [(field, None) for field in add_custom_fields] + else [(int(field), None) for field in add_custom_fields] ) - custom_fields = CustomField.objects.filter( - id__in=[int(field) for field, _ in add_custom_fields], - ).distinct() + # Resolved once, instead of re-querying the same field for every document + custom_fields_by_id: dict[int, CustomField] = CustomField.objects.in_bulk( + [field_id for field_id, _ in add_custom_fields], + ) + # Passed to update_or_create() below rather than a bare id, so the FK is + # cached on the created instance and auditlog's post_save receiver does + # not reload it per row. Only needed for additions. content is deferred: + # the one field here that is both large and unused. + docs_by_id: dict[int, Document] = ( + Document.objects.defer("content").in_bulk(affected_docs) + if add_custom_fields + else {} + ) for field_id, value in add_custom_fields: + custom_field = custom_fields_by_id[field_id] + value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ + custom_field.data_type + ] + is_doclink = custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK for doc_id in affected_docs: - defaults = {} - custom_field = custom_fields.get(id=field_id) - if custom_field: - value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ - custom_field.data_type - ] - defaults[value_field] = value - if ( - custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK - and value - and doc_id in value - ): - # Prevent self-linking - continue + if is_doclink and value and doc_id in value: + # Prevent self-linking + continue CustomFieldInstance.objects.update_or_create( - document_id=doc_id, - field_id=field_id, - defaults=defaults, + document=docs_by_id[doc_id], + field=custom_field, + defaults={value_field: value}, ) - if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK: - doc = Document.objects.get(id=doc_id) - reflect_doclinks(doc, custom_field, value) + if is_doclink: + reflect_doclinks(docs_by_id[doc_id], custom_field, value) - # For doc link fields that are being removed, remove symmetrical links + # For doc link fields that are being removed, remove symmetrical links. + # select_related avoids a per-instance reload of the document and field. for doclink_being_removed_instance in CustomFieldInstance.objects.filter( document_id__in=affected_docs, field__id__in=remove_custom_fields, field__data_type=CustomField.FieldDataType.DOCUMENTLINK, value_document_ids__isnull=False, - ): + ).select_related("field", "document"): for target_doc_id in doclink_being_removed_instance.value: remove_doclink( - document=Document.objects.get( - id=doclink_being_removed_instance.document.id, - ), + document=doclink_being_removed_instance.document, field=doclink_being_removed_instance.field, target_doc_id=target_doc_id, ) @@ -1178,10 +1180,13 @@ def remove_doclink( """ Removes a 'symmetrical' link to `document` from the target document's existing custom field instance """ - target_doc_field_instance = CustomFieldInstance.objects.filter( - document_id=target_doc_id, - field=field, - ).first() + # select_related: a signal receiver (auditlog) touches .document/.field on + # the save() below, without this that is a per-call reload query + target_doc_field_instance = ( + CustomFieldInstance.objects.filter(document_id=target_doc_id, field=field) + .select_related("document", "field") + .first() + ) if ( target_doc_field_instance is not None and document.id in target_doc_field_instance.value From 2d646840432397436b7cdfbbd685aca5eb723636 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:00:37 -0700 Subject: [PATCH 35/68] Enhancement: customize sidebar items (#14052) --- .../admin/settings/settings.component.html | 16 ++++ .../admin/settings/settings.component.spec.ts | 47 ++++++++++- .../admin/settings/settings.component.ts | 60 +++++++++++++- .../app-frame/app-frame.component.html | 35 ++++++--- .../app-frame/app-frame.component.spec.ts | 78 ++++++++++++++++++- .../app-frame/app-frame.component.ts | 15 +++- .../common/input/switch/switch.component.html | 10 +-- .../input/switch/switch.component.spec.ts | 10 +++ .../common/input/switch/switch.component.ts | 3 + src-ui/src/app/data/ui-settings.ts | 16 ++++ .../src/app/services/settings.service.spec.ts | 35 ++++++++- src-ui/src/app/services/settings.service.ts | 36 +++++++++ 12 files changed, 339 insertions(+), 22 deletions(-) diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index 305bb8a96..b520e8218 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -112,6 +112,22 @@ +

Sidebar items to show:

+ @for (option of sidebarItemOptions; track option.id) { +
+ + +
+ } +
diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 7b65281d0..71889e74b 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -24,7 +24,7 @@ import { SystemStatus, SystemStatusItemStatus, } from 'src/app/data/system-status' -import { SETTINGS_KEYS } from 'src/app/data/ui-settings' +import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { PermissionsGuard } from 'src/app/guards/permissions.guard' @@ -209,6 +209,45 @@ describe('SettingsComponent', () => { fixture.detectChanges() } + it('supports configuring sidebar items and canceling changes', () => { + completeSetup() + + component.toggleSidebarItem(HideableSidebarItemID.Workflows, false) + fixture.detectChanges() + + expect(component.settingsForm.value.sidebarHiddenItems).toContain( + HideableSidebarItemID.Workflows + ) + + settingsService.updateSidebarItemVisibility( + HideableSidebarItemID.Mail, + false + ) + + expect(component.settingsForm.value.sidebarHiddenItems).toContain( + HideableSidebarItemID.Mail + ) + + component.reset() + + expect(component.settingsForm.value.sidebarHiddenItems).not.toContain( + HideableSidebarItemID.Workflows + ) + expect(component.settingsForm.value.sidebarHiddenItems).not.toContain( + HideableSidebarItemID.Mail + ) + }) + + it('enables sidebar item controls on general settings until destroyed', () => { + completeSetup() + + expect(settingsService.organizingSidebarItems()).toBe(true) + + component.ngOnDestroy() + + expect(settingsService.organizingSidebarItems()).toBe(false) + }) + it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => { completeSetup() const navigateSpy = jest.spyOn(router, 'navigate') @@ -249,6 +288,7 @@ describe('SettingsComponent', () => { it('should support save local settings updating appearance settings and calling API, show error', () => { completeSetup() + component.toggleSidebarItem(HideableSidebarItemID.Workflows, false) const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'show') const storeSpy = jest.spyOn(settingsService, 'storeSettings') @@ -267,7 +307,10 @@ describe('SettingsComponent', () => { expect(toastErrorSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled() - expect(setSpy).toHaveBeenCalledTimes(33) + expect(setSpy).toHaveBeenCalledTimes(34) + expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [ + HideableSidebarItemID.Workflows, + ]) // succeed storeSpy.mockReturnValueOnce(of(true)) diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index 218f6683c..9076d2a3e 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -39,7 +39,12 @@ import { SystemStatus, SystemStatusItemStatus, } from 'src/app/data/system-status' -import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings' +import { + GlobalSearchType, + HIDEABLE_SIDEBAR_ITEM_IDS, + HideableSidebarItemID, + SETTINGS_KEYS, +} from 'src/app/data/ui-settings' import { User } from 'src/app/data/user' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' @@ -102,6 +107,14 @@ const documentDetailFieldOptions = [ { id: DocumentDetailFieldID.Tags, label: $localize`Tags` }, ] +const sidebarItemLabels: Record = { + [HideableSidebarItemID.Dashboard]: $localize`Dashboard`, + [HideableSidebarItemID.SavedViews]: $localize`Saved Views`, + [HideableSidebarItemID.Workflows]: $localize`Workflows`, + [HideableSidebarItemID.Mail]: $localize`Mail`, + [HideableSidebarItemID.Documentation]: $localize`Documentation`, +} + @Component({ selector: 'pngx-settings', templateUrl: './settings.component.html', @@ -149,6 +162,7 @@ export class SettingsComponent bulkEditApplyOnClose: new FormControl(null), documentListItemPerPage: new FormControl(null), slimSidebarEnabled: new FormControl(null), + sidebarHiddenItems: new FormControl([]), darkModeUseSystem: new FormControl(null), darkModeEnabled: new FormControl(null), darkModeInvertThumbs: new FormControl(null), @@ -186,6 +200,7 @@ export class SettingsComponent store: BehaviorSubject storeSub: Subscription + sidebarItemsSub: Subscription isDirty$: Observable isDirty: boolean = false unsubscribeNotifier: Subject = new Subject() @@ -203,6 +218,10 @@ export class SettingsComponent public readonly PdfEditorEditMode = PdfEditorEditMode public readonly documentDetailFieldOptions = documentDetailFieldOptions + public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({ + id, + label: sidebarItemLabels[id], + })) get systemStatusHasErrors(): boolean { const status = this.systemStatus() @@ -230,6 +249,10 @@ export class SettingsComponent constructor() { super() + this.sidebarItemsSub = + this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) => + this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems) + ) this.settings.settingsSaved.subscribe(() => { if (!this.savePending) this.initialize() this.savedViewsService.maybeRefreshDocumentCounts() @@ -279,14 +302,21 @@ export class SettingsComponent this.activatedRoute.paramMap.subscribe((paramMap) => { const section = paramMap.get('section') + let navID = SettingsNavIDs.General if (section) { const navIDKey: string = Object.keys(SettingsNavIDs).find( (navID) => navID.toLowerCase() == section ) if (navIDKey) { - this.activeNavID.set(SettingsNavIDs[navIDKey]) + navID = SettingsNavIDs[navIDKey] } } + this.activeNavID.set(navID) + this.settings.sidebarHiddenItemsEditing.set( + navID === SettingsNavIDs.General + ? [...this.settingsForm.controls.sidebarHiddenItems.value] + : null + ) }) } @@ -310,6 +340,7 @@ export class SettingsComponent SETTINGS_KEYS.DOCUMENT_LIST_SIZE ), slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR), + sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS), darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM), darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED), darkModeInvertThumbs: this.settings.get( @@ -436,6 +467,12 @@ export class SettingsComponent this.settingsForm.patchValue(currentFormValue) } + if (this.settings.organizingSidebarItems()) { + this.settings.sidebarHiddenItemsEditing.set([ + ...this.settingsForm.controls.sidebarHiddenItems.value, + ]) + } + if (this.canViewSystemStatus) { this.systemStatusService.get().subscribe((status) => { this.systemStatus.set(status) @@ -444,8 +481,18 @@ export class SettingsComponent } ngOnDestroy() { + this.settings.sidebarHiddenItemsEditing.set(null) if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save this.storeSub && this.storeSub.unsubscribe() + this.sidebarItemsSub.unsubscribe() + } + + isSidebarItemShown(item: HideableSidebarItemID): boolean { + return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item) + } + + toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void { + this.settings.updateSidebarItemVisibility(item, checked) } public saveSettings() { @@ -473,6 +520,10 @@ export class SettingsComponent SETTINGS_KEYS.SLIM_SIDEBAR, this.settingsForm.value.slimSidebarEnabled ) + this.settings.set( + SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, + this.settingsForm.value.sidebarHiddenItems + ) this.settings.set( SETTINGS_KEYS.DARK_MODE_USE_SYSTEM, this.settingsForm.value.darkModeUseSystem @@ -632,6 +683,11 @@ export class SettingsComponent reset() { this.settingsForm.patchValue(this.store.getValue()) + if (this.settings.organizingSidebarItems()) { + this.settings.sidebarHiddenItemsEditing.set([ + ...this.settingsForm.controls.sidebarHiddenItems.value, + ]) + } } clearThemeColor() { diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 6faddbb8a..a8dd12d34 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -86,12 +86,15 @@ }