From e23e7809914c448908da70a2007502ef5f059e35 Mon Sep 17 00:00:00 2001
From: LeandroJatai
L2?9GL+~|;-2J)c&t;W>UgQ>}ygHXr} z+k^h%1#c^a9E0ywD*yQG7d2%%ZI zok;XQ0J0$E_8E#`cTL@vul%GywFH0c&B2EHGIBF=?G3T9$VcXyQ66E7ys85vE>MO| z)v$w^1`sN0sGmx+8)S_|fLpPYm5BTLS=Q9yRi2|0TBG_iTNSG{jjodDMT)>sn!exz z0|;}0NWQ!SmS?8cWz+I4- Ro)%=hB=N=2WatdwQ4`>A)Vu z^RjH%JPTDwoNSurV1S|v8qsvX^{vtPREJQy_hg4>%Q%ccEa1_@tKCBLg3DKmeK()< N{tw}lo8zwH0sy~EL4p7P diff --git a/sapl/static/sapl/frontend/css/painel.e2b9504e.css.gz b/sapl/static/sapl/frontend/css/painel.e2b9504e.css.gz index 155733ba233148483c8cf32a70c6bbd20a66c743..1b4bf2f314914f29b3f85d745bfcb1b0d9a24348 100644 GIT binary patch delta 19 YcmZ3$w1A0AzMF#q1elmNa)~hl03)~p*Z=?k delta 19 YcmZ3$w1A0AzMF#q1ek<3a)~hl03-+k=l}o! diff --git a/sapl/static/sapl/frontend/fonts/fa-brands-400.5d18d427.ttf.gz b/sapl/static/sapl/frontend/fonts/fa-brands-400.5d18d427.ttf.gz index e14c40204d86a551732974a2814f70ca600cd93e..d407ce00bbdf01e744f61709779dbb0a2a9bc851 100644 GIT binary patch delta 24 dcmX@HgZ;z~b}spD4h9flVs7Nx%Ef4R2LM B21Nh> diff --git a/sapl/static/sapl/frontend/fonts/fa-regular-400.81482cd4.ttf.gz b/sapl/static/sapl/frontend/fonts/fa-regular-400.81482cd4.ttf.gz index f8b292ec733462422694bd89864ad9401ad8115e..edeb41c64652c287e699a62f9aedc295c0e0e7c5 100644 GIT binary patch delta 21 acmZp>#MpL;kxRatg8>AXm^X6$$p8RC5CxL} delta 21 acmZp>#MpL;kxRatg8>AXgg0{i$p8RCU <>K7R#dX>m E0A4f <>K7R#dX>m E0AOScNB{r; diff --git a/sapl/static/sapl/frontend/js/compilacao.d3adb1c1.js b/sapl/static/sapl/frontend/js/compilacao.148cb66b.js similarity index 100% rename from sapl/static/sapl/frontend/js/compilacao.d3adb1c1.js rename to sapl/static/sapl/frontend/js/compilacao.148cb66b.js diff --git a/sapl/static/sapl/frontend/js/compilacao.d3adb1c1.js.gz b/sapl/static/sapl/frontend/js/compilacao.148cb66b.js.gz similarity index 98% rename from sapl/static/sapl/frontend/js/compilacao.d3adb1c1.js.gz rename to sapl/static/sapl/frontend/js/compilacao.148cb66b.js.gz index d6d08faa554e886630c50f6ae557fac653aa0fe1..9389026862e3d26c613b91b922ce2ea6afd6f252 100644 GIT binary patch delta 19 YcmZ2ty2O-AzMF#q1elmNa?O$g04y&9;{X5v delta 19 YcmZ2ty2O-AzMF#q1ek<3a?O$g04#q4^8f$< diff --git a/sapl/static/sapl/frontend/js/global.8ef6e932.js.gz b/sapl/static/sapl/frontend/js/global.8ef6e932.js.gz deleted file mode 100644 index 3d44bcc617b50deabdbfc84861aefd063e8b974d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14144 zcmV-GH^0aqiwFP!000026ScidbKE$#Fu0#zLE-k7MOsKIQ}UxsiB`z6U9E1*c1y16 zhen}5k|#4!W)dtAR7xe~V&<1j$6a*XFQOMScV^RD?eA}z?;L=yRJyu)aU*Oc2^_o+ z&Kuy)lP&L^(q1ML$V>2KK2>@lj8wT-WFSiLN&s(`sfozKZ(;fzUheINKbGP9ljFA^ z&fkXS#&}P5{x{zb;#YUqLVGDsIB*f)efx6!BJzP7F6ulP3;*f!m%9O1vG8}FJ>3iT zg+G42`!e9de?EFizq>D<@8a*?v(YHvOPQx?8ETknDCcj4flo5|0y>^nd4e7FgF6|f z!azakVF~69OhuKM((h$xm`Rn3?1B|$`1*`p23-1#AY@)th6NpHKiJ0}L!7FlT0m~X zpDNI|=a4~Sls?L`e$jmuf=K2*@TOnDO>hU;pCe p5XrWYYL-pW!l$ z_SJz1GstIVzOS~oZDTr?q0w^TiwkubV52%Lt4V3J%xC_HgDbH;4&pe5Z5FfO&crDs zDuwUQPL9>0P&wqLSFTek*q>++Usfv(rqa1*ni;?e(x)mFS#O2?&WjzE^x3~H5blbK z;Oa?{shNL=?;i`uqpdL~h-hoXmohc;=-Ft*=OAZu6YV`6@p7(~(H%^ukeG-~GMT50 zr jD zZdwKddbt*v&sea+8gsRPW_Si}4CZj~*}uuOPVc=+arKq-95;58xgy-}^tgcXr%INa zJB^?}v8HQOG?mvw4P})Xkt!@SmszSI_k&QwLS2Jz;WjQ2Qw8L%q7t_hZcNM<#%Q0- zWtu|Hc>Pd!_ZK^9x9i OVrZJf!uS!6ytC<~E$NhZoN{zhb=%{Q+ON~2Xid-VbGdA0DG zLI*o8<6s9%y<$NS`6ibBHwV-U rS8vMNvSW;@A&Vfyu9$fnSTP0>&Yfsl#Ik5x0YS zW!LAx)x{1bdgoR9ZZ@EdU6iA*Oz`+%!#WjW1|@E{#|;-6T+1|*)c&a|jTU*Tyz$5@ zv|NDHs`Lntp!76k!pLh(@ybegld4QaNF|%~c--x;;4i|p3+35~jO;2x$^vd;u$avu z20NFQ`aGr;V4K}Q8#)wcb3V3U`o+IeYBTUUz4=OmYe23d(U6HGQzhW`M;_VblTI8s z73G&cn_!`V45lgv7K95?e({dG79 zUuF?9Op0k!!DuuJ-T&cev|hp0)m7;J4?E?d=$5CFn~GAQrj(wwh3Y0B6{=gS3mIvu zOkF_+(oSRBYS{m?sr|73r>?Xs71j0a(7#R9?9jiq^jvAdJt}{Ziv`4(a3yn@NOk!* zV=5PqQ!hI$M~@qw&!fen+uqfDQTB^`tyEN&?c6OM4fEHPOtiusXSDiPimq1t+h?Qk znp|A)DM`duHrCCPjD6vc$9pIZfBWp^w_Pdu+m}yA`1|7N%V+re?5Ta pC66-f_h>mptbs&p3+pRb2RHF3W(= O=R(NyXZW$bxF zMy{1iz0p=2&t3IYJ{K|@Ryt$Bp nX+J;4QUXJ=do{^mX)4ap2>pXz!$xAD{A6+W$dfCnVp*LQW4!tZ;Nc9lu>7i zR1{{Yl_5TcL&>*dIT-kPthkCb*Kv-m6h1qUi`gs2?_?SQFLe@qWsX(>&*Qs=%+KY| z5M4au&o22FxIM3mLTQ7-v(XjUOvA+`SGiSxqQ|;Pxo@R%hcjy5xHeIO7%qj*Wj@1R z`yvLeaWYI0mJ&toS?ROzN5TTTV~}0O>hWB0)l02sii>c0An)%*I6qK_IuvPojAGMz zI3*CLa7IQlqks=gj0f{-olz1(_-IEh_bF~mR zS8jf(;6&a05nto406i{UuR~q@xAY!=4J_bUyfx-*$Pl|pl(`sP4hDV}Z;iN3ap_KX z^v3};OKFE`Pc@cPpVVInoF<7 `}_) zs_s+}`Q0v9O6k;Ql*x7s>dWFlJ*oS2G2%MzAcwDpVh4g9v~qAY)R+4Xt`8-KSOr3j zxEN}#E>}KoFJv5UHqs7;1-uC1#-Q+;^Scs^+pOsdp&i*1Qv=$U0WW;^Z)_Wa@OOwx ze$Kl(>IGcJNa$DVL@3MDayYHB3~`fIJR1yrgby{4y~wY-#)fRWo-s@Azd1 {{ m.resultado }} + Resultado: {{ m.resultado }} {% if m.resultado_observacao %} - Obs.: {{ m.resultado_observacao }} {% endif %} {% if m.voto_nominal %} Votos Nominais : diff --git a/sapl/templates/sessao/blocos_ata/materias_ordem_dia.html b/sapl/templates/sessao/blocos_ata/materias_ordem_dia.html index 8f14181cc..92ee3789b 100644 --- a/sapl/templates/sessao/blocos_ata/materias_ordem_dia.html +++ b/sapl/templates/sessao/blocos_ata/materias_ordem_dia.html @@ -25,7 +25,7 @@ Abstenções: {{ m.voto_abstencoes }}, {% endif %} {% endif %} - Resultado: {{ m.resultado }} + Resultado: {{ m.resultado }} {% if m.resultado_observacao %} - Obs.: {{ m.resultado_observacao }} {% endif %} {% if m.voto_nominal %} Votos Nominais : From c926b75c05e63b214184e1a292565df56d80e8dd Mon Sep 17 00:00:00 2001 From: Edward OliveiraTt>VUX{XKo9KSu%&@%S7EU(qO*X_LF$67egGy_BYJ44C9;aWcwQg%nlig zzVWC6NL@{kvTuTI_Ud3KmDjI`J6T5H2jNtr*dNXzD;jzAa30FM1Z`f!RB2>yo56!< z?o^b|ssmI{bFx9)M{JMdI6L&ehVq=|wOPm(qQUNfZ;gY ~xQZArF9!D`cBEgdmh zpvep67*&;!s%75m>0sbD JK+ZARM zGM~kgt8Slk&pZ*LsN*{gVfW>WmuvFXDRjiEkASwv-Mwc)m|8pGzVM%pU-YD@m!s!a z*xDNh+&_g_xt$XC&3lbG=LU(9BSBq05NxyJhOHX7FCDn^5bY7f6t;2opashi4B}I? z5`}6yEdkX$M<&pYHZ>eVql(M6jA*OAAf*@nH2fKfo_+cA@aK#1We5I8glB5 Y}+_0BI0l7IZ^>T T&vE8o1^grKjpIs}O8X4?Izy|>Aw;mv zP^Tvn#oL^kLwpQHV&t_j@LQ3m8R*hSafDAHTndxSe-F2%AM~t4??SBQqg{s! T~d32~8s;KCY_txRO%cW8-vCvPD2QS^F`3VLZ%kCo0r`|tfc z2=?FMz&>dxOGy20b{+)$of|c6 }ZzE#`P*S=|Xdv`G!0+7m5 zOfpFC?;AWdgTdB$NKEcjUBmr-jqeKOqu=_5<3>kwTxnGCNvlH4gxxc^(=}W}UVA`% zViBDVy{)$q;bjaVNp(Idoe)|H%@6hkDkbJX4F;nFb4arKP6PU+1&nlnAYw4^RqX3n zAXuIGq$v^bN!!wn;@6$Fb~LZER8Xht&SL-6&ez>d1mVq-3BQdXynS-bZ+0 EEeBB+}eQBR&zyml^TwW0ff# zvE^JEKx0pbD}RTb`@LlVI}@V*JJ%eQ2724O+_}CC_<2~f#V`?B=D*=T`CWtya6oIQ z)VA^aW&-N9q2{Qu%Qf 7|3;yDZk{vWWOxk%yg3^&6$KH`ie+@<3h z(*u?2;7q{h5O>}R*rL~ucK4!s0^J1OHq>+{qDg&SgZKrq&>}t$7vjcE&;9*~|D}^M zrj*~rmJhTr0G%WY^mq hfd(h>$|+8zP8br1Cbu(>-Ne(j=PRUgSG?eV;lJSk ze8R7};s&_p0=NRMflF|P+8lTZe99Mm#+N-I{%(aLJn1mC-ctrVgE$x0awd$@VO4@Y znqj1XQSa3f^s!}VDa1RAQo>GXpfr94RF0Coi}CRJ<^LXbvP|r6QYuK5xfopr{72u! zJqQyV-mM}N@Vkl9WHHatf5We3DJL>R0?p_-1I8CZ&tx8rcp=hM<}-X!rKCO1#H3VN zg^?9T714;Jo)nE%0Z(oFJOLzvx3SMoPQPQs$i(b}Siqq_+KSVQ(WMne27~(5iX?}A z>ZFtwCg5o;uyi2NKI99C(T->rGc0vt>ne4^>v{w{szpFk2lubyG)ywuYCRVxTB3=1 zq5X8OmVOY_J!|EytEOX(poQ&N>(5oKdIynhrCS-z(3oSaNgT(`&aC%tyfq2}{?@<3 z?d>du_R(7Xu<523$8pn-fF})~_Q<1ap>7+Q@3_ylYfG>l>s`CV&f5y?5zB5U#42!B zn g<4U}NXmcr^MStQkT5N*(_BBv0Wc+8b|1 zPjwA?nyF<}d#YCfKl)c2VYU&*_jX4C|GZXwZQk0pUDuwEMynu9C)rm#(LEU%Wtv*2 zucaCYnu&Jiv8!gLE8s@~uVOzz P31iS8_G9GW71VW~pa~fUgU?Qe zb5r@}?pz{DR(UG)E!rNx_nRuWcb`;kwf60+GlfLt#vfLJB_#IIP2cWIoW9mz_Paip z;%h9)$?2csyF`O9@J5T-40P*C`R`E_KZVIuX1EK0JWBtX+Q@Toq6$vThJO97-@uBy zmrbzFBo#xbw8Vgn?Cmv0rI9fy{fxQ=lt?BCuC`kJO87r>SaDf8=M|*U8St|9WQ~ZF zk@n&M?(et8j}j02uhALG()?ZQFXLIfh^O(0-%T@F94n>MGWvwPwwBU+BZl~A;x@B* z8k6w8j2AJ=3M<}9?;A_xKm~>~UILXEF8Qpk!`3hZV+~PV->x(J5&OSf@zz?k!B=P1 z394;>x@`_d=yn>$W_udWv783}e_{1pN45xs5v*w&UZWzuZPFx;>a}qyA~O z+eUUZ-Hr{P;h)8J41BpA1NGDzt~PYyGx)*&x(%l>tz4M!W|y1r_Hx}2gE^gy@^O*f z%XOnnU1s-kMV%s|7JY_Qz|Ue|62JQwiRbRq5pR^o6`Jwxh*+SwWN8KR?Us6J3OQU$ zoG4?|f>Kxf&T4PbEpYpECYI4L@J3XQjtJjgTlx}R0k^tG^aZ%14$(PEgQ%)ReXDCp z7Gpaq7yfwT8?>yIA$4s-)`~k3w2Nnr4F3fi3rKsE<c|vK>6Cm$wHe@3=;#AldnV)$q1|5^ zp^f#7F#aj*k!zx&N1bRi)9Q<6Gv{1W<-;2e;q7BZ9+4L Iif*iPKJE)iMs2=dv zs4a#2Awe>9R6E6*tP&U4heO|x=8|JzCrRis2%K)1@ig9hG2&RmKAHJbEoN_1ig7#z z+@BQBaNfb?iO^-x#E4h%dLEz0qy70oRY!);x3`1pVt&cNr=N6tySulnA2GFs83l}Q zXxN!V+IEY0Ez4@I@o0Z`P}EJ&wzq@gVs^==G5suJ8~no;F{<5I4mEK;jlrMVj`eJN zHIO4pt2I4?@=(ZM3 -i}H5?=llBKd*5VoA^u2WbgTCwTX8e|7zY*H{6+^ em_1e2HflQ(z`SPa5();_l#TbhANA^N*RpC$SXH^+|;aB0rs=o7p z->o}MqJET6Gr6RnDZoHDhz*BW@OjT_@bnqVlyLa9t%LolvFsXpS&*l7&Ju!1lAZlg zEzRg^*ctS)s-n)oL2iw|h7I#a*sww`5c&d7!T+Wg%A-Xp74z^Y!8(zdqQt_i(7=Y{ z-BEGF+<_f7-o^Nd$IRvn+%9J5&JyseDcED9OT7^_ZlyN(aWL>TzV<_5tRx0uVs!R< zxV^uJZ~;dA9&R0X-x}>(?PhBW!g4OB=J#-WI37jgM%BSuk5%ea9&hhOv_JuPX!t1T z5NmJmrOkMh{;f?P@uBHuJRQ{;{q)@z(VFA1 <*RjQUw?f8+yc}3u5jhr=|^Akx-lu>ca1x-yUQP~loP3RI8{ki`T?Th zej`G;c@&K|2dyQ8f!`16zzEmYiRMVLPv*|Y=8;WSlvZY&LC5T&z_ C%D{0$fWB5i_T0`+ZD?jhFh&Xg7@9)tXGUECA zPUN@_e9eV6<|951_(?5`T!H_URuQa&tcQIw0Urm^3HW0^?yl*TvkUyeKcZ0lo^SS? zx=R7a&6&KDnhcOZr`z0Gzk5w% cl2>u| z#Be{2<-zE1I;^5 !4*fJ9@p)URpAM(NjzLdTelR**4Cm2&dl5vp2kBtor}6Et z0H)aP8K1}Hj^Tcrx7eOjlSw?{Ri~C@xCovYbT4*07aboYgMpvK$6a6xy9OHY=|Osk zy*UsV*}g3zb&w9H)Z^*4!VWGDlEcNe>v6Og)_srC1B9CnQ(Jb3Wv4?s+Uc-cb~;R5 zS^E_56o)e%#wkzZQ9VicKD^~=d>ilt0T#nJ;RyoRA>sS*m?!aZz~6!Y2{rBoA{B=H z`kK~0qqQ#vgJ$G9cvTDr{wy9H=)>uD5lx3h!2i;L$bwgY84UboJUYk@7u$b{7Q?>; z9AM|V2XtUl8aRB77DN0A_-}S5y*3Xhp=tFuJCkTS{LRh;ryNb(vtc~t(_xHOZ%SAW z;{{&~<3+${lrSB}Gd>%}vw$xtVKI!Cd^wDlWCXVm5YL9U2nA# =as1p}WVmqTVz6`p1{^y>&a51{HMtTgMM3>IRxK0?mgwf@z+Zy85LcBVz zA&vun(T!0iuY50Id>M2Cg+IaCF$W4S6+Dj(FJn~`6LA2vg9Vp38g*dY@uV^~0l&~Z z=aQG5df!1mwh#Rhw-ho;k$o83gmHa%0%J+9Ljsvo(vZ9%ZDnU2q8Hz-xQH)0IyvL5 z=2SmJp0Z1>I`<=xy8bo;e50Us7VH~+dxs_+h)4U-UX=jNRS9^sOUF#*Y+GEwWgv4; z?ISBy{i`1|XLhLjbiSz#gpTV#Xmz1418y#`n#))=cO!)DIM7yIcfNVXL9G(uTq%w4 zzUo*h=D%R2=sNj=#sta#gu*ZBGjC!%PC~&9tr)Jk4DPyNkAHMmX5?+?!+svBJX0c# zA9 r&Yi7|xJ?sNpyEzc!G0Z70F7aeA`LozWQ_tq{~e 0#4yEugZ^T4RkRLGzp&<4bRb{&eP==t z-f(mWDD+b(^iyyhD=txBpbUmf$ zWj;GE08(DW8cR=OjRhC6#xk>5Blu}pTZ0fi`Mir+$#(3IN26g9>{J}9=pLskwv(Ol zX!IlxI9A)un!2nKvvzw~3zwB))?P1b=CaZNC54wS!*37b #|BZ;;q7s*@9_B}T? JFf&q&p^m(IL#N+ `Ctn+Jyl$hm z2SaSMq>hn{?< &^t8#J?3!0pW&fJ*EDFY z^oF*P0R5WgL@*eLy15ma3hVAVXI`&kUl~E$-wm;}@m Dk5wM!USWxkOJ#Bi u 1FoaoUNb@8xh4@ccsABl8^A*c|afjJBySVX*VX zRZWsZx*W^zRT&Z^J~9FK#r=Ih3olEeY^0!+gD-xt%2Dkt;>V$vJ{*ikhit?m#)55F zafrvfcY&fiyy*J24cpr*uJDo*VoMiHvu(eO8T?d1X^!fh@=ntgFPHcC=7KF@@ UBdI OieWCkj4$?{!!!OIp7Js5@$u-gKK{Q?ivNM1$0K)$p*Am} zeIsT=bU(&rJmOg_>oVm*wlCuC?J~&Y5%+a0FT`bV5Q7b?^mALwG*ejljt+t+I^ceO ze?LAD_xA~1l7cte49EBP6~3WflN_cO;*u7yphLp7pJh8)1-%&~2Nq4)xR*Yaxh&`Q zq*gaw<_7e&$mo8_UJhNB7LR&qnJ8(w){+SaH>4x@dr>?}EjHCr@3gxH#gV5as>%&I zV(|stVn;Qtw|gWS@$QlmpP)-j3964xwAETXY6wP41nlnhEg@JtyKZ71VjDOoOLuz_ z3l8yiI~Fm#a*gf7g}L-qXnSA ~C`m=9bwvaoQJB4oxHi>{hqXT1(cI z8E?MZ$&R^3miv3Z_l{nmwH~3REX+xB60r&GN+3|!tPqD-4XIF#CcO6+D{c<0{EetJ zj3H{@Fm4!EY%7lOK(TSujq(m+aeu$nOL`Rxx-X|mh=pye#q*Jy9ixhxnop&Mng+ik zKn%!PC&N^qOYUr^^@WtWe@$x#i;R{wUes2C_v-rVh(P$eVRh`vIKo#iOhX5oLAecw zuBPs=9lOv#S4OPOf4zWqX}R)y*kS5ca9-Qfz{-b4f9Or%IcbZJht*XD-K=yf?u};L znPl)cwm}NvYK<_>W*K~~k;BfaM?DU?PJ|wtCD)KQh8mrTVD+@wW~VmjkE4r+$h7+_ zXc|PV$)VxhYSCKmns#)f3%p@c-V1cj!y2c=74P9AGy(!`fOh#=UM(h|+bcLW3Loo& zM>9^e91i2J2rPZ E4K=q{`qMKeiJf6lm@t_a$S-n*o3U3hzlo3N-i zeXA9hF`hJ>=AoUA&00R!by!k;95asNxeqbi-!r^Y$6eTlR313QE{*GWYs+!SzEB(B zwR9KilGi6Xs6d4BwzO6X61h7uskkI8!uzFb;aodn*@Yt1W|1BYv?FZ27$=sAV!e_~ z6#Y7^Y0F(af<(nWT2iuB`T{Qdg`EaeA1Lv%(537 *r4Q`f|;w-Ly4Msse;nW zmGR(4;sr>n4mDYISF08g#L5~RtZ^h*@n=u>_9D7z-N^iQkpP-iPsZQoJ}b=C>odmh z&LCA0JA b9{XRrF%cF4CW?E8-cH?xQ*DcDkQ#r5~Trr&G3y`brtXDVv&8J zyK6mDrHXO(0hV6vI%5(0psrQ(t{DILC1M}HFn(+eyUhMl#Xrch5}7AtTC#{8{aB^K zyQVb8U#l#Q*aw(tiM5; jURl=eg1H3!uhX&E^q1 zlVw85RROw8v@A@-&ecQ%zF@`Y)uc(MN4UoMVaFK=?YVu2m-0pIJzeJQ(!|5x9{wGN zf0W5t?qMtsj=}cBd-%URyrLR(DjJXkP{3vpJI3pwMJ9iyNwAqVO1oD W z8acg X& =w^RbmG&lDElbdZ|M@q@ zb52PbDB@R=Kv|+zZ|2}7nJgws=rr^`*$)s@B~Z?>(b@K&c8+&`@V>w;!Fc6dX_HjO zD*7POkWjVqa&Zkakt%KHrmV0<;R$R?QOic*WIy~>gYZ)D@Pg-Z3i{!H=7|)c_ep7Q zDz25L_VI3KBeJ qH-cxJ*<^3bx*6WM z&Drhe?6x_3{hYlv=V?FZX`Az`pYyEEdEU=?ZhK7pV?&qm*2%B*8MD69Y0UacuQBT@ z-Nvl1^c%Atcl?uvhBrp6d81``Bd?K& h?4P>+)|vk3y(Ic11ckizcgh;Jkf5ItEDIL^BDqd zRd8dxg-Rjxut#_sp0|`)Mo<&*3M`d+qU^#~xsjQ7t18cOCu{w(SAlzKIz=S^=p|}F zcakSau01Ht!(TlyQ(Abiz$O({rm*5%g&r2j4LtlKRbC1D;jdJX$}f7Lo-hyp;a#ib z;eUE5l$o5_>V>BYB7Py`?48Pu_f9NicFQ7qgE|!rp-9(1i-pMNL?_6uO+N_rn&<(U z^+fK~SDJp(DycMh)k4^`1>+yjBKGlnq*q!M4X`%{jV18>4bFL6!V^DLlJV~gG}$A{ zXbm%@O|)8Wu^1=PThm;}u3YRbVnV8k!XHbWWP~n%*GZKTVwDL}$`UI%|EUs)SbwNQ zstNBH-I?JXWyM^uh>6gfgkBPQVHh@<-)Vp^|BGAx*Z&=Q|MmZb-hcgPKmQCDl|@X3 zo}~Y>&i}qBI+Y)V-s{ji?q}A8PeSj6{-3z~qdb|T^WfqC%oC*r;~&U^`#~*$@sl)S zCwP_P3DMIkOFfd6yrndAPk0#^1DbnNg+2^OJ(Xv--sIM0l-#r0M9IBWlykseWlE0t zrScLjycP27GQr#O5qD2gFNJGB%)>3M@qtz`1B3U5moTejsytCZjynTCP=h(x)Wbg( zpjF9{U_bl~9}1NlMUA3~m|i?pd8HE$QdNeQ6M1uS4PKR(;u=!V9wRS3p@FtqS-FR$ z$PIEMcP|QfVkvG9S;T&zEn#qXRjJ4y{w7MtT|{;*%Ft^863_;I7T!-4;2we>g;xrx z4;g1?P~v9;Jk&V6R11 EWm$;1C! zGJaGU+Kfl`GbC^GMA1DqIGrTrxpMFDg4;v` p}=`BD5x% zX$o2s{X`bomH-<-=M}=tI~6BlA@ey-Zw=Aw32~ uDcG$6CVsgM)lErcvBb3)RKRJJNnmb)9R8d_?FmZ~H<1~d4l zx+V93LZHagTd&4)(ioj6{_qb2MD~8GwEP(bzRaW3t*G(jbK%XK^p^SoQoma0sOcV9 zx>w5`jG`Bja@TvhgE(n-b6bMj*<4=QiZ5^E0`ig~beMg9`2V0J!kOb2Wl&1g3N;gi zVG%nOs4BX|hre0q?;r;)vWR_$Ke(Z yla=+pSq2p-6?$SZs)>Z(eAmm$!vyi14iv!wv*aZNmH XiB=aFH81?38(z$)cGAl3;(J zB6SiD9cs9C20D;!Vh#JYi&t+q;**u&aiKpJc6FNxK%l=UweV!#)wF+Bs>xARPfxd5 z$9Qoz@>eqFxBwG&25F6YnEX*otj@9x_u}yPHL>2D7{qHWiG7^Ug~qR^wcW_2ktzI) z{2WafrLp=IiQk0I8Hs4ukm2AyJpBC{B#kXW4)>i{EJVb<6DTyC R6~COn{DDQ*%-aPj%G`iKtvC zZRwFGCPLn*2nF~Fa=4Q^TQh!&y^dJZ%MU6i3q1-Ieb%L%S_TDLb*>?!zRkcRc!oMV zOsqUvJp2`}7Hc{$N-~8oX9e#VN63zJEn$iB*u#Get-;zFb3bXBBTZZ2$KfQ}oy9fO zG9lwA>P76mN*d$unUnTV#b(w5?S!|2R3)3;&FB 2y;Kop2 z|MNyJR4s9eJoTKc$vS=NNsvU+(GGyl3j9(8Z4Xj2rxJG;-T536ssDFWFQr$gvP80< zd5|Z>8W7 4CS9dHu#X`{NwR z80lWDRlYooU6Diz&kM*h`WOYVL$vQ|PidoO`r)rr@*JGZa)lNf`n9};Qrv{PWH*~O zI3U7FYfbh@D3td`k_IL;@`0mNI=6_R9I5hJ%5dA)V1#>T`x-H^#Qrm8{E{jvTG%)C zlGwVgw>Ori)cMDdp^N3=Ke7s6&v3)Nw}#4Nl~s$J@VO#HN7!F(HL2@ir=VxxC6Ezg zKbq;PAoBp_lfePMMM40ynAb9ux>MdwfhJ=oo+a+{;JHB249Y$v-BS7pFoi;qu
From 55f9312ae1eb2056216761700353c55381c8a694 Mon Sep 17 00:00:00 2001 From: Edward OliveiraaJBxgQ z-Yp6t&9x4)p2E<>AB$`DqTY2tiaX2u1&)QYpuC3} luKx2x6{IF5s;CXY6oTD12D9IKl*M}K-fvN{+L-eL g|OMZ z!bJy)+@`9@w!*rw3XG%FYTEd4{#OMLf6r5yppn3)qGJUK;vA_1Ifqc8x2gdpke7(E zW$um{wI qlW@{yAv9v XdHC)n`H0XU*-IXeY)H7Z5_Yp}27Qt% zVh{gE?b;<(t#J^#0#fBrH$!S1*y* D{d*!j|a?|#pI_x|PRTl)>Fy_aJO*njctDFy8By`cA}FKqz-c>I#S4Ek)> z!tXuZqx_dIsMOx`=kD`EvGAXb5LV;&M8Dk|*<$0DF9Z78Xqe+cMaq0WdO>x)c=2>> ze|PCaSbN_-rCP_&cVF1AjM7 e-At#Q#2 z1(n_q2RlAM#)ETPKK|ZklXWq^91OZ|9PpSjhO8(Z+55}kkfL&h2EihXtEycXE?Q=S zUv);?`_>pktrAGDv{L43V%>!W m&pFYLU)VGM*`M&g{L^L&h zl6dvtFCn(i{*4IFP^0pJ!CNmLeV0zfcP!i~%y6Pvg!_`$ui=knonpJYlcA~xec%mQ zO~KWE_Zn2ZVOsj_<+q}Di>!24n~6~Q3NJLR4;Ng( Bl$Uzkhr6;p6A4cOSq1@P=_%(Vc4L b0x5*_{RhsVOVAAI7CqeJSpIS9^4~ zT5-I3!3N!#x>as?`&{8EKwFe Fg=AC Ddh# zKWCfA;C6_K>0?b-S5Tf3q8(!RBh<&+C>XK{%dW!IH~dcE#n0T(cX4SQa1H8ip*_ws zK@}>zA3qojw(t??(am5AQ<+2aDHq^^-$7n2K$G0FHNvCUk+~3;$lI%M7WdCIx52b7 zx_XwWiO4?VQ7YHdHihmN=>&q_-{U@O4tlUo7Q+jFn`=HcHRJ+CT^ajE7hzVRs%OFt zvW<%Ff4RmYKZ5cq)U*!Bjc7{J&Nmxx7Za5Y2KF~J>YQvspT+F!)A$&c-%)HgsQ^_* zU)%axVb-DmtBY-o>;mMA6g%qU7X!?^#4QNT#aI}u-n_&YmjRxXuEHPV=ilz~B))t8 z?QS%}T0Vw9Vl8+vRdNBB_#GREJMBrc3p#3c85`P;a_Y;lRCvE&{0jF5mJ94QCOpdt z&vHU8zX}%@;xf+hG#G6$9hZKExBTEau)zSogR`F<$nFkTvAoETpeR4#Di{os3w3G# zUW_jBt15WzaMW&qk9fS9`w*9qO~WNj3Xyy{o>%#oQWV+MRFeXe{*&zc`-{r}qmbX? zVJLSyI-R+4*CAHrJnkmjB%1(1;BLXh?zp>}FP@E_UUJ;6m5 5 KiHFvQiU0tEe$}4< diff --git a/sapl/static/sapl/frontend/js/global.8ef6e932.js b/sapl/static/sapl/frontend/js/global.bb3e904b.js similarity index 99% rename from sapl/static/sapl/frontend/js/global.8ef6e932.js rename to sapl/static/sapl/frontend/js/global.bb3e904b.js index edf1f467f..9304dbbb2 100644 --- a/sapl/static/sapl/frontend/js/global.8ef6e932.js +++ b/sapl/static/sapl/frontend/js/global.bb3e904b.js @@ -1,2 +1,2 @@ -/*! For license information please see global.8ef6e932.js.LICENSE.txt */ +/*! For license information please see global.bb3e904b.js.LICENSE.txt */ (()=>{var d,c,e,a={8917:(e,t,a)=>{var i=a(4692),o=a(2543);a(1629),a(6099),a(2762),a(3500),window.refreshDatePicker=function(){i.datepicker.setDefaults(i.datepicker.regional["pt-BR"]),i(".dateinput").datepicker();var e=document.querySelectorAll(".dateinput");o.each(e,function(e,t){e.setAttribute("autocomplete","off")})},window.getCookie=function(e){var t=null;if(document.cookie&&""!==document.cookie)for(var a=document.cookie.split(";"),o=0;o Nenhum resultado")):(t=i(''),e.results.forEach(function(e){t.append(i(" Date: Mon, 29 Sep 2025 14:16:38 -0300 Subject: [PATCH 21/57] Refatora rate limiter --- sapl/audiencia/views.py | 9 ++- sapl/base/views.py | 8 ++- sapl/comissoes/views.py | 11 ++- sapl/crud/base.py | 96 +++++++++++++++----------- sapl/materia/views.py | 40 ++++++----- sapl/middleware.py | 3 + sapl/norma/views.py | 20 ++---- sapl/parlamentares/views.py | 19 ++++- sapl/protocoloadm/views.py | 27 +++++++- sapl/relatorios/views.py | 67 +++++++++++++++++- sapl/sessao/views.py | 15 +++- sapl/settings.py | 2 + sapl/static/.well-known/traffic-advice | 5 ++ sapl/urls.py | 15 ++++ scripts/test_ratelimiter.sh | 9 ++- 15 files changed, 257 insertions(+), 89 deletions(-) create mode 100644 sapl/static/.well-known/traffic-advice diff --git a/sapl/audiencia/views.py b/sapl/audiencia/views.py index 443fc81e8..d5ea7f710 100755 --- a/sapl/audiencia/views.py +++ b/sapl/audiencia/views.py @@ -9,6 +9,12 @@ from sapl.crud.base import RP_DETAIL, RP_LIST, Crud, MasterDetailCrud from .forms import AudienciaForm, AnexoAudienciaPublicaForm from .models import AudienciaPublica, AnexoAudienciaPublica +from ratelimit.decorators import ratelimit +from django.utils.decorators import method_decorator + +from ..settings import RATE_LIMITER_RATE +from ..utils import ratelimit_ip + def index(request): return HttpResponse("Audiência Pública") @@ -105,6 +111,3 @@ class AnexoAudienciaPublicaCrud(MasterDetailCrud): qs = super(MasterDetailCrud.ListView, self).get_queryset() kwargs = {self.crud.parent_field: self.kwargs['pk']} return qs.filter(**kwargs).order_by('-data', '-id') - - class DetailView(AudienciaPublicaMixin, MasterDetailCrud.DetailView): - pass diff --git a/sapl/base/views.py b/sapl/base/views.py index 68493dc04..2b53aa57c 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -48,7 +48,7 @@ from sapl.parlamentares.models import ( from sapl.protocoloadm.models import (Anexado, Protocolo) from sapl.relatorios.views import (relatorio_estatisticas_acesso_normas) from sapl.sessao.models import (Bancada, SessaoPlenaria) -from sapl.settings import EMAIL_SEND_USER +from sapl.settings import EMAIL_SEND_USER, RATE_LIMITER_RATE from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured, get_client_ip, sapn_is_enabled, is_weak_password, ratelimit_ip) @@ -68,7 +68,7 @@ class IndexView(TemplateView): @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', + rate=RATE_LIMITER_RATE, method=ratelimit.UNSAFE, block=True), name='dispatch') @@ -1400,6 +1400,10 @@ class SaplSearchView(SearchView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PesquisarAuditLogView(PermissionRequiredMixin, FilterView): model = AuditLog filterset_class = AuditLogFilterSet diff --git a/sapl/comissoes/views.py b/sapl/comissoes/views.py index 5d3e450c2..6017ae2b3 100644 --- a/sapl/comissoes/views.py +++ b/sapl/comissoes/views.py @@ -28,11 +28,16 @@ from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, RP_LIST) from sapl.materia.models import (MateriaEmTramitacao, MateriaLegislativa, PautaReuniao, Tramitacao) -from sapl.utils import show_results_filter_set +from sapl.utils import show_results_filter_set, ratelimit_ip from .models import (CargoComissao, Comissao, Composicao, DocumentoAcessorio, Participacao, Periodo, Reuniao, TipoComissao) +from ratelimit.decorators import ratelimit +from django.utils.decorators import method_decorator + +from ..settings import RATE_LIMITER_RATE + def pegar_url_composicao(pk): participacao = Participacao.objects.get(id=pk) @@ -333,6 +338,10 @@ class RemovePautaView(PermissionRequiredMixin, CreateView): return HttpResponseRedirect(success_url) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class AdicionaPautaView(PermissionRequiredMixin, FilterView): filterset_class = PautaReuniaoFilterSet template_name = 'comissoes/pauta.html' diff --git a/sapl/crud/base.py b/sapl/crud/base.py index ceaef7580..fd091ec52 100644 --- a/sapl/crud/base.py +++ b/sapl/crud/base.py @@ -26,7 +26,11 @@ from sapl.crispy_layout_mixin import CrispyLayoutFormMixin, get_field_display from sapl.crispy_layout_mixin import SaplFormHelper from sapl.rules import (RP_ADD, RP_CHANGE, RP_DELETE, RP_DETAIL, RP_LIST) -from sapl.utils import normalize +from sapl.settings import RATE_LIMITER_RATE +from sapl.utils import normalize, ratelimit_ip + +from ratelimit.decorators import ratelimit +from django.utils.decorators import method_decorator logger = logging.getLogger(settings.BASE_DIR.name) @@ -101,7 +105,6 @@ variáveis do crud: class SearchMixin(models.Model): - search = models.TextField(blank=True, default='') logger = logging.getLogger(__name__) @@ -238,7 +241,7 @@ class PermissionRequiredContainerCrudMixin(PermissionRequiredMixin): @property def container_field_set(self): - if hasattr(self, 'crud') and\ + if hasattr(self, 'crud') and \ not hasattr(self.crud, 'container_field_set'): self.crud.container_field_set = '' if hasattr(self, 'crud'): @@ -267,12 +270,11 @@ class CrudBaseMixin(CrispyLayoutFormMixin): obj.public = [] if hasattr(self, 'permission_required') and self.permission_required: - self.permission_required = tuple( ( self.permission(pr) for pr in ( set(self.permission_required) - set(obj.public) - ) + ) ) ) @@ -337,9 +339,9 @@ class CrudBaseMixin(CrispyLayoutFormMixin): if not obj.DetailView.permission_required: return self.resolve_url(ACTION_DETAIL, args=(self.object.id,)) else: - return self.resolve_url(ACTION_DETAIL, args=(self.object.id,))\ + return self.resolve_url(ACTION_DETAIL, args=(self.object.id,)) \ if self.request.user.has_perm( - self.permission(RP_DETAIL)) else '' + self.permission(RP_DETAIL)) else '' @property def update_url(self): @@ -347,9 +349,9 @@ class CrudBaseMixin(CrispyLayoutFormMixin): if not obj.UpdateView.permission_required: return self.resolve_url(ACTION_UPDATE, args=(self.object.id,)) else: - return self.resolve_url(ACTION_UPDATE, args=(self.object.id,))\ + return self.resolve_url(ACTION_UPDATE, args=(self.object.id,)) \ if self.request.user.has_perm( - self.permission(RP_CHANGE)) else '' + self.permission(RP_CHANGE)) else '' @property def delete_url(self): @@ -357,9 +359,9 @@ class CrudBaseMixin(CrispyLayoutFormMixin): if not obj.DeleteView.permission_required: return self.resolve_url(ACTION_DELETE, args=(self.object.id,)) else: - return self.resolve_url(ACTION_DELETE, args=(self.object.id,))\ + return self.resolve_url(ACTION_DELETE, args=(self.object.id,)) \ if self.request.user.has_perm( - self.permission(RP_DELETE)) else '' + self.permission(RP_DELETE)) else '' @property def openapi_url(self): @@ -388,6 +390,10 @@ class CrudBaseMixin(CrispyLayoutFormMixin): return self.model._meta.verbose_name_plural +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class CrudListView(PermissionRequiredContainerCrudMixin, ListView): permission_required = (RP_LIST,) logger = logging.getLogger(__name__) @@ -496,7 +502,7 @@ class CrudListView(PermissionRequiredContainerCrudMixin, ListView): if m: ss = get_field_display(m, n[-1])[1] ss = ( - ('
' if '' in ss else ' - ') + ss) \ + ('
' if '' in ss else ' - ') + ss) \ if ss and j != 0 and s else ss except: pass @@ -516,7 +522,7 @@ class CrudListView(PermissionRequiredContainerCrudMixin, ListView): um formulário de pesquisa herdado ou o próprio ListWithSearchForm. Só pode ser usado se o model relativo herdar de SearchMixin""" if hasattr(self, 'form_search_class'): - q = str(self.request.GET.get('q'))\ + q = str(self.request.GET.get('q')) \ if 'q' in self.request.GET else '' o = self.request.GET['o'] if 'o' in self.request.GET else '1' @@ -551,13 +557,13 @@ class CrudListView(PermissionRequiredContainerCrudMixin, ListView): if 'page' in qr: del qr['page'] context['filter_url'] = ( - '&' + qr.urlencode()) if len(qr) > 0 else '' + '&' + qr.urlencode()) if len(qr) > 0 else '' if self.ordered_list: if 'o' in qr: del qr['o'] context['ordering_url'] = ( - '&' + qr.urlencode()) if len(qr) > 0 else '' + '&' + qr.urlencode()) if len(qr) > 0 else '' return context def get_queryset(self): @@ -612,7 +618,7 @@ class CrudListView(PermissionRequiredContainerCrudMixin, ListView): ) pass - if fm and hasattr(fm, 'related_model')\ + if fm and hasattr(fm, 'related_model') \ and fm.related_model: rmo = fm.related_model._meta.ordering if rmo: @@ -710,7 +716,7 @@ class CrudCreateView(PermissionRequiredContainerCrudMixin, 'sem estar em um Container %s' ) % container_model._meta.verbose_name) - if hasattr(self, 'crud') and\ + if hasattr(self, 'crud') and \ hasattr(self.crud, 'is_m2m') and self.crud.is_m2m: setattr( self.object, container[1], getattr( @@ -724,9 +730,12 @@ class CrudCreateView(PermissionRequiredContainerCrudMixin, return super().form_valid(form) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class CrudDetailView(PermissionRequiredContainerCrudMixin, DetailView, MultipleObjectMixin): - permission_required = (RP_DETAIL,) no_entries_msg = _('Nenhum registro Associado.') paginate_by = 10 @@ -749,9 +758,9 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin, self.object, obj.model_set).model._meta.get_field( fieldname).verbose_name if hasattr(self.object, fieldname) else - getattr( - self.object, obj.model_set).model._meta.get_field( - fieldname).related_model._meta.verbose_name_plural) + getattr( + self.object, obj.model_set).model._meta.get_field( + fieldname).related_model._meta.verbose_name_plural) for fieldname in self.list_field_names_set] except Exception as e: username = self.request.user.username @@ -773,7 +782,7 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin, self.object, obj.model_set).model._meta.app_config.name return reverse('%s:%s' % ( namespace, self.url_model_set_name(suffix)), - args=args) + args=args) def _as_row(self, obj): try: @@ -940,7 +949,7 @@ class CrudDeleteView(PermissionRequiredContainerCrudMixin, username = request.user.username self.logger.error("user=" + username + ". Registro não pode ser removido, pois " - "é referenciado por outros registros: " + error_msg2) + "é referenciado por outros registros: " + error_msg2) messages.add_message(request, messages.ERROR, error_msg) @@ -976,9 +985,8 @@ class Crud: view.permission_required and \ hasattr(cls, 'public') and \ cls.public: - - #print(view.permission_required, view) - #print(cls.public, cls) + # print(view.permission_required, view) + # print(cls.public, cls) pr = pr - set(cls.public) @@ -1036,7 +1044,6 @@ class Crud: def build(cls, _model, _help_topic, _model_set=None, list_field_names=[]): def create_class(_list_field_names): - class ModelCrud(cls): model = _model model_set = _model_set @@ -1080,7 +1087,6 @@ class CrudAux(Crud): @classonlymethod def build(cls, _model, _help_topic, _model_set=None, list_field_names=[]): - ModelCrud = Crud.build( _model, _help_topic, _model_set, list_field_names) @@ -1101,7 +1107,7 @@ class MasterDetailCrud(Crud): obj = self.crud if hasattr(self, 'crud') else self if not obj.ListView: return '' - return self.resolve_url(ACTION_LIST, args=(self.kwargs['pk'],))\ + return self.resolve_url(ACTION_LIST, args=(self.kwargs['pk'],)) \ if self.request.user.has_perm(self.permission(RP_LIST)) else '' @property @@ -1109,7 +1115,7 @@ class MasterDetailCrud(Crud): obj = self.crud if hasattr(self, 'crud') else self if not obj.CreateView: return '' - return self.resolve_url(ACTION_CREATE, args=(self.kwargs['pk'],))\ + return self.resolve_url(ACTION_CREATE, args=(self.kwargs['pk'],)) \ if self.request.user.has_perm(self.permission(RP_ADD)) else '' @property @@ -1118,9 +1124,9 @@ class MasterDetailCrud(Crud): if not obj.DetailView: return '' pkk = self.request.GET['pkk'] if 'pkk' in self.request.GET else '' - return (super().detail_url + (('?pkk=' + pkk) if pkk else ''))\ + return (super().detail_url + (('?pkk=' + pkk) if pkk else '')) \ if self.request.user.has_perm( - self.permission(RP_DETAIL)) else '' + self.permission(RP_DETAIL)) else '' @property def update_url(self): @@ -1128,18 +1134,18 @@ class MasterDetailCrud(Crud): if not obj.UpdateView: return '' pkk = self.request.GET['pkk'] if 'pkk' in self.request.GET else '' - return (super().update_url + (('?pkk=' + pkk) if pkk else ''))\ + return (super().update_url + (('?pkk=' + pkk) if pkk else '')) \ if self.request.user.has_perm( - self.permission(RP_CHANGE)) else '' + self.permission(RP_CHANGE)) else '' @property def delete_url(self): obj = self.crud if hasattr(self, 'crud') else self if not obj.DeleteView: return '' - return super().delete_url\ + return super().delete_url \ if self.request.user.has_perm( - self.permission(RP_DELETE)) else '' + self.permission(RP_DELETE)) else '' def get_context_data(self, **kwargs): obj = self.crud if hasattr(self, 'crud') else self @@ -1168,7 +1174,7 @@ class MasterDetailCrud(Crud): root_pk = parent_object.pk else: - root_pk = self.kwargs['pk'] if 'pkk' not in self.request.GET\ + root_pk = self.kwargs['pk'] if 'pkk' not in self.request.GET \ else self.request.GET['pkk'] kwargs.setdefault('root_pk', root_pk) @@ -1182,6 +1188,10 @@ class MasterDetailCrud(Crud): context['title'] = title return context + @method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class ListView(Crud.ListView): permission_required = RP_LIST, logger = logging.getLogger(__name__) @@ -1410,10 +1420,14 @@ class MasterDetailCrud(Crud): return reverse('%s:%s' % ( namespace, '%s_%s' % (parent_object._meta.model_name, ACTION_DETAIL)), - args=(pk,)) + args=(pk,)) else: return self.resolve_url(ACTION_LIST, args=(pk,)) + @method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class DetailView(Crud.DetailView): permission_required = RP_DETAIL, template_name = 'crud/detail_detail.html' @@ -1429,7 +1443,7 @@ class MasterDetailCrud(Crud): if not obj.ListView: return '' - if obj.ListView.permission_required not in obj.public or\ + if obj.ListView.permission_required not in obj.public or \ self.request.user.has_perm(self.permission(RP_LIST)): if '__' in obj.parent_field: fields = obj.parent_field.split('__') @@ -1499,9 +1513,9 @@ class MasterDetailCrud(Crud): @property def detail_set_create_url(self): obj = self.crud if hasattr(self, 'crud') else self - if hasattr(obj, 'model_set') and obj.model_set\ + if hasattr(obj, 'model_set') and obj.model_set \ and self.request.user.has_perm( - self.permission_set(RP_ADD)): + self.permission_set(RP_ADD)): root_pk = self.object.pk pk = root_pk diff --git a/sapl/materia/views.py b/sapl/materia/views.py index cfd9306c5..fd05d5e2a 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -51,7 +51,7 @@ from sapl.materia.forms import (AnexadaForm, AutoriaForm, AutoriaMultiCreateForm from sapl.norma.models import LegislacaoCitada from sapl.parlamentares.models import Legislatura from sapl.protocoloadm.models import Protocolo -from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT +from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT, RATE_LIMITER_RATE from sapl.utils import (autor_label, autor_modal, gerar_hash_arquivo, get_base_url, get_client_ip, get_mime_type_from_file_extension, lista_anexados, mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO, @@ -134,6 +134,10 @@ def proposicao_texto(request, pk): raise Http404 +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class AdicionarVariasAutorias(PermissionRequiredForAppCrudMixin, FilterView): app_label = sapl.materia.apps.AppConfig.label filterset_class = AdicionarVariasAutoriasFilterSet @@ -394,6 +398,10 @@ class StatusTramitacaoCrud(CrudAux): return reverse('sapl.materia:pesquisar_statustramitacao') +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PesquisarStatusTramitacaoView(FilterView): model = StatusTramitacao filterset_class = StatusTramitacaoFilterSet @@ -1461,10 +1469,6 @@ class TramitacaoCrud(MasterDetailCrud): return initial - @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', - block=True), - name='dispatch') class ListView(MasterDetailCrud.ListView): def get_queryset(self): @@ -1537,10 +1541,6 @@ class TramitacaoCrud(MasterDetailCrud): return HttpResponseRedirect(url) - @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', - block=True), - name='dispatch') class DetailView(MasterDetailCrud.DetailView): template_name = "materia/tramitacao_detail.html" @@ -1918,10 +1918,6 @@ class MateriaLegislativaCrud(Crud): def get_success_url(self): return self.search_url - @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', - block=True), - name='dispatch') class DetailView(Crud.DetailView): layout_key = 'MateriaLegislativaDetail' @@ -1934,10 +1930,6 @@ class MateriaLegislativaCrud(Crud): pk=self.kwargs['pk']) return context - @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', - block=True), - name='dispatch') class ListView(Crud.ListView, RedirectView): def get_redirect_url(self, *args, **kwargs): @@ -2059,7 +2051,7 @@ class AcompanhamentoExcluirView(TemplateView): @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', + rate=RATE_LIMITER_RATE, block=True), name='dispatch') class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView): @@ -2323,6 +2315,10 @@ class AcompanhamentoMateriaView(CreateView): kwargs={'pk': self.kwargs['pk']}) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): filterset_class = AcessorioEmLoteFilterSet template_name = 'materia/em_lote/acessorio.html' @@ -2435,6 +2431,10 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): return self.get(request, self.kwargs) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView): filterset_class = AnexadaEmLoteFilterSet template_name = 'materia/em_lote/anexada.html' @@ -2559,6 +2559,10 @@ class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView): return HttpResponseRedirect(success_url) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PrimeiraTramitacaoEmLoteView(PermissionRequiredMixin, FilterView): filterset_class = PrimeiraTramitacaoEmLoteFilterSet template_name = 'materia/em_lote/tramitacao.html' diff --git a/sapl/middleware.py b/sapl/middleware.py index 7a8d4ff65..329e24909 100644 --- a/sapl/middleware.py +++ b/sapl/middleware.py @@ -19,3 +19,6 @@ class CheckWeakPasswordMiddleware: return redirect('sapl.base:alterar_senha') return self.get_response(request) + + + diff --git a/sapl/norma/views.py b/sapl/norma/views.py index b82d83d39..38f4eb1b0 100644 --- a/sapl/norma/views.py +++ b/sapl/norma/views.py @@ -38,7 +38,7 @@ from .forms import (AnexoNormaJuridicaForm, NormaFilterSet, NormaJuridicaForm, AutoriaNormaForm, AssuntoNormaFilterSet) from .models import (AnexoNormaJuridica, AssuntoNorma, NormaJuridica, NormaRelacionada, TipoNormaJuridica, TipoVinculoNormaJuridica, AutoriaNorma, NormaEstatisticas) - +from ..settings import RATE_LIMITER_RATE # LegislacaoCitadaCrud = Crud.build(LegislacaoCitada, '') TipoNormaCrud = CrudAux.build( @@ -60,6 +60,10 @@ class AssuntoNormaCrud(CrudAux): return reverse('sapl.norma:pesquisar_assuntonorma') +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PesquisarAssuntoNormaView(FilterView): model = AssuntoNorma filterset_class = AssuntoNormaFilterSet @@ -151,7 +155,7 @@ class NormaRelacionadaCrud(MasterDetailCrud): @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', + rate=RATE_LIMITER_RATE, block=True), name='dispatch') class NormaPesquisaView(MultiFormatOutputMixin, FilterView): @@ -239,10 +243,6 @@ class AnexoNormaJuridicaCrud(MasterDetailCrud): initial['ano'] = self.object.ano return initial - @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', - block=True), - name='dispatch') class DetailView(MasterDetailCrud.DetailView): form_class = AnexoNormaJuridicaForm layout_key = 'AnexoNormaJuridica' @@ -291,10 +291,6 @@ class NormaCrud(Crud): namespace = self.model._meta.app_config.name return reverse('%s:%s' % (namespace, 'norma_pesquisa')) - @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', - block=True), - name='dispatch') class DetailView(Crud.DetailView): def get(self, request, *args, **kwargs): estatisticas_acesso_normas = AppConfig.objects.first().estatisticas_acesso_normas @@ -352,10 +348,6 @@ class NormaCrud(Crud): layout_key = 'NormaJuridicaCreate' - @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', - block=True), - name='dispatch') class ListView(Crud.ListView): def get(self, request, *args, **kwargs): diff --git a/sapl/parlamentares/views.py b/sapl/parlamentares/views.py index b48dc6c23..a46ce2b84 100644 --- a/sapl/parlamentares/views.py +++ b/sapl/parlamentares/views.py @@ -33,7 +33,7 @@ from sapl.materia.models import Autoria, Proposicao, Relatoria from sapl.norma.models import AutoriaNorma, NormaJuridica from sapl.parlamentares.apps import AppConfig from sapl.rules import SAPL_GROUP_VOTANTE -from sapl.utils import (parlamentares_ativos, show_results_filter_set) +from sapl.utils import (parlamentares_ativos, show_results_filter_set, ratelimit_ip) from .forms import (ColigacaoFilterSet, FiliacaoForm, FrenteForm, LegislaturaForm, MandatoForm, ParlamentarCreateForm, ParlamentarForm, VotanteForm, @@ -45,6 +45,11 @@ from .models import (CargoMesa, Coligacao, ComposicaoColigacao, ComposicaoMesa, SituacaoMilitar, TipoAfastamento, TipoDependente, Votante, Bloco, FrenteCargo, FrenteParlamentar, BlocoCargo, BlocoMembro, MesaDiretora) +from ratelimit.decorators import ratelimit +from django.utils.decorators import method_decorator + +from ..settings import RATE_LIMITER_RATE + FrenteCargoCrud = CrudAux.build(FrenteCargo, 'frente_cargo') BlocoCargoCrud = CrudAux.build(BlocoCargo, 'bloco_cargo') CargoMesaCrud = CrudAux.build(CargoMesa, 'cargo_mesa') @@ -183,6 +188,10 @@ class ProposicaoParlamentarCrud(CrudBaseForListAndDetailExternalAppView): _('Texto Eletrônico')) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PesquisarParlamentarView(FilterView): model = Parlamentar filterset_class = ParlamentarFilterSet @@ -245,6 +254,10 @@ class PesquisarParlamentarView(FilterView): return self.render_to_response(context) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PesquisarColigacaoView(FilterView): model = Coligacao filterset_class = ColigacaoFilterSet @@ -301,6 +314,10 @@ class PesquisarColigacaoView(FilterView): return self.render_to_response(context) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PesquisarPartidoView(FilterView): model = Partido filterset_class = PartidoFilterSet diff --git a/sapl/protocoloadm/views.py b/sapl/protocoloadm/views.py index b09f7680a..345382438 100755 --- a/sapl/protocoloadm/views.py +++ b/sapl/protocoloadm/views.py @@ -47,7 +47,7 @@ from sapl.relatorios.views import relatorio_doc_administrativos from sapl.utils import (create_barcode, get_base_url, get_client_ip, get_mime_type_from_file_extension, lista_anexados, show_results_filter_set, mail_service_configured, from_date_to_datetime_utc, - google_recaptcha_configured, get_tempfile_dir, MultiFormatOutputMixin) + google_recaptcha_configured, get_tempfile_dir, MultiFormatOutputMixin, ratelimit_ip) from .forms import (AcompanhamentoDocumentoForm, AnexadoEmLoteFilterSet, AnexadoForm, AnularProtocoloAdmForm, compara_tramitacoes_doc, @@ -62,7 +62,10 @@ from .forms import (AcompanhamentoDocumentoForm, AnexadoEmLoteFilterSet, Anexado from .models import (Anexado, AcompanhamentoDocumento, DocumentoAcessorioAdministrativo, DocumentoAdministrativo, StatusTramitacaoAdministrativo, TipoDocumentoAdministrativo, TramitacaoAdministrativo) -from ..settings import MEDIA_ROOT +from ..settings import MEDIA_ROOT, RATE_LIMITER_RATE + +from ratelimit.decorators import ratelimit +from django.utils.decorators import method_decorator TipoDocumentoAdministrativoCrud = CrudAux.build( TipoDocumentoAdministrativo, '') @@ -535,6 +538,10 @@ class StatusTramitacaoAdministrativoCrud(CrudAux): ordering = 'sigla' +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class ProtocoloPesquisaView(PermissionRequiredMixin, FilterView): model = Protocolo filterset_class = ProtocoloFilterSet @@ -1032,6 +1039,10 @@ class ProtocoloMateriaTemplateView(PermissionRequiredMixin, TemplateView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PesquisarDocumentoAdministrativoView(DocumentoAdministrativoMixin, MultiFormatOutputMixin, PermissionRequiredMixin, @@ -1165,6 +1176,10 @@ class AnexadoCrud(MasterDetailCrud): return 'AnexadoDetail' +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class DocumentoAnexadoEmLoteView(PermissionRequiredMixin, FilterView): filterset_class = AnexadoEmLoteFilterSet template_name = 'protocoloadm/em_lote/anexado.html' @@ -1642,6 +1657,10 @@ class FichaSelecionaAdmView(PermissionRequiredMixin, FormView): 'materia/impressos/ficha_adm_pdf.html') +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PrimeiraTramitacaoEmLoteAdmView(PermissionRequiredMixin, FilterView): filterset_class = PrimeiraTramitacaoEmLoteAdmFilterSet template_name = 'protocoloadm/em_lote/tramitacaoadm.html' @@ -1878,6 +1897,10 @@ class VinculoDocAdminMateriaCrud(MasterDetailCrud): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class VinculoDocAdminMateriaEmLoteView(PermissionRequiredMixin, FilterView): filterset_class = VinculoDocAdminMateriaEmLoteFilterSet template_name = 'protocoloadm/em_lote/vinculodocadminmateria.html' diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py index 4766a8026..bc28b3ffc 100755 --- a/sapl/relatorios/views.py +++ b/sapl/relatorios/views.py @@ -48,10 +48,10 @@ from sapl.sessao.views import (get_identificacao_basica, get_mesa_diretora, get_oradores_explicacoes_pessoais, get_consideracoes_finais, get_ocorrencias_da_sessao, get_assinaturas, get_correspondencias) -from sapl.settings import MEDIA_URL +from sapl.settings import MEDIA_URL, RATE_LIMITER_RATE from sapl.settings import STATIC_ROOT from sapl.utils import LISTA_DE_UFS, TrocaTag, filiacao_data, create_barcode, show_results_filter_set, \ - num_materias_por_tipo, parlamentares_ativos, MultiFormatOutputMixin + num_materias_por_tipo, parlamentares_ativos, MultiFormatOutputMixin, ratelimit_ip from .templates import (pdf_capa_processo_gerar, pdf_documento_administrativo_gerar, pdf_espelho_gerar, pdf_etiqueta_protocolo_gerar, pdf_materia_gerar, @@ -59,6 +59,9 @@ from .templates import (pdf_capa_processo_gerar, pdf_protocolo_gerar, pdf_sessao_plenaria_gerar) from sapl.crud.base import make_pagination +from ratelimit.decorators import ratelimit +from django.utils.decorators import method_decorator + def get_kwargs_params(request, fields): kwargs = {} @@ -1841,6 +1844,10 @@ class RelatorioMixin: return self.render_to_response(context) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView): model = DocumentoAcessorio filterset_class = RelatorioDocumentosAcessoriosFilterSet @@ -1885,6 +1892,10 @@ class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioVotacoesNominaisView(RelatorioMixin, MultiFormatOutputMixin, FilterView): model = VotoParlamentar filterset_class = RelatorioVotacoesNominaisFilterSet @@ -1954,6 +1965,10 @@ class RelatorioVotacoesNominaisView(RelatorioMixin, MultiFormatOutputMixin, Filt return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioAtasView(RelatorioMixin, FilterView): model = SessaoPlenaria filterset_class = RelatorioAtasFilterSet @@ -1979,6 +1994,10 @@ class RelatorioAtasView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioPresencaSessaoView(RelatorioMixin, FilterView): logger = logging.getLogger(__name__) model = SessaoPlenaria @@ -2213,6 +2232,10 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView): model = MateriaLegislativa filterset_class = RelatorioHistoricoTramitacaoFilterSet @@ -2270,6 +2293,10 @@ class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView): model = MateriaEmTramitacao filterset_class = RelatorioDataFimPrazoTramitacaoFilterSet @@ -2333,6 +2360,10 @@ class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioReuniaoView(RelatorioMixin, FilterView): model = Reuniao filterset_class = RelatorioReuniaoFilterSet @@ -2367,6 +2398,10 @@ class RelatorioReuniaoView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioAudienciaView(RelatorioMixin, FilterView): model = AudienciaPublica filterset_class = RelatorioAudienciaFilterSet @@ -2401,6 +2436,10 @@ class RelatorioAudienciaView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView): model = MateriaEmTramitacao filterset_class = RelatorioMateriasTramitacaoFilterSet @@ -2515,6 +2554,10 @@ class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView): model = MateriaLegislativa filterset_class = RelatorioMateriasPorAnoAutorTipoFilterSet @@ -2594,6 +2637,10 @@ class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioMateriasPorAutorView(RelatorioMixin, FilterView): model = MateriaLegislativa filterset_class = RelatorioMateriasPorAutorFilterSet @@ -2665,6 +2712,10 @@ class RelatorioMateriaAnoAssuntoView(ListView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView): model = NormaJuridica filterset_class = RelatorioNormasMesFilterSet @@ -2705,6 +2756,10 @@ class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioNormasVigenciaView(RelatorioMixin, FilterView): model = NormaJuridica filterset_class = RelatorioNormasVigenciaFilterSet @@ -2769,6 +2824,10 @@ class RelatorioNormasVigenciaView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView): model = DocumentoAdministrativo filterset_class = RelatorioHistoricoTramitacaoAdmFilterSet @@ -2819,6 +2878,10 @@ class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView): return context +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class RelatorioNormasPorAutorView(RelatorioMixin, FilterView): model = NormaJuridica filterset_class = RelatorioNormasPorAutorFilterSet diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index 63f6a39e7..7c76d3196 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -47,7 +47,7 @@ from sapl.sessao.apps import AppConfig from sapl.sessao.forms import ExpedienteMateriaForm, OrdemDiaForm, OrdemExpedienteLeituraForm, \ CorrespondenciaForm, CorrespondenciaEmLoteFilterSet from sapl.sessao.models import Correspondencia -from sapl.settings import TIME_ZONE +from sapl.settings import TIME_ZONE, RATE_LIMITER_RATE from sapl.utils import show_results_filter_set, remover_acentos, get_client_ip, \ MultiFormatOutputMixin, PautaMultiFormatOutputMixin, ratelimit_ip @@ -3798,7 +3798,7 @@ class SessaoListView(ListView): @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', + rate=RATE_LIMITER_RATE, block=True), name='dispatch') class PautaSessaoView(TemplateView): @@ -3817,7 +3817,7 @@ class PautaSessaoView(TemplateView): @method_decorator(ratelimit(key=ratelimit_ip, - rate='10/m', + rate=RATE_LIMITER_RATE, block=True), name='dispatch') class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView): @@ -4001,6 +4001,10 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView): return self.render_to_response(context) +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView): model = SessaoPlenaria filterset_class = SessaoPlenariaFilterSet @@ -4088,6 +4092,7 @@ class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView): return r + class PesquisarPautaSessaoView(PesquisarSessaoPlenariaView): filterset_class = PautaSessaoFilterSet template_name = 'sessao/pauta_sessao_filter.html' @@ -5386,6 +5391,10 @@ class CorrespondenciaCrud(MasterDetailCrud): return obj +@method_decorator(ratelimit(key=ratelimit_ip, + rate=RATE_LIMITER_RATE, + block=True), + name='dispatch') class CorrespondenciaEmLoteView(PermissionRequiredMixin, FilterView): filterset_class = CorrespondenciaEmLoteFilterSet template_name = 'sessao/em_lote/correspondencia.html' diff --git a/sapl/settings.py b/sapl/settings.py index 511c7f2e8..20ff48ad7 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -315,6 +315,8 @@ MAX_DOC_UPLOAD_SIZE = 150 * 1024 * 1024 # 150MB MAX_IMAGE_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB +RATE_LIMITER_RATE = config('RATE_LIMITER_RATE', default='10/m') + # Internationalization # https://docs.djangoproject.com/en/1.8/topics/i18n/ LANGUAGE_CODE = 'pt-br' diff --git a/sapl/static/.well-known/traffic-advice b/sapl/static/.well-known/traffic-advice new file mode 100644 index 000000000..bd73e50d9 --- /dev/null +++ b/sapl/static/.well-known/traffic-advice @@ -0,0 +1,5 @@ +{ + "interval": 300, + "requests-per-second": 5, + "burst": 10 +} diff --git a/sapl/urls.py b/sapl/urls.py index 4943c7ca5..d2967e927 100644 --- a/sapl/urls.py +++ b/sapl/urls.py @@ -100,3 +100,18 @@ if settings.DEBUG: 'document_root': settings.MEDIA_ROOT, }), ] + + +# Make the rate limiter return 429 (Too Many Requests) instead of 403 (Forbidden Access) +def custom_permission_denied_view(request, exception=None): + from django.http import HttpResponse, HttpResponseForbidden + from ratelimit.exceptions import Ratelimited + + if isinstance(exception, Ratelimited): + resp = HttpResponse('Too many requests', status=429) + resp['Retry-After'] = '60' + return resp + return HttpResponseForbidden('Forbidden') + + +handler403 = custom_permission_denied_view diff --git a/scripts/test_ratelimiter.sh b/scripts/test_ratelimiter.sh index e67651c5c..57e243db2 100755 --- a/scripts/test_ratelimiter.sh +++ b/scripts/test_ratelimiter.sh @@ -1,9 +1,14 @@ #!/bin/bash #URL=http://localhost:8000/materia/4379 -URL=http://localhost:8000/norma/pesquisar +#URL=http://localhost:8000/norma/pesquisar #URL=http://localhost/norma/pesquisar +#URL=https://sapl31demo.interlegis.leg.br/docadm/45 +#URL=https://sapl.joaopessoa.pb.leg.br/materia/186300 +#URL=http://localhost:8000/materia/4379/materiaassunto +#URL=http://localhost:8000/sessao/4984 +URL="http://localhost:8000/docadm/pesq-doc-adm?tipo=&o=&numero=&complemento=&ano=&protocolo__numero=&numero_externo=&data_0=&data_1=&interessado=&assunto=&tramitacao=&tramitacaoadministrativo__status=&tramitacaoadministrativo__unidade_tramitacao_destino=&pesquisar=Pesquisar" -for i in $(seq 1 6); do +for i in $(seq 1 12); do curl -sS -o /dev/null -w "req=$i http=%{http_code} time=%{time_total}\n" "$URL" done From c43f1d011cef742c782750883df73eae2c25bd7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?LeandroJata=C3=AD?=
Date: Wed, 22 Oct 2025 12:40:10 -0300 Subject: [PATCH 22/57] feat: impl filtro m2m com lookup '__in' para buscas com multiplos ids (#3807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: impl filtro m2m com lookup '__in' para buscas com multiplos ids * fix: remove alteração na criação de rotas da api A classe DrfautoapiRouter removida nesse commit bem como a alteração no classmethod router é uma aleração necessária para que a api rode no django 5.2. Por outro lado, esta alteração quebra os links gerados pela template tag url e exigiria refatoração das mesmas. Esta alteração não é necessária para o propósito do PR. --- drfautoapi/drfautoapi.py | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/drfautoapi/drfautoapi.py b/drfautoapi/drfautoapi.py index e0fd0c348..da5a3a736 100644 --- a/drfautoapi/drfautoapi.py +++ b/drfautoapi/drfautoapi.py @@ -1,20 +1,21 @@ from collections import OrderedDict +import django_filters import importlib import inspect import logging import re -from django.apps.config import AppConfig -from django.apps.registry import apps from django.conf import settings from django.contrib.postgres.fields.jsonb import JSONField from django.db.models.base import ModelBase from django.db.models.fields import TextField, CharField from django.db.models.fields.files import FileField +from django.db.models.fields.related import ManyToManyField from django.template.defaultfilters import capfirst from django.utils.translation import ugettext_lazy as _ -import django_filters +from django.urls.conf import path from django_filters.constants import ALL_FIELDS, EMPTY_VALUES +from django_filters.fields import ModelMultipleChoiceField from django_filters.filters import CharFilter from django_filters.filterset import FilterSet from django_filters.rest_framework.backends import DjangoFilterBackend @@ -23,6 +24,7 @@ from rest_framework import serializers as rest_serializers from rest_framework.response import Response from rest_framework.routers import DefaultRouter from rest_framework.viewsets import ModelViewSet +from rest_framework.urlpatterns import format_suffix_patterns logger = logging.getLogger(__name__) @@ -65,6 +67,21 @@ class SplitStringCharFilter(django_filters.CharFilter): return qs +class M2MFilter(django_filters.ModelMultipleChoiceFilter): + + class M2MFieldFormField(ModelMultipleChoiceField): + def clean(self, value): + if value in EMPTY_VALUES: + return super().clean(value) + values = list(map(lambda x: x.replace(' ', '').split(','), value)) + values = [v for subvalues in values for v in subvalues] + cleaned_values = tuple(super().clean(values)) + return (cleaned_values,) if cleaned_values else cleaned_values + + field_class = M2MFieldFormField + distinct = True + + class ApiFilterSetMixin(FilterSet): o = CharFilter(method='filter_o') @@ -117,7 +134,7 @@ class ApiFilterSetMixin(FilterSet): f = model._meta.get_field(f_str) if f.many_to_many: - fields[f_str] = ['exact'] + fields[f_str] = ['exact', 'in', ] continue fields[f_str] = ['exact'] @@ -173,6 +190,14 @@ class ApiFilterSetMixin(FilterSet): return filter_class(**default) return None + @classmethod + def filter_for_lookup(cls, field, lookup_type): + f, p = super().filter_for_lookup(field, lookup_type) + + if lookup_type == 'in' and isinstance(field, ManyToManyField): + return M2MFilter, p + return f, p + class BusinessRulesNotImplementedMixin: http_method_names = ['get', 'head', 'options', 'trace'] @@ -208,7 +233,7 @@ class ApiViewSetConstrutor(): importlib.import_module(m) @classmethod - def router(cls, router_class=DefaultRouter): + def router(cls, router_class = DefaultRouter): router = router_class() for app, built_sets in cls._built_sets.items(): for model, viewset in built_sets.items(): @@ -407,3 +432,5 @@ class customize(object): ApiViewSetConstrutor._built_sets[ self.model._meta.app_config][self.model] = _ApiViewSet return _ApiViewSet + + From 7735e4e2c7c97c0fd2590f4b3cedb42c694e07af Mon Sep 17 00:00:00 2001 From: cristian-longhi Date: Thu, 6 Nov 2025 18:06:02 -0300 Subject: [PATCH 23/57] =?UTF-8?q?Conserta=20bug=20na=20pesquisa=20do=20Rel?= =?UTF-8?q?at=C3=B3rio=20de=20Vota=C3=A7=C3=B5es=20Nominais=20(#3803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix recibo proposição e adiciona rate limiter em matéria e norma * Conserta bug na pesquisa do Relatório de Votações Nominais Na pesquisa, ao selecionar filtro por Tipo de Matéria, Número e/ou Ano da Matéria, o sistema retorna erro 500. * Fix recibo proposição e adiciona rate limiter em matéria e norma --------- Co-authored-by: Edward Oliveira --- sapl/relatorios/forms.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sapl/relatorios/forms.py b/sapl/relatorios/forms.py index c6edc7d49..23145f899 100644 --- a/sapl/relatorios/forms.py +++ b/sapl/relatorios/forms.py @@ -70,16 +70,16 @@ class RelatorioDocumentosAcessoriosFilterSet(django_filters.FilterSet): ) -def ordem_or_expediente(queryset, name, value): - if value is None: - return queryset - value = getattr(value, "pk", value) - ordem_q = f"ordem__materia__{name}" - expediente_q = f"expediente__materia__{name}" - return queryset.filter(Q(**{ordem_q: value}) | Q(**{expediente_q: value})) +class RelatorioVotacoesNominaisFilterSet(django_filters.FilterSet): + def ordem_or_expediente(self, queryset, name, value): + if value is None: + return queryset + value = getattr(value, "pk", value) + ordem_q = f"ordem__materia__{name}" + expediente_q = f"expediente__materia__{name}" + return queryset.filter(Q(**{ordem_q: value}) | Q(**{expediente_q: value})) -class RelatorioVotacoesNominaisFilterSet(django_filters.FilterSet): tipo_id = django_filters.ModelChoiceFilter( queryset=TipoMateriaLegislativa.objects.all(), method='ordem_or_expediente', From 2f1189e6b264dd55927da78e8850385f4a679959 Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Mon, 1 Dec 2025 15:06:18 -0300 Subject: [PATCH 24/57] =?UTF-8?q?Aumento=20do=20tamanho=20de=20Nome=20e=20?= =?UTF-8?q?Tema=20da=20Audi=C3=AAncia=20P=C3=BAblica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0020_auto_20251201_1450.py | 28 +++++++++++++++++++ sapl/audiencia/models.py | 4 +-- 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 sapl/audiencia/migrations/0020_auto_20251201_1450.py diff --git a/sapl/audiencia/migrations/0020_auto_20251201_1450.py b/sapl/audiencia/migrations/0020_auto_20251201_1450.py new file mode 100644 index 000000000..6afbd9b0e --- /dev/null +++ b/sapl/audiencia/migrations/0020_auto_20251201_1450.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.28 on 2025-12-01 17:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audiencia', '0019_auto_20240711_1400'), + ] + + operations = [ + migrations.AlterField( + model_name='audienciapublica', + name='ano', + field=models.PositiveSmallIntegerField(choices=[(2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), + ), + migrations.AlterField( + model_name='audienciapublica', + name='nome', + field=models.CharField(max_length=250, verbose_name='Nome da Audiência Pública'), + ), + migrations.AlterField( + model_name='audienciapublica', + name='tema', + field=models.CharField(max_length=250, verbose_name='Tema da Audiência Pública'), + ), + ] diff --git a/sapl/audiencia/models.py b/sapl/audiencia/models.py index e5d011762..6b74cffd0 100755 --- a/sapl/audiencia/models.py +++ b/sapl/audiencia/models.py @@ -65,9 +65,9 @@ class AudienciaPublica(models.Model): ano = models.PositiveSmallIntegerField(verbose_name=_('Ano'), choices=RANGE_ANOS) nome = models.CharField( - max_length=100, verbose_name=_('Nome da Audiência Pública')) + max_length=250, verbose_name=_('Nome da Audiência Pública')) tema = models.CharField( - max_length=100, verbose_name=_('Tema da Audiência Pública')) + max_length=250, verbose_name=_('Tema da Audiência Pública')) data = models.DateField(verbose_name=_('Data')) hora_inicio = models.CharField( max_length=5, verbose_name=_('Horário Início(hh:mm)')) From e7e9a2bc937be22f7af33e91502eb6adc1737b3f Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Mon, 1 Dec 2025 15:31:54 -0300 Subject: [PATCH 25/57] Aumento de nome de parlamentar --- .../migrations/0045_auto_20251201_1531.py | 23 +++++++++++++++++++ sapl/parlamentares/models.py | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 sapl/parlamentares/migrations/0045_auto_20251201_1531.py diff --git a/sapl/parlamentares/migrations/0045_auto_20251201_1531.py b/sapl/parlamentares/migrations/0045_auto_20251201_1531.py new file mode 100644 index 000000000..2b04bc989 --- /dev/null +++ b/sapl/parlamentares/migrations/0045_auto_20251201_1531.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.28 on 2025-12-01 18:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parlamentares', '0044_adiciona_cargos_mesa'), + ] + + operations = [ + migrations.AlterField( + model_name='parlamentar', + name='nome_completo', + field=models.CharField(max_length=80, verbose_name='Nome Completo'), + ), + migrations.AlterField( + model_name='parlamentar', + name='nome_parlamentar', + field=models.CharField(max_length=80, verbose_name='Nome Parlamentar'), + ), + ] diff --git a/sapl/parlamentares/models.py b/sapl/parlamentares/models.py index de451b555..eecc2f314 100644 --- a/sapl/parlamentares/models.py +++ b/sapl/parlamentares/models.py @@ -206,9 +206,9 @@ class Parlamentar(models.Model): on_delete=models.PROTECT, verbose_name=_('Situação Militar')) nome_completo = models.CharField( - max_length=50, verbose_name=_('Nome Completo')) + max_length=80, verbose_name=_('Nome Completo')) nome_parlamentar = models.CharField( - max_length=50, + max_length=80, verbose_name=_('Nome Parlamentar')) sexo = models.CharField( max_length=1, verbose_name=_('Sexo'), choices=SEXO_CHOICE) From 012dad004d412a329fefe44462f88d8ad1757eec Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Mon, 1 Dec 2025 15:35:15 -0300 Subject: [PATCH 26/57] =?UTF-8?q?Invers=C3=A3o=20da=20ordem=20de=20ano=20e?= =?UTF-8?q?=20n=C3=BAmero=20em=20Adicionar=20Norma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sapl/templates/norma/layouts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sapl/templates/norma/layouts.yaml b/sapl/templates/norma/layouts.yaml index a44445bfb..733c3eb8d 100644 --- a/sapl/templates/norma/layouts.yaml +++ b/sapl/templates/norma/layouts.yaml @@ -32,7 +32,7 @@ AnexoNormaJuridica: NormaJuridicaCreate: {% trans 'Identificação Básica' %}: - - orgao tipo:5 numero:2 ano:2 + - orgao tipo:5 ano:2 numero:2 - data esfera_federacao complemento - tipo_materia numero_materia ano_materia - data_publicacao veiculo_publicacao data_vigencia pagina_inicio_publicacao pagina_fim_publicacao From c1614a76a85e62c20abb9948020b8458ff8bf7a1 Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Mon, 1 Dec 2025 16:55:53 -0300 Subject: [PATCH 27/57] =?UTF-8?q?Coloca=20negrito=20em=20resultado=20de=20?= =?UTF-8?q?vota=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sapl/templates/sessao/blocos_ata/materias_expediente.html | 2 +- sapl/templates/sessao/blocos_ata/materias_ordem_dia.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sapl/templates/sessao/blocos_ata/materias_expediente.html b/sapl/templates/sessao/blocos_ata/materias_expediente.html index 63cbb8291..8168ccb84 100644 --- a/sapl/templates/sessao/blocos_ata/materias_expediente.html +++ b/sapl/templates/sessao/blocos_ata/materias_expediente.html @@ -24,7 +24,7 @@ Abstenções: {{ m.voto_abstencoes }}, {% endif %} {% endif %} - Resultado: Date: Sun, 14 Dec 2025 16:01:47 -0300 Subject: [PATCH 28/57] Hot-fix: conserta erro em JS de voto individual --- sapl/painel/views.py | 10 ++++++---- .../{voto_nominal.html => voto_individual.html} | 11 +++++------ 2 files changed, 11 insertions(+), 10 deletions(-) rename sapl/templates/painel/{voto_nominal.html => voto_individual.html} (97%) diff --git a/sapl/painel/views.py b/sapl/painel/views.py index a4c9e2da3..cea4e2870 100644 --- a/sapl/painel/views.py +++ b/sapl/painel/views.py @@ -93,7 +93,7 @@ def votacao_aberta(request): return votacoes_abertas.first(), None -def votacao(context,context_vars): +def votacao(context, context_vars): logger = logging.getLogger(__name__) parlamentar = context_vars['votante'].parlamentar parlamentar_presente = False @@ -204,7 +204,7 @@ def votante_view(request): username = request.user.username if request.user.is_authenticated else 'AnonymousUser' # Pega o votante relacionado ao usuário - template_name = 'painel/voto_nominal.html' + template_name = 'painel/voto_individual.html' context = {} context_vars = {} @@ -237,13 +237,15 @@ def votante_view(request): if request.method == 'POST': if context_vars['ordem_dia']: try: - logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e ordem={}." + logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e " + "ordem={}. " .format(context_vars['parlamentar'], context_vars['ordem_dia'])) voto = VotoParlamentar.objects.get( parlamentar=context_vars['parlamentar'], ordem=context_vars['ordem_dia']) except ObjectDoesNotExist: - logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. Criando objeto." + logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. " + "Criando objeto. " .format(context_vars['parlamentar'], context_vars['ordem_dia'])) voto = VotoParlamentar.objects.create( parlamentar=context_vars['parlamentar'], diff --git a/sapl/templates/painel/voto_nominal.html b/sapl/templates/painel/voto_individual.html similarity index 97% rename from sapl/templates/painel/voto_nominal.html rename to sapl/templates/painel/voto_individual.html index ecd915c25..c35daa5fa 100644 --- a/sapl/templates/painel/voto_nominal.html +++ b/sapl/templates/painel/voto_individual.html @@ -132,9 +132,6 @@ {% render_bundle 'painel' 'js' %} {% endblock extra_js %} From 15a2dd99f67de4c5c3bdfc6c10e3d00de1cc86bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?LeandroJata=C3=AD?= Date: Wed, 21 Jan 2026 18:56:44 -0300 Subject: [PATCH 31/57] =?UTF-8?q?Impl=20expans=C3=A3o=20din=C3=A2mica=20de?= =?UTF-8?q?=20campos=20na=20API=20(#3809)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com> --- drfautoapi/drfautoapi.py | 192 ++++++++++++++++++++++++++++++++++++++- sapl/api/serializers.py | 18 ++-- 2 files changed, 203 insertions(+), 7 deletions(-) diff --git a/drfautoapi/drfautoapi.py b/drfautoapi/drfautoapi.py index da5a3a736..70922b319 100644 --- a/drfautoapi/drfautoapi.py +++ b/drfautoapi/drfautoapi.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from functools import cached_property import django_filters import importlib import inspect @@ -6,14 +7,16 @@ import logging import re from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields.jsonb import JSONField +from django.contrib.auth import get_user_model from django.db.models.base import ModelBase from django.db.models.fields import TextField, CharField from django.db.models.fields.files import FileField from django.db.models.fields.related import ManyToManyField from django.template.defaultfilters import capfirst from django.utils.translation import ugettext_lazy as _ -from django.urls.conf import path +from django.urls import reverse from django_filters.constants import ALL_FIELDS, EMPTY_VALUES from django_filters.fields import ModelMultipleChoiceField from django_filters.filters import CharFilter @@ -21,6 +24,7 @@ from django_filters.filterset import FilterSet from django_filters.rest_framework.backends import DjangoFilterBackend from django_filters.utils import resolve_field, get_all_model_fields from rest_framework import serializers as rest_serializers +from rest_framework.relations import ManyRelatedField from rest_framework.response import Response from rest_framework.routers import DefaultRouter from rest_framework.viewsets import ModelViewSet @@ -85,6 +89,7 @@ class M2MFilter(django_filters.ModelMultipleChoiceFilter): class ApiFilterSetMixin(FilterSet): o = CharFilter(method='filter_o') + id__in = CharFilter(method='filter_id__in') class Meta: fields = '__all__' @@ -109,6 +114,13 @@ class ApiFilterSetMixin(FilterSet): }, } + def filter_id__in(self, queryset, name, value): + try: + ids = [int(v.strip()) for v in value.split(',')] + return queryset.filter(id__in=ids) + except: + return queryset + def filter_o(self, queryset, name, value): try: return queryset.order_by( @@ -434,3 +446,181 @@ class customize(object): return _ApiViewSet +class DrfAutoApiSerializerMixin(rest_serializers.ModelSerializer): + """ + Mixin de Serializer que implementa expansão dinâmica de campos + via parâmetros de query string: + - `expand`: expande os campos informados + - `include`: inclui apenas os campos informados + - `exclude`: exclui os campos informados + + Exemplo: + - ?expand=campo1;campo2.sub_campo1,sub_campo2;campo3.sub_campo1.sub_sub_campo1,sub_sub_campo2 + - ?include=campo1;campo2.sub_campo1,sub_campo2;campo3.sub_campo1.sub_sub_campo1,sub_sub_campo2 + - ?exclude=campo1;campo2.sub_campo1,sub_campo2;campo3.sub_campo1.sub_sub_campo1,sub_sub_campo2 + - ?expand=campo1&include=campo1.id,name&exclude=campo1.secret_field + + Onde: + - campo1, campo2, campo3 são campos do model raiz + - sub_campo1, sub_campo2 são campos relacionados do campo2 + - sub_sub_campo1, sub_sub_campo2 são campos relacionados do sub_campo1 + + Ou seja: + ";" separa campos independentes do nível atual + "," separa campos relacionados do mesmo campo pai + "." indica o nível de profundidade dos campos relacionados + + e ainda: + - `expand` pode ser usado para expansão direta, ou seja, campo1.sub_campo1 já expande campo1 + - `expand`, `include` e `exclude` podem ser usados juntos na mesma requisição + - `include` e `exclude` só funcionam em subniveis se o campo pai estiver expandido + - `include` tem precedência sobre `exclude` e já remove todo o resto + - `exclude` remove o campo do resultado final, mesmo que esteja em `include` + - Se nenhum dos parâmetros for informado, nenhum campo será expandido + - Filtros da API, paginação (`page` e `page_size`) e ordenação (`o`) + podem ser usados normalmente para filtrar os resultados + + Atenção: + - A expansão não é aplicada para o model User do Django + - A expansão não é aplicada para campos customizados que utilizam SerializerMethodField + - Uma exceção é lançada e registrada no log caso ocorra algum erro na expansão de algum campo, + inclusive devido a recursão infinita + - A expansão automática de todos os campos relacionados (expand=all) está desabilitada + por necessidade de controle mais refinado. + - A expansão de campos relacionados ForeignKey e OneToOne é suportada. + - A expansão de campos relacionados ManyToMany é suportada. + - A expansão de campos relacionados reversos (related_name) não é suportada, mas pode ser implementada manualmente no serializer customizado, ou vir a ser implementada no futuro. + """ + + __str__ = rest_serializers.SerializerMethodField() + + class Meta: + fields = '__all__' + + def get___str__(self, obj) -> str: + return str(obj) + + @cached_property + def user_model(self): + return get_user_model() + + def get_control_fields(self, control_field='expand'): + request = self.context.get('request', None) + if request: + param = request.query_params.get(control_field, '') + param = [e.strip() for e in param.split(';') if e.strip()] + param = [e.split('.') for e in param] + return param + return [] + + def get_fields(self): + fields = super().get_fields() + + if not hasattr(self.root, 'drf_expand_fields'): + self.root.drf_expand_fields = self.get_control_fields('expand') + self.root.drf_include_fields = self.get_control_fields('include') + self.root.drf_exclude_fields = self.get_control_fields('exclude') + if not (self.root.drf_expand_fields or self.root.drf_include_fields or self.root.drf_exclude_fields): + return fields + + model = self.Meta.model + expand_fields = self.root.drf_expand_fields + include_fields = self.root.drf_include_fields + exclude_fields = self.root.drf_exclude_fields + + expand_all = False #['all'] in expand_fields + + #if expand_all: + # request = self.context.get('request', None) + # user = getattr(request, 'user', None) + # expand_all = user and user.is_superuser + + def parents(nd): + if not nd: + return [] + return parents(nd.parent) + [nd.field_name] + + sources = parents(self) + sources = list(filter(lambda x: x, sources)) + + if expand_fields: + exps = [] + for exp in expand_fields: + if len(exp) > len(sources) and exp[0:len(sources)] == sources: + exps.extend(exp[len(sources)].split(',')) + expand_fields = exps + + if include_fields: + incls = [] + for inf in include_fields: + if len(inf) - 1 == len(sources) and inf[:-1] == sources: + incls.extend(inf[-1].split(',')) + if incls: + fields = OrderedDict( + [(k, v) for k, v in fields.items() if k in incls] + ) + + if exclude_fields: + excls = [] + for inf in exclude_fields: + if len(inf) - 1 == len(sources) and inf[:-1] == sources: + excls.extend(inf[-1].split(',')) + if excls: + fields = OrderedDict( + [(k, v) for k, v in fields.items() if k not in excls] + ) + + fields_with_relations_map_model = {f.name: f.related_model for f in model._meta.get_fields() + if f.is_relation and f.name in fields} + + set_fields_with_relations = set(fields_with_relations_map_model.keys()) + set_expand_fields = set(expand_fields) + set_fields_serialized = set( + map( + lambda kv: kv[0], + filter( + lambda kv: not isinstance(kv[1], rest_serializers.SerializerMethodField), + fields.items() + ) + ) + ) + + expand_fields = set_fields_with_relations.intersection(set_fields_serialized) + + if not expand_all: + expand_fields = expand_fields.intersection(set_expand_fields) + + # remove o User model da expansão + if self.user_model in fields_with_relations_map_model.values(): + expand_fields = [f for f in expand_fields + if fields_with_relations_map_model[f] != self.user_model] + + if not expand_fields: + return fields + + for field_name in expand_fields: + field = fields[field_name] + + model = fields_with_relations_map_model[field_name] + + if model: + try: + serializer_class = ApiViewSetConstrutor.get_viewset_for_model(model).serializer_class + if hasattr(field, 'many') and field.many or isinstance(field, ManyRelatedField): + serializer_class = serializer_class( + many=True, + read_only=True, + context=self.context + ) + else: + serializer_class = serializer_class( + read_only=True, + context=self.context + ) + + fields[field_name] = serializer_class + + except Exception as e: + logger.error(f'Erro ao expandir campo {field_name} do model {model}: {e}') + + return fields diff --git a/sapl/api/serializers.py b/sapl/api/serializers.py index 903051d10..b8bb06b4d 100644 --- a/sapl/api/serializers.py +++ b/sapl/api/serializers.py @@ -4,6 +4,8 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.db.models import Q +from django.urls import reverse +from drfautoapi.drfautoapi import DrfAutoApiSerializerMixin from image_cropping.utils import get_backend from rest_framework import serializers from rest_framework.fields import SerializerMethodField @@ -13,16 +15,13 @@ from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura from sapl.sessao.models import OrdemDia, SessaoPlenaria -class SaplSerializerMixin(serializers.ModelSerializer): - __str__ = SerializerMethodField() +class SaplSerializerMixin(DrfAutoApiSerializerMixin): + link_detail_backend = serializers.SerializerMethodField() metadata = SerializerMethodField() - class Meta: + class Meta(DrfAutoApiSerializerMixin.Meta): fields = '__all__' - def get___str__(self, obj) -> str: - return str(obj) - def get_metadata(self, obj) -> dict: try: metadata = Metadata.objects.get( @@ -35,6 +34,13 @@ class SaplSerializerMixin(serializers.ModelSerializer): finally: return metadata + def get_link_detail_backend(self, obj) -> str: + try: + return reverse(f'{self.Meta.model._meta.app_config.name}:{self.Meta.model._meta.model_name}_detail', + kwargs={'pk': obj.pk}) + except: + return '' + class ChoiceSerializer(serializers.Serializer): value = serializers.SerializerMethodField() From 857d1f0c4a405fabc1fd09038fe6647c8f4dcc36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?LeandroJata=C3=AD?= Date: Wed, 21 Jan 2026 18:57:26 -0300 Subject: [PATCH 32/57] Implementa Header LastModified na Api (#3808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: impl HEADER LastModifiedDecorator na api * fix: ajuste no frontend para evitar cache automático devido a LastModified sem tratamento adequado * rebuild frontend * fix: corrige last_modified_func para retorna sob retrieve * refact: impl LastModified diretamente nos models que possuem campos específicos --- drfautoapi/drfautoapi.py | 12 ++ frontend/src/__apps/parlamentar/main.js | 6 + frontend/src/__global/js/functions.js | 3 +- frontend/webpack-stats.json | 90 ++++++------ sapl/api/views.py | 129 +++++++++++++++++- sapl/api/views_materia.py | 19 +++ .../css/chunk-vendors.045ec640.css.gz | Bin 60853 -> 60971 bytes .../frontend/css/compilacao.0baf3580.css.gz | Bin 10310 -> 10324 bytes .../sapl/frontend/css/global.042f6737.css.gz | Bin 27801 -> 27746 bytes .../sapl/frontend/css/painel.e2b9504e.css.gz | Bin 288 -> 289 bytes .../fonts/fa-brands-400.5d18d427.ttf.gz | Bin 121928 -> 121851 bytes .../fonts/fa-regular-400.81482cd4.ttf.gz | Bin 26886 -> 26634 bytes .../fonts/fa-solid-900.69d3141a.ttf.gz | Bin 171462 -> 170982 bytes .../fonts/fa-v4compatibility.2c070fd2.ttf.gz | Bin 5131 -> 5054 bytes .../frontend/img/down_arrow_select.jpg.gz | Bin 484 -> 484 bytes .../chunk-vendors.e8ab4373.js.LICENSE.txt.gz | Bin 1743 -> 0 bytes .../frontend/js/chunk-vendors.e8ab4373.js.gz | Bin 670677 -> 0 bytes ....e8ab4373.js => chunk-vendors.f2d6067d.js} | 4 +- ... => chunk-vendors.f2d6067d.js.LICENSE.txt} | 0 .../chunk-vendors.f2d6067d.js.LICENSE.txt.gz | Bin 0 -> 1742 bytes .../frontend/js/chunk-vendors.f2d6067d.js.gz | Bin 0 -> 669993 bytes .../frontend/js/compilacao.148cb66b.js.gz | Bin 6820 -> 6824 bytes .../sapl/frontend/js/global.bb3e904b.js | 2 - .../sapl/frontend/js/global.bb3e904b.js.gz | Bin 14144 -> 0 bytes .../sapl/frontend/js/global.d557d20c.js | 2 + ...NSE.txt => global.d557d20c.js.LICENSE.txt} | 0 .../sapl/frontend/js/global.d557d20c.js.gz | Bin 0 -> 14069 bytes .../sapl/frontend/js/painel.f3190acc.js.gz | Bin 872 -> 865 bytes .../sapl/frontend/js/parlamentar.a1a4b65b.js | 1 - .../frontend/js/parlamentar.a1a4b65b.js.gz | Bin 1614 -> 0 bytes .../sapl/frontend/js/parlamentar.f63027a7.js | 1 + .../frontend/js/parlamentar.f63027a7.js.gz | Bin 0 -> 1670 bytes .../js/skins/content/dark/content.css.gz | Bin 489 -> 489 bytes .../js/skins/content/dark/content.js.gz | Bin 531 -> 531 bytes .../js/skins/content/dark/content.min.css.gz | Bin 489 -> 489 bytes .../js/skins/content/default/content.css.gz | Bin 460 -> 461 bytes .../js/skins/content/default/content.js.gz | Bin 505 -> 504 bytes .../skins/content/default/content.min.css.gz | Bin 460 -> 461 bytes .../js/skins/content/document/content.css.gz | Bin 526 -> 525 bytes .../js/skins/content/document/content.js.gz | Bin 571 -> 568 bytes .../skins/content/document/content.min.css.gz | Bin 526 -> 525 bytes .../content/tinymce-5-dark/content.css.gz | Bin 492 -> 491 bytes .../content/tinymce-5-dark/content.js.gz | Bin 539 -> 538 bytes .../content/tinymce-5-dark/content.min.css.gz | Bin 492 -> 491 bytes .../js/skins/content/tinymce-5/content.css.gz | Bin 460 -> 461 bytes .../js/skins/content/tinymce-5/content.js.gz | Bin 504 -> 503 bytes .../content/tinymce-5/content.min.css.gz | Bin 460 -> 461 bytes .../js/skins/content/writer/content.css.gz | Bin 473 -> 474 bytes .../js/skins/content/writer/content.js.gz | Bin 517 -> 516 bytes .../skins/content/writer/content.min.css.gz | Bin 473 -> 474 bytes .../js/skins/ui/oxide-dark/content.css.gz | Bin 5234 -> 5229 bytes .../skins/ui/oxide-dark/content.inline.css.gz | Bin 5267 -> 5260 bytes .../skins/ui/oxide-dark/content.inline.js.gz | Bin 5815 -> 5794 bytes .../ui/oxide-dark/content.inline.min.css.gz | Bin 5269 -> 5260 bytes .../js/skins/ui/oxide-dark/content.js.gz | Bin 5779 -> 5763 bytes .../js/skins/ui/oxide-dark/content.min.css.gz | Bin 5237 -> 5229 bytes .../js/skins/ui/oxide-dark/skin.css.gz | Bin 13227 -> 13236 bytes .../js/skins/ui/oxide-dark/skin.js.gz | Bin 13312 -> 13323 bytes .../js/skins/ui/oxide-dark/skin.min.css.gz | Bin 13202 -> 13213 bytes .../skins/ui/oxide-dark/skin.shadowdom.css.gz | Bin 264 -> 264 bytes .../skins/ui/oxide-dark/skin.shadowdom.js.gz | Bin 304 -> 305 bytes .../ui/oxide-dark/skin.shadowdom.min.css.gz | Bin 264 -> 264 bytes .../frontend/js/skins/ui/oxide/content.css.gz | Bin 5287 -> 5280 bytes .../js/skins/ui/oxide/content.inline.css.gz | Bin 5267 -> 5260 bytes .../js/skins/ui/oxide/content.inline.js.gz | Bin 5814 -> 5794 bytes .../skins/ui/oxide/content.inline.min.css.gz | Bin 5269 -> 5260 bytes .../frontend/js/skins/ui/oxide/content.js.gz | Bin 5830 -> 5811 bytes .../js/skins/ui/oxide/content.min.css.gz | Bin 5290 -> 5280 bytes .../frontend/js/skins/ui/oxide/skin.css.gz | Bin 13235 -> 13244 bytes .../frontend/js/skins/ui/oxide/skin.js.gz | Bin 13302 -> 13312 bytes .../js/skins/ui/oxide/skin.min.css.gz | Bin 13217 -> 13227 bytes .../js/skins/ui/oxide/skin.shadowdom.css.gz | Bin 264 -> 264 bytes .../js/skins/ui/oxide/skin.shadowdom.js.gz | Bin 306 -> 306 bytes .../skins/ui/oxide/skin.shadowdom.min.css.gz | Bin 264 -> 264 bytes .../js/skins/ui/tinymce-5-dark/content.css.gz | Bin 5234 -> 5229 bytes .../ui/tinymce-5-dark/content.inline.css.gz | Bin 5267 -> 5260 bytes .../ui/tinymce-5-dark/content.inline.js.gz | Bin 5818 -> 5797 bytes .../tinymce-5-dark/content.inline.min.css.gz | Bin 5269 -> 5260 bytes .../js/skins/ui/tinymce-5-dark/content.js.gz | Bin 5783 -> 5766 bytes .../ui/tinymce-5-dark/content.min.css.gz | Bin 5237 -> 5229 bytes .../js/skins/ui/tinymce-5-dark/skin.css.gz | Bin 13752 -> 13755 bytes .../js/skins/ui/tinymce-5-dark/skin.js.gz | Bin 13866 -> 13863 bytes .../skins/ui/tinymce-5-dark/skin.min.css.gz | Bin 13732 -> 13737 bytes .../ui/tinymce-5-dark/skin.shadowdom.css.gz | Bin 264 -> 264 bytes .../ui/tinymce-5-dark/skin.shadowdom.js.gz | Bin 309 -> 308 bytes .../tinymce-5-dark/skin.shadowdom.min.css.gz | Bin 264 -> 264 bytes .../js/skins/ui/tinymce-5/content.css.gz | Bin 5287 -> 5280 bytes .../skins/ui/tinymce-5/content.inline.css.gz | Bin 5267 -> 5260 bytes .../skins/ui/tinymce-5/content.inline.js.gz | Bin 5814 -> 5794 bytes .../ui/tinymce-5/content.inline.min.css.gz | Bin 5269 -> 5260 bytes .../js/skins/ui/tinymce-5/content.js.gz | Bin 5830 -> 5811 bytes .../js/skins/ui/tinymce-5/content.min.css.gz | Bin 5290 -> 5280 bytes .../js/skins/ui/tinymce-5/skin.css.gz | Bin 13740 -> 13742 bytes .../frontend/js/skins/ui/tinymce-5/skin.js.gz | Bin 13842 -> 13839 bytes .../js/skins/ui/tinymce-5/skin.min.css.gz | Bin 13717 -> 13721 bytes .../skins/ui/tinymce-5/skin.shadowdom.css.gz | Bin 264 -> 264 bytes .../skins/ui/tinymce-5/skin.shadowdom.js.gz | Bin 306 -> 305 bytes .../ui/tinymce-5/skin.shadowdom.min.css.gz | Bin 264 -> 264 bytes sapl/templates/crud/detail.html | 2 +- sapl/templates/crud/detail_detail.html | 2 +- 100 files changed, 214 insertions(+), 59 deletions(-) delete mode 100644 sapl/static/sapl/frontend/js/chunk-vendors.e8ab4373.js.LICENSE.txt.gz delete mode 100644 sapl/static/sapl/frontend/js/chunk-vendors.e8ab4373.js.gz rename sapl/static/sapl/frontend/js/{chunk-vendors.e8ab4373.js => chunk-vendors.f2d6067d.js} (97%) rename sapl/static/sapl/frontend/js/{chunk-vendors.e8ab4373.js.LICENSE.txt => chunk-vendors.f2d6067d.js.LICENSE.txt} (100%) create mode 100644 sapl/static/sapl/frontend/js/chunk-vendors.f2d6067d.js.LICENSE.txt.gz create mode 100644 sapl/static/sapl/frontend/js/chunk-vendors.f2d6067d.js.gz delete mode 100644 sapl/static/sapl/frontend/js/global.bb3e904b.js delete mode 100644 sapl/static/sapl/frontend/js/global.bb3e904b.js.gz create mode 100644 sapl/static/sapl/frontend/js/global.d557d20c.js rename sapl/static/sapl/frontend/js/{global.bb3e904b.js.LICENSE.txt => global.d557d20c.js.LICENSE.txt} (100%) create mode 100644 sapl/static/sapl/frontend/js/global.d557d20c.js.gz delete mode 100644 sapl/static/sapl/frontend/js/parlamentar.a1a4b65b.js delete mode 100644 sapl/static/sapl/frontend/js/parlamentar.a1a4b65b.js.gz create mode 100644 sapl/static/sapl/frontend/js/parlamentar.f63027a7.js create mode 100644 sapl/static/sapl/frontend/js/parlamentar.f63027a7.js.gz diff --git a/drfautoapi/drfautoapi.py b/drfautoapi/drfautoapi.py index 70922b319..3f9e44292 100644 --- a/drfautoapi/drfautoapi.py +++ b/drfautoapi/drfautoapi.py @@ -231,6 +231,17 @@ class ApiViewSetConstrutor(): class ApiViewSet(ModelViewSet): filter_backends = (DjangoFilterBackend,) + class LastModifiedDecorator(object): + def __init__(self): + pass + def __call__(self, cls): + return cls + + @classmethod + def last_modified_class(cls, klass): + cls.LastModifiedDecorator = klass + return cls + @classmethod def get_viewset_for_model(cls, model): return cls._built_sets[model._meta.app_config][model] @@ -346,6 +357,7 @@ class ApiViewSetConstrutor(): if not hasattr(_meta_filterset, 'model'): model = _model + @cls.LastModifiedDecorator() class ModelApiViewSet(ApiViewSetConstrutor.ApiViewSet): queryset = _model.objects.all() filterset_class = ApiFilterSet diff --git a/frontend/src/__apps/parlamentar/main.js b/frontend/src/__apps/parlamentar/main.js index 97f5e5c75..4392fa9ee 100644 --- a/frontend/src/__apps/parlamentar/main.js +++ b/frontend/src/__apps/parlamentar/main.js @@ -3,6 +3,12 @@ import Vue from 'vue' import { FormSelectPlugin } from 'bootstrap-vue' import axios from 'axios' +// TODO: refatorar para aproveitar cache e LastModified no frontend oficial +// Configuração global para todas as requisições GET do axios +axios.defaults.headers.get['Cache-Control'] = 'no-cache, no-store, must-revalidate' +axios.defaults.headers.get['Pragma'] = 'no-cache' // Suporte para navegadores mais antigos +axios.defaults.headers.get['Expires'] = '0' // Expira imediatamente + axios.defaults.xsrfCookieName = 'csrftoken' axios.defaults.xsrfHeaderName = 'X-CSRFToken' diff --git a/frontend/src/__global/js/functions.js b/frontend/src/__global/js/functions.js index 1f9b44a6a..cfc86fa4a 100644 --- a/frontend/src/__global/js/functions.js +++ b/frontend/src/__global/js/functions.js @@ -66,7 +66,8 @@ window.autorModal = function () { $('#pesquisar').click(function () { const json_data = { - q: $('#q').val() + q: $('#q').val(), + utc_time: Date.now(), // get_all: true } $.get('/api/base/autor', json_data, function (data) { diff --git a/frontend/webpack-stats.json b/frontend/webpack-stats.json index 6d71f74a0..900bf80c9 100644 --- a/frontend/webpack-stats.json +++ b/frontend/webpack-stats.json @@ -271,25 +271,25 @@ "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/img/user.png", "publicPath": "/static/sapl/frontend/img/user.png" }, - "js/chunk-vendors.e8ab4373.js": { - "name": "js/chunk-vendors.e8ab4373.js", - "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/chunk-vendors.e8ab4373.js", - "publicPath": "/static/sapl/frontend/js/chunk-vendors.e8ab4373.js" - }, - "js/chunk-vendors.e8ab4373.js.gz": { - "name": "js/chunk-vendors.e8ab4373.js.gz", - "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/chunk-vendors.e8ab4373.js.gz", - "publicPath": "/static/sapl/frontend/js/chunk-vendors.e8ab4373.js.gz" - }, - "js/chunk-vendors.e8ab4373.js.LICENSE.txt": { - "name": "js/chunk-vendors.e8ab4373.js.LICENSE.txt", - "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/chunk-vendors.e8ab4373.js.LICENSE.txt", - "publicPath": "/static/sapl/frontend/js/chunk-vendors.e8ab4373.js.LICENSE.txt" - }, - "js/chunk-vendors.e8ab4373.js.LICENSE.txt.gz": { - "name": "js/chunk-vendors.e8ab4373.js.LICENSE.txt.gz", - "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/chunk-vendors.e8ab4373.js.LICENSE.txt.gz", - "publicPath": "/static/sapl/frontend/js/chunk-vendors.e8ab4373.js.LICENSE.txt.gz" + "js/chunk-vendors.f2d6067d.js": { + "name": "js/chunk-vendors.f2d6067d.js", + "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/chunk-vendors.f2d6067d.js", + "publicPath": "/static/sapl/frontend/js/chunk-vendors.f2d6067d.js" + }, + "js/chunk-vendors.f2d6067d.js.gz": { + "name": "js/chunk-vendors.f2d6067d.js.gz", + "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/chunk-vendors.f2d6067d.js.gz", + "publicPath": "/static/sapl/frontend/js/chunk-vendors.f2d6067d.js.gz" + }, + "js/chunk-vendors.f2d6067d.js.LICENSE.txt": { + "name": "js/chunk-vendors.f2d6067d.js.LICENSE.txt", + "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/chunk-vendors.f2d6067d.js.LICENSE.txt", + "publicPath": "/static/sapl/frontend/js/chunk-vendors.f2d6067d.js.LICENSE.txt" + }, + "js/chunk-vendors.f2d6067d.js.LICENSE.txt.gz": { + "name": "js/chunk-vendors.f2d6067d.js.LICENSE.txt.gz", + "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/chunk-vendors.f2d6067d.js.LICENSE.txt.gz", + "publicPath": "/static/sapl/frontend/js/chunk-vendors.f2d6067d.js.LICENSE.txt.gz" }, "js/compilacao.148cb66b.js": { "name": "js/compilacao.148cb66b.js", @@ -301,20 +301,20 @@ "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/compilacao.148cb66b.js.gz", "publicPath": "/static/sapl/frontend/js/compilacao.148cb66b.js.gz" }, - "js/global.bb3e904b.js": { - "name": "js/global.bb3e904b.js", - "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/global.bb3e904b.js", - "publicPath": "/static/sapl/frontend/js/global.bb3e904b.js" + "js/global.d557d20c.js": { + "name": "js/global.d557d20c.js", + "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/global.d557d20c.js", + "publicPath": "/static/sapl/frontend/js/global.d557d20c.js" }, - "js/global.bb3e904b.js.gz": { - "name": "js/global.bb3e904b.js.gz", - "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/global.bb3e904b.js.gz", - "publicPath": "/static/sapl/frontend/js/global.bb3e904b.js.gz" + "js/global.d557d20c.js.gz": { + "name": "js/global.d557d20c.js.gz", + "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/global.d557d20c.js.gz", + "publicPath": "/static/sapl/frontend/js/global.d557d20c.js.gz" }, - "js/global.bb3e904b.js.LICENSE.txt": { - "name": "js/global.bb3e904b.js.LICENSE.txt", - "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/global.bb3e904b.js.LICENSE.txt", - "publicPath": "/static/sapl/frontend/js/global.bb3e904b.js.LICENSE.txt" + "js/global.d557d20c.js.LICENSE.txt": { + "name": "js/global.d557d20c.js.LICENSE.txt", + "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/global.d557d20c.js.LICENSE.txt", + "publicPath": "/static/sapl/frontend/js/global.d557d20c.js.LICENSE.txt" }, "js/painel.f3190acc.js": { "name": "js/painel.f3190acc.js", @@ -326,15 +326,15 @@ "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/painel.f3190acc.js.gz", "publicPath": "/static/sapl/frontend/js/painel.f3190acc.js.gz" }, - "js/parlamentar.a1a4b65b.js": { - "name": "js/parlamentar.a1a4b65b.js", - "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/parlamentar.a1a4b65b.js", - "publicPath": "/static/sapl/frontend/js/parlamentar.a1a4b65b.js" + "js/parlamentar.f63027a7.js": { + "name": "js/parlamentar.f63027a7.js", + "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/parlamentar.f63027a7.js", + "publicPath": "/static/sapl/frontend/js/parlamentar.f63027a7.js" }, - "js/parlamentar.a1a4b65b.js.gz": { - "name": "js/parlamentar.a1a4b65b.js.gz", - "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/parlamentar.a1a4b65b.js.gz", - "publicPath": "/static/sapl/frontend/js/parlamentar.a1a4b65b.js.gz" + "js/parlamentar.f63027a7.js.gz": { + "name": "js/parlamentar.f63027a7.js.gz", + "path": "/home/leandrojatai/desenvolvimento/envs/sapl/sapl/static/sapl/frontend/js/parlamentar.f63027a7.js.gz", + "publicPath": "/static/sapl/frontend/js/parlamentar.f63027a7.js.gz" }, "js/skins/content/dark/content.css": { "name": "js/skins/content/dark/content.css", @@ -1000,27 +1000,27 @@ "chunks": { "compilacao": [ "css/chunk-vendors.045ec640.css", - "js/chunk-vendors.e8ab4373.js", + "js/chunk-vendors.f2d6067d.js", "css/compilacao.0baf3580.css", "js/compilacao.148cb66b.js" ], "global": [ "css/chunk-vendors.045ec640.css", - "js/chunk-vendors.e8ab4373.js", + "js/chunk-vendors.f2d6067d.js", "css/global.042f6737.css", - "js/global.bb3e904b.js" + "js/global.d557d20c.js" ], "painel": [ "css/chunk-vendors.045ec640.css", - "js/chunk-vendors.e8ab4373.js", + "js/chunk-vendors.f2d6067d.js", "css/painel.e2b9504e.css", "js/painel.f3190acc.js" ], "parlamentar": [ "css/chunk-vendors.045ec640.css", - "js/chunk-vendors.e8ab4373.js", + "js/chunk-vendors.f2d6067d.js", "css/parlamentar.cd5dc5a8.css", - "js/parlamentar.a1a4b65b.js" + "js/parlamentar.f63027a7.js" ] }, "publicPath": "/static/sapl/frontend/" diff --git a/sapl/api/views.py b/sapl/api/views.py index fac2eeeeb..c2b8ae786 100644 --- a/sapl/api/views.py +++ b/sapl/api/views.py @@ -9,6 +9,9 @@ from rest_framework.response import Response from rest_framework.views import APIView from drfautoapi.drfautoapi import ApiViewSetConstrutor +from django.views.decorators.http import condition as django_condition + +from sapl.base.models import AuditLog logger = logging.getLogger(__name__) @@ -21,8 +24,122 @@ def recria_token(request, pk): return Response({"message": "Token recriado com sucesso!", "token": token.key}) +class LastModifiedDecorator: + """ - Decorator para adicionar suporte a Last-Modified em ViewSets + - Baseado em django.views.decorators.http.condition + + - Por padrão utiliza o AuditLog para determinar a data do last_modified + - Pode ser sobrescrito na customização do ViewSet caso necessário + - Existe um exemplo de sobrescrita em sapl/api/views_materia.py + + - Devolve Last-Modified baseado no timestamp do último AuditLog + - Se for uma listagem, considera o último AuditLog de todos os objetos + retornados na listagem levando em consideração resultado de FilterSet. + - Retorna 304 Not Modified se o recurso não foi modificado + """ + def __call__(self, cls): + + original_dispatch = cls.dispatch + self.model = cls.queryset.model + + def wrapped_dispatch(view, request, *args, **kwargs): + drf_request = request + wsgi_request = view.initialize_request(request, *args, **kwargs) + + last_modified_func = self.last_modified_func if not hasattr(view, 'last_modified_func') else view.last_modified_func + + def patched_viewset_method(*_args, **_kwargs): + return original_dispatch(view, drf_request, *args, **kwargs) + + django_decorator = django_condition(last_modified_func=last_modified_func) + decorated_viewset_method = django_decorator(patched_viewset_method) + return decorated_viewset_method(wsgi_request, *args, view=view, **kwargs) + + cls.dispatch = wrapped_dispatch + return cls + + def last_modified_func(self, request, *args, **kwargs): + + default_fields_for_timestamp = ( + 'data_ultima_atualizacao', + 'ultima_edicao' + ) + + # tenta encontrar um dos campos acima para usar como last_modified + fields = tuple( + filter( + lambda f: f in default_fields_for_timestamp, + map( + lambda f: f.name, self.model._meta.get_fields() + ) + ) + ) + field_name = fields[0] if fields else None + + # Retornando None. que indica para o decorator retornar 200 OK sem Last-Modified + # Na prática, desativando o AuditLog como last_modified + # Porém, mantendo a estrutura para possível uso futuro + if not field_name: + # sem campo definido para last_modified, usa AuditLog + return None # self.last_modified_func__auditlog(request, *args, **kwargs) + + pk = kwargs.get('pk', None) + if pk: + return self.model.objects.filter(pk=pk).values_list(field_name, flat=True)[:1].first() + + view = kwargs.get('view', None) + + if view: + queryset = view.get_queryset() + for backend in list(view.filter_backends): + queryset = backend().filter_queryset(request, queryset, view) + else: + queryset = self.model.objects.all() + + timestamp = queryset.order_by(f'-{field_name}').values_list(field_name, flat=True)[:1].first() + return timestamp + + def last_modified_func__auditlog(self, request, *args, **kwargs): + """ - Método padrão para obter o last_modified baseado no AuditLog + - Pode ser sobrescrito na customização do ViewSet caso necessário + - Existe um exemplo de sobrescrita em sapl/api/views_materia.py + """ + try: + if 'pk' in kwargs: + obj_id = kwargs['pk'] + last_log = AuditLog.objects.filter( + model_name=self.model._meta.model_name, + object_id=obj_id + ).order_by('-timestamp').values_list('timestamp', flat=True)[:1].first() + else: + view = kwargs.get('view', None) + if view: + for backend in list(view.filter_backends): + queryset = backend().filter_queryset(request, view.queryset, view) + + if queryset.exists(): + last_log = AuditLog.objects.filter( + model_name=self.model._meta.model_name, + object_id__in=queryset.values_list('pk', flat=True) + ).order_by('-timestamp').values_list('timestamp', flat=True)[:1].first() + else: + last_log = None + else: + last_log = AuditLog.objects.filter( + model_name=self.model._meta.model_name, + object_id__in=self.model.objects.values_list('pk', flat=True) + ).order_by('-timestamp').values_list('timestamp', flat=True)[:1].first() + + if last_log: + return last_log + + except Exception as e: + logger.error(f"Erro ao obter last_modified: {e}") + + return None SaplApiViewSetConstrutor = ApiViewSetConstrutor +SaplApiViewSetConstrutor.last_modified_class(LastModifiedDecorator) SaplApiViewSetConstrutor.import_modules([ 'sapl.api.views_audiencia', 'sapl.api.views_base', @@ -39,7 +156,7 @@ SaplApiViewSetConstrutor.import_modules([ """ 1. ApiViewSetConstrutor constroi uma rest_framework.viewsets.ModelViewSet - para todos os models de todas as app_configs passadas no list + para todos os models de todas as app_configs passadas no list 2. Define DjangoFilterBackend como ferramenta de filtro dos campos 3. Define Serializer como a seguir: 3.1 - Define um Serializer genérico para cada módel @@ -47,7 +164,7 @@ SaplApiViewSetConstrutor.import_modules([ recupera Serializer customizados no módulo DEFAULT_SERIALIZER_MODULE 3.2 - Para todo model é opcional a existência de {model}Serializer. Caso não seja definido um Serializer customizado, utiliza-se o genérico - 3.3 - Caso exista GLOBAL_SERIALIZER_MIXIN definido, + 3.3 - Caso exista GLOBAL_SERIALIZER_MIXIN definido, utiliza este Serializer para construir o genérico de 3.1 4. Define um FilterSet como a seguir: 4.1 - Define um FilterSet genérico para cada módel @@ -55,13 +172,13 @@ SaplApiViewSetConstrutor.import_modules([ recupera o FilterSet customizado no módulo DEFAULT_FILTER_MODULE 4.2 - Para todo model é opcional a existência de {model}FilterSet. Caso não seja definido um FilterSet customizado, utiliza-se o genérico - 4.3 - Caso exista GLOBAL_FILTERSET_MIXIN definido, + 4.3 - Caso exista GLOBAL_FILTERSET_MIXIN definido, utiliza este FilterSet para construir o genérico de 4.1 - 4.4 - Caso não exista GLOBAL_FILTERSET_MIXIN, será aplicado + 4.4 - Caso não exista GLOBAL_FILTERSET_MIXIN, será aplicado drfautoapi.drjautoapi.ApiFilterSetMixin que inclui parametro para: - order_by: através do parâmetro "o" - - amplia os lookups aceitos pelo FilterSet default - para os aceitos pelo django sem a necessidade de criar + - amplia os lookups aceitos pelo FilterSet default + para os aceitos pelo django sem a necessidade de criar fields específicos em um FilterSet customizado. 5. ApiViewSetConstrutor não cria padrões e/ou exige conhecimento alem dos diff --git a/sapl/api/views_materia.py b/sapl/api/views_materia.py index d37658afa..1810aea2e 100644 --- a/sapl/api/views_materia.py +++ b/sapl/api/views_materia.py @@ -110,6 +110,25 @@ class _MateriaLegislativaViewSet: self.queryset = self.get_object().anexadas.all() return self.list(request, *args, **kwargs) +""" + O próprio decorator LastModifiedDecorator já implementa o método last_modified_func que atende o caso + específico de MateriaLegislativa, baseado no campo data_ultima_atualizacao. + Portanto, não há necessidade de reimplementar este método aqui. Mas segue o código comentado + para referência futura caso necessário e como exemplo de implementação customizada que sobrescreve + o comportamento padrão do decorator. + + def last_modified_func(self, request, *args, **kwargs): + pk = kwargs.get('pk', None) + if pk: + return MateriaLegislativa.objects.filter(pk=pk).values_list('data_ultima_atualizacao', flat=True)[:1].first() + + queryset = self.get_queryset() + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(request, self.queryset, self) + + timestamp = queryset.order_by('-data_ultima_atualizacao').values_list('data_ultima_atualizacao', flat=True)[:1].first() + return timestamp +""" @customize(TipoMateriaLegislativa) class _TipoMateriaLegislativaViewSet: diff --git a/sapl/static/sapl/frontend/css/chunk-vendors.045ec640.css.gz b/sapl/static/sapl/frontend/css/chunk-vendors.045ec640.css.gz index 23522926c85cde2926b177e7ed77ececa099e573..877296780118bdf35dbd124c0532d011b3f952a6 100644 GIT binary patch literal 60971 zcmV(*K;FL}iwFP!000021MIzfZ{$dJC;GoXg?0u5w5F3aB=ad1-0*n3zB_=uzPr8# z*6 I!)7R$vzgBw&OpuOW%B~c&(_) zj`iW?KFZGL1B<$su4l)^-yZ7I<3DD_Kg*wg&+9GznLRY@=3oA}_@7PQ^v&X*KEFJ$ zQ}oZ-sayO@emV7*#UHX$pSA38So}A3cw&8BWH |YLye?xoB4yxpt)ermreig?b`AAr_SHE=?{y2ZE`>4;((Vjmzh=-B-&c5Y+o%twE z{l}ht?xT_wO`G*~b5gS;3+-u>1@g6uH?PW`SSv< ApVIea$<5-q$^!qjOfk zaKC=wD{;er^gnb@54ZA SY0tmC*DGOusg;1AWi8$l!-Nuhd7LsieQt@I zL8nMhA(YwE2xVjQ2zBJ_z${or*0eh1dS;!nVRE53m|s !WyMETT{I(?SuXCs@pby^wm cFaAzF{g`b@@-bZYzc=D@T2O zdN~&CRexf7{(RFlhq}D^HDgSFc+_TPed+F#m>-@I0-$S)^Iz88`H;QbR|m$=8~;x< z42G}u<#_sw5Q2v90lsF>_xrjm*=d-3{vMzGCw>U)uFmU2-J9+IU+UwzX?xzfoH2XW zqWUD(6DDt%4{Lr*m-;+YjoipGR@QyfMuL=Pys&+J)9ZVd@$tM4yI7yEUsqN2@y#pk zO`fxVqU`ed^)IoaU- KF_OCxAySQWzw~AJO$cln>u(P7N>n`lm zC%sJaOh%jt9lQI2Um>h@kB_1z{8;f}9`9G@&tq+ttSj33EH<^%aM!o2FZSL#i`&rY z>*q_?*VRj;@A7*|7NeZ?&rGlQbiY59_0y{+aMzDoye(V!Uef{{4C^RY5ZcZUP4PI^ zRLjm;-M;GX`{pc}3jKy?vgF6KdHD1xZ%Q+fXj>8AXH_#}A%lb5KIGXC@gj+nMVf3D zACg;_ =FHRf5^H%D)#lE zG;FSr=)SRE`T(!f?Rpu%L-XD#etWwhY$2mgCeMoh%H)iTsjzg8TsHY=i3>5N*!iaK zQHKFU5}^q)^3eNq^V9vQ-$%nX`XRk_dZW};S9fP0B+!xZPq~4AclxtDY5ZO0tySvZ zcj{qe%37l-{KlTB+1d>=rVooyeiTi~j V=7uzl-@XXR7Rj+|!o%sq*9`6~1PrqhYaLgc@% z>NcQ+BPvRzi(SUQd0Q9qx7Y<{1ITXjtgAaQ*Ww3Xh3ETZ(5=Z`@i#}tcfn0_I=tNQ z6=sZclBe?Khe4gZ-E7aFg}%Rhkw*?j!Y7_@ibuXI>g-)VEvo$_<{wRbbzQaD5tN?8 zO-twyWsx!!NOdC=>3$4pypM0h8+M ;JG`z5#SP%&Inp}xN{C(Swaq)Nbg zP#?1gruDi*soAd5HZYX8?98(MUj5%kcYdJlK>apnhUV$qEgi2m^PHR7(VOW*C{ 5hB(&uVs_41A--1Evh7Og%GyGRg+ UD;R0xooB0ajdIUz zb`Cb!ea?7&b55Kkm((`$mbN~1tiOrnKm5^+SjmO@DU|ht#!9JXfAuRxo@FzQn4tbe z(rUTp5qsj5IyG8~e#=({2^W6^AHeUWF3S(B{P(NR3cmH-O{|{oyMxeV#=ltzlC$`* zzJ<-p$r@iAP7_%f&aD)3B;~t@QQnejueqa?Rnbv;`oWPwz^cQ7B;jjPbovXA-I1Z# zNe*HWj8w^0bA~F^dozcss(#y0C${GMjOJ}y6B{nNQx>z{oV{6AySsemH>;}OwgOk| zjb*V*W{yl%oj)=q|8oYQj0BY)pVBL;cKixBm09~jH{kLbaNDdMuA6+>m2R&X_BAig z_=qtZ*heXI^BphTTK*u`0QoA@2d4ds#XsMNor1RCg`IMpmEMU*8R10cav5XuJxT$` zXC9wOY)Yi&YPnjhwu? {vN#L{RlTM>M)0-)n4F?Z>xY#8ZvT+_!hjJZE>az8b2&=ZehU%iA(z zY#zS^Rj(YUxL~F)xwqbCw(5)M{M!5WeXiMWy YeB9{GqdzqpVMCdh!4 zRrE1iih9TTq#5I->%<1#efm_N&X@ib&Zf{cQBJf*yChUf(h=J5+<41;61sWcV=dL7 z5ewEbj6=XaTxtw`M@QW=kQ-tAP=ilbW21Kq<+6VIbUDcazEuda?`_iMEol6IkSct= zmpi@ZZ(qNq`(S@s{>)c?m(}dzx;KS+h)*xyZNh+H&x&%R$2|c}BM}^rSAy1!D`vuW z-s_v+(qb?d@6;}`?8KcQPc(MQ?{y15e&bdjgx7cMiab+OJvkHp=lHZYc1RzRS)VDd zb+otFQF ck*N?fw4v zz+hqx(0hX%n%j@A(;N=U)*MBB(;SAE%>(Uig6kP$qAg+4oz;*uJ_{!LUi?P}D#7&_ z@2pEc`V7~+DS1(M`_xblb=OlvF#?OU->Qo?rgS;7P^_ > 8CzypG`RUQOg*b1C*wA@1pm4xWQYmrkr86YuEgXza~B){Fu)`b==k+*`- YTbB9r@3imz_1Emk>Ff8J4P&`ryk%nEcQAS~-iNNgVHUpM zHGKUh`NqpnH!CGpE)hJAu zc(ofJvC=Ilm-Ja88TfJw@3N22d !-v1xzu`1gPMX`H^A_nvE7=6` b-DPdx}hAYH#WV112=7*z(_q zqPBU)mPuW6H;J{8EVQZSd4B7YbyV--Ly7pg_v}VC{pxcr^847S{D{UpzEA&8Y5-jN zaOPW`(LQT??>v5=?vHg@9vHIX@86aBj__lxc?Lg%nVqYsJ>6o*4%MvPs@}S)i);2Q zzL?*mJGFPQ`F!8rf}JX-`g?sFwlS@>{Vtt3TXXn2m`U8AdCkqIoPK0*a8pJ7%bDH2 z+D0vlKd^Yid7aaY&`+vc%YfxZaqmVAw)46exdR=`*?dDWEmzoW4370a#NkA%vu|00 z^5t8K-)Z4hC*-=1^!lb1SNm)?{Y~$;*y9HG$#vEz!&~SO_wp?(Y_~lM<@d(Lr+O5V zF;|hazi {_))HJN!YUe_QM$Gwd97x LmQk2=Y6*q!~T;s7vryM zJ(d_X-ci559P?KkO4gG9;@FR4TF1Yk{25`)DTbN=?v^PvA_;2=;|c~ch^O{I%q)%d z=mzc&8 R`(`ckMU%|=Cb)DgC&5z zYCaut#rv? ;0^C4Mxi$Q-Xe;YI( dlCZ;`?Jxm`O~V0x>-NkyUa>ZQ;Uce+AoEg<2W0W&)QPaE4It@hipN5XO|? zPW&wu(}0XjpQ$C^Jy?{<6& Dz|ot22165H>5Y{Lt2& zTXSr7x{eTFZ*~~iQ8>CW{CV0Rb~aOHdMUXK{YpYCq@JZYL1$8-;k|wXPy-!g3hPG! zM2%Ryt+CyRx|}*@uPu{fLSUlgpiutVG{rujvy)g9z|SW)*HHK7{7U(aPfZc|HRo4E z5Fe_@C`Ju<;SAN+&LB!!`&20|NbD``=S1NWTIPFaH)?M#KwJ87Hyf*PjeS_l&W9I7 zVSyM)r9E=WP&Bf7va6pkHJSeC9VKiH8=$QF(Ws6&!}F3Cn8rc44p+vPzCE0z_*Tf! z)$QowDUTC<7C8g$#v_dD8t}=`8;RaRb5hb+h>Tb5tPn_UdaoUtOdT5mCUhMQ#z#Mk z=G(nui8`q5pr=<3c0dcI6Z=?xMB3p}UsVZT?Adst@x)L#oub?<9N!7JAH(Q{ImF8C z5 S!S?H#>TK-Ous8A YqMkPZ?*0NWZe}E%ioye0U`_&=Rj8YN96pneW7?s`<_r+wNmf z;3axfrsIz&%z^6uB=Bs%6$Q*=VoBx^-CzDREaue|Q;e?O C3OuYNcIRh41S_H;V#<-xrcoC$nDx -6%V~T(=Cstq?5e!9l*<9$SQ3Zmd(U4mwCeWM7&u~D#=>zoXQ`u$@d%mUz zPWzP}lZ!ODWAh`AsXCshL=rX+`AMu^a{i6jJ9T%gyY6+w&k(Bib mxb~Y=1k0D zZ>KO=j1K)Oea*N!#_sfWLU=L}8fI4xKuB@mUGjZMfgUMtF{N^xoU$j%J@iVR-IB K8;rsz ~WfkKVW4 zPS)?+=JfFD3Efj~T@J6p^FeVLQd=j07K^FZ%5rKgo5NzdYq(T2nU}ZpefnYDIx0eZ z1c!GSfqPo!q$!^M^y#6M{ksg_H5KSj(R0Zp8m?QW=65 )sg-va}!n> z){T|3R<3HA{tdb?vCpI;Y`(Khpz9(>BeuqZd?+tIDz?|m$h8K@QT6qAutzR29am;? zw!-r)J9q3J{uo!k$#-vhp%c6jUUJ@T>Ph@NboSjg9O@76({>8;x^fR{J;Bs+0^6kG zpUDPnvK?w*>DSBG@MAyH{zd-VSAVw74`~B+#PLW?qzJKI_;w2}_c!^^k(vm8w#O92 zUR^gc-?TfP<-a2Re=WC=WM;lSOb_ji%FC-c^iRJMNfB`R6X{HRA?&a5$wk)d+?+4x z?sd#H=i`K_K~iS@rL#3C (h`e(0)-%_Qacm-}|fO0zbTd6L&YtQ|so#vRmjL#NRh8>!w?!hC0;Xg1?yQ zxO+z(?{xlXrDL{i#PQenX*DufG2;=^(|NAdy-Z!?KmB&`EiK5~eo*9%+1VXCLl7JN zev3Aiz}i|a#Snb`*5)SGX}FojrexL tO_ti5}+o!W_&R-gt zI-3lrb!nV_VVuiz9MXk`u*|wWGgsg&h4;7vCBYV2Y(Wyo7ErMlL<*lV;@xZ#M{8FD z`@Rt_QIYh=#%`HOp8%{Sk8KFms_xCb8s{A5X6m(wyP@GCnrYxD=9*2WKLj<2nAZ$v zH;QZ(6T2t8Os8FZhyEp_LwtstYS+#-KWqYv)hjs@tT(#%l}$T-$VLHVKGH{6x2?S_ zpkaX)`ceERg3Rg~rKG!0s`f^l6TN*buM~50s0hLO^l~hi{@h!vx7iLH4pS($g!nf~ z5w{%SwF3^dGBVJs5N~f0ko{{$J=E339&d7MvpjZ~-?|&`%PBKRkZKyx*&Bp=46)70 zg7=o5IfX19Un0P$n74E@*D0M^C*fap#X3!V!Sz%7j^ANU{x!QSEQun168jedv%x NIG**Up4#EfhWG!#X1(k6`OrdeBH`Un zZ!;Z>&!wlTX5%3O^fvmoMzC>snz&^;9#b*8_7k{F*UinG*GFb$&g-M|A*D<+9V5*l z6M>;=aw(=cdhi^yoAlfi #qm_U1R}Kg2PRkA`#b(K^|0JrbTK zV`^VxJMxtPR;OSiDLUoeKbr|iOZQ;QcW8LYsV-X+A2-#2>`hg74NKAmA;cbXG6>L) z%xM0A>;3x&xNU`Z5o4}P<9Wc{aXSrSbK_UjvBFk^64mT2hAPY7gS?Twq {Ku-ou^;u{~lFYf3xH@kEutTbN^ydcG9Ug$i`z*VOR#W<;rsV9|F7t zMBw^^z`b;^i7@XeSoePeCh6a?wI7~hO6pYng9ktDCKK5xaa*Yyb%eun-S2B#!#26y z(OqkHIMwX*o~I(DkPZ4)SDWxGc@fqw*Rb^bz0a *!aTCipe})Xqo>H<}2}b9^QG2m^-Zij0#D;;86fuUWNO>>RpX zou m3~x+&Q%IYf zggsYuF47P!0VUCgf8TVy*V1C5r&k>O$%9- e#XUj>%7&Ovp4bzus&%26XKz2qo_xvbzZi6ci;Z z$yhG8GU1q~p#B925<$A fA^1% zfBQf3UGgtkzgwR^KV1Ih_y6_x|MdLVe|_r@NXj4DzfsbKHq^rf75O2Vn~!-zgRqbF zU{BU!X1yQH*hmcA;C)V7&O&RcpirEkACdjSV6d*S$eKn3Q_X5!Q &=xJx7X`n&UFCo)Od+f53^k?!!(j z9D#1tjDR^KNQn`|ZyiGCO# &lb2w#GLpFjsZKrs zwE>p3+jILLCTO;9;;kdGJN$13)~KovoSw_PO>NfoKS lxetEhbP%xkI@{nygiLl}=*Z${u zX9hi3uT+lfISmN2>EgOqw4(LZ_I!H-?40OoG~c$t%#vg;=TgyQ^vsv;qxerGe}_2` z+&I^nJy{K^ZnDGSt2dGQoZZM({nMp!k_*}uil(F Qgc65vdB+$3M$wfUzUO7Z?*wVN0TeZ1msizJ zO{DtzIP+qZhjxhTp;A3a7Xf8bzfXP_C8|J$x(#P2NfczM3#oN(n`0w3&X3OYI89>J ze~}Ytk2*zW!NzE^kp>)?rj-Xlu*T<0$+fnSb2)Wc#coJQ4z{b3YUk&%JR4 B~y*l z+y4En#UBZy5+6fdaqd{%oZr!|=tS^hbkCYyJiwQ8w#V%A4+8mqkyP!iB%wEc=|>+1 z{9};n&erityWtRBZcAr)j!YH%$LYqxjx2{lJ>$}{A$&?87+DotCBza4yw&RwR-G2k zuj8{zyyw_IuCN*s%?YPXFUTCL)Lmr4dgMM~E5^#q(-V|iwkdYxIY!$ySO|0L8_3&r zvR$sy>H2~16IIW_vQ1aZG%2rM-=N>?z--kwjf2@z-FaxXWs=6(H;jJ-&enZFIcXy- z$GYJE^R`G8=gWLKbBoB9XKxTy+iZ{RHrXZBRPBk)0kzz%k;dEj?hRAx^m*OJP+xP+ z#(>UzkmLBx1E0Mkp0oC}7<(vEETTN>I@~ao*fKLWjA;AjJk|B=9VMH~CYu|!T()89 zdZM|B9j7bF4L9BMjK)+wcLNvcE`AGbv%diL@7zINx!>hfUbElz*a8lj8kBb~*8HFJ zty98J#$=DPPfvZS8cZ_Yux*ulr=v*9yt >_r9!bc6OR=R@r8v^2BD;m7JZJ zY?jOQ`VG@=_jujTQlE0o&eDB(8zxV(w0P^tXKn_Kb7m!2LKA6v9Hphrk{YX}Q=&23 zjh0JRv3{!O31 K@jDDek7rT!q>J2{3(x~d<&bu#&7!zdb{Mkv7*-0j6 z2P8V#vI}b*j$#zV!mSS3lB{v7R5<>ySIDrJ?=@_rvU_MbD;Rl-U9x{~+LnI*soQ_x ztl=CK;~n!BJj$t-=HpY2we)^?mGgR;`)T7aHs|L2idyEze5ubO{hqY*3*OE8S}8gk z!JB%+iAY$?sRW^lgUKRPMT?j5H84WIr6&;k`OI3r%0l_DkP3$dry>ibAfy{sdb$Pu zTt{r%ZyXL%@vv| 4 zumctRBGQUq#RC FDW 8=S{q)#`%Nq8KE4z>t-k*rXrn8{Nh_3069^3sBv!ARdzItlH z$2PlXeu#t4N2Xj`0LJEARk0^sI{7>E{^MKc-RghdZu1vpYT~Cp{hFQbFUaJ?)150( zV|v3|_3Rb!e1?%%W-P2-(Qe#! 0@LEKz1hyEZU?@J zPgZdyEZxDUPaWqse6N3+yE3nxvy-jZa>@ymUu3!>(Yx?bDGp61VikZZ7^}+}XWU*C z)D&~f|9>%3MY$v4a)f5v2U%$y@E*$cikci&@!eezTjv%|+e%5>s3RwsSLA&QG?xzE!>&q7@0lq}vx_q#12PHY^JwI(?CjI0HmeV_0_!Km5qr7@ zp(2r9;dsEO9s&e7jC(4($mQ!*z8V*aq$NB6`dXs^-XqbH|4ZE{H~tj^pA-ThAy6#V z=h~<&O(nBC8eA5~*K9YtVGF6|N2{mBHN*Nb01(2)wDdM?a}j9}g!d;I4+Y7aD3YZf zOPBrZR3DX!wa#V1e+0Vob;cIzT`QwHpg zCZ)4pkkw|TCm07|dxNt*Ig0V8(NgTd?kDoo7vHT5u{FM5>;BF#N5kOEef|3Foky^z zUgxmO-_(i9Ac%G&AH7FMY8PgbGQHwkR6V2*`%l01bn&xKn=q;syz&0kC1W;~PKy2f zoJcb!WaP+Gs2JBbWoJNjjb>Q8j(>(Xx$+iSOw1m2QxH_#vh`A*3U+DCMRp2%*lvXM zvz^_DH4X1DB)hfu7@JvxcNyEz+I3^|l2((j&B5}5+d-h&pr?P%E8eWQulxet+kZ*( zGUY^XHJ5X4taSWxe3+nHBeb%kLsdU4+0hQexO%3r-fhUPsUc@$Y5*;h`Jxoo%$4WP zb92I*yzcOgIl_>zDfY)3Ce?A6ka(;ERKO6TKEnQ!>@SH<2!fF7!XB+%^TyH&3o~cc z0TUX2!U-JJ%@ETs z<1Mw9$;P4#Xkg_?Q04NPR3>5~%pZZj_E7i=V@QUKq|RkeG>HWJdC%ENUZs;2LhxU_ z8u|?bi(tJm#CqBvr$~&G#3j7Qyw9Bi#l2=Vy!#4@CTC1kn)=Q$Sk UP+mH{tmtWip3(9X$>H0r1Hh@tSuK zuMK6tGKAinh>(2wUx8G)A9?*ge0%P+qIVBPK*w(y?}B;zP8$`A>|yI%=i~rKp35Q9 z&pC%lnsMwAplfOE?(y-B)|hpwVH|U}nQr{b>T0ytz9c_@cxmqTbO!K?z6-|2I_pxH zU`$47@%XzluZHP8e=Na&Wi|M(tX-EgRrB!hif0 jezAu;PeLq7SjiP#LDj8n^bG zE=MlG=@gXCRkRiVAkvm2%b^kxg>&n9X4qY0yqp}sX#HR8shk+<)S 0e3r^4Zg>SZ#XT7u) zVl(K>TT=KUgFu4rkxD^yI8rCnn1xCuJfRKJ+JtUtK#5YWRi2~Vht2l(TPDQ^1;A^} znmSq0=y7J~p{O>@J{?yFk*kL>A(J Gx3Tkgbox36RJ2T~t1F z^VbgQFGoY~uaiKL7Cs#7(}2!3Y-_oJm-OwW-##CLm*s2n-*}wMmX>zCpMMJP{Kfp2 z^@TFZ<|`RWC)2{^3q-E+WQ+c4JU@M{S^jOhecuuHE&j3ncR}=<=wNKCU87!@mjmu= zu!K45&dp<};M3)EL_X(*AHyOvNMMDC R1~vSd z?r7HQKmFFf0jP>jtclqkVj`rm&Yd3!k? vBBfT?#we)@M5+02`o^${H?Qr9G@H%h+$Lfr(Zw*_ z$iR5SZ)wNZlB-s4ox`TRM5d!UM|QjyE YqNr&5b=fw>3XpFLv`;-)F`CC D3#^irge-V#8V76V zTUrdx{C8(2Dd7)^8#(W<=1=2GhSwWgNa_t_d9m4zXH1`; UK&BvdjRSa(@wey zMr90c4CzP5!P_D3s!s#|HN?25158-bjW>(N&ilY(3+TydctHV^Sv_W_V&AmmyWrJN z8xm$o`H|RA*kg;PU;&*7;u8S%_$!up2{@7~w3^>E@Ns?RSLEbHD)p#XM<3wK$h6UC zUvNG*XAd9CNl}LN!zof(KS}6-fI|AVT<^1-(g4=W`UK~aT+D26HZfSbkTHBY`=*Wd zEaTtw6EUK>gRow4xoUE%{5+<_L4BRPq|jSs>E0><=@aa-dvmPi&0(d*{cccjlL>QI zQUskoaI>dSeUv;<>I@ABU=Cv3n`St?o7D`vABm7?g^Mr;p2NDijV>#m%(vur6h-F? zUkE96z4|E?M(GU=vWT!3maYtS_jj97>cm54gI74Y>QJhVx#-VsS4Fv!%?_>gH351# zguy1lL&M79Hq&@NH>CuX)lQF2L;Z$yL+ewf;Y=(#V;Nr4Nk{rkJYYKpspaf_zriyS z&DU2tUus{P{BPlTgUw3Taz^TPES;>b%0-;!`Gmf0F%~-u2pT3 -wh3hxhqYR;*Gg;KG}WnMaaav z+rqAJ6&BFH(z8x|(krp_FiT2XX}8!iHVSp7xN`3e_-i_oT6_~zu{^84SR%F1Y0gg& zWP*H1ToyOraz_v{a@vMMlU>#|SiOrK%Ik9?GKPFHbi($YUu}{8I@K2Ggo@Q3bvyN5 zc+cPjwCO7bgU1?Bb;7U%um9=BMuHK%gZdt^2-Ju8zTD# l#3~R~ zzBMr6>)bBfzyhM`sJ}Jw&eS5!4<5j1i6#8EbKTFcYv+2JaMdh*liRr8*i|0n?#q8k zXL5Tej76GhXxf*zGZaoUXdzse_9t!Q_*>hvQp1i3zu(sMuqO$z^4ppZl4XvZ;Wspo zM-siIP1~G#M8a&})TSat$Xt(~|0_F?9?xFl(%W&Ii8%!AJw~DPultthck0+_-NkR9 zsoNEw#Xsmryn Td9CrCNyLWsW}e5zyEcj%Yr!@Q+bZud_`fu%8x ztGw}$;qTJHn(v?YD@P6m9T9shrbv&%HvEYZ-3CLlc8c6Nmci&9MyOqF?FF3YNc(La z{<*_)P-~X);eaju(O3;XUhB^Bj?*#a#QWB+DC(LSPnhYf3S-cvc=V-Z5pistgu+@D z+s-L-h~RrnL7 fM*2 weT|(cHCF@lc~xFeigA-moM};PIPSY`oMx&Urg2mS1_D2 zQ!l;A{nBlIDk0cIrbLbLW%mUUdK*v!0Uxe8 +lA mb4&Q_|bmGc3KuHrYm2tZT5zq3CFyF3E5(g gj+dPX#rRJ;)XNGtomJ)vS;kRy&iG@N7Xs!y~0mCQ$=MyH(VfVk+{;-pO-{6$z@ z*NlGduzT_AU;WkZe*0hk>gKmMf6wdP{QjAB&5_;wy--PQKCC~aH~GuW-w4W`=||Dc zzVFZ7{qKHfm3}CiqiEzGYJQP)Bb9!4P&Iy6i5`CVV zMPGkt&M(RzEREBZd{IO=b&H!no{A5= z#_v8 {uUO?w_*uhlu}Bu1LCc@hj)cH}czmb?aQG?u(1NIKKVL8(;8$BMB+p z$v+Fe|GT=7f4A)6au6W};;ntwp4DGs`-;DE@gMoLdzG%FO(znri3{@BH2q%ezlSz^ zk q%hs%k Dw6HUysGeUb2qpDVvexDBdf+0KrYgc+T}gE| zpJ0yVp^jAfA{AeC>t%+}4gPefUy++izHcSUwOf_LTHNsOy!~yI++x10#HWO n)dT7`ZZPYLTu|>H)X3|Q_)^YTyEV=u6|AR5qz&(^BYvs z<% jPaQ*FT%EUZ1g;epSQ0- zpBLf9qSy*^$1H#tF1)#Sjsd+KIwg2=?65g?i~V^^J`N5@x}msp%AlRN`)Vf~2fj1@ z&EgJECcavS#RG3GCPbDkgn-1S)o)z!WN6bjR%l&(`!$KjI+E=ug{bN<`pmmbZWm)! zUKStjZvB3iGy3`Yay*-EcpHn63NLaTPA`6KB~>jVDFNPoUC$W7ArsN-=0oFkI0mbV z_|SFmrR(j~wEIoAO#D$Uv|P={x>?OwIwU)NP{-RClHCy#0r)ItDB?+^OF1igi|tqS zI^VRs=b!;o!EWUvvbF8*laJ$@T($>0J;AoRFJOMTeRU2z35ivyqH(BUAWl?k&)2`G*Extb3 zGzIn2EmDscntF24_f1=W5nA p&G-$i{B6?I!2SXh^>@>12+9`3rP*8i#c%I>4ep_={N?mL08XUNl)UM~U9 zzUSM{=|B}0*$HViWqOoyzv>MZR*PYgE}d|wt~nXBdyu7{k6HVObpfl$x#fP}bVYM! zSQnMvSVeOb@O}PQ@Gd)nt%P*2&3>hKeAQqxm)@C{Ie*@De$xWCLm{3D9_Rw_q+)gD z(M8%^rFyTk&*wuUoT9KTz=l1={7z+=IM!WID%Jw~j&-4$d(NIX_wc7n)?(AKTQXIx zwan1g^KG1~=lP{RNaMi;9ek+=U)G$J>-R0gI$NjuunCfWDzn!AOjBNFFCDh*TTK}h zmYj7OibrfzS>_F1zaUQ}b$Iy4=*{&cjNLDJCSa-ff9#PCkFfP#SI)`jn}lx MN!UF zxSL2?=CQ55(bILTi?%uMo0B|d5tkFXy*q8@xjb@C4QJGF4diWIKCsE{Sm^3!A;Ae9 z0N?NE$7VnYP7ka-^P}0L_lrJFB1v~7ojAz*AkH*NK4uT6x_S|3;X&BcBY}I`4-`o) zCZFVD??1BJ+w6c?HSM$TL>0;nrwRTW9->=MAF)a}taV30$hfR?beix>osZA;1y>!i z2Q}lYWms29!eFRPkDg_CfSu%(e!1iHra8b0v(LpLI}($_)*DTF#Wq5(7&r`qL*=LM zlFR>lb{0WC?5MM!#LMB16&j?XmoH$$xzlTZX3bgf6iriM)qoSy)d+I6hRZ!?{2D)A z9wK%;b0O9mxp;!!*#+DY*#j4jB7VJ=tl(E{(_*IuwzneIoQ576b4AI1#BcKyn S0B$Paa_S6JZ9#((ysCOM$bgD(@S#5&ON^Hv3 }=q>`u4*4c`nf4E49 ^;4JGi^$S$yEsMk=zLn HJ?vTE*U^y^Z-Gcr9bRybWV6lfeIeN~zBw{}*aSHv zF4;`aMR#soTF1q&Q&%6H2QJU659`mIEqUHEjG<=hM9%=@^3s9dq#fULhezj9$#^GL zq5DZIdtc0 (% z*Jt75TNhZxt!5wcLxV~!yTlsQsXqOjVRR`r7o#BQ!$l*RT=M>djjix%dy7b`8KbCn z76Y@`2p-yFLjH8A%d+k{+cohVl{Yjq)He@;qiCuMJCg9Zg1M`ooJ&na3Ax4Y&9-$W zs^s%@La8%buhs!|zB8JkL8LidIPcDRcvRjo55?QKK(RAt$y{1JzkM!J)E)Tc;qt {D69CFX3lUAP0rkMm+jX_smy zu!9h$ibs?@z!J$duMQVZCLHSN7P*tAPp!Ec1!chYRcusTLaQVg7vMC(w305d?$zeQLC zbx=`y2V6Xtl7yW6#Dq?SO9bfExlWh%ZLid3VYj+`oFz!sdKQX{`*KkWkq6}v3QeNB z%SrO}ee_%(@NJg?$>+>O-$gPoH^&9Q-3m58my%yN`+(ok>!n2v(9tnMi7>8DTc-&K zeh>2OiKHtH7Xi=i#c4{RBD%FUw%kjc2xb(8oaj7Z+l(_wcknS{GqUr+kzwruhuATr zpBCy%5var2W1;!QZh<>Pb@*_l*ojR~T);^eThme1+PlqG?>YNywE^AOp)Q&;ej}~H zs3P>*e9h2?7_#|&y@a#(Dec5M4h0&?u2RxWWsA;|%f-F{xXA7~<2M>1daMimr9ypQ zwDnnfp@`@(T+rXGK?rbV-c$A@REg+8xN}I9LXyXL#id#QR-KfkE2lv*J7Kq03MW!& zJvn3e$eguM<%LkkVr)Qm2WAJpCr J
3mZ^VWf8pc1sbVDjA}{HfKE{6?;Z4)`{qP%-X>B!_Hah zk_&nQov4Wfuz+g}JY}fBz5@NnksTXsXL5oJ`<+YO!bjwhp-eAuFj8qLG@0miwgQ7F zt|F=8bY+YMm&Y2HP#MTnEbI69Es~bTG{%LrVB_fG11grp#{8#^wiG|R%9qZ@;LMN^ zcF;Ysbgi}MS<{~-wRirO(S_IsXK4Y)?(~RG*lL*6l5gBb8yQ8YF}g 7S1QGdx@ z!_LBbf-*BpkRHyc300JfWx=%(ca de6z9D6*#T9Sg77ZYFO(b7CW(QUI6b23hZ1Cq2f^Q*&z^c6%0vZh6nAl;P5lyO zfx Dn0EnzIy*Q5T%l6W@Hv4W(2Y%)^EH=v9&9T6sbpkFw`?W?qCaS&Jir@rcXJ z@9KFtghNNq=*i^WfHMNiD(80Rzfa7a9FgJIueTRwwWgBf2xpj5@VSu;{)BJPdb6?| z=qlU}W3-cPH=-wM3FXdmFs2H+$s*i&1(=^Y((o|Imx0Eq>ofdr%_~!(#%KXcVVP^h z> f;UUu#yWEywbULJpjdn?vy>c`=rmRD$4k6aXDoE8|tC` zj;nUQ8zcvL&5J0ohdr%`1yewF%z1C8f`!&)(i5(Vt2;+@dp^zdz`D~yb9ZW S z5U=5;YCDu~sr1MH3E8K5k0T@vv~H)SXWhuMt;{)L$FqnQyl}GQ@L%==ivv^7_N+ZN zM<%?0QtAQ4w!r}`g>M*fvXTv4G1 u*ZA7_-&{Dk=3F6qZ^gz(2UW&z!6IYL7v(UW`7VWs4%MMvcbOv_ z=bVB)x~`*#5_fh4U4*oNfWVG<^$Y*}3g1hsEvN{yvZh>dx4B`MWb`Sa_hhT>(lUj1 zy)Y(VCkWynaL&7083hL?_<{e@Lw0$PpBoegmOgckg#$L9fEEwd*rbEoa$WGLl1wn) z#0OWiBp;|c-eXstr&kl{YPB9L)}i~D^rS)^$krHpJmI zYL5&hSHSHF`oix*oF>41C){q(3ZOW7Ib{zGF12j#az{iK*HcvD3TZAV+nRs1b w`o-0yD~mSD1W4Pf%2z0 z;!aFyy0b4_4K(WLN#(M+4_r}mZ9?Y+k=Ncpez;%uxju?;R^&q={6lP`2eGgDoDo;) zni>Do?Cjn!y>ME87@QeMJ?JI@uX{e+#&vSvU#bc>2X9?Z)b?^Zd747E$PLj0{}G8+ zEfP|;4eEvimXEeR>&PaEW=4e(@N_xXIFGYUwzl~z#|;c@RoPJw_35b*Dh^TuZG3xW zT*FLx4}|N_LOF?YEwHj@>DETr&vtO1P}<2YxWx;5<3%tqasvlD8fzOA@U{)VZ%7M1 zYVV9eAuP0FVdR<+O2VKv*D(qjVC@rr;=pM|dTH|yeX2{cxfQsW$nj7&Mz;2q@d>Vs zmjEiLg46hWOKezW2SkloKC427Y^+N6#^&-&*7X%*CE?f%m(+}fBZDAqnu(Ij#01K8 z!l4gUN9490L?ZbFE i3N`(hFJh5rmYNycZ`7U63&wqpx|wlty?dz6Px zVIhhZm)eZ`ph8F%)aC)Fgln+ZOZ{}`1Wdc?q0FL4wwoAAk5Z?5iAUiGA5 dx|2 z p1p=)hzl#n0N5-H^Y=jP5R@B8s^H5#XMwEofkOKBjqk9o*PXa zJMg= j-(+%P#2E=x*kiSe;BOBt*ox_EbvnuE|x#SnSm zm|$^#0cN(Zj9B7NA5kY<<7v^qpbj<_7P37Stc|gNgS+PWEL{sxJ7}`8z1h&q7vid# z1K&}UcF}fI>ll7m4ErwbYx=HELIV=*B4`sE%2(KE+$<%KcmC`rPv|9Oe3Y`|IeXv} zFE4MGUsm@yr#!eRcncm7eiWRuZXi842G jX~;tS^ct54v2W$IjjDRK zCTz12w&V(xch`m=YYu2g4}kk 0Nr3(l2d`Wk^F+kBe1?u3 zd`=zwZzmEC(tuLBsofg!{qdc|2LB(*mI&d1Kl3%jNFVS{=f-uk0#7&*d^jS$ZH!yT z<$<7hTf;18Jr`w!)}eLP9W6XUbr2BvRO9Z_MlUHLVV)b5wcFV&%AYO_-@2d#a0!jO zuCe1bK#-IbgvR=aFeGM2nYx;zJ!cw-*13#OhSYUW5m(B3mjdF7G-!y>WKOfX-uG|e zw*uh|vPP7!E)8^4umtm%V7}RuliCVAyF?aOLW7s%s;XV=C=o7s?kV@xgJ MT*wKp)OGmS4U!DH+u_>J>OJTFXI;Gqy>c4T?N>xlI`bdFks@9jZrFv8;qLJ%I6~(G`byqJ>xd>>2#@ zBj3iu$J|g_kO9g-Bt-0jh-2@8yM}-W!H#%j7GT{B{wc?-!(B5i4?>AvilSJ+^fJso zy9C9SP-X^-6j_K`b-_{O*bwsPYFGLobhzG|uPUMU-tLT6ScKE#LOXa62sfEC9<;G7 zQv|Fih=|o_MYKl>TqoJ-l;t(L3jsCF2$q@SKreoeINpQ*u`+a_@->KEOn8! P6uFH%_XQt z*=AMm=|-SzBwW-)CY=S)GL4M`4l#967q~fVo4Mw>ZqE*cGdw5+PV7P$j8eVtYr#8} zI0j^q7(VTh2tp5aVt%k=Q0j{KnIDu(qVaH{H3h*>+)ADB6e$Wknkl`@&=5XUi8kt6 zY$Bi!SzD7r72K>(&4Hg?)N2h)3a+M2I*71RYGtMZ#Nog^
T}g88A= il%%?1Fk+YGCwb9*(^WsEb zy0J#56Fbl*#0|J&f;k?yh;rEjQ%4iQu%n9>XE|!z6UwGQ`J}R_qZGBG1GW*~-mr?~ zwF}B>0SrQDbys)H1~ftq%uwTu0@6SLlF@~T6cdEXXAS1@bB3EqV;J&Yc#LFEnQ$83 z UZw#`q3ul?QxEL7+#3Y6d(AONwSOr%U_8`6-1vZY0H6FTv*5vd))fYOc1cV(EGY|T@>vC*P 0mCtJ_ZfOZ4-$9dx(cfxWpi-SdUrTQ2w+30G;ARAW z%becef>Xw4RL})S1IEgOf2EvT9PeON?woFfXM}4J8-aGJmP> _ZY_X43gJre;PNwIP^sIxgK -ayCw{9F#mw?Za&qOIr zVc;oBV@wKz`oOO<9OuXQ@$kbGc#n@M*DDj4COL2vxLZEqEa3wkUTQEH0I8K?i&8L- z82u35=kQp0-qY4xxC8ix?d5GRg48gb3tIy~^aE$}vJ)+w-MLiMp%a>)mrkb6{(d ?A%sysv`nZlr!@2+VeD&mD8m48S;{xb z6M9)MVFzYCUjx^u*E~9 9$&0A8xbpVQl+g(AV-DHNn1@eQ`UyhH2EA@`HXwIC zqYjR1K$UcSGspo#&)TxSuaAPgv4Nx(!{ag|boUzrI-`v30j#Jj|5BgN_%13ODI7I+ z?-2DisoEyl^>}bbx!?$@04L9xVe+`eVDS{#bTxX2mbSM&Z;Z2xTAfXi=FQfaKrbyG zN|lr@wD5@F3yPGIUypn>Q4J?V@qIyJ|1wW{uLI$|reya!%U6ZEh5+RR5hGVf$RK`C z*)?z`gT=tiY9yl|@JbgezjO4qG4X%MEX9M?ygCq;1CUov?76e?30x@==(lTA0Wkjv zy=XTuRSvgFWeOVAym5Z>4pX^MPDaUg5cMwvfMz^oGjT0Kq6D4m80Ja>2PJOMr!3Qe zz(F}0W*Zl|hgK{-0w}~!up1}|Pif?RGmG_z_FD(z{}W%nrlpD6+UO2iX`~_oXtt~{ z0sEW}KnZY? wyaJFf4diH!ouK=pen#7+FPJ%-%#J=M8}fYm*w-m?uM{9jddoANIUS>K$LslF%toe<&waKwLL*xDv^4|ZVS>dRChY+ zP*jf#`tM85I+f-Q)#9%JQ{l0Fb|%Jk=Y%rUxNsLlN1&c z#qew)07atb>@&})t(WC)G>b$J=(38TjQtL508C5}YASaqjS%4{7$#C1XqaO=E{%-J zUfU}#3@c)ruou_0?a21nGuINmcJ=_h1Ge^wv@C_1T{?}ONn;NekXyX8K4d+bm1&)> z0z(%{oRYyuR?xoA50esz jsc;zkU}GCQ7C8N zd@b*>=(%O5!b|?qptwU@zz!ZneH8A3i~7|78wQ_bDM{TuW`}~IV($t=zel|$3`G2q z*({W$1|S$KlivaG2dK!Ht?|uL{2Gp0&6c@I?O8nbS$&|z{i5z$Jj`L^q?fyh9C~gb zj#uXLSwP(-WH{}62j*(HDl;z $F?+zBe(A=R18MvGRz zBhW_Yr9)lE)ewo`S}5#Tgi*lu4RA8F|6Y=~xw!b*b)DYD@9|7scP8H*0hpG6norpf zWH3*LTMnz-zU^svvpeHcUNS1DG339?`Sr^19F)T&G-?5MEnhXxE%X9N7>`dcVuIa4 z{lidBVmaiA$&pmVx^yxtP~jqlTOq jekWu3><#%=xv5M0mjO?bl;e%1Mbz>89QejVMYCvD8 zyB2pKnlPvY)xrhOZcuLhgEgAn#Xx tB{Y&jyF{h{^1a(jN0brjY#5cVp&6e4|4Tcg>& M^CQG{#yF}1>>nNbzBKJxFgMhet zC6qZ0$&ubMNAf?`oF~fK7v({Qr)TEEzkS0i{vXxt#TJW5W`2hqN-+H(c=y8jCu(wm zwnK)jyJ=ww pZcv2Zx7Hcc6_k zn79v_nRUS92$WY^#y+%afU1g07I$GjFmwwh1C|krs)W)CxMmLgRK5tl-1R2IA@5>f zB%kVi)}ks^z$$fJc>^#-&yyGRA6O42S=3{Xh?ivUja)W;=Q=0Law3Awg!GJNF$5$R z?K+HN7KPc#@eHpcsa-MCUl>;4Jic`3#ZnAtv?7Pk<%oKE1qi;lQUmIh?d2%KTG-)? zUq~Cf!IAut>DobL6HtQQ<)MpI1yoQE^}C2)nSQE*KM_vHc^+umTUMgs7+@X9>t>&Q z!KM5y#ETnChD0#jTR8Iz`!V*7gSxxzbVMVn6Wv-4jWn7kpu#9&HD~>^5R7T;Dc$+u za(d`!pR>(2$fUr$1cgDVyXkEK1r3!oH~|7wP2@MLkFI&bJo!v%HcXmLIYq4igC^k9 z?n*x}m;UsXol(4b2qCi=>VdJUm}gZ^qB}CVG#(QNQNd-?(6Qgu8ZdCQ8j=2#!otAl z>d$Cf2F6=ZFwVl9aP2I}AR23!R!h6gQBLG4*n)0AdQ3mpC0Z+9t_OMfxh4+fhV2IU z 1H{p1!mUTBNWskRbKQJ;q{uFDdHJQ~G=VBh;VjfoL_Q~&5nk{R zYNcc;Y7E=@i5yXxdyOR5L+|9&u2>v0TV!(d?BJErvNI?8HJ;>syLQM36{)d_G3z7A zA_ERHnf4lhKNJQ()hnbkl?0{SVA~XoJpwwUZO~LlTgYSoGhztM?Cb2QX3tf9Dse-^ z-P)2|#3sR&raLUEF?9K&e1Kf#bQyTqy5=O*4(i}NmK8cmus_tfj|e3+k2TIDY{2Y} ze7OOyW96d@(*+T Sz z7y?9tw9*slAmUg H2>$x?KlV+T5e5`ESX*CN hEUFrZw*Bpi!NfMaI_!n6yjE}h25gg#9B z1<%NU)fCenwIWctC)G!UbjCL_fLRk2wnn4|;hQp9r^d+9X7vGAVE~Mct@;GNCf{a> zR2fV0wHeY0654?cz*)#6iK-YAD+$FOPZh}bv>VTX2QV|^W8b3$f^W!jh*{?ZM|eV^ zGW%|0gENupq4g%r%}OJ5XQQcEQPX|s6GTER8Ax8v`ryzso24()OlkFe5V+vssLl@q zghQ|8)N%-xZk5iFDp3-d#kfkCP>~@IT}|1>I56fp!#!|TxfP=1otz =09U>*-6CTi;RfIbd0ggb3 zy-Sne_L4ulsBjX@(=B%~J)P`m580_l0V@E%l=ByLb|Tzt?1yeS?igDb>UIw9S5ZdW zSs_@-I{Zl(ktWM=j&2F0RUY*b6e_VM)U1QpT%I!2$e!4tFtp<;e2e9Lp{3xQ3d4vu zHZU;i5_cC6w2`l*(u{$xx$SLDyY!nNu9mT0szy+W955z?4-&~p?<&VVuBp%%AJ7eF zl10fBUv44-BWYRU7q^eGm30U5nbMM7*`R(2Z`S2FfPHcG_JqnlPC&<~h+>GZ& %`Wp+;U_Yb>=7t7k p{XV#GVrfCLNxZWwWiUY*8aX<3^lPCr#o8xpMV^aj~2bt0C;- zg1DT!u(@s11UIk!=qYRKj8M`+Y^&5tMOEWsdlpo_hh*!yBzHaYJOkKy+$DKuaF=iw z)zvPwJ)%%&sI_qkV%m4Ok4R5pK+r(u6R-*DBO`zWQ?O+|vKI=6B}ob!YncMXvpfV( zT)1*2iO@4-)ZY(@Jy=q)2mPKCXNV-|s7Qk)^#Fa{jYQbF-q9ycy6SS&OB!{Lc9d s^&YkQo}Po8aJw#HDPoiN?U!0xvmV3xaZPgEQM> zkZY_IhlG^K4XU@ul_8k-vxk HQ z(c7E3p8B4TKFT&_wtZjR8+fe!06N(`@ 4H(&Bs6stv!gb;(&KV&w=mM_RQEL>PEP7t_+&} z+!;@4gy`5nk0Z)*P%}uc4cugAF#jIw=USLXQNJ@FfVP1#&~RwXltLuR#BHGlMAOOA zVJGxJd^! vqG;Npuz+Auo3$N7GsQj9;t!KfUMja(w_rR-DXvHYC3k3Q|z|1r>z_w#^7)#8kA0xmG!1?_fc&|8Ie5XITr1ldJ=7~-JR z1#2^gqLzyhdxq}WZDv5~MULhM2i67|v_6#rs|q!uY}bSthsc#cD20~mU5GKRP)||s z5U`b3ho&9zNpV9KBsVRu4|$W2@YHM^x|;NqS$X;95EQ(^bv456yYY7Le&}UF3sjU~ zzT14!ph w(9yf0%%pe) ze&j}%vBxwVsM;d2lSH1gXFS`a!3t}IvDcLq9_xOT)4Ox@0R6sg^f@kZsAm9x6Uafh z@6&pnB973c4-hlZ75U`>B~K V8!`o@uwAZ;k>O@4>rFJ>n^ z2-6mVM1;49Eh$|NOFnaoUCf)z(` K@jf+~%u&la=t$`VHK;Q-cx#>;iZf0@ytY4LMSWd$ zaiLUfb^eNaP~n_?Y5+cPKakiY`nZS_PNzi4L!%sxU>LjR$3tD=@h6Gxr%c&Im*6ya z&V=byz{?RS&N7Fru)`TM`YPmmag(9mY%4Dqk4l5wQho|n&L^DL%0mEo )F*qe$~)JY{h$k;xH;FGocUE< z)Ll2v9*KzzTAi{%qn8c&1M5 Dx(mZM|W$7 zgZbz~cEA_sW__qB_c3}F*GL)(8A^x7N0>zR-C2Oghw3d|?sKg003}5`3zI}SY@+s{ z%I0}mercjS&(?zV;DqSmfq5yCS|(k-UGsNK0GERQce&5nBcIId5f5@-n;g153dL6K z0DLUxS8<7$F(BLEbIp$lp>#-rVG)7WsBnJ=DJlgnA^uG}$f4q>ItggT75INw*=ICp zqZs_&AKL7UYK+U$c0}deNOy3l>ywBQd|{|b%p{owU}+RL`OZ#o _T`&fR zovI=9-ANdwB-O7}c8AR1cGcOQ5@FLJalDJ5?VL82;RzyQ6PqI5I}3s9oRy{6ogT`Z z8a03KRCjagV2ccX$-*LfYC1PU2SUoYK(7M%a0Z&oNwF2qE6y#>#I_0pb_{`I2hPu@ zjE;a}No7L{jb8LA0B1|m!ly$1AJ#Lo^@>7b3N(rYJWOH~@CCy;NE0tAu9c9#=A}h_ z7|HsME5u-ToA^TwK8!L&2^QZvVWM7EZgN_Rmja0&c!f2_K=_QkE=O64*wWamaDtqO zE8YUcl2ms~)iD-n%93SfxqSQJC$jK@r=I|90M0`+2Rau@WkUoNgwA%i9&Eo;ZoHJ% z4FV(E>?E(s4%JFB1DdNi1u8W;EHStd@EeR%Z`@uFh@5$weW?%9cbd^)_y&U!VoLlQ z$&{DP5jQS)X$4R=J{_Dup6brSIqaYOPWvZp!@*FyeOn*TC l{g!MkEqUk7*A73T&CMWvNO)60Z=U? zolo_h^)urll~AGanZY56;fe*fs&m`?>_M05BB+AiktRU>j52eeThKoC$4igJbqBCF zx|{;N*<`jRlXk^pF|bNfBOl-+gKG|&8V(RRyS=#1iM%N4A&Hfctn7@8;;Q2}Xy&?h zXy6{gU2O$X(+?Ls|6uNDq)2Xrgdp0P{KwAHqwed*vGMa6)f!hO#gDkuFNC3#tt}@y zM^$w>^!54RnjU4AQCHLBZ5C>m#Ic*)k4$M~rTRoq|B%)}=f)I)Y{lNC_SRDZNMM~9 zFT0LR*Jv!f#?_6+Nh}Qum%pF_r~`{dWv;(VZ7>`;IVctm6~F_tECY$~Fk@r2mcqoO zn1PBIcY%db`di@16#?B?+TcX`9wNbwahaBwF!FrzH_&?+asdfSN1D;V0Y|3SL^hN0 zV-&7Y#3tEV(7LGJOAonrVOP36dt`^eg070al0V@U|Ab=fr{Mm3MBO9Otw}pbxhjLp z6emJ`Bc!sUht$kVDd?IVY+~(tPT&Vu8=&;m4l;hfI!J~_>o+vfHXTs7#RiX6MsW#$ zFP}%k@jzCPjwEMaYO(b$?Gujonh)j>(&AubYpFWuF%L`i1QSlG+>w?jTwH@JTjL&C z#zR5c+zAMe&7cMBgm1gZ7J*t?3_z8{8P4$}=G7J~C?M@ppiS$g0~(p3yn@YC94Mf* z6g0`FrY~8ADmoajRvpVwC&?{93k`5RBNlDYjXp@CalpedO{fY?1(_HKz%yEjlZc1B z-3>xT087yd5fV4}nv{LVbJR5%db%J{l|1oumS~o``iT_NPmH81F=$^Io=9L qpK$vp7**OPZ*V;*fZ7v7+rSlFG!LC0ag1`*pi+D0q6y_kzPekX z2`VX1HIu6Fd}w++i*sK1pcT@P6v9 {yO!=U#Ts=^gsYH2nCSUL*Cn7=CaL1~+w&06Z zDGAE31pi(Tj!?s1iNK8czWEwxjFf30&u&YA!N)*@RXj5Rxw5YXH;P8Jfp3(XmNM#| zS=OV;DV;~|o?M1LfKpvid8AY4=R7w!8tH~P&eK8W3sOg!Ed5-v)6SnGKxk_uJ`c@O zr+a;94wY1r37N^6J9vN^(PEcf8b?K$;4hQq7E6Ro`Wzb7gs?50>Kt_rDhDtn`vl`d zU?!AI6rhp{#JL}{2N#t_2DI9xZWuW@5}@7zn{t2 R6*P)ov5I&mFL>P;G%9&Z#ib zoC*`&t&9|bgUp(Ag8E1kSt{8YaLgW>^99%RZU7Qoc|$dY(5Ymh@00t$4baUUGznX6 zHz@WH6{f66>sZs$@K4 zP!LKAO2!DTQY+d62Ql6NunIEcAc6_I zNQ3f(rHz?X2z5A`Op%$;0WJX4_{o6ObppR+GMe+wjiR>HZL}}$dbtBFKWB@ML&8vJ zv)&mveKd4u;|Ah`Q_ Iv zM7JF?xs}>ehN22?H%RU`0u#wddNQ2gPy7P%@*cK0eFeTk#jK#`vUH$waZYfz8iIw~ zm|3}FaKV4#74%-|RDQ$-k3ilB%a|)6+jtf_By2A9P(-=#u#1UvFxCZcV z(iohSRP7 k`=+1{)iI_r@kCW1s(FO_cAbxRo?wOO< zPla!QjY-U9_y;unXz3~WvGvLmOvQpKS8T8s8I{dP?do&gGUpsHJE d0d^}MQJEN*2BNtVC?&Xl7Op`%|DCav8yr;s0flwC z^}uFI)C#i&9qAz}1t(~OI1Hg_%6GpxxI133GwL-|?X<>xj6%nv=m|YEr7JicJp?GX z$Thb^(#}0&2O+ow_5 $tIHA z-3^u))CiLfFf%GuFX1t{86f;oN?jU;O6ZPUrwUPqrz%@*Qg3Hco+~ND2_ZqM)_vtj z- pMs1mvxxJ&nk8IC%7q q6M0-#+X@fI2~-m* zCWqpeV@TAd!4?SIA}8G;fwi!LsA&Nt+dkATzp+=^!yb>yN-SZxK1nqhI%lz)h_^n` ziFl13x~iDxMfnhQ0?HP}Hv9kBd+%n)k?daZe|?Jkq?n0mv2GLIbj9p02zu{HFgD@^ zJV3$`h5%U6-~GWbL6^8yBR#G36kFX@Bom25BF)KTe@Ck_Rzz^8>MH30959mto-1z? z^A0Xv&P?69M=HZ};d}QLzA^FW=>4j`E#NU~GEx!1`Ju$9hl3ERr1UudPUHu07eoLq zl;%ep^l{9STmT2Bp#Y1LgF(f45{yq6dVz`n70Qhm*2K#b024i*kQ#5E+aMg_K_@6x zIW%RoIJdz*XACb&imOmTk*%OEY60j87I{%y725UkY|Wj8pz;|Y%r$6jfQc(6&2)|t z9BY=oC%nI9L=V+xXv&weXO{uu0Zj=yw%Dtct`5;9lTy()E*xU4Kwm^z6B?)^0l#m* zCh#4rurJb=^ZaB6##^qkw~H`#L8^k%^E7mCx4-9ehNf@^rV~h5F7T~;BQEbv->6G{ zMm&aj^nQjUu?^}V@hf%4E69A2NV-lsufjH*m#ljLva~Sb1kYJ3vdtAnuTqws^)9lX zb s{;dDL&>dEg>|l5RR=;>u*AUY;qu!=J*Uc@epMLJ!_@M z2$I}BRm ym37VCDd-(pTxpEW~ z=!e+>__sfbpTB+ky{h_Uy&s;N?b&>Hi&Btn|4{v2W_d=vGh1@f99o`%;syk(|}=S(x>yiV3l*V)!&eB#ectjj}0?FvkTy0$Bli_`+l=^l+Qu_ z{KIgA@Y^4Geuf{PpT! >LkM79xUOYFJrHjEPpVo_p$1TVS lr><3m^X1o}#oFgT& zLNgN!1c}@%;yS^nBSRg#LBts}+6o6@1Q|DHi0}Z(8(~P@8tf_wec-}13m#lv>0Tla zDjWlSxo0Nk1%@jD0^Va!O*|{R3N>5-OM#M#7Sw?X2Z{^{e!Anp5K_Q^cp@|?h#{(| zu{6k800pXP0A6P$tllG-%=7pGHLa__pi{61bWKZvG2PkV-d_9ST%cmv?PNR#^+)0n zx;y}eoo(h211? W5JF3_x7_e^C^(d*BP6sg%1o>HSd zB`Tkv!s)2yoX3HipPs9%KjBKzdz6g_PcH%DXOD>mg-ii17=?pC0Zay6DR}K$LIWlU zfTSD2U6oV>2$Qj4%TP@USixnmnGB~8I3O_>sCx!rbaPNb&r_g}A@7EY+?f*^APmg! zauDEk*lNHqnNhPG&arl}m)D_F1t5b>JLA}(NC d9?NhFrXFqD~B z?$&k>emsHXY1sh>RV`PL**UC`Zzj-b0xCi$hr22)!gb?QRe^Gf peBB_Q5puiE- zGhEd%lVUOyAMUGDWfB#2wGKfT1#qRJrDx2c2sJ(46AXM7xhS9;5U(NM0sPzp( Vz%|NBYq(Dkd8%HZ;u7{~ !2I@{mPy@Qz>1x@Ss%OGa zasn7p6u9S5CAUCXA-T$b3WvT#fi)zP<};=>KL(+W4g@gf yjdf?nF7b*hYtsPzt%%>_r0EL$Tio0BHtwW;*(Q>!R3S%>TRWFxC^-H|! zkN*O7^h^&ynxO3Pl}#7QX`n#0tYr*~1ha5tj30=(h*YQ?OMrwZqAW*sVr+$p>L$4s z4=T|!F9bA`?V gJZVFY%ymxYr+mt5LIl3QI4-tb8z86F_bYW&~Pi2 z01V*ClutP(-m6zmIo SVG(Wv*JywZUg%B~UxZH? zz=pr|JawZmlAh?zt*CyX5&%UkHcw{0b+T_zUW)?9jH8Ipz+JKDt3d!~`H0F>H&4@0 zv#J2#glJ_gn{xIHFOn>1L>vhmjvY Y5<#!6{(@?h6XHsnT7!$ zo7;-9)SQe-ZlXX>K0qsrNNGPu%!0>B(>1){6|QY20I0rUdT_|uDiMK^#|S`ADB@Ao z0i~N2cOA$sl(OIejH=|Cq063mp=23(ge#JfikcM j{HMt`A^I-`*}-uI|1;=zgtx(=f1&vePUy;Y{8=-I52l zTcZHK$1pE&+lN+3IVWE{!6A}?8adBI4tMCT&|JP|Ndf$qsMI)Z1EoN@4(r;%o|P@N ztbBPL8ZcEMvPYTNu0|d_ARRDo*&(AQ&@+a9R_JKHXsda~Yp8ZkzMIfY4XXAAo|=vd zC1n)Ie+k&%FSLbEx&j}bubc$OL&hZ(UNkx!H?;~I2x1e;?l3@Kk~?j~-EasDocW$A zo}dVFI5>QYP&;sBLFw6Tqo+5dzM6E#81O?^ZO?>ndEh>&Iv1#~Q%3RGC#_=+js|^0 zmBQ~T155mRU1ryz`u6Hrj}$XUT7s+Ba4;Wt!51pa1076u#ukhql;#0<5#LoJIs7yt zs2dpuyr}ZHP|bytU$)Y%!G&K8q)*PqQmI+G8M(Ptauvrt<*Ga< 1P#`TD4WO$ NLS zP|8DW 2m>DCdg JGN$C}N&G7ivc&5$(+*t1h?_ z0S99Mbq54cPq*&Jd6W`V3j@3^loIghQ&LkG3g%7$+LZ0IT!8|mq<95n7g=&mZm$Y1 zp|JcsfKNPIegN#Jn>8arT>&t(+GW?sfwIUIM<*Lrjn~PuEg^XU-AW3~^;%s4V0QD! zKUsu224TQKBKC5xP^e9leFPvXT>CZX5y@0ZfNQ)I!GNf`ScmH;;#E*ZZo7ky8sHnf zj7~#)fBg*9* bpgsF0y}P$JN(q72f=}t#O(}+ z07F2$ztWkiucmy0st19{c`+q85E#&v2X2(Dh u}J@3NOyd4XpLH3b3|* zq9+E5$kf(cCxxCMRh(Y}=fqKPLaJc^MHf}*7ux#;^ODhEVDQRB+!{M84Pdm49B?T= z0ikOi9JNx$FXVq=*pT2TQ+>GUDeKSe{gW4K; -*~yCVT40;paPq%yVC9G3>F93gPzjg9(32&h*ys0c`& z@Xmz5`FFs-iIxTp)x!Z0LXq0n%dvo<#8au@KLXdP!KKE5uW15LU`o8`)E Jvn7iLo1WkikiIZ>y z3=IRuZs0iu?FR@XkEHwrfs$;1RHu5Do5Ha~aS%S6??@ Y zfV;}5hx(EKA}RuZ@X3W93YCG;QL@blg`@!fiWwv0t9tGw3q3E9%I>V&aDyjXvkQF{ z?rcJ+clFOtH15hh-!gm~p_KIE!wanh9F%iMG}wWK1HDD$!PV$01-!1l+7CdfnWuEp z%o_Pa1{tyxvo-{R1h4^7CKN)|ambxph50c)KeF=w6s0^dG@LNwiUfGQAaWcLniT;c zG-dS1N+r{Wt5VqtJ4;m5!i?e9sY^)UoGM14>-iZomB9HNkh~~1#4r${f;Wag)71LO z1XQQM-Ko ;O8M0oHbCf+sF&@};YiEa;4{2KcM@RAEu|6K&5r=)0QSg(;>(f9KQs-^|bZ{ijH?%I7f2B#ECtkG(R9A)^ %Z;w}? z)Vbn?olp$YTo}(m)pXH@O8*ovsQVE#2?cWZo=%{MUD}f?Q$7Fpm9G<72`Y7&D%eU* z2oD}nDY6RDkf1(hC|K!vfEjep%9UW4mh3sSCxFg~Iy@$W1du+3z3|JpunL7W*ARzr zlLrc1<77{C;LdZ*t34PRXoQ1fTmLF@PKW!NDK9P)(L x(*D+pgIjD*rkboi@ z7K%DdJ#r79#j)dvbw0Y;!Av{|hfsC{1^zlwok|pYbku?hYE{-+=r#fVP@+}i0Y`y& z_h$#|XQ7j>oQ;dnFzB+Sp1fOfNccE_bI}zxZpIv>P(rH$<32p06%G8*k7}L-9==+r zy8@&JREB*>w+S~c;FYQGOA~&D7@%qJ#Bm^}jpNx+PKE#rt0u7-1NNB|!W_p@QhW~1 zfo^5x&10{g`9{Sm8%Dm!GaCC9G6PkG+(eQD2Q`xEd!qU>N%jK_*tf?kP~rhB%z1j} zFC6HBUf5OAK!u*Wpe4Y1Ka1es9>wx6;tDE9{hZ>#sTH6$dAxE)?sZXyvcbSS8x+*u zcu9; Y$kjqH v;I(o6EpVV(2${dv}l0ia5YRLf`Zq9KsMLE zL~zGGt-=$60<(z^0id;hMwv91z4IHUR}oX-tECV2Pcgc>er^ExMTD-r@_#5k*R9(H zq!upAilpGmzXPD_u{j1M7)(}(FA=(KH{`_Fe}m}y8a+)e00-nhpac8>o));d)#ElV z?Eh!;!v4GFrRel$*2eFMgvfG6(*7N@a&L@c6&xi{@yj_Ws^>Ej{CIhxUmG6s30Us= z^ 6&cNnWVZOO+&+qap=$X6wS2TzK1VC6U`SDj5!n!71XF&$ z6&1b1$n4=5DV5*LB075#Mib@t@*cXZz{urC(MgOge@SHdL6;w8>(cF7SyRql2b0nA zd(m4=LCO!T{3xHM$p6kAk|z)PBfs7(7W(%*?vx_FDG1w}=v<4-|FlK~$GOGVVKT{n z4`VLgbh)qotOb6^E%cVnwe@Ap>Be<*N!H^Hw;N~+S#Jrab!WRn`|(IWNyE75&`lO0 zX*xlro{I#>^g2e&b)uWRzd8uc?i3zSdn%9SrzJ`6n2}^|E+$4LGso-3yt^32t6AJ2 zPM4-e(QDcmt(Pl)(i)pbPq^}}X@?tZCMg#3n58GEc_M~_o8k(#_33RRY+khMWv9)X z*XBu`6YFD=I75D;oRUG>oTg24D58TIB_b}hYU;>H?3}mF)+iN^mvB0XTX7Q)HypdT z%E5@>cNeU8MxsU-a?Ae8yBzFhf5UUeS!&I$O%`twYnq$m3*o}Fh}J
h0Ql8>vr$5H|wPzEdX82ud!Y!JHnoSWmX)keB z=yd1XHb37TRRKSumd=eyncwJ~%rD}QS)XW+<1WXBVh$JFjumGou5%i>oYFKmdv?^4 z&?ep;xU|ooblMrj>$9mZNiJ?t$f(_rP-Je~Iv4Yufy>#_@T^R(o+r<72RpQvWU8*M zQ?q|sALjTvB$^GaeQJ?2ZLJYZLsKa6imG#Z*|=VOl0wyJj??V @Mmg`K9TuwkpA zUr?bz^X!!+jp0=~D$S|J%$iraIl3^DW^!$g=S*L_TFrTg_s&fvrA$@~&SKm`&DA6j zmM(u9bL*DQ35MP6C^Ea8t3Aiv`h$Sz9oaxXc`kii4Y%vjZgn1wHl%V`EEcXOkZUto znd$j-ocDSY*>u(np*acCoF9{8Le7r{(`;Va6xHtpJ$16(%q&3;4zulCHMa^z2hO;W z2L34Zsjao#A(xA}HCE%@o?5R3Za(1%J=iw9i*s7oo3l)@@;>xLd92El{XE=y18>5l zcynpt S8-eYKhu}CnPuWFg_5+ z$LOd}=A8q{tvkHo_eG{P38(W;3&(p*GdW4Zn&CXBf98> pe45> zR_d5zAxZqfPLpRxebuFgl($EyW_NA%+Z#zVD1F#&s*AS2Yy>MCJ@QkX!?Q&&<7Yv8 z8}uFRur=&JoO&lZOg1}-ip1&R&=@(_)x 7hC zce{-aV`4BnBlwBgr`~QJYy@tb_P9Pzably>(Z_*q%?IqVc`~=$g=cYZb3|s_w6mbZ zQ$zI@1J|JxYCB+#_9E_WmCNuVbobFE*cmehWjLJ>u;X#p)(_iGv}A^zWNr+1qwy$k z+3QS}#nH}Y<%5vy`}8U~kGRXgPlX98q=^+$-C>#t&KBJ+SLte_alIBZS`v(Lz)~Xg z1$z{p)>3~y2pgNzKBK4$f9Q3)n>G?`&%$WBn#z257+!lwcorwVvb=P6y8~`bg>{-- z##dQ1G07PQHqQ`^$qAobx6C$Ka-&JMz#4l(3zUX7PNUv7B`#xHrW{2~#e>gu#_i=` zM~Pl=5`3!bEo4GqFPAGnpR;q1H5UiMod#2j8z0Y`tz=@Ryb8z#J8`Y!T2w}qvRqy+ zt?rCVgQ;M;v}27tsw4JK*tSD2{FWfAE`If+ If%jt%2*?2lL$O*c9-_B$;tUR}BI2@hK1 ztQ*se2F+UXdT0~Onh-5PU7gv+#Og;!nLK(aHl8&7%K($r$n&+Pzjm55LYYR=?~ejx z)jsu=DAHY_anZUY(=~|RiHp2B-_5q-;<%Cc%@sGYS69z;(_9QrF)`eYw*zY;Tj=q6 zogVh5j_nDa>8Rc&TP0h6;TqevFt{$nML+fpp2W|3G}0FQA?;(cU@l>oHOphCb=Swt z7Lpq6fla6QRq7m9$E7qyBfiJ3^)SSZ!)d<08t!D?kCk=63U(+3)P(G9BD^ pbt z{RCC4-g!VI?t*f#_Ml~$c2gbAu;t9_x?|ns+)>ayj+W9!q|M$L=O*z0U)#LCXrE{9 z#!9d~W%Typ&gj{@p>yKBneH?u10zi`Qnlu#wAb)uNu^A)Aq2i3Ut%Tcb{Py+#z;HR zyf_rBokFIpB)FYjXTUWY91?M&+HSO0PU}2PTAaQ-cU((ybR^_hZ|~W<+eXt=NtUgy zw>Iom G-8F7G20BG&)y5lK< b`;k5ChN`$GTI-A{pTtUc<@UQr8lOwcVBog>OYb >!i`vCMaEO zZW8C%n~cpF9bC}aKI*KF#<9~~I-QhdmyR `gtUr;Hy}3Foe8#gL_Wk43#pW6|4MPE; zN!_r*O+%H2;cmCK+y2bMb_|0rS(g?SRot~N!xP?b)AABanYGcMUs+#?lSn5?wB2iT z!)SW7E)Bln?_FVxYD!Efhh0qD{BFZ)64&lbF<$l8r#Z6Y&O}2wIDzHz#F;&eJ%ib; zlAe2AcG=#BBF_|>(M9ikz+LP}XN(q!*qwbCFVw?eL=PI>Bi0-R2W!3_ixHb>>hd~4 zG@QrhJfAT9IP};t(Oa?lP#>PeZD*?^^KNS#_mzRPbh0J3-3injWm5Xwb5;?O(ZZ8_ zAn37f7>cvQq~zSemKcR4f)Fd>FzGX`XfEJ~@zx&ES~J+;^s$d{J%Js@!!?sI)7}st zZ+8u9%&2s<-r3Vpl4w?!m{Z+$CzK7<%EZ(}8_hO)G7x8uIStz5wAG4+^qJ92Y=P=M z5mOEqCzZD2#^tnYu_t?u%v+oNA(IX_ljc-hPX@F;;_0)^jvLCn@2r;5AnAG3xkm_% zb0cyLZ%K4!+JLzRf!x|eEq|$R`m>1CqQ;6muVn+>7&3{Fj&ID)myB58qs0o1)${SP z3;D^s!E{K5K4S+vN$t+z;GMMTM(OdEDo*50Eb+a@TJ w% @xEsgIMkH)G)WfDVZmmm8(zI4o$6ehsdNte{oPqyfxE_ z*$|hkPGC0O_Qs#B_)Z5wm)qHZTx ~ mOS2Px9zdpR+s7ExYoz=Xr9ui zNVt0D{@C6PSF7ei=yjGm&z;h4IN1^|!cU283P%V{sOEz24Wh$Y9vTrwkt4l( A&$q?4cTstYVCr4OfOyzVR4G#-rx0j-`aJJ;>vKKqo`4;!jec51V6N1Et?TwhC zJfdFu&fGa<8*a~0%4VQkkiJK8*h$B@Id4c9hcV}gcb#@abY&sz@Su%lVyh+5$QtQK zWg4?qm@ejcvw<}i(UC%=#UL7Tt}*qwb|dtb_{Qd%hGKUc-O0&bUsn4~u B&3q!YBiX@%;Yy&r zXiu8-McHkUZEw4t(E>hg`QaWBvpSJ9xvmKOILkzI`nVo@899?ezrDdHqd7)nLnN}# z!hY6{W+$Q1G#jz+TsdyO-wDwyY)SlKt|Z7&Gq26TDm&QK*>v6WBx>m&hR!HZ{3v3& z*B-jGQJWE)OH<)b=!I#cTAM@=qvZ%LNsK2-b~xFheLxdDZJatQb(9c}X=jX-fY_!t zo^-89L5^c_+PEHO{9(d{bF$goG27#k;@U>owmGIbu@RGL4 #Uz839b*RPC* Mbp;HCDYW@MuTOqH0$h@QTAP-iTT37It${`k=FBB zPtA@;el9iWHeu?Chb3rdpzp4TKD+Lh3(G@Mn?PM;df51F{M-8d_!6{wpy^CI5U|v&z*LEy&aB*jS1iM7sHh-4JIr@?wk5F?k}t} z(w!qAJ)Ed3Wp@lQYZ$IWbk{&b#k)3+#v0qtdwRPY%_oPgX7EkZVS}T0-tQKBU&ed< zMIJO#pPpzzHrS05bzwB0*~=|&qPMsO~;<1o|CnD5T5y*ej`y`ivnX2
%n-M)S849DDJ&bChxIvFJ!?%YB-qsc55tZP`a)QHW2V|4h<`b-i+ zdwFq{jnr48Z6gVm#&lu{qwJBBjNP`@jq@fM%nsR|>m}9n*4c3yrRsc&sm>rf@a&O? z^ZQ`gA5TpYRip71MY_we6YAo%xj&&kw@-JpFyE7789ie|W;CP68xvEL;MDC1Qs~G> z{oD^n2bz|+qt0*_Yn&$MsIfg?N6i&`Y>C5(bD5BeMQ{0V+ft)b$RdI#cQHDgp68g_ z5682scj%mqeQbpD<+g`!Q)V|MMCCknM&~QHltRR!JIjH5YPfjo9Ja@p7H0I2aQ8=U zyogP8mb5nLL`YCx>332u?DvKvVX;_^=KLz{COk1fR;N%}ptB_xu*TFJb# 7Ol;v`j1foWeF0zYyeP!ipJ!v _A za#I>rn$l<``%ahUJLYD-E3Ip#Q3+woLbkNyiE{Z-UXx0rU1>T(l$&v`H1(;aK@Jf= zvwupv7*^WFN^2rY fOg!!j`{V>AN`=i-Bp7WZK z`Fl3XS?R!$GwQDfe|*TUul_Cv!M6`l&f$$s*SY OZA)ox2CmKO^mGY@8d`|74a_^(#vi4 z>R&LvDp`q6kp1TTY+U}5Yu|IHO4T>DziKhKU2-kuuUY_pX49?Of6b;Wd!=PI(oyht z%1iz#>AY*XeqYT5O8@VQswx_t6|U7JjC}Q0Y?zrOntPl4&m6rYyF9S k6~6QX{JVzb5U%&Wfbx514AEDf zIs>Y_^ad0w#h Z38(J;0p-sf0wbu <(&r0zV$q1*2KTJwd zicu_~GT_;qocr(o9hXE=#qSTl6~90Ic7c3)`mONRvL@etYsH`Y^Ph{s%Y#YJ%2kcg z9tNPg^3^J>Dq-Z)fZUs96$K~qL8*#xD*x|cVD62m41Vuh$M;?T@3Ml3h y<6Gm zy>P5*;bZ9|0lSqmUXoE?-t|&H?+rNRx;a!;5dv0FWH-knEKeUZJC9sH`2I0AWz`8( z b0B|}`S_k6 z`HO_gkG;r&t-~L;CXt8xtIoJlSfD{5-}8@8(2?Kq?_oBTIqsr+!_3Iv@aAc2Ue!M` z!ts~ydE$)9@%DKDWJ?u&%}o9fwNUlH!LGczb^r5sFq*{~3sN}Cd1`wvQ2zj5>L2r? z`1yyFP32>yYgXaMZGiG_gSX7Thpv~ue4afWHmtYRnO)OND-VC+EYA@Cc3Z`NM}8}w zQ*fU@uAgRsOn~M|jkD66KUH%NpZsl ^u z1ox55cXqYFOsANj(oZN`7iIK`XyqB=?E={Owsb!8H~Rb?BUStUKE7%d0T$(~=H1K& z{FBZ9K-QTg%BP(Da*BX0RSdH}-M$qa%Z4(K+|Bz}cIZbjeq=VBw>}iVKeK)J{mH@U z@ou*yCimRnIpm7+JRk5mD_W_T%a5mD*_7uT!;fdbs@u@t1{yLfw=IHsNx3Dft@3*| zj2{pG(AlUyZ-Os-b6eX#Kyw*~^@074WsTexclo;21mrw&+28}S3`w8 =e9(w z-mGmI+=6RygPZT#yxDA~eX_-Mil5`Mw8a&_2dyQ?(ZAL7=`+s=pAmu|?-LKk1kYwZ zpPc;jcS5xxEh*+D7X-iylKENz%My856wjm-dCo+Sf))1sfRa>*1!MCKaz%6HDERSk zHvD;i!93m7` W8P4gxYni^mYiA^e4%eYk?YAF3_yx{dmYIcx+@3DM2x^mh+YKr|Ec6cz*!&QIAa_ z(BAxPjpgUb>%meSZcN1|-4B{SpSx2$Bu~hegb$CY2;glTie+(IHsz0h`v5?nSU#s% z?YGAi-=D_jrTt>@zV3rRUkCb+KZ?~?(1p)J3-RrxPwzGEENC?l#i%_hTz z-TQyN7L*4 56iTA!HQBo(3X4%>O1fO?*MeqzP0jxvn5KZ^+sEN^~R5EWja6Z z=Noh5d7f$-u5VuRyz5p?D~-BWD&C`55f$0`#WDGXcJq0FH~Z5LKd`ERi@P=I-;SAo zLvQOFy)BYoX!WLb{4DnSjl$66Uv8@~t0n&}Uy8ZjHlOO>eyzEurZYNlAHW+6SwuaR zZ9S@EZ)>EYfOpxUT&{%#p3#j=5dFxRAMp2IWLXBRkzWYw*&%&rj2{N#>1jWE=4~rd z8QBIBL;n5wK{=s+J(Yj`x$hT?U#e1d=-a>T{_?=TA07{4SjjOz5F6(Hg3SH>%4@{E z;OnWYMAJG6^sZ$G1Dne~6(PZLi75Z)E=WLb{doQfB(!>7ylnC(tvDGi{q}Vg&sQ%s ztOb@I>*X8$&fIx`;>RyM`18)1ef{{d;*b21fNB3pe@jmMr*-pQ{3r<#_Dc=ldQvox z{!;U|JA9y;`h{DHqv98ar94ue&(rsbs#hui?(l^v{7DZ(Gqlu7KFz2jp3VHT+KT}! z;G+caE6v`lMi0j%dKZWaq3^Yo@`jhT?Cf^q+ppA2RS|4Tg8a*C x7^4I(JdvrNP&25Lu!5AUypEyZ+jV zQhup@#1}U`Z5NjO%Y7FVstm|RC>#64;C(0?B9Xtq@4~;j3&(%QYwjbyx(_4&^6M{L zV7kN4GLdztq2n4Oc4jVN>b5@u%dg;^{%*{~b#0-X7|Q){iPZ z_aE6WNCoJ@TphOGD-G5!+*+K|;7q@1yngZ4{HcGuoWcL_v;TNL_W!XSyv;>%_dj&w zGogac V{FC``s#YH= z0*65;k-Du4+Xd7J)c(xT`ZF>Vx{+)Y#=A$EqIzVFZSLNsTQl#p Z_kB55d^LK?&I>f6K