From 99427cd48cd41de7ccf2f603f8439f801210ca12 Mon Sep 17 00:00:00 2001 From: arielit3 Date: Fri, 16 Jan 2026 13:39:55 -0700 Subject: [PATCH] Subir todos los cambios recientes a la rama principal --- Zpracticante/Backups/respaldo_docker_v1.sql | Bin 0 -> 264 bytes Zpracticante/Documentacion Practicas.docx | Bin 0 -> 172771 bytes backend/alembic.ini | 36 + backend/alembic/env.py | 65 + backend/alembic/versions/add_clients_table.py | 28 + .../versions/add_system_id_to_tickets.py | 20 + .../versions/add_updated_at_to_category.py | 18 + backend/app/api/deps.py | 10 + backend/app/api/v1/endpoints/categories.py | 53 +- backend/app/api/v1/endpoints/clients.py | 61 + backend/app/api/v1/endpoints/tickets.py | 75 + backend/app/api/v1/router.py | 14 +- backend/app/core/config.py | 17 +- backend/app/core/database.py | 8 +- backend/app/middleware/tenant.py | 61 +- backend/app/models/category.py | 10 +- backend/app/models/client.py | 29 + backend/app/models/ticket.py | 6 +- backend/app/models/user.py | 1 + backend/app/schemas/client.py | 37 + backend/fix_password_script.py | 65 +- backend/requirements.txt | 3 +- backend/test_db_connection.py | 18 + backend/tests/test_clients.py | 71 + backend/tests/test_security.py | 27 + db/schema.sql | 68 + docker-compose.yml | 7 +- frontend-client/src/lib/stores/app.ts | 5 +- frontend-client/src/lib/stores/auth.ts | 1 + frontend-client/src/lib/stores/clientes.ts | 44 + .../src/routes/clients/+page.svelte | 53 + .../src/routes/clients/[id]/edit/+page.svelte | 83 + .../src/routes/clients/new/+page.svelte | 71 + frontend-internal/src/lib/stores/api.ts | 45 + login_payload.json | Bin 0 -> 172 bytes package-lock.json | 6 + proyecto_completo.txt | 14279 ++++++++++++++++ tests/test_tenant_middleware.py | 54 + 38 files changed, 15383 insertions(+), 66 deletions(-) create mode 100644 Zpracticante/Backups/respaldo_docker_v1.sql create mode 100644 Zpracticante/Documentacion Practicas.docx create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/versions/add_clients_table.py create mode 100644 backend/alembic/versions/add_system_id_to_tickets.py create mode 100644 backend/alembic/versions/add_updated_at_to_category.py create mode 100644 backend/app/api/v1/endpoints/clients.py create mode 100644 backend/app/api/v1/endpoints/tickets.py create mode 100644 backend/app/models/client.py create mode 100644 backend/app/schemas/client.py create mode 100644 backend/test_db_connection.py create mode 100644 backend/tests/test_clients.py create mode 100644 backend/tests/test_security.py create mode 100644 frontend-client/src/lib/stores/clientes.ts create mode 100644 frontend-client/src/routes/clients/+page.svelte create mode 100644 frontend-client/src/routes/clients/[id]/edit/+page.svelte create mode 100644 frontend-client/src/routes/clients/new/+page.svelte create mode 100644 frontend-internal/src/lib/stores/api.ts create mode 100644 login_payload.json create mode 100644 package-lock.json create mode 100644 proyecto_completo.txt create mode 100644 tests/test_tenant_middleware.py diff --git a/Zpracticante/Backups/respaldo_docker_v1.sql b/Zpracticante/Backups/respaldo_docker_v1.sql new file mode 100644 index 0000000000000000000000000000000000000000..8716152f76e511d401db17e0018b9a1c69b480cf GIT binary patch literal 264 zcmZ9Gy$-@K41~WKiFb&2fJWkHVPk=%0|<76+JGv4Qkt~l>48hcge)g#pMAd1yXC@> znw~o?OAL-q;gzYXgdPef6lrp0guJ+rXNUZn9~1OfT-A%3m|z(=Ny{pG=~!Ly+Kjr} zX)5sKrfwlruw}!Z9i{MsIWwlxFr1~)XwJ!LaNdWqrS0 zrR;Q+M9hbE%<2rUTda*~!{$Y59e>2d%*3ZC{E2=?n&JwX2bTBoJgGm#EP+%u`J^^b zrmWq4II^nr>T6J2(8U`w3Zwyrd{4bsg$vUV5!@T5( z%(&e`BPZ16w=}%=lGznfFPl##<%yCxPv_bAs_u$L3WM1yiA~|Ios7nDG`q~`<=aoe z87}+4II*O~llT5LH?Ry#GQb<@?{5>VZVM&PfiP32*eN4cS)kdim84rCMZnKoWSZ={8 z82=5H!qmns{5Mfl7k8fb6T{ioR$k2bbX^C-5&4~Ebmuyzc-F}fl7Di^PcJKc6=M$4 ziVPYFvhm!U4)OQDGxqxn3Z(e|5h{KHcH8ZLBl7?05cWTz8vHf0b!MRd-~7J<{XbZh z|F5N2Cl354P&mQcpzolC9;M9z%p!RPlld*o4M->*DOuF5b?ddCZ{GEF5WRCFiK)e< zq9_ZC~{UM#tZg-%RknR#D_f^LOxU7>G^Y2L#DXPh!C@u8J zIc$i8xA63te$-=X(U;>gSSwB@b6phepVP<_ECKdgBCTeFeFvOuJ)y_{?CSro4@&W0BHSb(AXOd+Amsmo^3UO~ zDTAqliL2dzZuY-z?8;{&_E5SN&=v5BD9paMg~6?sU3OJ#?Yzu8#~ckBnMA2XI-<0w z;ui|Pj`+HO=-YmV$hh}N@l6&>SRyOMrE0F8%RO05o1c7uq=Ve>s_!*s40c%Ow^f}j zYz$U#-C&=wQRmZF-=VP+G6;c5P)^{5_hk=bfaSb=c{6>DtyGK0uF z10{0jRd_*e?cRb>W|vDYhq#P}778LInpGuaYHM$1IQK`G2zPJ?J-AO0$&x4*fG)r>* z`nOYlom7ZbzBy^Tza3OYoC=>>ch0!Rw!{?C2xdWLPkQ>go1ikQHj;*%`2>6%*;_yG z+|~Nj@VeOq!K87nf{lL>UQ762L^ZsiHj8!P+d%S^zk6=f<@F!O$c)cWo1w+Flij7! z<^3KNlhEt;wg`Nc)^J_vUqtV?p!#p2^4Sg}1`Y^=08@g^I=7*JuP8cKgyd_{DaU%R zHLoHt-Ssm_fD7=?A1$N?5Wge)8?B3HSMEmV3q%h+QVQ*d?KC8Oj&ycUp-TFrzICi# zUZtwGg^gj7k}SqId6pZJ!ge(>c66VCm1~Cw7CD_eN;kJ-a?97f<-tzw^)QB zpCtv_qy0qscl8d=5ms65d6eP`Ml7sCh6wLEX6u6qpQ1m+?0#5d)gqT;`@d31rRaTU zG2hk+RJ)tn$dJGov;bdZ)L)T&RE||v2~T;LH{sg=f7x8(Q`$_!WS;W(;dubeKWNk^ z-JRIH8KP+kDZNKFyX&d7k^0Q@1k32�@tQSb5Y8Hs~Gm1Vbjb{v0r>gKqJ>3*DVt z2zVVlvoa*IDv134=hwMumfC3YJ^UP#UUbh+ly_Sl`!~Ooxv;vVJ?bJe5O>p%trvpO z&O7jNaV>X_{zozX$3`qKfAOC(G~S8UT_b@X9zn(-72zNRKdA5`ACqOBpRB@|KmF?F z3qEPCcMIZ{E*&9f$*9K%oSq!Tga5|PelyDP)^Q_p%kbO@=7SCVA&w{tR5+txoaHQw zeT41r(-6HO*m|jJ#R*8w`S|(=)*&;mOT?%;wo@TQQsgs+09i4l-spH`P;`c z=^TZSF5dJ~aA2OR;Dk^Hl3SjT!rDK!Q{QX7J`A%Bdx(!) z4V4hAaBWtP)qdEqPy)5B-GV;5wYVaJRsYk>&xJRXS(*m9csOv2ce&J$D+vZeRTOdk zfu#dNAQ5>6%`u2+&!%apXj|ef$0pQwXDLQg@LZ6wmOFiSeOFqB`l1w#JR zP+dUAuwGv4B&_3_D2NEQEZ*ROGwU--&pfDX5yMm&o-wgswT7Q;QR-U;K5TINN#C$i z>-rRciHQDN)#b6XCpI?{{d+XcI67R1~+!(em*{+@Ks~ zAukJ_?2m!|&%cm9ER1jkC_uFwQ3u08lYE}CsbmAAOl=1094#NL_(C9RuD za3lh|vb&PIIsu5#J_VUqJ{Qj_q>pEV-@nSPN4}x>WoRg5BUMWinV1R@)w0{oSGlA3 zOiyJR^luAiAt9g8(YIXizgq&#*guR+WPZLGA+L%#SVBb24hO$ES{=Kd%^$|9-$6Xr zRVM?k1f4#;sm~tKKPAUSpQP8C0e1tg=_TG9)$`tw@l{@Zxc0t?am{^P%kDF$+i9gw zUtDe2md-pKkE%y>R8*|Zob^T57Ji>a+V`JupbRC(SmOezrsK4ck&bL5CH@aE>hI8O zKcLkAFWbGjuk1fFtVcpqOrQ?WJ<#_!SJ8Eq zw|=YL{cqm+X9iFZx6`;V9&WEJ+M*5)P(kBvxoq!8zV0KKiRNN|dI#Ti7^r;u?mk`| zpVo^+{l%cQmB$}7yb{U-3u(Q$b&f0={W;>u#>hcz2iy!!iN0`fhrOwE zd)fq@s<$bBp3f%MyVP#hRLFJ^&D$lZX;T7bB{;pR_G!J0rVk%?b5Yhzkxx0Vu4i`+wz%PFo&?va=1 zRc8FC;T(44F*ifLaV>{@0$-pb5}d$;P!oLhuLUxGzpyX~dA``*Z_>JZ2SNofp>}m} zz3E}!-q|QN`bKu%9u+KsZ!;D>sQ!FCum}%y;eqlMT>riVgGHL71yE3qo91=bA(<%U zdL~1@VLlibQp3N-c;1MoIh1+49&YB<{p|kob^m>3bZfVm*x#+)AppX!`tc#}MLgWt zKeWTN8n|y{tEHvf1Sdy2CNY9B+y*QXaieRyMF;8DYI2+1{F|1-=u7~eZh4P%f9y$r1Mm~(eRo@ zE&|0o!(zdsfP^w~knVopM5AwBpcvC9Gwg$2^NjG>^7Rt8<_$X}c8b2Slni?aqdx|2 zU{)6(P_0zgh;Xr}n%MKz&#X%_-e%)wLUR8wAmes^#Ygq~_4(^<*jDQI^X=#33o?8_ z3qkltUd>}%)Rh#uV7fcx8Pw{fccI0%B|=LHea+G~ATSKJmo#+^6uUf4 zVC^|u*RnZFIe;4pq);)ez~Q-8tRgCTQLGQQ*!v++*~Ee1tqGu8S3S?2$*C%)<=b;N z-~YDRuP?*nSs=G@0ar?;n^4JOn>PrBIrMKCOC3G}#(%cKDd5Ye;g|BI=>`K-X^t?j z-+Qck#s$5tFX9Sw2LM_4@d0&|8y(u3#xiQ<%QyiSx{}a0f{of0@L+hq-o?YQuOtpr zYHitY^7Q4P`#r8F91hd*5v@1>tkjk5_pmqr!ERz>n2v<;t=8#@$+z_Q{QB}6&2sBOaR{f2R+TgLp95%!vdpc zg16Pr-ltpQo9>#7Urtr8aktW^u4i`x4h}#l;y;_Ky2^5VtU*Z2N&xE=xZ1%FeVP0h z-HKuU3K3c)?q-|}zm*B9w~r>yf+@DI5t6~wrl2u7JFeqp-iwYtv@s^B&lTD9_hBLgDa1m>*Kw}@$BgDSIkukrnV?(L8ToL^V(p|G_X`t`~rj+mO` zJ7^jRfkl)t{w}Z=`{K#P;?_h6S3HI4t$*CZ_@a>Kz<>dQd+p%k1M^onQ2d$5gIBd2 z8a^Z{!tObtNZI`u^I!`z)Ch5d({U@8F<8}m288$1zN+OhW2yn$sV_l{)L1)}Kz;t@ zkcD6_u=6z@ea2ciE5-QY9s?r4?wd~g7GNj}NR+T1+tEPM+ol!obmMj{O%4mIOb-Wo zLtqzR=p#*e6P?pYIY1DN3>uZCz#;?oqyb&~dZpK#9N-HM7~8vf&obuz93hXReA;iN zqAycGjrK5gwrj9As7QA9zFd93^PnDG`)e6x4lE(&7|dcM6sa!Wa8W>T(APN&Y}pb> zz@v^FW7H3Vjx2s{5$-&t^PnpY%#wH z{yG?ZMh?T7yFa(sgkY3^^w7Q8GJ{&b4{!lCy6E{n{XEk&n$YeltxsIfsajO-SiWop zv~;gg+sk@{Sk{|ep#_EUf^DEhuKv%2C%Bs+W`be_Y`3K~T3+}<_0@P1b_h&pkkFF! zFA8IL2^)X<@A=3Ls#qA~!$}SqI>-gMLb3FcC_ghEi-tPJAM2P>f1(3asG1XOQs1MM z_bhYIUor;;mi8=Pp~1vVoPAI!e-qaW_?qx|HuWtG@a}jDUZDe@%c{xO_OaoFUM&jF z6b!$B02kOZv&RCCZp<_YA{_JG32U5H*Eurv2e0VRjoP~g<|kqaZJQW8E=v@KhA#-5 z6&JYA>)#yS-0tJ9J3eKv0kVW3peT@+pSz1mj}0$pG=W6T_en2>{8 zf(T?nXLo@k8t!*>i^xtk7OzOKxXq@(-Y@&_$eXg}_G`euGe#RyWrwBu|4tXGH!s~F zqL-1tjsJ$DA=0ABK12+X+N{HeY7_fzZZG66zRHyUpk9Q z+e9!|V>(o~Xj8;1tQIcYn`)Qz_))_{mxEhDsUtWg1}iHF)fUx@v}r!g#tK5QTHrE}y8HjR|p>4Uo`(aN^wv|k`w5;g5t2l~6 zB<)B;9!J{ya-cxin$ESt;M(+4>f4nd0fFuMyIQJp7-RA^y^_tyQ^*j zAX9V_D?g>^Zn7Qf=PZlT7x1F4Nz8zjDP~$0cntuA#omzG7E-2ZcP{C-p9km%d;^tDwFr+FVgjGq9k^O1J#Ri@UG%a_*+TBo$q16sVju zZK+eCjq?AzP4F>FOc!r54-+%(uf%rg>F1RX(nPF`>##4Na%`Pg`f*HkEZYZc8AZ5p z&k4zy9oE-7v(_9n2Zq!|5i3*mvo{mXGF?|Ssbd$j$V8SOB|XSz|CPX7{Vl2GAFB9E zl0Cf^o1RKZQC`=P!KY_0PUpop0e0{|{o6TxGJx~}cnm=CML%is+D9V9pGb0(f z##!{H-W}55pfbxXs?xbCNbMa=#Bhoct zDwEK(z>Zt*6n*oCuwdgrYAw!cR@MbTSR38mQ}!h{1xbP4GM_q31mL6)*m`lASj~{0iKWazM(jAM6SnX+ zPs+UeiS5#cWXzhm4p<@NY_{|(s9R=SBSgGLI#0Dm%@INi)w|B)FS#}v4@N1~Q#1j`Mz@Ny;A`Noj8*!x>o< z|1%&Kg@HC@4hbB)DLRW_e48qwT%pcxyVr%nZKMz8rkp>gVy@Q7IM40WBs$$K1kXl+gaKP^}rT7n!Ale90I-dou-$&dqyT;TzaU9z5FiSiO@-2og06 z9vi?d=VLD>a2bpx_c@pJNWK~I#cQNWCuy8mM^cuYt_xioUn*-I3R6ihMijTo<|K+z z+~K)=g*;Capm`swK~WnjC!wD5J(T2Jq%a@~M8n?pPA`%im;Cua){v1j-BlQJZ;<{4sNzAMS+Pxt~CdG zn45x5W}#1(@GYx9YOKLX-F}B=o-(H04l1Z`c|U-W z%4S$EfXNo1_tn*x`ntK7A^7G1F{DY~oOQ<8k`qhOpfo5vUl$p_2IgWIW>EmNp!{HL z&}%AeIEv#gBl3vL0Mx-KzCam;@k0|(bmYgQEBgwE_<}{+qV+c~j{&nb*X+%p)|}C~ zkD8Wu3s5#s!^0dH&C@?0?MrzCip+U1^;lVC%YVAkxDSiFyNzJIx1d*gDah`p==iGW zD9ZlViw`C?-$`jy@(u1WJus+FUKopv!p;@+2tY)9`>kC4$Ff-%_8L5+(imup0e!vl`uVT7zlX0)bhT%RsCt@0_F; zW+-9~jW<-^qlb^I*3jJ}Z5aKl$0135L&1CVmDIA}W5B@YPJ`fU#p|ffop-eKQ4Z#v z=GN&4W&}m{fJdsXhiZuj{?--5MU=sy5s)brZP#AMdmd*vmINo2^qVcT)W9Q+tlE57|T4jPsfY`P|zXh79Z~H_%_6A$oSk2l;%j$U~>w& zhRl;cJ85v51XEJ33x!r9M8#5w7X{sVo3Rdi~r;3Fl{J!B8u>&_LADXmkO1CXnn&NTeUTfaz;oBsq@9bG%;$sewNElGpuWe80 z0Vq}aWoM2n4a=@d8L*!K;?s`I?faGW1-&k*N^5~_w+b^0ZXHd_B$0hz?oF@Du0t_Xn&Qm$$Uf2a)?f z)-5U#Zxq1+r~w-s$l76sAn;+EsUevNY*+dLufhO65UF`?ql%6g)6F_^07{#Cd#W;z zvSP?MjCOtMbr%Rr0W;~_-vYAO^Z?4zEl6Wq#YqQ{Ko2X(zLn~Mns~&Y3&6bwnp|TY zcZh5$G;rC1^H1cEW<+A+#2Lduo&Us0RhP=RWMIL)#6)SDJW&x=bV0erf+kpu5@k{o z8R-;~;ny2xR0@kG+{|EwJWIQGfN0-{q0t!!746l0ioi2&!qgq45_lYuHK)oD6@Cu~S zvyf&F%b@fiYn4&yos(bCH-u40+kS9Wbp7UytgCi}tJrNJ4_pg(Lpro7H&J5iL1^@7 z%?22Q25MTrnWwtegL;X?i=5jKYdkv+gqR9c7M)Bg7B2ECXrVGT#19l{^&^vtDZH`n zEMy0t5&xjfrZ=xLxxf=}&cVdJnYukrns#A?o$$m1-mUP{U z+_48pesgO_*|NC`#f?}AS4>LPR-$e5+IhQP3Z+Dq+LUIU`8^I=0(r?n`QpXW zjpX{izN~8`jLI5&gr0f#M0c&dC0G9gfAY19_#ARwmv|QlrkW?_CRHlLS$A*BB$;cD z?bnJnM|5bs)gr_vK1rKc{&FUs7umXeb*&hg4RaczoOy;st5U8 zY0fZ{CDTh<>?I%*S>ZBaJXf4z^u3bQk&%?dr4{VNGFVT76~G23-b}*)CF*)zshE(~ zBMfz|MTuRF_N_AYpA=yfCTdTEYeWM@=+HP@%QO#;4dCeqRS88iO zJ_S8XKwvgp+vASBIL8lImi9=4Rm(@VVupCaya=h{I~!E8hz1_qR*6`D>P!K*PLYu7 z=%!VV{5Xx%WJZlK!LAudmK+uhLU?L3?SW*6X<}zYy`8=vCMj6y%Dj3!)&BckD5bw! z7Bp|(`eGfS@UG3nU$azIQ1MLqHd+KjqRf9~WM17$$JY>Xs7?)NsY@;+ZzmuI>)36u zZHKb?6Y)^9f1J#gtjX+cxGL%dH4{R4+62m_+!|_{wF0#)LM6_=c z4j^P~rbY|Z&Kob^p#WVwtN$)kqhnXYM>#x)V;5E)))&RM%qDQDsA9>F+zMW-iA1zV z%5kCeEfX*C*ij>|8jmqPj|>~|KB#r3z+wmP{Bha5T$uGT=jsW`#7-_QtA8&jld6CSKoX|&`Y%G_?9+Bhil?8?nBW6|2=k@ zPnxLwG`hhLm?NsXX&X=pxXat)i>j_H6-cfAAS~9;i7(`WAu~vd2PvY27-4GPeol`s zbQBVG67yyvdM;XH{R<~i_|)`9*&Z8fbpHK>mkkE_RusK_-Ceom8l#Y*G2)$+Z0rjm ze{ud;iN81vDeyd?(1Yw7Np7mENT@C_slX&)$VE8B+l?J>(q!{GT{B5MGgjCS%JVd! z@DrMCkodTzNe^JZ4qGK>$*`KrE;J5P^wTgxhw|+O5lGp-ZTm z0N$E@<1$h?1F5~H)=o&K8Efqb>s-cOV@+|Znv zadBO;ITQNHwzPM&N@va%wkHCRQxor$W=QR*v{yQ?(g|wBroFgrDm=?k%wE4yx8dm%Q$pyn)X#eA{0?533W!OyPv?1&*-h#p&tUKtS^ zYGN^xen`|(fqiKjgUshJk(`pdTalgwK5^Lv)5&_7qMC!Tq}f!?n3AZ8lQ;~!oas4F zIS6uzz+>>mR>hA?ZX8$&7tFTGqc+f;QTwll#6*uZ<*VqtL77)ebK$2dv7Zw{T;7b= zM9YS>;Ln{U(5f9w0vTPN|61Hmr{apWx_ncceWu9OiN0agAxp~9*{m>8&SjkPR-iNYSU~5$98Vs4P`3L-BkqYau z`Ny10-Qk*@g#cLLzz8(9U(6Fy{TX<@JFWhL1UR#ez+BA$|E3q|&O#G?Of3bFA2;+f zUda#t2KC`QNNsj=S{INzSZNSzl7K1SO)*BSsU&Ln@i;6Py@#xe_S4O=rV*@08Dccd zf!F-w;z=mxixdCN#OEk4?6vUF&nSBERwcLkkLiz;GVEv|^j8q$g$-uZ8~vy!x(grE zY8&>6R6{P>C4q2*BX+4%-o)0D|q)$;}v4lPng8|J)(ML3G zz7TbV@aJI7KJwweqg7I0ispnZn>{c!?%S7Y1>-iK^{-f)V@J=4!K0ZZo3a=3`>;pF zOVlfnTZ@0u#r1_Gx2Ein=Ecr1PRZ{#)VH9s*cSE3x_U3R#F@M3B|IWX&*GOSB?Qfy zu&|?c&ug^FH-mw+O$bErUPPPQ(rIXLVN(!@5#?A9GD)F3P@bIYOK>Z8XcqdBQ(>^g z8PXj>)cybQ;%BGxH!)-cP!Me=HdEPDSrODd9k@60^2$48GR6*IqZWS??EpL0ojGkt z6;`Kvq&&Tl7%z zkcFhwWdwA!WdvkdZr6f<1?O)S>oRyvF~s+8Rb39UrtmlamltnVu}!1Gvb%F5QSi7M z$iZ?NCS%BBRN)(5XyOT@Z8<%9tXTcx(!V)7K5BpUA@C)j0+y>>2s=tHq6SP^Z9RBxnKWXr|92Gnx>I+u2- z1NhvHXa(SxtflioyW=>$r7OJ+e@;x51`7E62TXy8PxVMNdDim>tAmFwg7#o`0EB+q zIi@F+vFsqYHc61=IRvvGX2MI?1?cuk4LbhK4*30ALY#RUh!nqZV5d3r2i+ z1$94@y&uFq|9wR94K2`!DihGQxTm5E%V!aou!uoh^2K-DNcMO`Ip3PVqBsr}@bzWN zKdW&pNjR|&6m3FWD@^<_>6i4q=6`R^2S?oS^;S*Z<45G#j+F=7l3KUB%+Up3NEz6fK78QSc z46LdPpvE1D=pz9|DrJfs-peCa#3vfqigppP=$i-DF!koedm)7@k>+g znQZwfLIvtuL{ngYHdXmWVh#$ylZ6UYk0czFty8{q8hZk?x!cDJn8-$(1| zeuYlWEK8wC^)#;d9~hv@00bk-{Kg$~dim>B+YwRE-9U-nPR7sA|HfDnm>@*$doH#r z*N2Xo=tRp=o?oKOODfc5j57nWsXyHM+sdU|W>#1>0Wv?kA8fx4VezK1i9kLE(e(X= zA?~vdX3<@3dANT`-R%)IbgyY9j@wAk$T;wnIv9(orPK?00$6k>8+^F36<~Y~UkXun zH1@{LSIfwGIAsu`m=JHuUG?2thp5T zv70VBw9_=(@Jj=OMcx!U8C~~h*<{=if3;-AUP}WQfydp#iixz*Dh52GMRNQDM7nWYnzsdhBHLmja zQXxD++F%CxqbnaKvJ$JN=ET*&{*gw>U1SB5oqJJ%gjPm^?jtiAIJKzEwZI*3@*v`f z75yoyOo?Cz&gx^T3@n_!Esj5P!}>eUGX`hGL|tt!ca36fu@L2@lIwoc3A`qmU+s+2 zZ9fnZAK-9UUNA6oIfq%hn{OsrW`WDL!Q2ty0hz^goT*nA*XYTN6u1~gYPwh)L>MpO zQOipY9Wr7jbKoySEFQ8w2Ng`dsD-tLoVW>meQ+S(5kQ$F@$jR(n%SPTP+^qJ51cqv z0Cg-P@tJD*k7i>y?TUN9QF-X4||_DN_VUh{$w#^sXzAL(xl__8OtH2}=$ zfa)Y9f5O~o9&PNGeo#*Cto7qgf?F`H%?WE}*nqq`dyG0_`PFu*6rF=lR&p+U8Aw$Is5A@ls6@{AEdG3abJmD>-@88&MOzv|N2CC#VFivx#z*v9cWL5KSJ711Y+>i^g79IF@={Ku_4nc;LS8%8Kg^K|jGgFd{ zBy%AbRk3wTC5e*0X76qaDZk`3HQX}_W=^~@omL@S4?F^e(`X#wovUr4|uM1RcrPgP0bppg`M zILZbYVFvoFsGcpHzZg-%&hiFTF$0T`8uyR_gQ3B`g=9l^vo6U-5J`G(^AP-Y1-=KM za`P|HRed_l{;Q!Ye~9v7gbag`!EU_dFYZ2JEBHAo2Yo5{6{*p3PVY6OLr4(CR?@72 zW}Bvyl=-Mu{s(pE#rU>t6<9?C`Koz56`v12anA}==&qdkh-sCffyJFvil#E2qX|6* ztcR1-3Auq$V9ezn zpgl9d^a1(x9I$_w)(%(2+c9C1zBik$S8gbAXMu)v*&YiwxD=vs&Kq}kSI^G>YOU@F zomPc4n$27Hxboe?aE7w#_WKO3A=Bbabgr+kDq(H2PM+Q^HX30#Wz{4l)#e9y+Tw0+ zrr^AR(M7$Fh=12RRlz|u_DFDA=6*J2sM@q%u8;>R}5wyC`kW6H% zd@&7*c2pawfGSwnEXE-azIB#>Ka1j4#cV}Fl+wjCs;`lcrO>)?9-F7-T!<5JkRo0Q z&eB3Fg~7_I0)ab{>P)xE0i9!jQB8TBuA@S>JIls57xl!rY4{@VEu7sPQ3(s(1T4*P z;Zn*110lyKqfIl~n4=ot(6-z3pg=qU!~F@%M|9bfnM9W<)r#6g==N@ZJyf5P_2b=v zVd~kNp+{~!=Qkwm20TTU-HMJq)rN`r{|mk=08hxbJqt>6*Jri@8$LbJI_KxEtb(pt zFeHba{8>@@4sWQuXu;S`m~Bu%=!q_m^T(XBHQrhpW8J)^Nn_m3CAh(>*CIK!x3(AE zUg-%$T*J%8h$v`>)B)j0oRI~)l60{^@;P!SjPI6>a>axQhA+;yiwQW@40J852AUbs zGT|dTP6tg+c+WbJ)U|PanyLx+7R_QSl6sRs0R`De*Kddh0_NguP#JMY5R-G9MZ@Z9?{-`Yd$X4cq0sU#u ztn{g@@C9Z=j~*QFQl<_)08f+<_@`y2O`SQK-!~KiGb#y1(~SW{xKNBN<2_w75Q4?wDQJOHm@+7gesmvF_&I2ID6oUG!Fpvv#=*yhnTq*(vic)z<*-;$1Mf z#OU*_1GpbS<6ls{^0btbmFe}H5}q#6;tlY}wioe7VXkouJh5VvC-ot59?TTl6K$el z0uYJXu8dZjY*U%5fc?!ibkpOBo z84L<`pSIMVk5(eMDs!5N>(~y{HrP=nqa>VddVo6+7tsL3^hn6EtBxD%k6NL&HPXcx zTTM_rNSBgt53|?e3STVLi^oiAMCHvrpJfOw(eP4vxNf*sEr-8k=2)}2<%qc-9LN2) z87TG)S%$5o*hl!{wd%MYutbo*4M_<;T4D2d(|O6uKrRU9(|)yT!-dlRTwm=ej31rB zJ8una9E@KWW~%|smmDyxfhMvs{cEbX>Kl(dfO??cxb{w>PyNlAuu-V60WgWUq8eB* z>ncD@%u{aHlZ43B<&GFN z0|))ba%J#oUunONqPSyl_t`zaBlt9iwn%;AMw%T`k8?C$i!|(B@KrlLPBoH&{)ZA} z2SQZe&WEmR_hKqp%c)ZUe_=?TONPW_hTFWYAPz&ieIhIiUFZTcH+xiBr=)DnqTda? zz(wJMP3uT=vAG8EXLg;(-5v;s? zJ}e+zK}{tO;tOj9Er|F-4)&D7;lDOmHbFJusK z6{YdFC>0toFUzt%MS8iG7ZIgDM3yiVSjiM(_dJ#`9)7H10)Jn3B1`wk0JmanAq=D! zA+iMw?B@paa(pKl6nUNx75@VR6%I3*uA+O94IjUGxf<8GRDL#bg6}&KHv>;z3_(o% znod-b09UEgc%sZdvdTQyUrrcA#$2|XyRarlBLR~_P|V^|AW(u9&rqB^impwdD~vpf z_xR#1Vd<#|UL|laLo4tiFX8fnUi97Xt%QW9cC5`5-7BId7I1^@kXxiL!zr0`oK}c9 z9)`EA-o7iWdb2 zt7$9xqo?tktekQ~Z5{7vyrLAxh>ByDKg`gRw)VJLH^?L>3O)bwqR;=9I7YH0ODIt( zgjQJDTgr8wUbQQ>k^0F-lj7SQDen?tt9tfMT4bOJ2mT=%)RylwoH?y3WBVvcCz0v~D?=f%A7JC47(0&)=8YV937jcAw=%_xZ^Qd0X zweUz#0Sznnjq!|7_xn+qYe(1aOV36k=(FyXay$IEDdm%iG5Y5NhIx^&z1bq|{ECFd z3r)J`7!x-tuNJ(iJ6v53Ga-(@tl|#7UB5P{yYn}nkV2Sn&``hr!oS|`A^W5c$c8;0 zFB*h@z>}W3A}bveM!}retAEgp6P|olQO*V68>~6Bp~i4ZMHMN5i~PchBZHA}N&H}p z63~QD-U6kOB6|(6cSSW}djpl8*v=6p>e~XT9~&}1?R+f(eaF5Fs(QBMzVp4_XL9s= z5(O(O&sk%gZu-}%24z|fP|Jtt$2$kt1|!{WPcB$ zq;dsvSrc9Z*dyYu$v!9urlxjpOxR*b-u`|Wz%vygpSeN`ysw*R{?aMjq+vhri`?{` zHjyX)9Nxoze^OsxiaTVFjl7?R0YCAx@p8_2fAgb6Q3YT?+mfHYYMl1|GP)T{#*%?~ z7HrZ2-Y^o%c^$x_xir zP1ywB9OVUn5SX0b?O31B&&Dm@_}sLa^O4719shfxN3QSJuM{kw_I=4UKhV@}rZv{P zIk$74eU{_?anAhhl^ZVCaU*}qwE(gJN z(ON3HbKlM2k&~SFc2XLJ!rzb3?Ah{1XU`8KWeo}&aYJ;2z)`_yd@7&+ltAO0@` zrfY9ox`0ZTp0_~p?_?TB)^X7LgGqK60FfiipcCWjmk;vodI!_@@2zTFPIaRMkH#-Q zgP)P++m*?im-8!U-$w$J!1yhJ`6vNsZ`|P<(Lq$v@~N>XuAdGRsy>WaFOY%hy_?s6 zucfcXzc_*nALBRQD)q02bw6x-Ji9mFb&$QkWxu!YGCy7&hCe<6t_omZqW%BD0*TS7 zp(29drmkUqnxR)IIKPYd)fo@FA1Ri1xoqJh8=t-~9ErC{$3+=;<{unN-r_}lZK4Ul zvrE5|tK_mNN7yQ!RwBZW@qYnMK(W6tQzg&z1uhJrp!8fDZce%_ES=jR2ZRkl#1H*q zValgWJ93Tgt}ND~l?kEH#-0Ly3ikUIZQ8y(GU=8g9gr#5bypTfpnBVt1_?Dk#kJ32 z;DVU&I9A&}cN_6M@tK9MFITu% z#Z6YN0AEcBbi@==%qg24jLB9piKQrRWk%spuQf8?yWOQGja}Sb4+~QU(4*j{z?IZZ zaP(uO;jY-O$9N%eBl# zFn<7#`_*pWw${a{7($9Kd$xjhJel~VWZBISA{sNL>mtOX3zs%GXK=1lduqY_W>zjq z*VaKwxrHfLOcm_d>kh%0LXPT!l8KE?v>D0otS#(chKq#;4x8jK=xu@;Z}{NT8jx=| z^TP5tf$?r6eo!BPPkmeV;7go&ddwf3KtjobGapzEc8J*1>tVB8dPgZ@3rMu5Fj&VD zzM~`lTbo)oldxDlBCsC-1{aVdFiT;+L!EqkvP5nnX*riS=XUlSj6cKsHzj6A|l> zy}hFRuuetgklYsM!kW9)7n2>6j!z^t*}>wpi^Ovdg0x8MEyHLa$PDZk6I8oui)H;x zZ29;D=oenHR6pOoIi#+#-dnjn->A8@+cW&7fXkTr2>AKH z`e{Jyest`8EZFuZYuy{G`&jeUy=0fl5K5n)7hx{+L74l=w#w!N<|XKgM!oxV=_jv= zzx&nlp?hG4U-oc*>d&2>c{!<%)xmswZfP;=dH@~Zxz3K36~G|9iIVx@~;4W3|`Cw-Vl8R zgMLTf6lnRB>7y^Tg+GD%^F7uR>OZ0W6Y4*q{(lxNX?Ux z>W_ejBYTwyUOp$|R!r?pa)N2lOiz}ldAEt7xs4%9r;UVb7sMR~aue#Vw-w5263bgw zRh9z32dd$3AnJ`&>)?KKIqA_Yv$xS^)C$@kf>uAQ@%+%a z7vX>jR-WDLic`6bmB?mibg~sZ0v4-*kP_e~HY~)6YuqA0v_FZxP~!H+$N;;x57DXM z_6QcEr}P@$_j|INj2+D@T&`suykAlks^`^u30UoLQP|yNAPp)bYXu(OA5AB^UAU^V z-3xoEi)~1hMQfaPwSAGL9d%W;5@c^JShA$QM$9-EAYZkZ<&ZHS;%^%uaQxI6VsbB; z8>lnnoIT;ftx+7{Fk!1piCm9V1b|06SB>nf-}FeSHU$-J;BzLIMm_ZR>;4h&+A^7D z;)evVlc~-!2pI?mLW|8&k;@4q>D{V}s*_j+tgP+P26HviQsPFp&faZr z97H(w2JsViY~#gUVnU>fj!_Fgv*StHYxpV0j36!Z-rdG*Gwf!MNM~X}FG%&Ra|)cG zR;R#1%wZSU>w*HDy{Oy}azibiqCtQ>PN4T zT>_o1SK~tayQmX(t%KZv4cMW*iBTOD1b`Pb$T2&k6}V#8LuYRoLA^8vQDxvPAZJfZ zudU6zGSy; zty}X|2%~`CK^&WX;|rv~)^_69j+=BK*94ymh0 z!8dbBd19XNB;cF5f_-A1u|5I#W`2h^&dtrS4c7wTm*PBQeFE^!oDa!2UtRCEJ{*Fw z9!M??{b$5wz&E>$-9_Nsy(}PbP7>U&{rlR#uTv4ror<7SgojcQ+zV3?wrJS8_Fx2& zL{yu`IW%hVQZNk^_~+q!@HKIqhcxH~`tXH02u|fy_0i6T2Pp_%kXa4DFes3y$cl28 zEgnxo@Z`U45(20-kc1eCpus71x?#!E4dWQ6PB`2fB#GDiCs*IT*Hg2Stjeq)i#hj` zgQneyVie!x`nVMVR~-fJt{1}`Q$RSbw>+G@vrmc#?|(B> zA2G0KqBs*2q$nm|$aFGzw;P}xdpw1L%0->0}hxYJ16ggn?+3L z?yqw{L>mlYqKHX-9Af5Ih8Yyc2!L#eb+P$BM%x#Iq{*L-Fgq%eR!BOKR&ZvRRuDn< z;OnQJTHuGjc~q~IctupQ$%Ddbldvv(-$&L7(L;ZW(+iIR^Cm%XH zLi7901>qsLX$4Anw5zro(ZNBwFI|5{myx!6__cXre!Tg@|7fUhm1d4X+~%(io4Rp& z&VIi*5#-Nd`ZoC%Yeld8v?^#FeUTmFSHavs1 zKcy`H>srWX5_JxgPUFLVsLI#7U$|`dbJ#h`1aJ2Prv_UH9q)zLogH7b*qg5*Z+}GJ ze*B6LcX0gS__$#xKfZ{q&pTW8r?cIYRxWxMk{l?$uJgAc*XB;bmVJ11dVKytu|6a_ z{+G*}Xy;}JyH`m_Ty6K)nW^qxYtik8ty}Gf`Yqi0zOTwo^g}pw^nCKv+4VjaejSP8 zkI?Wwr|pmYTkp3be>poivPAJbk!AMkG;l9auqA$U_PJ0o+k45U?7Ojtc2dvz*!djp z31`24AKE#_%eT+6VS2yZZ=ZzQ>*KGIaTM;b!f!d0Pi}rVy85lrJZ$||PY$oYfBW#w zJL671AH5Zl=J$)%=Ocsv5DVeiuT+fUA4lD;i}VXRRKD-Zk#Vr&w=GA->~|udj&k%t zu`kZ!>kraS`1VA0PR_ri=-0=skb8TqpIv&XawdB_rfjk98QVg_r@X*_$Gh}Cen+jH zj{nO%>!^FXAJfDVKY#t++`awvwRs#RUxkycL!8Cv@+xZ{3g*eDE#dfV_w1|q_L5D* z%bQMoD6~F&%rS^hSdEiE+ZQJv)9>~vX8D8cSK9pc?R)bx zx^5qGVe`iq?dtNa;@(<~tFPv6$_rfV3hLJPAIJM2Zr@(K-8mAjcUscNAE#~m(iXpU z(UA7kOUrAcw_aOoa`BCOxbH;V-uVst0jr%t%f=JvG+m-R4y8N^I!&$*pMX*>jZQ<< zZGHr#?iaZB;rC~K6qNEL=rs9~?N;N1u-||y#t0~7_^$y!Rsx+S|3V5;V+g|#H3Nx- zo*fS6Skq76QPZ9ntkeYCh5$zG=huGz%l-U;^g;z-wkOP@jLzLyeuD4*lk6bjY9L4q zyF0W+WONl^p- zp~8?Q#C<1{al;_5?T0?Fs~5!&QfmVX=x2!fKR1}n@x#C;0? z$bz6+oK%E&;zdOiEV+wOK9H1B$GZ9aJ73iZZh6Dt81lp;4TjbG;zb(8v@?BUnJRw_ABo^#a z?&F24nyU?@_ivfER*Ow*?P#U~adYf4F~vesBM);Mqju z_{MPTbF(4zcJdpb4?7T4eJtP#%#;f$72$sHa^Prs(EsS@f@D##95fSP;G|)iLuP^? zN}MX*U&<5Iyebfp8)Z8Tzn#`i*ec7KDv0`ao;R6=?)<%-^)5gvtqTy}{b|^g-oFnw zN3l7TX9d`Xfz6R6R*^Mber$6F_F;I3US}T~`Cl`c?ygkws3u2TdW=I4$Jjd}irk1X zx}={<-=BeTcnc`Q*^v7+VlSnMqVQL#NC%W0R?8;yreEx}xy9 zFo(Mvv%LuHdc%5jM+fdXeQyze`{ek`BCYWOzdS9)l~~UE4oZ><<+j3f*e=gxg2%a% z#D=2uUlxhc9`h#VdS|E+`@}g1-}5S22=~*C9+XHf%8|9fFaf?oA%tG&kwuQ*K)oj0`Tt&!$4FF2;eNd;_` zks-+OZp19i=Sjo>mfHLG;gV%ZQd!=z3rjW?-c|+Jh%z2SRXnG1OO_l_&^5c@Bq^d` z@O**FuV@}>K zGdC6*eYe~ua|&$rVgqr0qLJc|Si2K>s-wk*m370GtuhAE@`z1UsA%EDjr|TyPH!{E zn6fBwVyJ44;&T*sBZ)+dy8+;clLzYy%d~C68UDMRbtmE63N7KyW{IPSHB3PR;Hz4u zW=|m#fkVu6Vn1~K4hnFxU4mq?rrWH{gPl;5Y}O?6J!bvvY|q+Yjy}HGX7)Uy=%{52 z)dF|_3Vk;!BX*L=JCY?8y}+fW;o>NNNTn~P(jQ~;u7A;w&0YPNaO^?UZp64-vDH7f z$*Eq3Zv~aJRGBS61k}1`CTy7m+4&$TM=2kO;pfF95p#CDUrHA_#H=<{NYK|bojFZY zlxE2wRc&&~iOU^!{I2Q9<&LYhKP2ubW7|(`vgqlCn;{D zGP*{zSzFYtTGz~jp3jR=agwA9j$NjiswP=QBUB;@@kO3d$f*k|D8UI;K(ws{Wq@?!yXAEdCrBF0nLx;z zyt5|n^HL0ws!I;9)e<=ml1(Yapm37nNKTz%sBAjEPB1J4nU*=dAjzsAamo^RI}`vy z!*^#x0HKSbJDyku5SkinKU*kiEfB*#y{@N=?sv(OY0!=CKpD1YWlPh|B9>ZxHdaG% z;p+}eaa-yLkAvM70IM-{vE`j9Sur?4kypy9s|Z~m!TK9bad8Q{%YF9}r$1ek;`1o> z@f4yxoT$r&tk)F{mC;PYAs{oS_1zytgFiV3By}l6we5J_&@`K?kA-;<=$U1_mcw&e zDL}HCWEG9E$%ZMg+-h~|)MdPNlhH{ zNm`PP4OO-Em8yYSI5vjq#(os8AS08NmyZ7OoG98dyHa_JYLi!5{1`8y6U?ypGr57b z@o5zF8G_D>Qr%cH4{{Dib5C+8fP_InWaL4dDvI+0zdW*AM(+r$>Nt|NQtnG-2>RUc zqG>CRSj*r%NH(SLA}2YbY)Qr{(Xb9L*5SoEyf|?8WfX;Eae}Seb**n5UYu8WaU#?y zndN0iRO%X#d9b5=@3MA1fHDe(Q!LJ*L!)Yf&6;52hePzeINGNb5!WuoWdu-Ec|jG8 zy1{22^n5Ptn=V^|UdR6CL9!`1bb`qVoK0t&)plC#(A5szO9-{h4xK2fh9E3+qGyHr zvho`GFl#q(aXkI9w`MJriYYD7 zfESe9N!GZ)Zo14`S5LAqtwr~N=!&exNjm4$qFZ?uPP&t*x`3y4jug3mV1M0U_6~j? z9Utx-+b1UzV4VuOVsI8EQce5?l=V*RN3oyY-1Ee;Y3(DrKo2caIOX|1&;B8i zclK$6+1=aO-C)j+0lv5#a52lNtgJ8bl)%;T_j`!3jhmHV{DJV|vT3lh9K?AQ$oMP6 zOGiE;hFlYGOr4Tyu`uH>T+oj4=w_l z!a2SuMb1whg)RaaN4{j4%1n^p^bQ>GQ+A>1Q)|a@1R?Lsa94qlP-#4gogj0zt|>KAS_KXLM-Z^zqbzMjOFh!08}6a0 zl9qo96Q2ol_l)CGhcPEF$rO#1d1IwwCX2)|Ms(k$2|*Z23tV#S^t`j^Y(R5XgZvQ*rhZjUk2!?E^0TR zh81S6=jaPW8%&YWUIu75cRZ6D%4)%Vf%CuvRt3DDZFZ{-*V+)CewT>Z-(UlIp3c zm1N;4@VbucGO_T+;sz5!{Cf!rQot)r7!;;BVQz>MopiFhe}XqXiBgelS!Q{$o?%fL z3Uln@2l8+m`N7W=w>tqM>^X@5jyd~ug?B_1*r{gISyOX3gm**?_S6r8d~UWI#W9A) z8|)g%{NoYx-Isw|EswvX}rdot~Xq zhG%(4*I3qEVzlo?=GpE*m3xPBnfP~sBW#VxyID$_+&AeD`?`n@yyZhDTS=ND^ZZJo z%r6{YKNF{MkyxA9o{M| zBJ!JmwrZH`lHR+KH)*b{DjQ!YNFFWH0;>wF!^0szAkvb?8ip=IWbT}FGeP~A1$=7ptQ0k2jlPS?$wF+&_?_3NiMu?+?IgcmHr0I* zb??~<$?M7y*Q<#;j<}0N$DyTdjSd`XIxDh8NO3_mBt@e29|NxbkS|$#nWKJrEG>?p zmGcTi*eGFA=t_n{0ra@fxky6w-;n~hkSwFsxx~w?tZ{|fT~W6rNwT3YuT0)x_?7Bt?$JwDB+=ppNg7@< zZ%CY^N`T1IUot0h95?c?8h)kfOO|y*QH|ofUCx#zRyKz1_GMYJD3~J8^3L#*M^@83 zcgeF}@K}vzLi+!fx(A3!W7I$wD5j{+H0zu^BWpYRrM=H+YAu@ZsHqAK7GjI{!gCz@ zn0&#a?lLidEd)g|>j^%;`xj;LJ^jrF(~dkGP`8*|p-7J&@+c}>h|eR}j~;aVcK2l6 zZo~JZWP>pS7{q|dqtm>!<_!brE-?&??#fcmqyQWgqd&|Sq8?{&OpJht^yn>qb{WWUtrdzHI> z?^2fQ&0fG^)}Bx@=eD(se!>7KBD4Mva*yP$-1HwC%s-Hu`d1hpB^kLGdf0;}E(%?8 z-{i@ge=8v^q9Ag-QEVxbdBN5d9%2vEJ-r7vX^=Yq_y2s(?*7TN$kPAoNbNCZYfPem zm@Gt0;1kL+E{U3M9JK)5pL`fAY_6A4_H_UU2Ml3XXdvW*fUi_x+`h2=- z|G}zH{19pm3ubyQNo0G|7znHvJKn@!*4Lcn+@pcZy{)iVmgr* zgEx;n|H=tV4 zjgw;zTrzqe6q_?NQ7HB;+mfTP1M92)2|8_`vYbKlymXHs3AS!dLp~e%-x*lK$F^f1 z+Xtq70wH36FIxA#H_YJyagX-xQ=2jM6H~YB!ofCI%~WDpWz2ill4Mg73lkS*L$E}3 z03D>ZGMCHB>}H&ptGhpdKNB%)9puIMUCY0S)_T##JO)|HnyaEEIt?VU`Q3e~gP!<^ zw#63ev_w;~G))1Ku7!8;EzNym4Ep#MF?yCp?YqBH-$m5uY(y~_okT=&UXs$E3&pH2 ztkj;q9FJ~(H&5*S1m}ba8Pum&&y??*`kRb><)@J2huXWf21>FRt=$*lm63i?k%^s8 znK%cFm4;nKJrlV1`d#B z6t~IyC?AzMM|f7$Mb;=xRg!f}mt@LZH5)Cy73pMY7Dq1ywVA7~kD7jXzL=W+{U&pY zyEUAcLFX|(hxl(k%{9#MRRlrb5TZ{w&Z`(Z2;LGDYsx|&O4sQCQTi%Q(l~PoZ4ndS z{W&LUgIJctDTtUhzKCM@;K}EG=7u?l;NKh~vW!+xY))ZKsh|}^-V`iWA8vapl#_M$ znwM0w-_1Ly6^35PwL*UBRkXr9YRq1*+NQrz^Iyi}K$c%{5V>%SO(?QeS=$)%?A;UH z3L_{q#LBJUB6)X-Z+=H{cg0V}ar3T(Nq_d41wnfUmi_I;LBEOb=DqJ-|ID4^u z`)TdT%qf0&Fe7SrcYO9MJUiJt*qPBH&&kQU-sh=O9VNW&aF)S?@j4h>5p`X4Xcm9K zC>}oP=6L+kqpkwSUymfPwcTHPlA&f~hz+*r+-E`B#M&a3R0k7NIbeVVm`0X>g>~Q0 z7nF9VWQ*5$*#UcKFzf6ntRxls#cR%4D9$;Gz&0n`Lp*Q4>s+OTjk*m`n7R8CdcKP{ zh%KKpP{OPYmGFa;&d9{5-gs!))7p=`&j%AHpy@-sf7E@j9za9Wn@xsxrUboTVs`cp zHkd2Kcz%ps;`0;sg>Pflz~3LAX4?H<*^} zN90cgMJ@1b;Md5vHvvhej2MWVA#=J@$W5`lAnBYkoLHXFe^@N|2v$>d$>xgi5v-;N zHai@hexxmD`CX7SJYZo>~~58hu3U5x@DIwp4+e&2%D2h4+uK2Y76g=A`B)LPt@O zp2sgtW>t(@OpLGm1kre<3sKG70bx)lgZKvxt9L`648ZGc64k+UGVDR@G`ynYCkNlt zMC3}+MrG6jC$gF$7yH`;j(1d5>xT?$4Nxr&@Z9`ezYXieK4lKh_V-_mWL~v}zqgZg zQY6zwBqd}{pzJ*u?=a097n_5eh(ymMqw`G3kM(8-9gqy6z?VgQGOv z0o3&eUvXnXEvF1?xq{*uv2}yH@x_vHX3hQq+7=buNrr=Rw_Z|t2+7$vEm_~^8S z+vE$|ei+4e_u90SbpGj5@HRZBIFhE98K1BKq<=L^jE~CG*GFsZez*P_pd2GFTBF`{ z3dKVgeTE$HrV}67^I2T@%nsZ~?jMZjyfUH`Cs{BvP%Z;rv&x+GBsNL@wrwc?B8-v1 z_O~gdIJ6~udD{rp?2J!1Ch*Uxb5eppied>WClmtxvcc<`&i3J=m*e;wTl6=9{6eoj z*HMh*h-kSyr!~62p2a|7$s{=t+r+Ep~t8t9ui2PhB z_UA=jRcTk_0Q+;INUo_<4L;7n86CZ3Q7|1_RSPPa(>YZZclw5_EEOBJbtO%*vp}KY55O{95ZhdK}Ea^6D+6C|{g%em)GyCgXrX?$i zp>eWURA0QN=)5#smc2BLZ3~8B>xG7lms^xoW^A2RB}XVkXjxIxCEj@Skjg_VQ#IVG z6rmloPRgb_9*HCMsPH?tUmj_P9c|gzWJicc7yGv;x5e-0l@-mSk@OkZkl}?rCpehU znNlx}3%=~mraj;4HTG#@(oD6kuZ!0|d-j;dvWe44e()Kq{>Rk0a+-J7>CQ8oGONDc z297>7WRv4$sTjj!E!*S_;{^s_B^~=o;@S$Y$d^-Oj8s`pgfqVDBwi0z?(a_CS19zx!syT5^jfY5{z=P_y%9U132m>jU5(ki_2^R!@>)J)>jIPbr~wO#-o%39$$ zA$4+JAq{Qb9&Q4KOA*-QWku7b?$Az1;GxMotlD!=AM?ani4z5r4mZ=T%%CiojX>dCy+<^WBtx|BnYJ?NqsL zyExHx#$10vjO@lF3_s=sP)$=%9|W#ToB*>?q;pV*qc@7@bX8`hLh6c~o+>BMLZw$a zDqnKA6zrk7>}DCcpHV^fy{CR(2IG<+CT170!Saf%+>E|L3i1`?@WK>!jJz>wT;MJyIMI~u-Jgj0 z_y3^dCNjVoCf@*!Rl}OG_h**td$Re27cI-u9|U6!L6r?*7?`ovEDNMr9)s8Gb8|C; z;i$l>8xW3x(nZ=y+C{Tl^H7_HS6aCRu(mN6d+`vRzewXeo6uuSTpl6?0N2-_w+L0W zfOWSPMobIc417~SgegN1-eKCA+lulSP6C4A3=L7yI8Bucv1(RR z9m$+-Ic0T>1KlB30RN2m)q;e98uYCtT*3i4q*5^2CD$|0Pb12^>vq7bea`&5e}>Er zW5NJYFIt;-EXPlNA~*G~ibb8(OV-bLV;WHuU=&twTY7dJu^;e82`xoD3|;5!0uo2% z0uE2^%&P(OKZ8mo+QK2Plw$8Ens#l$6mD)T5M3McA8o=ve%hi>nOjJD^jI#8ey_hN z!*h~hYMLcMlxBeEB-vmMTYkkn*6OfV*725;y~=Qxq{=GK75oTZ6f{NQhG%Ck7NbaP2zw?kgjk{bHxiwu9K)a^%BflaGpa63kUc?hu@+|&6`c;ghKg5`4F~J z6^DW&DXhhonZ-L#|7iZPA6C;$*`Y0Tiyix^&5)-xSkES3SVa~_zZX=Ct-sl3_KTIK zZ9hg(iwd<@e#FE$h%T5LTCdpd(C~E=yDgYwLF^t1761W=(xGgLlQqi{D;w9Xj1m+L zO_P*j9i${Es^v%!W1F4tS?jy!$=CRFm3@!4k$|8zapFT`1yrAe#8*RrX5dC)lcu7= zzR-1Ri0S2k$&w7kL`K(%xVllhlci8|4E4hl;h;My77zsCY8wS$w&eB3@B>+r!wbTT z06qu`*@uFe#Wn92Sr@;_^Mg6jVth{cBZjd+_#7AmR4{1Va)Ws}%Qn~tMbx#HeXvoI z!`ZB?6fhhFohV;b8-}TT;ht1x$%@6Zx>O8m%BrmMk~7uLfC`4%p^^bbwqds5QZ8AsS%;J1e&?a|C}I!|y*F?(iZRq&(i}nl6!2KP zZ-L=?uu;tv$M^4|&mO)h@I&%Vyhz^zPmZ&IT!2one7B-3h0FVW%Zz_i$DTrRggL;! znd7L9LipNVliSD_yW|`33J|h*nf`Jk|JzMcT;e$F-P%2c@9&jXI2CB#z1B@+^zRNQ z?>>E_uMzqN{y9oB=*5C(^n+m(j;ov)N$Gl*^2I+kp&e>Th!qP)>>9Na59!>Fb>cUE zz%has{-83sG~Uou;_4TNB`>*j&X2uDFw~L@)N8DGY}ks(MLxj>{tt4;>;k*Q1*5Y( zBbepe2Hdy=`h1&o0!AnH-By%D|3;j@bC}`#$Rx)_QdHB}<@fve2E*hZ#=;%YMKFeV z0XAo*OxHyc9tzl%0jJ<4-ZDAk8F;Bv(Y>YPM_kX^yZ=P3wre}+_2lU&CN~t*@#Qa7 zHD;8>K8|5=WjCqt5!j%?OXW7FjQcbt4So9kXzU={nxCkCUI&<(H)T|^tjLxm2}YGv zG9Bq&tK?cG`}yBjw)l?G?Wmfx3hu{jl}LXGUJe;VEIv9m^Yv5YuXKpb_t<*b5Z{&M zLg)z%`eRKhY9Gy)wGxWqR4cF|6hCdo9D)w4RoC-X*DE63E|2 zs%+>wogQ7sOPJa?U%_zf2|mwY>IpFcPO0&EguEEfpfqR&0x8oNY`8+KSSp^vN~$2* z*1Xb-bv&h3*>yannl@lDo>KcFbr@x)VU(9j$^TX zABOVk^h53J&7Sf4^Pm|!U|Rk~1ed5C$Asj~aX#rKFT4ww4n!w5hc+2p0=m^hej=wS zf+QEaAL`Iv?I*5GJmnO}E>u0;hxT^;3sgt;YA0#toLDNdr>KT5Xd<*}RYqgik-b`D z)8*BXy?GjmI_+Ig?*42rH-Jb1_9AV^fHF+zn0`>#JN;8_aW|D10^pj|p2@iV*z}G0>{gORH>jvZS&RYXLiT*m@zd0)AU2) zKY=M&8-P#haDD&OK4wmJW4~^_eO#;8?nH@PwC`Tx2kD8G<)o6HgrI1OX=-b=Cs7~e zwI@+~68$r$qDM}B$W)P8dKs#_KWKyLKmkT!i|T^9)vB?WE&GvAg&Dp=o6HfwOlP4V z6CZ2?P{1LvRjaab-dC;23Y>&`K0L)McQK5If>gVE_a}C(6)?Sn}{Il}BW@bVcQ*BD%WF zI)+L|fGqNod-@=M97acQ0u2d}7o*?AZz2yn+kObH65|mA;1qdcVtCYRS5=w!wcB+b0<_aOi2(7*on?I*E68|w=eLD zDCMZov`;mK@fG!D>ZK8-;Y1-M|+DhCOjKA27IyfY+5r?$> z|JnPJ-o}wN!M_slJ9O5JQ|7*S@N$}KclKmf`lYkb12h^BAu24hiejj^xYE_=A5+IV zz>V-sJHvL#uvDQ{d9l_*&vDT)!_zT<*@%%&&xzRi*og@ONMhGWM5QtDkY zo< zZN(B?OK8C^Elil{lWW;wM3i&%cGAF5kX6ZcRUQ27E8IqR%}{ooTh=lp2CEvn>YAHcv!*$| z<#D!qyX4R@Lq1US&XU*9blx_S0$UH{jqWXq>N&P;uZz*4ELn36S9bIbF}gLZw6WyM z@vzrE)Q8&Lhu=1RCo50&ssi_(c_%xv7ShEZNu0+ppCCcZ(7R@u=ac=nZ7m;~du3N~ zL_Xq_tv>r#GTukfY)5Cq@B>el1Uk0>&z%!W=1WXv8sFj%a4(R>|G`{|oKes!>mih& z|IL^l34if*IeBuee|xAsV^|Zvt%~mVRG&HxO|JJtVj_%OiVFfh0E}jIQYK8PzeY(s zo5y&B92m${!<+G{3CX7ktu*{-@qGrPXwnFVjW%kk<~WiS7)_e04TPw7$ZF40Jj(zL zQZTnpkb^wxFN<<9GgZ@d8afV+Ji<25wf%Q{e;H5CX@GcN%MVYbNZ>^UahKT zy``7&0J&GCE|udr0_C_GxE|qbPKq9D!b1eqH7biZiGS}gSb8mQc7TN^g3NGAe&*w9 za+K2~j^d((BCCJ?^5tyJE=iZ;G3PXe8hZ9V!B?b-L89xfENM=YvZ_HFB$6t5vaUkz zixBCrO~FB4WMyT4pJg$#rsp_mfV+}x>wchw0%l#|Tae;0nqQwHr;d}(m*C`upK;1~ z80ON3=>vGqSQ?Jv#rHc6eTDF85s1g&8M}z5+#MEACJFfsLt^6_@6+e=!1T0?Ok>6;fpl_|l?Bd64S1yCo3`#YDw`HO^0~o==kdvvd~%9B_Ioi&s{!R4 zcJ<(C#ySwqrZ{Chal($yX@+Kt?~{@#bdA8Py0Yhckj5y$+X?QO(Xt@fnh*rhcSF4e zg$edsinaU0NUJ42tzM*S1{aVcgULR$r<$r18o@!SpZ!5nl1}C{PM=uT)G;&#QBr%+ zCNyO@%j1w2aS9%`KC_fTLJJYdtY3V)h$9A@Hz3=|&tx`FXpgmMTo#u@0#c7bQRcms z!fe|~5siLmq~m&~A4)-!b`e6(zB(XhbV8D$30a@Z)Jh|S^-*rG@r3?myktuve~;1? z|MtO2pg!ulr|hGVer{M{=ovuYf^T#V1?%#>p5os+k03cS@n@A&hosEPpI~&2a-78M z4G-mQ@%uDEVa@YZ*KagiN1*VlBSA#7eG7^@gugdF$s2D!WU-PQ#WQB96xA>&p4JSx z8|=(i9;xA>o3#=*^oISxaA-8Y22XW${qmmhr78QJ3@7MKbsVm1g8rr=*`Cp;&--A| zzcVNjCgsl*fczz6!2OvvftM%CmTYX4mI-*h3cNm(h|DW;V?aB<%@=8s2BH~upxL^h zv=$(@-3s}zk@S-!WQsZa6@I>sfvR%B#t_#b3_?}VPk^W`)uxeREgPX{s)FBjhKSBq zz2i`pb;JA^&pQDP6R|57-A}xKE<9&{Xy*oh};UGy141yuAX7((7)_aqgWN4uu+M-Od z4PbsI`Me}~(WotfCTo&zdj}O~=srWNyz89ku1b(FnakrO-s9yrQ>U9XPpyJdGloIJlGNkRTs+|v%ljk->%x*>bIBp6s{2&T_nCohJ` zz$%q@U~MOtka}DBh6V+&0kog&x#+pC0VSiJ81ls~@b6$&mU)S@xY0CHN%1AiG7m^r zZQlo3mnAvygpaEY$_yUYR#GucJCuWiYMIuf7+4ndlhMBGZ%GJm_|@Z5?X~uMS8;w@ zhrQB+w24?#LQQsbPh@Dc0m%Q}Tl$&fDH-KlPwXiX-L^Zx6#?CL_HCpNYL;YriYT{g z19o*tew7c_JTM~(FZ$=LM|m*_&6g4UjecbjjLkhp34Afzqj{4qDW;_RLYe9$V@l$^ z1{HSa$#=I$T%!2uG=z-QUWRe9>}kWPe|5lX)?G*d|xQei+&6E<_TMxY&wc2 zy76_>Ixc9|XG^WtGD!lieDL8<6FSB)b#rrK#JYNdnSsg}e|_>M4Y84mBHONQhGO(f zD=>dlmmys?LRIvTk$RU=12)Vw}`*4j6b6bWa2$9UQ?q*c#L!t*$o1}CIV z3(x-MA1;exetL3}&7~c(I)R>bG7(} zc`+juR$0e@wc3Yh)p$PpbpE9Yuaq6%_v}MT9qu|rwGr|u!_$n(!QN8ZkJC8)x(}W0 z5DvCwcy=f%H;PLj!QI{6g1c*Qf(CaB?(XjH7CgASySoH;cV-wq&U@eIS?8SZuKN#M zR9WVp#gJ;>ePXy ztq(?PVN?3mKz{jqB*^?Srzt63I%{yg_*yWBWjqE#L4&CR6)O9IccuYC;!6TkTG1tvz z^wfCrR6j!A`8ofqZP@-sN~G?`WW-KageALINQ8{_&c`$GQbb86lktIp(|T+o+G+$U zVON||Y;HO=mTXG$<1Syy)qe>Bn|fZDL@hFP?v|uE z1H&=w+bmp=KBjGrWD1n)pwsNIexqpMO5B|Zi&?>xLFg`{CA`$)jpGz4Ki%UcgMiRO zViM^kz?`$H@-|>6&`8aEdXCqbM;ffWSHHe7T6_-1x?L!2e7XcHTubN^IwnK~1N1v= z=uzdeTk@KmWK0k$F~?zo-KUG)v^W-4jvZV11pUwwbKD3Dm+CU4t)oLAZUP&4>bY=G zr3WEQSTlkx$*gdS0~-cbjfrq-#xhFW>WF7C^V)T-?nc$56X-_3#*gx#gzTls2bvz9 z<-?@DIBP75-8H2wltmD6+BQ;Yv>Gn^5wXVJ1x(RQTgq#1l2U$)+wY!rw@*Bmpi|$b#&_x$~#-;F_&(v2;*Kjz`Q9IRbd78rT zn+?A=+Do~}Xo#8hR}2cp2a#2XMIWlrGnUQF14c9dwzbT# z3uzAGEuxSu!4Hb*_(-EzyvUU7mbr>3@XB|os}a_YXbmg6@PayCZNro}3Fg%}*Q#X` zYF6s_Cn)He4BhK~qkKuwrCT6(&kJK%k2$bb-<3+hdIm4O-eJ&k!O*zlN<2YSX%u0? zFQFxs&9GaUgJC%PZqDpoPm@HT{b>{LqCsWpj_&=obFll$_)RQ95nY<)4+0WZ_(=Hz zHAH9Edt-jR!?ln7VyJo#w1i2P!m7BDqc9sp8N{@*uhUq-Qfe)mJ-$9Vd3$SwZ=2^g z)Y5Z>h@l#Pq}=f!f}Zm}ODhYlo*g8O(McE$Di^RDSw16)i`adx`}8UMPt6pF5DJ8Q zRa91*cHEFk+k6wg?mC?w-0uK{(Yb@d&Ak4}`V4L4B7qcxu1p}d^ft(;3sgz&3%K#U zqr+)Pbc={gSiQr41k%ySkGkM*FZ<~GH?Ax^$U_(KjURs{Ve616Xm@c>O%*=B@%HTK zx1z``AQpChCE~G!0`otN6}$VHA-_bY0~I5921At3C71xw#>9NHTx1k^&f8VUBR@9= zOMbmBn$0kxT-f_l>Oc>5JaHLGYRc{TWmo%q?Y1t`sq;@2J###lk(NKBJE*W z1jOIvrJ%&k6e+l3Q?%q*HD%}dSD!zf51;147`m5!d(#1q0Pgox`2R~|6JYG#1YYz>8Qsss8o=e#*Is6%v$olJ$VeZ5EFns6ni z*^bFVkJw`19sYc^hcHXXkTh3;pM+ZB2kkl=`#P_ZvFD+Mcc41Eq?Pv1Im{g*|vVBr`GaLo7 ztU}g7xap(g^R|{E8V^W}QYOX%j|m;$wg|F}Vv*PL;6Ooq(d{1-dIbquhT8Xeoz3QP zmNAuSI@u}EXzT2UXt+C2^f3|_1RR46){EB46L+Yu^53V*xf~qvE^4^Kvpog12on!H zUbwcA%V+Wb; zMM+#)FzCyo@N_G1F0%6Bd|kSw<60P{T7M-Rh~a@}1a!A)+lsNrM@OS)yT z=zW%9BC?8%E$=GGBe=8uUE~)9eOwGB!!j=`g~^uu96b{rtN?_{PW!PQ6i$#u%DivC zBqqrT!Nu&P&19!`>vt$I3TCp-6=hylG- z41fTh^stu-& zQU>d!e!LS(MB(9aToSW!crK4hoU`_6@jACT<6h)x{t;4|DxC$=#a-CrXV z9jJ=k0X4+xQ-ntP`g|VhI85rT6L)kLcjWHG9j14~RBX*&ex=MY&&6I^#p)5yL!wNT z1HCF%tWs1Wh7$OSJ^<_%iWI9Fi$G2))A%5gJ&rL!o@=)cK=je*&~4%7Q|i zIRmL|(+wvJpn+%@m?gUikq^%D7O?mfI5%+u#N*K|dxkjzGYm>ZZcuLqiKwNe#@fj# zBCk};CkG-=D@3{8k}|#u8}m2N4?{eBD=U^NE1ssl{vyUO*~+ATTr9%mjafnj*Lfe` z;^e~LYk?vuLgyW!PMb7GA@DZ-Qc48vxn`jFSBb2_Qc7%&Zi|ya*hFuazC<2fVIH1d zEkU@^T672f``({ITPD+@6zcC@xdSQ@j4VW6(zgMeH*XawCV>3HrkJ||**X6mYYyHr z@QAUT>I(mPb+_`Ky$(?OMo5qj`gIkzNv7$zL?c~0V;n=HEqTM)r@@6vK#kI8(f0RG z^39eX9#mxc95!cRhE{**l1@&|{f{(*xaS|~)e!#cBDsQeB%5;MbtJwD`t>b?z4K^oGi{Cg$6CaZip3XT4p589b9nn|#${m%5a1UQuP{8Ll5Gl83MDOl5 z=bw~>nsI+)I9r;Q;y)?byU71RPSpTcf#oS+(>RxX{OoG4sk7kT<(9mQMZ>Sa3Q8Y` zAaON$+;S1lv$FPnnK~1V-`~%gsP?qMa^${y1C=f5QGm)&b(~&)DHZS^|!<^6M-mtWVZ2!nh zH+2g^dErr{_JvAA>_0j`ys0rk6b!6KEd5r0lao<}lTun1(E8cG)j6Jhc=0SU#gY8H zJr{={K1y!tI;jPxxBEI1>q@}DNgRMY6#6udozxg7$&$qhpUg3e^R;e_#x z*_hkh?WSen9E#)ZAlK{{jgh4zi4pu*_WV);9j78Hrol_G)gF7Kg(L> z6N|#dfGAXxBmX5N>>p57K-`Ip{j7sMVpQY6!-V8E=bsk(0@fSup7w+`tH>RT`Nq75g_EVaqy1%vCK2rGKP*nTp6Brp5G?u)x2`$mAts^hi$w@ram8)r0g*hSxsX zeTLb~0`g#xdAZmsCOV)cT=Irg`7z9g1kZbwiu+lRwVHm2bna63QPT~8}AkIC08mpc&{}lS9o_piRSWaRwKO zOO$Ae@i)`G+_La^ucbpwXOOmXtbOxtXa-`BNbetG2Vw{#hB2OQvYj=w0A z9-Z^M5t|i-VQr^wQ0;MW2f*4K2){d^E5<0G*cT*C4dMJCvUQ&`XMdfhKb%Fa+mE$kSwK$XTIA(|s zh#Mx9Fc#580=@DvVk?$Y8PB&@VZC^_D*VfX6^@&No+ioCzIQ6`bh_gtpuCWi=I&GI zP8QBeHE^vcg(=2M&R2Y`$O;axZ54yqWz*In*JXkt7fdXpzy3->9lBt+A;p!#p#L-J z(u^CPAm7OHfmnQ%XB2Mo*G^OTA)Vq(j@VuDuuYbE*R7NnuZ~)_#esrqk{sF5Al{&( z>L8qVKAQ_V>#ZAkOe~p>g4wRS2i+JjgN&>azHB_zMmk-VCVb4^ zjp`RC5cIg#VN=m5b-p0hRtm7IqjEYp01_~!YjsCwmQ(2~NcKp5TAGZ<6Xn*|n)Z9t z5eeHbRuZgM?;^T861#KJ&`qGs;*|XmSc_M8@#eMcV4@sz^q;WSwL9J3#Je*803GEV zny$_xs(S))kl@SPj8NE_s91Qv3B$1*9AGK$51R*2@!eb%$hiwOWBKJ<=#$KaZf4K2 zZgPRGrho3*VSx{hA&?X51J9Wmqap4;iC%+GT?p!__LHM-qWnZ4Sk8N$Hzbb85*5Ko z8Ot2`9gKKSdoRM_;tc)*5u2OW@O1gr=L-3%j)GUXZ=G8=lbob;178!4$9Bz#cOC8pqfKkJYd6z4{>hTabTNC%j? ziKJ$^vv*f>jKG=wR6Zztg#Xfc&`XT0!6C}{o$et%UIaSF2)Pm`eTdi`T!H`F1>1L4 zSx?20+C467=U^2Va83L#4!9Ht96S5BNwx}A)mOTFne*qf7hb$@czGY|lp||ud`G=r zBhC^}y}_tboyX&^{{#nqPifrrOcrXLvC5a1OYtASr$BB!y6REf5XZf5NDdx{`pX(z|_+;)tgN3&MHbiQPU#e@op zLH6-Te8;+3&Np1wk5#a&dXm@_xSrbvL&F00JI~kAfLk<~g=Dl%b&| zM(H_@_2`--m(QE8>D9c?#={&Ylk=P@9Sprcj7BkpFt)GdbHZkty+@=~v#Nfadn4>m z^YMpn1EMc7l+E;Y>Ymz)7X<}!eFp4@1Exp<^{XF|46n}%X&ih{(lDX3H**w52FF%q z1E}JTe#ij18L1B}h#rP5v=+38fk&hnJ>RXIKUMF9QnA05(uc@EjC|&fj11#1wxs7O z{LbaeTasu63h^!uDynpSCjtX|e}@E9l7)mq2ZI5F0|Nsi0fVMg$sqv;1M7kU1H%V{ z19!D^G-lFwG_n54WNc^TY-3{U#OP*Y&G^?D;sPje>O8QIZ~y=MuOq2fp`RI9>`DqK zy3gx?nn$c!iWRg#7ldS=C~t_e9^VOl`&9Y8QCd9QqxS2iChr}4N~ezZ8RIL|Q8GtB zu|ppzb8%IY1(YX~o-Lu){QQL0Ag@1)VyJDv1`#*lon3DB-8NGZ8)5@#~Z&DGT1*d8qd>r0KaJF6+$pJArO z?2B}XMNe>rdR_MPkglVPY z%>xtd#-0SYzdY*up%?#Au}}Z2BP3wMhY&_=sNi6K{bPKb{T~&xF)_CI!DM0c!_0(* z(cac9TuDLl^C#TDdVQ9b5>o~PgQ@@ngXo8Y_&B3xg+BN30sd22QUt7e3jgF|m@#QF zVO2N1^G+l${6+UGZ**d8W)>O+X=P=}xoU7o2PlbO6dvu%Un3~~6u2g~J$hcziFXM{ z5a5irIT!GhO;&~NEmboZK})g{*;TFs)|`LosYLBl_vw1rw4y)^`hWeCY`l?x96)ex z&;u2=eU!z<#>>Xr&Q{M0c;_PW7TV%J;+}rVDO`1b6}F}A(| zi{B4+ub5u`7;U%Rx90*90X1!>n>eW*+KxL9vHIEqp4P|5t7lssj~RVI{#yS3{5?M* zVKn9}aL_@hz@mVa-uQ+fC~+>cLg03dgQ$k7m3eT>TLT1qoMhAJczVQ`@1`{;PQ(8P zMIQvB(}M>>Kd%ka`SR?cG5Ox_Q8Z6<8g50O_tmWMUfHi-ZJe<&ww~VBU-1Ddw1|L@ zfi28k>tkJ>eCuTj^)=0P@1#CmgqUOaX72}AD-}_lxavNmO%d2cP(omTYs9Ybav)yW z>qP0!(J~TwLne>-EtykRO+PGCoheiO_s6ITeb2;fgJwOqD|FAJK8*7StLo>0VzX&G zob>aY9M6;C>ITBF^9q&4^9AJQ#yV#Qyen;MEhI%dQ9&ylPleJkDv0ykcUVBs&jZ!@ zS$CucC~#!3zcp$wh%Q0n6hWMvwUiqDM=Yew=$=vEBPQDy563oY%s;x_`0uB7J`<#G zcA(t@4aM%eyX{*2PS?dkji>kNZr``bd8%!%z4MFptrH%H`C;3R!v%SPv-lkMl+71+ z7b>IaECq$+B+qP9Z6c%X(;v;h5=QpteH68Lp#G%?enJxP&!FqhD?S5eh~O7VCPzq) zUy<5=z8;otMx!TFipkKXxlH_Xw`0B?uVZI+ows-{udW(CQ%@iYJCMs{p`hShd#ny9 z_bBJ}T%JhhBd&&4=e>G zn_~@)bgPbi>yV2UNs3{r#i~Hd@M>e@ziZj8^4%)5l$D_GyoEEO(u(9UuPm2Fk)-+u z7e6>^FC%0JRbmaA&D($g_NC|g?Dy47gPe}J=FxnwUG^VD->=8cu7E)K$5T(X*r~Gi zcl~!`phhtfL+9hwH7$Q5XE1?6F_Lju&Wf4itnYqd`1pX zB?BBCY>ZjDr#wW!Y#W~E25*|I9$3x&{)qwbKQt+apM~$5&5txYZz10&2gJUJBw9Wm zBjIEYhZ+^@bkgwcpa~ zncnx8YjWN1HUM{1T|Ot{%kPKD8ovB@oAdPokG&YF@0WG5RS$=ZdB%WQj!fQwFJWHH zHK2&iXNm)*%kG6!o|M~pkyRnWy4jtr=9t>|XSEH+>Ct&^o}_t3wJ^PWtn5MJp_uXD zq&i|PkEl(~+~4|Xg*a91cLk91K`pu^;5;_)svByPYzz062Z%#BY( zO6)1nA_iXW6I?TxXANkBNnh};x5igqu={nfM(|;0-0tlf(Cu?pwLH}Y0uXgR3MOy4 z-^|wwrhV&Ur1?#_+iC=?WNK3|E`^spN9&%}^O9p31-XwDFmV`22Hll}i!J1;| zNn{haJx*>0AyE8uzW}}ikE4k|=XhMIUbnA`y3Ti#%TpcY`;T*Ywr`6jL_PpWmz#n{ zV4Y}><`KhZz5S^R$ikvb zc7?KsNpqEj2^Ri4o_-t?OMeWQ2o-AkuNxzdbA>VhKio_Z{_$@br4l}fC-2S2`!|XE z1mK!}?#;$Z*nh+9{~0fVJZ=RdD+lyLTYgfqv7)5pB6L0^zJw^=Zkf-}OLuu>L+s5} zwQ`3`Z4PNYDRteajOd%UmbaP}rsRgCAnkV^e0J%GW0?PN-+&zXDslOl9;I7tf-Dr7 zDOau_mXiD;o=D*=SGWN-a77YrzA$*)3=$AZ8aho*O9g1vyrL1e+q;YRLp#c!0G^Xe zgRW8D*m9tL5zQpC&$95mKewe5Et|7EOsCwyI8Tka=sk-+Ecf3M%H9uXE!r9ky!uB(m4qsK3VeTUCl4RC^fc=QSw)Fk;$&s`^WXs z2qg@V*Hf1S&2FYX2cM9fV4X#?+g^;F;I(`6{2efVWLBrLF^i}7Xxpv%Oj>L(KHnKI zXLp)=LoP)i*&H+i=B0dFZ1t2FQgekiQm13C;(1g#$IA3XJ_8Bmm@UMI5C;kxd9Ggq zrQ2;00yY(Jsa_|zh+I5~qzf^TKmUhse^9{doiQaVLGZ-0iST3yqgDV%j?%9bXIwlKhn9*FU8xQ16G(2{_ zGG(z&t;}>s!cbHUaa=o8B}30~?hHZn_SO@znVV!Rb_yLQl< zyZ7z*Zu9kWnilue#PzJ3>?e!)B_l37(_3oVZA9P=*4=m~!F1f${ z@v$A9x@%`Qat_!hA=Qc(V?D%~e!0p1#xV%iQTY2?+V;)B3WHPBTPaS(<5^CM`@tj; zE;=+g78E^$(7~YyAq*g-UBg?ib1=!AFHSAD0_(#YysSvWIPn|WrmsZ6e^oR-d6kI&5ven(-}Ksz=+p(pBO5Js|NZG06)_hC&|(bK?2UZ5&WF$1Lf(JUQxJ zQs;5N?Zbo1A}GCm8SESEuwJ~b-C4>6^0_iK%50(TdCja)3qbNYLOXvozpWQh{4a%W zP}KRMj@YTs$g*s!=)!A>;mOdYS}4<5X>%}3)dL{={k)qcKO~T6Qn~4ZwCDHlG#!&VwMnlC`?Rbi{Q<94jM6k=$Wg~xmM0G zGp&bvZvHXQJ~>Z!@a5@K7|zUcT1!}KwLBN8p1XewjBVO*FXjAo%DWzdxF#>)b2Kr> zOXQu)wfuhRQ?x>8k&}T%YbqI%3{7pv_^UO|^J`{1hO3X6KBfCtX0iXKFGVsv2KqPX zKb7-nVxk=pkGrwDbYHFOcu!NW_XOIh&>m*D?Y%G>!A+Fgzdq(p0?$KSqK6go4s;3% zr8228h?Is3sEW{qUM7o^q$P`-xy;m@Sva!6rFPNM{lc-<*o$P&+#=DIgO<&yeJ_l zz1dFxSaF-G4JQ4scTIuHDmJp%Nc}z&zBS044wc8<;x@M+LxV(}iy?Sb7&pPYua(zf zSXa)I+tmx!zye-XRMIw+As2gekmc9RU1Mc9ii9Q4daPvM5jo8LbcA!|W3LaA%> zO5PvqAm(fQvV3WHG-sWx2*wM9;a{4$3oSrDG(3XDl@CU(iou@jEDX}KgtgiRTCS7f zN^`#TOLF3Lo4ArQI!IoR@5x1G1ScI9Y{ zq7fceAJ8K)f$z=NHpyfCbGq1>c0OBXV540;w0OLmrNK_O^Rg25mL3mGluee~6#P*1 z#q$`kmWk`Da`-WxM?-YwhN91zAJ06I;BE4Bo#d4Tw>kbLolM|Jvw*A}a5q__@+))c zTwU{JIzG1^CH9TRuUMod8u14(hQf!rDy|std3o&H{|!rsvEa<7Z@(*LNa>+IjfyRB zDsI<@e_)UF)ZiBE{oQ874Ss!u>v31da=O~@iYm{MpTrE7U|VnQg_Xyv7S?j{OLLa_ zM%F^ZXO~WTTARCyL~ittR5;OkKv1p|ACD6=5JX-$!ZTLyShRTC!Z0)bS^V`Zt!OCL zwL#6d)#hvZ&4Pf!u)GQ4tn2M;=*Z05m_Jz+_+r%kqAk$pDcbrg{*v_j7#UtNOlp&W z*boCRAB~qzbSj%gN`;C6lh5l~1)My*_YXARaJSW*8rwC7dg^)co%OS>C%! zEGGJa>(}gxv^g&b!Rn$=04LAt8 zY~9={Eu*uc@;poqNd{ic9MI`?ST%rYP_o$P_Fn}0>p&?Es-q69)r6D#e!nDi?bXR8 zukjnzIWcQSMy6f5hQ8qP)srOW%h_u}9T-}y)pu5wS{}Ix$I~rmJ8)JDGEZZ0+PP1T z!|7DTT7DluS4*u9mH5v}nIx1~*BVEsB;y=LxEM@(W(79h=995?aA*FTF(|&*si?Wx zRo;fdME|$KWC5Pjy&L&!&4|OB78&#kH-75xHRnw=>xL?aO!3w3Q5z(O3MY#!K9<>? z(SZG*?E2xcQhN~h>KTR3V~Y|W`I56q9@amSkuG7E&_NtMRyXoZO4(DYA zDyI;QZ4k+UexfMDB2bE1!DxF!`#8HR6ulx^VvfUwy;`BxZZdGvIJN1eLc8hL-u%wN zOcUlw{~D0kRW#c38`TOpQ5IpyydrU>e7ELdIgU5pwEs>seZLSWAZopq#93(2j&@;Lku^0{I^R{7cv)P$P8-F}Y)EYKwf>vvtGvvhrQSNIh=r<|?y} zaoX-qc;aV@u=1|_>U_q z41Y^-+aqvdSh%zut^!7&63q}`od25!eW1^6yq`B^npYNv(-aZJ$8|v~X!*Zi{|lgc z$RMsE9^l9a{;$I36xzq)U3aoj(El%8YQgv+;935m%pbQHclTGa3Pcqa{;y&G@4cbD_p*_4f5rqI5Z z9h3d|z!oM1Em);ZYPvzM8hY*&G=o(79yz~*xn3Hlrd>HIF>;dc3luejL2ZrRk{o8T zC_H7W+Qxe?XZKfx7m=UoH<;E&P(H?=5lXI!u|ut)&U^77F8uSysxh0_fvj1tfGv_(7q{3MD&?Mp3^2Z#n^J_zRW*|Z)?4YdE z=uB@H&mau`nqsLvf(h(_P2KGy!Hj3}<@-J*#+AfjYAwv7?6%yPZcDAZ zh1uI@AKxm)Wkv;G-KQ0B4i4L78Tc2E=qNPiE}IIQkdL)tLJB`itY00l+3g@QPpf}9 z{d-v!oe$jNVYc`!VIgVMv9O-o0`x0&i%hjG>{JoXoo<_7w(*>$@$5N2tBm%W#?h1Z zAbg#+9dlan>!qO*%5Z7>$S1YL`GMy$%8ZiECTU^XE`?gKNV`cEqrcgp{|1@R5nC=` z<4C>gd-1AI#*5icu(BzN65N4fbGxdWnW=|{Q7kr*uJjEKRXxIFY}JJMmL*sMWhyWP zX6KN&58M7*+d|H3$So;i8a?!L$cvRjw~RU^Gn<739LF(w)E(U|Ha)~dPzx<$v){8U zW-z-hi=&)YU_>NJdzUC{duLl%(ZPH+05?SXhr1`?u)LU{Pw{c5Rd%@v%81nG&3L zd6_L7ibljmkC{$7$vJvCUYq&;vg@Kg2>K3=!KnX+Ncp*APN9_$ z)C4{g%PgI}-fa|*9Y?`K-W-2NWu%wDv$^w}WGm&T@0s{LB=vXZ5yxpmxn<=FOem7a z&K|&2U2t?v&Ie>9j&H1SVK%L(bl zvK|UI%lF-$EYT-4OhrO4AM~ZbN5#H9Px0Em_bX1_`o0#vGiW{p_xSQS-&WlnZFi5z z{)Bc>e9N z`Mj#x=P90!>AjCAb<^jr)bxEzlBxSVA+j4FgfQuS)%jH2VR;xE13U|M-5Ld?8~v}@ zkD`7Ejh_3yo~+jE>LszX%BN{z#hD@EssG}xeF#T;1|65TB!CBVYhAT>MeOLU9g)f( zRq7=o@JNx+I(rW~Coz5|2+NTe@}tll2^GWtvcHQEb#1vMq4`(%Rr;YlgMk<(CUqt_^h6uTQ2x)@R zH%hM(Nv^N;ZI?TS zec-QX=gTTrSR{V_+Xep@E}iCQ!R?pRiWso7RB845%plU%`$h1R58#p_ zRfqNUK7SkZxQR%8vWR@N&F_4BZZzHf=sN7vO45tOk!QNFQsF=X`=DerI3YHmO4k@&QCU;DZ=@h5@M$Pb^FqmHM{Daku@aoUTd)%$tj3yyxN#L`Jo zGNV>!EuADnqO6Au=2iovfTC$OB@50$dM-*(|74CQUS!CWVW}jHQ(=cQ|Mom_V##4i)ph zA3VL`wpXfpcQs+Rq|y>5LmiG);hT5>&{!1?r87})i+?fc%`{dS>k*Xg`! zlIp#GTHy=0Y)|!mzC|0>&$DK62S3Pu(gUQPn8bEF0gk(UAE;`)&2FO^;C;%#ISPjs zf_V-Hq4LT9@q`8xG@v*dy?l`4p5`6LoQtMfBOfP?auO_<@0wWouTom8a5$ zB9Oe6o<0AAL4j5eF#9*rR=na~n*n$-<0C$7Qbv3E)e3=lMbSdV?Wna}vqXrMxXkoL z<}-_eC9UcT_ju@>)vpWp4$AaySTv>%$*}58r(a<9tg)TN9&8kd4aVdW!D^(-(JWS4 zKRjrw@Sk8JqV|UnJb{N%E2(UK(Cg&8-SN!x%8p=l$IIROOYG>r=iMaNN%w0cy-+#b z^JV)L=(cfLz-hGjeyROR@9U%WJxm7PeBDeyWeb%8J#g?PelGU6IM-xZyYo;19@^VA z4A*4dU%cppn^;W_HvAZNmEr?m+kpt~_x$7Z^Zr9wWE_Y=;w=U(6$$ooV#cURzjP~j zMl#Be6IdLFMSz<#*J0$G8{<{HAA57|Ar5mgmUhJ=iN_vNcSf9#LmshPoN9V4O3akOa?%IJfP!wxDh(| za+nu{K4*vwNCug#$z;~w&Wz57s%vIe26sfWa3POyKn&-2x`5=l;$NQDYNq)?fxa&% zzs&^Shg~-?o)!1kr3G>+*A=lnuexrA@SF&rBQ^Bj24gGiu{Wt_IL1SCS;+!9|!2fgv_gT2-Cq$y? zCWD$9#py@p#LE7Q$ha9p(o{XnqInl9FgpkAO2YANZQYxd? zfcH++3$1E*``2W!q@qXqjcRsgfvPVxsf13aqV;SRee072j~}bB4!oYWiCJNYO(!*Q zsK5sIYnwJXsF=$}vj-hko)go>5D1dc5!vJY`e9&D>=aUD#8M|TWv5BsSiagb*>_QF zP0J^e6IT&f!Sk5*VB;ZlA`9y$3XA?Bib!qomTVUabn|U*H%+Jq8?}!|VYce(e}Wsm zDF)4hJGLGDz>UBAW?B^8Vx~kSgpE}5jjD8RjsvmILQ?Z#&5NjxUh}(f?1xM!!?}S+2j~|xxeX}F95 z;)iUoTxhHHT9`bt!yJ^TXWH0~q)@VYYw5~7=+rLh4vzk~)A1wXY-7JRBqJEXXxcua1vl|O zD!Co%5pC{U@>Hb2v&WU6f*!#l;L|z$vOB&>@F7t>cSqAImNTd>H_N5^RO4||FalmL z2YSDfD7kC8?+~SP9B8rux_OX7KBMmuRztY08 zK7w<)w;0;Jq70x$<5vc2!YHu?UQu$^m5#XIg;sd`zjAMG8>96)zOEhCV_f73@0)pl zYo8bPM9RbteRNbNq)k^T$kOyP28C0}o>^H>vqmPemnPz09dmG3abKL&>RKy^JLnYZ z4MC!Xvd+?#wif*KPIeb-Em#LOdRn+W3fFNy zCp*>Y5K<|Tdo_eFDK+fMR{bX?`&OA2yIkTA2b*nqTIWlN6=6eKFf+u7^JxrQ&qU?z zuPgrRLHr69yvvuNpOv>v-GD|_2u4=XY4(W!O{dKZr}yU9*d3r$l; zfXcxuU7fj%l%WUh?CS<}z&soBc^5 z4ws@_QV1@&#jEx@q130Y=D7rA@a&JM;2gf=UAWX!EbvmE_-1h1QuS4x}&lk9u1kkLK|GK5I3WD&3+YR0cVJy!0KKam(qI zT`9;<@OtzSPNCi5t}&f=`&2-7t~m#4H-D+V8A;#N=$FP|lgcCLR>MmZ-`~=neXfa%de2wnD<1?`#S86=YQNFo&+iguiF z16UmVczjG6pWrNn8x5gSo8*4NsDB^Apng&=xa8niFz7I{X^#W~mysH+kTjQ+wNlHs16*9c>X7Uc-~>EPbvUH>%+d?-oE6%C<#{XAAKCJ}Xk0$#i_7sVIywSQ zGd}x*a@Ab7v|Jx1whJd%^vV)_a3mm^ETK;IV$GmR^tq$r>Y?1+x-)o*&5|3GKqZ+Z* z4}eC>g&sTB+Jb^?sd*aG3_J*Ii~F*(^4q4lo#R=}NqR~(?P(;zCxV%m?oXnvttu;{ z+@886|3H{}B?8R_oXD5(aBS=Ej8P5YQ| zoIuGRWsb2H;$*vR^t5x7WqKm284B>c{<|YFoR$r|Rx?vQt@y?MC3EL5@^4OHWwh2D z+dU~pd}@e)$w*VCQ!M%uPfR^vBY|!AfejrFALT)wy+AKgSuoo_)>%!aa7_>Q6nyPU zs{zbE3c1ArR5jgwKR^6P7P4th;*w$0(XdcN6n8lZ|6s(U+52>;>pP@zA2b&t@= z>kGPgf9n=ReokylXEW1vgr^9|AAbZVCcATB4J$@5KR365;z7xg!{hi!65G#csuh>& z-*R@Bm^Pn9VW@oLk7Hnq(gT+iqWP}H#RJd_FfuV1PP7NFMhj|?(r9;Os`gwJP1PSE zP5|m^>r6{N8fxJcw3ZW>%2pFYLx(5&zjHDsbt*$kgpGzW2){Hk?V|YY9z|Yc4mi2onP-U6tuWbFdQ-QC@t;O_2j!QI`02MF#O+?^mn zf)m``f&~p0B)Ic7$=rKq=6|p1Rh^;^M|$_!t>0STTK&ac8IcTwG{n!H3O`;YJ^#z5 zc#~=%Dc}8P7*qbs*tU!rpG!$c#-|_Z89mCJS`zRFyhrx+Z6b#y(fmVUCo7Yac#I!n znWET1TRt4J^ng_D!ByZ%4knW()uB)1n_tUfwjqNg#0P&8=iyPEDwgEe_Hjj#8l-)f zsYX!bG=6o*qmDi0(ax%F+?sn6XK1z`d?GzHQT*7OF1ngyd?)R@av=z481x*G#=fwH$;Ei9m?Ib4p z)`85k2>S?5=q-=B%xryhgrMuHlagU)Yufud>8pYh6x51&`LrBloDIwE5-7#=i2V72 zTE{cUye%iHq=J<6tH*re4&9@j=|gY<(lKE{ZS}AE(-SkSR!&YZ+B!JD?-e!=t8Cw` zD&$E+qn&MX5E+vnkDgh_DX&_daRjy*vgC9_mwfBnqVT1H3*EHK#^OX_9){-9Px+fD0z&8T`OwWu)TOfob_FpZ?Q}&aFf}PsQ%xE9e(nwOC)~2mjGf6Q zdPPly#Wz=k`!f1_`4ugJBeJWmog{=JgGi6U&K&aA3~mcB4C@Z4ka#gO$w4;lblEfu zrTVNj$@6x3ob}sZk3paIayJ?MQ3#hr$BQCx=yec`lPv^i6)BVtI!)kfq(y|l)vto& zsT$LB-UQX-RfAfSEiau!xSS~XDgSQlL$=Lv8FZhvL(I2_|u#e-8 z{;to}&2)4Q4~JS>0nZoipDhxO2a%uVUS^nyThC7PJ}NIpv3{{GB?9+%lkB(u+*NY> z{xBrK*J4d!@e#Quy4&l@E{58togz+k@|yHnV%?Rx&-84|XGl)sPTvZLXtjSS60bNT zq2$tux%otMA8zFt@w5z?HoziU;1d10#T1oyKhFojH_BT=&72C0oA4fwQq#Cu#!vBu zqOY`F4&z`q!D~xy2~#&l=fRvyp79`md^`Ln^Uo$L`}N%L<@UC*MsG&!34;Ry$IY$? zd2Bh;v6L;cSS8Phoq~6D@QyPQ=#e7mZ<}T={UmO4zMs}qk}Iiq@X@r`!eMJY4aHKM zvw&VfN~r-Xdl^UxAo7|DYQ;Hhc)C-ZwWh*GSV4~ zsO_zrsvq;(7+Lw}FI(roQ^ZN?C6}uVGM<$X_S#_3A-pOe4L~z+?o`c!+6DulLLfiP z=^^{&F5vVIns>kOO<>oJPnYMK`{J6*MSEQxYyFA;^F^-O2Jtt4;+H<^%nVK6O?bm} z4*J6jS_a|gi*=u_Y=55f;LR82o4qO>w;7nn{o-Qx&aaVijyYTpT}Lq;nV;@XqdvWq zMt(!~^Oa1|lFS3=rZ2d%>!LgS_RK&A_F2Nr>m{g`Of)2yeHEQWF4^6Rn8|!reTj4K zxf{FdIwn2v8ndzs@3c~!dW}$?Y?-32dl)ABnFHZ5sX$3*qw=1t;}cY3%$3@OjaATn zY}Yq5ziWDUJMT;8(=#OHg--<{}b0-y+#|2Dl0g!BIWK|v3rLv^91K5 zj&>>N{dLdpxMT;jw>TS8OEl`O9M*ZcrFR*IDA}&+q(zT;uW;0|4Pvk47pW05~9)|-mt`aJ(v*eg}*K-ahDY2RT&+b1i`k(a<=Ds_OIL~zHz=PN; zQo8Ax_3w1ve9}7D4JAIM?I=-vPtjYmwJmTt`VBtJ-+Pni(4-bWserI1r<$~m!rMYJ z^x@IWqAbk`B4y7sS_WHLO+@#bDn=E&y8j1YaJYA^6RKmH;gDHSHLaPx(;kxxXiXR1 zaND8e)dNt7@Q*6^vvd5ox^&i`#`T)Q!yen6R9)Glx}0R}Qybh0e$x!FuH7`eFmlw2 z--P~bt=+I}^0Lb(%D_G4=+T&phG$#80*-_o9rlfl$&Hd-Yen9b@O!Sk`UFP_~za1WHEHVXltbceGlfn>$(ZaXcA2Of{R)fxV)zORwlE@G!>q*MHgRuaU zMnjg;_cD3mhb$S%><%R|-+YZ!^hH}j+Gv%g3pytCB@4}|y*__os*QyGGHe7gvza%9 zCWgKMCp=X~wi%5^lingR`6%6lMe@zZp5K(TtL)Z?{TInDBSIMu4*idD4y@INiFI5d zSTlvFhHq8O%`;wOiB2+UyAXZMvtrQ6MJ3<^C>#lK3C+G_b~yS)GSM=Ycf( zeLFByTHq_*J2_|?Lt@ZL=pnzEka&Ht%O%%wDWTRP|AQdn=*RQ4t|E4p9e7zLwKRPX zohlPIgxh?%CARICrn}S5i_@;#e(V?9?L?oWQD6VW_%$60u6ulJ1H$xnIlQ1{_v@a% zjZrs>vI~&$dQ_T;>>51;0%D-{pAAG)wnHu&?jVPpQhd+OHpoJ%VhcE~)cgs||U;TVL_iKdEcj`bfM} zROu6m*;O0in{v)t6UnKGDHYj-By!6xXAe>9k})n$0Hs4@OPdMdk>t#I!(IbVYEew^ zC}VwpGprg#XFb zAuR(hJF>OQO0p<{$Xw(C)_xuM0}O9FBHseSoV4GVy#He!9}ET;s0#tD14z06j9Ga1 zSY=J;p4@1T;Qd$>bEmZX`pX9L^TypCt=9X^0@H5kojx)Bmka2y99>_}C%=GJyOS6$ zeZB|NG~I8JxHBuqEUb{X<4ml_(JdQEOC~GD13+H8;flr~Rxk zu}n+ai?+TmUo$w|g6NR1MMW{?_NDta2^1BOZ$2&GZr!G%DM-jx=B6yu~wwBkb*`Q)%!tF@($?G-oEB2C3>tKx@3IQ-;a z|GJVcnGCLUGlaLQ3MR)5NjLX{My*S%#0m-wQe@)*JZ^q*5xY;F1r}w92P}DfcbCmO zsd-k+_mzjB_J7L8Webr$Il}AMZ=|erLi9MWYg5Jz$6}1MpnC5ds)E1(#Dy{Pk z&QpK;{t&Bwoep16`xKa`-n@QF0FQ_CfaJLgQoU2CZwQ-lyJ^2mlWm+RL#hUy7=E!1 zjSR2#J6;{`Qa;K9kIM)d{~Q@lDJc}`(C(A9HtUCJ%Sd>5mXu#p*bu^bfv~>!x|Ann zfqUWQLBd$D7^RS20L4=H@;qgBujxK{_tv{V}>bsiwX{=+WSS!_cM9BS$AN zQbl|heeUsNQRwhnT!R;m#-4S;l+l;LLXO~E>%+7kvQ^b+Y_PI6gv~zkratiT4WH4i ztZK!^^bW)1z#D<=n4>A0wZj1;r+3eZE$nvuMiDJr^64E{r@Az%QrB6IS-t z6B|s`tmZ~%5XKIGv(k+R5?-)&>P>zx1Hek4Oad{{<#YHe%^^-VP*c*V%DfCW==7~o zmv8WzXYXe=W-bdl8WjF?$xgIxT%*oUZ$-> zCHc8QZFlCmL0Lr~siAeHzkgyXhRF9#G<#O@Ob@vGyfK9VkZP_#H7sH|Bwx2?zaLJD z8*ze-$Mtf8F6ZtJ4}K<8^x5OGHLQ=mTX+Kj7IZ?zY~MK2>baO$&R>dR=hp7D|0sg` z#Xzu(epPW;!2L!2-HC5R0_S6To~ZDS6oPl;K0#-45Bp!s7UULEf9ngNDGiuQXx*E& z%cuWM?nDAStyv`d0O)@zA{`0R57GHN!FKumyS^`1P#|*3TSbRl;- zuVk<=KR}AsaRJ*PVUk7QGh>aIv~+&Thy`V-_(_0rtC_gi{qLK*lrufO#?o$Ei%02KdvgIbewQ9Pyjn4?ma({Ved}Q2w3`S_&>#NW z{eT-fBm_27;QG8|XA3$5953`lpM%q5_H0tljdTYg8IM#UQu^xiH z13@KwZN>jS0N&@_h0?PB(PE7ff8^;0y=wg1Wl3fO41;91{tq)B4L(w6sWQgK_rP4K zgorV|p^9rxNcd0!WdcDIQPxk2*NZY=N7YD$QEG*7G#lecuqt~e9<%F)Xq<3IgmJLR zDMp2ZY%GfLkH)5u5&jLq(H{K;!J!RT>o>cT9#ztl@`crRQKUVam-Q^M&w=pEhIDwG8|fK2P-=OVAyGM^;d<5!MQ z%ux-ejNCZXf?W7P<_rDf@>9l!X0{iiZz_gyABIk*HN9iN(FNaN(Qi8)I^We$(5 zcnUiz0{KrWW%+iZr_4oAwp_z+v+baM1T+w~58WUh1qXh~`bLu&?6m|KbWvUfUI>6MydzkW$@?3vx`k2}2tj3qO8@ z!r$Q%>I6s7(e#mVx7Gyta)sTp7SuGQ{p2@2RCt3TH80P0=`X~!b${)O?gW4t2+|{b z6_f>=U|!&NodA$=P505p*FM|Kq<+tEi|Plk9XA8a=O1iVBwJu@+`n;YR(NCav%g`^zQgNDP|t{QKgVI^ zbURtVM<~Yeyc+3<;YDC~X2t$6wqZ~$95`dKJ#v$Q`$Pz28tAFY!~AXVkO;v}v65t84Ugq6 zQyR+flUjqRS}2HtLeYhh9QJ+w@lFLvbRl~GNY#c#xpl0yEP^tUL%9W-GNEj8@8Y{t zlJ^|IJ~g1Bs>g^>I41OjMo|W|@nuA)(-IgfLYi`5;$kdmX%+wYwAp&pz;;>ZSalXo zHyWkA-!MLTyqU52-Uy z>8tvV+^>6H2Bg zSVXR|%Lv#G*8I*?$%jvSjbw1}*qngG1zTZ)wwj3-ew*U~i_`J=v|}&uD_vo5vx2^E zi{Rlp?sy3RbJPzkbQHnFqlGw^>QjM^>$&#=DBRnY{adr<>l&Z(plDq=_^FaQc9yHp zs7X3nM{HDKGDkm#IW{G1Ku=a%*cLSlq%V0qPY4*g!SrnFL4HAN7}-W1cqwi?Kb&@5 zOz^2;%Y9J$svY>nv8a=ngtQj|O{jM#12SEcy8?2by^OhfMA!=rf*l^kIZ$Rw4EwL1 z?IfEg*f@H?Vg>!z8b1N7n{ETJ)&CfDujB3o_Dd6p((g>CGPi5_JM`blPJ=9sw!>pH zlmeya#E2I09AM;?jffU^qxYz)jN5$nt<=&!oqpUB3=Yrl+jF)r)gwm=`G95AA0^xq zTfg4wDb~0b+O6Q>_^{Tw$)maE(FmOJb%_Uoc&iim=*?bRWjcAA4&`9NKc~*a_60aW zWl4_2NRA{kK%~^83Q5x=GiT3^LlPVSq280R2sV|n;Yk0;tg>70gtLKV!9^Le1Z(VI zqI=lA5QjJP#KtoE;^)7}Z3>?uYC2f@|lA8AUV$VhDR%EL}>+SW%nJNIElOED?P<#lFN-hm*{3nZ`1yzdXb{dyc!?~m({-lea*?r zxiFtp;rTY91|WkrAkMJWH{hcWf`o}s(m#LE@HbOxy`RBquHo0#D}#A$EG8K4Mv&fM z(g8X?;yPXzomH&EWgMING11<$Qb|o{+BeD(_|cLiAu9*{5(71)VT)>Pj31odd10Q-Q$Qai@-ZDoW1uf~tIqdLNMpw!P7hwN**k4` zZ^R~ia!a$75WP7Hrt+@5bnb3(@`{*JT2-xyglC%~AI^ZNfZ$$Hg?nYm$B5mRTu=)T zAjfLs@j5i_2Lc21hu{0EIanZ5ptK}z7S!nM)0_5F$!rj$4w@xRn! z6LN_D#P@Vzfh?>7bRP%^niM%F+!w(`%O`7xZ}U+r*qV^PCsC5L*BecJ@X=-cZ5pdq zZ%4LSkIHHuUI|3sHn_;FjJ%2_Df_e~$p+Ws$)&N1kK=Av}kA z{d1l)*l-OGJ#sd`pH*noWbNAmB>fptl7`Z*de<5RRZM)zy{xLAWl-C(1#GNG(J*Rl znz$Y$$T>h(-{AB&2bFD%(!_iCH$zUm1-ye!*r@RVCckrZUTok{vluyU(d9;=LX=UQ zgg5ZoMuQMa6|Na(37nD*Y0~gt?Ckeh{N4>T&Fx-#I6w4AeV%O;;d>qbLv)~FSVk_q z3w9{^JHy76SsXP|n$LKYudnP+TAEGEA6Xw`uKJ;l2= zs2VV(4X@-TQxvr;>YGO zacJ{Q);LJDaNZJ3AD&N6W&l)?IR`zGs(E%u-B;&DlI-Rh6FsjnAaVvKqu@X>_;^U& z^6)9`#fqFSMCNtgCOdWO>lgpaWEdo9qDC?vLRIHp`xt9Q*V<4KeD5MTeJxRnIMOit#C-7-)g7jD!9aQpT`hJGoIXPvKykZeGk53R$u2>0RFZVW4C@0 zLD4`%Gs$Qj9{`35?eFL|w! zYDz7WvQ8rD310Psyoq>}yGo3iW6|zNj~Ft_kQ&&`*=YD3HSD}y{FM)5*O&qbYyXN? z4ioYmR8Tu53(wq|s)j~^frbX7SknElyvJ;&#@ZIg)2V^H*9?oQ3)=Cel41pQhiWh7 zyR-%b?*iFKQDCCSXlf4imELFNoolq~#LMUEDrcie^dMS6IqIg}E}@C(tm+#W=22zxDQ1R5SJi-}lU}NTfGgNnj)XT1U!y-*2`Q2Nc&?Zl zx?!!dBj4}26pih!dv9i9# z>F;15nke?*GnS9(1zdyFQE1e`W-cERPJjaxCOpgfyHe%5fKVAoLfefLhswl$ z%zwpZ(AhK{g)Oz>+f+cv9u64W*m&|<5wG-hJj);kD@La%|6)fED@EpCSX#P0|JV!{ zfvj+Nw%N<)dBRX6vpfBEBAbuPcA@Iho^C{9ZceHH1cfT2Md25)uY43SN8*wkQ388S zpv?x-Knx9YBkvz>PD>|ifX$|&gf!D-KeRJb6_}g!d*LlhoaVUkdOt9ZqGKt<*KoP| z+wX8$TN=lPw*1HxzFE?Qk@rsXRiUWsthjGDS_K^YE43(rP!OFEO;LJJO}ff~RJQwuX5+spF>P-mOh|ottB3Fl#Z>K)h6eb@Y<__IDVDZuXu~tAEtS zN%~LB{q5#IX)mK6eGk0ffTXRP9#0)q+S^uMedF;t7o#H*FN<|~)p31KZrRz_?J~3# zMSPT1{HQ&iB^`C`lHY}52jfm5fT;KhHd&pQ9-LUfCMzF5+kTFvb3B+8{~DL1+?2BK z*lGKV0~Gue#~Zi5ZpWVxE$A&7${nr_TJyS*H*k^_woH%L%yOMx}xNV6|+mS3;HFlRzT^Ln#q?dUKaVLC6;jRtJiIN#V z9pYWpEm($1r-yV$zcgTVp>QQsu=L8Is z^+&5&%S=@s@BXl?$@QfP8?tOHt}tf25#o6JD;dLsDz`9;j4(!(iom+gWf{m#6#<}Vc-4T({n5Dxz2oBaK+XZ zEv>%76p%oG)6QuXehgf^{$HUJVWO!2y-j8PGt6vn{Eo2@ZIg-e+)JJTArIb^vzj0<# z^2xq>La#6uh>uk!P*9cQw0ZF?*#%?7fjWC~1T*|&kQj}*!Ycth zK9u`4n3h*(AI(__(s9?8%Tb)FA182Q<=Kb;k*MKfNfBbP3lw6qJDUUP*CjULaS)B4 zEw?ia9&zMP8hmtQuF11>alk;Pm#-QZTX69^LFl>dV&1JF^zcVQz@nM z2iWu?qutPCq4Va-c*wdY5LWj@P0k}uirsXj6*!N`;oKUX3AkcgJU&yH&ydzm)vrv6rB zB7QLxB{Cwb$Rlk|_Iqa48~gVTZcS>19@*M(?vz%z+H8|o<@&PV_GBaSSu;(U<>RlC zS2AI@e?TljZ%KqTaLL*$x_ip1>zqgl)g!i9SE6o+Rx3WMVR5Qi!$|4NZ~}`*SjtD! z#DpK>0rlt-YY^TM2N4XJ6Ehny)oyF$%?|IGfJ^t%L^k|1^h`2_95Zdvrddg8uvcbq z4s0Ts`Y%ZNCvHWR&c!aH=a|BQ$U+IkE<=~K`lo0Wh}cJ6iYt3wfHQa(y48B^@9hh+J$+YB(Z1>u0j#KbV? zdegH4hU)zT!{lJPOv>7U&Wzo!a;@n|QH{TMwHg?^^>*)tDjh&hy9LYBA`F6+H%>Rj z#cTE(lfc%5=luhJ|M_c{*xll~2e1!l+yf&ZpmX43AZ7@le*p`x(eLj^iv-9=BNtnN zJj8$Coj?&qae->^XLGR<#h(EYll(`Gk0-DbIXcUNSyY(4$82(zHAtm0ynX*i_r;0| z>?>ud(6et&GnlCO(|^+sf3Fw@3aBR*iK(f6$KRe$icAw_-6K2!+{9RZI)AiGEWoMbu$|X( zH$SNqxtc@|^`9mIK%-%xgWdpb6{PuQg+Z+c0r_TDWOblg&t?b>UCY~ObYcQB`br2j z)};EUZxIIxLM=~}?POwoLL1qlXnhULsqjZ#Gml4+MTN?RS4?H z#FpKaaAQICq2zkkBB*ZdA=m~b4*@dkJIBP*FZ3akj6prjihuD!Me-!H@bMETn!M7m zN%by7@HhbnMT2Oh|6R`iGtkc?gcGsyQqfAR=Y`!`&0QVY!_KBN)szGjt#wT%p57|Y z1d4+wj(H5 z+q1-k%loM-xz#NaE=gy&uf`Mm$0F<`(bF&HdGe_aL*;^E^P3SMZkl9R!NArr5+xLUG`b+7&b z6JMv(JZ{)5K2HZ=GHaUbbs)|(f7lX_)UnKdrJjpZ(w1?J7WP0P|26Rf{}KSs2m3gf z|8~y|6wZ0IMA(Cex%%HV{xoJcE5HTDs^pXYyS6(#z#HD3fy)2#(0#oKFk{=QCzqFZ zcCNEZvj3fVprHX9g49(WjTmMDHX)&qRvm)B6hE@uU_6-cf;- z{BO5BC>l*zpgVeNz^EJ*crtgJ|1Vk-uwEbqHG4nYD-k^M_flV<$72AMmN6Ei{#${+ zH_1a{0NZ-uG@bY6-@lVW0w9i6m!@F-UBq z8FSf?Ov9bPkw9}gZ$^MeX`}3d!N~qiA3W-kT4fRhjc0OAW^hz;GYZ?C?_49i3knQ_ zh`h$y0l@cyeQSKK*0H<+8l;z0V^EXI9qb^J1`yAa)sKcz5cWwVfU>#8qzR;MIBAJ( z9}J8LEl+Ri`RPFbqYQg@N1RMSY%g5f8gxrCjeJyP$%flE3*eoXR`Sj)py6xJ7BJYh zAPCm$;gPY3qTztnF7%}!VeEFxQsd!I7?QG@a36@7;Gu7jPYCQCgw+nDb&^{sB#W9hjPU$1k{8Y&|YYW7?-!}Q%n zO;OH50>20h=U1xM-Ab=_nhp~a5A9EQ^W7cL zx&O$c<2rlN3AP?u#5=6^@NYWm<+klSpNJG!-=vt!7kf`#3h&=H)W6Wv)_r5O&&B+p! z>ATPKj`h=Iow67uunQ4DLRR|V!#9|ED$r0cHY-CxY3J5ZQ(f(#_A@)cDygfw+R=@j zwtPPs8p5QhuC=&%j(hsqjDmf$NMEsG6Pf#&x7WWU+i@=!Hz#njKbd=Rz6oIdAz=WTyD8I2FMBeOt zWuxzwUDvp|Ce)_U>AhBs9LlnrN=V3)CTYrW4ofS>ROeE7b!KF$r{wESvh+wp+@x)VK+p5T!NB)pLu~PMI z)y40$+^S$W+&l|s*Mi4DryZbT{KVr~Uu4#v`V`^{KKDOy(S5rv7mGU9^bfKPkd;dx z&7RES5j#9k=st4D@w6w4F(kOn)zq5IUo?5QhQr?a{>K}b{U5YJa}3=F<@(1rYJnpC zevM>afnj&7l3$^0jOLdHb}_8aZj}j$r#FocvI_>VRL#J6iV1Fm&@u&o?@VWL+4nPj z;7ysc!q*!@;rIDxU0IvA#c5JxbI4$h9(8|u*)t0%A5^Lt#DACKMAl_UiZ6`7C7hQ- zT_|8R8s&~I9W_|(xn_tOw~?!{G8CX#O66ClniIaw5N)vbUNzbh5Nfp5fcE>UGR;B4 z||l^l0k}(ni%n@Qvb80CyR6q z!FP4Ww~}`fdXvO2);->Gy@F~@0qotWjzBlExu?Lsi5>hImDW8+;YZ5n_^EW-uc^6F z{WQ|S;hlqn+kg)Wa=624+Q(|+fE%C9e95clQ4QJ0xdhx|R@oC2uP66HSQ^A`?3c-x zP3BVHubAxSA$PPuG-BoN_9;ur68n<m@h^b5Fhcj{*$c5M*e30>Ljo-)$oVEYx zG^qseN`o~Yf`U>w&SC8)wlqW{NxrBc%LkJ!(w!LiWY5^& zRzQC;;pnz3ReMf4DikcmmUJ;byylo3VF}LTomm>YY{2m;^CdR`nsD)ZFQz>EgbG*< zF*5ODX5b76|Hb{0^Hm3{RSF8#kJ{6IY{MP04Y-U#_*{#|Rh>ZA5%tK=@cP!!D)ZFJ zJYCkfJWU5{B8+<~vorl_5$B0$qwj-c{gF(g@#Qam4tvdJ2Bt!7>@29;7{FYQcGl;+2OVIo&Ieoa2f1k1rty=s=O=MyyS0MmToQB z&n~S}X5aA~(9dho87E()-(PA({9@5K-AZNh5hNU#5fGPyexxCQuth4?Fb$p{^O)9PbyR?!3y zf4MS?3F(xtReD zg&IVT5R=l8;Pr!ggGdpm-_zAny5P-nyT`uuF3y=!0_L1R=i|xd1X#nzqapO23mMI= z?$l#^%>@Xov`J^>8%IT*@kmCDQ_tre91WV~FIk#k{>3h1&^+xta)T(#*O<(RC zVK^`xCdPxY<1VkSKb2P(Gq5X?W6Z0}P=E7*i-!TyIrbF<9mj=!eWg1Q1>8_5D?MYZ zzDp{EdR8I9So& ze*O%b3#RS-9a7);-KRXgRv(fgpR~<=JlHAON9e(7p43CY>hXg`j6sNA%CV&a-)`B4 zJ*zlFc1*YS<#BsO8yH{~4ADPAmRFTxso#M$` z^g~@vL&kZ&SB`cDgLtKON%TONP)VC(ns#OK)I?!bK&r#e!It(r=9=FnU;Fg1g`c}F zF4Z%tU8h99p^#8>#GPmVITmO6j*W^RmjH101>I@5v>;w6A8u09o}#H;)^7vpNYu5I z*|2w46RIXO$0aUX^}qud;8ns4W!`$rcdNn2y9B9j_5|4(UlD+-W|NDtP3nC5!1Ngwa&mj52)ZVZt9m89?3^0kw8fC zpMo{6C!bWRjwXYbvMMw~k)B3Q@zR^n`Ic{Yz70sIPVVX#K=q60%@voI-vxu#)`5QW z=Wm-y=aNvi@gaB7QrS${|A85*Ftf=^VQMq9gJF8NqRpmcNzZ(j<8VW7u7dDs&e_wz zC2^aAjq&~o5rjvEMBztnI(-vnTn)-m;&;(PJ4&q*uS5f8|<1ELC8Q@~16WkSr=1#31b936W+=FA;sPKYoq z3nFrP0U1!;9c(lttiGuDMHY2l2pF6I=8+u7w252u1mwL|0A|f)?jf8F1_lRj&C_-b z-LOO#T`Wlp+F@VKx7P}DfNpJK#gt7auIr+n_mCq0{C zAfkU2)xZ5!L~%st_pU951QFsqR+tA<509Hh^rU~X`|I(5?%5BeJ)T3U3*ad7>3&}i z>dxw74=uNVSyzujHvgd+0juU`Ec4Gr?VnFrh4~@(!Os#~rTPikHr1ro?FDJF@IbHH znQczJ9sP8O0nbWga55$NpYA{UOBpLhOF5M;Cs=~e?kQLh@BRn6ebA6Oa$+wBD1=iW zRk6BI*oz5;8^AiMbUwq`f%AbzDV&6$Ee8s>!cH+-y*xjaC}pj+dCt;ba@+%cHA;x9 zhr(b1mxaWBdwkz%Co(gMupQhSxR95r6}mUK#a-gY+k7~wHGeJ$JjWbn||xZ!JFrG*Q)pOQ|V=Az_$2^THkW${$Vh{YrT@CuLv8O zFo}8!fPUt1v5d|krrEe)x!cY6O3urzLZJ)Q_w$(nhZun2(4t`61)v|zc9c)l8)wdB z3RolyU=TCn!n%R69m2CkkpG8)0GzBM0c1-)VSxJ&?j)fUxGFMSJbSEhxdieTDkulQ zWgSJZI)5QkC~@5%5bA@s0ptTJRPcb=$hMmFsKQaV~qSg+(`dW&1 zkdX>Y`C8wbG646-e`P~biy}ar=Zyk&fO9zbSVI4MJS12QEvj@SL!T5eCM!*VSzy7Y z;q*4P(;ou^umPGL;I_gzxZQ#evw*I2acc+S8lWNoRwrBaAZj6@Cx6GwNQ?lxRTMq4 zE*KJQEB|WjhQDhkUfGEtbyy5J0;7tosedAw(GZDz;6(-?uwXhAfSFa)87uqW@jN1Q z>18lGJG-%MYq2X|Dq;5sz`h_C8>RJMpm|^*A*ie^ML-!TgD{`2*FUcTARTb8beO1) z%-H`S%6{iCme6zf!$uRABX)sL1zft%`Hum5aEDX{dOK2cr7EIiG^iUQ7MUn zOe~mHf${r`0KX&-mdWLq!(ofX(7n{yUs{?Yvv32OO70QG`xZkxFw|?0X^iJ9*rkj9 zu7WFIgJ=L3`iJukdjECrandzfKKne*)BPt zR2cdVkoAyloRQYoQXN}y-d=`4?gJBlO+v4b!6aihFy<^BXLxwuv~2$ZKmOZL62w20dn9>b|%-v6`6i5^twBJoZk_MZ#To*2= z6wFsLCOe{3d2M`gbWYBF?T$=seMej4cfv>Tj@Lqx$VPj$;J2q@Pjk#8ERzl)C%+%7 zMaTQgtOZjComGkplWUg8ONeum;d4cqL9D#pv4KIV`{1Avi8lG>e1cw-lY?}llIuKhvX*Ag5lSzjY^@K)h0Xe_dkdq#~OD6tNNNoU@WCCQi zLvKwd(87LX1Y8d(6mNfbs<&>P;b38{{4oQubkb4#X9LIbUq^EY-@~dimDVT^^1YL? z3+pLOXDsNkXQ^*3&mHTE*$iF*H{U(vAKhbE7p!fL2!M1nU7mJbex=$7g%V=UvU>E> z*e4Mz1G&NFFR)C@bpl1(x>JL8FD2B)-@P0K|LQx%09XnlS<6bnquJiJWe&q@i6c*h z%v0If-lOuutplR%E~1;YbZ%i*l(_Qv0)$UzHrv)NRcVOQNmd}iQ#^|udb1roB!5Oa z;5O=8O?Y|GV)^lhxG5eT=a;oJ*)RG7%}V9O<;$h%*KmGk{k}aVZ*tR}#6R`^{CFhw z#bj047(h%6P>Bo6;}`DXpf|XU8UAa8h~wD(+tI4aO|?DF{g;{k3}fFh?#5Bg2Zz+jj@ATxyT2 zACMlp6DDJP0uZwP<7s|&N|r2n$gJ3i*YUl|#b}fh@ zXZNqPEGhxSDSvbFpc*3hS-s4P;>Qd@#W*DZPwUnG3s3vi5HIRLqmY@3@{QpttHeSxH~?$O2sk>Nd;css5We+?Bd_{WqY3x5)vJ!lI&Je2!RhiyYw?Ozv%m8(&QX%7 z{nRu9EV|UFZM6)2xy;{7DA*o$i z!`%e;Q3;L^BdN65CB1I@qFf>NUvJIjd^He~SyKHmAm>|pSN(n8<*0O4_X}dMeNQU( zpH94K0$Arrv%Z|DI3z;jXqP?F-^hBx??JRr(0I3MJ6(dhZ=vb*Uj9N+2G4BE^79Dd z@ljI5v-;RMQA!JFq8vy?!E4B+o9Bi;$8wr9R;1t0SJuX>F1H*cixoP5{HUp{3wpY% z{XQnwWMOVRBdzt+EpiI;EJT6O&3CvHYdTbLLfPxa&HyVn{K&@p`_&11nyZlF|X%Qo9ngKTX9pX`_jeKDrmq3Kko$#Z%vx zQZFPiBKw+RqyhyQbpxDqPh_1VW81IdP%0K>Jw~cn4UX=xUw7XLJ0Vndo{qEh3+!~9 zkNW#m7j9ZhEcd-?U#Ye&5-rS?clzDm>1vOY^7IO*9ZZMn z-k;p}C;u#I(YR2}I>>H4f7cvw!@%;g+4w`>0IL1>*-^>Djfj_hA5KQmf%UeN{W%bZ zmhw1df$xoa<-k{bIiwMmvXR}7Wk>@Y4KjDtZ<}nbp7^DCA3EJuQ|%JYGO|F1{>C@a zI)h|o(@Hd;%+W$?q%>w3DjR#g^xQWD?$xq|eX!7|3nCJXPppluipZoXA@jl-Ey~J< zH2h)0UM(y@d3cx3T4I0c!%^m?!Q-PifS7|_7v%f-C^EB*b$I{we1$znnLj=@TOE05 z@a_0Me*3w=NC(vh>AO3~??00jUJnm}`vW=~_4+7&hBtbD_wg6q-Si1YXW~Btq43_` zkKE<*LXDl9D~<#1a)>P~3CY&FSy-zKTPlcH=ZbE)jop`Ol$BoZVV{adIeM-~yh!Rn zE|v5qdRHl>qb5#%@K8*NsFl$mK^`YK-YKE}jr|A63dA7x@!hX!)n)Cx_YvlZ#Z5UHwSmI`-4Yf+ z;Z_twcL%+|w!0yHSo?i9d;5F7mkX>pUzek4dNkoOYRW2*O?!!9Tr!+ZA8io zmLd2!>H{sq`{n5J z$#`|CgDe_8*UB8DItNg(n1sK zP1i}upgbb-_0Pzst9O$LT03y}x9!4Dlyf@iVpCluEm$`gOL=ZTvbQ{Cr>ZF1W98cy(Qc!2UFUT?_b2q+%jUFXXScK-=be(n)C8Cd9`j~>u?5iB zH1iQH?+iVjZbCFP$okeB`H1LA$Kl{>AIXknfEN?Vhg3?xgs$;q3 z8J`J_q#F+2n{Q*v!jRlqC*T=Y1o}?>7-!f@vX~-t)ADcI6H6wNP~h0A*;35;^sRy! zw+v&8#;_bC!t{3Ty2YRzD?R+kmEgqnOuz0V^m2?8$H==?jQ+37Z>3qkL{`rFs-ig2 zFnP9la?83o!t#9G3=ekFw_MDv=Wel$RASMiwXICQVEpOxpV~y26Y913#shhH5*-y8 zOCrKjSFl_2Hy!5=xjr3jvV@<5cSYGX4{}q9w=9x}U=IECJeQ$x@=AHhkssx%fQS=7 zn?qK%(s>{C?p|5}LLB>>A6AA+ahiL3L(}W*BQh5i1#A;rm#b7bGh&f9zGDo=SWM$% z>z@wCMqT#!{rXT8o904Tkr;rTD1ol&cKWHmUcVCKfrk~RZsTjUNmiJ+eFDJk6m0wC zFHFx6HmG_6!XPsW0cmhAuTftoxn~9nKw%pw$(E`CbcKJjS!Do`WZhN^B*8C+kA>em zYagr9L?yh@&r`qdVzp0qrMI+k#~F}s5JvKSK$n&?50=(+(QBaZ{}!)To`MDve|N95 z1N>2{sE3t*^2D@*_MjHx!1DbETKXK9o}izOL;KqRloX2x_8%L{U!WseX;6+{0E`Sm z;s^|=3@#0+5`y^gqIq~tHVYkxKt0>XM)KF~1a?c)D4=j)8$a0kSGD5-C`}lQopAbFE^W zjRYy;pmf17kM3R|Bc}A7de*8*!{_9kh!M{yD@xA=&82<%P{uepy zpV{gL8~vs~%Q`ZHkkACEpFfmNbm**pA$y-|Vm%;q{+R?oIazUiwatak8uCe9LVMbQ zW?hBe;<4M<-bGf3THs2B4xvdrC8HbmW@LuxydSsj&N!_iP0Ib3OB}LIhzoxqHh`e} z>-PQrjb~QqJ9c8RLXDK(KSj+wqJ3kJ8a*a9kmLv`Aw;YcK|FnB{;-vIQQe( zq7g@~VrDLinU$_hY;BYw5k$i6*)u>Df%A;3oRr-hL4_IlXc;8PHPhfXTmXoP??#VI!^a!Yw7S@lZ`F~>}UnokMBm; zWb$YjRNro{e@leZ+w-JA7W?ao0GQijmByc8=EpF-)Ru1x8V+qtex~e;3=38sZNyp%bJ$65dp@M3+FDY=A61s)%a##+jwh|4kzvji z-OZc1R0qTRR{^1-@tiNJ1@*I?v29hQ1@Ha6A?x3_eaHdi;y^AJE73<~`Z1}(9!G@| z)~m{h)JA2T7qOCZOTmYSeaPpFk1BcX8P9A{s-CRkw7hEP z4%Ovqh^sr;F!&M`q+4a=qg0^%{#net^gAm=;iUMms1H&?isMcJ54`DQ^ZK1y96L{I zxPE5lkpa=-K_pnGzxIpu@NO!faO6&NcT)vU7wWbMcdfd&KlmXFtKzfh!xp!xF6NzG zVwXpcc=0_8Iy$WvPCXP}c%oLE=vK@Tzp$CB72@Mu%AE;urO+QqGCw>vykO*1sb9)l z?rsU~=#fMfBYsa`Ao+x;yBt888KQMoj$tTx8JXE!8acMpXyboAB+XP_QM~lv)Tn-< z=k7M+*-5D^^FFNpelcg_TgrnjGhHaRFF0!V5}>D4(HiWcJy^5MTc>I=*0{i+m`638cdVY~x;!rKoJK(80!ECO z#FUybD)LNX*j!Sfm4UIE7dlHfAkkE3JoAWZlX-PkwE4M2FIYPU8<|U;9mm?b#SznO zSvwi$m_o&t0wdGYytO6qd{#NS09jek-`RG{+4|epI6>H~KOO^mwTr}$hf4;yfl)6B zv!B}6F{GBZN6a|sQ1!G+;3IgQfkk~0Q-bn>WcD9u<=NtdBsd#EdM$HhyT<<_#kr%L z7F+3x0271y;LrT-chBV6A~H}`Pgi~eSAyGr0ask!O#d!Y{;3+D=LR6B+f=`v2nm8f zF@u^90KX{;njHLa8Oe<<=@c;T>difpQ)I$Vil0y}IeWGxymqxM{1erWbzi^Lwrw)2 zhm)TEUW=)h!_g8l<@-=bLv4p~W47=a1u?`&EgL~5#&<`;{2kv98tI;w^$sA})5gS7 za@KVYl}~3J_=x$mFH^%doAczQtra$*-7KGbg3?Z0 zSvi?YnR2YcnScbu$hEM@tdFnrb6JCBJn`oi7dAH0DRyL6tx(n+iOYPrhLcG8r$!Rv zS)38hB}lq-4>!@Q5qAJCCsOU4e074O#*}Mvt2C5)d@e+*8ax6I{wZEe84}+t*>^&> zHXn%Wn@wIkT^J?JaK>gHJ zT&SDVjx7AYT0%pVAZAAJzz6^{{Z0Q(HH~|q|8_snf2)arq`Z3(Oj83vUL$LWZ_KFp z6jmz+)9K9;IM81=Aq^&Z^o9g!+RD`SHeFDcsO*p6*zYAT36ky3Ay`OlxKGOWc3b*@ zxk8fyMo6CE=(_#41v!0A0s05`KaJ7d_qYM{4=EQ~sef3Ad(;r-wH7rs@f_Sw?7J)% z&!A=Qk}rFQ$gE*|(DQ$tn*fhK6k;bWzgApL`Nf2#)?6geLOR?T+i!anpXK_h6M*q(po|B7*#>?Asatgb zgwz$Rngk7xBP;262%u>NkGAF#2nvg{vfatHQ;@m~}Z{R*4xCZxM(+1Ade(1LSat`~mo&Nb39J1?2 zVdd{j!$JZW2L1IjX*7?Y(?B#lvIFGWST5B1kGjEW z!{i^d2PN^3Vr#kHCP@x0O^7p9Rli0tG#x0XDXa-ANWd3v@V&&|h#CS30)e+V-(H{P zf0}>WIKlQ){cUy$y{`<(*0Uh&6()4}tRga#fAIum&}zGMb~0dw_V5OI<&AIKmiomT z#>G|IhGlCs&U6C+p8431oioi|phx%~Bu`NOvW&jRE|HVX6&f-O7`!w5$P13uDxx4U z4ghrBeQlmhinC6&(j3urMc3ePY<~2C^-F+qLr@y8USVZLq)=<-d<9dpMnkinwkgR) z%b5op<>s3Z_BP_>9+JbY()c>iH#vlk3v~Rb&*qr~I=N_~{5RDb?@n8alz-B9EHZ>l z5ATWFs{0y6%^T@ zGx1DH!{M$CmmO*7Yxvrc*8%~y?jD{SN(x$-{6z94~-;_=H!$gTX7A2Yg%|&FicE^{5qF(<3G>3%H${>-5)%UbNZMc^NkH zMBX>5A5TJd;bkXsORQ*;+Nv{KNWPf3xvi!r%Hn2JVl7vg5;^6hWnW1M7e9T8MG%!` z0GI~J`s@{a)?S)q@ZgpbQ!s+u9TxK{+ui)44tn;3??YQa9SmXbv=(66l(lNq+xx@z z+edMRG479ZoFYvQh-c)+pA_sFzZ;fH?t@hu1i!2l!T^L?dvw}Uf%&;=jmu>GHICPG z9$R&gemo?oLY*e`EjXZMU?1Ctfh6^Ky-nkDwzV^MfoFz3Sz0nuq}BnCU)*D$W+?cz zcDY*JSCzi$EB2t4ECdJd(BM4E(KNDJR;rY)CoL>aUKneXzWR9R?Ae<^7@j2aC#c`b znGqR}(zK`MmgbY%Ua(;RBOyT>iU&qQXM%|iP|Ip#@+jc9 zsUg`xUhw*vY!OlL0IM5uUK#vN$ycar-zhh3CGq=Le+9VUTb-4W%XpRL$}A;SA|nps z&6s+cd2xxAV?_j1r(C6jud>5pHa7S%nSe(2!*XVKrT*@11(`#jL#*E0C2PT=`>q#n zCJ)K8Hric!>c`xZ$RzDX8~#MoNS~^QG;4Yv>q0eSk;-z6(redaI(WiU3QT%U*M6EO zdWLCxf*^&f6~mErtWpSP#ez>7f#F88^R;mPygKmo{TGHYW+Z(#+tSUCt7*QF=tElq z*gVga?8{|p@mkferxg3uA4E>QB_@)aC9fLC0S#8COy!f1;fq%(wFx-Ensa8-T$8kk zG#;3Cv1-n1nk9~-RNt^5hge82zAXvbajqOsSRN>7Mp~)6=9)6i<2VIGNYEG9V#^7# zSXl2#GfXmqZ>ta)krZe*3^_Y~Q7o2&JJ{9(r39)7rYLIzTVeCzg2cvyR!q`Fs=x8~KfJiF)re zYIxw-)8Zioropu|Ql~7QhSt8VrzQsxXH?8M2RN;BFv_sY+3EBmbf3H!w;vgy%VHg- z#}r|IZh6Ds+d3CY4<3T8rzbO0ittz(x~zkfXjj399P%$Cdc{h&5Dvv3M&D1k#!@xR zjiD%M`{wjpQQcgi=?`#5J|?MFFs)wy=iQwHu@bE`Eu3{n2O4~2g?u)yZd+T*8*{Ih z(x%*MAQJk0ZOOjOS2y-mc&~-ubELEi07h`i)Z#-kQG$ELQJe_!dR2*~uU`?+>-bNx z$CN6VkPH_habZ38WH2B|8qXWD$fr(>FXx8$&`@O==NPfN`I=Ri?d*ptCW=-|qipFx zU$2t-ik9BzNVXr;mp$T#nFl;<0oafu+g7JAS9O#Fae@`tqD`1ZY$XP$s(ASXB2p-2 zt|1?#<^*H2lrGyz|^b+}jKiF)U?vMQ%ymGlb|)6Hd$RnF}k>Mnr=|g*vPo%j$mv<#Pq8i_o!$ zIxaTl+P=5p*K5qCNwQdaiTa{=vO@T0s5KmtB<^z8H{mg zO+e#b@QnY}*t{;xDBTO6g211?h%8BF5GbkHW6NBh!*Sd_sU+t^)=Z>0yJqC*%li>z zICgS3v5m#tGq`;{3PRi}oN7(YIBEo!Y7BJ(dYT;mq>$h;4>%3!G$B2lPotzqO{3L5 zIj4+RT;GESf&wX0lolfx}R#2Kjyutm?X^3TYLsIKP3hV)UvY1Y{KvnLuAP zlCrC9>>84`|M zjAFO8pVFqKu(Fa^VbkIc;}8PI(QkxBLru{%q~YNCBf)5{`&UY+LLZ-}OB$qgR1)X0 zh27H8ke-*!13hAWqSH;ye^LrU9KHU$c`Jvt3a)Q&|$q-Rn8H?EXf3=)t7w z*lgLcrudarmR96iS0FSUFhU*b^~2q*3a64|^$mCXpJxt=^K&dZl93xgRuY*U5S<~H zbW^N7jCI-xbQ*n9=wMbaMU4d|ijWTr2~g{M^vslpw`%wop>=sQr-o6nvC&YJ$~aFyN@4cjo=HqPsShV z+-V{f74cUU@el!Y0ApN4Q^90NSju#9IV_$ch;T9*pYL2za=1RSN^Hbcp&~DGjV1ZU@)bT zPlN1noz6AL0&R#2a-1md%icV`5%txeHgGro>t`4Z;MgK_UH+Gp14Q7<2RJ0D77G7= zBOSr)0AZP09L4vqVELhmK?-D#V}lyO6aV!cWO2xW6eBRYKtl#O|L!DMKToEHd`Fgk z8OQ@T|IGtrfIJ{eS&R@(Iu=M{u$=>`rLeo$mB6`3joOUe07_J2S9EV8ybLyeRTt}9 zv&<6$fWGVkYUSdyNN#;m<`Qmx zhTPXz$L1Q*S6^82%8lu*07Vv76lLCNAoZ^%k{p>Un(1a zWh~(nR5a-|DRQ$RH;AKCN;w7MtGx~F@wWVxa^|h*y73xxHOq!h>ut{$gnxpY`3oV@ zY5BZvI}!S3;Ye(3ZRbV?pwx5`(7rT|ggK%C#}uCrLocacbMw>YXU#(<`e;MXK@lf} zzv@E=M(m9-^G!}jvY{Z=Mv@;46chW~*uE0HdNbrX=0)k3GII;E>^B!h?_ITAc=Sr~ zosD_3G0QBaEJDS25;b?tRZEr^>H1nBdbhoTOGzag0(@lRm5iI&nIhpx$6GImTkA z*9Cx$cfJ^9yxXua1BgkcFaoeJft}PwKA(nCU=~$-Zq=iPdYlQ(f^qSQcgG)GKhLp}wu< z^j^5AWVOiSYTDz%^Pt=Eo2TKMK=zR`9Ujb^n5I6{=lrnSsjt7?+2+)t!<^Hli%K9M z9`S-n^<*Jq>_WZpu_zr>I|7|w1Ul=(X$SDV1uOr?So|qIMFY=LQRcfIhNopuzsi<{ zgOrYkVduRAW|XOdWi>Z!C|BaDgs*fdM+eo;@y?iQK7a!QkCIw2Z@6m0UJKE(yC?XR zs-7v)&oU~~*?8?L{j65kU=U>+pt2jlR1Fnl7_leF*5qKJ%%@B7 z%rjnr9Ufmuhe^w_MfHh!tpJd{lcU`6qrs?ftWm$A(S)SmtezoHKeS*RYiPsG`lud` zDKZv>uP>`^-~|d&)(hKTcC}L=I?I~Uj=)NJdwo!-pr9SSp-YYHwz-X{_iGy9ZHM+9 zcMjpLpn0Jz`CPk)7YznFfm|wBwu4!dL1$m=XWTAzKL$!tDnq(eakfrCREyd){M8;H zzdV3ri!Ak=@7;^KL2s5%l_)fR_yRcAWu`!TQT01T^B+OxGfz{A)nRE%3ZVpYpq>dh zKcqSEE|1&E#k{F#)mo$&seyIk?!5;4DL^g%Qyv1p1Whn@S79t>5T3SX)>o!ZQTYgH7S! zMvuqI17JgUih)4_MWD*w8J#)JVxYP|rxL;on|evx6|!C8XK%+nt*1*C7l)(1MIQV` z*GD)BkKaYH!oD`235c()p-ny0n5kJ;CmaZlq!^E}Td|7<_))!cAF^%{-u-6MAGJ{( zN~ieoaHELWqGa#jEsx{B_CF(g_kk^H+1nIS@dla!P)Ig8lpnZug@t;ONZXp`+xD7B z`E_n>Gt+Aa{S`VaZAWn{{B(bJ`UqH`f#eOE0fCGH<`Opk@)^#BsfeE;Ukv0C6@WwD zIkS87%PM5PNLf)QfzJ3la%;TP)$c+rbYKBMLl4M~%Sef^GQp(oHpLc**9h0}zt55! zw9^3)@{Mbpl{z4Q3=0=oEA%CBm;-0WEUDY_t~fCTG&rcez(&GJ8wB-5J!82^_K>kX zOBm;{fVX#wRmpmU68Y7jm0I{mj6iT?BkUIRL7Z}v^Inyp>-l9qwyD=c18Rr|hP+!-{U;Z(2-GO&2f3Xm zlLo`KZxWoY5&V$|DL?VR{^oUvQLMj$;a$6^{VMs=$Sfu!^;OTcyJ}CGQ1FCvxsP}w zASAUDN#&DVLVX~oU)}Iom1Lb-zcY!ux0mxh3}K5sn^GJgNp&shB@G0ALuMc@rcJqm zFfEuH*5;Qi=Rw=&#>Gl40K%KF_qRj16Xb)+vZcsA^XJ6N_hF{LgON#i&lixgX15Q? z*+s`tU*uk0deKj{*}+$drDiva3z%h`zMB&K5SNuWCdR&|TY&ksm(=J<`|BKdfBx)p z5Vj1vI1lhq0wAXu5dy`ly{hd`)=oDfU;UTQ#*S{7xPyfcI3=LRa`1mSY=cNIkMl-~ z>XgpCukwFm2@xPq7{PKzXM)^`g#tpZ0VMBs#}&#!AuaSzdC(u^R{$T69;X2Rm8)mG z_eo&`IcRu>&*^&L2r^PUP=2L)PB%tduY5NVtl@u$z|xeEKsALcZ$f+Uq%rlaDm$Ko zSGMmfW(3A~SwQ0|x&*svi-E8*p2Yg7yp*IgATDzh?YqSw>Pavd5)Hlu>3%YlI_K%) zcTO!Ba2b3F>v_m2q_9Y>+4s<7u3zci@%`;>+%`oCJPdg8z6yx}b#J9&IJIq1>hKo%IWl7*)IeEJv0vmF;ZcRUZ4a2iL2#N>ou=%tP;qLwh^Q=v z8aT~$$R4Qtw9F&((*VBn+`iVrDoN$Ua?r&-Cw zBYN!VF*>?tFdCt!jzP^xuV5cWL6P_M%IcfQsBVj@p|Ud~n#TIrO(Y{-#0w6wJoDfU zwL*Z!O7HuO@iOc;S02X0&mA>c_Ff7X#~Q)>vaPI1F4gpJJJvY#gYM*q{1&f z0R~iggs21XzZ`);t7ANai-E_%>}&7jh+luo5TZLWBFIaj`}%GCFTB%&Y2M3^=ul77V=Sfmdvrk33_w!i-ZuEPLym8uwCd;M2p_J8+=ZIM8``KqE2 zyG&P+MDmYp$4XH?fqYZJvt{No-9wOT9Hk)VK=at99qFW)(zkDgM`evX(P))HZCOZI z{$F*LTo*BP-p7iHlGPwky>&1Z!QH0^!0#jNld$r+uI1BVq6}QIei_3{l|)paqpn(?ID|!YTugU zlw16yvIVGU-#RWx>5?Q^w6)GF_>Cj6x zgYi)L7w!+Zs7g(7#7aG6l%#IK?2r^Y$#8^@S@P28qeAi`kgmrUYyy?pAtJc6pk2PE zxsUY0@~j`vLpc9gHUDIg(1CWSXDWxUP%VVg0}4M6yB2}b^Fw{HY-p>eRW$c3#Fy{- zI_!J}aa63PENr{Kv%XUldrnySAsS4xhVN7V3YDDY@FRPlXJ_v1h$X2Fr%rOc)nSH( zF@6IpEMqq#crfm9u0j@v5h&B_kVF#@FZ#0SNE_S4E>l11b7g}fT*Xt|wGU;c8Eu~N z)00L#q{i>8K~L;3P7-J;JX3kd^pfiM)#z24M4!2m3RUkPlzpeQ@d-qcY&{(!%cVY8 z9Vn=A4E)YGA<{Ly$~7&x4`iCCI0*EA^{@hR0vfYZKE8RoYa-WXS0s>q5x(F=P{NmC z+31ea($~1n_WgjYI`YeL%3u}m7Kz+XK8{;UI4hld?95VK zoJe0{3un#mMC_8Nj4`p*Lb&{o4Cf-odNU^R?YBZ2PrDxU3vxOCp|6{?xMhMJ96ze! zcd)33wM{fGHZN08n7L97TYcyp{KZ^k-WdC32Y6+?->nreaZzX@jt0F+zIf)cVm34h z3y8$ItmSb%S}6b*4~@@=kp=kdf}M~EJ>VG(k9oZ6fI{3e@NPgpD4--*fFzh_gj?dG zu}F}Pv_k^7*#d{=Q%uBrA7@erOX1e2`KSHI5j3fq<7&GZ29SV2+$d?`Uk`HtA&3GF z`PFPcj&tfaquc9_c=Lr60lVw7%gpl(B8*q0bI(}V;j9X)NucH()_<019`-*;Hooa= zFV%1nu;u$9%-Ee}NJ9HJ496RksB})mst!A~se18#NIoZV#WN`{WquaJq*Fg{PKChN z#+~7S$Ba*yUP!6Xqed-6CNuGn?JJOVxb=u=*u;5$QT?2w?WxN(c_gIs3*!m>8#G?1 z$t_${x|e+3*T&VWskk@<4l)7B@-u#3 z>-8~I&sip1y99O$jZgMjC8dF`KN0<5|tM!X5ni` zX-ea3JxDifID8o6I3|8iUwH7VP$jC0EcJm*UeUbqnn6m+B<9+RZ6;*|OW8atdO|Av zxr~mWII(+oJkw%4L!FNVK$XTKHOB`}5 zVH~fltQQi}i{-o(&8!8Z6P2JPHB)EoIqw+}n{Pr)T9_5&n>|{5>Yy&Hr2f{d#C(AH zbO(YzV&G-&pIBBje*H-yh-X|y*H}#LPzu}sxF`nhCJyXufcT@hM&vR3?1+M*_T`CJ zg;T+IzJD^9L045F!PUu>lQj~@L0Ew)VwT3gs$_H^FAKF&;K^!Y17 z;e{j6A>89(oQR?W%lx$~o`!-60LiNdh@+=t-dDCzqX@i;>lf%6(kIm^(>f2(#sjzF z(FZBCd77)mYc>__h)@%!UfiyHIJ$3Qd7ZW-CV=;Z`c=%koo7)%=CT~BU+=vTJKvZK z@OArqZi&l&3a=G5m##5n0#=`awR{(5H{eI&!n9N%L%o0`yb|`sc@ge>F${9;%3g2t zL7wU_L|ZJ3t@(}7gjRrXS%lM@egLVsuKUBJv92rdZQ=%^RU2z66@t71DeiX#*Onz` zW3?~S+}oaIl^jK1ETYZ^1whHf);`N!7aDrg_IXr$LH3XDJz1O}B+$ju)-sbX=pT9V zqt~f5?7jhzt*wGK9!9HDP843-{EBzPKhWFg$@>ZL>W0k}GqGO(0O){41I$A#w?&Rd z9{G9q)_?q2By`RLrZ`RzS8^_wzXs#urmY7s0~`Vj_>0 zcHk!E`h%0ojieepDIEXR+}3umhfBH{>&IW>N3TCWGBxoUGsNh{{sb}^Fb74nWbq!a za9CCm(LRAScwYY5ZXq}y>d_5?faj$EK6%L6nt*oCZ&0`5CNEkT8%Ks$ySFRw>+KX& z%S?{N2I@c`A+(zG%raYmcoxX(ZpQ2zDl zR21uPV1__%1Q{Ix8zSY%@8b@eGQ9KxdNF|-8M09)JHolTK1@jOs$KO!S9W3I!(6uh z9FUnzT5mVe>%s(*g^0Axhun{XO4mLP?7Bi?K4j(pxvPQ{>vb?gBf4+{u>4*y@LmyA zL?#G_Y;(4G-6Bk013@D@KIH+>ZgXa_GOQX4yAecfnKb%b2x|;LD4ey)w zESCKYb0K%}5zCN3)S%v@;Xtq9!&=SG5Mwp3n6D!~Z}YJ0pdUqxL+p_Kca_F#!HmxF z23R8Tq9648CBLV=dpdepZ1R;$YEdH1-!SeK%s!075G@giy-T^QT$PD|#YByeUz2xI zRgagG74y$p49ucp9VRK5eMj#{26PI%sdp>Qx}>x{uuXiciXrZVT@rjDX187OBwRq7J{P5RT7`EHMeqs(f0SM7A^5ObP4X{xDZz zCyVo=)ps1iQ*1Hf}^5oa45q@QCTqxrW8_gVGMwV+3x|r`HW7B zTD2$P@c>_8po8G2Z@zd3JCAOHbF&`9JP%yyL^o6Irp~G!&R6pKbX@*0sM;!Iul= zdg6XD@coTESfbLrP&0Zy9Yi({i%u>(JL0Yyk-q)iU7T52ib0ysc`V}*5@(PxeQWz$GCBF(d@j-5d&7YPd|kMcVA?KQpBmnm9GXm$e>3d%%h zcKGZ3NA?3bnBg~{}3H!6EO1Ri%3$!9dj{yr>C`*Hv|f-TPoC-9sAe%H(pNO_`_0o8PWS!oh%+%=>83 z|7=ab3&ad?Gb4D^q>I*S3qKXu1;5nGTyIkY-doPhsofwd3q=)^sTrdAC%^)?wyvgEGb@%ixM4OVpGi% z$Nxfp;u`?IKh~e$NE8%VxQqn94Odl`I zA19HHVID_(VAf@$%`{&(<&$+w+dPd1#`7pQljHJMwlU98-^?}^DDRU$_a>ykls9A{ zO?qrDK`RUrz9yT@>$#h^&16oGXw-wJMdc=nkzMYDo3n@=JJO0ymTNyDy54I* zHWUH20(aJds!3xhROabN^yWm_F{=9(LdNd9=i+0J4g+xB5d7RVdrqQDQq#PI&5=qz z41F<}&wzqW#yG=aqaVW*TUZuZd?C-4GGr|nB!)QkmdsV%<cF51ik7~bkKy>#InGLm^bXP@%cFSp9^cyVgPowP;=ZCj)+)NYMcd{S|Rq!Xhat~pm~rn zyrF$p_C_aM8i8Gq!YUeBin+Dp9Z`LjK|p_ zOMgG@@Y}+WgE;F$hb4aO_uQeV_NYn8m6!~<4W8e-KT;12w0noT_%}}haMH(51Av4{ z2b@G$A2PK1i}(+Gb&dZ=7T9ZM&A5()lxEc2W%6JIx@gvGY_?Fe0$3tu-FGHmldSfi z6|$21qbpKJdAXPz|Fu#efbipip>?q33W4{)&@%deV`v?9kbG)wR)fZ5Zp~L8;p{3v zR1t{za)1oG>^2y!DEZ#{zpfbA<@SFWPSSxKKk7$8b>n4hNI|rPz(`hdm|~7_W6!c_KhE!h*}uO7ji~?& zMb}t!PwfyS`!&_VjHsPJCIJCl)(`j3c-=0v}8J@?jR7Nz)w%mC`8BjK9g}dd5G2IH}~y!Abe#MoJ9z(^YpA z_W{BCwyx$9uBjq;6E{9W{cznespq-y*y9_Q_Z1#EcQB%I4{^9T(^oj1^}AL>_mbTK zHebD0bPTE>cImcwYc^wJbddo*;G&_W7Dn{fcxcH$g}zl2lg&F7$y{wQ8sC?cAt5Jc z%K>hfPM(_yO!7yVFkB@<|qcS~-zy~Z%fyiBH;Ki&tZqB{Di zi4a3*0+t=Tp*lcn;kVsd>hhM})Z7Rk1q<6)0Pzr#za%E~@$!ZdGdgC9ezHtq%ngux zWsoR8$>DRBg}^J;+c8b!^`zLb?QT-p|@ZTaY39 zc3#kOc>9&bjjCC~%gH%h!^R|;HIZGM>7`{k0@vsj+Th)i=--5)nRZ&O{-Bbn#X3k| z0$(`(dX`?;J=H%*z#wOA%eTgdP7TI}pf2CTB7(l#gqA^y}?A1x%*Tgn!8)YH?q8}-)&BtycX*a+i52P{z}GaH_oprN5W#@G5yE@B znpHHLlH%W#>YIwWMQOjMyz9?bSS_hIG)LzOhzomiU5CRZGzvsH!9!#^vBx#$5`IOQ zH^f2{_Z_?Ni7z69IbB)q&D1i#T0BEBi)Quqcg=O~Zp|H=t0>DULNNHA<6494-G}CA z0}1*s7ZUI_z=^;|5-SVyL9J%n$N8}ba9PJH9=NP?&ywuU|18dJ-bSG{WnQ37>b)0X(D!_kzc@GC&QdZk{Z*EX<*L zxNO)&-$OoMJnFe;7I(w0JvZ+VMGumC$NpWM;lYQlK5?8F8B0~X&a#f4|K(-@OfDG6 zj0*S>|MNs~r`1x=5NqYW_jw!a8Bv3Ym1jJCzQV)dPb-@|P%7V;y|p|oU?=FRFVV6j ze1QeGC_e%~=v59Vc77ltv6_XTJ+59tfZCM00i1f>GKamGwj2v27 z|4V&K5l5Gpk6xc!Siqf6V$=C|%c9i<0ruvyr~k+TV9bzfst%$~d84rTo5!mM)x^|= z4}mK53l_yc=Nov0yorz5Vn>Bjr$Kl~tDk)qBuhfD~t>m0~K-dI^1&6!(Dk6;2P45Hq8D!pJM zfV1|Cn?Kpp%r%dFVrPPCH4i^L_i}1rb9~Pr#eMAsWC<}F?BfMT>j}~kfEuIHaiRx+ zNF02;1Lu(eA3@KE^0d^+;r@yl^_73+TXRJ zZ4}6g=+w8QBwjothtGZp{oK-86cFL0pN%JsE3C2B=74gXT)n*Q?Sl(y#Q!Kyph}XhV+T5TG6L5$ynypjWhnpkHQjm}!sRq5ysT?*6C1NhgQf z20+>j1@;U{X+Yo!m_Q4rNIBqw`t z@~d~*i8X%9vYd96y>f`=ca-~x0pF?61xr#H`{)Q47-_AQp3SG}#{i-x> zAsY5M@*{uixe+};#6!I|^=i=XHH77MXr{Sq7q(He7 z$RV;mv_3Q1wi0H}%am~-5e@E&i)Y`gyimuuk**jg0hK*K8y+Dll$|VK;Cuv>!+VrD zwxZTn+fptu?h{D_&2+78fQqX4`*dV&V5pne9QRcWs9|K-weYN1xJyUb9^S*t&ahXz z2*#O}c%#Ixy^(mxA;(ZOp|kK#S}J8Tz@GD~0D3o^&j0G&pwRHMcLRA>vy(q3;=&(# zoCqFbD49D!d;I&60slgzPofiiMn$!6Xhl(cR3*JtOC-OSs4q|$JK3H;K`-!bI~9hm zaM3_I{hMlaZi#QRDvVBlHMX2}c-2U%z!zIvrq^Lid3$RDyE>UFro>QqqkDM~(F~b7 zSb(pr7jUF8)b--tozoKzYh+m?+DG7lUl+9L#zs9`DqePl^`)eG;RN$@=9VNAA!GEC zAiMFjRL+{bPAQf}PO_Jaj7nPwSq(HElS0D0x`DPnBmAP6GF_n)@rdGpT4lN|O3lNMxPVLs_>3WRUkW-xNzRaFTpEI0?vw-K7xdLKkZFAydY~E{N*-1h zn1y5Uj6xT8Cqx2DdR-Z~3(|E3p&UwFq&C+t`&hHW%Rp{!aM?YBM^5Ae;d42j{AqD& z3~v3BVkMtee98yM4g9vqqI3Jw%GlqywPT^)Fh<~k3cXy_HxQ_dZ@03Va2rMy+BsSqz-?61&zC|{~oIWLo z&ggz6$<-O((cs(o;<8En)n+}HG&!vO=B(7K--CRwz7Fp&Q?>ivm8V#6I?XF>gfyCu z0h1^~X#6_13K8fu!Brk6{+&DN4BP~gSM#eoP^UEc0THZd+`aI7su{BKWZTR5ZH#^~ zV{r@;bmQ_=0r~j6n50im7vXcSxrAS&j60)%u(t#-(@?ApeqyFc5yl|W6T)E+$R5G` zCX&dXRc$uRKp9;z8ZmxV9#f8Z6mrgL9-})?2m1{G4&UF;(=UfV+i)w}2SnZ9++=J(5`^;2$wvlceuJJVc0VZkgrx;cbk+7=g_ys~UH1)4)D`h5u$VFb7J5W$m795oC5(Q;~HP&F23KTMs zRF?gCuLnog8)iE|QI*5R8l(;?As`A8P*Pzq|Kn2Z5TW!s_G8z-o8I$5=oW;@)X5n) zT=7}K1&DrJ^P#P`-$_raO!Bmx0cg}z{6Zt0eW(20ZSVcqg~H3+8)NaK+i&n6aRtx) z8Z%gc5%MCY$_oTp=Z)MF(u)s+A}y8&I$?-^Be?w!fy(!Yd<;~#E^{Pv3311aWxvAb zkD^Zfp+s~0V)qViWnM{6Q$hTRw)2A0vWaM3`hY2oG)rDe08Oanq*i!=U;mMBm|fx{ z=;nMl->W|Z4B*NS@A)EyKJ0^uF66yLH%o;d{dT- z0_6HiV35(T(ZgV~Td~-3vVFwq*{krR{}G3W3vnNSh`4REVn;)O?8LYcgbu8oM;-Y| zaz_))Qb@T|x2|(${MGJ5FO*Mm3rqqRI&L_^6TFubUFj7HkeK z&#)ewIRrNK<$D4;0{Op#GSAT3BNHIr#YeYCQ2yAWUoYxuAmDIusnG+mWpKB0> zG6AD1lJQRXzt;e=su=;t=gh1vmG(d54Lmrh1VDHEe|?mng->~BzY3q6ZwAyv;03$K zVVxT?Xl!4xQ>`T6HdrgNzJ~fU2rpnsucoP;*zv#y`C8GyeibdHQhaE_62M#r!U;_= z37+47zr5o7?xPn#kOFYSx?y1deMS`uV4(KIKp^o@fefx=Fk2YJL3A)cqkS3X&vl5F z$)5cS)GNcDh%j>QuvOKbY7<4-L29TJy$L4${u*_p)yj@_WYbqJ@_-VdSx;tM#J5s2 zxj~@YpxywNno%KU&TgFvz%^jdGX2c}fp`!T(?2u5ajemq!qeU?Winm$_KYdQVCJ2n zG`l6bE=>83LBZu5yakH}a})etGC)n>J>WmcMOQy7j$dI$4JF@n9!VrnynR@p`>8p; zuWjX*Ans#g7(MBFCEw?{53vnhj?2_JlQq3yLH<8_5x#t9L=9hltmd%jbA+wU&-e9J zZNfBs`QAP-x75AWKrVN=Bn@v$0-<0=k4lDzlZiELlrUpv4YZ$OL1CF50IkvQ8?`e5 z?Ne|&Y%BSyW7b+|1_LBH|2w?T2hv|59O=pz$0gK!mc5lV2w9(qg_VnjHqR%@Nxa(4 z=GLX9?+thq=L)brkQ}UXjdH>`;X}*HS{_y7gLnGky@yipZfEg0gScIIsk|IHz+wLG ztNC0ufZipPW~z#<*3Y8ryr9R_BSE+#v=}n~__0tjNF*R=aIB@>Ehn_vypNATmbNY} zFT|UPQMWE?Q=7z%A^+l!RF%Y+V^VRuEKH<&8a4 zH+X@$_jwhPC#+%ZOX6hlTRep9+T^pIl5@y{0anVspq;}$Fuljg;Eliol}kRhW`Z0c zf@@;IbD2*>Pb!s~Ph|DYlZuxcVXM85 z<-0@I)m0;5Ey%BrB4Xv3N;hqx2c<-n^3Fw+4gFZcIK(9qfJ`QE9ZoRI6g%)JJ260P zQkx-PI21`EXO4_g;cK6%DCKgOu2{^qT~KpM%`KU+xJtYszWVKh_@_gf%(nq-9f_h; z_CO0TX57nSZL_J%=}3A3cu{OLadZYiP^X>w786=c=MCG8~t&W+h~m~OGE8~oC?MH ze|m#?tiwnz!z1HWOC*Z2}#TA6|}R9~-1 zziW)7?`T2_f2bg4vIC@cPZ%#*Z49pG*WRsSf8sc=vJ4fppL@rPa`Z@gE=`6L(V@tb zwLZhL`jX^BE1F2{JszSNZ_H|se@8MJZ9LUVg0 zB^sso8oKry{^=usNzcH=y9>gJx?oDk@P%-Z?(Kdh$>OQoB2^J7G)cC%>A8`k8;D5D z3KttR{A^FRK|+ZM@?d}$&#BPW@Rh`9$@Rp}8B5x6tjI8XP=NG1Fn5UOjcS-pq*u=k z+a_D;206&Y2cayAxYhfzJ4n_$Zd$QgE`zv#{2(W>xt*dvmeQg@D~W<^cJwW5Kn&0| zWC7Ajr}5n?^u>bWv9qa5H-5A&|*R4X4d2d$#FbVf7jciX8)-y&3Nm+)C5g|-gP zB3yDcV8=iB_7yI!I@NyMQ7)9Y%2cBC;p}2O(|+7IPUmO-XJhryAtoBGg_;S|oD_=_ zGl`RW;yTQrXN{qJ9aLsA@5i*bF)<=5!u&}F_n_D1UWsB;u+X!)!GWLG3PNHZOtm4H z)zq|7bc6wL$(r*{PQTaA>M@M`V89KcVwZeLp%2PHK0L1@+m29D61kW_70t_pJbM>$ zJni1*X_X!ZZ9bYQ6#w0ker4a^m-$lrQQuyW{x9MBSeVf6469(WU9#;pkh%X#F!s!K2O_h*&shB|@9`g^%hUYe0)m*_sx3=W6UGLs*&$j9GC3LJ=ZAKgrkzL~s2 zzM_-Rv%i`4(lK&lp34>s<){qWBHv({^P)Zdu58&aB4iz=06nEoiv3muURn%xEOR)D z!OGp4C+3LzNlJ}i@pvM>b+w#*bV*KA{}DGm%@OwLjlLKekov;zvi%v3p)pIIZcJ8O zhZ!}vPzW_Y=(SiFA#3tuw|@>@&2+#7u|?jlW3$H*E3 zR^H9gaOKlg#K<8z8tW~rSDxmsn%g0?aqsjiB}BMqg~&rSYBfgR?eX_lu;qiK4Wz%P zl+WFX`<{PH-!8+W(odZl&quZ~@Y-~YOufid4mFoNjG;S`E^?Le8;-e#Exn)LjIGEl zn7nK|{kvjxp4nvnDqk?Vd*ty>P&nbi$K_j;&XBWl7<(KVW-|K~1|Z85+aa!6c<-&a z&s@vt6qn>tvim~h`rXNd?^w&eX@T*`<1*I7DTaEJeuH??n!oMjLfoOljOa^O`lnN? zlhl8utoZoRwt)^agOUZS1Zm}wo>t{sy262oxfb7FPRK=4Ohmfq=hdqUeZyRbZ)E8} ziDvT?fAbD^^8M?Xq5!S+jJ=R3TR1!1xSexN_eV#p{*07|BybeTk(kbcS`#TO5^7bftmnLR3%X zRpg3NVP-<(y3)I#uGx7hFF=ZL6B_1nQDXcVw!QjNGYL$U`AuHOBq=8xzQ80|N@%_B z|4-`9jd1ED{|OY~9(Q`=T?T`TjhWznOCZ+8#d>!8}KsCk*He#t@!`xq*(&Hvyf5HMq6`rc~p z$hwgRCJAEpGBWv)w(+w+9YNMf(_<(gniqalTW?89y>}p6e*{48*%0S*tO2Fg?dcH^ zv~#o8wSOZp@>lC@D-XV(p$>{$Id#Hf9U{JNZ%WN)%uH9s*ouz9Z~u=O`sGY`=)o%2 zqIYrNtVUsjEfA-N5W}89YM0}FuLmkBEFA=1{__s~3hwYpqAkDjBn~fSqXVKKb%(Kk z7tTC89&ev3)&dKH+5gz&pZ`Rs0M+yC{b4&iBTk=pCO{rLjw1l}&$)rp`yNN~Sao+0 z{8LYN9yA2%Ry+-cau92IM!InUT4}o10#N_{Hu|~q$SIiim+t2d8$#dylNvyT?DO0? zKFIl%y9x45x$Fqq|GHU?r0(MM|454c9qa%qV}SsVgKaAAKbQLT$Uu8>5kPh?hUpdj z|DMY#4shAjHSxFq@>YILvL-ML+rKr>{`T5D)SvsZ$0$M9{|*baJ}oUaxeF^K2M}mQ zK!o$TfBowI7TJpbz0BK*=^%6a7XsDNsx)R!nLLmJ4gBcHdFU&?3m7j`#{8ZSi^p#YHGlM1{T*VJ{d3axEJR`p*MNke0ArN z(c5qT4g?eenGXb13oaD&RyLyi`S|A-5Xkg=+VO&A;%Ax3y2-`nD-Z~X!8FZX83(Nw?Hyqb$%ea2C~Ox3(3Lu~`MuXv`<)B5YoR>jFf4Dx@sfsTZuf@L(kxG24xo z!zv{=S4qC4v!?x)WZshmSCwnxbtwoV=yq=e|D6xM)*P?~AS4j4c-X|)5M+88ysrce z(&BdDEJ`!|Ly8>{p^xXaSLDnQlf>JFi{N_25&ag-nv8ILkMdJ#MO+SA<95<1eIK># znR1mnVXc_$nMipW?e0|}Kf4jq7+RyB7dE!9*8QmGe&8$ib(%!4x6LK4CgY3#-MaHRK0y&8njfTmU!<9LTZBd2LJGmnQHSfgJ{Wcop`PAM{a$>M6^CXg`by;`W;R^58QAk* z=6$LURMBy?8%|}}{<^3)41&@aMYnB&o{o9_IP`)Den_uI-!UO8Aq z{pV%4{?dY?ZRXnqKsJY~h{EV4Fft7p$q{;CH(p&5iJqAAT z&^)kzUQj*|k5*)CZYAz3%gfAYgHQrFOjAbQ2^Sq%y(;Z9TgTCM-X|GzgNnBLNwsE) zq%(Hgg43C~zPa+50VA@ktRka$Z_FyxIq8^Ymhzp^-6{=>@1HWd)hshx=2z^XYIO~C z#J_vuKccdJ{`YnVA)tipxAf~gpGaL;AxDUo(Xw=%_r>Z>Gap&QL*PiKN3Z zHffX(c29xraiep^L|J*h&t%xj<9ACHVE5Y=BCe7tJO{#{w$0K!H|r5}&DX0j0UZ5d zD>P5D^QtX-QdM?JR7X-3uaCsdXXoT3n-Yq04Qn}th7wyc2&{-K`?I*ypC%lXqD-me z2rZ4tlFc$HIrFAz4{W}g9$7{1%gCDqsG3xoeSCA1x$b0@zcQZZZw9^VymQpIp+0rd z96eB?(pnytH(uXQ>#&Bc>N4NYV?Nc5-<#xo-vcri72nR;?0&CA%Dcwddv`bMli`xPd0{f><@OH}Z8hX$56hYIZwRFmnoK<4k)h+Sm@h~nn8oDhdq6f& zaEKEsYmJ4YS307$COPn-a%_6pEM0QMs zb@|lfs-z@K?c$`aI|*mc>G+9cp2ggZu zxE!BffjCDv9%NnG|3s6)4?{FU2|=o5#S0#JpZ_48{ACsP2s#*euoAC%>NtcE{MRXZ z?uYWb zn9sk@POwmJ_fsH2)fFnVP|W7^xY8~OLf#|YX$eLu4bvG8xCg%D&yFLZ!2*pkIJ50q$bn9sRSoX9964=CHef` zkqqE_@WfKI3~KytQD8_x*#kXd2;m?`40{Z5w1@l+$&wnJRyPyFt!6Z`OV_E6&K@c6aVP?~s~i#472Jz{kmav$acIp%yb`=Y)o5Nxw|aScyIj}oGE$KE zpy;!Uk4E} z7cG5=YH;x6puP|Ho4XmI{L9wa7=RM$yNlG!=f8I1o!{hWqkD=#vc}lBBvq-)LA)wj z$}fSf)S7KgJus>I3bK>soiAE2@e&=`&TpVSP>ktLueV{m2gy_`3H z-4jQhdk6y`8HsLs`o6>E4tubI<_){`qi$IJ!fI7kB(d z=8`Hb=%`Fk6ozdUmL5tfS*93p8G+cE7ow9UgIY8>UR<^9JA^OdU4(fACAR{&*v_D~ z0s@^%Z+1v1>;~|**jz}v9AMKG1Dq8n&II+!(t&>GhC#6-i1L2_f}N2Y2(EsRDPG6H zMS`~|@OHUoJB;H^+*if8F&1-AYsCY$3?Q-9pS+td=^XkJW)aH~#2a2c$k>`nZxvLb zf^FKL`*$pTrO19i|HG_gZ0KsGzPi(jXuTl(($FB#MvTNT_?FUyLrYIe(I9@zBeZxc zV1HrF>eg4!W+%kpz&4R5Jy#4l#g51Mv{g)sE;1x{Uz>?74AEd=7uiAp02g4g+S3IN zHBI+fjgyCz7L)Er#n+*jX@ge zt9U}`N{~&>YK1wHDX&&0m+%7D@-d@HqZ za4M9{VN=%UwNkhw3OxAImgNRyX^6)a8_vQ64|`+?)*!kZ<%6XbD5N*0IlHzAoq289e9 z@aJ-R?ZAAELCz0>GzBp^d5AySXm*pKflV;3j&(gIJmNZc*A?$CPS9*jmJi$6C6ZTF zkqusf(cHW-m5CR}u!{?FSZHU=&g*cjbDH8#xY@2fXNu$6uZ))B<@sKnT$)9!7o023 zBH198tWi1Sz>)i45O+T_X?KG6Y*YAzF>=jItLu#C40CwO$(2OJ5*pvql?=FUY?vn& zKC&gG^o6P?7&{nkVh)G-N<&fxuDo* z#!J_>#&6?|~1*-|USgU7ans&q1@8elFK^fA0ki&{!ICJkOp7|pLiQO@&j)U`&)S5>h z!s@J?HClZ517X~}vq1S>nia*bu|r|A%`>>_r5Y-+MB099^-1qthQi`$c@(7E4WEIl zWO9SEyci^zM9SUjIuY$&Ui0^gA>lLPnKxQA)qfFu2OD4 zPbHwLx(>a`VHAj5&zvI-ESxe|v*~+!H9dFVlEQixez{zZfoUl*WBZY zUrj2_t@XZP>2p9LuQ8{gKsFv{jG5?I9{SIvYv%FPOO;&tXcR!_(BH6K2|<6*sY0{q zX7VE=n=XZC!Pw{+YC^;=N0P!;RrEC#ETO%o@R#MN?k!Wx97M_JA3{^mR#RNzx7X0L zJ1IGkj)rYxFht8+U_u7&u97PW#{!Om7vc|g#+NC2w%5p#aD-i?H>ys9PICigg+VYXwL>zQ=GCi$42i(^1eyN+@{F<>Hz|F zK*k5PtvgfeH|9TCEc+6d?g>y(EYNIvSO{uE;dWLR2En2*{Ms0~;VjhC!)mo}?X^*q z7-Gm7bXvt?eI0)$m?%1=ylQps%dx>Gb}F)w09LI7zdVeTMgrJ9bF42{VUbNO*mho1 zmV~E*x0|TTmovePug>Y_XOV5jt(a@nlBpRvYx>(zD;Nn-3bxhfCO`yrUPu)|w4mNp(%1VL0z`{9x=TZDBdL(oiw^5++HtDrPso90qa;)WJoQ&Ff z%$sgeE@EggHYw<;hQ4(vrPa+P>Fz%hURX+7_)on z`Mg7q#6iL>;$h&h?@Bm<*{rqLz0b`=SD86vkdsr*Lu%fMsm9G!i@|%7Hn^L}+1S}gK7`H-rPp33519(m&i`~r-vr`Jdh%) zyp~89$2)N2H@z^?drX=U%+-hOjKRJ8;+4$iocizvg-Q3Uo7cDb)IfLNz8GAxty3tm zA0)w%&AarLw?9B5K%X9xL=X8rc-ipnXwbbz$(e59%L;glDNlQbrzvi#qb<8@H~tg( zQ!+hkP zKrYl_4(72H4eLS&4@OP z=`}`(7)!)_$ja(3y)l#@7%-s+W5bvd=ryew>!gk7*wx!(GsnY@@w2hsS* z!zeOVQ6+l8FfKk;$5q9E1by^yYq}I3W<1|#>)1P4i<{ah`d2o z3)mp3QkHRH7kj;wAH1_EH>EKj5qqdE${`3?HTfQ$ZdL>67M;%|)(WzPo+sVOV=PCi zySuyL=AE0ijeT8G=Cjd59Ay(h86-6&GPoEAO%BnR5 z;TBUY7Zf9)uT($G7V=`eHYXhlezko4)aZPV4Q}R?()DO0uwK(*2>U`L@orD|i9P24 z@`UvF^i?Lf>)AkWy7UiX?PxVzfJ2hFySD%~sXC8>I(l7;4qoiTo5xQbp{$ch>Ng3y zXwB@Pxe`B)k$?aFH@O%Mf|IHyK4{^8x5G3ue71i2lyKU9x+~WmFi8(yN)yQS%YXb0 z(4pWgH$#CY8`_L+JrfpQw^rA~_?dnPkQXJ>vg7HQ&d(AP>@l!cP=hxIgw`|?@+zF;R z7+O=0El}PaMSJ1(UAgSEofR#JLQThq@;1{oytvj?{tg&5m@mZ3o09Y`Dr{2`vfRxz zNp_wmY%d6o1ppQjpNoOdKC-sE23UgT=Z$uBBP^IUN!>jlE_))zKb@>3iPQ%>sspod za|=40R8AYf`F{O5#1e8Ee!M0*5&hrk9$2y`VD{r$C(0$&%% z+)<;yqUjctMk@uh69il~PyE)VMSZZ)TPW<;4|8FwdUPYf;|Z0J&z1%-H)dc5E}D=d z7I0tON_`SA;#_fYQh~fn5DS2uu7~Y3|LgwMmD~|Rk%UuWN?*`RJNmQzA&8&Pqy_is zu@*?c-k)m{)66%75qSY>zj2#_t%)6evp?oE6_tU1qb z)n7!eGpL>C+ZH6>KVLl`(F+07jBRMb|DUf1u!z8!{_nRC|2Gc5*3kcr!~c!L-)8Oq z*2Dj;hyOpc=F+mcZNIUO>8DkQE@)Y{4e;H@ zz?sOgY-#>{!b(9ZB-mXkcYwYZ)|x}u#A`fC8bYvQ0MUrfrm311Lqy2Sz(@Di0q^It zc?{8hx*XTC*nP50(r9bEOraha=B<6YF6-#+rG$kAUtL|*NzZUP-={>;XtK)6tgOt< z-Tv+uadXoRm5m@tz+l6tQ|{0vTXCTDiGhy?4hrztULr`^{z4Qs${wZL%pvB^hxQ>{ z#*|4*A8~g|5UCpE>!11;It3}@j+*a+)7}+fiJ3~KD$Rg=5NkCGQUcW&j%s8c!zm z>>&Wp3=zwxcb>yL$Fmw21RcapCB@+jNeJDxlGA_|2deQ>h68-Cn6v(9uQw@bjgC*pMLo22V(u=^Gj z$&|<;XMJIE_4WHSmL|+y8cr?MM}dM>%WQqUcbE5We@w&q&Y06s;;oMx zHuF^+K@DP2y;W9owNPGRkJFBRRc?BOY@uIJx(RRt%D@e1f6D)bu!)W#h%=r>Eq1i{ zC%1jS$D*h}l9!>!DtuO(rP_L2;5JiM;CGFY%h_wW%`Yx*6f@hGC}~VT-^;k@x)aQ`bK#tnLNn?~H;p9nuHQ*Z3hg za)BiZeV`ow?dKA84}lo+9>Pt&hz)!c6v`#L{c>v9Rvj`f;#)_kpAgEL_{DVcx^jb^ z$J69Uhc+s@r|ign-qp#$)x)Yfx4Bq|#lcvfT2xtCZsOsVoK|D@ESR$|AfSLQ=G*){ zuVdT2cV;FB&Q(F)&O*i3;5!Q{B)I&%l9H;v;56p$r%FM%NS&-t!)r=%h3CRsMN3J0 zUsx=!b_XhbPb}j`5|X=z(O_(3K^a;lXHS@Lf0=!Ii1u{e`zfR`DOi9JmL27-(-`H&d6>JsnL6a&-PD+w*+bV~ zoi^6Ze%-gbk8i;6hy|jg%v|T}sJjIaV$_ z}(BR*{}~cifT%Cq@O zPJ^HtC5i&HB)DsoikDctqfktMO5D=};LAjs1i5+UP-D z;#%=OyS>5e(GSHu4f`pa-osHsjif0XHc!=MysfY6%VO5aWBCpCpVD>b`=@B9sE({= zvSPQl`xtWyClt#4ORD)74g|UH($Z+z-t4yX^4x9mKCYd%c}3aUhC}qtxZ7@QSm7;8 zeEuB%Xnyk}#H9JYydnO9vQ5#n^gfTwW3s-!{GCshGUa@P9XdJz`qvW@RiO`}ZucVH zt=fC}4O>|`y*dI-u5Vi^jB(QFct9NMihMqrY&m)kH^F?N0wM!NM8m(<{YcyMp7>yP z_lf%gHObtb@-7WO$)nS_iQy)EBoq{V$$hCQsB$GE8Ic`rAU@x? z^Z9`QM`f0BW(w7IK)T#3$}-m!W@7=ti6A_(dEb6`si1Iaw-Joy;ATE4j^u#Mh_!Zd zHJ47;FaEkI`E`Ymeb4%+%34YyMTERY2_>U>TkP0Y_%-C!c3R3sjwGl;48w6gJ8}VNh=7=A}x?W4l zL12s$|2C4EnVsfBNQ^~ImfP*h(LvZ_Z){*abRa@^*39HJS(g#6*qef-PXqPTY>vvZ zBWm%{5mm^?^`C1bzlz`oLc*$Y$+)x{o!m6w4NSo zaxx>aFeV_Op;f3~l{1uDE_qlsVa&9&RAw84jF=ptqNB5a`6ObThkhvXE&|o3veIHq z^J_J9Nb}+-!UQG{h{4wt*wYP8j}}ooGMnD zJ(CFgbXp9+y=E6~yE1Y6SZSPGde?AvNFq=Xmi9Kf2!nZC?l3Ttc9J8NEn&mg7W7XI zn>vZDIXmj@39n~OFT9{cZk3eOaHm?_hAkEzne6tzX+4%dI+tas)s&8mjC6H{dVu@f zyPB^LvO=pArOje%=cdcQiJo?9w!Pd}p`6bM$u?iujG(bt%9j|Kxbiy@nqVb#Z{7ZX zGxlKbQVY+U8wbGOt&-rc^nF+^NGTb4dehKC{dm81`@_wuYU%{Zac7o*-U>ncmf1>G zb49r)M*>FP-mXkyhO6vdQjZ~L9lsjuYl@Km70}=$)$xPJq+q)d|6!LEk?vkZE9&pV z6UOPZ3JsaQ<=!KT@s2QUwR?#2@uM3qYpYVpTsWA7T9z_zB$LA9QWz!}Nx8Ug23hxf z5X$<7QfzHMgF}6|y;=!zm2Yx$>#4I=!s0Qcp*?(wF~QBHUf%2rP}fdl*}k#Uv<$a7 zO@&QP-Of}B{!#7Rrt+f^j8!(=F6V{RPS7G#n~lt-jVzHID_9Z<8cGq!1u<) zL{}zuR3;(b_4FJ!*QUla9Zt4wa$+6to20j8y*gT=;=1?pAuN78?I)NWN(rKn(oE)E zay(S8iH$w9`sSmux`s z{t|-$%VwbAh`o6x`HXi$^!y!Gr7lQG(|MAr6>r~U*F$*nJZZoQo|@M@l`!-EfQ|Uj z_@av@_i~$EmZbB{2GqseJ41`Pty*D=9!leQlKexiJgj01lkl?dS;dvGxR=J=g6f8I z*o2q%s)a2=JybaN+n|bXZthymw|Bag3k+{1|R)cG7%IPEXgT z?I~qnILAn6MS6N#%jHa_@ZjU1$;l~JZR-1i~#39oXr7`C@!pD6F#s>e={cq|1AAp2ZN41qnwY_Tdf_~gp zx;bhxTqq;qAbnr$lLw2)X??`KbXW4E+GMf7z2u?|&?j0ojk=ImJHkg&@l1KTReqD# z4Xr1maNO^2uET(7wETA(v3vs`nM{6MmCz`X`}Tpl1JuUB*~=vO$esAZ$fE~dS^Gu= z>G{XzwKbkFln%$M#m9*5T{?}z;yjt|J3XP?=+kd%MGP%s+^76vPkM7wGbe)0(MLUz zG7dF{F|@BR#mr-9p&c*r>Wt+yMMO@r3YKtgQaQO-Yr6*tBJY}x-iLqzY9PHJ;8{5w zvWWokQNhP4J3NZ{Ye45wUsF9OZB4ib8=jvnExc(DNQggpa%gIClzAuYYO`H)$ZfN} z{q6X;vNNFmd>aMzK_1g9%b^>-AC4ivb!qn@MWG|yJcozfgQ zM|@B2_Qxw1!@EoTk58v`#&k{NWDMnMU%q&CriVc6#@stnpz%+%@tHGgmv*A!p`)YX zQESQD9yq2dl~hJic^%y-+FjZK6a<11vZpWzs1$%DxG~mTf5UE1KKTx_t&e;vkA?#v zsbO|I{p*RX!4)tg{4t?i+rgD92`uKtbq|-&QzapFXT>8H3jz%`VL>0yPg)J~w6VlW zInJlD`d5~h<&3X_rYrM^$pi^v1TtTfIDM@Lg2jXoJs=NhSiYdY+)*?L&4)I&CPi)x zt_C81P(HN!Gzo?5c}J(+hn32lcRno9QBj1PigwFd^)?JsnsA4(z~&e;Ra+Z>+m4bA33RPMUhrO=!bleERZA5|(;U>W_PvfKms zl+WWkrSRtKT=pi1TfE8nAlH3;@4fCq2sslI6T?I4iD+qQ$vNtUhm%JW3zUi@Bcm~a z?V0T}1MgET9z`sd-fL*y*u@`u?%DSI6bSAJrD%2>5<+Vt}fB7vxopyIVLsTO0eeosASvCcG)PK!H%fQ6fPje7-QA6JcY}1dbT>%DzYnPQ zuDiaUMXYreXYYOXe&2a!o_S`b;nDODG5Lp*r2t5dp^5oQ%;#10XDQcw&i;|rXpb=R z=dnL+sUY+#K=Tk>7?kj`7D%Cnq5OB@NH-i}(4m!<;lvj(T;kOKKG;kCv~l=5FV?!u zpKb!m%3#2kxQL8!iJ+d3oFpU^Jm>}B>owS%GLvgo0~i?S2NQV3+1k#4(bCYs^aG=* zl#9+mQI)3mZe|My)R}QMeVbzNqix zTjA;CjDk>c^s$V;qJErx@tU*NX0)H3i7p0W&0t+GYGzbw?6LJI$!#ZgXE%P=HFd{Y z|4_STZzD-|(jm1((Q)Xp?zh)tYU_WsQCk!K^@vCLff(UG)ox7SI8<>k#D5#D>5GCv z_s<}~LH%oOhg0rdu6@7dmHN=|2=>d*#p$Z$u~FR|7_s7Qun1$#_lczae9MOWp_Dk3 z^LZc@lQ`5@i!>>;gc3vYj*NPtsy>RVl|n)(OlYYzE1Gb))~db_-j^^a%Rk8WCyif46}E+q9|g3YoO`b*zrleA%y z6M>1x!(%yUMd0(w1`pvr(uPglV8cys0B z+Lxpkr=jICy;!qv*maY!RekT#m&Pbskh6``E6W*Eyov+kT`QQ5f4K=vv?w4r9YMBy zMA%|f60OO0j2M$QO}BBotv#fVK`>^Q-CdG(ra7l*W?U+$FCFS?CvD2^&|J8w>$7a; zx-^d2kT&l#_AexNTU5Bf#$LzBCkl65Ey!7Xd-mxbtFN14@jz2l)ranNW|W84w`e7@ zT9PpF*=k=?A>(2_@>G_D;%z1Eh%XkRyhK@x`pK_xw+I9N6oB&6cspRrnm(u(P5u6~ zv#aJ6Ma0L~bn0`De*P-v5=ExvT4yDG<~EWp8qX|pb1s?F_QC%lN>;4wx0_-2<6X#4 zpI*bAZ;z8LSBeZ8SE-K*(CC-7Wmda{587udATHy_NV5Q3sp>8 zU{+hvdO5OtEazlXY*^$tcTh)FCrpoV*f%;=Sg%=4_yc;IQ%5S*5uVg=l%B zsjMv~@V7uWG8-RTj0!ehBd>~1z4Y4ENO)(j;_<1yz;#3rcVAWJG-lmwHr<{TCd2%T z#~;yAlGp;%>h7|M#EloiyOIpd(|gytl^v=n>`R6C+SI`&%eJ>R6l@MEy;F%LVbSm~ zct>LGxgTITfc2q_z450_N*bwmrG7U7t&Au5Ty(hzj8724*T6E& zs!smYKX#Of5Q?kbrCaEyfEcqVw?ju@^?=L4S^<*a*EDIaRG#~jJ!pxS!OxpT3ey|E zG_9A)KHP&)fTuqHl^zx3pDsI*V#L$X{ba~E27fvWdGoyNEeoVX=|K3~*bA-&0RQ>7_|s91C*(srinRf?$%PuwNj)$U^(|l0`XEf>q9E^>hbTsHE)W+RZrg zg%@Ar=6U>tjQ>77oSnKz1as6{e6AaAu*2f*Lb!Hs%E(0qJE+@~12Gun3q>d>H;Puq zI+xAfAQd0pY3E_Pi?P-b;a75^4)04nnEG^nftp6OzJzV%$AvI(U_OAN3PReFa&bIV ztUgdZZQ-)dZ=}fP)g9UXb{;nM5Df zq9T_BL|L9X!WTrY>9O|(J$(&eBDg^3uGi?T@cm)lnGN`;F2|eAE`?~HTQUsZhb|RS zZ%A&WCNf?=QXst<@8hziF+HGq zA(lo_tY`btMtM35JytuGb^I$Nt9iUwZnBSx&3_ z<2tq~n0Z7yQL<6J8d006vu&2ciq)1)33gXk6IqQzf}B(GCmM+bRwU|pYVJ)eYhIpP zVV(U=SyiM6VI0Fa1`Xx;l@)p-Ko@TB^p~H#SulQXlchpYL1xeL6!MbNfFup6xcQ51&++ zX);L2D&|jm6M!+Nt;i$$N-`0s{!w=qas{q11z0vaa6~k|{O%BLrs?I6Vh8lW+G88? z+}uCdfiads`<3rt#UP_0n-XOkd zAxigi@7V1fwAqx6330g@RM(=xV*?~yBuxYh+H35ds?J9y@E2 z8vDZHs4qUwN$pYPW2nj|Nh05~@Pd9X|}P#-A+dc>J}Awaua z2I|+UZTlC&e-2m&85pEtT;=G$mffGx!E0TTn9^VV_1RjP;PhVn@4w(s$;8RQ{_Rzu z7Y7&t#K`JwMhSS`McgvPZO>KMb=SFd;mf2vPTP09usl)_==y1LS6^gl%v{{;sy_G# z=@K)&g(`Yi;(51PU0B9%xdvOrzPLkIvjLe&wA~#(+1V>ROY0c$9i@~OH>Xf|MQ5d~l~ zXRBo(3lriUWMzs73%BfYa%c&>v^b<#LlU*#DR3e=sK|(BnL@JbjL*x*=TotLg!H4I zVv@h*J~_IN8ktZ8$CIFf7@pp!SevXH9+ltlDm{XbX@LuB3pFntbB9l7D7*4xrk8ez zrO%k#hK<9QVq_5RA(Kc_I^Eqn8-)u@xfRu`gzo=cDyb8;rTwj=$6VGx2d$2!=poDN zrdMui<%Z^6$WG$-p9dWt^^ZHLD5cI0G%wX7rfz(UPMVfla3@M*uDGdL7*0!fAbW&R zVs$lt^FGv$&4q-;wn$gec$ShiW)E(E%PNYY^qPabv1DU3_uF7+*$Zn#E(#5_sHjnP zR-8X+^AS%(s>yb^=wlRBT^hSLzO$z0p`JH1A6##pT_RxH8Ow1VPDlf%gdlw@=d>w_ zr5KwtWG3&W*20C2AX{O@MNKbf6|z=eqsEZ5$dm8>P8@A%b!vDJd$+@b;%41$^^2e{ z#qpiF_RpV*_wB=0Kfm2fU$u_L?XJ5HBeP@UQM%#g)b9S+)rJ3-EB7BeEHzPb%fW0 zzr;0#&)e!nM%4@#>4&|^vQG%7h@4FlvKsTM;JImKDPCGIczdCR@WQ{B!;3GkUK(3h zET-*ySkm3iwUFAG*7s~0?{>gPR?D`e?uLGS8Cr;yA~@Gy6H@YpiQ%;+?In}xE!o?I zosiZmTq+CoH|C3`z8aBIzkZ!3vc2EgYk3%Lgk7U?ebL5ebmk|#EPmJaWgwnE^c_Rh zqtXN17&iAM?|$lUAjT1~JT(eqt}+o_yk6tmXwN$6fwjStQn)WY6G~fay^v(99JdBN z)~ieWl3o7-SHHSnj9C63TX55wm|x2QHqMnV%q#angfX=;V3U1=;LNYH`700(6% zl0ZS9uFaKL(Ze2iHr<~Bv)?!z$-dr==in;eU0+-@OCiE6?tGXWdg#bH=-}s3yY54e zCxk$ToQA0z>EwqEn&8g|T)#*d_L*08zi(`Gdz)a5LVM@-qT{h-r4haY78S*6%Y%K{ z9#SCSeFH`7L&8|U|3-$>SQp;vxUc7dlo&Q!9vHp1I<%-fT-@`9x+hQ-q8dr`?1 zqfU*EqkWT#`$`z8SR*BSr;?ZFV{2>e1r4TJd_`u)PMWxp)r_R{sb?3N7ey%Gd#!b{ z(78K{*e^!Kl0eJjwbS(a5KEKEo!s4tFf3Ps%h(7dgbU+3Vb%zmcR;5{c)d?~t zmt(h=wke_W39tVwlNHo9!KN$8Llk3lw5)fH%s`*b)zx9)Sw<4{DGFPkhJRTNQS1da z@>I)4cgmtUd(dI(%}mSZX+ox?{r=TbN`Ba+V+-SIXTElhr336B|`cD3KY zl4r75Q3YJc-KMO^;^~e}XdDa}^%VIVAFgIr#`(1(POhZsgyRjldp!FaOk z{2<`#8#f6pD>-lKJ!uZWX1uswVUEGfcn*sZQ^2%%e2s$$|F!L7L2HGVc&|r={Kr`& zW^w@>J?LQ^-hb^$4FD`5Cj}-8{_RGC!vjS7|8tbj-Vvxce*-r#Sl~p6QEja6QT`zk zF5qGI!4?g7ani7nR9)ikp0mepnMR^7+GlK@KdPjEU*YH`FcV2I212Qq z|IS!O3gGDeS15=-J$JxhTf2NY|I!@9OioZgd{~@>AiV$GP`xxgVMuIhxcn~hP(GgOp!^jTdN%$SP(wp+8K|2FLf)CH>Egs1UC-Z{yqeZdXh@HIdJs{1 zC0ZO_x9j|u6_;CE?M+dP$KZiJoAgk?+ufrRYpfTMzM@O@gn@ONAi}&L0d5ka|L0ps zn}Z8$gw?Z#o=G{Ta?(&d1i;Zn5>+`LOW}uxBscsX5uwr7_0#LYxfE*7m$>zduLlhe ze4~{`6E)L&UmUPXPE)Au>5~;l5r&JiRJ~EY+RXcj-Ah3f25VE+ov82TmX_lWtoS4) z-`*Edn@T794;!rMOF(`MS1P7_A&Yjl{=G6E8)s~;vKdvQ55cYo|8T&$$A6mp_ECJ9 zGwifzuLrmr4;i&r9&11R+Cg;E4j(?RP*Qikwjq;Tkz~{}wpFHxdxh++rOB5Vw&HBK zU8R2#YX$Kh*FGtdr9=1eh7ZI!s&i&1XSCNp+#DIOQV6XIml1etKY2X+7)GznIjrQ5W$ok81g6a~tdKO-=)Wx_HbK~wh^qm?vr=8)S zYx{z-LZhkuxQG~%{?YdvW0STGsSaLQ!nBL%tvHUu$Zsj*KD%im*j_mgUItY7@WnP2 zvfkHyy1F{>(@hw@iC&o7{B~k794mYuSebW~OcOBOhn$(%#(I5xvJ=f-Q`GsBxY6rG zfp7AOb@>qTps&ApC4eYvo;Z~o9LzKQoAKcj!{g(bZi6pr{0*2^g9=9QQSkt!LRbe8 z(hKzIZ*b>EX+df(4k#cyGX!;KG+4J!hS6woli?slh*3Z{?Dnv+lb$zX?!1}u+w%TM z(KQD_q}|aE;7hDZDlYgF#ibKvV;g^3hLB#*3f2==eSKqQ8=L@G8TnAK8^AQ-AcNQ8 zq?OjOmFBL9jk`16r87ztHo6MY4E!*F_3Qzrbg@L&v*sBb9BUj=q0x@Xok2Is-;T?BC(d0DMApf1`2=n{rjs z?U6Wx5;EM67Ogx*Xxc%20Fybw!9(XQT2KO8;c6+>|zB_m%9dev#Sg}&Z8BhI{e z!lR~&gi1z(z@e62UJ{2BU9b92>DAC{cCYzW);28;l=TV{?Bm$G7!x>9 zNb>dluj?Dz&aN|ud{_LU9EX^6^7cgJYFuf;iMn9ESR6kw2dSOBx+~2vZL3?=_HS}K z9)z=piN1lk?oN6CMoBUeL#PShzEpejuUGa6FJPn>D}&+vZn6#aO{?`6c5&E+eR8A{@xMrb)&!CF3ve8hIG&oq_>}Mfu&@tlPL(C#Z zr8gb)Ofm2{+*S~<|u<0towk?OFuuc$DZzpmmd zRujnf1gNAR?u@-ol0J5K-0Ikqu*th`>N6#z&gW%`iP#}fpHT24)h=ZDAgUNn_-zvI zcm9ulsU|XPV#fkTw!mN@suDeuU9r!Rg2QR<6bYqVi_Q({LF;M z*Vt7I&=2d#K=Hx3`ASIh@8bYR@Lod1 zusYt2Yhv3RUZgZNr=Ki-@nJ!TUIsVlzXs3=!x^y3nIWYnaiI7>7Z}Gr{N!!YtY)3* zz7%3mn)RA6eUV+2Ib@lLHc5oU%-Yi7wb2+wewm=JUGAd&-hS4VNc&Uyl7+Iqgm=(| zi2%pb>Ah1x2SsLE8hx0`U1^K&=y7(nF5;6olTFMsarg54Eor#3I!mMF`Fmb6WxH2H z3)qj&PU8KoIb%8-+Y*lFl~=YjF=k3K$I>b`*{(&S>eic3V#5`LNY6e(5HGj^E8)pY zd9nIypsm@kC>?ZtDjmdMUr%Q02zd-Sp1&G1>NSG@;XfziR9ue)F^YIIkq^oMVJK#J z#dqqDqs5dAPyz8nwtgyqUl@g=`1ryw15U{0?+x|-3AkDY@4v$T`4=z&2oh^1l^Nxy246F3O*#3Q11x!C*?_T)kV=MJiV$9eX~&E{@bA`Gan( zvmDnrpJUPRmU+)<$@#5y{vNUIrGwB{C(t>V5FwggQB9rKH6id}vI!sIbZ7hFBf%I3 z(O?fP^Kpjl3*Bl$Q@%0R<|0n{j+FQHyC5poVQ{txAHz7T9q2~!V}TB^<8h!&B`~ag zpEgW_*6+hV4ds^PXkO}-86?dk%pa7xu{E?ebdri_tc5C{?7P=)PRxiapoyU>^MnWJCUFWr`Bej1>u1~`r*zX+; z4dH-JX}A`XjwMn!x1!YcCR74BOYUnNu339w(#!^pM_A~ta>SSShoR058jgk$0#7#9w_D0B}YMc)l0|J-X4Z8Cx3(H%yPKO_7A|Hp~@H1xf zogUM&pe{{ctvwry?Qni9NIxCK%A0#ZN`?pWdrqs1Tj6GIPLI$s>!&@tuvuvDWpq85 zP~ff=`l+rYKi2!8{G0Gm)?~)Ru18nAuK`1c*wI35y@K#A>JC^zZWv><{&wCPjP+q) zjoD|l%U|XxMZEn|!k5&X@}`EVW4v>c6kHN`A#WV|Lg+Yv3{m2j5W2GqpFFQcM=YJr z!gmeKnFYBnc$$=P_?MFk+6>-}wP{&$7nxr9p0|$BfcV8O!D?voX}cq(Vx&3+q&y*WjkGOuo=nke8;Vu zKc&w})T7vP)eDQVaj@90BeCI|_H7L%$swSHw7-ko!^+_jM=LU(>3J>iz2Msow&_JV z?`sDIlQO4bDu?z1G0ET2JaYwt=I(~JQaXh4wK-UNS6o>Zb2)9`!w7Gl{1!#&)HAtcf$}<;UtW9UNjpEOyJ2l@g3q#SC3l;$FZRuXOBkpJm_lY{#I&)B-voliROu zmv;k-8{;1?G#RRBGF7vv%xkoQ(U;oCi`#LRb(ak>28qlBM0Bd&t8mW?7yvUY08jo7 z)rMW}s%&ZA(XlA?;`F=MrMPvm`~gOIhU8%-@p4I`N;ntY(IuU&DpGCaa*5R_;jrp% zvcyu-`C$?HB6r4DcOhe~*C;BUXmf?D42>Gsdb04GhUXe$(B+N2uO$jCx|vJ#BrV)8 zTmd~T@j3VLu&x+sD8uioPQ=x^j4gHs)^ENU&s&TQ;t(4CU7F;E6)A5odq3|5_^!=~ z@%DlprGKC_S)G9Q@A6)msNq#c`E1pQC0u?@>I;h5AQDb7-ZYZgUUNzj!$*087hN>! z83tye#T}zC4hyO$H@}nXa!z{6f`-~FVt-VgeyDRf$>mXxTv2%7)29`!R;aod5=*f_ zEIV9UwQdX*mHjbOjoFY&Bv3luWLR1<9zuc;r_;@Xj8A4Lp-lI|pSN$sW;@Om&T3iZNKqAZh7B z4mE$=9U=4x&?`U8{Oz9-Sl-RzwDaT(D$PDk3?SO2D|)=ctvuc})yCq{19eA(mE(rG7;Kvy-eD8y>33#Gsa-lK#UV5AKyu(3QD;ywLzROs>%c|1Tk(h6ivH_32YRT$P#a21k4Pg(hbGaQ_(Tjdx0U z(|CY0*K~rXZize!K0x8Edzc$SVSM=UGnM|wQ2H;)5j>0CZ2N+CO1LNxQx1pJoJ&1I zAH~2S^g&q&HRORfI1wUyire!z@hN9pDmwV=ea~BAO5Sf-FadGC;b2xDYz+3K1Nc;H zr`hdukLEH2C`K-KETxN*BP{WRKsQGMi0~M6pj+hOxcN?P*|FG`P8`L=$P3Wtg_Z$Q>W}UixAhRiK68 za7-FBk}3z5W4m&>f9^Npk2QQV8EcY}*+~q*Q;+DCYM@vQ8vsx-BjXg7k_H5puHTYx z14&+)24~DAid7}8i5sP_EAc&C6S*tUA`i=oXBM&hN+~pW$sO0+Ce`Ckhrx0LHGHDV zmOaW!n_|;XQL!52T$~9LOT&8^X8_v#raSaj@;0_NHlG|1_r82WSz5rhmgaN&(2OC8 z;5R2#>JT8UsT3FgrWCL? z7U1?~9-3h^TqBkb@Mp=yo2$ByHhX;pEZ#&CsDt%>35g0+(?%)57EWe8oe`0M9aeWC znWRoHD@ZB-VijM>0$;2J;c6>@8?Yxj2cMpEr?&ZS{+I2|sj>sd#z^-%Mtkt!T2!$RZd~+25#C@!w(O3B%AMpfKSPE(I9~-|Gc3OBHAsUBwUZIq z!!*wC@C|m1@BC77oOA3LqTWwdq|nA&Vl}J?uYPT{=3t(yirg2_Flzj2FE8om5#pAd z$eGc>8+@gXXHC3T7IN!jlERd zD=gtSN_zgBC79KL&e4p(S%dJbL~7S!$n;n2W(9*extFbeKGzorZ|c`Tgi=ODFe}ol z8)>TQNB9tqQN9sRy~2+8)(AmoH?X4WF=_=C8=5MrAzIF0gH2@To zE{U$a7Q#Ec3$6Ofw(ZZXJ0kiDyhR-nn1Jrx_j)7~kPR(nNc)vW?RhW2SYozt#dMvg z9y+tgATK_M^)o8f40v#Wu#3dj4NrRQB2CLf&|P`KPnDKvpNu3oNC&|dl1%c%?JHIB zX$oOVQ`=aSwnw>43d8p^Y>Hdk;P)LUkNrc*IX@PInh~}FQkH9f5|aiX`+U~oo>1j6 zUMF@FN(7!WFWUAf|H=~H@&=3v08ELP7}xLX{0R&e5wYEEBn+o*UdeR4?h=cVI{1bS zwi)BAo!Za0ZTY6IboJtZ+g`e2_mC$xD_lUQ{h0@+F#6Tspe3|NGc%&^*om{(X%U@m zrRF?rfF31NHWz3-s?r>bA=6@R8x~g z%5fF@^jdx&#?mr6Eqq187^n5GexCp}_vu_{x5U(9Xi7&My~k}l2$?&gq?s>uY?hPR zY!vkz0MDRqm@dfqH_T0o%BxLuf>m{6JX0wAdrA%|sfWp#WUx})PazQOIP)AR1afgV zB!uCjk@AltF~hfh0}pO{glDg9_)600jWranE{R&QN&|JJARQj_jMkuPPS;cXBTb_6EA}ECNSo zXgo|YgF)d%>p41rH23;EhOfqA$$LU*cYVj>tK9!@Ae)y_BBqpQ<#`x)cQ*kCnQA*B zFou)`#J3oK#=`*X_A_QwA1482W+5bU;dBNa!US^@fwQoQu zpifu}>yJ{Pycje4(JpH10wQ;R{EDsMgaHxv(82)WknW<0|0#ovhk%v~mYnc9K`Ax# zG?UfEY#AxYhf zP|>nGRLq}?Bn>3KcsZi;W!GCW$om>7lTp8L#^C)6S4zPUGa$I?6WL5y`wv_Z4JuUL z!gRSd467&DK6Cfy??KmADs*G;#Dy>b*fG5Q6b;b`FjG>k42vQ=if~FBa1141Cgt+I zf~93BF{>s=vRg-pcJJ((g1aV|$h{k!7Z^ELi2v9mdD=YWV}y(RJ@jXPZdWM0qp9D4 zn0ueFsOcIZE~!iz#!My{&DL4>` z7bgN>gKm{{HtyD?&T#$de#5xz>c~R^SK}RvxGX-oa-UK5g|8%^as?1J)i(!BL&hLI5%3p*rndcg4-6A8QMc1s!i|2HVvxSv*|+m21wzj+efba-@Ov>jca)h z0v6G?C;8=(2{t^w8R2a~rFXrsn9SfKCs*aR6+lNn=YH6nv<3J!%S&ko03EwK;L9kr5NddI)N@a!@ z#n@ZpuYKueC^qPMHXM8_ovNA#sC<<8v>K+EjNFDHL!p6P%1=BD<@N?ho@iF)A4l8q z3iG~Qhlndq7RG)&ld5B1y!P95%0?E=sISU$7o8wmjlQ(zArTZtQQzT8pn7l2v@dEgNOv4Y6HbR zqc6c)H*3x1kKgW2b|W-phuD7C_aSL!Px~*W5w~bDTkW?|M{Da_sC^}U@!=^1T4Oly zbvLrMP$lj3z$~?1jyF%S{1PYxL_)gxphyVEwt=HR{nNMZdU&>Ts*bK&cL&XGE@n-` z9k#v0cnhB~YOML{u_4)YDq@h91h9KAy)B^Oxve_e$B!3+7m(y)7*dMcH;k{dhwqVv170t(0vCwGei;Fs1sHo?`ZepZB zzLYct&*6>p!+_=fL?0g_5CVm42~V-%C#F4xK(!WEr5O9dRYEE-H=m$3PT^?@Ad7l!d-!lZD z0W@7(*1sVlINT5&>H@gjRj>ae(BS}q(oiPkENJn9)^9FA+XcD&G8OgfTVnqxK5`F* z`ZsxL@8jw4((ww)?X8@`7-}Smoqkcjbx`3W6vsXcT_V)9=T9#9_3^>R>=JF?oeM9n zG!amg6~_gl)u^cr+&6@EQ3QMzep}YX?knfuPYf|7HF4?Tf3Hyi3DIFV#1yar}e1U{JyC%aU#U(V$aW^+6W8C z6?M&_Ldf~~&JhTDpkv=#=xSVZwZ-q5Hx(Tr!#Ub;q_sV2p5QyBM9&z5*A;< zw7VFTpmu4@mR?kqsKsy>k6|hwV|b(?{Zj2jHd*5dAG$H|BQiAIe6sh`VBtlm9&_ve zka8|_-QQU8MEx^^kEtgum%1x$t>J#^D=_ab=wc+36x-4eJ>G@#=41MAg&O6~-LDCD z`fP-5X!$&m^GtA1?bx(?C=j8$Du2A{8n~~ zkep2cH&>Fjk-1lgWmJC7CM#Xi$siqS=|n!x(Tu5C z?MRcS3OoNOv&v}calPR>+-=)o*KL-wBwzIBAkYRvm(TBeOb-4FLM(+Wl6l}H$Ql8m zwIs2HEEI0g!S`FHI>&X*!~VfE7CbnBCWiVrKH?)H8NkQn{i z8+{c~4u@0($=RfdMk_e-H@Y9N$dde=U#%)-JX36uD7Ue>W-1jo^sySzqqGW9P3o?A zO@6saR$+&wM=SxGC{<}Br5c%xPEOW}&{d#is&@)vknYru4(hq4hTX0^TW+&zrpC+@ z4V^T~riuFd$~_gA%rV827jY^ElJA@x1An_TD;z1EZf4Pbq~BSRAKw0nBVWQN&mB3l z2#Qiz&$##7B?(rQHeADnqEu#s^tFzDCMf^X)KOyCJ9X$~=ngeA%jYN&1IZvl5e2+@!AM z%$@b_6p3jL0j7|Wj%JtW!B(ef#!qHM2^8*I3w{z0C;XV}axRbjn<7tEflZ)@{aJC% z;Q#sC1`u@2tz9c!OimS*#b$X#r5N)KUd%-G_g|K2+}%ku$CQ76(~eplUK2pdZcO#I z20r#cMQ)#boJ)fL@IQo_b?6hJ=Cb!v(>VEwMk7mPE`uL}8{Z^jbb_X|Q!w4j9l+j= zE1S@k9U7cXNRZt8m^wPC8mP|GzT;c+^bAShq|Dj$xkM!>wa#}CO<66mWE}AW%U)4& zC2&6);=jP7m0!U=se*z7UtcE4>*4KRE;7vPHt8#<->vak;?41KnH?X!pmmwe#R@H6VswmrX@PK#xs|G^WYmTZ0s6kY#Z zJFSg$JB)7|I%SGD(TE4>R3KnkPiPg!p`!m#bxw_0xaZHu%`v1YpIQLiD4G1zCghVd zN8~T)DUMIvnsAT|C&Al6tVk)S8ZUruTb^V-Wt|lM@XWT8d7C<(=oLMiRscRI;#!4^ z(xjMlzx|W?jJyE2GHre#%zunq8;EgJYbMalCBUR`*X=2%0)t54Psx>*aTo^r9*Fz$ z<3ZfNkptq9n4uzeX0m555A>Uf4AStY07|>kE8#?yAYP{l{R?Iy^>2Slc|4$Q>U@B~ zYiX#U5Baos#b6D%jDFg(VJk<`KCWgXU%#vquW`h19gxN}ICDOcGn_3Z%0JEzkGO~g zWk#?$D|Y=(A<0K<=MXC|N7+)IG)E>|Xl_LI5V$IGPnyMqiL;L@;R{x$ zPpPAfu`R;ss`9DCbL*wC&GA*yXp@@Ca@jgCQgTkk(7E>0UxOWK(nBVUnpI1c?b0z> z7_1UJ<*8A5-Dq~0)YQg_Y`5I&K=yRRHjKHSf)q0Su#s}_i$z0v->h!&=<{GS{vkL6 zCLpyqC4KDjL?8J{AQr~*B8u-xUxZd3k{}QPZhBL@_e=^(p}prEldmmM31eqa#9j=! zturP-Hu`=jTsjrQVR%~C6D#m>DVMI{f;7^~$Urbd|IY*6jsWyFem}z>zks)AUQ21+ zBBPIw*E|e5%Gar2d>A>A-N2oE>2VIz*6XV;FqJqJvM5>Jr^L-IS6qooEh3{@1tZU( zO*X`%rIkibwIk5+w+0Md9N;ErOZxAT`b7l!68Q2Ztzb9UBD|z%A{XB}Ze^8K;?7V_ zC8{tlNX`}11dt8PZ0w_dIZP-E6fQ}fofH4XxL0C2@i#3#iU#13+EBOil83k?b?Ewg zRr&kTmw;WoOHJypB6BQTsx(sv^o+)b1Xj7EwH6wPulJ1AwRXz;_gnY zagCI$I&rr*h)WNxE@I0=*hS@A|4t@v*~iTs$Gf>G3*O(WbZC7RyhXwgYO=2lO7lyW8?qffEum{ZhyCER$mD!E*FTQaiil5#3uhI$$&qf$H)p9YA;Yy z5up)DN=qmd$*Q7HX8Lzy>7Qr%P_2m)Ck7hdt@{iA+f|BUdUi-r2L+r8=hQbu2Tqkz zD^~*Ke;#g}1cc3r)W#1%>c1~oKtwk9u*@#J=KZ_NfbGg8$He>R9sucs5I``4iUM~q zSqjWLuH|n{{Lh*Lzxv6HhkbKg%lWC`M!<4bcSJJ?(I-@Z~eUC~mZ0&r@;Krd zPmC((->o?MC1zQOMg_`m1Qf)o=wH)4Ny-e5u%CnkU{D%xfHA>`i#^nBocMlU#Qck% zU0LfIRIbMo8v4E03aO2-Scdv!%30lFPkQeg#az~rUi^;Si@8|lU!^*p3{>LnlKzvj z)#6Be7}oEws#PTKLM6x!vu)fo$&ogBz%Xtf2kvt5q-7}fueU^(jRp{OVEg&5wbn$S zXCbK|S!vthz~OT#Gw?B~W%?Wz<3B0&x`?*9kW;BcGh}CeSzYjB>Yy?B2n7@mLse8# z?@yY)fimOou&qSKk0y2wTFqgIeEFG7-8A9*A_e+x% zv~(G=tdGH4V|v_^J?lx|HMrMbVd`+G zrk=7}!(0bRY^a16=^Kz+GsbUI1xGmkDk4zF@Or4SvXeUP)nWX0qx#BFbVa1`rUH+( zpdRgQxbZxT4IpKeH0XT!6B|1!sN6HF7zvbBHrzNf8btmf;7; zLTEfF{w0>54e8+6<+JugL1WF(ZMLRyx|lsW=scU@zfCEqgb=<0DRz_o7O9wBvr5+V z?D}S4_~QxCyFisHIfc!Q#hbO1In!Ok@NrePE@##2K{8&2SJ#}Zg6M{z{dWY3dUx;p z6rH^42!N_r5+ka;dg*U{sD@st-NCphr(QID`1AYOyY(V58K%VJVc|+H@zugzQ&k@{ zWz-ACa`<`l3>R%q!>JNX>E=UWO^flk@C^`O@3OL$#Q;Aoh<6@__9+qCozV3S0IOgS zoD^Yg{G%v_xT?*QoU8Ad?s&4vgA?~M1g*csy9=bOyYc+lmRf7N!Q zyU*>^{6;29(a5f3)+hF6)nbyQJfD^8J^lKuUHOwwT%JveXHqQ}U8fFIAsPv^t6%-N z8Typ}Fgi0BBIE`tI}FWp2FebVajVrm_C!4KbsWd}q&48QT>3rn>l%Bg65dVt1T>l< znKt}YECd58wsP(0d5);q*JWW#RT%4*qbUehvyG5W(-VV0>7kKcrkeE}S9OiJ&UX+9 zi0x{urmL=F7tC+)y`PFWBnanl>b0^f$VPDT10?6-CZz!l#~&Q+`nRiIPKV^#*vSn#9&RW}kl#I*K2_H8 zs|t>pkxax`tfQbZnR5zE&L-0_x4@QsIoiL3Oq77w6Fua;fDT-2)>;c)$2$v5hIfUb zH%;(FEm1P5tD+S}ADc|cJ+7`8j)IZKPzSmq#KOLNUGe08@>8?ss-c$6*)FD>q%^Md zb(AE+HEao0b17Jmnq_%SB%x^>Nn7$J^lg~86xFHgXnbZXc1+M$W4Ef1aAh+0zRD1^ zs3OQ%>C6A5td;Hi*xJVCvx>`~lh(xgkw3EYR7A(#M^f^4mR-r>Xxm&O9Aq^U&E8es z)6OTq(Uh7%N46(%oVNcuwSGRzL{D`aRMIRHhr&GHn*KCi7}1|UN58uRm0Gvy7t@ED zHR&zSotKIRmK|m3F_Eoz)4yhzQthp(IPnvsyC%}Ndkdsu`qlgYW~<{3r!p-LVQWj% z){&7g2#x<{t6$H(MUxL=Yjas)P_3fDasy*U)nhb1S-t$$L!;=3@m%lt4J}R!Qp8AG z{Go3%$21<%*985nco=GXAfh{23f$3v_w3>mF9#`ww%{O>0e)52Yx@MFK=GXFKB5Uk z*&73(0n{@f!vT1!r|`#r1%nMD@b1$FD4VGNh`ZBtK^pczvN@0#W+Zu zg2*f3KFbv>N5Htbj@kwx zRe7R-`-!A?j#{S*+r6R;P7#}FJ)VfWpb$x=UD!*k8@DzbB?S2iLCoH5xPY2l%qp3g z0&FzrJAY_}6(Haq-RBV;G3$SKHs}(kC$TFG9->^5=Vq#0T&8Y4<}vhXR?UTM;McPg zXG8hRDs2RY#Lpjv#19H#ItE2FKrNdzsxu>^QW*DIFH5Rg{4t^tH{Xg+d@|N!M7vXS zU5R&0asm(2EUN;cZVg{c+*5J4mJzj7aeif+pPI#HRQAqAG=SdrSV``Y=}V*G!ni-_ zkYB?$4!-sl4dL?2c!fo_y)g>QJ*A1dcqc$uWs+5Ouq$!4F?i9ExoQvMb5JmS{5sAQ z87TW@q8o8J*qtBKep!#g-uNbxo8W>dwH$xIW(jgBDxkqL$Pk5qMBN)xD566JnM=v% zlW{*?=!CrbkA?s|>do~$XA~(@va)2gJ7epa_(vZ8G8BngkjOx3uHJbWz z#L6g1RJ-jKH5w7}Ei#!zroNq&LNq}7WDMcD;`?Er>_&~KDc20uM~TmKVG^mjrse0m zY_>E7h?f!!LCWaf9ayWUOsR5QgsT49uKb>8FF+7k@5)1g{=_hP_YsHn@qZ{30e zELKg*#{A|A#2S!pc0RM`KUgyXS~p=XmFiWlS2%>>%zuZ(Gm(@JC_p!RTopOi~$>?!?>_!d`P zF!E7B9X~QwbX#3ClG5!gp)byIFp`JD9^vCt*f@g*FzA&9pa3Sk^T$TK121D@^9Up6 z(ABPP3XOT@xN@f3F6MmEZ2#BPMvSbxO*b?9sa`IH4JM@ffAmGQG+ ztr6kyXP-&>wJgQBsiro8@uRLtgZ&# z+AdYF?0{NT2DgcB3tB8jA~C>fx1wu5wMESskY2rgXUU%V`}|47j%y(NR4dcV3lhv; zr-Zo+b7}WGe%!~|`gA_FBgd^ck33O=y%gHc5ZZE%m!UX(cT~OmJ5jkj^J-8On=+Ta zu99z?Wxoi>Jam|DSH<){WcI=bD0d3?O@z3B>sfIw$gT1n=3CVMNo%jwtz`iDhj(Sq zO02E3pgYA}57R)5O{8aHNscil=BsAsCxd0m9`i{kFo_B?cY@Mq^ zwp(sfjV1Q$uk7R|w_B3%xgR*Y#z}k^lF~;2QTu#x+6<6~aQJZ&!cO6z>r%9qg4EmA z-P1j``_cEZrhwTFO9zWGbNuY*F2X%vMT*d}Wku$W*SkXIh3CjnLXdf=9H-y-T$?tT z)*Gj8PEHqnRboq`LISyCi;-?knakDvQa}!zu&C=SK0gffYSSGPB?!kE!B|NYQuYzEpN(% zQSC27Na@6|OyI6&_xkM6Rt@>RFrA%aLIhaZL|L=)YC+v9JEEB;kRan_#}CZR>pyc0 zF+n3_G6jjPIMWa@Z^b@0({Va!$rCXSFWBAYiy60tfZ%Mi8XdD5#1iEm;vY46R6hBZ zijsP23@Q09V7vZBh&Qr&YP3}MvH0{rss1z8NznrsrpR3>hX^_?@J$g5iXuY^4)VUO zbKsXTZF?&Gr!1Jdg`<}f4Z?$2&cJHMb0!n+(2L#cfu z7RBk3w)5^!RsuXAu*87@y{B6MwU>z>g0mbJ=uJf);(1Gk2E%50z71x@1)B~n`zP}N z&0%BmB!{Dj<<~{TfbVm=#TrtOIi!mt>~l{F04c6012QI~yfzb0;u-<5Rm>l;6?C90 zhCk2)is|G+~>S|#$fnj5BAB{mguVw4k+ z*;-B-zELFg_Cf%s31zHUrj zNiZpQGn-KN=JxO^*TOXvo*;t1KYOnQE_6d+H46MIYqo2Q1w!go7G7nEW;0wtsn zShXJ&6>J8Jy7#;#PYREZAerC`E9RgozPT@o6( zKDoKo;~F4(hl=_JvA5txz8-Vyc)1vTj+$HsnK2?<lU_ixbQ1Hi2l4g}p6sYq! z#;ZWf?R8_mAg55Ip#@hCd1pu+eM`GuDfjQ)@~`w@VWg^!!7Co?6Ue`rVb~ zO(At$&hEom7^~oF0$>r{Kz(4z>44p5N}CaUQ5~gp(QjO&Ey_yVK83v|#|=_{hy_Os zpOF$UW_`*XNI=^h(kE|Z0B?u4-Ign}{c70^R2fa5p%`?uYm=UMyrH4)!%9N|8uiPb zWBSPr1j4I?4|155L811>E~@$~ZeKpEpJMImr6Shjd(JZszDV?|s~z9FJftYBk2Ian z$oy8T6gP(pPRE_09g_BPSZGJIb5t_u(c|a3-RQ?IDF*_i@JZWi7zDH3Z#JH_3 z0)J0!vPY&1xmdXG@$RF27{Xe-w` z(Om;4kOvmZnOBQ@A|tgKHP)-cQ`Ot+s%)95_c~!)<6jhbmi1Vd!#ij1)e!41gUk|K z@D8tD!fXnw!PvtL3NcMAVXyhJHGzrnXyxXi0)h5lEp3!FPLxl_R09GGu##x4M!u^w zlOq@q$SlR&v1Yt-QK8amx7Db(h~A_X3uxiqHZ}nq2{iWkR~~@JT@(8+IJN#75}fK~ zBKT)Xe1pzRyJG)TeRui77*yRuX{PXhI@?CshwuYKLm5ET7RBmlSK>Zn0X5(8J$Cv0 z+s|du!c>Kw2D#D)n5}ys#ygWtNP%zlyMX=##KuVFCM!>|lB#>EDt{yTOu&#pt3()} z5B6yih}rz(N<{KTn>xSDGpGel_T4s>ok}*JCV`W=DpM? zDd1adDHp7g@l3PKm;bTd2d5k&cNaBTiP-ol-$)DbH-PFCG4w^5mufDOgECyS}BmB z9s6GLv5~nwiEW&Ygam0qlQmvib?xs{Gr*;oF5?)lk<;9IK(}}kGlkf6pK31Ar7x=Y zytl8V6q0b-Z2O>wJst>*nAa5EP?RFx+Hh+GWZe>-zPH=T^_?p9lN;a>HiCCoOOBun zvCRB0uBIn{BU00h(0j7vkP=q0Dv`7t%@YsR-^Ragl0h^_dU7@J-7nl4kBgbVoGJ#3 zIoOO~8gCjirzatysoG7H;$a&jSqc)@?d^v3&OxIuWUz~SUdz>Av1-z9_C}U zf1FOs;}rq&kN#wRKk__2zJOn!k^?1`Yt6sT`m1d7Qh-~VM7ACI2rBepB{suiYxk4d&UH$u zZ5ZF^@8H0sz|V*a><IHJh=0L3dNb}Vai~j z(IO>H@8?kz@i=S5wg2m^5j3UFofzp&d$W_fAV~H5hqwGf#EXYn-=6xP8cHFuI6-`dM*k7eey{v7cSqoEad{x8aM#4N}0=u+k=3mytQsU&Zba@so1F)Xm)366& zauUknYs$~gj0my5w|U|vfS2<406HIypvST2$!pI@{sm^SIO>HE$>d-qmd7`g9clV1 zTD!{CrT@itvvwgSw_HEu=_Bo^7c-9Sd)-`u_~{}&sE-!z$6%w7Q@j^<{R#F%5nTuy zW*!MMQIiWSmsQJ{im(s6o(i(|61agKoRdfHFO1`~Qy)V7g=L=K+5kA&!JN3Mf5K6% z(5<`>Gdo2bD4qS1|H}2nK4q6{G}g8~H~#Ja^tCfKmHpA-8Fviw4sCcsR$TO7)lAE2rR!dyZQmfR z82guB>l>W`&92Ul>+-X&N1wHy@aHReY*UU6APXf6$wGb7rFA$7);5jRD9@pnEIe0w z?4uKh`*lR0WinKc$i3LUx~?tUI98ecYg)%K5T0-3u~sEHAB5n2lIdD^(R?{46$j{!Xe7pCvAU)XpDr?2(cP8mN$mj6VZ)UL-wJ$&Gw){TvIEdRU46<1R z;a`EN%TFBc4 z{&+b>o-Ql%F+;0XT<)UuE{_Q;IaP>W;kQi+$Q6e^Gs&0}uU*3=h?))U{IAH^8Owpw zyhiGzmu?tF4m?yPxy1E8&Isp=6#1@8PCVXgO@X*H@pWSQCY5N+I`BL zjdec5689*$^n)l(jg(AlHr>uG@lmQmR6SGkMB0o_nepio%q92 zRg%zYq}4)}SQa74ull04eO<6KE z)@1&XfN+*;$x%Zn;3LBNZs;>7t08$88tEiXc8 z-hiT0#mDs1-yAzRTMY@{+*XF1_OD~N6ULRsZJ4+9g7yrGZ_9W?ybfN{L!J-qYZy3H z;pWGFZ=U{<#Gw4XVc?7*^!>zo^B+3IxCl_cIE^5GxLiU0x5YqW*7UoG)WcDaCgZ

An!Z-y56m?PppYVI&dW`h(n5Q1cOk*3;^Dl@9<*!3^l|60%dLGK!s*uQ-A2s>N z^QS>{x5N`_Uef#Ig$d_*C5a$E&}XWIs_VUYN&K0-QP9(dE`L;nf9(!)Jz_js57OCp4t1+XBCg}SahAQ_ou_}SqbAWW|udNRz)dZ^AsnmC%U|A zmAatjI&d&vj-?CubDCxdQ9LC-j-= zrIKID6^~;|OqH(n@ZZDVz?HA9H^(P8OrVYst>RQAn6Mp1u^{P6OThn}7yEvMb~h>a zJ7LGq|E<+C{YeNSGY@lK>GbcJ|9^R32(G8+4oCoJK1s>rnLo4|`R~k!eYy@1#Wp&X zC_W_{1fLRk0LOiAwwHAz(fp&$l&LeMEu0l?NlvxMGf zG@!QMwAaxjv{dEf>((2D=iRO;K@YLmPD{;R9WO}R!UF1P&;OQ&75UQ(1k@-&qs0+C zZH;dq2{j}T6}11>E7(%B?ZnTkOdJSJCt$lC5<%LKNjyr*_kMuIy^6@D1(fkwAyFZ? z9H~((o9@0ztsw$`R5szUBRDryC`)_z?v!M6?vL^`rcHAxT6qri<&lhj{sT+4sp;=` z^sm&YO+5%rdcz8?)FqR#Y@PL2YjkmgDbldke$56IEi(>jl4)z-MqmG2%bUdT0h)i( zcc^8YNG`5qdftOpk}j+&iq9d}v4Y@-@*myyzj+Y-lmM$>mHP@x{^MNDc#vBR4iOTd zNy#iAYmxa4#^&^%i&_3(#sc7_$b!yCEREp7pbnyV$}}EY(PnXyAPbBL%1>q@yUVNf znAK}J$l+s+EGUYK`bS_o z2Jpescis4Se?YM9Gmt3k%IjQ6DrqT%{LKZ1DDj`*)gb)U=$naU$S1tt@ZOGn5Sg+Q zEW=Ep_x2g32WO05yj>!TwhnCFe9rtWOHBf$SsqpDQ7=<%G^zkfM6L<&=0aKpM3Au2*)2Ix_7 zRnpRGjO8WCerFU5o0w@pJG}~#0Tl<;k7*o(GB_7;fyVFV{C(`*Sl8t0L5P>=aWO-K zdGS~b9x8RcC8=GiCncu8 zJKCDyvsiFDwoTSzh3kfRq^Mae$%~z^q>_MCLmzip#omr@<*mm>Xm%*ih1^oKSSA)< z>*m)5Pg;^?i?hMc8A&s>w<%l|;{`WjGO=F>?pGY_w^W8z=9WP- zNciaPJntBft8Oa6>6uvIA5y~dpbKsz<$jh~*Y`)NN zTcWboTjk$@Q3IrR%?XpnKT+ITDN!;c!As9I3b%IAUll8!X>8sN0R8Q&hP>gC)h1#c z33JvBP9g&UnViPXg6jzM<1s4XhV)Ar`rgv+yg^Qef4vZ%^743IH)%H)I5$he2NQ4MinTRH|?qI(QVex zQg?M=aI@(IHKU{?dkanG*D@~pcyh9GZrvt!JD0Rv`F8R3-?s|xruP_sf3|>xncBcU zAXiB^0?_kl<0F$g1F!QhIS^7gpO{yjosyVHJ6v5QclQBdUSOApO6uA>MGE1jx=k6N z13H(0%iQ+X*^ZR)TJD%{Mw4@NX*Fh*Z+pR#7RZkZ%i#H!8r#Y_8Z9$rxBzBP&Xz?5 z#OGln2pZC+hDri}fp+!%NndG7D-9!ijInc}d* zdaorxrClF?(P}yJ(ch|U=$T9)`gL-aoYl3`cCkIHFUXR${nE7p<=%X+K_)ry5{CVm z&bt+@>GAO4OD#@dMYh{h((mSBx%CAL}?{W9!7x2CcG=*|R z&hN!Mp87h;t9%#QG#tn0;a6V^%+rUlYodYN;zJe-#D>$CE&h(x16vk|A{VLmsK{vz zJ}PqKNRNsf#1)c@UkbkeGPPwA%j*&$O#D;)1Kq(&gduIZd9&pGH^(X>FN88O zUv69FXLYjUW?7#WqaBcp(BY)7lOC{+sA*Pb|8bWgaUQdoin6cIo|S#Yajbr{z8F=?s(mH98W$r2N>~p?fAP<=vwH5uUb`kEZzX4l)k{e&or!!iWH_V_rv}-xmmo!#pp(J>lE+Bq)44IqW{*u4$Bi zDzZ_>!@_CAeKq5q91MR=^(~KMa?iwq`}7qr$AAQLa*G)*8r zg+f|rw0poqsC0q`q7MoI)$ zLgu9CLmA-Vo)9kzJQB$wUz+^wS*Alk#4COjjK-5|ICx*EwtO7_V?=ScaZB<*L-XTN zy|?h8TdHKa8}_{*SZRxd@)T^s*Ezmbthna5V&2Cj%1uiZ(d+5Najkr2_2_!Zf~g4+ zCoJGcdUUaKX2fZol3P_YF{Nj3&qJf0zUsX|WB%^KRO-&fg}?@o03uDt^Ye;eAy=7k zx$GXSgfBs)@g^(on=?_v*)Y2f^N)7`5kR6n4nhp`1t=1}ibqN$sk;h0`IGI=AmS7V zpN8k8Gn-Ac8S*C(g8{wY6R}--^njkO)i&Zt7z{klcUT+j*bb$Vup4U|bF6O?xwK`} zugWnjm%ksNrz_bSIExo$**C=eHK`UcwA3Y%cvR@~{qhSHTar8!CYxmVIp?SJn(;py z30f3s#+$#2RAz~$`5Os`Gik#HheX}f)~BxEF)DZGPl*buXe!4F3v^URil+~NmE(Ry zyJ3Dts+~}!e`-#E{37$^hh>9>Kn2_=ZO3^Pb3GHEK5oy74vcs*S~+gLcx(+cwr^I~ z_p>CWV!^B%y&MoVijx{+b~fA z{!ZT-4|U??pvLA`X?)4b)XChTV%qAYt++R5*(RsP2~N#5XQVS;7fBphF9l21S^<2X zvoKLrlJ3|1pbqNLz5lVdz@!hL4ycWBC9(cKx3!EP{|Yyh0Zj31V2Z=0<{&b+b-dWz zQMudLBewALZz&ZQ?X3XP3eQd&7~aQ|Y2n7kss+xplLvP~Qw^PbJM&+eUA3R-(bF_1 z2qF`Bo5ILICFXW3sq>1hD&cb2EJHNmp?ag<`%;x*z(UwA-pqq)96IAc2w}qjPyvC_ zj?$$RVbhp6C+{~!696bTP(V7R5-I58>-f@nJJ!;gHB_h`TD1YY|MPr8d)P7*+2yGtm0z; zHg}*j`7j1Cr0U67&j!!~y3fl;Ek-i2PBZ}g&45Fn+W{xTLidY&h^!9zAnmA^Cz=a4Yn?!R^G*zB|&!L5^D^6jjUhPDiEY zu=!61GSseuiG3B!9O>*Vr7Su7#RNws?emT}ss;Ui;bVWb&q&Ug`$?_egdagwZLD4E7zS0Lo6QetUO^=Tv!{3u{$)DkyO_}T3+?wQ z%7I@b->i=2sM0X-@GqciY@FeeV)OvAhIU^P$S)PH4PU)$-NnKBRi|jsYsP99o={2a zNEtY00f?bK&w^;;m)AWuoP3MM1L}Og=Ha}`pl-A_VN89*4E!pCG0dEW9YWasE0Nl8 z0upK6G!K5os{CcxFv)BLs5AY-`7eEG3#!3{s)ixh=)!e(8| zT?H;g)-Kdp)0(r=`CHi;{CZ0^GQx%quJdP!gwb_bal$`~?gg<`oGDdQ!ZZ;@{Vt$- zEbUy5qyi`$=ql4zcShTz6*q4mdTZN+M;>kn8V=`Eu?=OZ8{EC@(L%&f(E~!4Ox$S; z_){MI;c4!$=u~5cl!6cgd z^scb4wA&auHT*~c`R|Ox0;_m15m?g5m*pjn0O!L9z_uAaQgP6lttpfy!o!U&10te@ zO{qSowE)v`jI%dkIlGsoavX5Z@MfG${E0YU5^fKm;Lty6$Qsb8hzZ|0N$5Wk?Z;Ox zj|&p%AAn&_uZ)?M$KDHcq^%EDoxS7b_3z!(8wb9{E9AMn7a+|ZxbWkx?n?7e25h~3KIwTpdPCKmJ-iNEF}O&m6G$V#$q`gC$l#M0ehpwG6omo(&{6^$9-tpu9vU*3EALU@Q^HvQv+y~`ctddM z5o<%|qSWxRyT5)-YJc@cNaf-0Y9dTJQbJ#CF5J^VUz*{XPP7MtO@k*16J)v0b&kp_ z>b1_GCkX34P8o7i577(C0N{Z)Z$p{5!EMKse~)Fh_YFwF^U&Lv$jAH-Hw_}5hKA$w zc+!7vME^lOkm?3DX|pbSX}7%nlAc6)`AlzP{02?yT`{|10q>vz=7YiX;X}1w2YW?S ze)yaF#_R8WM-DE{O=7%A2E|5eDp322aEC7@_Z%thfcQnMDNB(tV@2|VhzhhqT^?GY zf7vyADSiLgHAUaXRK3oOt^G_g`1H^>8S{!IG$KEO0e%f(mKi?`^{ zw*>8|!3{v41#BRfxTDf9s;)?d%kq$S7^yx~iVkw_G}U#xH_Hsv-^ZAF>}ea2>WhC! z^%e0);NqNooEn(`W(!qkzI(=n%q&CSvn^_4uqLHhDcyn*ai$bA6~F1+jxxPgh6Jvt z_+tq1HSh%lp)ejUYW~oQ!>I@M=>SqHNCHTuuUGd-c(+uQ8@Hx*Ds5%6*xtXF zT4tjwbl0ZQ?$vo!pDeozpaTeOU42Sme)>6OAlhQ`&itOy)Yh{(yi@mRe8Ct#uC6uA zTFPhx10FZI4_9E)xqtNARLQx}TOd$1f_ea|c4e-Hzfi$f2s^8sv(O%{Lm`Tgr4ld1 zmJ9fAr6CKX3!00Q8WrJxyblOotvL=)ksHzk6>Gh;Hzg602-zlIR;j#?{+h5a-PyKA*^T!*y>eOLU`YN*WZU6s_q(STuTHZp_(r;p^EtB%( zdk+&oYpf2AFE%wRzGhAa2y07bq>w~9V|7K*!x&SMGem`zCi*ZsErKdgiZYlJx(K&s z!3A*DfNY;N%P-Nfr_VSzGw_i+u*Q6guBXyiu5_36mj*Hx#nB$?*12#12d7FSr;Pv2 zSZ!#>^S3<_LeWl^kNNadjj&~kcm)E0mrUgUo9e3+yVyM@%xv{y-p`f?;^!F2Ry3|) z$U~gQSOnm|NWB+%&hSx^>rk_RuyJO{*Pndf;-|eg>&-r!%VY*H7wBzw4peUyED0i! z`uz}ueV)q{UDw3`c?-N|s9G)fki!U{l^)(cHhci|+3>%R)F?OOA`oTN#p0ChCFGQh zJs@O((QGTs_C?W>eDGBV?SKnw_dk5v<6@Q%tPqq7y}?a&=U`WNsI2ZlswZ@tY_aTH z@a1@XfRWUTRltZV$di>~6w5p4Pm1W-8FJ7ITmrEWYG_LgeUf*4^Ef*CfYGt$!5m|d zan0I){Oa~0)JM}@{MH}Et?E4Ur6A2jK2{$=yfT0lm|B)}HCY~F^!o3%o5B$JEFbxx zk_vMk8EB+W>iR!yny&bpGs~fr`F?LJZ~>oLj11TJKHK6@UI5IZR>1$^(VF$V7Gwz!mg_B zYgDtR{ncRdizg4^arb-_2|!P@hJ?q{%4@(5=JM(H-#Cguhr_DcZ}c+Aj?~U-XaS%n zTw=1O*=uALocez50ggx_GkJMBz$Pd-O;PrvXp zR^T1$QHiRkK0s$oFH}6bN!ZmA+-7m@gd@xsp?1ecV?vPHUR6C4gUpbgSh1o~N z4yYhUjeZSCAR`|o_;HL-TPL#Ih5;bCT!B)__q?-an%?FDUgDWDpJL~}F7tKXtrr{h z@8~cN6^eXpn5D>1vhr6GNnO;cT&kHz)Rxit;Ebp8I6QRG?Ku=n1o2MSTUiZkZqC#O z1VI`Jl~bw}QD>dn0#yeBmR*vKvo~gPiwk-x(Pt31&g>QW=;#}L*a(B7chi5M1C`ma8n#|8WZ*|IS&&unL#zBX5PhLc+w{z9Cs-9BN!22_0_)Q4>n} zECX+uWwujona41PG$lj@e1iD=LapViw|Rv}9U22N_lx1X?+`qi(Uz-2#*@TbQ*C5s zb(haMJv$-HQ=zjoKgNOs=dE6Ay{mH~Yz&KZd=N@lAqhV%EY@%*Iqk^8XXi$i;y!3& z%+D8JrSTam)0Cc|0pU80V**tEj?QZ<5a%pz&R2`bawi}ge(o-ywyFNx%u2!ohw+%Y zCC?r&NrjJou740W?B+i_&j6gL|5T$MX`Z%;kHNla3_9*)ZF5Ng=J`M&czI&cmH@;& zL>fbScSU~KOqy)i`+rGo=?acnXI=%LBL4|{B!(~l>CKP>B%6vnSk)*Ff?;2HV@t%M zWJ{YQAl-MAo=_XffiS+2R^E}yEz#}eFq5aa7n6aOGU073F48iuXy&<8@wZ8IeI73) zGA;?EYE1EJgXJ+~gb1QPQ@IE{;J^-6Oo?*Q0fE!6B4?cdNpym61B}}IPxn?r3M+Zz|T7^TH1K7T+ zip;kSV5tejsQXjUo080pjy{-k&>*A7QKW0L{ zb&w~eUP|Cvc+{ z4|l8&%$q@iDDMj#zn-@`q zD@8qx!?zbewxe3qqCx}O3o8|qTE{V6pI6KqkS1f%Qnq9jM9>Hox?<&q^m&Gy2U1pT z7!+w!)7b29qM0YNGR5{W07n1XfcoA`cHe73C? z{NgTpt77{tC~;!fS)HHr&@9Rl1_($cL{S3A*jVtFq4@u_j&-wdEwmjXmrd-IoixTQ zGF%Kc1Q!4k4ErVj{ms@78;@`CC$2WYz6DEGan@G5T%ZK}ZZdCs;cC3V1|2A<9IWJc z3QR;#s3gyS+kqF7>I{Hix*AUs#Fa^_dme5>bN3m62gzLM*LFfWj&jKqOI?~f&8Nb8 zDV(@K?pcMdhZ-?o9zGAnDBmk2D}Laqv6WbSQ0*dI*$afkdnFiO61Hl|&{}$1kn`^R zsJ0b;wX*bf`6fu%vsrG$c^6@`T14K5Y}avxEZ*~@@_=HIyT^Ez*1kuCxBxExq-+Ls z{Kwytbyg8BKhuU z^-(OGys=m&;#ok^VA&}I?#9s0tDZ-nd#nxUobv3ES zhTBJP^I@f%j892vahj6S0Q|+tDv7OhV{7A~s`HJM8IvLy71=28IlSTCf+93Wyl4I7 z^EG0Pk5TyAX*VR*348@dtv^(q9^EHlKt_ODAkeyZ=Q}*TzB7v@9w=#e4=hgfVAPT94NmXQkeTg)0|1M=-UGOx+#|> zoo&Gnsvi($e!4(S`WA&$S%Qv)9vfe%8bjIwafFq$j%m*RnQt^#YJ!;Y0)At9(XmEm1hb?L99nji z{faV?x)vcFOPq?eNpceK*SKWa<~l6>rZD#!AuOT@M8Mgy%`#I0&@H1lHH&>$v}!LX zZ98cC+kl0hq_k9_aWu`h6v?`lgu6hETkbpQTU6n(zUBVmpmsUZ=?XUTx*h4#k1c19 zdo!jY7VhVoH_?qv)GQDw1DZcgX>o=)0ANriJm{;VYd}DgTT)SI=5eRj6~OhE@Z3@0 z9WA)UHuutGhN*up1FP#!)BSuX?A=OMSZWlmc?v((j%4>gyWhF?^CK3^gb<>O5~Hi$ACZNz{7wDKx}x%l;Nk>6!#GXRGVkNX7hvVMLf7te3q+Q^l4B?c)!e?U|kA(CsP#u3NhdQQv& zSA!Z89klknDe?l}GAom>AnX}%}yfIEZfhoIb4I9VV{n1xB;2xY2F@Q?*J zCvgG06%avZ+j=OO@{T;hNc4yT|Jx{7|0M;N4eK zBd@h>sb<`Gv;%?5j1?0>9)^2g1|Rq&K7 ze-%@?&dzl~g$;Pj>{kJrz|%rMESC^$TqVjiTn*LwEO5g`sN&&aWjsHa}zp#EnqYPE=9sRvITlN$y(Bp;Ofr zTe|2!ou*@7G!SlNojmv&V{+2Ey4thJ1yGwGVmzV^96k~55!I^tx`MfhoqJcTetxsG zby_v3Qdv$T>)qiL-^NjGQ$n8NAZqcj;2|lVsri&H8eW2u~$Pty*tz7O4s9qibM zlruOQakQ~j+SjQT1|?mNO@86nL?O!357LTVwH-3Wi8|$wInkql85+5^53jSBNaZVp zLYi_n?qv41|HzBEg#^R@Y^aoS3+HnNGUysZ-J z1&jsfGlaf(aN`~tSvQVOdW%+5JVvioKKhEWzKL5@SWnGXmPx~X$)`M*SNl@`ELQ19 zp>bik9x3*5Z%$5+de4zCBvJ=YOsv3uN8wXnfiX^cCFZ16m_Zc6;?95sW3B&c@0*b2 z2?JZ*3zZDoA_9u?*# zjq~F!)jL{!Q4Q?LY8k=#{xJt)DC&Jn{9)@y4pW{ok02qErx{ z2^rGBsrs^sE`N&nFy_hb!*w&Z&LV4VgS9)z(Qv5yDXfuAnY;>@D!;1Fw%^OpceGhH zZY3UmxD>OB?7$C6nxbT!qf}$FYm(UD-E~kTq@%K~PCmU(6pe^_I(+||SV4-TgR|Ur zCr&C>9HA|Fs2ndpuKwKQqp?%nlo^{B8af2#>L>;^oUe;?(VjR6+sk<>F(%S zPfs>)Ik1$N@W_IDO19foH&y)Ob9nQ9eG)3L9Cu+{)ncY>x+pMBiQ#PSo7T==DqEr|=KwOJp987Te&Uu78~q|c0(t8G;b$Y;JCGp%F@VRt z{xZSMK2t586Fkv~5ausacwid^$Eq0(Y?Tex zq7AgNn-$-XRetD$r z#T7xnzHb>t08@UPhqWvr&REoqDnYpF;+@&69bklXc**3Yxxhxa91P&G z$}-vSp!Yk=VB5^p#7*Xag>$iG_U+ZfM)^1>AmsKWX1}O`hoB-?`Es)$&skSv8*96_ z12+~rQ$fXUF8I52uYh=m&b)GK8_z`mTMQ_xIR*;mSeAHCNlJEWH_FLqzUB6~+CY0& z0mPOV-N&a8x;@Sx;1^f&-f}T+npf${(D>FtZGr!$dBdwq%ctM5V%2TKN4>?%cWn3o zwzd2cV|gf@>pnm>bq{i44XHc*{(V~F1Px@BqjKNhZW)A)0XS=*=nmN?N=!v25qYZe zt^hF-K>0^BsEm6t*@J{W2mn6_`0&5687JKqwmA_zvXm`(h!Ud$6>Ui1POY+Iu!fgT zyWfn0(t&gs3bf;*+qOXf=m)H9@g|C8fL zI$ILQ_$~FrRj!4j=8$SsNxXCsRehMj`}`P!Oo3^Wa&RfrlUM#6YrDNCS6egZ^laGf zSWw)_Z&Xfg(Q|xJH7?F20v7a48+eo9@cR|}s74Vol5Hgl5g}rd3Y%MtFea&<6K!K* zy5c7EC6(9KemvOtwEXff8*oiJf=z3UA+oq7Po>P2V(l)?v^HA8tM9IAvE~3lx-bC; zRQw$-U&v4zj!Te6?5Jt+yb$|y;f)eE`D&UI`+WSfv5J8~vD*xWoJoau<$B%kjtN}f zYctOAyyN55*vxtnUK1?yGHQM^@(YEIU0$<^M3dVjHUZNzTU%^}ajy6re$STz`im2T zcN&@XvGj5T#SJCcC0Q+&1YE-AmQx(!eBXmq=wD+qJ>`sIb0UrJtVz{=$vwKDHJBu^ zPN=5)4hBV;6$$LsT-!cucfZnbKF)tjo0%uEJ|S;Gl_U>f&7O4mq1yW(BIUWU@#OqF z3erc2h`$pe`w29;eU005bVEw zP-ZEo0C@)*!!o30!_QL3a*wK|b{JUex^6HonmNra?yI(y)5 z+2%I$v*;Fx_h2w^TK!nqtc@*zlrj&Z-0vKhHThzRSkJP321;rFc2V?foOcPzcY7Ea zUnLaLr#f1TuYRS1-};S+8lJ);e9UweI0Jf3YLzgRRqXau!Fl9)AMfvpL5h%-~b+GRay3D7iHimbi`ibka7yFuj#$i zdbe1B-<6+u`&^ihcV5>f9c%~7H=0Hol=UUbW7Tjl>SNo z?sh(bb*&-uQ&sGE8kTwdVLV=b==a-XH3)W9tsI#66D>fY3~ZbDXWi;{wZQ6@)+q zA`l&3whc0T%^j*uHN!1-J!G#F^ohDFKqRMl;)wi3c>RbNf~nE@E|4hlwB>3o8G_Mr z^4ZWw-CS%41cQG^H(HM2{lLjnbK6{>iS-J=&|?XrY)-X|6^W7zCRNfqIYn?%cdjiM zuIoQoq(S+OOV~?=Ldbx0`HJ$#i1qa&Y69 z4eWoy13k7e#-0w>Ogo)P1lS~JTpF!bRvO*mwq_C-tdZhC^YSzk^Bx?)ug^1UOo*mg zpD<(}Yw_ScdG5^3{rFW(?-vVboZfV=uN5Il6~+KxMo0f>epTnC`*urpvAV1=2ug3n2wjy`->%I4c9?Vbbqv9xiT()r;?k%L#xC7g3#9Hh3XlR?IFsUW zf(c=GbQI1bTdLI>ZtyF*@oidGZv#r0(%8>;5<#?T860bMdLJAPEZDv`@3OxM4M=20 zVmXsWcai?OHH}59pr?n7dSHQE$4AK{y>6){igF~_BFAXKsuU-io7&a5v81+GuSz!v zMyXYeL?lWrLH1p3*YE?Q*c3quroi zsQ)Ea3al{IFaY)Y{d@r}LK$JI#-tQ9*B;)*PkcW zvi5As&zwlmUJ#!!4S_MAQcIj3l1dhoBiL%I;RsAsa!$Wj6O?T_>|nQ zWeDMAEBr{DiplQt+P8+S2R!K6pel5MnDJLhLSB=nu~Z70e6>0;GiR=jaU*kM!*QKJfmN0~R*< zfNxuSI}&ERU4E9$^HDRw^;h~jj)jN50SmUByY7bkjTYb0R6Of;bb0WAM==gecNi48 zKN1n?>9{={m|Oi=B!!xs$^L0RX?>2?E0NtIP@f(q&Zk3>T`+-gG|8Fff}VT=j9(~O z`pSq_xrGSPXMO9O|P z4)y%a%Oke4bg7yzhg*Yl?Yt7tiARzEL10xgT`_D;@vXp{kHS>@C*o2IiyW)x#owA&!+a6&j+tnJz%H z9bGF|Y6Tfb^N?{=UqDVnk0}pdT(4OW+%L6oy)EAb1R>Lczu_#zMd#E+#qp);x?e`f;r*JF#;e09$pIz1j!SR#oQ%Qu31_Da?V(LY^=~ zA!^J;o=r+4A7OATy5taff0@j6*OAkixU{lDYP<5fdDk;&5XlI@=pqWg_tZ7Lq&`TX z(#LpMaQuahJ$D)qd}+kDp{SXwG}7fpJ-h_Ue1JQCm?J@9t)=a^MvxLSkxAQKR~Abf zHu+GZjMj?7WQR&{1l^!cTp)Q~^$C!t*{PNN$Ry>GHydyucg9)GYf|^~a!{Ltt^joI zL@kq+I+D*JYnd)5zX}n4CsU)ahn{rZj=fJ;CVqc~V>$|D3P4lOf(_GLw%ZywsKbAd z@eI8-E8@e027Cx3f3F}_ev82o(E__OEzw=TX9Q`z?PpE>#^svOxX@b`(-FTgP_u$n zkS0G}VV$Y}c4*G3d>gxxAN9w{N=eS%#N&Kf`!ipZ_2oUd=k#WI@hD|uOb6h|%=LB{ zqrM_32iaBcvzQ@>q!*Kq*gfrJkf&IS(qR!H_`wxl=)W+Q8KclJn3!2k7UQ1y$dle>SyT|NxCyRA3 z!r?!D4Pl-dqt1EsYB9^auI+Y9C9H<Hq@i9KLHGsz55^|`0%$D4Pq7O6arM4kx)ul-K7%&)s@k5+4r?$%IgRSA)Re3O{z z`r;Z)2kqeMaL%$GO=($23(+C=dBcHPRKoQB zD$yvEl7#je(u+}y4y@>&9F%Zz-Kzh$iQ^*o|C~5nTmNO^{PY>%T5lY9E-)D?t-F9~ z&m}t}R}Ct{;0+N#c;Yy*i7A&h07}AG2+pUtw=uUK6#?^+gla;i2E$_sWxGMd=GKd# zmyhAq?ywKqc)q`z(51U7E?2q^%+y@Uz6UWik}x&Gh7IJ$-NdhKgD10vwIHL)o*c5@ zQpxG4o;ZK{yi#UxwKz;+Pa1l?J5nf`A=5$S`7tCa@bDoEJWTZ7q{3c}JS|dv`tf+~^k0(b&%QB+0o9 zFkS?LED7rhEG_dI;?-%i)oZ1y^#XH`6lDu!BPivc=JjnfUy%yqQ z^aGNfsDLwJQ4|G~i0IPqn}-LV4zSEE^-NM_BWqi^ssO+d;69AgW|aN@3#d()s`bem zh)Re%Wz6@GxuY2haJg2uc5B3Aqo=$TBAfVdX~khyoY-VVGL3N*f11bqQz3 zZwYbFU)}Rm49~~E{q;p~I+XhlJLp5ZGaAkZzm5;!fKGX$+nziZ;@}~hJyBk7HVLoZ z1$MRM>d8*=)C%h7wtv1S*{lFyE@3D7V@m4M@;k04W>V)G4}fm+`l?5U>f7Uinz)=9 z;8-Ym%F*jesFVP?)Ijbv)Wjnc$bB2Rlr8y#bv{Y0eE{Hj;A;x7LWxn3*4kg5`ebp2 zc#?_H?^nycDu<2&WQTdpQqmC2thD&>*-a0L8I&-Qw;V2=4Cg?(Po3-QC^Y z-QC>@kl?}Hg9eA-4sYPz`>FpPCw)VS4&>_nB>H?X~P3Mm%8tV5$7~fiht66C9{? z|B`(IUi|_Qu&KlIGakw3fAendgZFwJp7Xyq1zdix2fPeJq5PYf3!DQ4xkfkQg}?nf zSHAcGn6rocq6rB9om&6LM=*$hh6okB2sg-c6lY1ASRW9>+FPfH>=v7!m}U1j4`lgYt70_?q3_npu!*l#czN(#QT`*L#cg8@MvZb z$x&fD>sj@7lM7tv(cOex=(u%$X>~nYaR@WN=#LQCRmqCR-u#FtW1xi0amV~3yA6|U z#<&~u`)Fh$HHY4fPa!lFzjvF&ZQ~b!Tz0f(*pP&B4T#hM>UPh21Xk;T_}+RDb%W`{3n)c-^KoMGZ&U>9?AXAJ zNpGe3mb%cp@VZ|+jrKm|WZHNEHtiL4x><-zf))KVMrv1astSylPujpQA9G~Radm31 zljgp$Vc=cH|BIEVNBvS5p!6Z7d#j7G`{+47Vjv&(_QQ6UQS$OxgzxER3AiH(cojl- zYVEO7B>{Q|{|--NEmpIpOPOJf&5%Roxe6?Ff=0i@ZomGVe{)vVN7^c4!KYVDYNS}` zySrxMkZZD*fmw$Cwa`8WKDLG1NpJUg^EWGFSE9FaCY!2Wpr73@F@V&<)p1$QA{^Co zTx#a9&FKi;YBPfawqZ*ticIb@hXO#?;Yn%$TAj7piLE_tQ4ghhRJTMQt%isvfL>dPO6hvac0BxPsny3M zyCP*@Q=a8}o94p9rCW^vCvM&bl73#+P{$(rYYIklcgOys4Q~S5k!mG|;{4TUo*=9% zqlLvbfYb2oTV%A2mM2xp0@BI0#8<;gfI}mVRFeBS8+pa0St>iG(?~5DbhtEGIGH1N zom8fOnwSB=3d#PfuPq5u#Hz5US2zApbFz2-vzb@FlPk3pf*V1u?ohOfYANoGbZHau zz4kHp&bF9EJ5d@IAyYT4xV2ds- zYD2GzY)a|n90nFx8#SftPXO$h%ESJt#(np-8w#HI>cV|Yj_7$5wudK`txDeaod)Cm zb<^arSYDV9=%UO2=!f^gMdHHWK{!_&c+`=&Ap}_ z+-L|WUz}2`%oCP?(CdYzl||_TR{{*mUSOX&eDaD=nEZRIkwU@Jz<7@VsHq3pN%*B2 zkQPD$tuc4p?xl7fot;Gi2|&TQ$uofnr5Afa+S%;8%KMk!?OP-*tzQj?kXF7~ri?T_ zn(qK;%RL>$ybJDGo^A2eS9+BXdS_e2F{GvgTP6U=quW`T3g1Li2=JVRI5})-#2{Yk zV_#=Pz`wn+aDgt>a$VKi=Lvl*e{hMd{6U#o+XM*KB;Bri;r}B}NS69BUb!rCE~6eU zwl4Y=2d8_<1MEwbE~~LFk-CKmIJU^&aC$xT7B1hYJN-&jNnF9wZ@nGizcdkK6`NjE zuHR#K?aeB#ZNT~NDxKxA4h4`GLk@r8b##l>^iH%PU)xJaJag{8qY|Ish;ODkw`n~~ zb^|usaI(o^YYk|+Uw`i2cLgYGew~dS|A+UeG#)S{G}r#*v5leNc)M4x2GFWAv2cEk zq%2xz_Cs`Xe)0*pjUY>M=l6gj86SmA^0!Y`LMklzHN%3PhO@y?~wb@2z$xO)k z6~%!7+9rTlLRre786q;jjk|aJL%Zf%WSR6$Os}Qfx`y3sr9Y4Bi9J+!=!L=VMu&zw zcJ4+v-uAX`l1=NOl1o*R=tSg3i+ZC8*HIE3irwvB5pOe-JhBw6zwYJ;NH%nyo@VgWhmmprTY`}cj7TR6+P6SF4! z%ueKm8d9!PVMzJ;b6uSZutS5tJ%(R|O>`tLlfzfVH1|nZ)4mp1q_l92eSAdUJnENA zn6de5crWUN@j(u7DY+w@q6yL0YL2>>Ll|&D^$jxP?n2T2_d+GW1!q)l2Qz&f=>r{x3n)s+408y<{_B&|sX&BT z6ds}>L}3&=LW=rVjJ34T4{01Ktebm?0ZW?F@NFkO_rB!yA$b192o2n!c%Fz{gp5k1 zNnJRgbSqJrDs5}ap`eQ}6Q7q59~y2(;HYSRC99DvYhqKzD#mw3zDIgi6c&}%yYf3) zRO*#cuMdhW>pFP0uPPRh@ffdWJNr3?M(;xbE+CS5K>^tCACeoH{FOUfUdjWBWv8mf z*g42OiehWeUnaXh2ho+udo!5>z&O|lI_Kf9=}zKF-xZUYshiXA91o8f###O&p#~S* z7aoU|bPyuEI;YCt(NdatGPX4C$EnxVNr8DzU-s*Lr4TbNwwsbTO2M-2c7uhXdBiU9 ztCLHTL0lKLFC5qLdRJ=Xp2t6I4-}N^F0}DH@q{!uP`||_q_c`+VHlpe+{^0~Vg)j` z=~41Uhm{-D;|iE1l5E~QHVoomFW`zzqE}->e4@34CLa#y-)`@v9GQwMGMN6>1W1+w zlsM3A&?k{(Ozh*tngPTe43;3mI)V)qmbcwJ+l*u1u7fBu_e--hua}PjhOz={Ja1jS^ zYUJad5*b#@k)0k{jcv(lcO?@ITJt?4#r$0Sg(aR~x+?B+6R1|qqV8@4n)A(Y-O*Ll zVd+##&Br4l|S?)>lSxPr!k1pA;_&U}Dz!8uOK>^mE4&?U{y= z_OUu2ikV}2+Wb=b+yzUVC2eQE34UIDr@Cb0S=wqSgvxI${7$ zO5utV010b@20+48-T_T6|A8cA=s_HUU3yvYF(uUj7>VM)i)`GbJ5k|zo25;e&|l_D z0j({trcB%hm!4fd)02B=j{#+9joM-+Z64ttO40-jp8S!^8s;c~-WYfK`7&0sqE(bk-C zj4j=oGi!Qp>4=%uj9cMsl=YsJ1Ymb4+lW<{F~EC5SV9x5W#z&e%Q4vc{dsw^p+CSm zZ&3hchJiCf@L%-3n{=y+1slSrLG8~ZR#shBl~0mJ7R*60vqLb9b*ibxbo;cY>?PSX zKkkiA8hR$^7K{lWPxO=3Mnj(mw4*j}DStNooR&x4`z#AI$;o{-%mqjiGWbGBrReDa z1b*P+lfI|INp{@sSsT?UV`zT51c=D^+E*{nB=G~my}IQN6Du&x7P&1~8}(nz+eG5h zIe_#&(5+`=Z7kqcy*s>?ZiDm{W%FjbL)MK|Hft=(th&ztw&`mXTgj>yZz){kM&1Im z@IRH#T_JTA4=<6+Q%D z0|5eE07nfCvD9?_u^_IMtt58a?UsQ1MG<(pdv37KOKmPg0BHn{$)5;$z!g6_>!{w%qrp@F9N zl^Xf0=H7mT2uK0oGfEQxBtCN-mDAAwX6a=)Qku)8=lGTvHTidMFFNh)_Er^b+k!S9ekR@i!^v2pJ?8w> z1*AelO~21njmNTljbn}sS;W@7FhSg^cS;qH?VYXt9>5p%-WP|Hd2ROGgvBFDDM^h1 zbZ?XV4BiBX$RF3GH1ET#hcO-*DQ=@8kIARK|J2-f4pzXT$Vu#|E;HSS58OXdxaZQe z<&o-v)9NKb-73As8%pu}54gtSrVD!vku-o3%%?hH&fvQt(AWup8lSQuG#^)bnZ@h~ z;E=V$XnGNi<+!MC%lcs=qN^rl=97K}B1O&r<;g6HgFTT#RuuovM|X&1z4`4fAL`?F zILjJ@Q&X^E)#PH1X>~7iBtQoM^ny%SW52uXS3ngoSs9R@6o1g=kk}?g)hb`q5oII+ zs5tYo*8fyF^}d{@sk!Sb8nX!JE@oK>@pd8qD(0X&G{zQ9SwQ)x zIs!oWMK23992O~UjHUUUdVYVqYSMVQ=PS9c!7r)HIrQ$mn7x8>C2HrzzxyotCZbnS z-4dr{#W#_94X(JJ0nD|_G1=K~M7G74y@XgDm$sJ7A?B!HF}H^2eW~W9ST&!$W!!(( zG3&3I*JVbbWNAA}&v3aq??#iF{)&Fqwtxb_0xp>qW+9IN0icINz#_Sd+IF&4>sG{& z+u7VG&&~&8*NHwnUq5$r734xL-vnYsi2WdL#KFLG>71N0AnRN+WI`|3t+u)AWi?|{ zS_GD#eJLcxjBSJ`Uv6o7W&t}#MwPtdUp~%0A%jFXv@2Ku{R)vS=1I7ot9yuRF2o-H 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 mIoIKTHaEY+nbLlP|7v_5B7H$F User: + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user", + ) + return current_user diff --git a/backend/app/api/v1/endpoints/categories.py b/backend/app/api/v1/endpoints/categories.py index 4aaabd4..91ca67c 100644 --- a/backend/app/api/v1/endpoints/categories.py +++ b/backend/app/api/v1/endpoints/categories.py @@ -4,6 +4,7 @@ from sqlalchemy import select from pydantic import BaseModel, ConfigDict from typing import List, Optional import uuid +import logging from app.core.database import get_db from app.models.category import Category @@ -11,6 +12,8 @@ from app.api import deps router = APIRouter() +logger = logging.getLogger("categories") + class CategoryBase(BaseModel): name: str description: Optional[str] = None @@ -38,18 +41,56 @@ async def read_categories( db: AsyncSession = Depends(get_db), current_user = Depends(deps.get_current_active_superuser) ): - query = select(Category).offset(skip).limit(limit) - result = await db.execute(query) - return result.scalars().all() + try: + logger.debug("Fetching categories with skip=%d, limit=%d", skip, limit) + query = select(Category).offset(skip).limit(limit) + result = await db.execute(query) + categories = result.scalars().all() + logger.debug("Fetched %d categories", len(categories)) + return categories + except Exception as e: + logger.exception("Unhandled exception occurred while fetching categories.") + raise HTTPException(status_code=500, detail="Internal Server Error") @router.post("/", response_model=CategoryResponse) async def create_category( - category: CategoryCreate, + category: CategoryCreate, db: AsyncSession = Depends(get_db), current_user = Depends(deps.get_current_active_superuser) ): - db_category = Category(**category.model_dump()) - db.add(db_category) + new_category = Category(**category.model_dump()) + db.add(new_category) + await db.commit() + await db.refresh(new_category) + return new_category + +@router.put("/{category_id}", response_model=CategoryResponse) +async def update_category( + category_id: uuid.UUID, + category: CategoryUpdate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + db_category = await db.get(Category, category_id) + if not db_category: + raise HTTPException(status_code=404, detail="Category not found") + + for key, value in category.model_dump(exclude_unset=True).items(): + setattr(db_category, key, value) + await db.commit() await db.refresh(db_category) return db_category + +@router.delete("/{category_id}", status_code=204) +async def delete_category( + category_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + db_category = await db.get(Category, category_id) + if not db_category: + raise HTTPException(status_code=404, detail="Category not found") + + await db.delete(db_category) + await db.commit() diff --git a/backend/app/api/v1/endpoints/clients.py b/backend/app/api/v1/endpoints/clients.py new file mode 100644 index 0000000..23a4498 --- /dev/null +++ b/backend/app/api/v1/endpoints/clients.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text # <--- IMPORTANTE: Necesario para consultas SQL +from app.core.database import get_db + +# IMPORTANTE: Renombramos para evitar conflictos +from app.models.client import Client as ClientModel +from app.schemas.client import ClientCreate, ClientUpdate, Client as ClientSchema + +router = APIRouter() + +# GET: Obtener todos los clientes +@router.get("/clients", response_model=list[ClientSchema]) +async def get_clients(db: AsyncSession = Depends(get_db)): + # Corrección: Usamos text() y mappings().all() + query = text("SELECT * FROM clients") + result = await db.execute(query) + return result.mappings().all() + +# POST: Crear cliente +@router.post("/clients", response_model=ClientSchema) +async def create_client(client: ClientCreate, db: AsyncSession = Depends(get_db)): + # Usamos ClientModel para guardar en BD + new_client = ClientModel(**client.dict()) + db.add(new_client) + await db.commit() + await db.refresh(new_client) + return new_client + +# GET ONE: Obtener un cliente por ID +@router.get("/clients/{client_id}", response_model=ClientSchema) +async def read_client(client_id: int, db: AsyncSession = Depends(get_db)): + db_client = await db.get(ClientModel, client_id) + if not db_client: + raise HTTPException(status_code=404, detail="Client not found") + return db_client + +# PUT: Actualizar cliente +@router.put("/clients/{client_id}", response_model=ClientSchema) +async def update_client(client_id: int, client: ClientUpdate, db: AsyncSession = Depends(get_db)): + db_client = await db.get(ClientModel, client_id) + if not db_client: + raise HTTPException(status_code=404, detail="Client not found") + + for key, value in client.dict(exclude_unset=True).items(): + setattr(db_client, key, value) + + await db.commit() + await db.refresh(db_client) + return db_client + +# DELETE: Borrar cliente +@router.delete("/clients/{client_id}") +async def delete_client(client_id: int, db: AsyncSession = Depends(get_db)): + db_client = await db.get(ClientModel, client_id) + if not db_client: + raise HTTPException(status_code=404, detail="Client not found") + + await db.delete(db_client) + await db.commit() + return {"message": "Client deleted successfully"} \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py new file mode 100644 index 0000000..f20642b --- /dev/null +++ b/backend/app/api/v1/endpoints/tickets.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from typing import List +import uuid +from sqlalchemy.exc import NoResultFound + +from app.core.database import get_db +from app.models.ticket import Ticket # Asegúrate de tener el modelo Ticket +from app.models.category import Category # Ensure Category model is imported +from app.api import deps # Para autenticación y permisos +from app.models.user import User # Asegúrate de importar el modelo User + +router = APIRouter() + +# Esquema para crear un ticket +class TicketCreate(BaseModel): + title: str + description: str + category_id: uuid.UUID + priority: str + +# Esquema para devolver un ticket +class TicketResponse(BaseModel): + id: uuid.UUID + title: str + description: str + category_id: uuid.UUID + priority: str + created_at: str + +@router.post("/", response_model=TicketResponse) +async def create_ticket( + ticket: TicketCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_active_user) # Verifica que el usuario esté autenticado +): + # Validate if the category exists + try: + category = await db.execute(select(Category).where(Category.id == ticket.category_id)) + category = category.scalar_one() + except NoResultFound: + raise HTTPException(status_code=404, detail="Category not found") + + # Crear un nuevo ticket + db_ticket = Ticket( + ticket_number=str(uuid.uuid4())[:8], # Generate a unique ticket number + subject=ticket.title, + description=ticket.description, + category_id=ticket.category_id, + priority=ticket.priority, + created_by=current_user.id, + tenant_id=current_user.tenant_id + ) + db.add(db_ticket) + await db.commit() + await db.refresh(db_ticket) + return db_ticket + +@router.get("/", response_model=List[TicketResponse]) +async def get_tickets( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_active_user) # Verifica que el usuario esté autenticado +): + try: + result = await db.execute(select(Ticket).where(Ticket.tenant_id == current_user.tenant_id)) + tickets = result.scalars().all() + return tickets + except Exception as e: + # Log the error for debugging purposes + import logging + logger = logging.getLogger("tickets") + logger.exception("Error fetching tickets for tenant_id=%s", current_user.tenant_id) + raise HTTPException(status_code=500, detail="An error occurred while fetching tickets.") \ No newline at end of file diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 924133f..ceb69fa 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -5,7 +5,7 @@ Router principal para la API v1 """ from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, tenants, users, systems, categories +from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, clients, tickets api_router = APIRouter() @@ -45,3 +45,15 @@ api_router.include_router( prefix="/categories", tags=["categories"] ) + +api_router.include_router( + clients.router, + prefix="/clients", + tags=["clients"] +) + +api_router.include_router( + tickets.router, + prefix="/tickets", + tags=["tickets"] +) \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2c0a7b9..4d23ef7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -119,6 +119,18 @@ class Settings(BaseSettings): CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL") CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND") + # =================================== + # CAMPOS ADICIONALES + # =================================== + POSTGRES_DB: str = Field(..., env="POSTGRES_DB") + POSTGRES_USER: str = Field(..., env="POSTGRES_USER") + POSTGRES_PASSWORD: str = Field(..., env="POSTGRES_PASSWORD") + SMTP_USERNAME: Optional[str] = Field(default=None, env="SMTP_USERNAME") + MAX_UPLOAD_SIZE: int = Field(default=10485760, env="MAX_UPLOAD_SIZE") + UPLOAD_DIR: str = Field(default="./uploads", env="UPLOAD_DIR") + RATE_LIMIT_REQUESTS: int = Field(default=100, env="RATE_LIMIT_REQUESTS") + RATE_LIMIT_WINDOW: int = Field(default=60, env="RATE_LIMIT_WINDOW") + def is_production(self) -> bool: """Check if environment is production.""" return self.ENVIRONMENT.lower() == "production" @@ -139,4 +151,7 @@ def get_settings() -> Settings: Using lru_cache to create a singleton pattern for settings. """ - return Settings() \ No newline at end of file + return Settings() + +# Crear una instancia global de Settings +settings = Settings() \ No newline at end of file diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 46ec699..e878458 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -10,6 +10,7 @@ from sqlalchemy import String, DateTime, func from typing import AsyncGenerator import uuid from datetime import datetime +import logging from app.core.config import get_settings @@ -34,6 +35,8 @@ AsyncSessionLocal = async_sessionmaker( autocommit=False ) +logger = logging.getLogger("database") + class Base(DeclarativeBase): """Base class para todos los modelos SQLAlchemy.""" @@ -57,13 +60,16 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: """ async with AsyncSessionLocal() as session: try: + logger.debug("Intentando crear una nueva sesión de base de datos.") yield session await session.commit() - except Exception: + except Exception as e: + logger.error("Error al crear la sesión de base de datos: %s", str(e)) await session.rollback() raise finally: await session.close() + logger.debug("Sesión de base de datos cerrada.") async def create_tables(): diff --git a/backend/app/middleware/tenant.py b/backend/app/middleware/tenant.py index 11aa855..1836c56 100644 --- a/backend/app/middleware/tenant.py +++ b/backend/app/middleware/tenant.py @@ -8,10 +8,20 @@ from fastapi import Request, HTTPException, status from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response import structlog +from contextlib import asynccontextmanager +from sqlalchemy.sql import text logger = structlog.get_logger(__name__) +# Correct the usage of async for by wrapping it in an async function +@asynccontextmanager +async def get_db_context(): + from app.core.database import get_db + async for db in get_db(): + yield db + + class TenantMiddleware(BaseHTTPMiddleware): """ Middleware para extraer y validar información del tenant. @@ -34,33 +44,56 @@ class TenantMiddleware(BaseHTTPMiddleware): """Process request and add tenant information.""" # Skip tenant validation for excluded paths - if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"): + if request.url.path in self.EXCLUDED_PATHS or any(request.url.path.startswith(path) for path in self.EXCLUDED_PATHS): return await call_next(request) # Extract tenant from header tenant_id = request.headers.get("X-Tenant-ID") tenant_slug = request.headers.get("X-Tenant-Slug") - # For now, we'll be more permissive in development - # In production, tenant should be strictly required + # Revertir cambios para requerir encabezados de tenant if not tenant_id and not tenant_slug: - logger.warning( - "Request without tenant information", - path=request.url.path, - method=request.method + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)" ) - # For now, continue without tenant for development - # raise HTTPException( - # status_code=status.HTTP_400_BAD_REQUEST, - # detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)" - # ) # Store tenant info in request state request.state.tenant_id = tenant_id request.state.tenant_slug = tenant_slug - # TODO: Validate tenant exists and is active - # This would involve a database query which we'll implement later + # Validate tenant exists and is active + if tenant_id or tenant_slug: + from app.core.database import get_db + from app.models.tenant import Tenant + + # Update the middleware to use the new context manager + async with get_db_context() as db: + tenant = await db.execute( + text( + """ + SELECT * FROM tenants + WHERE id = :tenant_id OR slug = :tenant_slug AND status = 'active' + """ + ), + {"tenant_id": tenant_id, "tenant_slug": tenant_slug} + ) + tenant = tenant.fetchone() + + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found or inactive" + ) + + # Store validated tenant info + request.state.tenant = tenant + + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)" + ) logger.debug( "Tenant middleware processed", diff --git a/backend/app/models/category.py b/backend/app/models/category.py index 8ccb9b5..d54cf98 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -1,17 +1,17 @@ - """ Category Model - ServiceManagerWeb """ -from sqlalchemy import String, Text, Boolean, ForeignKey +from sqlalchemy import String, Text, Boolean, ForeignKey, DateTime from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func from typing import List, Optional import uuid from app.core.database import Base class Category(Base): - __tablename__ = "categories" + __tablename__ = "ticket_categories" name: Mapped[str] = mapped_column(String(100), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text) @@ -20,6 +20,10 @@ class Category(Base): # Optional: Tenant specific categories? tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True) + # Timestamp columns + created_at: Mapped[Optional[DateTime]] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[Optional[DateTime]] = mapped_column(DateTime(timezone=True), onupdate=func.now()) + # Relationships tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category") tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported diff --git a/backend/app/models/client.py b/backend/app/models/client.py new file mode 100644 index 0000000..1738672 --- /dev/null +++ b/backend/app/models/client.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, Integer, String +from app.core.database import Base + +class Client(Base): + __tablename__ = "clients" + + id = Column(Integer, primary_key=True, index=True) + clave = Column(String, nullable=False) + tipo_cliente = Column(String, nullable=False) + nombre = Column(String, nullable=False) + pais = Column(String, nullable=False) + estado = Column(String, nullable=False) + ciudad = Column(String, nullable=False) + direccion = Column(String, nullable=False) + numero_ext = Column(String, nullable=True) + cp = Column(String, nullable=True) + colonia = Column(String, nullable=True) + lada = Column(String, nullable=True) + telefono1 = Column(String, nullable=True) + telefono2 = Column(String, nullable=True) + tel_directo = Column(String, nullable=True) + ext = Column(String, nullable=True) + fax = Column(String, nullable=True) + horario = Column(String, nullable=True) + pagina = Column(String, nullable=True) + correo = Column(String, nullable=True) + medio_publicidad = Column(String, nullable=True) + nacionalidad = Column(String, nullable=True) + logo = Column(String, nullable=True) \ No newline at end of file diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py index a78aadb..44a916e 100644 --- a/backend/app/models/ticket.py +++ b/backend/app/models/ticket.py @@ -9,6 +9,8 @@ import enum import uuid from app.core.database import Base +from app.models.system import System +from app.models.category import Category class TicketStatus(str, enum.Enum): NEW = "NEW" @@ -43,8 +45,8 @@ class Ticket(Base): created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) - system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True) - category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True) + affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True) + category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ticket_categories.id"), nullable=True) # Relationships tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index c492222..8fd639f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,6 +13,7 @@ import uuid from datetime import datetime from app.core.database import Base +from app.models.ticket import Ticket class UserRole(str, enum.Enum): diff --git a/backend/app/schemas/client.py b/backend/app/schemas/client.py new file mode 100644 index 0000000..08d8c2e --- /dev/null +++ b/backend/app/schemas/client.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel + +class ClientBase(BaseModel): + clave: str + tipo_cliente: str + nombre: str + pais: str + estado: str + ciudad: str + direccion: str + numero_ext: str | None = None + cp: str | None = None + colonia: str | None = None + lada: str | None = None + telefono1: str | None = None + telefono2: str | None = None + tel_directo: str | None = None + ext: str | None = None + fax: str | None = None + horario: str | None = None + pagina: str | None = None + correo: str | None = None + medio_publicidad: str | None = None + nacionalidad: str | None = None + logo: str | None = None + +class ClientCreate(ClientBase): + pass + +class ClientUpdate(ClientBase): + pass + +class Client(ClientBase): + id: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/backend/fix_password_script.py b/backend/fix_password_script.py index 81fd583..829d050 100644 --- a/backend/fix_password_script.py +++ b/backend/fix_password_script.py @@ -1,41 +1,40 @@ - -import asyncio import sys import os +import json -# Add parent directory to path so we can import 'app' +# Asegurar que el directorio raíz esté en PYTHONPATH sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from sqlalchemy import select -from app.core.database import AsyncSessionLocal -from app.models.tenant import Tenant # Import Tenant to register it -from app.models.ticket import Ticket # Import Ticket to register it -from app.models.user import User from app.core.security import SecurityUtils +from app.core.config import settings +from app.models.user import User +from app.models.tenant import Tenant +from app.core.database import AsyncSessionLocal +from sqlalchemy.future import select +import asyncio -async def fix_password(): - async with AsyncSessionLocal() as session: - # Find the admin user - email = "admin@aduanasoft.com" - result = await session.execute(select(User).where(User.email == email)) - user = result.scalar_one_or_none() - - if user: - print(f"User {email} found.") - # Reset password to 'admin123' - new_password = "admin123" - hashed = SecurityUtils.hash_password(new_password) - user.password_hash = hashed - - try: - await session.commit() - print(f"Password for {email} updated successfully!") - print(f"New password is: {new_password}") - except Exception as e: - await session.rollback() - print(f"Error updating password: {e}") - else: - print(f"User {email} not found!") +# Generar un token predeterminado para el usuario admin +def generate_default_token(): + async def async_task(): + async with AsyncSessionLocal() as db: + result = await db.execute(select(User).filter(User.email == "admin@aduanasoft.com")) + user = result.scalar_one_or_none() + if user: + print(f"User found: {user.email}") + token = SecurityUtils.create_access_token({"sub": str(user.id), "email": user.email}) + print(f"Generated token: {token}") -if __name__ == "__main__": - asyncio.run(fix_password()) + # Validate the token + payload = SecurityUtils.verify_token(token) + if payload: + print("Token is valid. Payload:") + print(json.dumps(payload, indent=4)) + else: + print("Token validation failed.") + else: + print("User not found. Token not generated.") + + asyncio.run(async_task()) + +generate_default_token() +print("Token generation process completed.") diff --git a/backend/requirements.txt b/backend/requirements.txt index c189022..342124d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,7 +15,6 @@ pydantic-settings==2.1.0 sqlalchemy==2.0.23 alembic==1.13.0 asyncpg==0.29.0 # PostgreSQL async driver -psycopg2-binary==2.9.9 # PostgreSQL sync driver (for migrations) # =================================== # AUTHENTICATION & SECURITY @@ -42,7 +41,7 @@ jinja2==3.1.2 # Email templates # FILE HANDLING # =================================== python-magic==0.4.27 # MIME type detection -pillow==10.1.0 # Image processing +# pillow==10.1.0 # Image processing # =================================== # HTTP & REQUESTS diff --git a/backend/test_db_connection.py b/backend/test_db_connection.py new file mode 100644 index 0000000..85c8c1e --- /dev/null +++ b/backend/test_db_connection.py @@ -0,0 +1,18 @@ +import os +import asyncio +import asyncpg + +def get_database_url(): + # Replace 'postgresql+asyncpg' with 'postgresql' for asyncpg compatibility + return os.getenv("DATABASE_URL", "postgresql://servicemanager:servicemanager123@servicemanager-db:5432/servicemanager").replace("postgresql+asyncpg", "postgresql") + +async def test_connection(): + try: + conn = await asyncpg.connect(get_database_url()) + print("Connection to the database was successful!") + await conn.close() + except Exception as e: + print(f"Failed to connect to the database: {e}") + +if __name__ == "__main__": + asyncio.run(test_connection()) \ No newline at end of file diff --git a/backend/tests/test_clients.py b/backend/tests/test_clients.py new file mode 100644 index 0000000..08f40e2 --- /dev/null +++ b/backend/tests/test_clients.py @@ -0,0 +1,71 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + +@pytest.fixture +def sample_client_data(): + return { + "clave": "12345", + "tipo_cliente": "Regular", + "nombre": "Cliente Prueba", + "pais": "México", + "estado": "Chihuahua", + "ciudad": "Cd. Juárez", + "direccion": "Calle Falsa 123", + "numero_ext": "12", + "cp": "32000", + "colonia": "Centro", + "lada": "656", + "telefono1": "1234567890", + "telefono2": "0987654321", + "tel_directo": "1231231234", + "ext": "101", + "fax": "1231231235", + "horario": "9:00 - 18:00", + "pagina": "www.clienteprueba.com", + "correo": "cliente@prueba.com", + "medio_publicidad": "Internet", + "nacionalidad": "Mexicana", + "logo": "logo.png" + } + +def test_create_client(sample_client_data): + response = client.post("/api/v1/clients", json=sample_client_data) + assert response.status_code == 200 + assert response.json()["clave"] == sample_client_data["clave"] + +def test_read_client(sample_client_data): + # Create a client first + create_response = client.post("/api/v1/clients", json=sample_client_data) + client_id = create_response.json()["id"] + + # Read the client + response = client.get(f"/api/v1/clients/{client_id}") + assert response.status_code == 200 + assert response.json()["id"] == client_id + +def test_update_client(sample_client_data): + # Create a client first + create_response = client.post("/api/v1/clients", json=sample_client_data) + client_id = create_response.json()["id"] + + # Update the client + updated_data = {"nombre": "Cliente Actualizado"} + response = client.put(f"/api/v1/clients/{client_id}", json=updated_data) + assert response.status_code == 200 + assert response.json()["nombre"] == "Cliente Actualizado" + +def test_delete_client(sample_client_data): + # Create a client first + create_response = client.post("/api/v1/clients", json=sample_client_data) + client_id = create_response.json()["id"] + + # Delete the client + response = client.delete(f"/api/v1/clients/{client_id}") + assert response.status_code == 200 + + # Verify deletion + response = client.get(f"/api/v1/clients/{client_id}") + assert response.status_code == 404 \ No newline at end of file diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..41ff069 --- /dev/null +++ b/backend/tests/test_security.py @@ -0,0 +1,27 @@ +import pytest +from app.core.security import SecurityUtils +from datetime import timedelta + +# Test password hashing and verification +def test_password_hashing(): + plain_password = "securepassword123" + hashed_password = SecurityUtils.hash_password(plain_password) + + assert SecurityUtils.verify_password(plain_password, hashed_password) == True + assert SecurityUtils.verify_password("wrongpassword", hashed_password) == False + +# Test JWT token generation and validation +def test_jwt_token_generation(): + payload = {"sub": "12345", "email": "test@example.com"} + token = SecurityUtils.create_access_token(payload, expires_delta=timedelta(minutes=15)) + + decoded_payload = SecurityUtils.verify_token(token) + assert decoded_payload is not None + assert decoded_payload["sub"] == "12345" + assert decoded_payload["email"] == "test@example.com" + +# Test TOTP secret generation +def test_totp_secret_generation(): + secret = SecurityUtils.generate_totp_secret() + assert len(secret) > 0 + assert isinstance(secret, str) \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index c38f58d..e6f5916 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -38,6 +38,74 @@ CREATE INDEX idx_tenants_slug ON tenants(slug); CREATE INDEX idx_tenants_status ON tenants(status); CREATE INDEX idx_tenants_domain ON tenants(domain); +-- =================================== +-- DOMINIO: CLIENTS (Cartera de Clientes) +-- =================================== + +-- Enum para los radio buttons de estatus +CREATE TYPE client_status_enum AS ENUM ( + 'prospect', + 'active', + 'suspended', + 'cancelled' +); + +CREATE TABLE clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Identificación (Sección General) + code VARCHAR(50), -- Clave del cliente + name VARCHAR(200) NOT NULL, -- Nombre / Razón Social + tax_id VARCHAR(20), -- RFC + client_type VARCHAR(50), -- Tipo de Cliente + account_manager VARCHAR(100), -- Encargado Cliente (Texto simple por ahora) + + -- Dirección (Desglosada) + address_street TEXT, -- Dirección / Calle + address_ext_num VARCHAR(20), -- Núm. Ext + neighborhood VARCHAR(100), -- Colonia + zip_code VARCHAR(10), -- CP + city VARCHAR(100), -- Ciudad + state VARCHAR(100), -- Estado + country VARCHAR(100) DEFAULT 'Mexico', + + -- Contacto + phone_primary VARCHAR(20), -- Teléfono 1 + phone_secondary VARCHAR(20),-- Teléfono 2 + fax VARCHAR(20), + email VARCHAR(320), -- Correo + website VARCHAR(255), -- Página Web + + -- Configuración y Flexibilidad + status client_status_enum DEFAULT 'prospect', + logo_url VARCHAR(500), -- Ruta al archivo PDF/Imagen del logo + + -- Campo JSONB para lo que sobre (Horario, Nacionalidad, Medio Publicidad, Lada, etc.) + properties JSONB DEFAULT '{}'::jsonb, + + -- Auditoría estándar + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Restricciones: No repetir RFC ni Clave dentro del mismo Tenant + UNIQUE(tenant_id, code), + UNIQUE(tenant_id, tax_id) +); + +-- Índices para búsqueda rápida +CREATE INDEX idx_clients_tenant_id ON clients(tenant_id); +CREATE INDEX idx_clients_tax_id ON clients(tax_id); +CREATE INDEX idx_clients_name ON clients(name); +CREATE INDEX idx_clients_properties ON clients USING gin (properties); + +-- Trigger para mantener actualizado el campo updated_at +-- (Usa la función que ya definiste al final de tu script) +CREATE TRIGGER update_clients_updated_at BEFORE UPDATE ON clients + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + -- =================================== -- DOMINIO: AUTH (Autenticación) -- =================================== diff --git a/docker-compose.yml b/docker-compose.yml index 7a296c7..b06998c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,7 @@ services: dockerfile: ../docker/Dockerfile.backend container_name: servicemanager-backend restart: unless-stopped + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload env_file: - .env environment: @@ -70,8 +71,10 @@ services: - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT} - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL} + - PYTHONPATH=/app volumes: - ./backend:/app + - ./tests:/app/tests - uploads_data:/app/uploads - logs_data:/app/logs ports: @@ -161,7 +164,7 @@ services: restart: unless-stopped environment: - NODE_ENV=${ENVIRONMENT:-development} - - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} + - PUBLIC_API_URL=http://backend:8000 - PUBLIC_APP_NAME=ServiceManager Cliente volumes: - ./frontend-client:/app @@ -186,7 +189,7 @@ services: restart: unless-stopped environment: - NODE_ENV=${ENVIRONMENT:-development} - - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} + - PUBLIC_API_URL=http://backend:8000 - PUBLIC_APP_NAME=ServiceManager Admin volumes: - ./frontend-internal:/app diff --git a/frontend-client/src/lib/stores/app.ts b/frontend-client/src/lib/stores/app.ts index 300ea5f..5260ffd 100644 --- a/frontend-client/src/lib/stores/app.ts +++ b/frontend-client/src/lib/stores/app.ts @@ -29,12 +29,13 @@ const initialState: AppState = { // API helper function async function apiCall(endpoint: string, options: RequestInit = {}) { const authState = get(auth); - + const response = await fetch(`/api/v1${endpoint}`, { ...options, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authState.token}`, + 'X-Tenant-ID': authState.tenantId, // Added tenant header ...options.headers } }); @@ -60,8 +61,10 @@ function createAppStore() { try { const categories = await apiCall('/categories/'); + console.log('Loaded categories:', categories); // Debugging update(state => ({ ...state, categories, isLoading: false })); } catch (error) { + console.error('Error loading categories:', error); // Debugging update(state => ({ ...state, isLoading: false, diff --git a/frontend-client/src/lib/stores/auth.ts b/frontend-client/src/lib/stores/auth.ts index 0823712..94f4ac9 100644 --- a/frontend-client/src/lib/stores/auth.ts +++ b/frontend-client/src/lib/stores/auth.ts @@ -107,6 +107,7 @@ function createAuthStore() { isLoading: false }); } catch (error) { + console.error('Login error:', error); // Debugging the error update(state => ({ ...state, isLoading: false })); throw error; } diff --git a/frontend-client/src/lib/stores/clientes.ts b/frontend-client/src/lib/stores/clientes.ts new file mode 100644 index 0000000..7e4e4d1 --- /dev/null +++ b/frontend-client/src/lib/stores/clientes.ts @@ -0,0 +1,44 @@ +import { writable } from 'svelte/store'; + +export const clients = writable([]); + +export async function fetchClients() { + const response = await fetch('/api/v1/clients'); + const data = await response.json(); + clients.set(data); +} + +export async function createClient(client) { + const response = await fetch('/api/v1/clients', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(client), + }); + if (response.ok) { + fetchClients(); + } +} + +export async function updateClient(clientId, client) { + const response = await fetch(`/api/v1/clients/${clientId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(client), + }); + if (response.ok) { + fetchClients(); + } +} + +export async function deleteClient(clientId) { + const response = await fetch(`/api/v1/clients/${clientId}`, { + method: 'DELETE', + }); + if (response.ok) { + fetchClients(); + } +} \ No newline at end of file diff --git a/frontend-client/src/routes/clients/+page.svelte b/frontend-client/src/routes/clients/+page.svelte new file mode 100644 index 0000000..ab0b6a3 --- /dev/null +++ b/frontend-client/src/routes/clients/+page.svelte @@ -0,0 +1,53 @@ + + + + Clientes - ServiceManager + + +

+

Listado de Clientes

+ + {#if isLoading} +

Cargando clientes...

+ {:else if clientList.length === 0} +

No hay clientes registrados.

+ {:else} + + + + + + + + + + {#each clientList as client} + + + + + + {/each} + +
NombreEmailAcciones
{client.name}{client.email} + Editar +
+ {/if} +
\ No newline at end of file diff --git a/frontend-client/src/routes/clients/[id]/edit/+page.svelte b/frontend-client/src/routes/clients/[id]/edit/+page.svelte new file mode 100644 index 0000000..4d3dded --- /dev/null +++ b/frontend-client/src/routes/clients/[id]/edit/+page.svelte @@ -0,0 +1,83 @@ + + + + Editar Cliente - ServiceManager + + +
+

Editar Cliente

+ +
+
+ + + {#if errors.name} +

{errors.name}

+ {/if} +
+ +
+ + + {#if errors.email} +

{errors.email}

+ {/if} +
+ + +
+
\ No newline at end of file diff --git a/frontend-client/src/routes/clients/new/+page.svelte b/frontend-client/src/routes/clients/new/+page.svelte new file mode 100644 index 0000000..9c9a84f --- /dev/null +++ b/frontend-client/src/routes/clients/new/+page.svelte @@ -0,0 +1,71 @@ + + + + Crear Cliente - ServiceManager + + +
+

Crear Nuevo Cliente

+ +
+
+ + + {#if errors.name} +

{errors.name}

+ {/if} +
+ +
+ + + {#if errors.email} +

{errors.email}

+ {/if} +
+ + +
+
\ No newline at end of file diff --git a/frontend-internal/src/lib/stores/api.ts b/frontend-internal/src/lib/stores/api.ts new file mode 100644 index 0000000..0204eb7 --- /dev/null +++ b/frontend-internal/src/lib/stores/api.ts @@ -0,0 +1,45 @@ +import { writable } from 'svelte/store'; + +export const api = { + async getClients() { + const response = await fetch('/api/v1/clients'); + if (!response.ok) { + throw new Error('Error fetching clients'); + } + return await response.json(); + }, + + async createClient(client) { + const response = await fetch('/api/v1/clients', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(client), + }); + if (!response.ok) { + throw new Error('Error creating client'); + } + return await response.json(); + }, + + async updateClient(clientId, client) { + const response = await fetch(`/api/v1/clients/${clientId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(client), + }); + if (!response.ok) { + throw new Error('Error updating client'); + } + return await response.json(); + }, + + async deleteClient(clientId) { + const response = await fetch(`/api/v1/clients/${clientId}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Error deleting client'); + } + return await response.json(); + }, +}; \ No newline at end of file diff --git a/login_payload.json b/login_payload.json new file mode 100644 index 0000000000000000000000000000000000000000..744556cb5ce3dc850aa2c4592cb5a8f6dbdbada2 GIT binary patch literal 172 zcmZXNy$XXc6otQ8=sSpWDQGvRzKMjUiV$Nl2^~sby?V^x;&2a#^LN+(mx>n?h7l*; zQVI=L+KA_oc@lbEV5TQ><0&>NdCmEE_5vTNi@N8Xnuhz4kNUaqd2MxCbtHzPW##sA Llk_O{$Ay9o=I$NJ literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4d1d5c0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "ServiceManagerWeb", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/proyecto_completo.txt b/proyecto_completo.txt new file mode 100644 index 0000000..608f1df --- /dev/null +++ b/proyecto_completo.txt @@ -0,0 +1,14279 @@ +=== ESTRUCTURA DEL PROYECTO === +./ + .env.example + .gitignore + docker-compose.yml + proyecto_completo.txt + README.md + .github/ + copilot-instructions.md + backend/ + add_columns.py + fix_password_script.py + pyproject.toml + README.md + requirements.txt + alembic/ + versions/ + add_clients_table.py + app/ + main.py + api/ + deps.py + v1/ + router.py + endpoints/ + auth.py + categories.py + clients.py + health.py + systems.py + tenants.py + users.py + core/ + config.py + database.py + logging.py + security.py + middleware/ + correlation_id.py + tenant.py + models/ + category.py + client.py + system.py + tenant.py + ticket.py + user.py + clientes/ + schemas/ + client.py + logs/ + tests/ + test_clients.py + uploads/ + db/ + schema.sql + docker/ + Dockerfile.backend + Dockerfile.frontend + Dockerfile.worker + nginx/ + default.conf + nginx.conf + docs/ + api-contract.md + database-schema.md + frontend-client/ + .eslintrc.json + .prettierignore + .prettierrc + package.json + postcss.config.js + svelte.config.js + tailwind.config.js + vite.config.js + .svelte-kit/ + ambient.d.ts + tsconfig.json + generated/ + root.svelte + client/ + app.js + matchers.js + nodes/ + 0.js + 1.js + 2.js + 3.js + 4.js + 5.js + 6.js + 7.js + server/ + internal.js + types/ + route_meta_data.json + src/ + routes/ + $types.d.ts + login/ + $types.d.ts + profile/ + $types.d.ts + tickets/ + $types.d.ts + new/ + $types.d.ts + [id]/ + $types.d.ts + src/ + app.css + app.html + lib/ + components/ + Header.svelte + Icon.svelte + TicketCard.svelte + Toast.svelte + stores/ + app.ts + auth.ts + clientes.ts + tickets.ts + toast.ts + routes/ + +layout.svelte + +page.svelte + login/ + +page.svelte + profile/ + +page.svelte + tickets/ + +page.svelte + new/ + +page.svelte + [id]/ + +page.svelte + static/ + images/ + Icono AS(1).png + Logo AS 192px -192px(1).png + Logo AS 512px - 512px(1).png + Logo AS blanco(1).png + SOPORTE.webp + frontend-internal/ + .eslintrc.json + .prettierignore + .prettierrc + package.json + postcss.config.js + svelte.config.js + tailwind.config.js + vite.config.js + .svelte-kit/ + ambient.d.ts + tsconfig.json + generated/ + root.svelte + client/ + app.js + matchers.js + nodes/ + 0.js + 1.js + 2.js + 3.js + 4.js + 5.js + 6.js + 7.js + server/ + internal.js + types/ + route_meta_data.json + src/ + routes/ + $types.d.ts + categories/ + $types.d.ts + login/ + $types.d.ts + systems/ + $types.d.ts + tenants/ + $types.d.ts + users/ + $types.d.ts + src/ + app.css + app.html + lib/ + components/ + Header.svelte + Icon.svelte + Modal.svelte + Sidebar.svelte + Toast.svelte + stores/ + api.ts + auth.ts + toast.ts + utils/ + api.ts + routes/ + +layout.svelte + +page.svelte + categories/ + +page.svelte + login/ + +page.svelte + systems/ + +page.svelte + tenants/ + +page.svelte + users/ + +page.svelte + scripts/ + README.md + setup-dev.sh + workers/ + README.md + requirements.txt + app/ + celery.py + core/ + config.py + logging.py + tasks/ + email_tasks.py + maintenance_tasks.py + notification_tasks.py + sla_tasks.py + beat-data/ + celerybeat-schedule/ + logs/ + uploads/ + Zpracticante/ + Documentacion Practicas.docx + ~$cumentacion Practicas.docx + Backups/ + respaldo_docker_v1.sql + + +=== CONTENIDO DE LOS ARCHIVOS === + +================================================== +ARCHIVO: .\.env.example +================================================== +# ServiceManagerWeb - Variables de Entorno +# Copia este archivo como .env y configura los valores apropiados + +# =================================== +# CONFIGURACIÓN GENERAL +# =================================== +ENVIRONMENT=development +DEBUG=true +SECRET_KEY=your-super-secret-key-change-in-production-min-32-chars +API_VERSION=v1 +CORS_ORIGINS=http://localhost:3000,http://localhost:3001 + +# =================================== +# BASE DE DATOS +# =================================== +# PostgreSQL +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=servicemanager +POSTGRES_USER=servicemanager +POSTGRES_PASSWORD=servicemanager123 +DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager + +# =================================== +# REDIS Y CELERY +# =================================== +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_URL=redis://redis:6379/0 +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 + +# =================================== +# AUTENTICACIÓN JWT +# =================================== +JWT_SECRET_KEY=jwt-secret-key-change-in-production-min-32-chars +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# =================================== +# EMAIL / NOTIFICACIONES +# =================================== +# SMTP Settings +SMTP_HOST=mailhog +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASSWORD= +SMTP_USE_TLS=false +SMTP_USE_SSL=false + +# Email por defecto +DEFAULT_FROM_EMAIL=noreply@servicemanager.local +DEFAULT_FROM_NAME=ServiceManager + +# =================================== +# ARCHIVOS Y STORAGE +# =================================== +# Configuración de uploads +MAX_UPLOAD_SIZE_MB=10 +ALLOWED_FILE_EXTENSIONS=pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt,zip +UPLOAD_PATH=/app/uploads + +# =================================== +# SEGURIDAD +# =================================== +# Rate limiting +RATE_LIMIT_ENABLED=true +RATE_LIMIT_AUTH=10/minute +RATE_LIMIT_API=100/minute +RATE_LIMIT_UPLOAD=5/minute + +# Password hashing +PASSWORD_MIN_LENGTH=8 +ARGON2_TIME_COST=3 +ARGON2_MEMORY_COST=65536 +ARGON2_PARALLELISM=4 + +# =================================== +# LOGGING +# =================================== +LOG_LEVEL=INFO +LOG_FORMAT=json +LOG_FILE=/app/logs/app.log + +# =================================== +# FRONTEND URLS +# =================================== +CLIENT_FRONTEND_URL=http://localhost:3000 +INTERNAL_FRONTEND_URL=http://localhost:3001 +API_BASE_URL=http://localhost:8000 + +# =================================== +# HEALTH CHECKS +# =================================== +HEALTH_CHECK_TIMEOUT=30 + +# =================================== +# DEVELOPMENT ONLY +# =================================== +# Adminer (DB admin interface) +ADMINER_ENABLED=true + +# MailHog (email testing) +MAILHOG_ENABLED=true + +# Seed data +LOAD_SAMPLE_DATA=true + +================================================== +ARCHIVO: .\docker-compose.yml +================================================== +version: '3.8' + +services: + # =================================== + # POSTGRES DATABASE + # =================================== + postgres: + image: postgres:15-alpine + container_name: servicemanager-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-servicemanager} + POSTGRES_USER: ${POSTGRES_USER:-servicemanager} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-servicemanager123} + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-servicemanager}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - servicemanager-network + + # =================================== + # REDIS CACHE & BROKER + # =================================== + redis: + image: redis:7-alpine + container_name: servicemanager-redis + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - servicemanager-network + + # =================================== + # FASTAPI BACKEND + # =================================== + backend: + build: + context: ./backend + dockerfile: ../docker/Dockerfile.backend + container_name: servicemanager-backend + restart: unless-stopped + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + env_file: + - .env + environment: + - ENVIRONMENT=${ENVIRONMENT:-development} + - DEBUG=${DEBUG:-true} + - SECRET_KEY=${SECRET_KEY} + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND} + - JWT_SECRET_KEY=${JWT_SECRET_KEY} + - CORS_ORIGINS=${CORS_ORIGINS} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL} + volumes: + - ./backend:/app + - uploads_data:/app/uploads + - logs_data:/app/logs + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - servicemanager-network + + # =================================== + # CELERY WORKER + # =================================== + worker: + build: + context: ./workers + dockerfile: ../docker/Dockerfile.worker + container_name: servicemanager-worker + restart: unless-stopped + env_file: + - .env + environment: + - ENVIRONMENT=${ENVIRONMENT:-development} + - DATABASE_URL=${DATABASE_URL} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND} + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL} + volumes: + - ./workers:/app + - uploads_data:/app/uploads + - logs_data:/app/logs + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - servicemanager-network + + # =================================== + # CELERY BEAT (SCHEDULER) + # =================================== + beat: + build: + context: ./workers + dockerfile: ../docker/Dockerfile.worker + container_name: servicemanager-beat + restart: unless-stopped + command: celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule + env_file: + - .env + environment: + - ENVIRONMENT=${ENVIRONMENT:-development} + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND} + volumes: + - ./workers:/app + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - servicemanager-network + + # =================================== + # FRONTEND CLIENT (CLIENTES) + # =================================== + frontend-client: + build: + context: ./frontend-client + dockerfile: ../docker/Dockerfile.frontend + args: + - FRONTEND_TYPE=client + container_name: servicemanager-client-frontend + restart: unless-stopped + environment: + - NODE_ENV=${ENVIRONMENT:-development} + - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} + - PUBLIC_APP_NAME=ServiceManager Cliente + volumes: + - ./frontend-client:/app + - /app/node_modules + ports: + - "3000:3000" + depends_on: + - backend + networks: + - servicemanager-network + + # =================================== + # FRONTEND INTERNAL (STAFF INTERNO) + # =================================== + frontend-internal: + build: + context: ./frontend-internal + dockerfile: ../docker/Dockerfile.frontend + args: + - FRONTEND_TYPE=internal + container_name: servicemanager-internal-frontend + restart: unless-stopped + environment: + - NODE_ENV=${ENVIRONMENT:-development} + - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000} + - PUBLIC_APP_NAME=ServiceManager Admin + volumes: + - ./frontend-internal:/app + - /app/node_modules + ports: + - "3001:3000" + depends_on: + - backend + networks: + - servicemanager-network + + # =================================== + # NGINX REVERSE PROXY + # =================================== + nginx: + image: nginx:alpine + container_name: servicemanager-nginx + restart: unless-stopped + volumes: + - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - uploads_data:/var/www/uploads:ro + ports: + - "80:80" + depends_on: + - backend + - frontend-client + - frontend-internal + networks: + - servicemanager-network + + # =================================== + # DEVELOPMENT TOOLS + # =================================== + + # Adminer - Database Admin Interface + adminer: + image: adminer:4-standalone + container_name: servicemanager-adminer + restart: unless-stopped + environment: + ADMINER_DEFAULT_SERVER: postgres + ADMINER_DESIGN: galkaev + ports: + - "8080:8080" + depends_on: + - postgres + networks: + - servicemanager-network + profiles: + - dev + + # MailHog - Email Testing + mailhog: + image: mailhog/mailhog:latest + container_name: servicemanager-mailhog + restart: unless-stopped + ports: + - "1025:1025" # SMTP server + - "8025:8025" # Web UI + networks: + - servicemanager-network + profiles: + - dev + + # Redis Commander - Redis GUI + redis-commander: + image: rediscommander/redis-commander:latest + container_name: servicemanager-redis-commander + restart: unless-stopped + environment: + REDIS_HOSTS: local:redis:6379 + ports: + - "8081:8081" + depends_on: + - redis + networks: + - servicemanager-network + profiles: + - dev + +# =================================== +# VOLUMES +# =================================== +volumes: + postgres_data: + name: servicemanager_postgres_data + redis_data: + name: servicemanager_redis_data + uploads_data: + name: servicemanager_uploads_data + logs_data: + name: servicemanager_logs_data + celery_beat_data: + name: servicemanager_celery_beat_data + +# =================================== +# NETWORKS +# =================================== +networks: + servicemanager-network: + name: servicemanager_network + driver: bridge + + + + +================================================== +ARCHIVO: .\README.md +================================================== +# ServiceManagerWeb - Mesa de Ayuda B2B + +Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft. + +## Arquitectura + +- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno) +- **Backend**: Python FastAPI + Pydantic v2 +- **Workers**: Celery + Redis (notificaciones, SLAs, jobs) +- **BD**: PostgreSQL + Alembic migrations +- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP) +- **Infra**: Docker Compose local, preparado para producción + +## Estructura del Monorepo + +``` +ServiceManagerWeb/ +├── backend/ # FastAPI app +├── frontend-client/ # SvelteKit app para clientes +├── frontend-internal/ # SvelteKit app para staff interno +├── workers/ # Celery tasks +├── db/ # Migrations y esquemas +├── docker/ # Dockerfiles específicos +├── docs/ # Documentación adicional +├── scripts/ # Scripts de desarrollo/despliegue +├── docker-compose.yml # Orquestación completa +└── .env.example # Variables de entorno +``` + +## Stack Tecnológico + +### Backend (Python) +- FastAPI (async) +- Pydantic v2 +- SQLAlchemy 2.0 (async) +- Alembic (migrations) +- Argon2 (hashing passwords) +- PyJWT +- Celery + Redis + +### Frontend (JavaScript/TypeScript) +- SvelteKit +- TypeScript +- TailwindCSS +- shadcn/ui o similar +- Zod (validación) + +### Infraestructura +- PostgreSQL 15+ +- Redis 7+ +- Docker & Docker Compose +- Nginx (reverse proxy) + +## Dominios del Sistema + +1. **Auth**: Usuarios, roles, permisos, 2FA +2. **Tenants**: Multi-tenancy, organizaciones +3. **Tickets**: Gestión de tickets, estados, SLAs +4. **Notifications**: Email, plantillas, logs +5. **Audit**: Bitácora de acciones + +## Roles de Usuario + +### Internos (Staff) +- `ADMIN`: Control total del sistema +- `SUPPORT_MANAGER`: Gestión de equipos y SLAs +- `AGENT`: Atención de tickets +- `AUDITOR`: Solo lectura para auditoría + +### Clientes +- `CLIENT_ADMIN`: Gestión de organización cliente +- `CLIENT_USER`: Creación y seguimiento de tickets + +## Quick Start + +```bash +# Clonar y configurar +git clone +cd ServiceManagerWeb +cp .env.example .env + +# Levantar servicios +docker-compose up -d + +# Verificar estado +docker-compose ps +``` + +## URLs por Defecto + +- Frontend Clientes: http://localhost:3000 +- Frontend Interno: http://localhost:3001 +- API Backend: http://localhost:8000 +- API Docs: http://localhost:8000/docs +- Adminer (DB): http://localhost:8080 + +## Scripts de Desarrollo + +```bash +# Backend +cd backend +python -m uvicorn app.main:app --reload --port 8000 + +# Frontend Cliente +cd frontend-client +npm run dev -- --port 3000 + +# Frontend Interno +cd frontend-internal +npm run dev -- --port 3001 + +# Workers +cd workers +celery -A app.worker worker --loglevel=info +celery -A app.worker beat --loglevel=info +``` + +## Testing + +```bash +# Backend tests +cd backend +pytest + +# Frontend tests +cd frontend-client +npm test +cd ../frontend-internal +npm test +``` + +## Contribución + +1. Fork del proyecto +2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`) +3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`) +4. Push a branch (`git push origin feature/nueva-funcionalidad`) +5. Crear Pull Request + +## Licencia + +Propietario - Aduanasoft © 2026 + +================================================== +ARCHIVO: .\.github\copilot-instructions.md +================================================== +# ServiceManagerWeb - Mesa de Ayuda B2B Copilot Instructions + +Este proyecto es un sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial. + +## Contexto del Proyecto + +- **Empresa**: Aduanasoft (B2B) +- **Sistema**: Mesa de Ayuda multi-tenant +- **Arquitectura**: Modular Monolith con Clean Architecture +- **Target**: MVP enterprise-grade + +## Stack Tecnológico + +### Backend +- Python FastAPI (async) +- Pydantic v2 para validación +- SQLAlchemy 2.0 (async ORM) +- PostgreSQL como base de datos principal +- Redis para cache y broker Celery +- Celery para tareas asíncronas +- Alembic para migraciones +- Argon2/Bcrypt para hash de passwords +- PyJWT para autenticación + +### Frontend +- SvelteKit + TypeScript +- Dos aplicaciones: cliente e interna +- TailwindCSS para estilos +- Zod para validación del lado cliente + +### DevOps +- Docker + Docker Compose +- Nginx como reverse proxy +- Variables de entorno para configuración +- Healthchecks para servicios + +## Dominios del Sistema + +1. **auth**: Autenticación, usuarios, roles, 2FA +2. **tenants**: Multi-tenancy, organizaciones cliente +3. **tickets**: Core del sistema - tickets, estados, SLAs +4. **notifications**: Email, plantillas, comunicaciones +5. **audit**: Bitácora de acciones para compliance + +## Roles de Usuario + +- **ADMIN**: Control total (usuarios internos) +- **SUPPORT_MANAGER**: Gestión equipos y SLAs +- **AGENT**: Atención de tickets +- **AUDITOR**: Solo lectura para auditoría +- **CLIENT_ADMIN**: Gestión organización cliente +- **CLIENT_USER**: Creación/seguimiento tickets + +## Reglas de Desarrollo + +### Seguridad +- Siempre validar inputs con Pydantic +- Rate limiting en endpoints críticos +- Sanitizar archivos adjuntos +- Correlation ID en logs +- CORS restrictivo + +### Código +- Clean Architecture por dominios +- Async/await en toda la aplicación +- Type hints obligatorios +- Docstrings en funciones públicas +- Tests unitarios + integración + +### Base de Datos +- Migrations solo con Alembic +- Constraints a nivel de BD +- Índices para queries frecuentes +- Soft deletes cuando aplique + +### API +- OpenAPI bien documentado +- Versionado con prefijo /v1/ +- Paginación en listados +- Responses consistentes + +## Comandos Útiles + +```bash +# Setup inicial +docker-compose up -d +alembic upgrade head + +# Desarrollo backend +uvicorn app.main:app --reload + +# Testing +pytest --cov=app tests/ + +# Linting +ruff check . --fix +black . +mypy . +``` + +Cuando trabajes en este proyecto, siempre considera la naturaleza multi-tenant y empresarial del sistema. + +================================================== +ARCHIVO: .\backend\add_columns.py +================================================== +import asyncio +from sqlalchemy import text +from app.core.database import engine + +async def add_columns(): + print("Starting schema update...") + async with engine.begin() as conn: + try: + await conn.execute(text("ALTER TABLE tickets ADD COLUMN system_id UUID REFERENCES systems(id)")) + print("Added system_id column") + except Exception as e: + print(f"Error adding system_id (might exist): {e}") + + try: + await conn.execute(text("ALTER TABLE tickets ADD COLUMN category_id UUID REFERENCES categories(id)")) + print("Added category_id column") + except Exception as e: + print(f"Error adding category_id (might exist): {e}") + print("Schema update finished.") + +if __name__ == "__main__": + asyncio.run(add_columns()) + + +================================================== +ARCHIVO: .\backend\fix_password_script.py +================================================== + +import asyncio +import sys +import os + +# Add parent directory to path so we can import 'app' +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from sqlalchemy import select +from app.core.database import AsyncSessionLocal +from app.models.tenant import Tenant # Import Tenant to register it +from app.models.ticket import Ticket # Import Ticket to register it +from app.models.user import User +from app.core.security import SecurityUtils + +async def fix_password(): + async with AsyncSessionLocal() as session: + # Find the admin user + email = "admin@aduanasoft.com" + result = await session.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + + if user: + print(f"User {email} found.") + # Reset password to 'admin123' + new_password = "admin123" + hashed = SecurityUtils.hash_password(new_password) + user.password_hash = hashed + + try: + await session.commit() + print(f"Password for {email} updated successfully!") + print(f"New password is: {new_password}") + except Exception as e: + await session.rollback() + print(f"Error updating password: {e}") + else: + print(f"User {email} not found!") + +if __name__ == "__main__": + asyncio.run(fix_password()) + + +================================================== +ARCHIVO: .\backend\pyproject.toml +================================================== +# ServiceManagerWeb Backend - PyProject Configuration + +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "servicemanager-backend" +version = "0.1.0" +description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B" +authors = [ + {name = "Aduanasoft", email = "dev@aduanasoft.com"} +] +readme = "README.md" +requires-python = ">=3.11" +license = {text = "Proprietary"} + +dependencies = [ + "fastapi==0.104.1", + "uvicorn[standard]==0.24.0", + "pydantic==2.5.0", + "pydantic-settings==2.1.0", + "sqlalchemy==2.0.23", + "alembic==1.13.0", + "asyncpg==0.29.0", + "psycopg2-binary==2.9.9", + "python-jose[cryptography]==3.3.0", + "python-multipart==0.0.6", + "passlib[argon2]==1.7.4", + "argon2-cffi==23.1.0", + "pyotp==2.9.0", + "celery==5.3.4", + "redis==5.0.1", + "email-validator==2.1.0", + "jinja2==3.1.2", + "python-magic==0.4.27", + "pillow==10.1.0", + "httpx==0.25.2", + "aiofiles==23.2.1", + "python-dateutil==2.8.2", + "pytz==2023.3", + "slugify==0.0.1", + "structlog==23.2.0", + "prometheus-client==0.19.0" +] + +[project.optional-dependencies] +dev = [ + "pytest==7.4.3", + "pytest-asyncio==0.21.1", + "pytest-cov==4.1.0", + "faker==20.1.0", + "ruff==0.1.7", + "black==23.11.0", + "mypy==1.7.1", + "pre-commit==3.6.0" +] + +production = [ + "gunicorn==21.2.0" +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] + +# =================================== +# RUFF CONFIGURATION +# =================================== +[tool.ruff] +line-length = 100 +target-version = "py311" +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "N", # pep8-naming +] +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "C901", # too complex +] + +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401"] +"tests/**/*" = ["N802", "N803", "N806"] + +[tool.ruff.isort] +known-first-party = ["app"] + +# =================================== +# BLACK CONFIGURATION +# =================================== +[tool.black] +line-length = 100 +target-version = ['py311'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +# =================================== +# MYPY CONFIGURATION +# =================================== +[tool.mypy] +python_version = "3.11" +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true +strict_equality = true +show_error_codes = true + +[[tool.mypy.overrides]] +module = [ + "passlib.*", + "pyotp.*", + "magic.*", +] +ignore_missing_imports = true + +# =================================== +# PYTEST CONFIGURATION +# =================================== +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-ra -q --strict-markers --strict-config" +testpaths = ["tests"] +asyncio_mode = "auto" +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", +] + +# =================================== +# COVERAGE CONFIGURATION +# =================================== +[tool.coverage.run] +source = ["app"] +omit = [ + "*/tests/*", + "*/venv/*", + "*/__pycache__/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] + +================================================== +ARCHIVO: .\backend\README.md +================================================== +# ServiceManagerWeb Backend + +FastAPI backend para el sistema de Mesa de Ayuda B2B multi-tenant. + +## Estructura + +``` +backend/ +├── app/ +│ ├── main.py # FastAPI app principal +│ ├── core/ # Configuración y utilidades core +│ │ ├── config.py # Configuración con Pydantic Settings +│ │ ├── database.py # SQLAlchemy async setup +│ │ ├── security.py # JWT, hashing, 2FA +│ │ └── logging.py # Structured logging +│ ├── models/ # Modelos SQLAlchemy +│ │ ├── tenant.py # Modelo de tenant (multi-tenancy) +│ │ ├── user.py # Modelo de usuario +│ │ └── ... # Otros modelos +│ ├── api/ # API routes +│ │ └── v1/ # API version 1 +│ │ ├── router.py # Router principal +│ │ └── endpoints/ # Endpoints por dominio +│ ├── middleware/ # Custom middleware +│ ├── services/ # Business logic +│ ├── repositories/ # Data access layer +│ ├── schemas/ # Pydantic schemas +│ └── utils/ # Utilidades compartidas +├── tests/ # Tests unitarios e integración +├── migrations/ # Migraciones Alembic +├── requirements.txt # Dependencias Python +└── pyproject.toml # Configuración del proyecto +``` + +## Características Implementadas + +### Core +- [x] FastAPI app con configuración async +- [x] Pydantic Settings para configuración +- [x] SQLAlchemy 2.0 async +- [x] Structured logging con structlog +- [x] JWT authentication con refresh tokens +- [x] 2FA con TOTP +- [x] Multi-tenancy middleware + +### API +- [x] Health checks (/health, /health/detailed) +- [x] Authentication endpoints básicos +- [x] Middleware de correlation ID y tenant +- [x] Error handling centralizado +- [x] CORS configurado + +### Seguridad +- [x] Argon2 password hashing +- [x] JWT con algoritmos seguros +- [x] TOTP 2FA implementation +- [x] Validation con Pydantic v2 + +## Quick Start + +```bash +# Instalar dependencias +pip install -r requirements.txt + +# Variables de entorno (copiar desde raíz del proyecto) +cp ../.env.example .env + +# Ejecutar en desarrollo +uvicorn app.main:app --reload --port 8000 + +# O usar Docker +docker-compose up backend +``` + +## Testing + +```bash +# Ejecutar tests +pytest + +# Con coverage +pytest --cov=app tests/ + +# Solo tests unitarios +pytest -m "unit" + +# Solo tests de integración +pytest -m "integration" +``` + +## Code Quality + +```bash +# Linting +ruff check . + +# Formateo +black . + +# Type checking +mypy . + +# Fix automático +ruff check . --fix +black . +``` + +## Desarrollo + +### Agregar nuevos endpoints + +1. Crear schema en `app/schemas/` +2. Crear endpoint en `app/api/v1/endpoints/` +3. Registrar router en `app/api/v1/router.py` +4. Agregar tests en `tests/` + +### Modelos de base de datos + +1. Crear modelo en `app/models/` +2. Importar en `app/models/__init__.py` +3. Crear migración: `alembic revision --autogenerate -m "descripción"` +4. Aplicar migración: `alembic upgrade head` + +### Variables de entorno + +Todas las configuraciones están en `app/core/config.py` usando Pydantic Settings. + +Ver `.env.example` para todas las variables disponibles. + +## Arquitectura + +### Clean Architecture + +- **Presentation**: FastAPI endpoints y schemas +- **Application**: Services y casos de uso +- **Domain**: Entidades y reglas de negocio +- **Infrastructure**: Repositorios, DB, external APIs + +### Patrones implementados + +- Repository pattern para acceso a datos +- Dependency injection con FastAPI Depends +- Unit of Work para transacciones +- Command/Query separation + +## Monitoring + +- Structured logging con correlation IDs +- Health checks para load balancer +- Métricas con Prometheus (TODO) +- Error tracking (TODO) + +## Security Checklist + +- [x] Password hashing con Argon2 +- [x] JWT con secret keys seguras +- [x] CORS restrictivo +- [x] Input validation con Pydantic +- [x] SQL injection protection (SQLAlchemy) +- [x] Rate limiting (TODO: implementar) +- [x] File upload validation (TODO: implementar) +- [x] XSS protection (headers en nginx) + +## Próximos pasos + +1. Implementar repositorios y services +2. Completar autenticación con base de datos +3. Agregar endpoints de users y tickets +4. Implementar rate limiting +5. Agregar métricas y monitoring +6. Tests de integración completos + +================================================== +ARCHIVO: .\backend\alembic\versions\add_clients_table.py +================================================== +""" +Add clients table +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "add_clients_table" +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + "clients", + sa.Column("id", sa.Integer, primary_key=True, index=True), + sa.Column("name", sa.String, nullable=False), + sa.Column("email", sa.String, unique=True, nullable=False), + sa.Column("phone", sa.String, nullable=False), + ) + +def downgrade(): + op.drop_table("clients") + +================================================== +ARCHIVO: .\backend\app\main.py +================================================== +""" +ServiceManagerWeb Backend - FastAPI Application + +Mesa de Ayuda B2B multi-tenant con Clean Architecture +""" + +from fastapi import FastAPI, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import JSONResponse +from contextlib import asynccontextmanager +import structlog +import time +import uuid + +from app.core.config import get_settings +from app.core.database import engine, create_tables +# Import models to register them with SQLAlchemy +from app.models.tenant import Tenant +from app.models.system import System +from app.models.category import Category +from app.models.user import User +from app.models.ticket import Ticket + +from app.core.logging import setup_logging +from app.api.v1.router import api_router +from app.middleware.tenant import TenantMiddleware +from app.middleware.correlation_id import CorrelationIDMiddleware + +settings = get_settings() +setup_logging() +logger = structlog.get_logger() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifecycle manager para la aplicación.""" + # Startup + logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION) + + if settings.ENVIRONMENT == "development": + await create_tables() + logger.info("Tablas de base de datos verificadas") + + yield + + # Shutdown + logger.info("Cerrando ServiceManagerWeb Backend") + + +# Crear aplicación FastAPI +app = FastAPI( + title="ServiceManagerWeb API", + description="Mesa de Ayuda B2B multi-tenant para Aduanasoft", + version=settings.API_VERSION, + lifespan=lifespan, + docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None, + redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None, + openapi_url=f"/{settings.API_VERSION}/openapi.json" +) + +# =================================== +# MIDDLEWARE +# =================================== + +# CORS +cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS +app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Compression +app.add_middleware(GZipMiddleware, minimum_size=1000) + +# Custom middleware +app.add_middleware(CorrelationIDMiddleware) +app.add_middleware(TenantMiddleware) + +# Request logging middleware +@app.middleware("http") +async def request_logging_middleware(request: Request, call_next): + """Log todas las requests con métricas de performance.""" + start_time = time.time() + correlation_id = getattr(request.state, "correlation_id", str(uuid.uuid4())) + + # Log request + logger.info( + "Request iniciada", + method=request.method, + url=str(request.url), + correlation_id=correlation_id, + user_agent=request.headers.get("user-agent"), + remote_addr=request.client.host if request.client else None + ) + + # Process request + response = await call_next(request) + + # Log response + duration = time.time() - start_time + logger.info( + "Request completada", + method=request.method, + url=str(request.url), + status_code=response.status_code, + duration=f"{duration:.3f}s", + correlation_id=correlation_id + ) + + # Add correlation ID to response headers + response.headers["X-Correlation-ID"] = correlation_id + + return response + + +# =================================== +# EXCEPTION HANDLERS +# =================================== + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Handler global para excepciones no capturadas.""" + correlation_id = getattr(request.state, "correlation_id", str(uuid.uuid4())) + + logger.error( + "Excepción no manejada", + error=str(exc), + correlation_id=correlation_id, + url=str(request.url), + method=request.method, + exc_info=True + ) + + return JSONResponse( + status_code=500, + content={ + "success": False, + "error": { + "code": "INTERNAL_ERROR", + "message": "Error interno del servidor" + }, + "correlation_id": correlation_id + } + ) + + +# =================================== +# ROUTES +# =================================== + +# Health check endpoint +@app.get("/health") +async def health_check(): + """Health check para load balancer y monitoring.""" + return { + "status": "healthy", + "service": "ServiceManagerWeb API", + "version": settings.API_VERSION, + "environment": settings.ENVIRONMENT + } + + +# Root endpoint +@app.get("/") +async def root(): + """Endpoint raíz con información básica.""" + return { + "service": "ServiceManagerWeb API", + "version": settings.API_VERSION, + "docs": f"/{settings.API_VERSION}/docs", + "environment": settings.ENVIRONMENT + } + + +# API routes +app.include_router( + api_router, + prefix=f"/{settings.API_VERSION}", + responses={ + 400: {"description": "Bad Request"}, + 401: {"description": "Unauthorized"}, + 403: {"description": "Forbidden"}, + 404: {"description": "Not Found"}, + 422: {"description": "Validation Error"}, + 500: {"description": "Internal Server Error"} + } +) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=settings.ENVIRONMENT == "development" + ) + +================================================== +ARCHIVO: .\backend\app\api\deps.py +================================================== +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import ValidationError + +from app.core.database import get_db +from app.core.security import security +from app.core.config import get_settings +from app.models.user import User, UserRole + +settings = get_settings() + +# Define OAuth2 scheme here or import from auth if needed. +# Defining here creates a separate instance which is fine as they share config. +# Ideally auth.py should import from here, but modifying auth.py is risky now. +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login") + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +) -> User: + payload = security.verify_token(token) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + user_id: str = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalars().first() + + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + + return user + +async def get_current_active_superuser( + current_user: User = Depends(get_current_user), +) -> User: + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=403, detail="The user doesn't have enough privileges" + ) + return current_user + + +================================================== +ARCHIVO: .\backend\app\api\v1\router.py +================================================== +""" +API v1 Router - ServiceManagerWeb + +Router principal para la API v1 +""" + +from fastapi import APIRouter +from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, clients + +api_router = APIRouter() + +# Health check routes +api_router.include_router( + health.router, + tags=["health"] +) + +# Authentication routes +api_router.include_router( + auth.router, + prefix="/auth", + tags=["authentication"] +) + +api_router.include_router( + tenants.router, + prefix="/tenants", + tags=["tenants"] +) + +api_router.include_router( + users.router, + prefix="/users", + tags=["users"] +) + +api_router.include_router( + systems.router, + prefix="/systems", + tags=["systems"] +) + +api_router.include_router( + categories.router, + prefix="/categories", + tags=["categories"] +) + + +================================================== +ARCHIVO: .\backend\app\api\v1\endpoints\auth.py +================================================== +""" +Authentication Endpoints - ServiceManagerWeb + +Endpoints para autenticación y autorización +""" + +from fastapi import APIRouter, HTTPException, status, Depends +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, EmailStr +from typing import Optional +import structlog + +from app.core.database import get_db +from app.core.security import security +from app.core.config import get_settings +from app.models.user import User +from app.models.tenant import Tenant + +router = APIRouter() +logger = structlog.get_logger(__name__) +settings = get_settings() + +# OAuth2 scheme +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login") + + +# =================================== +# PYDANTIC SCHEMAS +# =================================== + +class LoginRequest(BaseModel): + """Schema for login request.""" + email: EmailStr + password: str + tenant_slug: str + totp_code: Optional[str] = None + + +class LoginResponse(BaseModel): + """Schema for login response.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + user: dict + + +class RefreshTokenRequest(BaseModel): + """Schema for refresh token request.""" + refresh_token: str + + +class TokenResponse(BaseModel): + """Schema for token response.""" + access_token: str + token_type: str = "bearer" + expires_in: int + + +# =================================== +# ENDPOINTS +# =================================== + +@router.post("/login", response_model=LoginResponse) +async def login( + login_data: LoginRequest, + db: AsyncSession = Depends(get_db) +): + """ + Authenticate user and return access/refresh tokens. + + Args: + login_data: Login credentials + db: Database session + + Returns: + LoginResponse with tokens and user info + + Raises: + HTTPException: If authentication fails + """ + logger.info( + "Login attempt", + email=login_data.email, + tenant_slug=login_data.tenant_slug + ) + + # 1. Buscar usuario en base de datos + query = select(User).where(User.email == login_data.email) + result = await db.execute(query) + user = result.scalar_one_or_none() + + # 2. Verificar usuario y contraseña + if not user or not security.verify_password(login_data.password, user.password_hash): + logger.warning( + "Login failed - invalid credentials", + email=login_data.email + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Credenciales inválidas" + ) + + # 3. Verificar si está activo + if not user.is_active: + logger.warning( + "Login failed - user inactive", + email=login_data.email + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Usuario inactivo" + ) + + # Create tokens + token_data = { + "sub": str(user.id), + "email": user.email, + "role": user.role.value if hasattr(user.role, "value") else user.role, + "tenant_id": str(user.tenant_id) + } + + access_token = security.create_access_token(token_data) + refresh_token = security.create_refresh_token(token_data) + + logger.info( + "Login successful", + email=login_data.email, + tenant_slug=login_data.tenant_slug, + user_id=str(user.id) + ) + + return LoginResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + user={ + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "role": user.role, + "tenant_id": str(user.tenant_id), + "is_active": user.is_active, + "is_two_factor_enabled": user.totp_enabled or False, + "created_at": user.created_at.isoformat() if user.created_at else None + } + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + refresh_data: RefreshTokenRequest, + db: AsyncSession = Depends(get_db) +): + """ + Refresh access token using refresh token. + + Args: + refresh_data: Refresh token data + db: Database session + + Returns: + New access token + + Raises: + HTTPException: If refresh token is invalid + """ + logger.info("Token refresh attempt") + + # Verify refresh token + payload = security.verify_token(refresh_data.refresh_token) + if not payload or payload.get("type") != "refresh": + logger.warning("Token refresh failed - invalid token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + # TODO: Check if refresh token exists in database and is not revoked + + # Create new access token + token_data = { + "sub": payload["sub"], + "email": payload["email"], + "role": payload["role"], + "tenant_id": payload["tenant_id"] + } + + access_token = security.create_access_token(token_data) + + logger.info("Token refresh successful", user_id=payload["sub"]) + + return TokenResponse( + access_token=access_token, + expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + +@router.post("/logout") +async def logout( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +): + """ + Logout user and revoke refresh token. + + Args: + token: Access token + db: Database session + + Returns: + Success message + """ + logger.info("Logout attempt") + + # Verify token + payload = security.verify_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token" + ) + + # TODO: Revoke refresh token in database + + logger.info("Logout successful", user_id=payload["sub"]) + + return {"message": "Successfully logged out"} + + +@router.get("/me") +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +): + """ + Get current user information. + + Args: + token: Access token + db: Database session + + Returns: + Current user data + + Raises: + HTTPException: If token is invalid + """ + # Verify token + payload = security.verify_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token" + ) + + # TODO: Fetch actual user from database + + return { + "id": payload["sub"], + "email": payload["email"], + "role": payload["role"], + "tenant_id": payload["tenant_id"] + } + + +# =================================== +# DEPENDENCIES +# =================================== + +async def get_current_active_user(token: str = Depends(oauth2_scheme)): + """ + Dependency to get current active user from token. + + Args: + token: Access token + + Returns: + Current user data + + Raises: + HTTPException: If token is invalid or user is inactive + """ + payload = security.verify_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # TODO: Verify user exists and is active + + return payload + +================================================== +ARCHIVO: .\backend\app\api\v1\endpoints\categories.py +================================================== +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, ConfigDict +from typing import List, Optional +import uuid + +from app.core.database import get_db +from app.models.category import Category +from app.api import deps + +router = APIRouter() + +class CategoryBase(BaseModel): + name: str + description: Optional[str] = None + is_active: bool = True + tenant_id: Optional[uuid.UUID] = None + +class CategoryCreate(CategoryBase): + pass + +class CategoryUpdate(CategoryBase): + name: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + tenant_id: Optional[uuid.UUID] = None + +class CategoryResponse(CategoryBase): + id: uuid.UUID + + model_config = ConfigDict(from_attributes=True) + +@router.get("/", response_model=List[CategoryResponse]) +async def read_categories( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(Category).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + +@router.post("/", response_model=CategoryResponse) +async def create_category( + category: CategoryCreate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + db_category = Category(**category.model_dump()) + db.add(db_category) + await db.commit() + await db.refresh(db_category) + return db_category + + +================================================== +ARCHIVO: .\backend\app\api\v1\endpoints\clients.py +================================================== +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text # <--- IMPORTANTE: Necesario para consultas SQL +from app.core.database import get_db + +# IMPORTANTE: Renombramos para evitar conflictos +from app.models.client import Client as ClientModel +from app.schemas.client import ClientCreate, ClientUpdate, Client as ClientSchema + +router = APIRouter() + +# GET: Obtener todos los clientes +@router.get("/clients", response_model=list[ClientSchema]) +async def get_clients(db: AsyncSession = Depends(get_db)): + # Corrección: Usamos text() y mappings().all() + query = text("SELECT * FROM clients") + result = await db.execute(query) + return result.mappings().all() + +# POST: Crear cliente +@router.post("/clients", response_model=ClientSchema) +async def create_client(client: ClientCreate, db: AsyncSession = Depends(get_db)): + # Usamos ClientModel para guardar en BD + new_client = ClientModel(**client.dict()) + db.add(new_client) + await db.commit() + await db.refresh(new_client) + return new_client + +# GET ONE: Obtener un cliente por ID +@router.get("/clients/{client_id}", response_model=ClientSchema) +async def read_client(client_id: int, db: AsyncSession = Depends(get_db)): + db_client = await db.get(ClientModel, client_id) + if not db_client: + raise HTTPException(status_code=404, detail="Client not found") + return db_client + +# PUT: Actualizar cliente +@router.put("/clients/{client_id}", response_model=ClientSchema) +async def update_client(client_id: int, client: ClientUpdate, db: AsyncSession = Depends(get_db)): + db_client = await db.get(ClientModel, client_id) + if not db_client: + raise HTTPException(status_code=404, detail="Client not found") + + for key, value in client.dict(exclude_unset=True).items(): + setattr(db_client, key, value) + + await db.commit() + await db.refresh(db_client) + return db_client + +# DELETE: Borrar cliente +@router.delete("/clients/{client_id}") +async def delete_client(client_id: int, db: AsyncSession = Depends(get_db)): + db_client = await db.get(ClientModel, client_id) + if not db_client: + raise HTTPException(status_code=404, detail="Client not found") + + await db.delete(db_client) + await db.commit() + return {"message": "Client deleted successfully"} + +================================================== +ARCHIVO: .\backend\app\api\v1\endpoints\health.py +================================================== +""" +Health Check Endpoints - ServiceManagerWeb + +Endpoints para health checks y monitoring +""" + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession +import structlog + +from app.core.database import get_db, check_database_health +from app.core.config import get_settings + +router = APIRouter() +logger = structlog.get_logger(__name__) +settings = get_settings() + + +@router.get("/health") +async def health_check(): + """ + Basic health check endpoint. + + Returns basic service information and status. + """ + return { + "status": "healthy", + "service": "ServiceManagerWeb API", + "version": settings.API_VERSION, + "environment": settings.ENVIRONMENT + } + + +@router.get("/health/detailed") +async def detailed_health_check(db: AsyncSession = Depends(get_db)): + """ + Detailed health check with database connectivity. + + Checks database connection and returns detailed status. + """ + # Check database + db_healthy = await check_database_health() + + # TODO: Add Redis health check + # TODO: Add Celery health check + + overall_status = "healthy" if db_healthy else "unhealthy" + status_code = status.HTTP_200_OK if db_healthy else status.HTTP_503_SERVICE_UNAVAILABLE + + health_data = { + "status": overall_status, + "service": "ServiceManagerWeb API", + "version": settings.API_VERSION, + "environment": settings.ENVIRONMENT, + "checks": { + "database": "healthy" if db_healthy else "unhealthy", + "redis": "not_implemented", + "celery": "not_implemented" + } + } + + if not db_healthy: + logger.error("Health check failed - database unhealthy") + + return health_data + + +@router.get("/readiness") +async def readiness_check(): + """ + Kubernetes readiness probe endpoint. + + Returns 200 if service is ready to accept traffic. + """ + # For now, just return ready + # In production, this might check for startup completion, + # database migrations, etc. + return {"status": "ready"} + + +@router.get("/liveness") +async def liveness_check(): + """ + Kubernetes liveness probe endpoint. + + Returns 200 if service is alive and should not be restarted. + """ + return {"status": "alive"} + +================================================== +ARCHIVO: .\backend\app\api\v1\endpoints\systems.py +================================================== +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, ConfigDict +from typing import List, Optional +import uuid + +from app.core.database import get_db +from app.models.system import System +from app.api import deps + +router = APIRouter() + +class SystemBase(BaseModel): + name: str + description: Optional[str] = None + is_active: bool = True + +class SystemCreate(SystemBase): + pass + +class SystemUpdate(SystemBase): + name: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + +class SystemResponse(SystemBase): + id: uuid.UUID + + model_config = ConfigDict(from_attributes=True) + +@router.get("/", response_model=List[SystemResponse]) +async def read_systems( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(System).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + +@router.post("/", response_model=SystemResponse) +async def create_system( + system: SystemCreate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + db_system = System(**system.model_dump()) + db.add(db_system) + await db.commit() + await db.refresh(db_system) + return db_system + + +================================================== +ARCHIVO: .\backend\app\api\v1\endpoints\tenants.py +================================================== +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, ConfigDict, EmailStr +from typing import List, Optional +import uuid + +from app.core.database import get_db +from app.models.tenant import Tenant, TenantStatus +from app.api import deps + +router = APIRouter() + +class TenantBase(BaseModel): + name: str + slug: str + domain: Optional[str] = None + contact_email: Optional[EmailStr] = None + +class TenantCreate(TenantBase): + pass + +class TenantUpdate(BaseModel): + name: Optional[str] = None + slug: Optional[str] = None + domain: Optional[str] = None + contact_email: Optional[EmailStr] = None + status: Optional[TenantStatus] = None + +class TenantResponse(TenantBase): + id: uuid.UUID + status: TenantStatus + + model_config = ConfigDict(from_attributes=True) + +@router.get("/", response_model=List[TenantResponse]) +async def read_tenants( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(Tenant).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + +@router.post("/", response_model=TenantResponse) +async def create_tenant( + tenant: TenantCreate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + # Check existing slug + query = select(Tenant).where(Tenant.slug == tenant.slug) + result = await db.execute(query) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Tenant slug already exists") + + db_tenant = Tenant(**tenant.model_dump()) + db.add(db_tenant) + await db.commit() + await db.refresh(db_tenant) + return db_tenant + +@router.get("/{tenant_id}", response_model=TenantResponse) +async def read_tenant( + tenant_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + tenant = await db.get(Tenant, tenant_id) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + return tenant + +@router.put("/{tenant_id}", response_model=TenantResponse) +async def update_tenant( + tenant_id: uuid.UUID, + tenant_in: TenantUpdate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + tenant = await db.get(Tenant, tenant_id) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + + update_data = tenant_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(tenant, field, value) + + db.add(tenant) + await db.commit() + await db.refresh(tenant) + return tenant + + +================================================== +ARCHIVO: .\backend\app\api\v1\endpoints\users.py +================================================== +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, ConfigDict, EmailStr +from typing import List, Optional +import uuid + +from app.core.database import get_db +from app.core.security import security +from app.models.user import User, UserRole +from app.api import deps + +router = APIRouter() + +class UserBase(BaseModel): + email: EmailStr + first_name: str + last_name: str + role: UserRole + is_active: bool = True + tenant_id: Optional[uuid.UUID] = None + +class UserCreate(UserBase): + password: str + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + role: Optional[UserRole] = None + is_active: Optional[bool] = None + password: Optional[str] = None # Optional password update + +class UserResponse(UserBase): + id: uuid.UUID + + model_config = ConfigDict(from_attributes=True) + +@router.get("/", response_model=List[UserResponse]) +async def read_users( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(User).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() + +@router.post("/", response_model=UserResponse) +async def create_user( + user: UserCreate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_active_superuser) +): + query = select(User).where(User.email == user.email) + result = await db.execute(query) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + user_data = user.model_dump(exclude={"password"}) + password_hash = security.get_password_hash(user.password) + + db_user = User(**user_data, password_hash=password_hash) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user + + +================================================== +ARCHIVO: .\backend\app\core\config.py +================================================== +""" +Core Configuration - ServiceManagerWeb + +Configuración centralizada usando Pydantic Settings v2 +""" + +from functools import lru_cache +from typing import List, Optional +from pydantic_settings import BaseSettings +from pydantic import field_validator, Field +import os + + +class Settings(BaseSettings): + """Configuración de la aplicación.""" + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "case_sensitive": False + } + + # =================================== + # GENERAL + # =================================== + ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT") + DEBUG: bool = Field(default=False, env="DEBUG") + SECRET_KEY: str = Field(..., env="SECRET_KEY") + API_VERSION: str = Field(default="v1", env="API_VERSION") + + # =================================== + # DATABASE + # =================================== + DATABASE_URL: str = Field(..., env="DATABASE_URL") + + # =================================== + # REDIS + # =================================== + REDIS_URL: str = Field(..., env="REDIS_URL") + + # =================================== + # JWT AUTHENTICATION + # =================================== + JWT_SECRET_KEY: str = Field(..., env="JWT_SECRET_KEY") + JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM") + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES") + REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS") + + # =================================== + # CORS + # =================================== + CORS_ORIGINS: str = Field( + default="http://localhost:3000,http://localhost:3001", + env="CORS_ORIGINS" + ) + + # =================================== + # EMAIL + # =================================== + SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST") + SMTP_PORT: int = Field(default=587, env="SMTP_PORT") + SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER") + SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD") + SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS") + SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL") + + DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL") + DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME") + + # =================================== + # FILE UPLOADS + # =================================== + MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB") + ALLOWED_FILE_EXTENSIONS: List[str] = Field( + default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"], + env="ALLOWED_FILE_EXTENSIONS" + ) + UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH") + + @field_validator("ALLOWED_FILE_EXTENSIONS", mode='before') + @classmethod + def validate_file_extensions(cls, v): + if isinstance(v, str): + return [ext.strip().lower() for ext in v.split(",")] + return [ext.lower() for ext in v] + + # =================================== + # SECURITY + # =================================== + RATE_LIMIT_ENABLED: bool = Field(default=True, env="RATE_LIMIT_ENABLED") + PASSWORD_MIN_LENGTH: int = Field(default=8, env="PASSWORD_MIN_LENGTH") + + # Argon2 settings + ARGON2_TIME_COST: int = Field(default=3, env="ARGON2_TIME_COST") + ARGON2_MEMORY_COST: int = Field(default=65536, env="ARGON2_MEMORY_COST") + ARGON2_PARALLELISM: int = Field(default=4, env="ARGON2_PARALLELISM") + + # =================================== + # LOGGING + # =================================== + LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL") + LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT") + LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE") + + # =================================== + # FRONTEND URLS + # =================================== + CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000", env="CLIENT_FRONTEND_URL") + INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001", env="INTERNAL_FRONTEND_URL") + + # =================================== + # HEALTH CHECKS + # =================================== + HEALTH_CHECK_TIMEOUT: int = Field(default=30, env="HEALTH_CHECK_TIMEOUT") + + # =================================== + # CELERY + # =================================== + CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL") + CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND") + + def is_production(self) -> bool: + """Check if environment is production.""" + return self.ENVIRONMENT.lower() == "production" + + def is_development(self) -> bool: + """Check if environment is development.""" + return self.ENVIRONMENT.lower() == "development" + + def is_testing(self) -> bool: + """Check if environment is testing.""" + return self.ENVIRONMENT.lower() == "testing" + + +@lru_cache() +def get_settings() -> Settings: + """ + Get cached settings instance. + + Using lru_cache to create a singleton pattern for settings. + """ + return Settings() + +================================================== +ARCHIVO: .\backend\app\core\database.py +================================================== +""" +Database Configuration - ServiceManagerWeb + +SQLAlchemy 2.0 async setup con PostgreSQL +""" + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy import String, DateTime, func +from typing import AsyncGenerator +import uuid +from datetime import datetime + +from app.core.config import get_settings + +settings = get_settings() + +# Create async engine +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + pool_size=5, + max_overflow=10, + pool_pre_ping=True, # Verify connections before use + pool_recycle=3600, # Recycle connections after 1 hour +) + +# Create session factory +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=True, + autocommit=False +) + + +class Base(DeclarativeBase): + """Base class para todos los modelos SQLAlchemy.""" + + # Columnas comunes para auditoría + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now() + ) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """ + Dependency para obtener sesión de base de datos. + + Yields: + AsyncSession: Sesión de base de datos + """ + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def create_tables(): + """Crear todas las tablas en desarrollo.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def drop_tables(): + """Eliminar todas las tablas (solo para testing).""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +# Health check function +async def check_database_health() -> bool: + """ + Verificar conectividad con la base de datos. + + Returns: + bool: True si la conexión es exitosa + """ + try: + async with AsyncSessionLocal() as session: + await session.execute("SELECT 1") + return True + except Exception: + return False + +================================================== +ARCHIVO: .\backend\app\core\logging.py +================================================== +""" +Structured Logging Configuration - ServiceManagerWeb + +Configuración de logging estructurado con structlog +""" + +import logging +import logging.config +import sys +from typing import Any, Dict +import structlog +from app.core.config import get_settings + +settings = get_settings() + + +def add_correlation_id(logger: Any, method_name: str, event_dict: Dict[str, Any]) -> Dict[str, Any]: + """Agregar correlation ID a los logs si está disponible.""" + # En un contexto de request real, esto vendría del middleware + # Por ahora es un placeholder + return event_dict + + +def configure_structlog(): + """Configurar structlog para logging estructurado.""" + + processors = [ + # Add the log level and a timestamp to the event_dict + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + add_correlation_id, + ] + + if settings.LOG_FORMAT == "json": + processors.append(structlog.processors.JSONRenderer()) + else: + processors.append(structlog.dev.ConsoleRenderer()) + + structlog.configure( + processors=processors, + wrapper_class=structlog.stdlib.BoundLogger, + logger_factory=structlog.stdlib.LoggerFactory(), + context_class=dict, + cache_logger_on_first_use=True, + ) + + +def setup_logging(): + """Configurar el sistema de logging completo.""" + + # Configure structlog + configure_structlog() + + # Configure standard library logging + logging_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "json": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.processors.JSONRenderer(), + }, + "console": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.dev.ConsoleRenderer(colors=True), + }, + }, + "handlers": { + "console": { + "level": settings.LOG_LEVEL, + "class": "logging.StreamHandler", + "stream": sys.stdout, + "formatter": "json" if settings.LOG_FORMAT == "json" else "console", + }, + }, + "loggers": { + "": { # root logger + "handlers": ["console"], + "level": settings.LOG_LEVEL, + "propagate": False, + }, + "uvicorn": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "uvicorn.error": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "uvicorn.access": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "sqlalchemy": { + "handlers": ["console"], + "level": "WARNING", + "propagate": False, + }, + "celery": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + }, + } + + # Add file handler if specified + if settings.LOG_FILE: + logging_config["handlers"]["file"] = { + "level": settings.LOG_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "filename": settings.LOG_FILE, + "maxBytes": 10 * 1024 * 1024, # 10MB + "backupCount": 5, + "formatter": "json", + } + + # Add file handler to all loggers + for logger_config in logging_config["loggers"].values(): + logger_config["handlers"].append("file") + + logging.config.dictConfig(logging_config) + + +# Convenience function to get logger +def get_logger(name: str = None) -> structlog.BoundLogger: + """ + Get a configured structlog logger. + + Args: + name: Logger name (optional) + + Returns: + Configured structlog logger + """ + return structlog.get_logger(name) + +================================================== +ARCHIVO: .\backend\app\core\security.py +================================================== +""" +Security Utilities - ServiceManagerWeb + +Funciones de seguridad para autenticación y autorización +""" + +from datetime import datetime, timedelta +from typing import Optional, Union, Dict, Any +from passlib.context import CryptContext +from passlib.handlers.argon2 import argon2 +from jose import JWTError, jwt +import pyotp +import secrets +import base64 +import struct + +from app.core.config import get_settings + +settings = get_settings() + +# Password hashing context +pwd_context = CryptContext( + schemes=["argon2"], + deprecated="auto", + argon2__time_cost=settings.ARGON2_TIME_COST, + argon2__memory_cost=settings.ARGON2_MEMORY_COST, + argon2__parallelism=settings.ARGON2_PARALLELISM, +) + + +class SecurityUtils: + """Utilidades de seguridad centralizadas.""" + + @staticmethod + def hash_password(password: str) -> str: + """ + Hash a password using Argon2. + + Args: + password: Plain text password + + Returns: + Hashed password + """ + return pwd_context.hash(password) + + @staticmethod + def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a password against its hash. + + Args: + plain_password: Plain text password + hashed_password: Hashed password + + Returns: + True if password matches + """ + return pwd_context.verify(plain_password, hashed_password) + + @staticmethod + def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """ + Create a JWT access token. + + Args: + data: Token payload + expires_delta: Token expiration time + + Returns: + JWT token string + """ + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + + encoded_jwt = jwt.encode( + to_encode, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + + return encoded_jwt + + @staticmethod + def create_refresh_token(data: Dict[str, Any]) -> str: + """ + Create a JWT refresh token. + + Args: + data: Token payload + + Returns: + JWT refresh token string + """ + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "type": "refresh"}) + + encoded_jwt = jwt.encode( + to_encode, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + + return encoded_jwt + + @staticmethod + def verify_token(token: str) -> Optional[Dict[str, Any]]: + """ + Verify and decode a JWT token. + + Args: + token: JWT token string + + Returns: + Token payload if valid, None otherwise + """ + try: + payload = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM] + ) + return payload + except JWTError: + return None + + @staticmethod + def generate_totp_secret() -> str: + """ + Generate a base32-encoded secret for TOTP. + + Returns: + Base32 encoded secret + """ + return pyotp.random_base32() + + @staticmethod + def generate_totp_uri(secret: str, email: str, issuer_name: str = "ServiceManager") -> str: + """ + Generate TOTP URI for QR code. + + Args: + secret: Base32 encoded secret + email: User email + issuer_name: Application name + + Returns: + TOTP URI + """ + totp = pyotp.TOTP(secret) + return totp.provisioning_uri( + name=email, + issuer_name=issuer_name + ) + + @staticmethod + def verify_totp(secret: str, token: str, window: int = 1) -> bool: + """ + Verify a TOTP token. + + Args: + secret: Base32 encoded secret + token: TOTP token + window: Time window tolerance + + Returns: + True if token is valid + """ + totp = pyotp.TOTP(secret) + return totp.verify(token, valid_window=window) + + @staticmethod + def generate_backup_codes(count: int = 8) -> list[str]: + """ + Generate backup codes for 2FA. + + Args: + count: Number of codes to generate + + Returns: + List of backup codes + """ + codes = [] + for _ in range(count): + code = secrets.token_hex(4).upper() + # Format as XXXX-XXXX + formatted_code = f"{code[:4]}-{code[4:]}" + codes.append(formatted_code) + return codes + + @staticmethod + def hash_token(token: str) -> str: + """ + Hash a token for secure storage. + + Args: + token: Token to hash + + Returns: + Hashed token + """ + return pwd_context.hash(token) + + @staticmethod + def verify_hashed_token(token: str, hashed_token: str) -> bool: + """ + Verify a token against its hash. + + Args: + token: Plain token + hashed_token: Hashed token + + Returns: + True if token matches + """ + return pwd_context.verify(token, hashed_token) + + @staticmethod + def generate_secure_token(length: int = 32) -> str: + """ + Generate a cryptographically secure random token. + + Args: + length: Token length in bytes + + Returns: + URL-safe base64 encoded token + """ + token = secrets.token_bytes(length) + return base64.urlsafe_b64encode(token).decode('utf-8').rstrip('=') + + @staticmethod + def is_strong_password(password: str) -> tuple[bool, list[str]]: + """ + Check if password meets security requirements. + + Args: + password: Password to check + + Returns: + Tuple of (is_valid, list_of_issues) + """ + issues = [] + + if len(password) < settings.PASSWORD_MIN_LENGTH: + issues.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long") + + if not any(c.islower() for c in password): + issues.append("Password must contain at least one lowercase letter") + + if not any(c.isupper() for c in password): + issues.append("Password must contain at least one uppercase letter") + + if not any(c.isdigit() for c in password): + issues.append("Password must contain at least one digit") + + if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password): + issues.append("Password must contain at least one special character") + + return len(issues) == 0, issues + + +# Create singleton instance +security = SecurityUtils() + +================================================== +ARCHIVO: .\backend\app\middleware\correlation_id.py +================================================== +""" +Correlation ID Middleware - ServiceManagerWeb + +Middleware para rastrear requests con correlation ID +""" + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +import uuid +import structlog + +logger = structlog.get_logger(__name__) + + +class CorrelationIDMiddleware(BaseHTTPMiddleware): + """ + Middleware para manejar correlation IDs. + + Extrae el correlation ID del header X-Correlation-ID o genera uno nuevo. + Lo almacena en el estado de la request para uso en logs y responses. + """ + + async def dispatch(self, request: Request, call_next) -> Response: + """Process request and add correlation ID.""" + + # Extract or generate correlation ID + correlation_id = request.headers.get("X-Correlation-ID") + if not correlation_id: + correlation_id = str(uuid.uuid4()) + + # Store in request state + request.state.correlation_id = correlation_id + + # Add to structlog context + with structlog.contextvars.bound_contextvars( + correlation_id=correlation_id, + path=request.url.path, + method=request.method + ): + response = await call_next(request) + + # Add correlation ID to response headers + response.headers["X-Correlation-ID"] = correlation_id + + return response + +================================================== +ARCHIVO: .\backend\app\middleware\tenant.py +================================================== +""" +Tenant Middleware - ServiceManagerWeb + +Middleware para manejo de multi-tenancy +""" + +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +import structlog + +logger = structlog.get_logger(__name__) + + +class TenantMiddleware(BaseHTTPMiddleware): + """ + Middleware para extraer y validar información del tenant. + + Extrae el tenant_id del header X-Tenant-ID y lo almacena + en el estado de la request para uso posterior. + """ + + # Rutas que no requieren tenant + EXCLUDED_PATHS = { + "/health", + "/", + "/v1/auth/login", + "/docs", + "/openapi.json", + "/redoc" + } + + async def dispatch(self, request: Request, call_next) -> Response: + """Process request and add tenant information.""" + + # Skip tenant validation for excluded paths + if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"): + return await call_next(request) + + # Extract tenant from header + tenant_id = request.headers.get("X-Tenant-ID") + tenant_slug = request.headers.get("X-Tenant-Slug") + + # For now, we'll be more permissive in development + # In production, tenant should be strictly required + if not tenant_id and not tenant_slug: + logger.warning( + "Request without tenant information", + path=request.url.path, + method=request.method + ) + # For now, continue without tenant for development + # raise HTTPException( + # status_code=status.HTTP_400_BAD_REQUEST, + # detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)" + # ) + + # Store tenant info in request state + request.state.tenant_id = tenant_id + request.state.tenant_slug = tenant_slug + + # TODO: Validate tenant exists and is active + # This would involve a database query which we'll implement later + + logger.debug( + "Tenant middleware processed", + tenant_id=tenant_id, + tenant_slug=tenant_slug, + path=request.url.path + ) + + return await call_next(request) + +================================================== +ARCHIVO: .\backend\app\models\category.py +================================================== + +""" +Category Model - ServiceManagerWeb +""" +from sqlalchemy import String, Text, Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from typing import List, Optional +import uuid + +from app.core.database import Base + +class Category(Base): + __tablename__ = "categories" + + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + # Optional: Tenant specific categories? + tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True) + + # Relationships + tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category") + tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported + + def __repr__(self) -> str: + return f"" + + +================================================== +ARCHIVO: .\backend\app\models\client.py +================================================== +from sqlalchemy import Column, Integer, String +from app.core.database import Base + +class Client(Base): + __tablename__ = "clients" + + id = Column(Integer, primary_key=True, index=True) + clave = Column(String, nullable=False) + tipo_cliente = Column(String, nullable=False) + nombre = Column(String, nullable=False) + pais = Column(String, nullable=False) + estado = Column(String, nullable=False) + ciudad = Column(String, nullable=False) + direccion = Column(String, nullable=False) + numero_ext = Column(String, nullable=True) + cp = Column(String, nullable=True) + colonia = Column(String, nullable=True) + lada = Column(String, nullable=True) + telefono1 = Column(String, nullable=True) + telefono2 = Column(String, nullable=True) + tel_directo = Column(String, nullable=True) + ext = Column(String, nullable=True) + fax = Column(String, nullable=True) + horario = Column(String, nullable=True) + pagina = Column(String, nullable=True) + correo = Column(String, nullable=True) + medio_publicidad = Column(String, nullable=True) + nacionalidad = Column(String, nullable=True) + logo = Column(String, nullable=True) + +================================================== +ARCHIVO: .\backend\app\models\system.py +================================================== + +""" +System Model - ServiceManagerWeb +""" +from sqlalchemy import String, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List, Optional +import uuid + +from app.core.database import Base + +class System(Base): + __tablename__ = "systems" + + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + # Relationships + # If we want tickets to link to systems, we will add relationship in Ticket later or now. + # We will assume Ticket links to System. + tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="system") + + def __repr__(self) -> str: + return f"" + + +================================================== +ARCHIVO: .\backend\app\models\tenant.py +================================================== +""" +Tenant Model - ServiceManagerWeb + +Modelo para organizaciones cliente (multi-tenancy) +""" + +from sqlalchemy import String, Integer, Text, Boolean, ARRAY +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, ENUM +from typing import List, Optional +import enum +import uuid + +from app.core.database import Base + + +class TenantStatus(str, enum.Enum): + """Estados de un tenant.""" + ACTIVE = "active" + SUSPENDED = "suspended" + INACTIVE = "inactive" + + +class Tenant(Base): + """Modelo de Tenant (Organización cliente).""" + + __tablename__ = "tenants" + + # Información básica + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + domain: Mapped[Optional[str]] = mapped_column(String(255)) + logo_url: Mapped[Optional[str]] = mapped_column(String(500)) + + # Contacto + contact_email: Mapped[Optional[str]] = mapped_column(String(320)) + contact_phone: Mapped[Optional[str]] = mapped_column(String(20)) + address: Mapped[Optional[str]] = mapped_column(Text) + + # Configuración regional + timezone: Mapped[str] = mapped_column(String(50), default="UTC") + locale: Mapped[str] = mapped_column(String(10), default="es-ES") + + # Límites y configuración + max_users: Mapped[int] = mapped_column(Integer, default=50) + max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024) + allowed_file_types: Mapped[List[str]] = mapped_column( + ARRAY(String), + default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"] + ) + + # Estado + status: Mapped[TenantStatus] = mapped_column( + String(20), + default=TenantStatus.ACTIVE + ) + + # Relaciones + users: Mapped[List["User"]] = relationship("User", back_populates="tenant") + tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant") + + def __repr__(self) -> str: + return f"" + + @property + def is_active(self) -> bool: + """Check if tenant is active.""" + return self.status == TenantStatus.ACTIVE + +================================================== +ARCHIVO: .\backend\app\models\ticket.py +================================================== +""" +Ticket Model - ServiceManagerWeb +""" +from sqlalchemy import String, ForeignKey, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, ENUM +from typing import Optional +import enum +import uuid + +from app.core.database import Base + +class TicketStatus(str, enum.Enum): + NEW = "NEW" + TRIAGE = "TRIAGE" + IN_PROGRESS = "IN_PROGRESS" + WAITING_FOR_CLIENT = "WAITING_FOR_CLIENT" + RESOLVED = "RESOLVED" + CLOSED = "CLOSED" + REOPENED = "REOPENED" + +class TicketPriority(str, enum.Enum): + LOW = "LOW" + MEDIUM = "MEDIUM" + HIGH = "HIGH" + URGENT = "URGENT" + +class Ticket(Base): + __tablename__ = "tickets" + + # Note: id, created_at, updated_at are inherited from Base + + tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False) + + ticket_number: Mapped[str] = mapped_column(String(20), nullable=False) + subject: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + + status: Mapped[TicketStatus] = mapped_column(ENUM(TicketStatus, name="ticket_status_enum", create_type=False), default=TicketStatus.NEW) + priority: Mapped[TicketPriority] = mapped_column(ENUM(TicketPriority, name="ticket_priority_enum", create_type=False), default=TicketPriority.MEDIUM) + + # Foreign Keys + created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + + system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True) + category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True) + + # Relationships + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets") + + system: Mapped["System"] = relationship("System", back_populates="tickets") + category: Mapped["Category"] = relationship("Category", back_populates="tickets") + + created_by_user: Mapped["User"] = relationship( + "User", + foreign_keys=[created_by], + back_populates="created_tickets" + ) + + assigned_to_user: Mapped[Optional["User"]] = relationship( + "User", + foreign_keys=[assigned_to], + back_populates="assigned_tickets" + ) + + +================================================== +ARCHIVO: .\backend\app\models\user.py +================================================== +""" +User Model - ServiceManagerWeb + +Modelo para usuarios del sistema (internos y clientes) +""" + +from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, ENUM +from typing import Optional, List +import enum +import uuid +from datetime import datetime + +from app.core.database import Base + + +class UserRole(str, enum.Enum): + """Roles de usuario en el sistema.""" + # Staff interno + ADMIN = "ADMIN" # Control total + SUPPORT_MANAGER = "SUPPORT_MANAGER" # Gestión de equipos y SLAs + AGENT = "AGENT" # Atención de tickets + AUDITOR = "AUDITOR" # Solo lectura para auditoría + + # Clientes + CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente + CLIENT_USER = "CLIENT_USER" # Usuario final cliente + + +class User(Base): + """Modelo de Usuario.""" + + __tablename__ = "users" + + # Relación con tenant + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False + ) + + # Información básica + email: Mapped[str] = mapped_column(String(320), nullable=False) + first_name: Mapped[str] = mapped_column(String(100), nullable=False) + last_name: Mapped[str] = mapped_column(String(100), nullable=False) + avatar_url: Mapped[Optional[str]] = mapped_column(String(500)) + + # Autenticación + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column(ENUM(UserRole), nullable=False) + + # 2FA (opcional para staff interno) + totp_secret: Mapped[Optional[str]] = mapped_column(String(32)) + totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String)) + + # Estado + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + email_verified: Mapped[bool] = mapped_column(Boolean, default=False) + last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + last_activity: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + # Preferencias + language: Mapped[str] = mapped_column(String(10), default="es") + timezone: Mapped[str] = mapped_column(String(50), default="UTC") + notifications_email: Mapped[bool] = mapped_column(Boolean, default=True) + + # Relaciones + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users") + created_tickets: Mapped[List["Ticket"]] = relationship( + "Ticket", + back_populates="created_by_user", + foreign_keys="Ticket.created_by" + ) + assigned_tickets: Mapped[List["Ticket"]] = relationship( + "Ticket", + back_populates="assigned_to_user", + foreign_keys="Ticket.assigned_to" + ) + + # Unique constraint por tenant + __table_args__ = ( + {"postgresql_tablespace": "users"}, + ) + + def __repr__(self) -> str: + return f"" + + @property + def full_name(self) -> str: + """Get user's full name.""" + return f"{self.first_name} {self.last_name}" + + @property + def is_staff(self) -> bool: + """Check if user is internal staff.""" + return self.role in [ + UserRole.ADMIN, + UserRole.SUPPORT_MANAGER, + UserRole.AGENT, + UserRole.AUDITOR + ] + + @property + def is_client(self) -> bool: + """Check if user is a client.""" + return self.role in [ + UserRole.CLIENT_ADMIN, + UserRole.CLIENT_USER + ] + + @property + def can_manage_users(self) -> bool: + """Check if user can manage other users.""" + return self.role in [ + UserRole.ADMIN, + UserRole.SUPPORT_MANAGER, + UserRole.CLIENT_ADMIN + ] + + @property + def can_manage_tickets(self) -> bool: + """Check if user can manage tickets.""" + return self.role in [ + UserRole.ADMIN, + UserRole.SUPPORT_MANAGER, + UserRole.AGENT + ] + + @property + def requires_2fa(self) -> bool: + """Check if 2FA is required for this user.""" + # 2FA opcional para staff interno, no requerido para clientes + return self.is_staff + +================================================== +ARCHIVO: .\backend\app\schemas\client.py +================================================== +from pydantic import BaseModel + +class ClientBase(BaseModel): + clave: str + tipo_cliente: str + nombre: str + pais: str + estado: str + ciudad: str + direccion: str + numero_ext: str | None = None + cp: str | None = None + colonia: str | None = None + lada: str | None = None + telefono1: str | None = None + telefono2: str | None = None + tel_directo: str | None = None + ext: str | None = None + fax: str | None = None + horario: str | None = None + pagina: str | None = None + correo: str | None = None + medio_publicidad: str | None = None + nacionalidad: str | None = None + logo: str | None = None + +class ClientCreate(ClientBase): + pass + +class ClientUpdate(ClientBase): + pass + +class Client(ClientBase): + id: int + + class Config: + orm_mode = True + +================================================== +ARCHIVO: .\backend\tests\test_clients.py +================================================== +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + +@pytest.fixture +def sample_client_data(): + return { + "clave": "12345", + "tipo_cliente": "Regular", + "nombre": "Cliente Prueba", + "pais": "México", + "estado": "Chihuahua", + "ciudad": "Cd. Juárez", + "direccion": "Calle Falsa 123", + "numero_ext": "12", + "cp": "32000", + "colonia": "Centro", + "lada": "656", + "telefono1": "1234567890", + "telefono2": "0987654321", + "tel_directo": "1231231234", + "ext": "101", + "fax": "1231231235", + "horario": "9:00 - 18:00", + "pagina": "www.clienteprueba.com", + "correo": "cliente@prueba.com", + "medio_publicidad": "Internet", + "nacionalidad": "Mexicana", + "logo": "logo.png" + } + +def test_create_client(sample_client_data): + response = client.post("/api/v1/clients", json=sample_client_data) + assert response.status_code == 200 + assert response.json()["clave"] == sample_client_data["clave"] + +def test_read_client(sample_client_data): + # Create a client first + create_response = client.post("/api/v1/clients", json=sample_client_data) + client_id = create_response.json()["id"] + + # Read the client + response = client.get(f"/api/v1/clients/{client_id}") + assert response.status_code == 200 + assert response.json()["id"] == client_id + +def test_update_client(sample_client_data): + # Create a client first + create_response = client.post("/api/v1/clients", json=sample_client_data) + client_id = create_response.json()["id"] + + # Update the client + updated_data = {"nombre": "Cliente Actualizado"} + response = client.put(f"/api/v1/clients/{client_id}", json=updated_data) + assert response.status_code == 200 + assert response.json()["nombre"] == "Cliente Actualizado" + +def test_delete_client(sample_client_data): + # Create a client first + create_response = client.post("/api/v1/clients", json=sample_client_data) + client_id = create_response.json()["id"] + + # Delete the client + response = client.delete(f"/api/v1/clients/{client_id}") + assert response.status_code == 200 + + # Verify deletion + response = client.get(f"/api/v1/clients/{client_id}") + assert response.status_code == 404 + +================================================== +ARCHIVO: .\db\schema.sql +================================================== +-- ServiceManagerWeb - Esquema de Base de Datos +-- Sistema multi-tenant de Mesa de Ayuda B2B +-- PostgreSQL 15+ + +-- =================================== +-- DOMINIO: TENANTS (Multi-tenancy) +-- =================================== + +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + domain VARCHAR(255), + logo_url VARCHAR(500), + contact_email VARCHAR(320), + contact_phone VARCHAR(20), + address TEXT, + timezone VARCHAR(50) DEFAULT 'UTC', + locale VARCHAR(10) DEFAULT 'es-ES', + + -- Settings + max_users INTEGER DEFAULT 50, + max_storage_mb INTEGER DEFAULT 1024, + allowed_file_types TEXT[] DEFAULT ARRAY['pdf','jpg','jpeg','png','doc','docx','xls','xlsx','txt'], + + -- Status + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'inactive')), + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + CONSTRAINT tenants_slug_format CHECK (slug ~ '^[a-z0-9-]+$') +); + +-- Índices para tenants +CREATE INDEX idx_tenants_slug ON tenants(slug); +CREATE INDEX idx_tenants_status ON tenants(status); +CREATE INDEX idx_tenants_domain ON tenants(domain); + +-- =================================== +-- DOMINIO: CLIENTS (Cartera de Clientes) +-- =================================== + +-- Enum para los radio buttons de estatus +CREATE TYPE client_status_enum AS ENUM ( + 'prospect', + 'active', + 'suspended', + 'cancelled' +); + +CREATE TABLE clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Identificación (Sección General) + code VARCHAR(50), -- Clave del cliente + name VARCHAR(200) NOT NULL, -- Nombre / Razón Social + tax_id VARCHAR(20), -- RFC + client_type VARCHAR(50), -- Tipo de Cliente + account_manager VARCHAR(100), -- Encargado Cliente (Texto simple por ahora) + + -- Dirección (Desglosada) + address_street TEXT, -- Dirección / Calle + address_ext_num VARCHAR(20), -- Núm. Ext + neighborhood VARCHAR(100), -- Colonia + zip_code VARCHAR(10), -- CP + city VARCHAR(100), -- Ciudad + state VARCHAR(100), -- Estado + country VARCHAR(100) DEFAULT 'Mexico', + + -- Contacto + phone_primary VARCHAR(20), -- Teléfono 1 + phone_secondary VARCHAR(20),-- Teléfono 2 + fax VARCHAR(20), + email VARCHAR(320), -- Correo + website VARCHAR(255), -- Página Web + + -- Configuración y Flexibilidad + status client_status_enum DEFAULT 'prospect', + logo_url VARCHAR(500), -- Ruta al archivo PDF/Imagen del logo + + -- Campo JSONB para lo que sobre (Horario, Nacionalidad, Medio Publicidad, Lada, etc.) + properties JSONB DEFAULT '{}'::jsonb, + + -- Auditoría estándar + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Restricciones: No repetir RFC ni Clave dentro del mismo Tenant + UNIQUE(tenant_id, code), + UNIQUE(tenant_id, tax_id) +); + +-- Índices para búsqueda rápida +CREATE INDEX idx_clients_tenant_id ON clients(tenant_id); +CREATE INDEX idx_clients_tax_id ON clients(tax_id); +CREATE INDEX idx_clients_name ON clients(name); +CREATE INDEX idx_clients_properties ON clients USING gin (properties); + +-- Trigger para mantener actualizado el campo updated_at +-- (Usa la función que ya definiste al final de tu script) +CREATE TRIGGER update_clients_updated_at BEFORE UPDATE ON clients + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + +-- =================================== +-- DOMINIO: AUTH (Autenticación) +-- =================================== + +CREATE TYPE user_role_enum AS ENUM ( + 'ADMIN', -- Control total (staff interno) + 'SUPPORT_MANAGER', -- Gestión de equipos y SLAs + 'AGENT', -- Atención de tickets + 'AUDITOR', -- Solo lectura para auditoría + 'CLIENT_ADMIN', -- Admin de organización cliente + 'CLIENT_USER' -- Usuario final cliente +); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Básicos + email VARCHAR(320) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + avatar_url VARCHAR(500), + + -- Auth + password_hash VARCHAR(255) NOT NULL, + role user_role_enum NOT NULL, + + -- 2FA (opcional para staff interno) + totp_secret VARCHAR(32), + totp_enabled BOOLEAN DEFAULT FALSE, + backup_codes TEXT[], -- códigos de recuperación + + -- Status + is_active BOOLEAN DEFAULT TRUE, + email_verified BOOLEAN DEFAULT FALSE, + last_login TIMESTAMP WITH TIME ZONE, + last_activity TIMESTAMP WITH TIME ZONE, + + -- Preferences + language VARCHAR(10) DEFAULT 'es', + timezone VARCHAR(50) DEFAULT 'UTC', + notifications_email BOOLEAN DEFAULT TRUE, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(tenant_id, email) +); + +-- Índices para users +CREATE INDEX idx_users_tenant_id ON users(tenant_id); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_role ON users(role); +CREATE INDEX idx_users_active ON users(is_active); +CREATE INDEX idx_users_tenant_email ON users(tenant_id, email); + +-- Tabla para refresh tokens +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + device_info VARCHAR(500), + ip_address INET, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + revoked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para refresh_tokens +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash); +CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at); + +-- =================================== +-- DOMINIO: TICKETS (Core del negocio) +-- =================================== + +CREATE TYPE ticket_status_enum AS ENUM ( + 'NEW', + 'TRIAGE', + 'IN_PROGRESS', + 'WAITING_CUSTOMER', + 'RESOLVED', + 'CLOSED', + 'REOPENED' +); + +CREATE TYPE ticket_priority_enum AS ENUM ( + 'LOW', + 'MEDIUM', + 'HIGH', + 'URGENT' +); + +-- Categorías de tickets por tenant +CREATE TABLE ticket_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT, + color VARCHAR(7), -- hex color + sla_response_hours INTEGER DEFAULT 24, + sla_resolution_hours INTEGER DEFAULT 72, + auto_assign_to UUID REFERENCES users(id), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(tenant_id, name) +); + +-- Sistemas afectados por tenant +CREATE TABLE affected_systems ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(tenant_id, name) +); + +-- Tickets principales +CREATE TABLE tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + ticket_number VARCHAR(20) NOT NULL, -- formato: TKT-2024-000001 + + -- Básicos + subject VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + + -- Clasificación + category_id UUID REFERENCES ticket_categories(id), + priority ticket_priority_enum DEFAULT 'MEDIUM', + affected_system_id UUID REFERENCES affected_systems(id), + + -- Asignación + created_by UUID NOT NULL REFERENCES users(id), + assigned_to UUID REFERENCES users(id), + + -- Estado + status ticket_status_enum DEFAULT 'NEW', + + -- SLA tracking + sla_response_due TIMESTAMP WITH TIME ZONE, + sla_resolution_due TIMESTAMP WITH TIME ZONE, + first_response_at TIMESTAMP WITH TIME ZONE, + resolved_at TIMESTAMP WITH TIME ZONE, + + -- CSAT (Customer Satisfaction) + rating INTEGER CHECK (rating >= 1 AND rating <= 5), + rating_comment TEXT, + rated_at TIMESTAMP WITH TIME ZONE, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(tenant_id, ticket_number) +); + +-- Índices para tickets +CREATE INDEX idx_tickets_tenant_id ON tickets(tenant_id); +CREATE INDEX idx_tickets_number ON tickets(ticket_number); +CREATE INDEX idx_tickets_created_by ON tickets(created_by); +CREATE INDEX idx_tickets_assigned_to ON tickets(assigned_to); +CREATE INDEX idx_tickets_status ON tickets(status); +CREATE INDEX idx_tickets_priority ON tickets(priority); +CREATE INDEX idx_tickets_category ON tickets(category_id); +CREATE INDEX idx_tickets_sla_response ON tickets(sla_response_due); +CREATE INDEX idx_tickets_sla_resolution ON tickets(sla_resolution_due); +CREATE INDEX idx_tickets_created_at ON tickets(created_at); + +-- Comentarios en tickets +CREATE TABLE ticket_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id), + + content TEXT NOT NULL, + is_internal BOOLEAN DEFAULT FALSE, -- solo visible para staff interno + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para comentarios +CREATE INDEX idx_ticket_comments_ticket_id ON ticket_comments(ticket_id); +CREATE INDEX idx_ticket_comments_author_id ON ticket_comments(author_id); +CREATE INDEX idx_ticket_comments_created_at ON ticket_comments(created_at); + +-- Adjuntos en tickets/comentarios +CREATE TABLE ticket_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + comment_id UUID REFERENCES ticket_comments(id) ON DELETE CASCADE, + uploaded_by UUID NOT NULL REFERENCES users(id), + + -- Archivo + filename VARCHAR(255) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + file_size INTEGER NOT NULL, -- bytes + file_path VARCHAR(500) NOT NULL, -- path en storage + + -- Checksums para integridad + md5_hash VARCHAR(32), + sha256_hash VARCHAR(64), + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para adjuntos +CREATE INDEX idx_ticket_attachments_ticket_id ON ticket_attachments(ticket_id); +CREATE INDEX idx_ticket_attachments_comment_id ON ticket_attachments(comment_id); +CREATE INDEX idx_ticket_attachments_uploaded_by ON ticket_attachments(uploaded_by); + +-- Historial de cambios de estado +CREATE TABLE ticket_status_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + changed_by UUID NOT NULL REFERENCES users(id), + + old_status ticket_status_enum, + new_status ticket_status_enum NOT NULL, + old_assigned_to UUID REFERENCES users(id), + new_assigned_to UUID REFERENCES users(id), + + comment TEXT, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para historial de estados +CREATE INDEX idx_ticket_status_history_ticket_id ON ticket_status_history(ticket_id); +CREATE INDEX idx_ticket_status_history_changed_by ON ticket_status_history(changed_by); +CREATE INDEX idx_ticket_status_history_created_at ON ticket_status_history(created_at); + +-- =================================== +-- DOMINIO: NOTIFICATIONS (Comunicaciones) +-- =================================== + +-- Templates de email +CREATE TABLE email_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID REFERENCES tenants(id), -- NULL = template global + + name VARCHAR(100) NOT NULL, + subject_template TEXT NOT NULL, + body_template TEXT NOT NULL, + template_type VARCHAR(50) NOT NULL, -- ticket_created, ticket_assigned, etc. + + -- Variables disponibles en formato JSON + available_variables JSONB, + + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para templates +CREATE INDEX idx_email_templates_tenant_id ON email_templates(tenant_id); +CREATE INDEX idx_email_templates_type ON email_templates(template_type); + +-- Log de notificaciones enviadas +CREATE TABLE notification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + + recipient_email VARCHAR(320) NOT NULL, + subject VARCHAR(500) NOT NULL, + template_type VARCHAR(50), + + -- Metadata + ticket_id UUID REFERENCES tickets(id), + user_id UUID REFERENCES users(id), + + -- Estado del envío + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed', 'bounced')), + error_message TEXT, + + -- Proveedor (ej: sendgrid, ses, smtp) + provider VARCHAR(50), + external_id VARCHAR(255), -- ID del proveedor + + sent_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para logs de notificaciones +CREATE INDEX idx_notification_logs_tenant_id ON notification_logs(tenant_id); +CREATE INDEX idx_notification_logs_recipient ON notification_logs(recipient_email); +CREATE INDEX idx_notification_logs_ticket_id ON notification_logs(ticket_id); +CREATE INDEX idx_notification_logs_status ON notification_logs(status); +CREATE INDEX idx_notification_logs_created_at ON notification_logs(created_at); + +-- =================================== +-- DOMINIO: AUDIT (Bitácora) +-- =================================== + +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + user_id UUID REFERENCES users(id), -- NULL para acciones del sistema + + -- Acción + action VARCHAR(100) NOT NULL, -- ej: user.login, ticket.create, ticket.assign + resource_type VARCHAR(50) NOT NULL, -- user, ticket, comment, etc. + resource_id UUID, + + -- Contexto + ip_address INET, + user_agent TEXT, + correlation_id UUID, -- para rastrear requests + + -- Cambios (antes/después en JSON) + old_values JSONB, + new_values JSONB, + + -- Metadata adicional + metadata JSONB, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Índices para audit logs +CREATE INDEX idx_audit_logs_tenant_id ON audit_logs(tenant_id); +CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id); +CREATE INDEX idx_audit_logs_correlation_id ON audit_logs(correlation_id); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); + +-- =================================== +-- FUNCIONES Y TRIGGERS +-- =================================== + +-- Función para actualizar updated_at automáticamente +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Triggers para updated_at +CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_tickets_updated_at BEFORE UPDATE ON tickets + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_ticket_comments_updated_at BEFORE UPDATE ON ticket_comments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_email_templates_updated_at BEFORE UPDATE ON email_templates + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- =================================== +-- DATOS INICIALES (SEEDS) +-- =================================== + +-- Tenant de ejemplo para desarrollo +INSERT INTO tenants (name, slug, contact_email) VALUES +('Aduanasoft Demo', 'aduanasoft-demo', 'demo@aduanasoft.com'); + +-- Usuario admin por defecto (password: admin123) +-- Hash generado con Argon2: $argon2id$v=19$m=65536,t=3,p=4$... +INSERT INTO users (tenant_id, email, first_name, last_name, password_hash, role, is_active, email_verified) +SELECT + id, + 'admin@aduanasoft.com', + 'Admin', + 'Sistema', + '$argon2id$v=19$m=65536,t=3,p=4$example_hash_here', + 'ADMIN', + true, + true +FROM tenants WHERE slug = 'aduanasoft-demo'; + +-- Categorías por defecto +INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours) +SELECT + id, + 'Soporte Técnico', + 'Problemas técnicos generales', + 2, + 24 +FROM tenants WHERE slug = 'aduanasoft-demo' +UNION ALL +SELECT + id, + 'Consulta Comercial', + 'Preguntas sobre productos y servicios', + 4, + 48 +FROM tenants WHERE slug = 'aduanasoft-demo' +UNION ALL +SELECT + id, + 'Incidente Crítico', + 'Problemas que afectan operaciones', + 1, + 8 +FROM tenants WHERE slug = 'aduanasoft-demo'; + +-- Sistemas afectados por defecto +INSERT INTO affected_systems (tenant_id, name, description) +SELECT + id, + 'Plataforma Web', + 'Sistema web principal' +FROM tenants WHERE slug = 'aduanasoft-demo' +UNION ALL +SELECT + id, + 'API', + 'Servicios de API' +FROM tenants WHERE slug = 'aduanasoft-demo' +UNION ALL +SELECT + id, + 'Base de Datos', + 'Sistemas de almacenamiento' +FROM tenants WHERE slug = 'aduanasoft-demo'; + +-- Templates de email básicos +INSERT INTO email_templates (name, subject_template, body_template, template_type, available_variables) +VALUES +( + 'Ticket Creado', + 'Nuevo ticket #{{ticket_number}}: {{subject}}', + 'Hola {{user_name}},\n\nSe ha creado un nuevo ticket:\n\nNúmero: #{{ticket_number}}\nAsunto: {{subject}}\nPrioridad: {{priority}}\n\nPuedes ver los detalles en: {{ticket_url}}\n\nSaludos,\nEquipo de Soporte', + 'ticket_created', + '{"ticket_number": "string", "subject": "string", "priority": "string", "user_name": "string", "ticket_url": "string"}' +), +( + 'Ticket Asignado', + 'Ticket #{{ticket_number}} asignado a ti', + 'Hola {{agent_name}},\n\nSe te ha asignado el ticket:\n\nNúmero: #{{ticket_number}}\nAsunto: {{subject}}\nCliente: {{customer_name}}\nPrioridad: {{priority}}\n\nPuedes verlo en: {{ticket_url}}\n\nSaludos,\nSistema de Tickets', + 'ticket_assigned', + '{"ticket_number": "string", "subject": "string", "agent_name": "string", "customer_name": "string", "priority": "string", "ticket_url": "string"}' +); + +-- =================================== +-- COMENTARIOS Y DOCUMENTACIÓN +-- =================================== + +COMMENT ON TABLE tenants IS 'Organizaciones cliente en el sistema multi-tenant'; +COMMENT ON TABLE users IS 'Usuarios del sistema (internos y clientes)'; +COMMENT ON TABLE tickets IS 'Tickets de soporte - core del negocio'; +COMMENT ON TABLE ticket_comments IS 'Comentarios en tickets'; +COMMENT ON TABLE ticket_attachments IS 'Archivos adjuntos en tickets'; +COMMENT ON TABLE audit_logs IS 'Bitácora de acciones para auditoría y compliance'; + +================================================== +ARCHIVO: .\docs\api-contract.md +================================================== +# API Contract - ServiceManagerWeb +# Mesa de Ayuda B2B - Especificación de Endpoints + +## Base URL +- Desarrollo: `http://localhost:8000` +- Producción: `https://api.servicemanager.aduanasoft.com` + +## Versionado +- Todos los endpoints tienen prefijo `/v1/` +- Versionado en URL path (no headers) + +## Autenticación +- JWT Bearer Token en header `Authorization: Bearer ` +- Refresh token para renovación automática + +## Headers Estándar +``` +Authorization: Bearer +Content-Type: application/json +X-Tenant-ID: # Requerido para endpoints multi-tenant +X-Correlation-ID: # Opcional para tracking +Accept-Language: es-ES # Para internacionalización +``` + +## Responses Estándar + +### Éxito (2xx) +```json +{ + "success": true, + "data": { ... }, + "message": "Operación exitosa", + "metadata": { + "page": 1, + "per_page": 20, + "total": 150, + "total_pages": 8 + } +} +``` + +### Error (4xx/5xx) +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Datos inválidos", + "details": [ + { + "field": "email", + "message": "Email inválido" + } + ] + }, + "correlation_id": "uuid" +} +``` + +--- + +## DOMINIO: AUTH + +### POST /v1/auth/login +**Descripción**: Autenticación de usuario +**Público**: Sí + +**Request Body**: +```json +{ + "email": "user@example.com", + "password": "password123", + "tenant_slug": "aduanasoft-demo", + "totp_code": "123456" // opcional, solo si 2FA activado +} +``` + +**Response 200**: +```json +{ + "success": true, + "data": { + "access_token": "jwt_token", + "refresh_token": "refresh_token", + "expires_in": 3600, + "user": { + "id": "uuid", + "email": "user@example.com", + "first_name": "Juan", + "last_name": "Pérez", + "role": "AGENT", + "tenant": { + "id": "uuid", + "name": "Aduanasoft Demo", + "slug": "aduanasoft-demo" + } + } + } +} +``` + +### POST /v1/auth/refresh +**Descripción**: Renovar access token +**Público**: Sí + +**Request Body**: +```json +{ + "refresh_token": "refresh_token" +} +``` + +### POST /v1/auth/logout +**Descripción**: Cerrar sesión (revoca refresh token) +**Autenticado**: Sí + +### GET /v1/auth/me +**Descripción**: Información del usuario actual +**Autenticado**: Sí + +### PUT /v1/auth/me +**Descripción**: Actualizar perfil propio +**Autenticado**: Sí + +**Request Body**: +```json +{ + "first_name": "Juan", + "last_name": "Pérez", + "language": "es", + "timezone": "America/Mexico_City", + "notifications_email": true +} +``` + +### POST /v1/auth/change-password +**Descripción**: Cambiar contraseña +**Autenticado**: Sí + +### POST /v1/auth/2fa/setup +**Descripción**: Configurar 2FA (solo roles internos) +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR + +### POST /v1/auth/2fa/verify +**Descripción**: Verificar código 2FA durante setup +**Autenticado**: Sí + +--- + +## DOMINIO: TENANTS + +### GET /v1/tenants/current +**Descripción**: Información del tenant actual +**Autenticado**: Sí + +### PUT /v1/tenants/current +**Descripción**: Actualizar tenant (solo CLIENT_ADMIN/ADMIN) +**Autenticado**: Sí, Roles: CLIENT_ADMIN, ADMIN + +### GET /v1/tenants (solo ADMIN) +**Descripción**: Listar todos los tenants +**Autenticado**: Sí, Roles: ADMIN + +### POST /v1/tenants (solo ADMIN) +**Descripción**: Crear nuevo tenant +**Autenticado**: Sí, Roles: ADMIN + +--- + +## DOMINIO: USERS + +### GET /v1/users +**Descripción**: Listar usuarios del tenant +**Autenticado**: Sí +**Roles**: Todos (filtros por rol) + +**Query Params**: +``` +?page=1&per_page=20&role=AGENT&is_active=true&search=juan +``` + +**Response 200**: +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "email": "agent@example.com", + "first_name": "Juan", + "last_name": "Agente", + "role": "AGENT", + "is_active": true, + "email_verified": true, + "last_login": "2024-01-15T10:30:00Z", + "created_at": "2024-01-01T00:00:00Z" + } + ], + "metadata": { + "page": 1, + "per_page": 20, + "total": 150, + "total_pages": 8 + } +} +``` + +### POST /v1/users +**Descripción**: Crear usuario +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, CLIENT_ADMIN + +**Request Body**: +```json +{ + "email": "nuevo@example.com", + "first_name": "Nuevo", + "last_name": "Usuario", + "role": "AGENT", + "password": "temporal123", // opcional, se genera automáticamente + "send_welcome_email": true +} +``` + +### GET /v1/users/{user_id} +**Descripción**: Obtener usuario específico +**Autenticado**: Sí + +### PUT /v1/users/{user_id} +**Descripción**: Actualizar usuario +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, CLIENT_ADMIN + +### DELETE /v1/users/{user_id} +**Descripción**: Desactivar usuario (soft delete) +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER, CLIENT_ADMIN + +--- + +## DOMINIO: TICKETS + +### GET /v1/tickets +**Descripción**: Listar tickets con filtros +**Autenticado**: Sí + +**Query Params**: +``` +?page=1&per_page=20 +&status=NEW,IN_PROGRESS +&priority=HIGH,URGENT +&assigned_to=uuid +&created_by=uuid +&category_id=uuid +&search=problema+conexion +&sort=created_at_desc +&date_from=2024-01-01 +&date_to=2024-01-31 +``` + +**Response 200**: +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "ticket_number": "TKT-2024-000001", + "subject": "Problema de conexión", + "status": "IN_PROGRESS", + "priority": "HIGH", + "category": { + "id": "uuid", + "name": "Soporte Técnico" + }, + "created_by": { + "id": "uuid", + "first_name": "Juan", + "last_name": "Cliente" + }, + "assigned_to": { + "id": "uuid", + "first_name": "Ana", + "last_name": "Soporte" + }, + "sla_response_due": "2024-01-15T12:00:00Z", + "sla_resolution_due": "2024-01-16T10:00:00Z", + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T11:30:00Z" + } + ], + "metadata": { + "page": 1, + "per_page": 20, + "total": 150, + "total_pages": 8 + } +} +``` + +### POST /v1/tickets +**Descripción**: Crear nuevo ticket +**Autenticado**: Sí + +**Request Body**: +```json +{ + "subject": "Problema de conexión con el sistema", + "description": "Descripción detallada del problema...", + "priority": "HIGH", + "category_id": "uuid", + "affected_system_id": "uuid", + "attachments": [ + { + "filename": "screenshot.png", + "content_type": "image/png", + "content_base64": "base64_data" + } + ] +} +``` + +**Response 201**: +```json +{ + "success": true, + "data": { + "id": "uuid", + "ticket_number": "TKT-2024-000001", + "subject": "Problema de conexión con el sistema", + "status": "NEW", + "sla_response_due": "2024-01-15T12:00:00Z", + "created_at": "2024-01-15T10:00:00Z" + } +} +``` + +### GET /v1/tickets/{ticket_id} +**Descripción**: Obtener ticket completo con comentarios +**Autenticado**: Sí + +**Response 200**: +```json +{ + "success": true, + "data": { + "id": "uuid", + "ticket_number": "TKT-2024-000001", + "subject": "Problema de conexión", + "description": "Descripción completa...", + "status": "IN_PROGRESS", + "priority": "HIGH", + "category": {...}, + "affected_system": {...}, + "created_by": {...}, + "assigned_to": {...}, + "attachments": [...], + "comments": [ + { + "id": "uuid", + "content": "Comentario del ticket...", + "author": {...}, + "is_internal": false, + "attachments": [...], + "created_at": "2024-01-15T11:00:00Z" + } + ], + "status_history": [...], + "sla_metrics": { + "response_due": "2024-01-15T12:00:00Z", + "resolution_due": "2024-01-16T10:00:00Z", + "first_response_at": "2024-01-15T11:15:00Z", + "response_sla_met": true, + "resolution_sla_met": null + }, + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T11:30:00Z" + } +} +``` + +### PUT /v1/tickets/{ticket_id} +**Descripción**: Actualizar ticket (estado, asignación, etc.) +**Autenticado**: Sí + +**Request Body**: +```json +{ + "status": "IN_PROGRESS", + "assigned_to": "uuid", + "priority": "URGENT", + "comment": "Escalando por alta prioridad" +} +``` + +### POST /v1/tickets/{ticket_id}/comments +**Descripción**: Agregar comentario al ticket +**Autenticado**: Sí + +**Request Body**: +```json +{ + "content": "Comentario con solución propuesta...", + "is_internal": false, + "attachments": [ + { + "filename": "solution.pdf", + "content_type": "application/pdf", + "content_base64": "base64_data" + } + ] +} +``` + +### PUT /v1/tickets/{ticket_id}/rating +**Descripción**: Calificar ticket resuelto (solo cliente) +**Autenticado**: Sí, Roles: CLIENT_ADMIN, CLIENT_USER + +**Request Body**: +```json +{ + "rating": 5, + "comment": "Excelente atención y resolución rápida" +} +``` + +--- + +## DOMINIO: CATEGORIES & SYSTEMS + +### GET /v1/categories +**Descripción**: Listar categorías del tenant +**Autenticado**: Sí + +### POST /v1/categories (solo staff interno) +**Descripción**: Crear categoría +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER + +### GET /v1/affected-systems +**Descripción**: Listar sistemas afectados +**Autenticado**: Sí + +--- + +## DOMINIO: NOTIFICATIONS + +### GET /v1/notifications/templates (solo staff interno) +**Descripción**: Listar templates de email +**Autenticado**: Sí, Roles: ADMIN, SUPPORT_MANAGER + +### POST /v1/notifications/test-email (solo ADMIN) +**Descripción**: Enviar email de prueba +**Autenticado**: Sí, Roles: ADMIN + +--- + +## DOMINIO: REPORTS & ANALYTICS + +### GET /v1/reports/dashboard +**Descripción**: Métricas del dashboard +**Autenticado**: Sí + +**Response 200**: +```json +{ + "success": true, + "data": { + "tickets": { + "total": 150, + "new": 12, + "in_progress": 45, + "waiting_customer": 8, + "resolved_today": 15 + }, + "sla": { + "response_rate": 95.5, + "resolution_rate": 87.2 + }, + "agents": { + "active": 8, + "avg_load": 5.6 + }, + "csat": { + "average": 4.2, + "total_responses": 89 + } + } +} +``` + +### GET /v1/reports/tickets +**Descripción**: Reporte de tickets con filtros +**Autenticado**: Sí + +--- + +## DOMINIO: AUDIT + +### GET /v1/audit/logs (solo AUDITOR/ADMIN) +**Descripción**: Consultar logs de auditoría +**Autenticado**: Sí, Roles: AUDITOR, ADMIN + +**Query Params**: +``` +?page=1&per_page=50 +&action=ticket.create,ticket.assign +&user_id=uuid +&resource_type=ticket +&date_from=2024-01-01 +&date_to=2024-01-31 +``` + +--- + +## CÓDIGOS DE ERROR ESTÁNDAR + +- `400` - Bad Request (datos inválidos) +- `401` - Unauthorized (no autenticado) +- `403` - Forbidden (sin permisos) +- `404` - Not Found (recurso no encontrado) +- `409` - Conflict (recurso duplicado) +- `422` - Unprocessable Entity (validación fallida) +- `429` - Too Many Requests (rate limit) +- `500` - Internal Server Error + +## RATE LIMITING + +- Auth endpoints: 10 req/min por IP +- API endpoints: 100 req/min por usuario +- File uploads: 5 req/min por usuario + +## PAGINACIÓN + +- Default: `per_page=20`, `max=100` +- Links de navegación en metadata +- Total count incluido cuando sea eficiente + +## ORDENAMIENTO + +Formato: `?sort=field_direction` +- `created_at_desc` (default) +- `updated_at_desc` +- `priority_desc` +- `status_asc` + +## BÚSQUEDA + +- Full-text search en `subject` y `description` +- Búsqueda por número de ticket exacto +- Filtros combinables con AND lógico + +================================================== +ARCHIVO: .\docs\database-schema.md +================================================== +# Modelo de Datos - ServiceManagerWeb + +## Resumen del Esquema + +El sistema utiliza PostgreSQL con un diseño multi-tenant donde cada cliente (tenant) tiene sus datos aislados pero comparte la misma estructura de base de datos. + +## Dominios Principales + +### 1. **TENANTS** - Multi-tenancy +- `tenants`: Organizaciones cliente +- Cada tenant tiene configuraciones propias (usuarios max, storage, tipos de archivo) + +### 2. **AUTH** - Autenticación y Autorización +- `users`: Usuarios del sistema (internos y clientes) +- `refresh_tokens`: Tokens de refresco para JWT +- Roles: ADMIN, SUPPORT_MANAGER, AGENT, AUDITOR, CLIENT_ADMIN, CLIENT_USER +- Soporte para 2FA (TOTP) opcional para staff interno + +### 3. **TICKETS** - Core del Negocio +- `tickets`: Tickets de soporte principales +- `ticket_categories`: Categorías personalizables por tenant +- `affected_systems`: Sistemas afectados por tenant +- `ticket_comments`: Conversación en tickets +- `ticket_attachments`: Archivos adjuntos +- `ticket_status_history`: Historial de cambios de estado + +Estados de ticket: NEW → TRIAGE → IN_PROGRESS → WAITING_CUSTOMER → RESOLVED → CLOSED +Prioridades: LOW, MEDIUM, HIGH, URGENT + +### 4. **NOTIFICATIONS** - Comunicaciones +- `email_templates`: Templates personalizables por tenant +- `notification_logs`: Historial de emails enviados +- Soporte para variables dinámicas en templates + +### 5. **AUDIT** - Bitácora y Compliance +- `audit_logs`: Registro completo de acciones +- Tracking con correlation_id para requests +- Almacena cambios antes/después en JSON + +## Características Técnicas + +### Índices Estratégicos +- Optimizados para queries por tenant +- Indices compuestos para búsquedas frecuentes +- Índices en campos de fecha para reportes + +### Constraints y Validación +- CHECK constraints para valores enum +- Foreign keys con CASCADE apropiados +- UNIQUE constraints compuestos (tenant_id + campo) + +### Triggers Automáticos +- `updated_at` se actualiza automáticamente +- Preparado para audit logging automático + +### Multi-tenancy +- Todos los datos principales tienen `tenant_id` +- Aislamiento a nivel de aplicación +- Configuraciones por tenant (SLA, categorías, etc.) + +## Numeración de Tickets + +Formato: `TKT-YYYY-NNNNNN` (ej: TKT-2026-000001) +- Único por tenant +- Año incluido para fácil organización +- 6 dígitos con ceros a la izquierda + +## SLA Tracking + +- `sla_response_due`: Tiempo límite para primera respuesta +- `sla_resolution_due`: Tiempo límite para resolución +- `first_response_at`: Timestamp de primera respuesta +- Configurables por categoría + +## Almacenamiento de Archivos + +- Metadata en BD, archivos en filesystem/S3 +- Checksums MD5 y SHA256 para integridad +- Validación de tipos MIME +- Límites de tamaño por tenant + +## Datos Iniciales + +El schema incluye: +- Tenant demo para desarrollo +- Usuario admin por defecto +- Categorías base (Soporte Técnico, Consulta Comercial, Incidente Crítico) +- Sistemas base (Plataforma Web, API, Base de Datos) +- Templates de email básicos + +## Escalabilidad + +- Preparado para sharding por tenant_id +- Partitioning por fecha en audit_logs +- Índices optimizados para paginación +- Soft deletes donde aplique + +================================================== +ARCHIVO: .\frontend-client\.eslintrc.json +================================================== +{ + "extends": [ + "eslint:recommended", + "@typescript-eslint/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "svelte3"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "browser": true, + "es2017": true, + "node": true + }, + "overrides": [ + { + "files": ["*.svelte"], + "processor": "svelte3/svelte3" + } + ], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_" } + ], + "@typescript-eslint/no-explicit-any": "warn", + "no-console": ["warn", { "allow": ["warn", "error"] }], + "prefer-const": "error" + }, + "settings": { + "svelte3/typescript": true + }, + "ignorePatterns": [ + ".svelte-kit/**", + "build/**", + "dist/**", + "node_modules/**" + ] +} + +================================================== +ARCHIVO: .\frontend-client\package.json +================================================== +{ + "name": "@servicemanager/client-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --plugin-search-dir . --check . && eslint .", + "format": "prettier --plugin-search-dir . --write .", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^1.3.1", + "@sveltejs/kit": "^1.20.4", + "@types/cookie": "^0.5.1", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "autoprefixer": "^10.4.14", + "eslint": "^8.28.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-svelte": "^2.30.0", + "postcss": "^8.4.24", + "prettier": "^2.8.0", + "prettier-plugin-svelte": "^2.10.1", + "svelte": "^4.0.5", + "svelte-check": "^3.4.3", + "tailwindcss": "^3.3.0", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^4.4.2", + "vitest": "^0.34.0" + }, + "dependencies": { + "@tailwindcss/forms": "^0.5.4", + "@tailwindcss/typography": "^0.5.9", + "@heroicons/react": "^2.0.18", + "heroicons": "^2.0.18", + "zod": "^3.22.2", + "date-fns": "^2.30.0" + } +} + +================================================== +ARCHIVO: .\frontend-client\postcss.config.js +================================================== +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +================================================== +ARCHIVO: .\frontend-client\svelte.config.js +================================================== +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/kit/vite'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter(), + + alias: { + '$components': 'src/lib/components', + '$stores': 'src/lib/stores', + '$utils': 'src/lib/utils', + '$types': 'src/lib/types' + } + } +}; + +export default config; + +================================================== +ARCHIVO: .\frontend-client\tailwind.config.js +================================================== +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{html,js,svelte,ts}'], + theme: { + extend: { + colors: { + // Brand colors for ServiceManager + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', // Main brand color + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + }, + secondary: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + }, + success: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + }, + warning: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 300: '#fcd34d', + 400: '#fbbf24', + 500: '#f59e0b', + 600: '#d97706', + 700: '#b45309', + 800: '#92400e', + 900: '#78350f', + }, + error: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + } + }, + fontFamily: { + sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'], + }, + boxShadow: { + 'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)', + 'DEFAULT': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + 'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + 'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', + 'xl': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', + '2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)', + }, + animation: { + 'fade-in': 'fadeIn 0.5s ease-in-out', + 'slide-up': 'slideUp 0.3s ease-out', + 'pulse': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + slideUp: { + '0%': { transform: 'translateY(10px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + } + } + }, + }, + plugins: [ + require('@tailwindcss/forms'), + require('@tailwindcss/typography'), + ], +}; + +================================================== +ARCHIVO: .\frontend-client\vite.config.js +================================================== +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 3000, + host: '0.0.0.0', + proxy: { + '/api': { + target: 'http://servicemanager-backend:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + }, + preview: { + port: 3000, + host: '0.0.0.0' + }, + build: { + target: 'esnext' + } +}); + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\ambient.d.ts +================================================== + +// this file is generated — do not edit it + + +/// + +/** + * Environment variables [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env`. Like [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), this module cannot be imported into client-side code. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://kit.svelte.dev/docs/configuration#env) (if configured). + * + * _Unlike_ [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), the values exported from this module are statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * ```ts + * import { API_KEY } from '$env/static/private'; + * ``` + * + * Note that all environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * + * ``` + * MY_FEATURE_FLAG="" + * ``` + * + * You can override `.env` values from the command line like so: + * + * ```bash + * MY_FEATURE_FLAG="enabled" npm run dev + * ``` + */ +declare module '$env/static/private' { + export const npm_config_user_agent: string; + export const NODE_VERSION: string; + export const HOSTNAME: string; + export const YARN_VERSION: string; + export const npm_node_execpath: string; + export const SHLVL: string; + export const npm_config_noproxy: string; + export const HOME: string; + export const npm_package_json: string; + export const npm_config_userconfig: string; + export const npm_config_local_prefix: string; + export const COLOR: string; + export const npm_config_prefix: string; + export const npm_config_npm_version: string; + export const npm_config_cache: string; + export const npm_config_node_gyp: string; + export const PATH: string; + export const NODE: string; + export const npm_package_name: string; + export const npm_lifecycle_script: string; + export const npm_package_version: string; + export const npm_lifecycle_event: string; + export const npm_config_globalconfig: string; + export const npm_config_init_module: string; + export const PWD: string; + export const npm_execpath: string; + export const npm_config_global_prefix: string; + export const npm_command: string; + export const NODE_ENV: string; + export const INIT_CWD: string; + export const EDITOR: string; +} + +/** + * Similar to [`$env/static/private`](https://kit.svelte.dev/docs/modules#$env-static-private), except that it only includes environment variables that begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Values are replaced statically at build time. + * + * ```ts + * import { PUBLIC_BASE_URL } from '$env/static/public'; + * ``` + */ +declare module '$env/static/public' { + export const PUBLIC_APP_NAME: string; + export const PUBLIC_API_URL: string; +} + +/** + * This module provides access to runtime environment variables, as defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/master/packages/adapter-node) (or running [`vite preview`](https://kit.svelte.dev/docs/cli)), this is equivalent to `process.env`. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://kit.svelte.dev/docs/configuration#env) (if configured). + * + * This module cannot be imported into client-side code. + * + * ```ts + * import { env } from '$env/dynamic/private'; + * console.log(env.DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + * + * > In `dev`, `$env/dynamic` always includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + */ +declare module '$env/dynamic/private' { + export const env: { + npm_config_user_agent: string; + NODE_VERSION: string; + HOSTNAME: string; + YARN_VERSION: string; + npm_node_execpath: string; + SHLVL: string; + npm_config_noproxy: string; + HOME: string; + npm_package_json: string; + npm_config_userconfig: string; + npm_config_local_prefix: string; + COLOR: string; + npm_config_prefix: string; + npm_config_npm_version: string; + npm_config_cache: string; + npm_config_node_gyp: string; + PATH: string; + NODE: string; + npm_package_name: string; + npm_lifecycle_script: string; + npm_package_version: string; + npm_lifecycle_event: string; + npm_config_globalconfig: string; + npm_config_init_module: string; + PWD: string; + npm_execpath: string; + npm_config_global_prefix: string; + npm_command: string; + NODE_ENV: string; + INIT_CWD: string; + EDITOR: string; + [key: `PUBLIC_${string}`]: undefined; + [key: `${string}`]: string | undefined; + } +} + +/** + * Similar to [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), but only includes variables that begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Note that public dynamic environment variables must all be sent from the server to the client, causing larger network requests — when possible, use `$env/static/public` instead. + * + * ```ts + * import { env } from '$env/dynamic/public'; + * console.log(env.PUBLIC_DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + */ +declare module '$env/dynamic/public' { + export const env: { + PUBLIC_APP_NAME: string; + PUBLIC_API_URL: string; + [key: `PUBLIC_${string}`]: string | undefined; + } +} + + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\tsconfig.json +================================================== +{ + "compilerOptions": { + "paths": { + "$components": [ + "../src/lib/components" + ], + "$components/*": [ + "../src/lib/components/*" + ], + "$stores": [ + "../src/lib/stores" + ], + "$stores/*": [ + "../src/lib/stores/*" + ], + "$utils": [ + "../src/lib/utils" + ], + "$utils/*": [ + "../src/lib/utils/*" + ], + "$types": [ + "../src/lib/types" + ], + "$types/*": [ + "../src/lib/types/*" + ], + "$lib": [ + "../src/lib" + ], + "$lib/*": [ + "../src/lib/*" + ] + }, + "rootDirs": [ + "..", + "./types" + ], + "importsNotUsedAsValues": "error", + "isolatedModules": true, + "preserveValueImports": true, + "lib": [ + "esnext", + "DOM", + "DOM.Iterable" + ], + "moduleResolution": "node", + "module": "esnext", + "noEmit": true, + "target": "esnext", + "ignoreDeprecations": "5.0" + }, + "include": [ + "ambient.d.ts", + "./types/**/$types.d.ts", + "../vite.config.js", + "../vite.config.ts", + "../src/**/*.js", + "../src/**/*.ts", + "../src/**/*.svelte", + "../tests/**/*.js", + "../tests/**/*.ts", + "../tests/**/*.svelte" + ], + "exclude": [ + "../node_modules/**", + "./[!ambient.d.ts]**", + "../src/service-worker.js", + "../src/service-worker.ts", + "../src/service-worker.d.ts" + ] +} + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\root.svelte +================================================== + + + + +{#if constructors[1]} + + + +{:else} + +{/if} + +{#if mounted} +
+ {#if navigated} + {title} + {/if} +
+{/if} + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\app.js +================================================== +export { matchers } from './matchers.js'; + +export const nodes = [ + () => import('./nodes/0'), + () => import('./nodes/1'), + () => import('./nodes/2'), + () => import('./nodes/3'), + () => import('./nodes/4'), + () => import('./nodes/5'), + () => import('./nodes/6'), + () => import('./nodes/7') +]; + +export const server_loads = []; + +export const dictionary = { + "/": [2], + "/login": [3], + "/profile": [4], + "/tickets": [5], + "/tickets/new": [7], + "/tickets/[id]": [6] + }; + +export const hooks = { + handleError: (({ error }) => { console.error(error) }), +}; + +export { default as root } from '../root.svelte'; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\matchers.js +================================================== +export const matchers = {}; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\0.js +================================================== +export { default as component } from "../../../../src/routes/+layout.svelte"; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\1.js +================================================== +export { default as component } from "../../../../node_modules/@sveltejs/kit/src/runtime/components/error.svelte"; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\2.js +================================================== +export { default as component } from "../../../../src/routes/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\3.js +================================================== +export { default as component } from "../../../../src/routes/login/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\4.js +================================================== +export { default as component } from "../../../../src/routes/profile/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\5.js +================================================== +export { default as component } from "../../../../src/routes/tickets/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\6.js +================================================== +export { default as component } from "../../../../src/routes/tickets/[id]/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\client\nodes\7.js +================================================== +export { default as component } from "../../../../src/routes/tickets/new/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\generated\server\internal.js +================================================== + +import root from '../root.svelte'; +import { set_building } from '__sveltekit/environment'; +import { set_assets } from '__sveltekit/paths'; +import { set_private_env, set_public_env } from '../../../node_modules/@sveltejs/kit/src/runtime/shared-server.js'; + +export const options = { + app_template_contains_nonce: false, + csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}}, + csrf_check_origin: true, + track_server_fetches: false, + embedded: false, + env_public_prefix: 'PUBLIC_', + env_private_prefix: '', + hooks: null, // added lazily, via `get_hooks` + preload_strategy: "modulepreload", + root, + service_worker: false, + templates: { + app: ({ head, body, assets, nonce, env }) => "\r\n\r\n\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t" + head + "\r\n\t\r\n\t\r\n\t\t
" + body + "
\r\n\t\r\n", + error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" + }, + version_hash: "3bl7rp" +}; + +export function get_hooks() { + return {}; +} + +export { set_assets, set_building, set_private_env, set_public_env }; + + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\types\route_meta_data.json +================================================== +{ + "/": [], + "/login": [], + "/profile": [], + "/tickets": [], + "/tickets/new": [], + "/tickets/[id]": [] +} + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; +type LayoutRouteId = RouteId | "/" | "/login" | "/profile" | "/tickets" | "/tickets/[id]" | "/tickets/new" | null +type LayoutParams = RouteParams & { id?: string } +type LayoutParentData = EnsureDefined<{}>; + +export type PageServerData = null; +export type PageData = Expand; +export type LayoutServerData = null; +export type LayoutData = Expand; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\login\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/login'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\profile\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/profile'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\tickets\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/tickets'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\tickets\new\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/tickets/new'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-client\.svelte-kit\types\src\routes\tickets\[id]\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/tickets/[id]'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type EntryGenerator = () => Promise> | Array; +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-client\src\app.css +================================================== +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom base styles */ +@layer base { + html { + font-family: 'Inter', system-ui, sans-serif; + } + + body { + @apply text-gray-900 bg-gray-50; + } + + /* Focus styles */ + *:focus { + @apply outline-none ring-2 ring-primary-500 ring-offset-2; + } + + /* Selection styles */ + ::selection { + @apply bg-primary-100 text-primary-900; + } +} + +/* Custom component styles */ +@layer components { + /* Card component */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden; + } + + .card-content { + @apply p-6; + } + + /* Button variants */ + .btn { + @apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none px-4 py-2; + } + + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800; + } + + .btn-secondary { + @apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 active:bg-secondary-300; + } + + .btn-success { + @apply bg-success-600 text-white hover:bg-success-700 active:bg-success-800; + } + + .btn-warning { + @apply bg-warning-600 text-white hover:bg-warning-700 active:bg-warning-800; + } + + .btn-error { + @apply bg-error-600 text-white hover:bg-error-700 active:bg-error-800; + } + + .btn-ghost { + @apply bg-transparent hover:bg-secondary-100 active:bg-secondary-200; + } + + /* Card styles */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200; + } + + .card-header { + @apply p-6 border-b border-gray-200; + } + + .card-content { + @apply p-6; + } + + .card-footer { + @apply p-6 border-t border-gray-200 bg-gray-50 rounded-b-lg; + } + + /* Form styles */ + .form-input { + @apply block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm; + } + + .form-label { + @apply block text-sm font-medium text-gray-700 mb-1; + } + + .form-error { + @apply text-sm text-error-600 mt-1; + } + + /* Status badges */ + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + .badge-new { + @apply badge bg-blue-100 text-blue-800; + } + + .badge-in-progress { + @apply badge bg-yellow-100 text-yellow-800; + } + + .badge-waiting { + @apply badge bg-orange-100 text-orange-800; + } + + .badge-resolved { + @apply badge bg-green-100 text-green-800; + } + + .badge-closed { + @apply badge bg-gray-100 text-gray-800; + } + + .badge-reopened { + @apply badge bg-red-100 text-red-800; + } + + /* Priority badges */ + .badge-priority-low { + @apply badge bg-gray-100 text-gray-600; + } + + .badge-priority-medium { + @apply badge bg-blue-100 text-blue-700; + } + + .badge-priority-high { + @apply badge bg-orange-100 text-orange-700; + } + + .badge-priority-urgent { + @apply badge bg-red-100 text-red-700; + } +} + +/* Custom utility classes */ +@layer utilities { + .text-balance { + text-wrap: balance; + } + + /* Loading spinner */ + .spinner { + @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600; + } + + /* Animations */ + .animate-fade-in { + animation: fadeIn 0.3s ease-in-out; + } + + .animate-slide-up { + animation: slideUp 0.3s ease-out; + } +} + +/* Custom keyframes */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +================================================== +ARCHIVO: .\frontend-client\src\app.html +================================================== + + + + + + + + + + + + + + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + + +================================================== +ARCHIVO: .\frontend-client\src\lib\components\Header.svelte +================================================== + + +
+ + +{#if isMenuOpen} +
isMenuOpen = false} + >
+{/if} + +================================================== +ARCHIVO: .\frontend-client\src\lib\components\Icon.svelte +================================================== + + + + + + +================================================== +ARCHIVO: .\frontend-client\src\lib\components\TicketCard.svelte +================================================== + + +
+
+
+

+ + {ticket.title} + +

+
+ + {statusConfig[ticket.status].label} + + + {priorityConfig[ticket.priority].label} + +
+
+ +

+ {ticket.description} +

+ +
+
+ #{ticket.id.substring(0, 8)} + {#if ticket.category_name} + + {ticket.category_name} + + {/if} + {#if ticket.assigned_to_name} + + + + + {ticket.assigned_to_name} + + {/if} +
+ +
+ {#if ticket.due_date} + + + + + Vence {formatRelativeTime(ticket.due_date)} + + {/if} + + + Actualizado {formatRelativeTime(ticket.updated_at)} + +
+
+
+
+ + + +================================================== +ARCHIVO: .\frontend-client\src\lib\components\Toast.svelte +================================================== + + +{#if visible} +
+
+
+
+ + + +
+ +
+

+ {message} +

+
+ + {#if dismissible} +
+ +
+ {/if} +
+
+
+{/if} + + + +================================================== +ARCHIVO: .\frontend-client\src\lib\stores\app.ts +================================================== +import { writable } from 'svelte/store'; +import { auth } from './auth.js'; +import { get } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +// Types +export interface Category { + id: string; + name: string; + description: string; + is_active: boolean; + tenant_id: string; + created_at: string; +} + +export interface AppState { + categories: Category[]; + isLoading: boolean; + error: string | null; +} + +// Initial state +const initialState: AppState = { + categories: [], + isLoading: false, + error: null +}; + +// API helper function +async function apiCall(endpoint: string, options: RequestInit = {}) { + const authState = get(auth); + + const response = await fetch(`/api/v1${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authState.token}`, + ...options.headers + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Request failed'); + } + + return response.json(); +} + +// Create app store +function createAppStore() { + const { subscribe, set, update }: Writable = writable(initialState); + + return { + subscribe, + + // Load categories + loadCategories: async () => { + update(state => ({ ...state, isLoading: true, error: null })); + + try { + const categories = await apiCall('/categories/'); + update(state => ({ ...state, categories, isLoading: false })); + } catch (error) { + update(state => ({ + ...state, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load categories' + })); + } + }, + + // Clear error + clearError: () => { + update(state => ({ ...state, error: null })); + } + }; +} + +export const app = createAppStore(); + +================================================== +ARCHIVO: .\frontend-client\src\lib\stores\auth.ts +================================================== +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +// Types +export interface User { + id: string; + email: string; + first_name: string; + last_name: string; + tenant_id: string; + role: 'CLIENT_ADMIN' | 'CLIENT_USER'; + is_active: boolean; + is_two_factor_enabled: boolean; + created_at: string; +} + +export interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +export interface LoginRequest { + email: string; + password: string; + tenant_slug: string; + totp_code?: string; +} + +export interface LoginResponse { + access_token: string; + token_type: string; + expires_in: number; + user: User; +} + +// Initial state +const initialState: AuthState = { + user: null, + token: null, + isAuthenticated: false, + isLoading: false +}; + +// Create auth store +function createAuthStore() { + const { subscribe, set, update }: Writable = writable(initialState); + + return { + subscribe, + + // Initialize auth from localStorage + init: () => { + if (typeof window !== 'undefined') { + const token = localStorage.getItem('auth_token'); + const user = localStorage.getItem('auth_user'); + + if (token && user) { + try { + const parsedUser = JSON.parse(user); + set({ + user: parsedUser, + token, + isAuthenticated: true, + isLoading: false + }); + } catch (error) { + console.error('Error parsing stored auth data:', error); + localStorage.removeItem('auth_token'); + localStorage.removeItem('auth_user'); + } + } + } + }, + + // Login + login: async (credentials: LoginRequest): Promise => { + update(state => ({ ...state, isLoading: true })); + + try { + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(credentials) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Login failed'); + } + + const data: LoginResponse = await response.json(); + + // Store auth data + if (typeof window !== 'undefined') { + localStorage.setItem('auth_token', data.access_token); + localStorage.setItem('auth_user', JSON.stringify(data.user)); + } + + set({ + user: data.user, + token: data.access_token, + isAuthenticated: true, + isLoading: false + }); + } catch (error) { + update(state => ({ ...state, isLoading: false })); + throw error; + } + }, + + // Logout + logout: () => { + if (typeof window !== 'undefined') { + localStorage.removeItem('auth_token'); + localStorage.removeItem('auth_user'); + } + set(initialState); + }, + + // Update user data + updateUser: (user: User) => { + update(state => ({ ...state, user })); + if (typeof window !== 'undefined') { + localStorage.setItem('auth_user', JSON.stringify(user)); + } + }, + + // Set loading state + setLoading: (isLoading: boolean) => { + update(state => ({ ...state, isLoading })); + } + }; +} + +export const auth = createAuthStore(); + +================================================== +ARCHIVO: .\frontend-client\src\lib\stores\clientes.ts +================================================== +import { writable } from 'svelte/store'; + +export const clients = writable([]); + +export async function fetchClients() { + const response = await fetch('/api/v1/clients'); + const data = await response.json(); + clients.set(data); +} + +export async function createClient(client) { + const response = await fetch('/api/v1/clients', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(client), + }); + if (response.ok) { + fetchClients(); + } +} + +export async function updateClient(clientId, client) { + const response = await fetch(`/api/v1/clients/${clientId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(client), + }); + if (response.ok) { + fetchClients(); + } +} + +export async function deleteClient(clientId) { + const response = await fetch(`/api/v1/clients/${clientId}`, { + method: 'DELETE', + }); + if (response.ok) { + fetchClients(); + } +} + +================================================== +ARCHIVO: .\frontend-client\src\lib\stores\tickets.ts +================================================== +import { writable } from 'svelte/store'; +import { auth } from './auth.js'; +import { get } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +// Types +export interface Ticket { + id: string; + title: string; + description: string; + status: 'NEW' | 'IN_PROGRESS' | 'WAITING_FOR_CLIENT' | 'RESOLVED' | 'CLOSED' | 'REOPENED'; + priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; + category_id: string; + category_name?: string; + client_id: string; + assigned_to_id: string | null; + assigned_to_name?: string; + created_at: string; + updated_at: string; + due_date: string | null; + resolution: string | null; +} + +export interface TicketComment { + id: string; + ticket_id: string; + user_id: string; + user_name: string; + user_role: string; + content: string; + is_internal: boolean; + created_at: string; +} + +export interface TicketAttachment { + id: string; + ticket_id: string; + filename: string; + original_filename: string; + mime_type: string; + size_bytes: number; + uploaded_by_id: string; + uploaded_by_name: string; + uploaded_at: string; +} + +export interface CreateTicketRequest { + title: string; + description: string; + category_id: string; + priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; +} + +export interface TicketsState { + tickets: Ticket[]; + currentTicket: Ticket | null; + comments: TicketComment[]; + attachments: TicketAttachment[]; + isLoading: boolean; + error: string | null; +} + +// Initial state +const initialState: TicketsState = { + tickets: [], + currentTicket: null, + comments: [], + attachments: [], + isLoading: false, + error: null +}; + +// API helper function +async function apiCall(endpoint: string, options: RequestInit = {}) { + const authState = get(auth); + + const response = await fetch(`/api/v1${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authState.token}`, + ...options.headers + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Request failed'); + } + + return response.json(); +} + +// Create tickets store +function createTicketsStore() { + const { subscribe, set, update }: Writable = writable(initialState); + + return { + subscribe, + + // Load user's tickets + loadTickets: async () => { + update(state => ({ ...state, isLoading: true, error: null })); + + try { + const tickets = await apiCall('/tickets/'); + update(state => ({ ...state, tickets, isLoading: false })); + } catch (error) { + update(state => ({ + ...state, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load tickets' + })); + } + }, + + // Load specific ticket with details + loadTicket: async (ticketId: string) => { + update(state => ({ ...state, isLoading: true, error: null })); + + try { + const [ticket, comments, attachments] = await Promise.all([ + apiCall(`/tickets/${ticketId}`), + apiCall(`/tickets/${ticketId}/comments`), + apiCall(`/tickets/${ticketId}/attachments`) + ]); + + update(state => ({ + ...state, + currentTicket: ticket, + comments, + attachments, + isLoading: false + })); + } catch (error) { + update(state => ({ + ...state, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load ticket' + })); + } + }, + + // Create new ticket + createTicket: async (ticket: CreateTicketRequest) => { + update(state => ({ ...state, isLoading: true, error: null })); + + try { + const newTicket = await apiCall('/tickets/', { + method: 'POST', + body: JSON.stringify(ticket) + }); + + update(state => ({ + ...state, + tickets: [newTicket, ...state.tickets], + isLoading: false + })); + + return newTicket; + } catch (error) { + update(state => ({ + ...state, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to create ticket' + })); + throw error; + } + }, + + // Add comment to ticket + addComment: async (ticketId: string, content: string) => { + try { + const comment = await apiCall(`/tickets/${ticketId}/comments`, { + method: 'POST', + body: JSON.stringify({ content }) + }); + + update(state => ({ + ...state, + comments: [...state.comments, comment] + })); + + return comment; + } catch (error) { + update(state => ({ + ...state, + error: error instanceof Error ? error.message : 'Failed to add comment' + })); + throw error; + } + }, + + // Upload attachment + uploadAttachment: async (ticketId: string, file: File) => { + try { + const formData = new FormData(); + formData.append('file', file); + + const authState = get(auth); + const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authState.token}` + }, + body: formData + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Upload failed'); + } + + const attachment = await response.json(); + + update(state => ({ + ...state, + attachments: [...state.attachments, attachment] + })); + + return attachment; + } catch (error) { + update(state => ({ + ...state, + error: error instanceof Error ? error.message : 'Failed to upload attachment' + })); + throw error; + } + }, + + // Close ticket (client can close their own tickets) + closeTicket: async (ticketId: string, resolution?: string) => { + try { + const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, { + method: 'PATCH', + body: JSON.stringify({ resolution }) + }); + + update(state => ({ + ...state, + currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket, + tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t) + })); + + return updatedTicket; + } catch (error) { + update(state => ({ + ...state, + error: error instanceof Error ? error.message : 'Failed to close ticket' + })); + throw error; + } + }, + + // Clear error + clearError: () => { + update(state => ({ ...state, error: null })); + }, + + // Clear current ticket + clearCurrentTicket: () => { + update(state => ({ + ...state, + currentTicket: null, + comments: [], + attachments: [] + })); + } + }; +} + +export const tickets = createTicketsStore(); + +================================================== +ARCHIVO: .\frontend-client\src\lib\stores\toast.ts +================================================== +// Toast notification store +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +export interface ToastMessage { + id: string; + type: 'success' | 'error' | 'warning' | 'info'; + message: string; + duration?: number; +} + +interface ToastState { + toasts: ToastMessage[]; +} + +const initialState: ToastState = { + toasts: [] +}; + +function createToastStore() { + const { subscribe, update }: Writable = writable(initialState); + + return { + subscribe, + + show: (type: ToastMessage['type'], message: string, duration = 5000) => { + const id = Math.random().toString(36).substring(2, 9); + const toast: ToastMessage = { id, type, message, duration }; + + update(state => ({ + toasts: [...state.toasts, toast] + })); + + // Auto-remove after duration + if (duration > 0) { + setTimeout(() => { + update(state => ({ + toasts: state.toasts.filter(t => t.id !== id) + })); + }, duration); + } + + return id; + }, + + dismiss: (id: string) => { + update(state => ({ + toasts: state.toasts.filter(t => t.id !== id) + })); + }, + + clear: () => { + update(() => initialState); + }, + + // Convenience methods + success: (message: string, duration?: number) => { + return createToastStore().show('success', message, duration); + }, + + error: (message: string, duration?: number) => { + return createToastStore().show('error', message, duration); + }, + + warning: (message: string, duration?: number) => { + return createToastStore().show('warning', message, duration); + }, + + info: (message: string, duration?: number) => { + return createToastStore().show('info', message, duration); + } + }; +} + +export const toast = createToastStore(); + +================================================== +ARCHIVO: .\frontend-client\src\routes\+layout.svelte +================================================== + + +
+ {#if showHeader} +
+ {/if} + +
+ +
+ + + {#each $toast.toasts as toastMessage (toastMessage.id)} + toast.dismiss(toastMessage.id)} + /> + {/each} +
+ +================================================== +ARCHIVO: .\frontend-client\src\routes\+page.svelte +================================================== + + + + ServiceManager - Mesa de Ayuda + + +
+ +
+
+

+ Bienvenido, {$auth.user?.first_name} {$auth.user?.last_name} +

+

+ Gestiona tus tickets de soporte de manera eficiente. Crea nuevos tickets, + da seguimiento a los existentes y mantente actualizado con el estado de tus solicitudes. +

+
+
+ + + + + +
+
+

Tickets Recientes

+

Últimos tickets que has creado o actualizado

+
+ +
+ {#if $tickets.isLoading} +
+
+

Cargando tickets...

+
+ {:else if $tickets.error} +
+
+ + + +
+

Error al cargar los tickets

+ +
+ {:else if $tickets.tickets.length === 0} +
+
+ + + +
+

No tienes tickets creados

+ + Crear tu primer ticket + +
+ {:else} +
+ {#each $tickets.tickets.slice(0, 5) as ticket (ticket.id)} +
+
+
+

+ + {ticket.title} + +

+

+ {ticket.description} +

+
+ #{ticket.id.substring(0, 8)} + {new Date(ticket.created_at).toLocaleDateString('es-ES')} +
+
+
+ + {ticket.status === 'NEW' ? 'Nuevo' : + ticket.status === 'IN_PROGRESS' ? 'En Progreso' : + ticket.status === 'WAITING_FOR_CLIENT' ? 'Esperando Cliente' : + ticket.status === 'RESOLVED' ? 'Resuelto' : + ticket.status === 'CLOSED' ? 'Cerrado' : 'Reabierto'} + +
+
+
+ {/each} + + {#if $tickets.tickets.length > 5} + + {/if} +
+ {/if} +
+
+
+ + + +================================================== +ARCHIVO: .\frontend-client\src\routes\login\+page.svelte +================================================== + + + +
+ + + + + +
+ +
+ +
+

Bienvenido

+

Ingrese a su cuenta corporativa

+
+ + +
+ {#if errorMessage} +
+ + {errorMessage} +
+ {/if} + + {#if !showTwoFactor} +
+ +
+ +
+
+ +
+ +
+
+ + +
+ +
+
+ +
+ {#if showPassword} + + {:else} + + {/if} + +
+
+ +
+
+ + +
+ + Olvide mi clave + +
+
+ + {:else} + +
+ +

Ingrese el código de 6 dígitos

+ +
+
+ +
+ +
+
+ {/if} + +
+ +
+ +
+ © 2026 Aduanasoft. Acceso exclusivo autorizado. +
+
+
+
+
+ +================================================== +ARCHIVO: .\frontend-client\src\routes\profile\+page.svelte +================================================== + + + + Mi Perfil - ServiceManager + + +
+ +
+

Mi Perfil

+

+ Gestiona tu información personal y configuración de seguridad +

+
+ +
+ +
+
+

Información Personal

+

Actualiza tu información básica

+
+ +
+
+
+
+ + + {#if profileErrors.firstName} +

{profileErrors.firstName}

+ {/if} +
+ +
+ + + {#if profileErrors.lastName} +

{profileErrors.lastName}

+ {/if} +
+
+ +
+ + +

+ El correo electrónico no se puede cambiar. Contacta con soporte si necesitas actualizarlo. +

+
+ +
+ +
+
+
+
+ + +
+
+

Seguridad de la Cuenta

+

Gestiona tu contraseña y configuración de seguridad

+
+ +
+ +
+
+

Autenticación de dos factores (2FA)

+

+ {$auth.user?.is_two_factor_enabled + ? 'La autenticación de dos factores está habilitada' + : 'Mejora la seguridad habilitando 2FA'} +

+
+
+ {#if $auth.user?.is_two_factor_enabled} + + + + + Habilitado + + {:else} + + + + + Deshabilitado + + {/if} +
+
+ + +
+

Cambiar Contraseña

+ +
+ + + {#if passwordErrors.currentPassword} +

{passwordErrors.currentPassword}

+ {/if} +
+ +
+
+ + + {#if passwordErrors.newPassword} +

{passwordErrors.newPassword}

+ {/if} +

+ Mínimo 8 caracteres +

+
+ +
+ + + {#if passwordErrors.confirmPassword} +

{passwordErrors.confirmPassword}

+ {/if} +
+
+ +
+ +
+
+
+
+ + +
+
+

Información de la Cuenta

+

Detalles sobre tu cuenta y organización

+
+ +
+
+
+
ID de Usuario
+
#{$auth.user?.id.substring(0, 8)}
+
+ +
+
Rol
+
+ {$auth.user?.role === 'CLIENT_ADMIN' ? 'Administrador de Cliente' : 'Usuario de Cliente'} +
+
+ +
+
Estado de la Cuenta
+
+ {#if $auth.user?.is_active} + + Activa + + {:else} + + Inactiva + + {/if} +
+
+ +
+
Miembro desde
+
+ {$auth.user?.created_at ? new Date($auth.user.created_at).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'long', + year: 'numeric' + }) : 'N/A'} +
+
+
+
+
+
+
+ +================================================== +ARCHIVO: .\frontend-client\src\routes\tickets\+page.svelte +================================================== + + + + Mis Tickets - ServiceManager + + +
+ +
+
+

Mis Tickets

+

+ Gestiona y da seguimiento a todos tus tickets de soporte +

+
+ + + + + Crear Ticket + +
+ + +
+
+
+
+
+ + + +
+
+

Total

+

{$tickets.tickets.length}

+
+
+
+
+ +
+
+
+
+ + + +
+
+

En Progreso

+

+ {statusCounts['IN_PROGRESS'] || 0} +

+
+
+
+
+ +
+
+
+
+ + + +
+
+

Esperando

+

+ {statusCounts['WAITING_FOR_CLIENT'] || 0} +

+
+
+
+
+ +
+
+
+
+ + + +
+
+

Resueltos

+

+ {(statusCounts['RESOLVED'] || 0) + (statusCounts['CLOSED'] || 0)} +

+
+
+
+
+
+ + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + +
+ {#if $tickets.isLoading} +
+
+

Cargando tickets...

+
+ {:else if $tickets.error} +
+
+ + + +
+

Error al cargar tickets

+

{$tickets.error}

+ +
+ {:else if filteredTickets.length === 0} +
+
+ + + +
+

+ {$tickets.tickets.length === 0 ? 'No tienes tickets' : 'No se encontraron tickets'} +

+

+ {$tickets.tickets.length === 0 + ? 'Crea tu primer ticket para comenzar' + : 'Intenta ajustar los filtros de búsqueda'} +

+ {#if $tickets.tickets.length === 0} + + Crear Ticket + + {:else} + + {/if} +
+ {:else} +
+ {#each filteredTickets as ticket (ticket.id)} + + {/each} +
+ + {#if filteredTickets.length !== $tickets.tickets.length} +
+ Mostrando {filteredTickets.length} de {$tickets.tickets.length} tickets +
+ {/if} + {/if} +
+
+ +================================================== +ARCHIVO: .\frontend-client\src\routes\tickets\new\+page.svelte +================================================== + + + + Crear Ticket - ServiceManager + + +
+ +
+
+ Mis Tickets + + + + Crear Ticket +
+ +

Crear Nuevo Ticket

+

+ Describe tu problema o solicitud de soporte con el mayor detalle posible +

+
+ + +
+
+
+ +
+ + + {#if errors.title} +

{errors.title}

+ {/if} +

+ {title.length}/200 caracteres +

+
+ + +
+ + + {#if errors.categoryId} +

{errors.categoryId}

+ {/if} +
+ + +
+ + +
+ + +
+ + + {#if errors.description} +

{errors.description}

+ {/if} +

+ {description.length}/2000 caracteres +

+
+
+
+ + +
+
+
+
+ + + +
+
+

+ Tips para un mejor soporte +

+
+
    +
  • Sé específico y detallado en tu descripción
  • +
  • Incluye capturas de pantalla si es posible (puedes adjuntarlas después)
  • +
  • Menciona qué navegador/sistema operativo estás usando
  • +
  • Indica si el problema es recurrente o fue la primera vez
  • +
  • Si hay mensajes de error, cópialos exactamente
  • +
+
+
+
+
+
+ + +
+ + Cancelar + + + +
+
+
+ +================================================== +ARCHIVO: .\frontend-client\src\routes\tickets\[id]\+page.svelte +================================================== + + + + + {$tickets.currentTicket ? `Ticket: ${$tickets.currentTicket.title}` : 'Cargando...'} - ServiceManager + + + +
+ {#if $tickets.isLoading} +
+
+

Cargando ticket...

+
+ {:else if $tickets.error} +
+
+ + + +
+

Error al cargar ticket

+

{$tickets.error}

+ +
+ {:else if $tickets.currentTicket} + +
+ Mis Tickets + + + + #{$tickets.currentTicket.id.substring(0, 8)} +
+ +
+ +
+ +
+
+
+
+

+ {$tickets.currentTicket.title} +

+
+ + {statusConfig[$tickets.currentTicket.status].label} + + + {priorityConfig[$tickets.currentTicket.priority].label} + + + Creado {formatDate($tickets.currentTicket.created_at)} + +
+
+ + {#if canClose} + + {/if} +
+
+ +
+
+

+ {$tickets.currentTicket.description} +

+
+ + {#if $tickets.currentTicket.resolution} +
+

Resolución:

+

+ {$tickets.currentTicket.resolution} +

+
+ {/if} +
+
+ + + {#if $tickets.attachments.length > 0} +
+
+

Archivos Adjuntos

+
+
+
+ {#each $tickets.attachments as attachment} +
+
+
+ + + +
+
+

+ {attachment.original_filename} +

+

+ {Math.round(attachment.size_bytes / 1024)} KB • + Subido por {attachment.uploaded_by_name} • + {formatDate(attachment.uploaded_at)} +

+
+
+ + + + + +
+ {/each} +
+
+
+ {/if} + + +
+
+

Conversación

+
+
+ {#if $tickets.comments.length === 0} +

+ No hay comentarios aún. ¡Sé el primero en comentar! +

+ {:else} +
+ {#each $tickets.comments as comment} +
+
+ + {comment.user_name.split(' ').map(n => n[0]).join('')} + +
+
+
+ + {comment.user_name} + + + {formatDate(comment.created_at)} + + {#if comment.is_internal} + + Interno + + {/if} +
+

+ {comment.content} +

+
+
+ {/each} +
+ {/if} + + +
+
+ + +
+
+ + +
+ + +
+
+
+
+
+
+ + +
+ +
+
+

Información

+
+
+
+
ID del Ticket
+
#{$tickets.currentTicket.id.substring(0, 8)}
+
+ +
+
Categoría
+
{$tickets.currentTicket.category_name || 'Sin categoría'}
+
+ + {#if $tickets.currentTicket.assigned_to_name} +
+
Asignado a
+
{$tickets.currentTicket.assigned_to_name}
+
+ {/if} + +
+
Creado
+
{formatDate($tickets.currentTicket.created_at)}
+
+ +
+
Última actualización
+
{formatDate($tickets.currentTicket.updated_at)}
+
+ + {#if $tickets.currentTicket.due_date} +
+
Fecha límite
+
+ {formatDate($tickets.currentTicket.due_date)} + {#if new Date($tickets.currentTicket.due_date) < new Date()} + ¡Vencido! + {/if} +
+
+ {/if} +
+
+
+
+ {/if} +
+ + +{#if showCloseDialog} +
+
+
+
+
+ +
+
+
+
+ + + +
+
+

+ Cerrar Ticket +

+
+

+ ¿Estás seguro de que quieres cerrar este ticket? Esta acción indica que el problema ha sido resuelto satisfactoriamente. +

+
+ +
+ + +
+
+
+
+
+ + +
+
+
+
+{/if} + +================================================== +ARCHIVO: .\frontend-internal\.eslintrc.json +================================================== +{ + "extends": [ + "eslint:recommended", + "@typescript-eslint/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "svelte3"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "browser": true, + "es2017": true, + "node": true + }, + "overrides": [ + { + "files": ["*.svelte"], + "processor": "svelte3/svelte3" + } + ], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_" } + ], + "@typescript-eslint/no-explicit-any": "warn", + "no-console": ["warn", { "allow": ["warn", "error"] }], + "prefer-const": "error" + }, + "settings": { + "svelte3/typescript": true + }, + "ignorePatterns": [ + ".svelte-kit/**", + "build/**", + "dist/**", + "node_modules/**" + ] +} + +================================================== +ARCHIVO: .\frontend-internal\package.json +================================================== +{ + "name": "@servicemanager/internal-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000 --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --port 3000 --host 0.0.0.0", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --plugin-search-dir . --check . && eslint .", + "format": "prettier --plugin-search-dir . --write .", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^1.3.1", + "@sveltejs/kit": "^1.20.4", + "@types/cookie": "^0.5.1", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "autoprefixer": "^10.4.14", + "eslint": "^8.28.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-svelte": "^2.30.0", + "postcss": "^8.4.24", + "prettier": "^2.8.0", + "prettier-plugin-svelte": "^2.10.1", + "svelte": "^4.0.5", + "svelte-check": "^3.4.3", + "tailwindcss": "^3.3.0", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^4.4.2", + "vitest": "^0.34.0" + }, + "dependencies": { + "@tailwindcss/forms": "^0.5.4", + "@tailwindcss/typography": "^0.5.9", + "@heroicons/react": "^2.0.18", + "heroicons": "^2.0.18", + "zod": "^3.22.2", + "date-fns": "^2.30.0", + "chart.js": "^4.3.0", + "chartjs-adapter-date-fns": "^3.0.0" + } +} + +================================================== +ARCHIVO: .\frontend-internal\postcss.config.js +================================================== +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +================================================== +ARCHIVO: .\frontend-internal\svelte.config.js +================================================== +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/kit/vite'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter(), + + alias: { + '$components': 'src/lib/components', + '$stores': 'src/lib/stores', + '$utils': 'src/lib/utils', + '$types': 'src/lib/types' + } + } +}; + +export default config; + + +================================================== +ARCHIVO: .\frontend-internal\tailwind.config.js +================================================== +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{html,js,svelte,ts}'], + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + } + } + } + }, + plugins: [], +} + + +================================================== +ARCHIVO: .\frontend-internal\vite.config.js +================================================== +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 3000, + host: '0.0.0.0', + proxy: { + '/api/v1': { + target: 'http://servicemanager-backend:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + }, + preview: { + port: 3000, + host: '0.0.0.0' + }, + build: { + target: 'esnext' + } +}); + + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\ambient.d.ts +================================================== + +// this file is generated — do not edit it + + +/// + +/** + * Environment variables [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env`. Like [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), this module cannot be imported into client-side code. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://kit.svelte.dev/docs/configuration#env) (if configured). + * + * _Unlike_ [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), the values exported from this module are statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * ```ts + * import { API_KEY } from '$env/static/private'; + * ``` + * + * Note that all environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * + * ``` + * MY_FEATURE_FLAG="" + * ``` + * + * You can override `.env` values from the command line like so: + * + * ```bash + * MY_FEATURE_FLAG="enabled" npm run dev + * ``` + */ +declare module '$env/static/private' { + export const npm_config_user_agent: string; + export const NODE_VERSION: string; + export const HOSTNAME: string; + export const YARN_VERSION: string; + export const npm_node_execpath: string; + export const SHLVL: string; + export const npm_config_noproxy: string; + export const HOME: string; + export const npm_package_json: string; + export const npm_config_userconfig: string; + export const npm_config_local_prefix: string; + export const COLOR: string; + export const npm_config_prefix: string; + export const npm_config_npm_version: string; + export const npm_config_cache: string; + export const npm_config_node_gyp: string; + export const PATH: string; + export const NODE: string; + export const npm_package_name: string; + export const npm_lifecycle_script: string; + export const npm_package_version: string; + export const npm_lifecycle_event: string; + export const npm_config_globalconfig: string; + export const npm_config_init_module: string; + export const PWD: string; + export const npm_execpath: string; + export const npm_config_global_prefix: string; + export const npm_command: string; + export const NODE_ENV: string; + export const INIT_CWD: string; + export const EDITOR: string; +} + +/** + * Similar to [`$env/static/private`](https://kit.svelte.dev/docs/modules#$env-static-private), except that it only includes environment variables that begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Values are replaced statically at build time. + * + * ```ts + * import { PUBLIC_BASE_URL } from '$env/static/public'; + * ``` + */ +declare module '$env/static/public' { + export const PUBLIC_APP_NAME: string; + export const PUBLIC_API_URL: string; +} + +/** + * This module provides access to runtime environment variables, as defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/master/packages/adapter-node) (or running [`vite preview`](https://kit.svelte.dev/docs/cli)), this is equivalent to `process.env`. This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://kit.svelte.dev/docs/configuration#env) (if configured). + * + * This module cannot be imported into client-side code. + * + * ```ts + * import { env } from '$env/dynamic/private'; + * console.log(env.DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + * + * > In `dev`, `$env/dynamic` always includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + */ +declare module '$env/dynamic/private' { + export const env: { + npm_config_user_agent: string; + NODE_VERSION: string; + HOSTNAME: string; + YARN_VERSION: string; + npm_node_execpath: string; + SHLVL: string; + npm_config_noproxy: string; + HOME: string; + npm_package_json: string; + npm_config_userconfig: string; + npm_config_local_prefix: string; + COLOR: string; + npm_config_prefix: string; + npm_config_npm_version: string; + npm_config_cache: string; + npm_config_node_gyp: string; + PATH: string; + NODE: string; + npm_package_name: string; + npm_lifecycle_script: string; + npm_package_version: string; + npm_lifecycle_event: string; + npm_config_globalconfig: string; + npm_config_init_module: string; + PWD: string; + npm_execpath: string; + npm_config_global_prefix: string; + npm_command: string; + NODE_ENV: string; + INIT_CWD: string; + EDITOR: string; + [key: `PUBLIC_${string}`]: undefined; + [key: `${string}`]: string | undefined; + } +} + +/** + * Similar to [`$env/dynamic/private`](https://kit.svelte.dev/docs/modules#$env-dynamic-private), but only includes variables that begin with [`config.kit.env.publicPrefix`](https://kit.svelte.dev/docs/configuration#env) (which defaults to `PUBLIC_`), and can therefore safely be exposed to client-side code. + * + * Note that public dynamic environment variables must all be sent from the server to the client, causing larger network requests — when possible, use `$env/static/public` instead. + * + * ```ts + * import { env } from '$env/dynamic/public'; + * console.log(env.PUBLIC_DEPLOYMENT_SPECIFIC_VARIABLE); + * ``` + */ +declare module '$env/dynamic/public' { + export const env: { + PUBLIC_APP_NAME: string; + PUBLIC_API_URL: string; + [key: `PUBLIC_${string}`]: string | undefined; + } +} + + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\tsconfig.json +================================================== +{ + "compilerOptions": { + "paths": { + "$components": [ + "../src/lib/components" + ], + "$components/*": [ + "../src/lib/components/*" + ], + "$stores": [ + "../src/lib/stores" + ], + "$stores/*": [ + "../src/lib/stores/*" + ], + "$utils": [ + "../src/lib/utils" + ], + "$utils/*": [ + "../src/lib/utils/*" + ], + "$types": [ + "../src/lib/types" + ], + "$types/*": [ + "../src/lib/types/*" + ], + "$lib": [ + "../src/lib" + ], + "$lib/*": [ + "../src/lib/*" + ] + }, + "rootDirs": [ + "..", + "./types" + ], + "importsNotUsedAsValues": "error", + "isolatedModules": true, + "preserveValueImports": true, + "lib": [ + "esnext", + "DOM", + "DOM.Iterable" + ], + "moduleResolution": "node", + "module": "esnext", + "noEmit": true, + "target": "esnext", + "ignoreDeprecations": "5.0" + }, + "include": [ + "ambient.d.ts", + "./types/**/$types.d.ts", + "../vite.config.js", + "../vite.config.ts", + "../src/**/*.js", + "../src/**/*.ts", + "../src/**/*.svelte", + "../tests/**/*.js", + "../tests/**/*.ts", + "../tests/**/*.svelte" + ], + "exclude": [ + "../node_modules/**", + "./[!ambient.d.ts]**", + "../src/service-worker.js", + "../src/service-worker.ts", + "../src/service-worker.d.ts" + ] +} + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\root.svelte +================================================== + + + + +{#if constructors[1]} + + + +{:else} + +{/if} + +{#if mounted} +
+ {#if navigated} + {title} + {/if} +
+{/if} + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\app.js +================================================== +export { matchers } from './matchers.js'; + +export const nodes = [ + () => import('./nodes/0'), + () => import('./nodes/1'), + () => import('./nodes/2'), + () => import('./nodes/3'), + () => import('./nodes/4'), + () => import('./nodes/5'), + () => import('./nodes/6'), + () => import('./nodes/7') +]; + +export const server_loads = []; + +export const dictionary = { + "/": [2], + "/categories": [3], + "/login": [4], + "/systems": [5], + "/tenants": [6], + "/users": [7] + }; + +export const hooks = { + handleError: (({ error }) => { console.error(error) }), +}; + +export { default as root } from '../root.svelte'; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\matchers.js +================================================== +export const matchers = {}; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\0.js +================================================== +export { default as component } from "../../../../src/routes/+layout.svelte"; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\1.js +================================================== +export { default as component } from "../../../../node_modules/@sveltejs/kit/src/runtime/components/error.svelte"; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\2.js +================================================== +export { default as component } from "../../../../src/routes/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\3.js +================================================== +export { default as component } from "../../../../src/routes/categories/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\4.js +================================================== +export { default as component } from "../../../../src/routes/login/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\5.js +================================================== +export { default as component } from "../../../../src/routes/systems/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\6.js +================================================== +export { default as component } from "../../../../src/routes/tenants/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\client\nodes\7.js +================================================== +export { default as component } from "../../../../src/routes/users/+page.svelte"; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\generated\server\internal.js +================================================== + +import root from '../root.svelte'; +import { set_building } from '__sveltekit/environment'; +import { set_assets } from '__sveltekit/paths'; +import { set_private_env, set_public_env } from '../../../node_modules/@sveltejs/kit/src/runtime/shared-server.js'; + +export const options = { + app_template_contains_nonce: false, + csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}}, + csrf_check_origin: true, + track_server_fetches: false, + embedded: false, + env_public_prefix: 'PUBLIC_', + env_private_prefix: '', + hooks: null, // added lazily, via `get_hooks` + preload_strategy: "modulepreload", + root, + service_worker: false, + templates: { + app: ({ head, body, assets, nonce, env }) => "\r\n\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " + head + "\r\n \r\n \r\n
" + body + "
\r\n \r\n", + error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" + }, + version_hash: "eelz0a" +}; + +export function get_hooks() { + return {}; +} + +export { set_assets, set_building, set_private_env, set_public_env }; + + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\types\route_meta_data.json +================================================== +{ + "/": [], + "/categories": [], + "/login": [], + "/systems": [], + "/tenants": [], + "/users": [] +} + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; +type LayoutRouteId = RouteId | "/" | "/categories" | "/login" | "/systems" | "/tenants" | "/users" | null +type LayoutParams = RouteParams & { } +type LayoutParentData = EnsureDefined<{}>; + +export type PageServerData = null; +export type PageData = Expand; +export type LayoutServerData = null; +export type LayoutData = Expand; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\categories\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/categories'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\login\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/login'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\systems\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/systems'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\tenants\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/tenants'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-internal\.svelte-kit\types\src\routes\users\$types.d.ts +================================================== +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/users'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageParentData = EnsureDefined; + +export type PageServerData = null; +export type PageData = Expand; + +================================================== +ARCHIVO: .\frontend-internal\src\app.css +================================================== +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom base styles */ +@layer base { + html { + font-family: 'Inter', system-ui, sans-serif; + } + + body { + @apply text-gray-900 bg-gray-50; + } + + /* Focus styles */ + *:focus { + @apply outline-none ring-2 ring-primary-500 ring-offset-2; + } + + /* Selection styles */ + ::selection { + @apply bg-primary-100 text-primary-900; + } +} + +/* Custom component styles */ +@layer components { + /* Card component */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden; + } + + .card-content { + @apply p-6; + } + + /* Button variants */ + .btn { + @apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none px-4 py-2; + } + + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800; + } + + .btn-secondary { + @apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 active:bg-secondary-300; + } + + .btn-success { + @apply bg-success-600 text-white hover:bg-success-700 active:bg-success-800; + } + + .btn-warning { + @apply bg-warning-600 text-white hover:bg-warning-700 active:bg-warning-800; + } + + .btn-error { + @apply bg-error-600 text-white hover:bg-error-700 active:bg-error-800; + } + + .btn-ghost { + @apply bg-transparent hover:bg-secondary-100 active:bg-secondary-200; + } + + /* Card styles */ + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200; + } + + .card-header { + @apply p-6 border-b border-gray-200; + } + + .card-content { + @apply p-6; + } + + .card-footer { + @apply p-6 border-t border-gray-200 bg-gray-50 rounded-b-lg; + } + + /* Form styles */ + .form-input { + @apply block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm; + } + + .form-label { + @apply block text-sm font-medium text-gray-700 mb-1; + } + + .form-error { + @apply text-sm text-error-600 mt-1; + } + + /* Status badges */ + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + /* Animations */ + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + .animate-fade-in { + animation: fadeIn 0.3s ease-out forwards; + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .animate-slide-up { + animation: slideUp 0.4s ease-out forwards; + } + + .badge-new { + @apply badge bg-blue-100 text-blue-800; + } + + .badge-in-progress { + @apply badge bg-yellow-100 text-yellow-800; + } + + .badge-waiting { + @apply badge bg-orange-100 text-orange-800; + } + + .badge-resolved { + @apply badge bg-green-100 text-green-800; + } + + .badge-closed { + @apply badge bg-gray-100 text-gray-800; + } + + .badge-reopened { + @apply badge bg-red-100 text-red-800; + } + + /* Priority badges */ + .badge-priority-low { + @apply badge bg-gray-100 text-gray-600; + } + + .badge-priority-medium { + @apply badge bg-blue-100 text-blue-700; + } + + .badge-priority-high { + @apply badge bg-orange-100 text-orange-700; + } + + .badge-priority-urgent { + @apply badge bg-red-100 text-red-700; + } + + /* Role badges */ + .badge-admin { + @apply badge bg-purple-100 text-purple-800; + } + + .badge-support-manager { + @apply badge bg-indigo-100 text-indigo-800; + } + + .badge-agent { + @apply badge bg-blue-100 text-blue-800; + } + + .badge-auditor { + @apply badge bg-gray-100 text-gray-800; + } +} + +/* Custom utility classes */ +@layer utilities { + .text-balance { + text-wrap: balance; + } + + /* Loading spinner */ + .spinner { + @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600; + } + + /* Animations */ + .animate-fade-in { + animation: fadeIn 0.3s ease-in-out; + } + + .animate-slide-up { + animation: slideUp 0.3s ease-out; + } +} + +/* Custom keyframes */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +================================================== +ARCHIVO: .\frontend-internal\src\app.html +================================================== + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + + +================================================== +ARCHIVO: .\frontend-internal\src\lib\components\Header.svelte +================================================== + + +
+
+ +
+ + + +
+ + +
+ +
+ + + {#if isMenuOpen} +
+
+
+ {$auth.user?.email} +
+ isMenuOpen = false} + > + Mi Perfil + + +
+
+ {/if} +
+
+
+
+ + +{#if isMenuOpen} +
isMenuOpen = false} + >
+{/if} + +================================================== +ARCHIVO: .\frontend-internal\src\lib\components\Icon.svelte +================================================== + + + + + + +================================================== +ARCHIVO: .\frontend-internal\src\lib\components\Modal.svelte +================================================== + + + + +{#if open} + +{/if} + + +================================================== +ARCHIVO: .\frontend-internal\src\lib\components\Sidebar.svelte +================================================== + + + +{#if open} +
+
open = false}>
+
+{/if} + + +
+
+ +
+
+
+ + + +
+
+

ServiceManager

+

Panel Interno

+
+
+ + +
+ + + + + +
+
+
+ + {$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]} + +
+
+

+ {$auth.user?.first_name} {$auth.user?.last_name} +

+

+ {$auth.user?.role === 'ADMIN' ? 'Administrador' : + $auth.user?.role === 'SUPPORT_MANAGER' ? 'Gerente de Soporte' : + $auth.user?.role === 'AGENT' ? 'Agente' : 'Auditor'} +

+
+
+
+
+
+ +================================================== +ARCHIVO: .\frontend-internal\src\lib\components\Toast.svelte +================================================== + + +{#if visible} +
+
+
+
+ + + +
+ +
+

+ {message} +

+
+ + {#if dismissible} +
+ +
+ {/if} +
+
+
+{/if} + + + +================================================== +ARCHIVO: .\frontend-internal\src\lib\stores\api.ts +================================================== +import { writable } from 'svelte/store'; + +export const api = { + async getClients() { + const response = await fetch('/api/v1/clients'); + if (!response.ok) { + throw new Error('Error fetching clients'); + } + return await response.json(); + }, + + async createClient(client) { + const response = await fetch('/api/v1/clients', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(client), + }); + if (!response.ok) { + throw new Error('Error creating client'); + } + return await response.json(); + }, + + async updateClient(clientId, client) { + const response = await fetch(`/api/v1/clients/${clientId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(client), + }); + if (!response.ok) { + throw new Error('Error updating client'); + } + return await response.json(); + }, + + async deleteClient(clientId) { + const response = await fetch(`/api/v1/clients/${clientId}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Error deleting client'); + } + return await response.json(); + }, +}; + +================================================== +ARCHIVO: .\frontend-internal\src\lib\stores\auth.ts +================================================== +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +// Types +export interface InternalUser { + id: string; + email: string; + first_name: string; + last_name: string; + role: 'ADMIN' | 'SUPPORT_MANAGER' | 'AGENT' | 'AUDITOR'; + is_active: boolean; + is_two_factor_enabled: boolean; + created_at: string; + tenant_id: string; +} + +export interface AuthState { + user: InternalUser | null; + token: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +export interface LoginRequest { + email: string; + password: string; + tenant_slug: string; + totp_code?: string; +} + +export interface LoginResponse { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + user: InternalUser; +} + +export interface RefreshTokenRequest { + refresh_token: string; +} + +export interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +// Initial state +const initialState: AuthState = { + user: null, + token: null, + refreshToken: null, + isAuthenticated: false, + isLoading: false +}; + +// Create auth store +function createAuthStore() { + const { subscribe, set, update } = writable(initialState); + + return { + subscribe, + + // Initialize auth from localStorage + init: () => { + if (typeof window !== 'undefined') { + const token = localStorage.getItem('internal_auth_token'); + const refreshToken = localStorage.getItem('internal_auth_refresh_token'); + const user = localStorage.getItem('internal_auth_user'); + + if (token && user) { + try { + const parsedUser = JSON.parse(user); + set({ + user: parsedUser, + token, + refreshToken: refreshToken || null, + isAuthenticated: true, + isLoading: false + }); + } catch (error) { + console.error('Error parsing stored auth data:', error); + localStorage.removeItem('internal_auth_token'); + localStorage.removeItem('internal_auth_refresh_token'); + localStorage.removeItem('internal_auth_user'); + } + } + } + }, + + // Login + login: async (credentials: LoginRequest): Promise => { + update(state => ({ ...state, isLoading: true })); + + try { + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(credentials) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Login failed'); + } + + const data: LoginResponse = await response.json(); + + // Store auth data + if (typeof window !== 'undefined') { + localStorage.setItem('internal_auth_token', data.access_token); + if (data.refresh_token) { + localStorage.setItem('internal_auth_refresh_token', data.refresh_token); + } + localStorage.setItem('internal_auth_user', JSON.stringify(data.user)); + } + + set({ + user: data.user, + token: data.access_token, + refreshToken: data.refresh_token, + isAuthenticated: true, + isLoading: false + }); + } catch (error) { + update(state => ({ ...state, isLoading: false })); + throw error; + } + }, + + // Refresh Session + refreshSession: async (): Promise => { + // Need to get current state to access refresh token, logic simplified + let currentRefreshToken: string | null = null; + if (typeof window !== 'undefined') { + currentRefreshToken = localStorage.getItem('internal_auth_refresh_token'); + } + + if (!currentRefreshToken) { + throw new Error("No refresh token available"); + } + + update (state => ({ ...state, isLoading: true })); + + try { + const response = await fetch('/api/v1/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refresh_token: currentRefreshToken }) + }); + + if (!response.ok) { + // If refresh fails, logout + if (response.status === 401 || response.status === 403) { + auth.logout(); + } + const error = await response.json(); + throw new Error(error.detail || 'Refresh failed'); + } + + const data: TokenResponse = await response.json(); + + // Update token in storage and state + if (typeof window !== 'undefined') { + localStorage.setItem('internal_auth_token', data.access_token); + } + + update(state => ({ + ...state, + token: data.access_token, + isLoading: false + })); + + } catch (error) { + update(state => ({ ...state, isLoading: false })); + throw error; + } + }, + + // Logout + logout: () => { + if (typeof window !== 'undefined') { + localStorage.removeItem('internal_auth_token'); + localStorage.removeItem('internal_auth_refresh_token'); + localStorage.removeItem('internal_auth_user'); + } + set(initialState); + // Optional: Redirect to login + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + }, + + // Update user data + updateUser: (user: InternalUser) => { + update(state => ({ ...state, user })); + if (typeof window !== 'undefined') { + localStorage.setItem('internal_auth_user', JSON.stringify(user)); + } + }, + + // Set loading state + setLoading: (isLoading: boolean) => { + update(state => ({ ...state, isLoading })); + } + }; +} + +export const auth = createAuthStore(); + +================================================== +ARCHIVO: .\frontend-internal\src\lib\stores\toast.ts +================================================== +// Toast notification store +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +export interface ToastMessage { + id: string; + type: 'success' | 'error' | 'warning' | 'info'; + message: string; + duration?: number; +} + +interface ToastState { + toasts: ToastMessage[]; +} + +const initialState: ToastState = { + toasts: [] +}; + +function createToastStore() { + const { subscribe, update }: Writable = writable(initialState); + + return { + subscribe, + + show: (type: ToastMessage['type'], message: string, duration = 5000) => { + const id = Math.random().toString(36).substring(2, 9); + const toast: ToastMessage = { id, type, message, duration }; + + update(state => ({ + toasts: [...state.toasts, toast] + })); + + // Auto-remove after duration + if (duration > 0) { + setTimeout(() => { + update(state => ({ + toasts: state.toasts.filter(t => t.id !== id) + })); + }, duration); + } + + return id; + }, + + dismiss: (id: string) => { + update(state => ({ + toasts: state.toasts.filter(t => t.id !== id) + })); + }, + + clear: () => { + update(() => initialState); + }, + + // Convenience methods + success: (message: string, duration?: number) => { + return createToastStore().show('success', message, duration); + }, + + error: (message: string, duration?: number) => { + return createToastStore().show('error', message, duration); + }, + + warning: (message: string, duration?: number) => { + return createToastStore().show('warning', message, duration); + }, + + info: (message: string, duration?: number) => { + return createToastStore().show('info', message, duration); + } + }; +} + +export const toast = createToastStore(); + +================================================== +ARCHIVO: .\frontend-internal\src\lib\utils\api.ts +================================================== +import { auth } from '$lib/stores/auth'; +import { get } from 'svelte/store'; + +const API_BASE = '/api/v1'; + +interface RequestOptions extends RequestInit { + params?: Record; +} + +async function request(endpoint: string, options: RequestOptions = {}): Promise { + const { params, ...init } = options; + + let url = `${API_BASE}${endpoint}`; + if (params) { + const searchParams = new URLSearchParams(params); + url += `?${searchParams.toString()}`; + } + + const authState = get(auth); + const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); + + const headers = new Headers(init.headers); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(url, { + ...init, + headers + }); + + if (response.status === 401) { + // Token expired or invalid + if (typeof window !== 'undefined') { + localStorage.removeItem('internal_auth_token'); + localStorage.removeItem('internal_auth_user'); + window.location.href = '/login'; + } + throw new Error('Unauthorized'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `API error: ${response.statusText}`); + } + + // Handle empty responses (like 204 No Content) + if (response.status === 204) { + return {} as T; + } + + return response.json(); +} + +export const api = { + get: (endpoint: string, params?: Record) => + request(endpoint, { method: 'GET', params }), + + post: (endpoint: string, body: any) => + request(endpoint, { method: 'POST', body: JSON.stringify(body) }), + + put: (endpoint: string, body: any) => + request(endpoint, { method: 'PUT', body: JSON.stringify(body) }), + + patch: (endpoint: string, body: any) => + request(endpoint, { method: 'PATCH', body: JSON.stringify(body) }), + + delete: (endpoint: string) => + request(endpoint, { method: 'DELETE' }) +}; + + +================================================== +ARCHIVO: .\frontend-internal\src\routes\+layout.svelte +================================================== + + +
+ {#if $auth.isAuthenticated} + +
+ + + + +
+
+ +
+ +
+
+
+ {:else} + +
+ +
+ {/if} + + + {#each $toast.toasts as toastMessage (toastMessage.id)} + toast.dismiss(toastMessage.id)} + /> + {/each} +
+ +================================================== +ARCHIVO: .\frontend-internal\src\routes\+page.svelte +================================================== + + + + Dashboard Admin - ServiceManager + + +
+
+
+

+ Panel de Administración +

+

+ Bienvenido al sistema de gestión interna. +

+
+
+ + +
+ + +================================================== +ARCHIVO: .\frontend-internal\src\routes\categories\+page.svelte +================================================== + + +
+
+
+

Categorías de Tickets

+

Gestión de categorías para clasificación de tickets.

+
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + {#if isLoading} + + {:else if categories.length === 0} + + {:else} + {#each categories as category} + + + + + + + + {/each} + {/if} + +
NombreDescripciónTipo (Cliente)Estado + Acciones +
Cargando...
No hay categorías registradas
{category.name}{category.description || '-'} + + {getTenantName(category.tenant_id)} + + + + {category.is_active ? 'Activo' : 'Inactivo'} + + + +
+
+
+
+
+
+ + showModal = false}> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +================================================== +ARCHIVO: .\frontend-internal\src\routes\login\+page.svelte +================================================== + + + + + Acceso Admin - ServiceManager + + +
+
+ + + + + +
+ +
+
+

Identifíquese

+

Acceso al sistema central

+
+ +
+ {#if errorMessage} +
+ +

{errorMessage}

+
+ {/if} + + {#if !showTwoFactor} +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ + {:else} + +
+ +
+ +
+

+ Consulte su dispositivo autenticador +

+
+ {/if} + +
+ +
+
+
+ +
+

Aduanasoft Internal Systems © 2024

+
+
+
+
+ +================================================== +ARCHIVO: .\frontend-internal\src\routes\systems\+page.svelte +================================================== + + +
+
+
+

Sistemas

+

Catálogo de sistemas informáticos gestionados.

+
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + {#if isLoading} + + {:else if systems.length === 0} + + {:else} + {#each systems as system} + + + + + + + {/each} + {/if} + +
NombreDescripciónEstado + Acciones +
Cargando...
No hay sistemas registrados
{system.name}{system.description || '-'} + + {system.is_active ? 'Activo' : 'Inactivo'} + + + +
+
+
+
+
+
+ + showModal = false}> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +================================================== +ARCHIVO: .\frontend-internal\src\routes\tenants\+page.svelte +================================================== + + +
+
+
+

Clientes

+

Lista de todas las organizaciones/clientes registrados en el sistema.

+
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + {#if isLoading} + + {:else if tenants.length === 0} + + {:else} + {#each tenants as tenant} + + + + + + + + {/each} + {/if} + +
NombreSlugDominioEstado + Acciones +
Cargando...
No hay clientes registrados
{tenant.name}{tenant.slug}{tenant.domain || '-'} + + {tenant.is_active ? 'Activo' : 'Inactivo'} + + + +
+
+
+
+
+
+ + showModal = false}> +
+
+ + +
+ +
+ + +

Usado en URLs y subdominios.

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +================================================== +ARCHIVO: .\frontend-internal\src\routes\users\+page.svelte +================================================== + + +
+
+
+

Usuarios

+

Gestión de usuarios internos y de clientes.

+
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + {#if isLoading} + + {:else if users.length === 0} + + {:else} + {#each users as user} + + + + + + + + {/each} + {/if} + +
UsuarioRolCliente (Tenant)Estado + Acciones +
Cargando...
No hay usuarios registrados
+
{user.first_name} {user.last_name}
+
{user.email}
+
{user.role}{getTenantName(user.tenant_id)} + + {user.is_active ? 'Activo' : 'Inactivo'} + + + +
+
+
+
+
+
+ + showModal = false}> +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +================================================== +ARCHIVO: .\scripts\README.md +================================================== +# ServiceManagerWeb - Scripts de Utilidad + +Este directorio contiene scripts para desarrollo y despliegue del sistema. + +## Scripts de Desarrollo + +### setup-dev.sh / setup-dev.ps1 +```bash +# Linux/Mac +./scripts/setup-dev.sh + +# Windows +./scripts/setup-dev.ps1 +``` + +Configura el entorno de desarrollo: +- Copia .env.example a .env +- Genera claves secretas seguras +- Levanta servicios con Docker Compose +- Ejecuta migraciones iniciales +- Carga datos de prueba + +### db-migrate.sh / db-migrate.ps1 +```bash +# Ejecutar migraciones pendientes +./scripts/db-migrate.sh + +# Crear nueva migración +./scripts/db-migrate.sh "add_user_preferences" +``` + +### test-all.sh / test-all.ps1 +```bash +# Ejecutar todos los tests +./scripts/test-all.sh + +# Solo backend +./scripts/test-all.sh backend + +# Con coverage +./scripts/test-all.sh --coverage +``` + +### lint-fix.sh / lint-fix.ps1 +```bash +# Linting y formateo automático +./scripts/lint-fix.sh +``` + +## Scripts de Producción + +### deploy.sh +```bash +# Despliegue en producción +./scripts/deploy.sh production + +# Con backup automático +./scripts/deploy.sh production --backup +``` + +### backup.sh +```bash +# Backup completo +./scripts/backup.sh + +# Solo base de datos +./scripts/backup.sh --db-only +``` + +### health-check.sh +```bash +# Verificar estado de servicios +./scripts/health-check.sh +``` + +## Scripts de Mantenimiento + +### cleanup.sh +```bash +# Limpiar logs antiguos y archivos temporales +./scripts/cleanup.sh + +# Limpiar containers e imágenes sin usar +./scripts/cleanup.sh --docker +``` + +### seed-data.sh +```bash +# Cargar datos de prueba +./scripts/seed-data.sh + +# Cargar datos específicos +./scripts/seed-data.sh --fixture=users +``` + +## Uso + +Todos los scripts incluyen help integrado: + +```bash +./scripts/script-name.sh --help +``` + +Los scripts están disponibles tanto para Unix (sh) como Windows (ps1). + +================================================== +ARCHIVO: .\workers\README.md +================================================== +# ServiceManagerWeb Workers + +Workers asíncronos con Celery para el sistema de Mesa de Ayuda B2B. + +## Estructura + +``` +workers/ +├── app/ +│ ├── celery.py # Configuración principal de Celery +│ ├── core/ # Configuración compartida +│ │ ├── config.py # Settings para workers +│ │ └── logging.py # Logging estructurado +│ └── tasks/ # Tareas por dominio +│ ├── email_tasks.py # Envío de emails +│ ├── sla_tasks.py # Monitoreo de SLAs +│ ├── maintenance_tasks.py # Mantenimiento del sistema +│ └── notification_tasks.py # Notificaciones y digests +├── requirements.txt # Dependencias Python +└── README.md # Esta documentación +``` + +## Tareas Implementadas + +### Email Tasks (`email_tasks.py`) +- [x] `send_email_task`: Envío básico de emails SMTP +- [x] `send_templated_email_task`: Emails con plantillas Jinja2 +- [x] `send_bulk_email_task`: Envío masivo con progreso + +### SLA Tasks (`sla_tasks.py`) +- [x] `check_sla_violations`: Monitoreo de violaciones SLA +- [x] `calculate_sla_metrics`: Cálculo de métricas SLA +- [x] `send_sla_warnings`: Alertas de SLAs próximos a vencer + +### Maintenance Tasks (`maintenance_tasks.py`) +- [x] `health_check`: Health check de workers +- [x] `cleanup_old_logs`: Limpieza de logs antiguos +- [x] `generate_weekly_reports`: Reportes semanales +- [x] `cleanup_temp_files`: Limpieza de archivos temporales +- [x] `database_maintenance`: Mantenimiento de BD + +### Notification Tasks (`notification_tasks.py`) +- [x] `send_daily_digest`: Digest diario para agentes +- [x] `send_ticket_notifications`: Notificaciones de tickets +- [x] `send_system_alert`: Alertas del sistema + +## Programación Automática (Celery Beat) + +### Tareas Periódicas Configuradas + +```python +# Cada 5 minutos +"check-sla-violations": check_sla_violations + +# Diario a las 8:00 AM +"send-daily-digest": send_daily_digest + +# Semanal los domingos a las 2:00 AM +"cleanup-old-logs": cleanup_old_logs + +# Semanal los lunes a las 9:00 AM +"generate-weekly-reports": generate_weekly_reports + +# Cada minuto (health check) +"worker-health-check": health_check +``` + +## Quick Start + +### Desarrollo Local + +```bash +# Instalar dependencias +pip install -r requirements.txt + +# Variables de entorno (usar las del proyecto principal) +cp ../.env.example .env + +# Ejecutar worker +celery -A app.celery worker --loglevel=info + +# Ejecutar beat scheduler (en otra terminal) +celery -A app.celery beat --loglevel=info + +# Monitoreo con Flower (opcional) +celery -A app.celery flower +``` + +### Con Docker + +```bash +# Worker y Beat se ejecutan automáticamente con docker-compose +docker-compose up worker beat + +# Ver logs +docker-compose logs -f worker +docker-compose logs -f beat +``` + +## Configuración + +### Variables de Entorno Importantes + +```bash +# Celery +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 + +# Email +SMTP_HOST=mailhog +SMTP_PORT=1025 +DEFAULT_FROM_EMAIL=noreply@servicemanager.local + +# SLA +SLA_CHECK_ENABLED=true +SLA_WARNING_THRESHOLD=0.8 + +# Mantenimiento +LOG_RETENTION_DAYS=30 +DIGEST_ENABLED=true +``` + +### Colas de Trabajo + +- **default**: Tareas generales +- **email**: Envío de emails +- **sla**: Monitoreo SLA +- **maintenance**: Mantenimiento +- **notifications**: Notificaciones + +## Monitoreo + +### Logs Estructurados + +Todos los workers utilizan structured logging con: +- Task ID único +- Correlation ID para tracking +- Contexto de tenant +- Métricas de performance + +### Health Checks + +```bash +# Health check manual +celery -A app.celery inspect ping + +# Estadísticas de workers +celery -A app.celery inspect stats + +# Tareas activas +celery -A app.celery inspect active +``` + +### Métricas + +- Task execution times +- Success/failure rates +- Queue lengths +- Worker load + +## Desarrollo + +### Agregar Nueva Tarea + +1. Crear función en módulo apropiado: +```python +@celery_app.task(bind=True, time_limit=300) +def new_task(self, param1: str, param2: int): + logger = get_logger(__name__) + # Implementation + return result +``` + +2. Registrar en `celery.py` si es periódica: +```python +beat_schedule = { + "new-periodic-task": { + "task": "app.tasks.module.new_task", + "schedule": crontab(minute=0, hour=9), + } +} +``` + +3. Agregar tests en `tests/` + +### Retry y Error Handling + +```python +@celery_app.task( + bind=True, + autoretry_for=(ConnectionError, TimeoutError), + retry_kwargs={'max_retries': 3, 'countdown': 60} +) +def reliable_task(self): + # Task que se reintenta automáticamente + pass +``` + +### Templates de Email + +Los templates están definidos en código por ahora. En el futuro se moverán a base de datos para ser editables por tenants. + +Templates disponibles: +- `ticket_created` +- `ticket_assigned` +- `ticket_resolved` +- `sla_warning` +- `sla_violation` +- `daily_digest` +- `system_alert` + +## Testing + +```bash +# Ejecutar tests +pytest + +# Tests específicos de workers +pytest tests/test_tasks/ + +# Ejecutar tarea manualmente para testing +celery -A app.celery call app.tasks.email_tasks.send_email_task --args='["test@example.com", "Test Subject", "

Test

"]' +``` + +## Producción + +### Configuración Recomendada + +```bash +# Múltiples workers por queue +celery -A app.celery worker --loglevel=info --concurrency=4 --queues=email +celery -A app.celery worker --loglevel=info --concurrency=2 --queues=sla,maintenance +celery -A app.celery worker --loglevel=info --concurrency=1 --queues=default + +# Beat scheduler (solo una instancia) +celery -A app.celery beat --loglevel=info + +# Con systemd o supervisor para auto-restart +``` + +### Optimizaciones + +- Pool de conexiones Redis +- Compresión de mensajes grandes +- Rate limiting por tarea +- Monitoring con Prometheus/Grafana + +## Troubleshooting + +### Problemas Comunes + +1. **Tasks stuck in queue**: + - Verificar workers activos + - Revisar configuración de routing + +2. **Memory leaks**: + - Configurar `worker_max_tasks_per_child` + - Monitorear uso de memoria + +3. **Email delivery failures**: + - Verificar configuración SMTP + - Revisar logs de tareas email + +4. **SLA false positives**: + - Verificar timezones + - Validar lógica de cálculo + +### Debug + +```bash +# Ejecutar worker en modo debug +celery -A app.celery worker --loglevel=debug + +# Inspeccionar tareas fallidas +celery -A app.celery inspect failed + +# Purgar queue +celery -A app.celery purge -Q queue_name +``` + +================================================== +ARCHIVO: .\workers\app\celery.py +================================================== +""" +Celery Application - ServiceManagerWeb Workers + +Configuración principal de Celery para tareas asíncronas +""" + +from celery import Celery +from celery.schedules import crontab +import os +from app.core.config import get_settings +from app.core.logging import setup_logging + +# Setup logging +setup_logging() + +# Get settings +settings = get_settings() + +# Create Celery application +celery_app = Celery( + "servicemanager-workers", + broker=settings.CELERY_BROKER_URL, + backend=settings.CELERY_RESULT_BACKEND, + include=[ + "app.tasks.email_tasks", + "app.tasks.sla_tasks", + "app.tasks.maintenance_tasks", + "app.tasks.notification_tasks" + ] +) + +# Configure Celery +celery_app.conf.update( + # Task settings + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="UTC", + enable_utc=True, + + # Result backend settings + result_expires=3600, # 1 hour + result_persistent=True, + + # Worker settings + worker_prefetch_multiplier=1, + worker_max_tasks_per_child=1000, + worker_disable_rate_limits=False, + + # Task routing + task_routes={ + "app.tasks.email_tasks.*": {"queue": "email"}, + "app.tasks.sla_tasks.*": {"queue": "sla"}, + "app.tasks.maintenance_tasks.*": {"queue": "maintenance"}, + "app.tasks.notification_tasks.*": {"queue": "notifications"}, + }, + + # Queue configuration + task_default_queue="default", + task_default_exchange="default", + task_default_routing_key="default", + + # Beat schedule for periodic tasks + beat_schedule={ + # Check SLA violations every 5 minutes + "check-sla-violations": { + "task": "app.tasks.sla_tasks.check_sla_violations", + "schedule": crontab(minute="*/5"), + }, + + # Send daily digest at 8:00 AM + "send-daily-digest": { + "task": "app.tasks.notification_tasks.send_daily_digest", + "schedule": crontab(hour=8, minute=0), + }, + + # Clean old logs weekly on Sunday at 2:00 AM + "cleanup-old-logs": { + "task": "app.tasks.maintenance_tasks.cleanup_old_logs", + "schedule": crontab(hour=2, minute=0, day_of_week=0), + }, + + # Generate weekly reports on Monday at 9:00 AM + "generate-weekly-reports": { + "task": "app.tasks.maintenance_tasks.generate_weekly_reports", + "schedule": crontab(hour=9, minute=0, day_of_week=1), + }, + + # Health check every minute + "worker-health-check": { + "task": "app.tasks.maintenance_tasks.health_check", + "schedule": crontab(minute="*/1"), + }, + }, + + # Error handling + task_reject_on_worker_lost=True, + task_acks_late=True, + + # Monitoring + worker_send_task_events=True, + task_send_sent_event=True, + + # Security + worker_hijack_root_logger=False, + worker_log_format="[%(asctime)s: %(levelname)s/%(processName)s] %(message)s", + worker_task_log_format="[%(asctime)s: %(levelname)s/%(processName)s][%(task_name)s(%(task_id)s)] %(message)s", +) + +# Optional: Configure SSL if needed +if settings.ENVIRONMENT == "production": + # Enable SSL for production + celery_app.conf.update( + broker_use_ssl=True, + redis_backend_use_ssl=True, + ) + + +# Import all tasks to register them +from app.tasks import ( + email_tasks, + sla_tasks, + maintenance_tasks, + notification_tasks +) + + +if __name__ == "__main__": + celery_app.start() + +================================================== +ARCHIVO: .\workers\app\core\config.py +================================================== +""" +Core Configuration for Workers - ServiceManagerWeb + +Configuración compartida entre workers usando Pydantic Settings +""" + +from functools import lru_cache +from typing import List, Optional +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings +import os + + +class WorkerSettings(BaseSettings): + """Configuración para workers Celery.""" + + # =================================== + # GENERAL + # =================================== + ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT") + DEBUG: bool = Field(default=False, env="DEBUG") + + # =================================== + # DATABASE + # =================================== + DATABASE_URL: str = Field(..., env="DATABASE_URL") + + # =================================== + # CELERY & REDIS + # =================================== + CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL") + CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND") + REDIS_URL: str = Field(..., env="REDIS_URL") + + # =================================== + # EMAIL SETTINGS + # =================================== + SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST") + SMTP_PORT: int = Field(default=587, env="SMTP_PORT") + SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER") + SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD") + SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS") + SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL") + + DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL") + DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME") + + # Email retry settings + EMAIL_MAX_RETRIES: int = Field(default=3, env="EMAIL_MAX_RETRIES") + EMAIL_RETRY_DELAY: int = Field(default=60, env="EMAIL_RETRY_DELAY") # seconds + + # =================================== + # FILE PROCESSING + # =================================== + UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH") + MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB") + + # =================================== + # SLA SETTINGS + # =================================== + SLA_CHECK_ENABLED: bool = Field(default=True, env="SLA_CHECK_ENABLED") + SLA_WARNING_THRESHOLD: float = Field(default=0.8, env="SLA_WARNING_THRESHOLD") # 80% of SLA time + + # =================================== + # LOGGING + # =================================== + LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL") + LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT") + LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE") + + # =================================== + # MONITORING + # =================================== + SENTRY_DSN: Optional[str] = Field(default=None, env="SENTRY_DSN") + PROMETHEUS_PORT: int = Field(default=8888, env="PROMETHEUS_PORT") + + # =================================== + # TASK SETTINGS + # =================================== + TASK_TIME_LIMIT: int = Field(default=300, env="TASK_TIME_LIMIT") # 5 minutes + TASK_SOFT_TIME_LIMIT: int = Field(default=240, env="TASK_SOFT_TIME_LIMIT") # 4 minutes + + # =================================== + # MAINTENANCE SETTINGS + # =================================== + LOG_RETENTION_DAYS: int = Field(default=30, env="LOG_RETENTION_DAYS") + ATTACHMENT_RETENTION_DAYS: int = Field(default=90, env="ATTACHMENT_RETENTION_DAYS") + + # =================================== + # NOTIFICATION SETTINGS + # =================================== + NOTIFICATION_BATCH_SIZE: int = Field(default=100, env="NOTIFICATION_BATCH_SIZE") + DIGEST_ENABLED: bool = Field(default=True, env="DIGEST_ENABLED") + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "case_sensitive": True + } + + def is_production(self) -> bool: + """Check if environment is production.""" + return self.ENVIRONMENT.lower() == "production" + + def is_development(self) -> bool: + """Check if environment is development.""" + return self.ENVIRONMENT.lower() == "development" + + +@lru_cache() +def get_settings() -> WorkerSettings: + """ + Get cached settings instance. + + Using lru_cache to create a singleton pattern for settings. + """ + return WorkerSettings() + +================================================== +ARCHIVO: .\workers\app\core\logging.py +================================================== +""" +Logging Configuration for Workers - ServiceManagerWeb + +Configuración de logging estructurado para workers Celery +""" + +import logging +import logging.config +import sys +from typing import Any, Dict +import structlog +from app.core.config import get_settings + +settings = get_settings() + + +def setup_logging(): + """Configure structured logging for workers.""" + + processors = [ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + # Add worker-specific context + structlog.processors.CallsiteParameterAdder( + parameters=[ + structlog.processors.CallsiteParameter.FUNC_NAME, + structlog.processors.CallsiteParameter.PATHNAME, + structlog.processors.CallsiteParameter.LINENO, + ] + ), + ] + + if settings.LOG_FORMAT == "json": + processors.append(structlog.processors.JSONRenderer()) + else: + processors.append(structlog.dev.ConsoleRenderer(colors=True)) + + structlog.configure( + processors=processors, + wrapper_class=structlog.stdlib.BoundLogger, + logger_factory=structlog.stdlib.LoggerFactory(), + context_class=dict, + cache_logger_on_first_use=True, + ) + + # Configure standard library logging for Celery + logging_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "json": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.processors.JSONRenderer(), + }, + "console": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.dev.ConsoleRenderer(colors=True), + }, + }, + "handlers": { + "console": { + "level": settings.LOG_LEVEL, + "class": "logging.StreamHandler", + "stream": sys.stdout, + "formatter": "json" if settings.LOG_FORMAT == "json" else "console", + }, + }, + "loggers": { + "": { # root logger + "handlers": ["console"], + "level": settings.LOG_LEVEL, + "propagate": False, + }, + "celery": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "celery.worker": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "celery.task": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "app": { + "handlers": ["console"], + "level": settings.LOG_LEVEL, + "propagate": False, + }, + }, + } + + # Add file handler if specified + if settings.LOG_FILE: + logging_config["handlers"]["file"] = { + "level": settings.LOG_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "filename": settings.LOG_FILE, + "maxBytes": 10 * 1024 * 1024, # 10MB + "backupCount": 5, + "formatter": "json", + } + + for logger_config in logging_config["loggers"].values(): + logger_config["handlers"].append("file") + + logging.config.dictConfig(logging_config) + + +def get_logger(name: str = None) -> structlog.BoundLogger: + """Get a configured structlog logger.""" + return structlog.get_logger(name) + +================================================== +ARCHIVO: .\workers\app\tasks\email_tasks.py +================================================== +""" +Email Tasks - ServiceManagerWeb Workers + +Tareas asíncronas para envío de emails y notificaciones +""" + +from celery import current_task +from celery.exceptions import Retry +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +import smtplib +import ssl +from typing import Dict, List, Optional, Any +from jinja2 import Template, Environment, BaseLoader +import structlog + +from app.celery import celery_app +from app.core.config import get_settings +from app.core.logging import get_logger + +settings = get_settings() +logger = get_logger(__name__) + + +class MemoryLoader(BaseLoader): + """Jinja2 loader for templates from memory/database.""" + + def __init__(self, templates: Dict[str, str]): + self.templates = templates + + def get_source(self, environment, template): + if template in self.templates: + source = self.templates[template] + return source, None, lambda: True + raise TemplateNotFoundError(template) + + +@celery_app.task( + bind=True, + autoretry_for=(Exception,), + retry_kwargs={'max_retries': 3, 'countdown': 60}, + time_limit=120, + soft_time_limit=90 +) +def send_email_task( + self, + to_email: str, + subject: str, + html_content: str, + text_content: Optional[str] = None, + from_email: Optional[str] = None, + from_name: Optional[str] = None, + attachments: Optional[List[Dict[str, Any]]] = None, + tenant_id: Optional[str] = None, + correlation_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Send email using SMTP. + + Args: + to_email: Recipient email address + subject: Email subject + html_content: HTML content + text_content: Plain text content (optional) + from_email: Sender email (optional, uses default) + from_name: Sender name (optional) + attachments: List of attachment dicts + tenant_id: Tenant ID for logging + correlation_id: Correlation ID for tracking + + Returns: + Dict with send result + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id, + correlation_id=correlation_id + ) + + task_logger.info( + "Starting email send task", + to_email=to_email, + subject=subject + ) + + try: + # Prepare email + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = f"{from_name or settings.DEFAULT_FROM_NAME} <{from_email or settings.DEFAULT_FROM_EMAIL}>" + msg['To'] = to_email + + # Add text content + if text_content: + text_part = MIMEText(text_content, 'plain', 'utf-8') + msg.attach(text_part) + + # Add HTML content + html_part = MIMEText(html_content, 'html', 'utf-8') + msg.attach(html_part) + + # Add attachments + if attachments: + for attachment in attachments: + part = MIMEBase('application', 'octet-stream') + part.set_payload(attachment['content']) + encoders.encode_base64(part) + part.add_header( + 'Content-Disposition', + f'attachment; filename= {attachment["filename"]}' + ) + msg.attach(part) + + # Send email + context = ssl.create_default_context() + + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server: + if settings.SMTP_USE_TLS: + server.starttls(context=context) + + if settings.SMTP_USER and settings.SMTP_PASSWORD: + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + + server.send_message(msg) + + task_logger.info( + "Email sent successfully", + to_email=to_email, + subject=subject + ) + + return { + "success": True, + "to_email": to_email, + "subject": subject, + "sent_at": current_task.request.eta or "now" + } + + except Exception as exc: + task_logger.error( + "Failed to send email", + to_email=to_email, + subject=subject, + error=str(exc), + exc_info=True + ) + + # Check if we should retry + if self.request.retries < self.max_retries: + task_logger.info( + "Retrying email send", + retry_count=self.request.retries + 1, + max_retries=self.max_retries + ) + raise self.retry(countdown=60 * (2 ** self.request.retries)) + + return { + "success": False, + "to_email": to_email, + "subject": subject, + "error": str(exc) + } + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def send_templated_email_task( + self, + to_email: str, + template_name: str, + context: Dict[str, Any], + tenant_id: Optional[str] = None, + correlation_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Send email using a template. + + Args: + to_email: Recipient email + template_name: Template identifier + context: Template context variables + tenant_id: Tenant ID + correlation_id: Correlation ID + + Returns: + Dict with send result + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id, + correlation_id=correlation_id + ) + + task_logger.info( + "Starting templated email task", + to_email=to_email, + template_name=template_name + ) + + try: + # TODO: Fetch template from database + # For now, use mock templates + templates = { + "ticket_created": { + "subject": "Nuevo ticket #{{ ticket_number }}: {{ subject }}", + "html": """ +

Nuevo ticket creado

+

Hola {{ user_name }},

+

Se ha creado un nuevo ticket:

+
    +
  • Número: #{{ ticket_number }}
  • +
  • Asunto: {{ subject }}
  • +
  • Prioridad: {{ priority }}
  • +
+

Ver ticket

+

Saludos,
Equipo de Soporte

+ """, + "text": """ + Nuevo ticket creado + + Hola {{ user_name }}, + + Se ha creado un nuevo ticket: + + Número: #{{ ticket_number }} + Asunto: {{ subject }} + Prioridad: {{ priority }} + + Ver ticket: {{ ticket_url }} + + Saludos, + Equipo de Soporte + """ + }, + "ticket_assigned": { + "subject": "Ticket #{{ ticket_number }} asignado a ti", + "html": """ +

Ticket asignado

+

Hola {{ agent_name }},

+

Se te ha asignado el ticket:

+
    +
  • Número: #{{ ticket_number }}
  • +
  • Asunto: {{ subject }}
  • +
  • Cliente: {{ customer_name }}
  • +
  • Prioridad: {{ priority }}
  • +
+

Ver ticket

+ """, + "text": """ + Ticket asignado + + Hola {{ agent_name }}, + + Se te ha asignado el ticket: + + Número: #{{ ticket_number }} + Asunto: {{ subject }} + Cliente: {{ customer_name }} + Prioridad: {{ priority }} + + Ver ticket: {{ ticket_url }} + """ + } + } + + if template_name not in templates: + raise ValueError(f"Template '{template_name}' not found") + + template_data = templates[template_name] + + # Render templates + env = Environment(loader=MemoryLoader({ + f"{template_name}_subject": template_data["subject"], + f"{template_name}_html": template_data["html"], + f"{template_name}_text": template_data["text"] + })) + + subject_template = env.get_template(f"{template_name}_subject") + html_template = env.get_template(f"{template_name}_html") + text_template = env.get_template(f"{template_name}_text") + + subject = subject_template.render(**context) + html_content = html_template.render(**context) + text_content = text_template.render(**context) + + # Send email using the basic send task + return send_email_task.apply_async( + kwargs={ + "to_email": to_email, + "subject": subject, + "html_content": html_content, + "text_content": text_content, + "tenant_id": tenant_id, + "correlation_id": correlation_id + } + ).get() + + except Exception as exc: + task_logger.error( + "Failed to send templated email", + to_email=to_email, + template_name=template_name, + error=str(exc), + exc_info=True + ) + + return { + "success": False, + "to_email": to_email, + "template_name": template_name, + "error": str(exc) + } + + +@celery_app.task( + bind=True, + time_limit=600, + soft_time_limit=540 +) +def send_bulk_email_task( + self, + email_list: List[Dict[str, Any]], + tenant_id: Optional[str] = None, + correlation_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Send bulk emails. + + Args: + email_list: List of email dicts with to_email, subject, content + tenant_id: Tenant ID + correlation_id: Correlation ID + + Returns: + Dict with bulk send results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id, + correlation_id=correlation_id + ) + + total_emails = len(email_list) + task_logger.info(f"Starting bulk email task", total_emails=total_emails) + + results = [] + + for i, email_data in enumerate(email_list): + try: + result = send_email_task.apply_async( + kwargs={ + **email_data, + "tenant_id": tenant_id, + "correlation_id": correlation_id + } + ).get() + + results.append(result) + + # Update task progress + current_task.update_state( + state='PROGRESS', + meta={'current': i + 1, 'total': total_emails} + ) + + except Exception as exc: + task_logger.error( + "Failed to send bulk email item", + index=i, + email_data=email_data, + error=str(exc) + ) + + results.append({ + "success": False, + "to_email": email_data.get("to_email"), + "error": str(exc) + }) + + # Calculate stats + successful = sum(1 for r in results if r.get("success")) + failed = total_emails - successful + + task_logger.info( + "Bulk email task completed", + total=total_emails, + successful=successful, + failed=failed + ) + + return { + "total": total_emails, + "successful": successful, + "failed": failed, + "results": results + } + +================================================== +ARCHIVO: .\workers\app\tasks\maintenance_tasks.py +================================================== +""" +Maintenance Tasks - ServiceManagerWeb Workers + +Tareas de mantenimiento del sistema +""" + +from celery import current_task +from datetime import datetime, timedelta +from typing import Dict, Any, List +import os +import structlog + +from app.celery import celery_app +from app.core.config import get_settings +from app.core.logging import get_logger + +settings = get_settings() +logger = get_logger(__name__) + + +@celery_app.task(bind=True) +def health_check(self) -> Dict[str, Any]: + """ + Worker health check task. + + Returns basic health information about the worker. + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + try: + current_time = datetime.utcnow() + + # Basic health checks + health_data = { + "status": "healthy", + "timestamp": current_time.isoformat(), + "worker_id": self.request.hostname, + "task_id": self.request.id, + "environment": settings.ENVIRONMENT, + "checks": { + "redis": "unknown", # TODO: Check Redis connectivity + "database": "unknown", # TODO: Check database connectivity + "disk_space": "unknown", # TODO: Check disk space + "memory": "unknown" # TODO: Check memory usage + } + } + + task_logger.info("Worker health check completed", status="healthy") + + return health_data + + except Exception as exc: + task_logger.error( + "Worker health check failed", + error=str(exc), + exc_info=True + ) + + return { + "status": "unhealthy", + "timestamp": datetime.utcnow().isoformat(), + "error": str(exc) + } + + +@celery_app.task( + bind=True, + time_limit=1800, # 30 minutes + soft_time_limit=1500 # 25 minutes +) +def cleanup_old_logs(self) -> Dict[str, Any]: + """ + Clean up old log files and database records. + + Removes: + - Log files older than LOG_RETENTION_DAYS + - Old notification logs + - Old audit logs (if configured) + - Temp files + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting cleanup of old logs") + + try: + current_time = datetime.utcnow() + cutoff_date = current_time - timedelta(days=settings.LOG_RETENTION_DAYS) + + cleanup_results = { + "started_at": current_time.isoformat(), + "cutoff_date": cutoff_date.isoformat(), + "files_removed": 0, + "bytes_freed": 0, + "database_records_removed": 0, + "errors": [] + } + + # TODO: Implement actual file cleanup + # For now, simulate cleanup + + # Clean up log files + log_dir = "/app/logs" + if os.path.exists(log_dir): + for filename in os.listdir(log_dir): + filepath = os.path.join(log_dir, filename) + if os.path.isfile(filepath): + file_mtime = datetime.fromtimestamp(os.path.getmtime(filepath)) + if file_mtime < cutoff_date and filename.endswith('.log'): + try: + file_size = os.path.getsize(filepath) + os.remove(filepath) + cleanup_results["files_removed"] += 1 + cleanup_results["bytes_freed"] += file_size + task_logger.info(f"Removed old log file", filename=filename) + except Exception as e: + cleanup_results["errors"].append(f"Failed to remove {filename}: {str(e)}") + + # TODO: Clean up database records + # - Old notification_logs + # - Old audit_logs (with retention policy) + # - Expired refresh_tokens + # - Old file attachments (if configured) + + task_logger.info( + "Cleanup completed", + files_removed=cleanup_results["files_removed"], + bytes_freed=cleanup_results["bytes_freed"], + errors=len(cleanup_results["errors"]) + ) + + return cleanup_results + + except Exception as exc: + task_logger.error( + "Cleanup task failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=3600, # 1 hour + soft_time_limit=3300 # 55 minutes +) +def generate_weekly_reports(self) -> Dict[str, Any]: + """ + Generate weekly reports for all tenants. + + Creates: + - SLA performance reports + - Ticket volume reports + - Agent performance reports + - Customer satisfaction reports + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting weekly reports generation") + + try: + current_time = datetime.utcnow() + week_start = current_time - timedelta(days=7) + + report_results = { + "generated_at": current_time.isoformat(), + "period_start": week_start.isoformat(), + "period_end": current_time.isoformat(), + "reports_generated": [], + "errors": [] + } + + # TODO: Get list of active tenants from database + mock_tenants = [ + {"id": "tenant-1", "name": "Aduanasoft Demo", "slug": "aduanasoft-demo"} + ] + + for tenant in mock_tenants: + try: + task_logger.info( + "Generating report for tenant", + tenant_id=tenant["id"], + tenant_name=tenant["name"] + ) + + # TODO: Generate actual reports + # For now, simulate report generation + + report_data = { + "tenant_id": tenant["id"], + "tenant_name": tenant["name"], + "period_start": week_start.isoformat(), + "period_end": current_time.isoformat(), + "metrics": { + "tickets_created": 25, + "tickets_resolved": 23, + "avg_response_time_hours": 2.1, + "avg_resolution_time_hours": 18.5, + "sla_response_met_percentage": 92.0, + "sla_resolution_met_percentage": 87.0, + "customer_satisfaction_avg": 4.2 + } + } + + report_results["reports_generated"].append(report_data) + + # TODO: Store report in database + # TODO: Send report email to admins + + # Update task progress + current_task.update_state( + state='PROGRESS', + meta={ + 'current': len(report_results["reports_generated"]), + 'total': len(mock_tenants) + } + ) + + except Exception as e: + error_msg = f"Failed to generate report for tenant {tenant['id']}: {str(e)}" + report_results["errors"].append(error_msg) + task_logger.error( + "Report generation failed for tenant", + tenant_id=tenant["id"], + error=str(e) + ) + + task_logger.info( + "Weekly reports generation completed", + reports_generated=len(report_results["reports_generated"]), + errors=len(report_results["errors"]) + ) + + return report_results + + except Exception as exc: + task_logger.error( + "Weekly reports generation failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=900, # 15 minutes + soft_time_limit=780 # 13 minutes +) +def cleanup_temp_files(self) -> Dict[str, Any]: + """ + Clean up temporary files and orphaned uploads. + + Removes: + - Temp upload files older than 24 hours + - Orphaned attachment files (no DB reference) + - Processing artifacts + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting temp files cleanup") + + try: + current_time = datetime.utcnow() + cutoff_date = current_time - timedelta(hours=24) + + cleanup_results = { + "started_at": current_time.isoformat(), + "temp_files_removed": 0, + "orphaned_files_removed": 0, + "bytes_freed": 0, + "errors": [] + } + + # Clean up temp directory + temp_dirs = ["/tmp", "/app/temp", f"{settings.UPLOAD_PATH}/temp"] + + for temp_dir in temp_dirs: + if os.path.exists(temp_dir): + for filename in os.listdir(temp_dir): + filepath = os.path.join(temp_dir, filename) + if os.path.isfile(filepath): + try: + file_mtime = datetime.fromtimestamp(os.path.getmtime(filepath)) + if file_mtime < cutoff_date: + file_size = os.path.getsize(filepath) + os.remove(filepath) + cleanup_results["temp_files_removed"] += 1 + cleanup_results["bytes_freed"] += file_size + except Exception as e: + cleanup_results["errors"].append(f"Failed to remove temp file {filepath}: {str(e)}") + + # TODO: Check for orphaned files in uploads directory + # - Query database for all attachment file_paths + # - Compare with actual files in upload directory + # - Remove orphaned files + + task_logger.info( + "Temp files cleanup completed", + temp_files_removed=cleanup_results["temp_files_removed"], + orphaned_files_removed=cleanup_results["orphaned_files_removed"], + bytes_freed=cleanup_results["bytes_freed"] + ) + + return cleanup_results + + except Exception as exc: + task_logger.error( + "Temp files cleanup failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def database_maintenance(self) -> Dict[str, Any]: + """ + Perform database maintenance tasks. + + - VACUUM and ANALYZE tables + - Update statistics + - Check for slow queries + - Optimize indices if needed + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting database maintenance") + + try: + # TODO: Implement database maintenance + # For now, return placeholder results + + maintenance_results = { + "started_at": datetime.utcnow().isoformat(), + "tables_analyzed": 0, + "indices_optimized": 0, + "slow_queries_found": 0, + "space_reclaimed_mb": 0 + } + + task_logger.info("Database maintenance completed (placeholder)") + + return maintenance_results + + except Exception as exc: + task_logger.error( + "Database maintenance failed", + error=str(exc), + exc_info=True + ) + raise + +================================================== +ARCHIVO: .\workers\app\tasks\notification_tasks.py +================================================== +""" +Notification Tasks - ServiceManagerWeb Workers + +Tareas para notificaciones y comunicaciones +""" + +from celery import current_task +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional +import structlog + +from app.celery import celery_app +from app.core.config import get_settings +from app.core.logging import get_logger +from app.tasks.email_tasks import send_templated_email_task, send_bulk_email_task + +settings = get_settings() +logger = get_logger(__name__) + + +@celery_app.task( + bind=True, + time_limit=900, # 15 minutes + soft_time_limit=780 # 13 minutes +) +def send_daily_digest(self) -> Dict[str, Any]: + """ + Send daily digest emails to agents and managers. + + Includes: + - New tickets assigned + - SLA warnings + - Performance summary + - Pending tasks + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting daily digest generation") + + if not settings.DIGEST_ENABLED: + task_logger.info("Daily digest disabled, skipping") + return {"status": "disabled"} + + try: + current_time = datetime.utcnow() + yesterday = current_time - timedelta(days=1) + + digest_results = { + "generated_at": current_time.isoformat(), + "period_start": yesterday.isoformat(), + "period_end": current_time.isoformat(), + "digests_sent": 0, + "errors": [] + } + + # TODO: Get active agents and managers from database + mock_recipients = [ + { + "user_id": "user-1", + "email": "agent1@example.com", + "name": "Agent One", + "role": "AGENT", + "tenant_id": "tenant-1" + }, + { + "user_id": "user-2", + "email": "manager@example.com", + "name": "Support Manager", + "role": "SUPPORT_MANAGER", + "tenant_id": "tenant-1" + } + ] + + for recipient in mock_recipients: + try: + task_logger.info( + "Generating digest for user", + user_id=recipient["user_id"], + email=recipient["email"], + role=recipient["role"] + ) + + # TODO: Generate actual digest data from database + digest_data = generate_digest_data( + recipient["user_id"], + recipient["role"], + recipient["tenant_id"], + yesterday, + current_time + ) + + # Send digest email + send_templated_email_task.apply_async(kwargs={ + "to_email": recipient["email"], + "template_name": "daily_digest", + "context": { + "user_name": recipient["name"], + "role": recipient["role"], + "date": current_time.strftime("%Y-%m-%d"), + **digest_data + }, + "tenant_id": recipient["tenant_id"], + "correlation_id": self.request.id + }) + + digest_results["digests_sent"] += 1 + + except Exception as e: + error_msg = f"Failed to send digest to {recipient['email']}: {str(e)}" + digest_results["errors"].append(error_msg) + task_logger.error( + "Digest generation failed for user", + user_id=recipient["user_id"], + error=str(e) + ) + + task_logger.info( + "Daily digest generation completed", + digests_sent=digest_results["digests_sent"], + errors=len(digest_results["errors"]) + ) + + return digest_results + + except Exception as exc: + task_logger.error( + "Daily digest generation failed", + error=str(exc), + exc_info=True + ) + raise + + +def generate_digest_data( + user_id: str, + role: str, + tenant_id: str, + period_start: datetime, + period_end: datetime +) -> Dict[str, Any]: + """ + Generate digest data for a specific user. + + Args: + user_id: User ID + role: User role + tenant_id: Tenant ID + period_start: Start of digest period + period_end: End of digest period + + Returns: + Dict with digest data + """ + # TODO: Implement actual database queries + # For now, return mock data + + base_data = { + "summary": { + "new_tickets": 5, + "resolved_tickets": 7, + "pending_tickets": 12, + "overdue_tickets": 2 + }, + "sla_status": { + "response_sla_met": 8, + "response_sla_missed": 1, + "resolution_sla_met": 6, + "resolution_sla_missed": 2 + } + } + + if role == "AGENT": + base_data.update({ + "assigned_tickets": [ + { + "ticket_number": "TKT-2024-000001", + "subject": "Problema de conexión", + "priority": "HIGH", + "created_at": "2024-01-15T10:00:00Z", + "sla_due": "2024-01-15T12:00:00Z" + } + ], + "urgent_tickets": 1, + "performance": { + "avg_response_time_hours": 1.5, + "avg_resolution_time_hours": 18.2, + "customer_satisfaction": 4.3 + } + }) + + elif role in ["SUPPORT_MANAGER", "ADMIN"]: + base_data.update({ + "team_summary": { + "total_agents": 5, + "active_agents": 4, + "avg_load_per_agent": 6.2 + }, + "escalations": [ + { + "ticket_number": "TKT-2024-000002", + "reason": "SLA violation", + "assigned_to": "agent1@example.com" + } + ], + "trends": { + "ticket_volume_change": "+12%", + "resolution_time_change": "-5%" + } + }) + + return base_data + + +@celery_app.task( + bind=True, + time_limit=600, + soft_time_limit=540 +) +def send_ticket_notifications( + self, + ticket_id: str, + event_type: str, + tenant_id: str, + context: Dict[str, Any], + correlation_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Send ticket-related notifications. + + Args: + ticket_id: Ticket ID + event_type: Type of event (created, assigned, updated, resolved, etc.) + tenant_id: Tenant ID + context: Context data for notifications + correlation_id: Correlation ID + + Returns: + Dict with notification results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + ticket_id=ticket_id, + event_type=event_type, + tenant_id=tenant_id, + correlation_id=correlation_id + ) + + task_logger.info("Starting ticket notifications") + + try: + notification_results = { + "ticket_id": ticket_id, + "event_type": event_type, + "notifications_sent": 0, + "notifications": [] + } + + # Determine who should receive notifications based on event type + recipients = get_notification_recipients(ticket_id, event_type, tenant_id) + + for recipient in recipients: + try: + template_name = f"ticket_{event_type}" + + # Send notification + result = send_templated_email_task.apply_async(kwargs={ + "to_email": recipient["email"], + "template_name": template_name, + "context": { + **context, + "recipient_name": recipient["name"], + "recipient_role": recipient["role"] + }, + "tenant_id": tenant_id, + "correlation_id": correlation_id or self.request.id + }).get() + + notification_results["notifications"].append({ + "recipient": recipient["email"], + "template": template_name, + "success": result.get("success", False), + "error": result.get("error") + }) + + if result.get("success"): + notification_results["notifications_sent"] += 1 + + except Exception as e: + task_logger.error( + "Failed to send notification", + recipient_email=recipient["email"], + error=str(e) + ) + + notification_results["notifications"].append({ + "recipient": recipient["email"], + "success": False, + "error": str(e) + }) + + task_logger.info( + "Ticket notifications completed", + notifications_sent=notification_results["notifications_sent"], + total_recipients=len(recipients) + ) + + return notification_results + + except Exception as exc: + task_logger.error( + "Ticket notifications failed", + error=str(exc), + exc_info=True + ) + raise + + +def get_notification_recipients( + ticket_id: str, + event_type: str, + tenant_id: str +) -> List[Dict[str, Any]]: + """ + Get list of users who should receive notifications for a ticket event. + + Args: + ticket_id: Ticket ID + event_type: Event type + tenant_id: Tenant ID + + Returns: + List of recipient dicts + """ + # TODO: Implement actual database queries + # For now, return mock recipients based on event type + + recipients = [] + + if event_type == "created": + # Notify assigned agent (if any) and customer + recipients = [ + {"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"}, + {"email": "agent@example.com", "name": "Agent", "role": "AGENT"} + ] + + elif event_type == "assigned": + # Notify assigned agent and customer + recipients = [ + {"email": "agent@example.com", "name": "Assigned Agent", "role": "AGENT"}, + {"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"} + ] + + elif event_type == "updated": + # Notify all participants + recipients = [ + {"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"}, + {"email": "agent@example.com", "name": "Agent", "role": "AGENT"} + ] + + elif event_type == "resolved": + # Notify customer for feedback + recipients = [ + {"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"} + ] + + elif event_type == "escalated": + # Notify manager + recipients = [ + {"email": "manager@example.com", "name": "Manager", "role": "SUPPORT_MANAGER"} + ] + + return recipients + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def send_system_alert( + self, + alert_type: str, + message: str, + severity: str = "INFO", + tenant_id: Optional[str] = None, + context: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """ + Send system alert to administrators. + + Args: + alert_type: Type of alert (system_error, sla_violation, etc.) + message: Alert message + severity: Alert severity (INFO, WARNING, ERROR, CRITICAL) + tenant_id: Optional tenant ID + context: Additional context data + + Returns: + Dict with alert results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + alert_type=alert_type, + severity=severity, + tenant_id=tenant_id + ) + + task_logger.info("Sending system alert", message=message) + + try: + # TODO: Get administrators from configuration/database + admin_emails = ["admin@example.com", "alerts@example.com"] + + alert_context = { + "alert_type": alert_type, + "message": message, + "severity": severity, + "timestamp": datetime.utcnow().isoformat(), + "environment": settings.ENVIRONMENT, + "tenant_id": tenant_id, + **(context or {}) + } + + notifications_sent = 0 + + for admin_email in admin_emails: + try: + send_templated_email_task.apply_async(kwargs={ + "to_email": admin_email, + "template_name": "system_alert", + "context": alert_context, + "tenant_id": tenant_id, + "correlation_id": self.request.id + }) + notifications_sent += 1 + + except Exception as e: + task_logger.error( + "Failed to send alert to admin", + admin_email=admin_email, + error=str(e) + ) + + task_logger.info( + "System alert sent", + notifications_sent=notifications_sent, + total_admins=len(admin_emails) + ) + + return { + "alert_type": alert_type, + "message": message, + "severity": severity, + "notifications_sent": notifications_sent, + "sent_at": datetime.utcnow().isoformat() + } + + except Exception as exc: + task_logger.error( + "System alert failed", + error=str(exc), + exc_info=True + ) + raise + +================================================== +ARCHIVO: .\workers\app\tasks\sla_tasks.py +================================================== +""" +SLA Tasks - ServiceManagerWeb Workers + +Tareas para monitoreo y gestión de SLAs +""" + +from celery import current_task +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional +import structlog + +from app.celery import celery_app +from app.core.config import get_settings +from app.core.logging import get_logger +from app.tasks.email_tasks import send_templated_email_task + +settings = get_settings() +logger = get_logger(__name__) + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def check_sla_violations(self) -> Dict[str, Any]: + """ + Check for SLA violations and send alerts. + + This task runs every 5 minutes to check for: + - Response SLA violations + - Resolution SLA violations + - SLA warnings (approaching deadline) + + Returns: + Dict with check results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name + ) + + task_logger.info("Starting SLA violations check") + + if not settings.SLA_CHECK_ENABLED: + task_logger.info("SLA check disabled, skipping") + return {"status": "disabled"} + + try: + current_time = datetime.utcnow() + results = { + "checked_at": current_time.isoformat(), + "response_violations": [], + "resolution_violations": [], + "warnings": [], + "notifications_sent": 0 + } + + # TODO: Implement actual database queries + # For now, simulate some checks + + # Mock violations for development + mock_violations = [ + { + "ticket_id": "mock-ticket-1", + "ticket_number": "TKT-2024-000001", + "subject": "Problema urgente de conexión", + "priority": "HIGH", + "sla_type": "response", + "due_at": (current_time - timedelta(minutes=30)).isoformat(), + "assigned_to_email": "agent@example.com", + "created_by_email": "cliente@example.com", + "tenant_id": "mock-tenant-1" + } + ] + + # Process violations + for violation in mock_violations: + task_logger.info( + "Processing SLA violation", + ticket_id=violation["ticket_id"], + sla_type=violation["sla_type"] + ) + + if violation["sla_type"] == "response": + results["response_violations"].append(violation) + + # Send notification to assigned agent + if violation["assigned_to_email"]: + send_templated_email_task.apply_async(kwargs={ + "to_email": violation["assigned_to_email"], + "template_name": "sla_response_violation", + "context": { + "ticket_number": violation["ticket_number"], + "subject": violation["subject"], + "priority": violation["priority"], + "due_at": violation["due_at"], + "ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}" + }, + "tenant_id": violation["tenant_id"], + "correlation_id": self.request.id + }) + results["notifications_sent"] += 1 + + elif violation["sla_type"] == "resolution": + results["resolution_violations"].append(violation) + + # Send escalation notification + send_templated_email_task.apply_async(kwargs={ + "to_email": "manager@example.com", # TODO: Get from tenant config + "template_name": "sla_resolution_violation", + "context": { + "ticket_number": violation["ticket_number"], + "subject": violation["subject"], + "priority": violation["priority"], + "assigned_to": violation["assigned_to_email"], + "ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}" + }, + "tenant_id": violation["tenant_id"], + "correlation_id": self.request.id + }) + results["notifications_sent"] += 1 + + # TODO: Check for SLA warnings (approaching deadline) + + task_logger.info( + "SLA violations check completed", + response_violations=len(results["response_violations"]), + resolution_violations=len(results["resolution_violations"]), + warnings=len(results["warnings"]), + notifications_sent=results["notifications_sent"] + ) + + return results + + except Exception as exc: + task_logger.error( + "SLA violations check failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=600, + soft_time_limit=540 +) +def calculate_sla_metrics(self, tenant_id: str, date_from: str, date_to: str) -> Dict[str, Any]: + """ + Calculate SLA metrics for a tenant and date range. + + Args: + tenant_id: Tenant ID + date_from: Start date (ISO format) + date_to: End date (ISO format) + + Returns: + Dict with SLA metrics + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id + ) + + task_logger.info( + "Starting SLA metrics calculation", + date_from=date_from, + date_to=date_to + ) + + try: + # TODO: Implement actual database queries + # For now, return mock metrics + + metrics = { + "tenant_id": tenant_id, + "date_from": date_from, + "date_to": date_to, + "calculated_at": datetime.utcnow().isoformat(), + "response_sla": { + "target_hours": 2, + "met_count": 45, + "total_count": 50, + "percentage": 90.0, + "avg_response_time_hours": 1.8 + }, + "resolution_sla": { + "target_hours": 24, + "met_count": 42, + "total_count": 48, + "percentage": 87.5, + "avg_resolution_time_hours": 22.5 + }, + "by_priority": { + "LOW": { + "response_sla_percentage": 95.0, + "resolution_sla_percentage": 90.0 + }, + "MEDIUM": { + "response_sla_percentage": 88.0, + "resolution_sla_percentage": 85.0 + }, + "HIGH": { + "response_sla_percentage": 92.0, + "resolution_sla_percentage": 88.0 + }, + "URGENT": { + "response_sla_percentage": 85.0, + "resolution_sla_percentage": 80.0 + } + }, + "trends": { + "response_sla_trend": "+2.5%", + "resolution_sla_trend": "-1.2%" + } + } + + task_logger.info( + "SLA metrics calculation completed", + response_sla_percentage=metrics["response_sla"]["percentage"], + resolution_sla_percentage=metrics["resolution_sla"]["percentage"] + ) + + return metrics + + except Exception as exc: + task_logger.error( + "SLA metrics calculation failed", + error=str(exc), + exc_info=True + ) + raise + + +@celery_app.task( + bind=True, + time_limit=300, + soft_time_limit=240 +) +def send_sla_warnings(self, tenant_id: Optional[str] = None) -> Dict[str, Any]: + """ + Send SLA warning notifications for tickets approaching deadline. + + Args: + tenant_id: Optional tenant ID to filter by + + Returns: + Dict with warning results + """ + task_logger = logger.bind( + task_id=self.request.id, + task_name=self.name, + tenant_id=tenant_id + ) + + task_logger.info("Starting SLA warnings check") + + try: + current_time = datetime.utcnow() + warning_threshold = settings.SLA_WARNING_THRESHOLD # 80% of SLA time + + # TODO: Query database for tickets approaching SLA deadlines + + # Mock warnings + warnings = [ + { + "ticket_id": "mock-ticket-2", + "ticket_number": "TKT-2024-000002", + "subject": "Consulta técnica", + "priority": "MEDIUM", + "sla_type": "response", + "due_at": (current_time + timedelta(minutes=30)).isoformat(), + "time_remaining_percent": 15.0, + "assigned_to_email": "agent@example.com", + "tenant_id": "mock-tenant-1" + } + ] + + notifications_sent = 0 + + for warning in warnings: + if warning["time_remaining_percent"] <= (1 - warning_threshold) * 100: + task_logger.info( + "Sending SLA warning", + ticket_id=warning["ticket_id"], + time_remaining_percent=warning["time_remaining_percent"] + ) + + send_templated_email_task.apply_async(kwargs={ + "to_email": warning["assigned_to_email"], + "template_name": "sla_warning", + "context": { + "ticket_number": warning["ticket_number"], + "subject": warning["subject"], + "priority": warning["priority"], + "sla_type": warning["sla_type"], + "due_at": warning["due_at"], + "time_remaining_percent": warning["time_remaining_percent"], + "ticket_url": f"https://admin.servicemanager.local/tickets/{warning['ticket_id']}" + }, + "tenant_id": warning["tenant_id"], + "correlation_id": self.request.id + }) + notifications_sent += 1 + + task_logger.info( + "SLA warnings check completed", + warnings_found=len(warnings), + notifications_sent=notifications_sent + ) + + return { + "warnings_found": len(warnings), + "notifications_sent": notifications_sent, + "warnings": warnings + } + + except Exception as exc: + task_logger.error( + "SLA warnings check failed", + error=str(exc), + exc_info=True + ) + raise + +================================================== +ARCHIVO: .\Zpracticante\Backups\respaldo_docker_v1.sql +================================================== +pg_dump: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: role "postgres" does not exist + + diff --git a/tests/test_tenant_middleware.py b/tests/test_tenant_middleware.py new file mode 100644 index 0000000..a960918 --- /dev/null +++ b/tests/test_tenant_middleware.py @@ -0,0 +1,54 @@ +import sys +import os +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.tenant import Tenant +from app.core.database import get_db + +# Add the project root to PYTHONPATH +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) + +from app.main import app + +@pytest.fixture(scope="function") +async def setup_test_data(): + async with get_db() as db: + # Insert a valid tenant + valid_tenant = Tenant(id="valid-tenant-id", slug="valid-tenant", status="active") + await db.add(valid_tenant) + await db.commit() + + # Insert an invalid tenant + invalid_tenant = Tenant(id="invalid-tenant-id", slug="invalid-tenant", status="inactive") + await db.add(invalid_tenant) + await db.commit() + + yield + + # Cleanup + await db.delete(valid_tenant) + await db.delete(invalid_tenant) + await db.commit() + +@pytest.mark.asyncio +async def test_request_without_tenant(setup_test_data): + async with AsyncClient(app=app, base_url="http://testserver") as client: + response = await client.get("/v1/categories/") + assert response.status_code == 400 + assert response.json()["detail"] == "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)" + +@pytest.mark.asyncio +async def test_request_with_invalid_tenant(setup_test_data): + async with AsyncClient(app=app, base_url="http://testserver") as client: + headers = {"X-Tenant-ID": "invalid-tenant-id"} + response = await client.get("/v1/categories/", headers=headers) + assert response.status_code == 404 + assert response.json()["detail"] == "Tenant not found or inactive" + +@pytest.mark.asyncio +async def test_request_with_valid_tenant(setup_test_data): + async with AsyncClient(app=app, base_url="http://testserver") as client: + headers = {"X-Tenant-ID": "valid-tenant-id"} + response = await client.get("/v1/categories/", headers=headers) + assert response.status_code == 200 \ No newline at end of file
+
+
+ + {#if showLogo} + + {/if} + + + {#if showNavigation && $auth.isAuthenticated} + + {/if} + + +
+ {#if $auth.isAuthenticated} +
+ + + {#if isMenuOpen} +
+
+
+ {$auth.user?.email} +
+ isMenuOpen = false} + > + Mi Perfil + + +
+
+ {/if} +
+ {:else} + + Iniciar Sesión + + {/if} +
+
+ + + {#if showNavigation && $auth.isAuthenticated} + + {/if} +
+