From 97ba58fa174c1c7066de64004aa5b25387f248cd Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Thu, 8 Aug 2024 13:32:10 +0300 Subject: [PATCH] feat: add user specific notification settings The settings can be customized for each origin and each processor individually. The default Web indicates notifications shown in the Backstage UI. By default, if there are no settings saved in the database, all notifications are enabled for all processors. The origins will populate by time for each user as they receive the first notification from that origin. Processors are shown as their own columns. Later, if it makes sense, allow users to also disable/enable notifications based on notification topic. Signed-off-by: Heikki Hellgren --- .changeset/slow-shrimps-call.md | 7 + .changeset/witty-bobcats-push.md | 5 + docs/notifications/index.md | 25 +++ docs/notifications/notificationSettings.png | Bin 0 -> 63498 bytes packages/app/src/App.tsx | 10 +- packages/backend/package.json | 1 + packages/backend/src/index.ts | 3 + .../migrations/20240808_settings.js | 34 ++++ .../DatabaseNotificationsStore.test.ts | 31 ++++ .../database/DatabaseNotificationsStore.ts | 159 ++++++++++++++-- .../src/database/NotificationsStore.ts | 14 ++ .../src/service/router.ts | 169 ++++++++++++++---- plugins/notifications-common/report.api.md | 18 ++ plugins/notifications-common/src/index.ts | 1 + plugins/notifications-common/src/types.ts | 13 ++ plugins/notifications-common/src/utils.ts | 34 ++++ plugins/notifications/report.api.md | 18 ++ .../notifications/src/api/NotificationsApi.ts | 7 + .../src/api/NotificationsClient.ts | 15 ++ .../UserNotificationSettingsCard.tsx | 63 +++++++ .../UserNotificationSettingsPanel.tsx | 140 +++++++++++++++ .../UserNotificationSettingsCard/index.ts | 16 ++ plugins/notifications/src/components/index.ts | 1 + .../sample-templates/all-templates.yaml | 1 + .../notifications-demo/template.yaml | 69 +++++++ yarn.lock | 3 +- 26 files changed, 802 insertions(+), 55 deletions(-) create mode 100644 .changeset/slow-shrimps-call.md create mode 100644 .changeset/witty-bobcats-push.md create mode 100644 docs/notifications/notificationSettings.png create mode 100644 plugins/notifications-backend/migrations/20240808_settings.js create mode 100644 plugins/notifications-common/src/utils.ts create mode 100644 plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx create mode 100644 plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx create mode 100644 plugins/notifications/src/components/UserNotificationSettingsCard/index.ts create mode 100644 plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml diff --git a/.changeset/slow-shrimps-call.md b/.changeset/slow-shrimps-call.md new file mode 100644 index 0000000000..7e04633307 --- /dev/null +++ b/.changeset/slow-shrimps-call.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-notifications-backend': patch +'@backstage/plugin-notifications-common': patch +'@backstage/plugin-notifications': patch +--- + +Add support for user specific notification settings diff --git a/.changeset/witty-bobcats-push.md b/.changeset/witty-bobcats-push.md new file mode 100644 index 0000000000..c2b357a860 --- /dev/null +++ b/.changeset/witty-bobcats-push.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add example template for notification sending diff --git a/docs/notifications/index.md b/docs/notifications/index.md index ddbb156dac..b44b04ef4e 100644 --- a/docs/notifications/index.md +++ b/docs/notifications/index.md @@ -350,6 +350,31 @@ export const myPlugin = createBackendPlugin({ }); ``` +### User-specific notification settings + +The notifications plugin provides a way for users to manage their notification settings. To enable this, you must +add the `UserNotificationSettingsCard` to your frontend. + +```tsx +// App.tsx example +}> + + + + + + + +``` + +![Notification Settings](notificationSettings.png) + +You can customize the origin names shown in the UI by passing an object where the keys are the origins and the values are the names you want to show in the UI. + +Each notification processor will receive its own column in the settings page, where the user can enable or disable notifications from that processor. + ### External Services When the emitter of a notification is a Backstage backend plugin, it is mandatory to use the integration via `@backstage/plugin-notifications-node` as described above. diff --git a/docs/notifications/notificationSettings.png b/docs/notifications/notificationSettings.png new file mode 100644 index 0000000000000000000000000000000000000000..cdad63d76485a0e267539401773cb71e93b6b868 GIT binary patch literal 63498 zcmZU)1z1#V*EUQFiUNuPQbQ<+boUG)f;5QI3=JdQ4bn(=cO%^$QbV_-FiJDz3?U5l z4fp-L@B1A8&p!4sd#+^mKCgA1=UQujQ&Ez6@`&OQ1_s8H53=vnFfecnF)%RC@E@Q{ zwu0i8(O|w*a?3Q>8U(zZNv9Zk(O_-Qk{9b3f;UF zq&gJQ&0#8Wu))DhU};QMWFIcG+^ane^znE$z?I7#4&_PZ)jAey8)=aeA6nN`%HGjMCv+fB<$V!IXz1TUCCz%TcW z-KX{MrMJ4gzi}$Y7E_=B&N)X5&R`7;$RmrDwnNVqXB~6#BZf&kAm#@XBN&Z)4iEZX ze>Tu@fLlJkAWEi!N5UZVWLk8^ofCi9T$v&hyHwqKHueF{ZB1!VGj2HTw|W0 z>o6ofN`3f%{`_d{U}|dPXkqL0aK_mjJ=8ozUDHWZQ9;<))|%bW#Ma1^-ObwWz6*w^ zn=ty)+SJLA*3H_=#!=W!jQ*b%!szS!Vh(!Rf0{U1iqUH-s?bW=I+)V(vvaa@(gPpS z($b1Ln3xHxy?g(k?&xn~^cGG|cETJSuCA`^t~~6v4(1$OLPA0uoZKAT+-&F;Y>w_W zPKIu5HjWJc4)Q0=zstI{Xb3JApbX$jpKiQ z7WxNr+}Ch$v2$|#@7UnBH32Ps=?^qfw>|9dq5>HOc7|LG{oasSi*>sI`GpZ_UE-)P_? zQI7w8XuwA@%<(Q57~&Wo-btvtVeWU}wZE2eX4JP`O&2&}WMK###CjDcj`faHmc05& zZKOSh0yiN}NPjoQvSnTP-|ep**U7kb`qlHk=9HxCU#*c?4;bQqyrPRECVv=+>?1QX z+bcUfTAgt&Yb|SSy;{A}|9QRY<*gLt2%jy5!{Mzz31C-i+u5S-YNbKAg=s2d;+iT1 z@Pb>Y_)?I<44))$gX_Wq`PxQjp-3r1G*>q?Sab5pQ~!jqQ()S)iitZNxwcu=t|V1* z)JhpWUl76N`xTPb%%(YKutna|Os3^LJaK75LLt#OK7 z2JYxV=wezY0bab9FN@7FS8YrDq8Yo03anO>z0pM>HQgvwu<&>2h_Pka7jI)@@?0%B zbJB&H_VV+CRTAF20+sWlM&6UB2gpIDb82Owmvy+P>h9_3yrq_Qsobgq%_W;G0e1ky zz+mD@U9;Ev_Ky;;%@gJ;I!?ysEYLbKdY;4x$z`3{*>FA|tUoz1&vbU;Jn8>_k&{U| zV{*b+IQyO0l=C9pM#}}xsevg~=v)kd*MOsW;=uvOA=Yn-_e0d4gyGpxfhxDn%C@56FB4wAeR>u+ z)ihfMH09%O@pkSV_K>(qnG9N0Mb`VD8XM`})}!EA<^6{5LU|IbiUqw%_E_p8=Ud%> zX*XX>PgH4Qjq}=KV)xZ!<56YP)aB$W3jhGrPKQ0BaM9lPS!9kh^8%+*9)=#)-qyht zi;-Vy1TJj~w$!#(uoVB6xL1MaK;u|tmX>Drs0DbM5A86)toyKjPHkORAq2zQB1Ug{Y~kQm24@fO8t zo8@^g!z{2BNp&KTq>J-+YU7Gnq}6yMpXw099QZ*Bbn$Scurt$W1A}I*X&;aYUu>)< zh86}iCDI`&9ph~L%wz;z{g*Xoh)RXa_VR~dMS$6wfc?Rme0a5f#<-y%f!%e^yXl#C z@E-e6r-5CaZW?7YCZ%XAdaf{c$09WCe!x0cyJbDB~Ypgx3efxG1&RF-IJv~ zF`I2WNa>Q1w=-)~r0*vexe$JSoH3mgfyB)`Wc#8=(pjbmEK>mbGZOIKVZ>8D-?PHD zd@pF10cGaX&SkB{=}Q{w^Cq?Hty!cIWA1P0L49XFy5aqEzT%MZhL=?>BIGc7k_^rf9sBkg&G&v1$tv@D3d z7gvY-B~|OiW9bUVKez`G)+94KjE(ne`kA5ng9A4aCu9Ci;QH)u2U?A3-+# zp^eys)YgaTX;dg~6MM{~I??HO&Cz>ARl15H_~J=?iW^G(#06R8B}ENyY?lD$afyEQ zr{KC79RaFq8Kh@!1I>W))K1KH?ojfe@Z%_U=<5j=0~ZWhZ1`IU68kC0c0dZdhj(1D z;bvYY9)>rqzVC{*j}bF<7|NIkmHK%mm>s0QD9FHi-|AL;n~| zZ@R;-6b+*qi%&;+z$kD6JSs@97vJT^i}%7BYoqC*L)Oc97#}Qj1a$d}DzVFpCh%6( zFF5&p4P($>d?aUA&f9*jetg26m^ha(_w_ijnee2V+#6~w9R{MihI1m-2~|T(vPN&C z%^8|!*nLrPvX=~IgVH*8#J3jXoXJj$)4E-DbbV<&MYwVT319rW>YEEbpL#t8HrX5H zAIq>YHG=9JKSJb7HA3A}-$#j@pS+owHPq=a1)jr%g9wy5Ja65)h?H1_}dziN5yPBjw4zIOY37vJQrrAc5B{W)-N7*V0_%q8AwU%PkJ{V{kW!Cid#7mu_}CR^HCoRukQNZKp6 zH4-#j-)@$2OS6~X8^@|*T>piSbsAy5^~ds56~THlZbjCVYD;f&TU#3sbeN^h7nooB z%4LQJ?Ygif-otab)Y74FN1?{7Xe2Y+pwT`4M~@eI{+rE^9Ocy*FHzncAG(D^ePeg& zZ^o@!L|K?yl1{+#mqx{cYaiNToFzQ^coujsyJIAV#+8?XYWF)U)4c@xlvQmDjH@BY zWrKL1Hy?)svXKh5$L?DKF+PEAAL3yTB>{gp_XWz^q8Y*TCn_A%&+OUBT&}3+1njS0 ziG`REES#fl6OrF++JUn0`eW^{0*}9M-eXmpfR)W=W_?Gg!E;-wp~&?i zlHuFT#cR2WymVHFiQ&q2+K!ErBo4pj+*} z?o|Bh^O*^A3?d7n%RunD889FBl#|a zRO=hnu!iC)ave^l?q&yd4yCp%2qbz3l@um_SuT&?SY_*aYjp*VSzW5J5((KT^RUw- zEQmXpQG&hmgitR`^fb^ETkUDAKZ941(%kiIM;(cuK|CW9!(BQa!K>{AXGMdAow-uNg zj(-?J%d*m&*@)^7?*5^W9@8}UrV?BGD>jxi$^sc84)5`sk%`Z?|3aE0qJoJAB0zRg z@vyeNUXP>EO2bpbCA}X~r=tQm;j)Cjt^uC-3sxFOH!?&2s=T`iZoUhIGt5k#18-WR znnp&P3^A8{-*~DJP9-S=YmVH8Ut%HbYZ;)?Y1zvhd*A~@!Z6VIU2cHob<|Ykc^o+! zvQdQI56l-UHifd1HO=r_v$my>k<-nO#~&b8jtA%6)Bw4(P(DZqG0^qVM*!>Np#y7R znwNos-R?a99qna3@!`g+#>A2H(4nLKm_}cG-|Cs{zLzHt_mujT?79Y=zy+KfZKv`a zR?Wg*rzOHZYt1cpSB}a9)uP|xORuRG0XL za^MW3T20&nGr;{x4fHBG8~kF=t}$E*`Sr?SkTkSW{5;(ve)DnvT7UZjW?S(X`CFuW%EZ(q&Z1v1NOpSL^vKy`UDH-KTToCe>@-Gp(FyrvPa|$Gg2z7vI{&73G8I zqV@*?{EzECDJ8OT+LBhgDjS!xx+u4~E*!gy8!2Q80p%lxtC+CPPq z%@-Az3>12&NjqRqA2#+upbEEk#A>ziN$JaRpy+2GV%DRata@^qw|@_#L}WZ_6GjnQ zv?jpcBk7lmSl(I&>9;Xfql+`le^pr}>z^=Bqc2?R|y+>B!6B_qS zdI7Zrp3Airj+PzM@suQm&Tpl?LjpSSw*GKr?+?j!)hEIQ?{vvFcX+-lKksc;Y~qzr z9j&7u@paiC%I_2;-=do;{RU7|VEZD8BT`MY|xm zzQU7LvFkMV{90}GmD=ZA<*Q^$*5W6%U~UeyAj(o_MIoA>PwvP(F8CI{dVpycsQkKX|3jAK>LN6yz;62j41wlQQmu&!|~;>wy1)-xPqI~4)2ijIj*|O^nqEUNAl}y@)ddrU{=EXdoZ{~50UR82YqCE z7coY5q+auEV0RfV$Zoj6vbrCfxTH_emJ{5pXpwVLlGY;t?8l`|&x<@U?VDlSr^>HrT#Kw;os1*x4bboWYm@ypNg zvoSPtJl4c$D@No!5-CY0Ei+g4tW~I9W_UoJ6J5IGNQ|2@4nrHmqKZRAS zWxL53H4HPi)LhI1yGALm6j)B8EJ<8`>aW+M7%yeQou6n+@jnFxlM8pr?Gk;kewAn@ zjm1T0$(-8}qEIpJ*J%G5jq*?WWbLol-7JP^KASHK%C1l{d?pumZAc+6V;D7Y@kkD@ zD6r>$HWqr%{&3Su{dWXnq8(nMk>Wha-P*U{7inuFSxq38e>QW#elmN>a+4tIjW!w1 zp;sUEyyAhP`5C*fp4?ct!qdX01*C@ayvVpg57hepK0PL`M$>y=q?39DTsjVd6Gf76 zkUy0DKusWusenfbhD8M^`4-LSi(kM_<%&kF7{S7N8JZXn^yRUV>XW)0aEDKCQGF4+ z4F-yjWxeR->p8UdTHp+^@kS?zD>PpvAR*&!L`*XXwNZFY&3V)@|dE9Lp* z%TT(@lz~s+BocpJ5c5(?iNGGfpAj6sEmbP8_bjmVHdiO<#KawDd4BR-JHlD@r&Ngi6Yss0@7b9EYVWKbR#bV0#aNzC=XvI4qkwIaz-8qw z`v4_)+3a?!{wtLwzxfl#3RE=)<4_GINT?s{YzftR89oLg+MN*GRBtHo{hlq=*v-qv zX+CodN&E7pmTVLu<6*DOp~A68=FVMDt}nB=95FVU!3~g&qx2QY9X2zb2o3F!<#dws zHvXBgOQK*J_F>BNi+dp^ebq;&%Kc{%3F&d7MS!(_l^Q|sSt>AP{QMkO|7VE1wW87P zc?Z4X5l&0Zr{}vLok;sneY7%|BPB?Ao>6+QrA8spR84y}D8)^H)Rs{x!S;~Vsok&^ zcD=y+$tK*6=`zHXTsp+&{jiD0v=n`MDmPlZKw96oVvpdmVeiuG*>4G4=eYF(-#gXz zfTm-SS~L){yqgQ6BXtFHBJrgD(}Iw9ZJ-#JKQSJcCN*NOkfXM3H(J4Cw(`*vX~0tS zssECOIs9heo*sITii3lEd1A5{)yU38?l!pdCZ3p*+RO$&F=+ z)cx$)(l_xgIF9Fcj3pb8E25GERI*TWG_A>~OG*;p`N_c$v|H;BS=OKVsy(;1_S76_ zHDFzDol*&1#O>&4LJ;zn4iGK3(Xq*oX$>)Xh$ns}G0e#n(Wf^fV0_D2UEiIUMy=S=k)yZ7E)`ruOGmKGY#El3lfph6l(#|LMCVhV?t2h&>gqR zHI}^lHeuY9Wp%-NQ^9PG6an;X!3Z4i;1_D_EV=@R148*?1@Vfs|yC zD@jh(3e|J+oA;^11S9PYV?FJS?r6mD7iFY2TIc@*H&@6Q0N4j_hl_5A|0PBS%TbhNo_ zUE%^_+7m z?1YZHwL%gxW}tHE4O zr0ZEdaqJ5|uSg>2X+f+5aR0&bgzZW1i-Q`hyT+V}#kf!d4)x7t? z-BU`M83R%;NK0z+J?467Zn&V{9;(9sY!*T_-Be;Kzk5qBXxEgyH=dFfF9#L_)hQJV?ulHStNO!L@&?}dZs?79E_-lFza`p35lYz z@mA0dt9bTHF8yt1=X58;qbYd>XkH`Bw#RuiAMWZohz))aF!^Oc?j#e*$p6`7smj*ZNY+42FO5XX1rq+DZ!8#Xv&>8!;1IA8#bg}e!j z;aaA<`r&mN0JM3&b4Q&V4#uk5OSXs3&5cr#6U+#vXZxMJPJaMm{4F%>bNgsW`SO_* zk}GmVH-g|tqld=QH~$5nxGg>v=!nM}+4754sylnw+c;%5NzS8hJVt^#H z!3J@izzs`0dk*KQOOE-ESL(#6V45G`VIS$2 zxx6!&#{T=tqajCbzctA4-G+`HO)FKp0hEUWw8Wvfi#^BJ%~(rtMe5it#TaeqMlz~# zmx!y@jL!N%X;*V*#Lu4e?Ri^qq9C%f&b7gDG(v)bj)Rq`Y5o1X=ib>{(4FFP_myB)pRHrL9M8_&Pv z4NvNj_e=G}6m!&dtcJ$pFR%VWi{!BATbHK_!^*KNBi}ldTJDNxoBAL9D*3|P7^XM! z^5IW1W(QTJFFvL6qt0Q!N*7svaDBY4Rbm}a2RfaW{B7TTB~nZ-?7+2jp2^#>%(4pH zK1Ib@w*hwxpJOkEH86)C2}#BvX=T`%(asX@g)J-kEQkri*$WuDxpJ}w=y0H# zemXB#fVXU2-OHt&wPBL{>`d6{5ed`Tczj;=pKG9(#a4=b3NR1!{OReX?)qSqBXnro=^lCEOXoGQkzGnfjJRCXHv^dQ!%73h| z3w*0jU5SdB>{K9BuPe{j6LIQpdE{8cK{J^yx&I`;w0-h|ctvuAf__Dm&)cG_=Vg&* z%BtYuZOg|@J$r^xIvv^ptVL6!^2C{GteI9B)mv72ng}i*N2W7-Mb{UfPZukT_m^Rx z>{~Ma;mv-p2=-^gXGWHr_PW+^^(+jTPi7OYl;VB`mO6)xn|%_Ut)?!Lul+w0}07xpge{k+2vQ=Kz;nRD=sKUdU zV$s25=N82D%3)gvU&)01Wpn9}v>iaPlfaPk*t!4|r`&p6)l+_~(Z?`38f-VYQB2#1 zAlf`$Dp8&Oy}sXRM7BD#>bfei+WuhS$-w|R+=6ytd*a{amPZ_*8DT)n2eiwE0IK>Q zig-!z8ohG-7HS$we3zmSlmPtRLs~D+$Hqj!tOfqx<|oC6uqvp?Y87`+(jv_wQ7u5B z3P3e)y!8E63mC;K83Xkgw695VHSVOQ+3*$;ofmxlPqc+R9t^jHO#}?b`U~Z5;0Rzu zc8ql}dSwP3NIssfQ^46XI*lby1kWcse6v0wic==oZS5=*~0{O*&`wo zn(qq0(2a7EXb&XAcRSj7j!~0a@|N}b1JRFBI_tG*RVKhjOUFw4N2P2xR#eMguQvsX zSFf2#X-?`ugz1bS;fKz)X9-ZB=+J|H0MY@e$5rS*+k2gXJ z3Ap?k{xe$a58hjP-^;)HLf=1fz5A4Bo_4%QN^MH&uiT|IX)TGTB+f{07Eec()HX>h z@S{?4zSX%fxr|DqZoPe)0Yxyd;GHbhfoo<`QmVlX9nZjRrLr}vbkNFOWhEAA;%X}y zVF21~$RO0}wZvHMvVDFhYy}EQH;SW=?&oaP1)v9(`h$r8fq=I)>jmnd&D9Kg4LGr9 zCO45{X_Z!a#HU7uY@@Q-;)Yj?%mX$M78ChtcR7dnYlc6mq6OcM6$=Aoh*)wS)47|X z;Le^C3J~y4%sql34rPt$zG##C^n%gPP29J6b3Xg;30&W`Rlb7HWtVzZxplt={w*4% zboTeivkBE9aP#wxm{Ux6bip@kR~zu{u}taAsTAf&%lYzrnXC5(SjL=O0&{MC?c^x` zu)@{V@_}fdsN40T4oq}9maOd*`sL7WOY7AWb~aM*Ebi02lmvkPi(Klb)rtzg({qR; znCFv8Q4&&v#k$n%T(YM@ z0%9hlxh-6!J1d#CsoxC~V)6SUS z{-C81btB7o$S%BAVU)Gx?eOIhGtuR9-NyGT)nf=UaB`7d7X!mD>GtV98!{Iya zRj}#P%T{B8NswlFG$ZH*9rT=Xm=Sg_Y+YEp?K3E151+Zi5Ok(ItLQSWQ7ntzHP$(! zsjcQ)>Cv=X%{7mfc#89ZwKE7FVc?6DkEZ|VuhCFkbiAC$IQcB&1!%@KQ^zf6!0$>4e!Sf&=)Rmm*ZnRJHkIRt$0wTf;B(FJ{dZ_ha5KMSI`Y!P9K@%O*B- z)Xg(pT3a;%2Y%n$A1mJ-9(|hEb*=E2fC67FX6lZh)CpL1A0o<`YSPA)E8ulCw(Vz0 za{dd^YZqr_&n@xcOi%d-y3t;w7Hglk)~ljP)TtgSq!4-={L0f%06p(w5_T9tDWJW8 z!BtLW7K3k#!o75`BuZ#%f!(!entlH!U>zD|Pa)S!+r5&jy-BQBHb(G`*x- zpv|}DMZI-Vv;}K_lsO9_Him{bIxKGmunGN}ZU0AG_2es`Bw~kypJM0T;c(yiA zT9c(0DOZaif?haVxqmxi3MAU;+=q@jCA|@Q z^XeC#Xa1ujK5+Bdn~64!pt}-0U`ev{ZWwWR%$Z*=EpbJG5^@D_6Vn4z`B7Nq6^}1w zb@O`cT909G!l5)|cQ8q>5I8EpwjpkO%9i-1-Jm>HG1?f_6?b01yEO5B07^e%2%Hul zCX)VDqu_&=hID7BiR~Fe3-~7KBqG+E>2PaPoGl)Z^R$RcRApsaSGQFFQ!jSjV>D9) zs?ZaWgS}R|)yB^!9c+yYo z>OHF$ZH;Ee94K_DOnBcX{tL%asztKQKcUGX&3fO)-u*?)F03E0+S}gi z7v{>)2aPPqhSaJd1n_;M*b32@ytm(0&5tx9fuxU*fJ263p9}o9WDstEi2nCCq7QS} zv!aayW~&ZwcUUyGWGJRPPx%46bOOQvf;Aqa4|R*=)(oyjlr6$>4gsoFnOx+aQ-Hz8 zxonxK4TQV%&JbODbv$xYqU*Wcq{!o~vEwl_Bs^ooujoU~t(;75tTC8)LwL&$flC8-pGV9o!xl}l z_|FRfDz<1rf-mLblZ=WyohAt#9><;T6mjm&q}((@7(EQ1LCHotcjU8&_^@@K7e7Zg zCYIIPG5=T2d_7S9_RmUnuDc0A>l@F5sSwR)2o`&cY*IfCu6|BPHQkycgbl5q~(2eyOqZk<*=0L zvBkSlOEddJCY~tbk)~BnPhTh&eE)^o71{$#@DIau1dBo+hL?xc=O1)8*?keYavHgIBdFI;NHXX9$hsOvo8$NN6%Eis-5VtjR{7hq>@Hn!3)r5_a36-i zQR%k4qMd&9S;Z3pKF1wL#p7-3nX_W!9q~(5uDQS5_Qv4fEh|4CT}-}!=WF-*E?>U$ahpCSVr?Od(g&@cemCxx=Eu$5^( zVG$gHJI*P$n&V{P)T`X>e===Vn`nehG`B5YD!cXNo7AK3VAld$bQ^|$J->P!GcKc@-i<4e?%C?5x^LN0{-b2@1;q`=%KbbwlSHuKX zUCLT-i_&MWK$oiVMBBg5pQ?~ub1jH;S*JmaV{VDVW`lN_3B2?L=tjsXph>K+;u~W< zVwF8C3aFWr>mwtYVs{787KJI7Mnl1elDQon6-j&8A9@V_J=(b`<~gRE5mk;fSGqkF zD(b*pxMfA-0QNx0CXn9Q(BnTk<-8c}H}uhYcWHH}Szt3NBfNf0+AT^7(y@MY;Y@dX z@s#VtzQ)0i0+c*~R*vYyAc?KJAHu4zKDt}jj~Is>_VE(ToVPa@sh!_A*4wFU8H$Ei zL$bIcW#oPlU0S>h)3qf}v}Nx0SVU~3qXt+Nmcwd(i<{h7KOO~E>b;}PwPNNl)6(8D z7Lpcup`2IgR)p;!_H`kllO1T**|%+CWRdBOR87t?xRfc;O=7#@K6OuU_j_JN`e=xu zRbeAv%l1^JnG}+d@HJ8skBBUMM(s0Q6&%mSi$qbVJLTN8#R_Z?QBkOy@bf*t^Ppg3 zcaJIP#&{`dvqyfYY1bZ!_=&dv#U7gzchf1kRh3hAK^6}vTzR_E|ps*~~372TWn(ehqjhH)#oBQF1{cHPu*RQ7>y^R`QiMb96?cVQWK=i>E-e^WDB4_R!R`#6FW4!~U~>ew7qQQ7gw!|Y+tibvcX?6#Mq#B&uf9=Y3f7aRS# z3AC6IZ3;_ zP;)M>vsIqMv6*jU2P03jRbcIi;BSI(+;;V&sM~(?Ta&`;Z;xMNgJbThCT@hz%Bs8@ z7GuYqSfich2a$fKQUDh=kpfXTv_9^9_XF^xAWttVyhy)Uf@^0y{U1MyRznF6QFDKFX&37FG2 z?|r{$;kUz~#sC(HgYl{HkDQ?+6r-d&OIQn-fuIL<7=Blh?s$%L*PoEDBaMN_O?lC% z?f`n9!!YSDRA-nb)kS+KU|$Lr&&oJWJ!NcROd~pdN=Lt_>^Ad&OV3jr^m3U0|VDW1+#q?O`! zNGrdDElQ>+@-$W8`MM57Em}ou7B;YOtuWyrD?n#c-0$5YJG5-cnRM`>g=^uv_YO!! z<4VRV=B8TDm-Uq#pO^4&+_{&iqvI)K^giDE#o2l+d3K~&EH)>yL*Xi^5UNwA3xXJ$ z+Xgwj>&NO3Av84DRV|&JfQv zzDMsdytP3qhA;-Y4yW>MQA7w6X#wamXrhdz3uWqkB2sx+7JcW|Ej9rd@`_nay>4d` z)9P5`xE(wOUbiTx^H_;{!CZBVl$L!B63$yGyDC<1e-M$o$9SGO!&H2)m3__E0A4@5 zXK`IyuHLXYq2imaZ$bxDDS>zovh6EzU$e`CE28hZxhNO30z3@7FIEE1W4>vomViMl(mJ={tq^N7K$@m?nHj3%TWAc0#-kkYS zJ=m|U8etkS%wku8t7y<{@;rf{B@h|;_iVP2!sIHwgZ6^d)uN%~PFE@Ui+lewU? zA?Rdkqm3A8guU5PeIc|Fx5&4$Wky{#QeI?!^%3+RiWObJbD!;K#ZF)+YFHsF&kEeA zi~k@jBl^bZHVEWM+i;s5K4Vs+ovrx98yz0{Crh^o_jrW(x|USN(_rc$!L05M{lE?r zRLisTh2+G_%Nbt60mqx`d^{Hi|LSplI$UvzY#GVvWk`XSNM%&g^DitD7uYxc<_>430or8@?_wTR)K0Vi$cPL7Nf43;Wf9y1Q;^?bT}fNS5VVMc=XR z%W7w&nnX~inR0v5bQ7Re@ZLcXqqpP8!}TmM1Qb?aKMT8FhY6T7MyH_Gn4`Vhfk*CM z{HN|qT3&_aRGpM?}Xsf^WC5=fX}FF^qM`w+WXxw$MvD_ zM@<#8=9^vf?;7pDU&G5CxKh2oT~sf196D(Xsoh=p{r&EXey`{W(e{m(*791JNAB$> z4wQ1f)8CdocNFVU2b}+=M(#n@4^uBm9_zGg^*~WnNaQT8tWfjTSNh9{rl*kL$G}Ve z?9-Um#o7YGb{omI$J3+eSBi@BM(#!Jzf$w-b5 z4WA7X&-h?XT4KL1qhCN%@}f57KC>hl?GEu)pU`R%j8r7W*Tk2?&lIuGNPOdJ7iatE z=iOqxs4)ekk2`p1`dxH3Q_hK|Z~V*6#543n-_*>DDx^zOG|5WG@)@*zDbtZ$`oNID z#-}G(g}PVnaz%AjroflxC)R1^^708DJYplKQ~cSx>D5M6MSb*H$WZ$4w`wR{wJ+Lp zO~8`{@NV*^>XhQ*dxToM)^GMFw3o70Gy5U$EYu1p89MuG^2kaMVy#jh_DfMr)EeNA z5kKV#^at;(07en}F-XTW4}#xc^NlvF#rLBc@8-_Zr@*M}JH4Af=`a)E!Y7XJq29m6 zI8^R#aJx!;F10{>6r(9ei^+H5L4U`Iz+5%7k@dE2^EyBI=JiIbP?&}v#CE5lDN&E` z)?jb^oF$m*rsPN!YN^1%%DY6OMiObe)iW0?9`n3FI}1FoKw<>>FTnSSn1R$F?&}}0 zE>&MDgmCZLli!r6(B~Ny2ajX}KndXj3*4ex5=4AnMVLP4aE>xCszW2{_IAb+YHkUm^bXuVLffv|hz)1L-ldD>52k5PLcX@u zuPjtNac=DX+<8nQ3b*{OW7I~e@``TJu z0PSD-0$jf>|7vnZ3$6cDXS^db!336|p1D%nCMQGKAHHDYOb~k$GI-8pZ45&+wrk}@ z?~$3H1J(31r~#CPk8JwIBp;2#uaiwo@G6t+$d!cND!q5C|I8QKyEJ6l_2%Q%Ie@(` zzKPHaZ33?P=T?tqA1$zW`SYJOKpsuXdd6(#58)xB-Qh9H0P(9TcEI|Zk@o{v#Jf-T zy*5HN95?@NGJxmiH#iu&w=L8|CZiFIpyl*UH;Xxz#?AqE0*N4;1dcno7GR8zRT%U3 z+l?z^>;zmkc7rRj?*KJ#P`tlVtOT+PTPoe=m^x!4h(8Fly#1h6X$dj!TSn@+b60@~ zp4aUKA~nwZHhpjB1j>B9?mTB*ffuzGQc?};b0fDN)dWWY~aA3>sbu3v(}YlYWko#&sNTM~C? zJFb2m>t0&{Sq-8?fUOgV;H0$+^rJeWM8pw0GPNv>;08C!W~QPW z!UE-&NnhN$BzO{4JH~FS8Rv!ql&#(}LFaY44~bmhmFovc?lGo_9;DP_fJ3O2>oos4 zwe3hcA4S*df}o2!+g`E>9YCrcDzQ}c(EH407h7^^^>_kGj;mRiL9EuXL zgW&J!L6!(nkvJ$)?!m!&SkqCJ!pD#uZ=`!6dj$(2sygfN$q3%PK@sVGVYVu{2X6;hQRE ze39OR-wqyLimn;H-2IVFzl_L?cXS9>`F|w0r5@RB9lS-rS`qJ$CN2>?ca~>}SC!I` zo#3dy=BUjd6QIr1L~bsvZhzn{v7P=I6nLj~#&LIUUl&hF#6JoSX_lDlm+EAp!RFUj zS{U@}p|P-PRZA7e@(6q|Oz>lpyG>!ct}I-+#R_k_X7IY60g@!O6eNm08dP~&>Crr` z%0CnTfZp4};FkC?8xLcq{Kx&!UJl03Wb>zl37@7lu9GA%wVd!&YeO(fj{XBe36+e8L()w(r{eMjj?}4 zd=3Xe`R>ZJ8?e8QE$Z~Q+fSE$2t}1Q;wO(ycp-+^JNP!XmFBlza-AWR169-zSe27L zzh3GczoSfbwrf7pGQdH9_5ng(7YJw^iuP!FSZ7eh@6mKlv@(4vLs=R!h8KB8aZXmb z9AgunfY(=7sg*G&GrY8|$Usvee}_X&vD9d5aVeySR#xHDcD#X%?Ag z{qCc+B}FGj2l*n$h3l=f6W&R?oYf5NT1RG3oIi`npbwe3zHk3La?3p)#M8C9%{mKY z`i^Et^SjXsKK-vug}4SOaqw`SzdA|Be7|ST;9O_R!9V7tmv5!2FJL$?9GJv(@J$q$ zw#k*{#JY5e7Q*k`ACQ|Yq4P!xVYJpHk-Yc{8Y+M8KJq&pw?6EZKVp16AVBrWN&U`f zEd~9uD4mW0i{{sx7-zIRg3=FfHb1?0?cD3>kUEUz?E7p&=TKXplqW>23Jlvr{(L)y zZ0NPlF$GMfKqmmlkvL9~xC@-+9Ha!95?H+;I5rL?H+R|ThqSZyHk-geQu{v;+U5d& zP9#Vx9oh}#6p&QPDFL!^G2%?}66R*jvXGK1t5PG=Cs}cmQ5N~)%@iG3x!PSAFj!Bv z_7d4rZNcO!ERwAVMJ0^_;^!O5c6%ts?ld;|nX(mBmjo&k2TsV>vv-J0L+I6rA@rnq zPpz7)q^@)cx@3D1iGsEiFcFdR`R6Z<4n&3prrTDUdmXsXE&X0Uj)C5P&wS>bduwH2`NeAUap>tUSMQCx z$I+fk`eGZGp1%k-z|A|x63>rP!GrHwj>ov(4PET$t52bHrwf@(GU2x(oR|`Ss_1&F z^o3tWyZ=p?M=4!D9pGI(_1%}zsEZTxh%Ka%WwpqoxvRSa8XY_eh8#Pg*1l=vbBzh0 ztl+?n`ON4~5VKhP@ybz*vG$>4Naf)NDa@UJB?)XPSO9~&X}-Po$^55Tnr?dCRx-E` zKi5wX{MnMOO6j`q=kK1ElT^&rfiqBTqp)VmYSKzjnlj7rAz>R8Q~Pf(0KpsxY{Id? zA!YU-CaI#v#n9W6O!_C-h{^~Ud=70E*fs5Nb{3T0VvEawx-HUUpWs3)?hYlEv&$!s z8UQwem&Z1yu1%4pnbgSsdS5F;=@*lf`oApr%6~U)rbB-{n2(FI8X}k%`l*xDLaVOQ z;}u8e=hd9B@iD_;rZb^fZ_Ki2yn&WNw$0}wE^3NeE=m~_PR`+R!oW@-0lIfCHh(lX1hQEI!^l@V$}j`a#c+xTJq?G{#u6|$Q&82 ziFB)-SqT4`I)YzJ_t2czx4`@%l67Z~9^;={;4NxCUYcWa8UhOoKcKwi@wDB z{^{(Dl+<*|{ovW=(9#Jhvt9=Eqt|4D(aW>~cd2RDTCZmYh&^)j3yGJW`k7#|%HHLk z6)MTKhISQ)r{0d+QF4|(shmwa02JWF4Ax?W)aN-il?WSJ6dOEv`XH$JD?;@1q7#WW z9H+V^I?SkzF+NSgNN>L=nXe zkV5KPa8aFWBk%nnrJ*pa({j6D&DxAGVofDzA>HNvzm#XkTJ|@>vzQ&_*{)`UjeEtL z5?%~YE+nTC<7}gCQufMc8 z8k>pUcN`Y(PyKQn6ttp*)ricGq*~nzf~kM}s8i%sNlN_tELvZ;l~XeAj(a`r!+-$! zVWMb!VkDCM0YtIVlV!vWLrA6$tXD1^ZGyyWY zgLxIPDyMC$O>}uu2Cs1L@4e@P_HCC;8{Lb^c-n!u)vkm=apw72BV1>tR7h|wHmyzTYcaFHJjOTL zQ1n+ATwS@#+V{8h?Z){(+)kRAT_aA@nO*C>j~Xf&u4`s##wrWxfAtWHpQlm-HU+cQ zq=yIL7IKb^tlV!H#{wQB0ZL^hMTP>9F1VNLm7#wJ{x%qC+{=BkhMx$2)#}9rY@RT~ zz2XHkb@s@kRvbQwITv(K_a*A%Yc6?eMg!&vBmY?Z6&8oid`K2&!v@p0-`-p1dO3RN zWQ_tY7|enDA@MZylvzapPSCBW;eFi4jF!wekMkHcjba#Cy6%?wC2+M=Q>aa%%(!#V zH{=oN(eDoumcJ0#gXMcnY+V|76cE((RVOB^GkYAe<73FGgGt{;IX zxWo?gr7iZyuJ=3-R8j<#c^0UT6awv*9AbsH#>ZqVN*c-p>)Uh2uAYn;-+F4pGam6g z`+-PvDP6P^g-emNx_#})W3r@l0F2+GWeW!KYC01r<`uGTt^&Da zudi`3j3^dhU5Y*-?yUBBxk>Hh>4G9<+N2Hy>cywUeXp@vEU0EOhwY0PC3R}_e`Ook zf3yp_kFl-6v3i)J`zp`?$Akh7q! zaFa8;)w|BVC~&7-Xzq4=9-~q>I4@;XHKE>#%_Dg2yNjQ4PN6=jNDuB)&n}IShiCB0=*+up zlwzjeXQ%>bs*+cuB;%3V)2Df?cGg;)xxLX&H96Y5Wrt8&iwe{8))`pIXEuY1!y-dC1L-@hb zMdsYeA6M~~i71Nf-UJykTNEE;v-oK1O}Hx)hWR2lf*!6TJ7Y~SJUtLZ+D$L!;^dTV zR)6DDF&A}18Usp~Cs+c}6yUXXt^e+V&v9IpyPn!QhHG_;vY0f3%TL(6(R5gSTebF9uw8;YUid{~tski>FJd#-0ZA2Uz!*f#D> zZxz-#u3$}Ktwe4u;@deJDCDt)zA)re^;X73@(rwuo509@UPodCR76K(A=VG=omAGi z1{57u*GwKWay5*Qobl03mVHhopvt%ZC_!>)=bI^Ip~2UZH^g@}G1VmBv;u8>^Y{6E z8qP`3aJ#;`+j+*NF9uW7EF|x;(M^8r>4VkS)JVt9lkd$}*yej<-MJo(6ccafT>%+x z*h{)c4JUi zw>>zdi19ZymWJ`VROV;!!rtx8F@21ma1nL+Zk}sUhU;$HBb)soZQBnSwVS=*2Nc zZA<;L|2$NRX~b^PU*K!E2tHM_0C8-tK< zvfr0(oxf!DuLETS8^m8;XGem`Z6i+& z1s+HOpk<4W`n!V%Gw|Y;*N?`wKpE!kaJ9j8b6 z$Toa;;lM_nximR?sH&Lw>;`9iaDTi19Xqw>9gNYp^JoHN7(kdWm}VJ0GZI-zy)78^V=36v-l5B3byjP?6v0Te z+W_6QkJl^=BY3@Ax-j>W`(_W)x2QC=R zWQPlg`s{lMh6ZO%D&%xTw*4x~3ind@ljNl8N8bEt}TXielQy`LB#^ZD9>${L(?y9ggw*c?Z?1-lW=i&@#cbaqE+uIIj zj#5!wFU?Kv$0CGYy?D#?l^pN0XzZhyg-=${s8=lmtPy8FV4?b3hiQ)Hp zHwxDCp0Kv=Nrs0DAB&HO1#O=WI0F>3%H6M{`Xi(?*3CIdEV#MJN+|mr0%gnPK?~8t zwO1KbuPP**j;e2qI4xcGL~Vj2xYSj=`%eqz()b}^)0`9y_ScvNJB<>bb!|$jJXAM- zGxE-!I_P}fd>km-?=>MXkgk#Ka>#M#ukq^T{A^DE zIW1BzLv)Pi)Q+?5n10s2Nq*W=;J`sN+9nMViBX9|R@YVc-Nveb&de(+F6O~@`MZTs z(GU{1iK*fb8P}T=W-?D#i#9UA-Jc6Y2^sC;huiG4uZLQ$8#e7p-D_=2WD1FQ9bA*V zoFc%D2M)K^oqP_v=%l@LGE>J58t5$O)R8%>`6@r|0dP)eV_f5jvfU_uq?uZE*48I} zH;&%B6W^np!V}!_o8w`TQ%ZuRq0IL5zf;I)_8;60{vLCQXsN-k)r((3Eed~8m*59rwE9TU!Q%9>( zDcDnVyFqxAp63& zfNrw}Av^afh>=~|cB)g0`!>!yH6AbCMl+NJ1fzfP9oN|Me>Q9xKRYQZN;DR} z()GFA3rex$Wa&oU8uXnHc8m3MymMN#t+s`sNBLt?HzeVqf%f1sDr6>jd!b4uf5|l6 zg=Dj$@b9X02@k{8a_ssFSminACh6|>q;ve=5Is@&N;!MumB1C$*%K=+8FYQ#)$;D9 zgC$4X(JFoMr?_Tp#|h(mXU!{}&aOc!tEu>^t3~BExc%gF3|Y}TMY4XCcz{-o2Q$Z! z^ufi5-_W&P10n9K3fuB#YC=r+2k_+|yo;UTT#~VZ3|+cBS#gFy@2jz_+P44Wobu9Rr8XOIiHJWu&73WLN{9lUY_x7Bv@*$D zw(hoNM@9GBe9#JuVamGep?Xcq? zRlZ8!pNxUl}NAp82B{sAqlc=GIR zv(MF;%(#O7=_V05^}^QT=69~k+`HFxEyq00NR1YqzcGmk{CmB8+^xst^`j-E3nodr zm|TvU!!0eIf*sz9oD}#z6V}6qLB@j;^!-0H&9OYtcs@2iM8 zVa{;6E3Z#n*CjG-^tkv(AF0}re2)V5E&im?!K?Qh>v@2Imx8zrRcuBGT|-@6!X-=c z`P01V){KPYfUFNoXU2pW)00OOW1q7uhshJudC9VFQy-V8xNI1U8GswVpERAX*RDx0 z%Ur{~ftJ(|;0)5rK0JBv293zwsLA0O^uwE8Dh&JA_Ilzb=}oax)HZf>K5}Lxvc{Sy zFhJ}8k%dWGjqL&(q5)nz?Lqrt0FZ#mZ~+eGRp@QPVnT&F%qFj^bwz; zQ+*!vmN5?8)8Jh+4tJ^4U6+|>(%zemqnDdfTld9=2j6Tik4Eh% zn9PzneWwOAeBTbY{FbX?e(M|IiWNZxdM50YEHI- z9AG1f#J~#Ems>L*)(B*!`%6kk&6Z zZz7?GKSpE>A4Nqv@7$kCNrN_Y8u6|~6*He(hUa$?-K@TM5b@ zhA(+?!fn`2Ux;-mKFb*wIJ-TnT0yCuRZHoZ+pXNQ^-A&;yVP!__4ief+)H}vN!34I zIq+^uu&1GmylWxDq+x$bvBTdGUaI?T2_d&>4`7dbJ3xTE_fuYM$X4?mTPo-4(tR)B zHvYlcv>`6Evn!VIficEP-y_MJgX$E< zdnV52+i%a;pyrv226e;Fg#M9RnPtPBX^HOd(lcBJl5H7?mmz(+W9SftkFO(7 zR|7M*t{8xT3tcA>Ynh@DnmT+i>bobo5m6?$IdmcaoGMr5itAxycd|9t%f561nGk`Y zzSL7P*g5&o@@UF=(qLeBu;(2dgceIQQa-)Wtnm%alGDo!ndH&lvubo|BnPo=tu5Nv zBv_x+SMY?OC3!AO-|X+4wf0~4sYfo6S7D264B7(R_~GS7+bQ3dN9{g1#d@S>LFL`f znK|DlKrht}x#ehr+tv=JbbfzcJfd4XrrHZ8Gfi(U7OFi`h2YnW59fOq)^oRPNyBTk z{xfO{LbY6mB|NO>`dSjAJ z@2^eZOm{5L>e?{Qu2-pP?OFRO8QkW0P`$vXtAUL(AB?Me-OKw#YCW!uBe&VvhU`HC z&zAg+zldb`sAO^+ChzP6>3eQ27yvNKE;#I0J22aT)Ett^fOKO+1@itvt1R{vzat1$ zChITGhkFqn#lx0?RG=lcH_JCIWL^B^=fY&mA-janmU8~A$!5yTzKCfi}u5?BSc&wjfKgK80Vnf;!;X&(*)|G{A z5CGD*lMJ$Ld9D+cc$wKidh-I1#bazJf*hs!#@mY$Cv1$6jRG>sX6#B;dJ%lHTAJ)k zm&o1N*bIXTlK#=tThHlRC!>r$XF!S(0DVCQVYq!Ju!XYs5irl*q&4zhxnY{jC}*GJ z??N%nhGU8~G|`YxrvzRU9hYFxhj(j%oV!V^%Jc#|l0LLZuDJ*=BkRUA2w>~!b+X+D z;Yj$@$C{ByJLJHl{IS&wS2cr)?r^^AfPBZV^=e=gKnPnCmy@Stkgnk%?sInH2jn!m z$Hx&{98?Ehj4>gwveJ**Cg)5Wqn-s`l`0{kdG<4b_LbBYbmXKW>bC6Tr`#3MCe+Hw zB#Ozhkk64g{6>ZhJHs)|jycC`?CB89cJ)!3;L5e-cIVa{luUfs zvKk;aYYl{=a9#0w*6;NWh5?f~Q)AE&QlPn#|GG9kb-5As9&0#3UmcHU1gfl#cXqJR zrAn+GG7w*BU4&UqMW%5c^KkvaVLOc=#&=yKX!tPAev+Px6_wER{*DJSzqkZ)rd~PRePr62&s6u7bk5E6=0t|m$y{3nZ`OE zTuCf>8R)H~Yn|)`nia4Wfm%>wdbdOEw$@(M41!R>Z#1qr-9EAEf1a_abQE8YJ>X!@ zTQI$<_i{7mLa_A>sz58xGlL0TQYtb642rxH$M^JR3ajn(c^dNU@uxWOtLdD9x))g3 z0&jxp#yVvBDh@EQ0lZ@sPN+XVAvth9#(Id;n&YFr`IT}`8m|EvGkCBBPY_ay|4rJb z3zbdmahms+f=_?9l%IIYqq!Z=I{y0&d*$&UjvyUSI%}lZx&|aJNFwM{jfpZEyn5jw6766>ylejc|T@Y=QIa0 z;MMDuU9{Sg+FT;uA2ymX_ZA<6#(bv5#Yhc5T$(Xje|M(Weta=7O;zC?7)8T4-Jk)e z48^v7nqjK}9VB5K?JIo4Vy|!OnpQYr&z=iKsT58QoY#cnm6idw{B+~wds);H8%*G2 zMr%?)${bAA^9R3Cxm?399gxb?zL2t&aE{&892*tNZB}xhn!It`nM21gDg|vaF_!GH z1C66cA6~zeCj9WUia8ad3JE(JNseMzI%7+tATQavO;gyNa;q=Pl3xiDa{yquY=r8a zxXl*#+Y%jM7RJXFefD+F7QZBc-Cow1>UBFI4-b%oyNO=UhrAs*EjA>fnYL`Q8R2aH zXvtT3^YxX3klh}#mEZzbYOb#Z3A#XQQvZq1%-=tW_Q7ig4YIUXk7yI2xCcx$+oEmm zW15+RcHbm_>nOopSQ2h546R*nWF;uAF9-s@$6`u=PnVAz>DTEAhr2E^TMPMwW0eFu z!+4S2Z0{CpBrNi_aD+JbAva0o^njy(C9|kU%Hny8k_zRsj8oTCt%%l(SNc?la~4aS zt(0jq?^FgQtBq?f7dqj^_!>Nmqp#VRx#uEBe|RI0328EfyjQ$e?xyC?*stC(_UqIA zflfFja=#Ai(Lb092`_JU@Crp5Uw+eOGHfDCMy(V@>gisXe+&WCCN{5xloc1?r7-#|VN))|aeK@V$%2sggy;@{)c z4Jtm~QWJ;|OgR?~h)nTd{5fA2IRk0{TToYTdo+sgs2vLzgF-Z>CINd@*U8>z&KACq z%RB|FgBBp;Tk=2Sum}xQ`FGdGkAJ zAS$+cQS8No*lCpz7|LLFPF)ZiIg(lCNFdUPA^?l^sX`((RDF!^WRG7;gh1ZmL`mz-N#pc%3|3|jk81)tmgcsn`x?OWq z@h)f@NcnV-5s!)-mGEOeX;hn2l=CaD1JP9L4rFlTRyV3w+AQj|n0=*mQzcz@pY}>z zEGyT?F&-L$2E2VG%ErVtuB%lUGTqadN7}U*fy0WHnGm-l@$)`S$W)a$z}yYX*k_)$VCaxWr+ykA7;riD`P8Myg-5$7MygwI&;N z91)>x!&^Dz^wW#e+LEf(UxOs24@WZ`Qfj`m_VZowKwfogW29v5o>4L>7Wv1yEo17V zjipB4_ps8|atONTiqNQ62b?SOv_~m%EoZ|TKbX+dHHo)Jk0|tM&u=ZTg&Lcbt@@1f z0a5XLjqQ0jgQ%Av2sL8NP9>6i+;NAP`BBbOmuCXiP&LwS_#gVAKjK21$(JT^BK1`| zT8uWg&zHf9+qgAoZn4r|WPGx6cqV~5mn4+oqe|WFI8V@%OtD}F3%VUQ>Igo-3@cj1 zPFJ?&yMX5j>*lAi)rPm2B=*Hl<8w|Ek4-t@hlVY~%MKzL+ClLs1?p_-zAx7^B26h9 zkFM>{1qMqi&ecF#q?oj{MX8E#KvJ;$f)pB|Givv3YD%C#hH?6Pxt9Ne4*;l)y`D`+ z+(Lx|i78yf64(d-aZWcMt@y$&;zHSwosbYAJhq{-EmQFY-LJlZxXHTQTgvui08SW9 z%6d)VVMy_zu^jD=yBg`;;*<79!rLI@H?5Aee-6-#OP!H#66TAygx0rcP zQYIIP(Tb0GZkzl8Mb$iO9S;WvT5K4$(1t5957r8!GSe>~J%KjIsh$2#b)|x*{GMk# ze0$oFWoGs>gy8ob&(pPq5%BO6clu;s=|stgulCy9OfKO%)amc`$u))@HLHryETvy& z6te{0Eh7~^$sSCPuYY;Vu;aAhZzCxWQ(CuU*IZX9(~$U1!K*wjl$EtM@r-M9ar zCr=0i!KQ1>Vc6;mKD>X=Rk)VSq7LjI_B0cjX*y=}{`goa9^_|ZDu6sh1$k+D#UAA7 zPCw1J5nYvJd`L55>YH;P9d1XEe976#ju-bZQVHIR@^Y)#yw8pFAeM`YhHxqvw-AXZ zmGoI<*hZ6&s!W#@sy`oaVk)SaOSJzX2Wmd3B0pDz6VyEdYQC(oG< z9UEB}9{wREdwWIF^otzyy$wzVl=Htr2oDlipcbL|uk(OZ>&C&?O(YBdAMytF2HPvs zCf=SEf{4s=CLsvUftAeSv2!1S^ktdzaB~ZgZRPt~-yfs~!jvt9n-ZJp_S`-ppo|eq zIYeJdVD!)EWVQN+05O$rFeT-Flv5Ji_!N zCa`&&rQ1UW8T?u~E`%JfwhOB7GON;+UGFAbNp7LzCBm?gNS0mlYo7D4q1SF@Vj|%L z{e8r_QFpO%MC90UcSl)77s)K<#Yi!v?=~ETEuE6#zSZyXsa~!>^s2&4-W`$5x6o+A zPY`N*0#O&`bklw=WW^)D7^}DV*%cAqzBE{(bhcy$hS5@MnftDPvQSc8i|5ufqlp$|B zJV6#p$(Q=oU;1$7j#)x?!q(o5sq$hiJ3?w@QCmPlH4l|qCmd5`QA*u@He;ohVgMKw zbqEu%TRc-7nR)Rd_7Bck$mRfn9oWY&jf?qZsoP-SskfF%`s}J^974-jR+ctoaQR1% zoxj^dGvTDbM>+$yw9zS_G=!=iMud03ehh#XkCQxb4RYkup2Be$x+ckvh)le@VqJbH zo1A44wt&q81&ni_ybF2=Og%-GPCp3E1S6y>{B~`wPbl7?-|cOEI%U#{Lu}wcJ&WIOK>@k9jbY+u}i%Kq-a{NO!^6th=e+VU42$O29|$@izH2|sg< zSsd#?8aAwj&e#~5)XpkzFUfL&onwxB0x6TVDOE<=-4^jj5~FOMr-cB}V>dZD?{pkm zM{Qf*yMA||5zi={n0uVoXOg-b&MNPyi#BWiqt!OZ@r9bkt&SWV4DwVmx5Rx&iCA~> z-M^@2Dr{!+tNO#{Y^$xms_;tV+aTMqQcsG}uT+h~ITFwZfd!Xev(rz8>U68>&VS3V zJmMaGmUGA8B?(%m+wYM|J^Q_}5=;;=v19l8pW>r$NQ2URQ9wjV_sq5a%MkdNPDpul z^L;h`Lxp7h85Wf%W5JpIQ+<5&J$H{p%5NM|J;Z};m4!Gvqo+E}Z->_aPY@YNJ>L1o zj8^e7)!LJ#L5`tk>a;Hya6837rg1xcYyQHjZ@(EtICf4 zAM|!7do$U<->+9J0r*KE_kXtD$v_0~tOzJ#t?Wx`N%n_-*1z8a?lAx&0%o?Q*~mA0 zpr)=m-$nK+Juz5Q$BM-~vzGs0nY--o>h)R=4*b}Q655%tu9)I7@p6Z5sE4(@IrE^= z7MfA9R3!!M1)n?l$DX(^-HQRsGvig0&GRx!D5ouyLfY0Ttz+Mm;POOQt9Bg<UlcT{W1Biy4Vd&hjzmi7W2Axb)3888Axy^tUXxJ+At1-DV;D#0o0qYn zOTn_?^{{@~z zo0hpi7A#6LF(uvoCiU?N0_W}Q*B$z3n>gGinkLzm=RC_2y`-{`m^W+rSKJ}yIrXh* zn1vbwhBRwl!s){>{}fCo*=ek=eqq*lC92J5_Vh|5MT6uR>D=gVLTIr`7rnV#3a8N~?$;X!FzT0~{ z+wqR~t!{JlT35Heh`<>180cyt$OrDF43LRU5P*97*A=JeTvt7hd*;j3k7Q1WYK)F` zPJUtD?M^?fQ?E>D;7_8nynV^@$Xp2|?C_MgFJ8VTAW~Hzv7@mK)$ZV2FV0AHH--iG zVmQh)D3V}w_Ht8v{({!*O$ zQ^}w?EtsQn1b}Tm_zp-tEVM!CRY(aGLHUBN*XE#8jz1Zm2Tq?OQ^i}*XqsV!9y1MR zXF+sCw&H>=uab#k%dp+(fY#DS5UY~6iXi5h|MLv06T5OYLC9zCKJkpn$$K{!pNSXq9_6XB_NP%wwEe9 zCdE&PC#QjRlUP>Qed*+e-8+es82`jTl#NrKCk>DoPNzYB$%+WV+4^Q1iTBx}8{<7x=Sx29uQ$u?LsoQ8AwN zgH~bY{l_|nw-SS2)pHM(mhDl&28m^vkha{30pJT+)9&c@;pB$*_ z(N{c?ly7=*5*vuG!`fq-joXdlk&}l-F#drX9m|(%6upXRma|5=}B-j^SzH% zok#)jwG`4te)Hth{N0tA)0Sn^&mK;pWC1U>pW2W)xUod0wfwR)8VNr?fM04l`+bqe2(QXD1P9b{t#VQ9ehb%Ns_mW%1e)_A z{2xc?biD0@A%Oc$P=4}A|6fFb?_{Jq`6$P16+n!6-gEfjr{|J$=h-{z{JpKmo6M@=->8T_U0W8tJI4D=eZs*%$dMo46(S znyUNgt(KyO!L0nNgBADt&T*KOi{KEuAMAm_57gR*MTK>(JW-9W{0~YV@Zl1$QC|@y z`|@JrH$qQ;ht>5aAbHYFzg63B+!#p^6PN0~HcXwXa?JlY;sZa%ImUG14^*0RTjo2Z zeK+_-vpvX)FhhE9I`Ot6i!}Rx2g8gV^OILUa)OgG?<}*0%jTy*E`z@9XW_Cn)Na@`B;t$jIwrsKUQcc<(-4{B!wy~Sz z4zrWwfp@`^y`QD>Hd3EX=PKfI_2`j1Z0E2!?3D9}`B1e+qEc*|q!{eZ5|Nh^96H91 zc}{NV{kW_&V&eO3**=Rw-6LxPOfA zE>K0;dc?j!u=2RJ7K{zN7o{SLfd?uMx!w4<0JChg9<0wfcH4ce26!fcDl5hx=u;8F zVu^u7ONnQ;Y5$(&PD_(aE$7Viwdz*ZCiA;IZ*_Wc;pkl7U%N7^ZLfL8i`Rk)88n0724N<^4 z4(=O$q=>Kt(3Q+z5T! zA8(}yf1YQ61h~!pmDC<3BPMn0iv7vFOF8gXAX-uNcj%{t$VR#M50rgBqxkXoETwu= z9BuxvNl(rX2~YfN48~6oiqmVmEA+q&4M$4Ad2mCasjmd#*Sl7@lI+TU6b~I@QMDYZ}TxI zsEk9v{8@M;n}8?8@d|IcU5(nXxqQYokCXhorm;OcO{jBV#5T06?p0TD@ulv5X34!A z-(gabX31!jdbCK3Jq3@zlf$5-xXCd|AjHZe*``WAY=9{Fj|p2T(`MH?-w+Le{Nmb< zlz7MF-Y^blX`V4)hohvv7RYcb$JqKC+gUaLJQNsN)a>A<1U#{G;F`CUSO5Ne&Ur4y`P`C5)HPnFUA1BgU6!3Tk z%MfqZ%`5C?k-Ca{AExn?-mD!*i3=;aBGh6W@ziD)T+i%4Uln8iCU%KhY2i65&@I(D zd42Ef!TrC>U|mbmKGYq1P%9T6`P*h7`0bq1?C&o;rp6$_g@*TMjSrdDDp-$%#@2YZ zM*_qz%>Uk!Le5+t!6+^Rui=ZZTk>iTH*g^BZyXpM9!T-reL)!dc;9bq0fs6@$V%{Nl`v~E>8YTHd?f`zwv@Py*dw-vZlOL_gCa| zegpfI#>9lKPa5R72L5og|NY6juYpKzb?!oCiP$S;h&jmZ>b3Sf4d=ype~Vb2{-HkO z^+|$Z^547l5K1$45Lp~o1ECcL&-6DZ}_fs;pKS`C`0K2cH>Wa#f4`o)B& zAC^8xmc{FpSha}qV=G_;Uf>o2%iU|N^G>rUo2XfM1;BUh}rP}{JF26+kpPVqyZP? zYfnKYA4OM1L;fF+e|b9#`S)f2Und|~9>+2M@1v7d3H#3zhWr09p%NAs|2Yr{!&87D znlBg#`On7#Mo<2)p#1lhKN7f|byTaGLjIqR6aN1)ku0Xqn?~i?zh4d{avRTQ_J_2( z5%wp6^NZ>?f0_n9>^l7|pn$?Gb+ZZG%PtbBl=z#+ zo!X=pAq`>I&@%llcgUF0sBMIb_p05gaZ=}-tluiu{-6&#Cb}JC+Oy>5V>fW&-E=&u z&4O=LTxT}pG^W%S%3fTnm}mavzXQrliN!BiY}*j28s+hhHM$-+zmL@CtleS3dCK_% zV%eXR({X)pr?FVY`|2sbJn^&IVajF=$yY zcJNh&BgLj#E8ho(UWZ=SrF!nx4fN?9Vz%_JKYF8MGvc6E2T{&(Yxe&4`JbzJ&W`o| z2_){b$WQyQ+jP39d|j^y#iCUDO1zJMDW4WznVf9TAT#|*UJz|GpH!86w<>9>uISw= zs!(=6smRdvzP?yWu=74lN!uOrn5;;qXc$w0HgV`R);8`3a%~@3LA6Q^#LxT9A?gkf zs;;jt1m+OHm3Wv(J1MkWpJ!ZKR7)-3r`^6fd3tzu28S*-{+tuJP+C~8Uh=*`Z^%IF zQ(YI`k-(rxy6-w>h-}F1Q@5OTI`qA)iBmM6EYd>8W?Y(>yBw|$ad~c+$!Hlz@Fm;M zK)nyb^yBoeF;i)Vo3ZAi2$}&ovy<=JV77+6W>5FCc90%{*2)1KdS!n;VCSvA3Be+{ zeCp=i5D^2fh}^(S_XXxliE=7@^qeQ&4_s_sUF_Kf6$Wu*FV~^^=tOi5t5~6Rc@GOi zd!F%DhOpi*^*ZdyTpuz)e{p-gYzOJR1Uu!M6WiOVzn3I^dI25ledXLT1I$uial`Ix zt*+bOzyWKDTjMZ}i}nxNHWQ84b9+_wx?2v5_Hc#)ULRm+ohPfN>c{`Kpud;IZI!e% zBeWaV4}f>SNYHVRI|r_I29@f(=)Rt&;8zew@7jEg-E%!D9Q+b{Bv)dS{JzI!$b)3E z1*`Y#S99eB1{kH)@`EnA1WbD05n6bGt6{qw?GL)lOp-dSR)OytpM8q2N!n{_U|UhTm3ww896VDsIc;0v3{viRdE{T9((`zR|;mt5`0m*=AW z-a_Ad-)68b>@7)!QSt3HEM1+{3~=*b=rORsPflF@VJ9>BL@Tp!Y>ZdBt4jWbi1cxyr4R%? znKtgjqt+RwVNpSAr6RtbzACxxDSi zUM|5GPpWIa%RtaE16$k1?$AbHLiJa`^(YkRzqhVzvP9l2r~9c7@{q(%&9!#GLZH+Q z+-P1|tHiE=Pjx?$R>L@1nM>bS59KEh$nL#=kTVa2NSgSAfGUNx@3GNw;~w*d!3@2N zo_o3vlB@mKgCR#*K*$?VxU8GF)h7scFq;DR)^KG-;S( zNG!$LxQF~%T9_!e;^&mvSs_#rYY7eOR#hKogA-1ZaN1u+is+7KRV@|$Nz(Dht}Pe3 z=Hu2i0e#Mijr!t9Bk^jPOYm+&^HJ`zE`=|tWSP3N@N_4YTWPjLX*G);TV}9pbV3VnU!ukO56yUi z_D+Dr82vQMy#)4Ctfc3t@uDg2RHDJ@)b6zJ(6p|^Riy#zkptiV8($+ffN8uqWZnym ziEo>1iM+XiuF|ml)CK67*f0fm=(BeN_D{vtdzD3Y#<%@vSP4Y<$K4h4;J`gHermUv zLT6K{>L|`ljni`4w8tlIyI4aM>e%0HF9=V%mtgF8;b{0YW4#>)-&pjPWH{YAC~Oj| zZy6iMg8!QNbAYgPe5JGn|r#wDq2A#`RyrgJm zbe*?HI+u9r9@3;3yZzaT)43evSJ6xvO8r&RT#tfGL(hu_q>es1kXx0GT4_1NFx@`y z(>p1D*6aiMOQiBCo^K2Y^ZU6&f{5@n3D=YMch=o_`P(II=fz{cRt5LlNWDwX6`=!9sjnyB)^j2Z4wZJU17r%x^-im{*@Z}@w20eT*6)P|FSt#rNtQIS zKKK+-x%#H1S%yc?Z@-arHp^+Ufh6{`DC?3|bzIxxwboL=eWE>eW`6jW)SQy0li7jV zB)dtb+$Kh);9{TpC>mf>H~)Xwd(Wt*(zb0_5lcoLDJ1CXLjXF7^2aFsxRFsaBWhd2P2_X!sUK&M6bdZ1Wb_^h)*JmCH7pfc?AmBH4#> zFVFKHNhf@t;4OrL^nm6mcY=+Po_gg?I@`tAh!_3>QMt_oR5SDT_^4G*miQ-T<;nUO z*8VZz0zI7S3ke4!w9w1yeI(j|;~X}l8__@e@Gr`o6*0R#s-kS9LmLYZ)d`hje^Pl94qQDLyM|k;Gme z6KUz>D58%f`@9De-Y02UAxmJyvw9mVdA#=w%#)Oie$3qb7pL1wrXo*uQEw)u+KYj2 zOO17gA>jNaoCzOcT<=g0P*8)$I$MLTn>3w77UBY@>5$bQh6i&gF$-R#IZXK zm_W3Z?<=BWyahLP#(m;Z!+HM+@n|3J`mdV5WyDU9(iwSZr6*x=m89f24RB`c=G zSi2ol7>_oF!i>z8wc>{c;7DZBrvjA>yQK3SSzJj*)Xv-Mmxix8UYyt@q35VE*X6?# zDZV7X^W$mNYX-_QrV}s8xWya?pLdg!ZN_a8FY3rPu~m{z`!Ci%_8-pHLk?us+FPCw zjhHNtL(ZiN0EDe~7+W;pyFtL|Yg#?Uz}{io?^!!!n!C8khiojVl2~CKSlj!^_iay3 z?k2}|+K^NwJz~#bxHd^Gdh5PILqX!%t_bGzt*R1NMZSH61*aR4`PbRg37_8|H)4Wc zD^{!tER~s}-j7CXkwgQ=Umz-J#+T&E!ilhy#W`;u)uJ%N2e3={4)Z)Ql!(8u`qAqa)rXojjmPS$23&*Qm(F+(mync-0pwg)!$}txm$Puq1 z{nfP>#I1^3)-7HYJ@YfmTX~IUI(!|Q0}=vNve5sCYI3iVR&7^BdKM0QWj9(> z5F+N^`PjBRL%3Z@I!`1-5F!cD5#hYGD{}jOkG*hoG1ja@wPEIV>B?Hg{Qhh^{5mh&8`S^VudQs9tc+HR&^-77fn&Ao=lfMr@UU# zRkFPAN@?J{oS$_{A;n`No^`_$`Q05?IaK_?8h12DnAEJZV+i z+7uUYbgG)$^LR0*%fNaZif>Uk=BPr}TKL#Q9!;DIUUFHA2nXja8t0Mk0Zi)fZ+e zt7pv|y?W1DyGPw7q-U$xi&4Ximp#2V*5-2`ad^g~#I8C#^1L^UT$!14QUn-|$W`Zt zl`dZ8j#0z8?RNG%Iz54RmT|{m{sr9UN{Ob5rhIX?Vjh>hVkY!j#KVtizN4mtJuBEo z8KGYKu%~L-d&Zf{mY7!FG3RM(>EbG0>Z4<-IIx;JWMevr>z_-rj#<@ewG10^*-VWo zJ&vWsg=ZZy9-J8#x7}ENG&4L?_HtFty4gJC9qUXY8D(y|wOAv^oJ8Sj&g-+cWAH7I z=viGiPGJwo(eg>bjTx;Z=!ld~w<>f`9O3gn62}qDVhy$Mf+^&JujG8U^6MWx;wUH!kwD zdTb6jU&QCTjB=z&dCP;!@v{*x*0WNReRx+BOUG7Tx|Xz&*V-Y7kfC2!zMpsfmY4Fma>wh+b^7zd+VNm zdt5gqQ+!BnGJeEbEzwR*vE)|H!NHk_qHM5&0?ADIsFzXtUBYu!BxG&M)>5XpY5||p zkW9InhJ$_FveMM@mg?$Re1m2WOWQp~=?r%^XT@`NpXmcU-@iNGZ8iCxNVoickO$4T zovkR{9{5H0jOIIiCApl1u0B=mlnfLYa#+Tr{G)!!7ge!%a_pXY;n>x$1jU$P9U-`GwK9XWAKU~REytD2E7uVQPEL=f<<2yv!VhSB%NV$#(p zvn@r7ZB?tf==~5(g8YS}Y+On1m)*&+WkBHyg#;KC**&-ZZaq$j0)&~4Z!p`?y9)V`WTG#Xse zO*R}{jE^{4B{18`OJ4rS=r#LA|0JQ& z{7DgWoxkmXcz*AX983NXYWB9xeZc$on)!yK3GncN@2vZ!t@n5DEA(=OMXQgc8!VFg z4JDIQj>M?t_FS_Li=4Fj?bX&X!8|9bh{)7>1r>XE`a~czuvll!$r^3xJrnIPITe)S z4fO+MN<4W^{sxLFxfpJuCWpWu%6#n0fs*D~;@~v0$v6uNd;+vr_;KemQ|Um@2;EYIwHE%5>&5Rd7yeLacz580 zSbmmWzX6N&<+jMxyA5ySEHJ^y+)o{`5gccdxbC(yOoq4aW-#%7uXk zV8;vWi=REMuS)nm^P6NqHO}#_L-vNJbx~&1qoI^?_sB6Xs*hEr7>73)pJREA$4jaP zn}b9)A9K+N8~;( zR^{`vMOc*%QrF6-XQpoQ`I@SlHnr4;tEHzB^VuJEK9kOi2+uWG#QVNcs}QsbKkq60 zQmHOFMWIBMN*&I@E~nGv`YHxKq3ea}EDVZRUVezkF5U+ZFKSFJV?EZ|qGk=tK$%)6 zvsbBxWa`db^)r=3I1bs+k55Qd`)78YRan!p;p8Edw4j}^$)^6Tx-vqkZYpcmS9BXu zD;=i`Pq22WuEo^V;I_oOZs5~}JK7)C^rB4@S7lHd0dw_Ds-msJ^~B{ZdfUX8uBwk> zTx@U=b3mXTl^cI%n3$l%5TalCq$|4>g`JDHA95SCyKO<%s4Up{8nH?;PIX2_tK(Sv zdHmeC4|P2f_0uj{HpW`A@gCehHwyynmE|gj0t)T=1P^QTx2jmDV@z+0!bXalT!NTv zJ-is`gOI+JMyr#GD}%ZlkIGg(^TzIkrou{Q9$#)t+2BgQ`e(D)#PyDBQVn`5Nw9|9 z>)h0T#p%{cfc{zQ;bYv5W?pRb9VIO*pWfeOog4Y$oh-ycv&r|>E!eO>Z==j8^NWN; zx4lm1%Jo`e%2cwcd`}MLoL+OnwJ@Dk{=AsFasvz(D?x(E^{Yt->B5On@s+P82@}?b z41>0pbYr~Damc3LTv)P`ZL5V2m(EbOkp#P=*eBnc$Ow-MzH1}+6o4BipagH&OT2J| zH}>^-=S|@Yzo$p4TyyZ5(Tn}=(|)}ay4zHQtjMQBe&92SYm zTD2PJ@>G1&n>*N*sS`g-eNxV@q-$QD?|3g!qx_)(Db_`qSJaSo#BoRq_MY~u(s%@3 z=`*(GGvCYDQd|Mv|6UJEPgaX~a(@MGQK{P=<}-R>IBPc1^bU7Jmw8x4sl^4yW6Crm zE%S_XhFe)%o*oRVnH4rbDZNH5`^|_?6=PUa^pd{-&p4)5SYAx({XWBOo+B*xKlmn>7SmaF79Ag6eC~ip0W&Hq1-`pfcPJO zAuEzUvTIrXrWPQ(2bXw}{CMwc7;92VPu+cdZf~KbZn^}jx&Qw7XR-p`?7uJUZ{h_R z!2gFr!fV6H!edzF&Y4CZ`>Q?OQ4(sl_jSSN`0i9MDV|nf4^9?#GLIK^vnQ4D^D!<* zoc|vPB5Z+DH%6ZvrHeUu^bCJkTFYOu3<@|Y&y48%*V5v@1cn!!+`YlCy2ESk_pq_h zyu|5n2P?SHC-dxqI^wS6a-El?gF^@Pej(&-{`vg-=Gj;jfZaVT) zm;bZ0Hy)2W!k)s<-MfW48#mxGTh0=|F*YM!@ybkqzRbp=_^luKXX#S z?S;GY-fzSQcYc%N7{7TP(p5*dA8H!J&n)A=M7oDsrDr|gl*q)Tn`!{m6k zR-e^DE&Hm4rsP7Tkb|L)&vJJg$j1!|82KzZIcIW84K2PV*jHq6YUjTqfzhh*k_g{; zff>KS(N57hPLM+xv13M+Tg}c*`SM-W>XhC?iq2-#1;R2`znX?Ef^4k1aIF?_99ku} z#w24vZQiA#n_U$ULE7kt*P7M~E~$9jYE<30)ehTp+1zprvGvy%=e4su)(1)tLFG zw)6TNWK+5_SPd%~bnPzyB$8M@v{+%K*03Q!fI9J%IRwID9Rju)PxZNax{Axc+3Iww zDSS8c!OP>T$>pozfbHx3^aD4M%x6!@S2? z+RPK|dX_2yg(;yAZS~+XtB`fJ8Q>UMA{nz9v#S zC%0k~B7w95#lv!1RAi<%Eq$aj(my;WW?wOd!h>}=k5#Pn*~G4;--oyXgAwFXMf=54 zoS~PCNX8+L3bS}OAQ{D;W5>mfj7aXAlB{jgT9Hfq+Uce0OlV>2>&DLtau zHf%t12~f&7hBqgK6s=u3_HZ=oth}boXZ6J}&nBCE$`_2Jv&{@>!;2l9qXNpG zja+1K(l^Oj0sJQ#SO%MAmyFAAh)>wXl z9JnCvhQmBVt7lJ~fAp$RbZ8-Tu{+PVIMH`~Dq!gQ zBS0);C2&kS^kg44LVsGU1{4Q_QL2U>JqH7b@YBi3N37l6sEwswB{;4Qy2X}O6YTJS z;A2=xdWB}%_}VPk&pYCi=eXRh6^mHUM0l!Ud0SBu+$7&W^pwfhozK&Lmb?&#sH&PVn6u`VE6R+Y-}s&uMx4zn9)4lC^m?&X ztEM13zu~uH4N{!3FyRS~&CJnn6kKB#{6Wf06g`sXeU zQe_#(Ct5cJpBt=ZT=Hz;pSe{XYxESZD)wFjE3ki5?V_&NRd^z#YWkPLRS1JcB>lEf zQKGiQ`j@bnu0>^eW~9`OTke(VK~@!qo%18M$cn~BZNS&`I4kB1jwr+kDf<*1nCjA1 z7VRodXM2R~enZg7^lE53FPC{|{x+R~rUXQnDuNj(_7ql_F%%>c&X=Cc<3=0{;&AXD zqi=((x(ajB=fx&^huPo~z;}r@4HGXZ)K6l}VTZ~V@FSC>cPi6S9^99m4`&lhrb)J? z`j_AMIX7$vPQi*yV4L5U{rGaUkG)6tJHz5Ytrc8wLf_co3y-9{<~vF3Vk595pGyo* zcUkF_78UNK z^Yk5sr&w07eqhMG;Qq}oiR~8c6*gPnb!^JeW(J!x7feSZO_Wuj+YOL3``%?e+eG53 zP@9v8EerT83T4A(WzND{?-oM>e9X7iNOOtl#T3LAIf+=EJPUcNb7=QFnYgUXABXxX&uH+fW(Hm_(Pn$NSd~qJj%Xb&8;RpB6bKL`+f0u z`Ovo3lN0{8kaXm6cFFP&#w@*z5L4A6F%YX$qH7~!xIKSjTg`CcxfVa7`;v(r%^R$% ztN&Neix+1|FT=dk`8t3XFSN2L$C@N2U)EObAAt4ME`&JnZ`dcftZY|sF)hE5b+IOA zZhH-pl2!gpqFm0y-f_uOM=*r@X8EOzNfM-TH z*u)d|6=QqbF6d&%T`{1ejLy%qtn?f(v3`!ED&iu+0Z|E4vsvw`A-Z7e`)X6dW^ya- z<-IV)m!;;otrW{DA9}!zGcT(_q}2ra3H;;CBv;+hCjUV|51Cm}W;Xq5fC+kRt%ysQ zp}GIAdZXzXEU=$di04A?_@PiM&$J*^*5f2*5$qNaxbA^(*Zth=LN8%$hQD1OLruMK zeP-J5EpWginf@VL6(jmaqO$d;%<`A(ihpwIAv2}~+dVVS#SXkFth`)=*c@XTcJ8EM zg5m)otQ?8wz2XKo%OhtGdHEh&7kE6Q5MxDJ5gjJ0WKP&2vn+-9J_pIe=?EAOTa^ur z)zO9)ww6mo84n0vcb4iQ!=`0{kq$_+Hc2(hcz1_}B&6UXZ;bTXI=DkU}w z+|=o=?puyy9|xST3#FY>(Gm#zr`2y$H*R=}(dpe@3){W|1Y1DVsjwD`LNjkww-^v~ zWJ=d7BuXkv9X7Q#!f>XQYz`AT39ruDZ)N?SoFLpICD@^szA_)JW!THV9j@C7GmpI| zZ<+YHsDh>>u^2JYU(GdLkZu)Wb{FvHnu4rYgg5S3|0TJUdV&zxXLV?tVa{EaoC$m) zsyAlERV9ZA)=VeE-DJsq@|bYy?<*HV6gLhbVQBb$H>1DaY~0D~&CH76w<`qPO%Ubv zW~7G@`ODSYfFtkccX0f4@hRuyfX{uyEPDjX?~g!58G$?SbV|xL34wI66Z-PSkPPt# zDFv{r5O(vgxRv`x9=(r>zL=+!0F?^K|FbspieUUD`gV5 zm8wWvWG%SaJ6J}cSaI+3uJ884f7}>TK}D!l$8<%g(qrlLAKfB!=~v0?eE}y%yb9Ot z1>977Y>-EgVh*LNDo+R-Y}PQnsc>tTcd-KuP_qZ8ZlN{k zOBj|IAs6BLbf%6#zmg$1rwKe?LWyC8K&omsUTwxJP%{jI;*P%dO75c2!E~q+Hux}5+K%-Q&^E4 zRwr)K7W&Q!S=qDEu~>VM5{C6b)#a)AuzVqR`4^MQoJA3RYt2cYQ>B|3<)OOM2gBAy z!ivkoU4_tlUf59MhQF?(Q)Lu?>Oygz zPmagX@l^w11(I0`GKiUyJ+0ayy(sC6lZu zf0C)6lLSwm4|D|YLtrM-SV$m9L$BISEV0*pPN>&HReZTiK@FoRPqj?lk(uJ_iM=da zSso!`j9ioB<0($}S(%lDrC|9%oi~GFytL9w<)4s@F(*~zYjS9Xf2^UWl`yHDuWkR~ z3u>a`-xr{yDP_GYvlWmL`vUE(9Bv{8SvzRQQ4WNN^Ceb2#ve~>*@|Qe+HKK3KxGB^ zM)6mM_mvkX&4L6tXFRX+0$}VW=?=ntOvCGhmE*m?%P{GVn)_LqR+P*oy2uk|&QG{K z{U}N-Gi@1Wjn!B81-OA5x`ZSm;G}UtNa$=LfF8yiyY9-mtmD|T9`_&bvspDGuZ1{x zrbTW9wSl>WlM)-!eMz~jbXRB_GAZNyUJIa^qmcZLLW(O%Hyz||jOPlY zOkh*CoC|Y#5ac#d0VKRVb6h+^ZT&Wn=Up!WDsuauN^Xu|YQE?ZhJ2NBzVOe4ql7CQ_Qjshhh5iDEU7eV z7!ax&p=lI^W0G+N?!KeX*aeHDu~D+CH!?gzan(c@yrR*%y2X7uBk#u^lcK}rfinK2 zF4Pu4Fz$v*tbgy!hn|byC*j0iVbw{wEP0lZRdi z4LW1x*vmB=Gf28*UFyL6C=;w{Y9Mi7c~68f~Jk4;yZ-o)E2{BWjmr6(%+Ze z!@@#yiiL2PmaOiLyiHb3W%%hqzcn@NGYjjNnIZVD(9Vu4v9c|Wwq+wh${oYhSe?+9 zH!lY&_oG21c2aa~$$Ig%S5gokeHeue#K*m6v7N8+bku`%RDj@Kq*B?NvN15}*<*f@Jp`h4rK zKp50~M1IFw@6tEW8FK9O9#J1$UW&v3x3bYbLzQ+)&TDjIwovs)Pnh+)oSvfiPwOm^bD%M>`ONhbdt93Oh>{7kI1VL~!|(3=~A?_sT!jD&{_z??h>T|Kpz26$4hWJ?tj1q*BA61mnXlcwp}IvA2>^zXj`2aNm>Mu@4~9M=~~4Y!Cf2 z*?>M#1M9ihut$Inh3rcPpt9rkfbX9!1p3798go=GE#n^H9$4Y4Iq)iK;0gQx8Tvz? zXoB@b?_nx`?1T>;C`KiHJ+`M&k$Uypk&Ji$ZY{I{|8DKB8vd)bKehCKQEUCK(ktYF zRMTv0lWYc{X%~aN;!E%#b&hA^iW4Vml`JH^R%U;oi%G71S=nWy&pV3dK)bjj86O+V z*LgDy>N1t-bif(7acvMvji5|ZZ2S=+`7o3BBfdO{Kswm#w7bo4>2Cmh_a-rr4dy(x z$e<&5$@ zqpgBMF+KZoGq~6f*OMp>{o9co!u1Oq#E97tUOZQP9xw^`5*YX*XF4hV48 z=w}&mvi8?^{&E`+l|(tR^QAoi$SjfG5A`lD#-qK>Kbcf`fhOqTAr72pk*gTb+<2*Vy7izH}-fs3adC@-5qz42h;z;|a-;4jZ{Dvnkl18XZQ zJ$00)Dt}zj&8?hJ(%%Ze)@rsWLjY;Fg$NC4CvR^~f>TRIPT+wr=YfJ%K0`wb5vI*= zhead5f(kZ(KxWl%49y|7HynxOlQ9R5-+bi_h6`|(4mc`$Olzd*sw-{)0Q?%6`~bB9 zuz}ZENl85GRuY#bpMi>2ZErk^eb^;h=-h(v+r zO`#Gw!^HP-++5RDdiydHj|sk%-mYYok73@EDj$PgTb2v~ycNq;^7Zuqcr?6FEm;|K zJk+S4;nx4k*#3*uh15m8xtdhx4sdfB!RnXh!j9O+5W&7TItv5T*CiFj0Ids&WQG-L z8G7I&kQa+*frOiFQ&t@szVmpG9K{2Pv+Nse;{|hldO%+~%2hr&^IX_c8{J~RLv6M9 zl%mnKA6XnPcykE%Lw9#QUvQ-qa{klmH~x;75h&3`pJk}FH?;v_MubM+ZL4sE?m|1|nqUt>g7j2wu0hK%q|G|j4T3F7H%GdC-*t4IG?&=c)4(%)a zSZ4afqA@E)UGcJ{YW9yhs4$@ji%j7{>DfDLD@qf+dsXy2jVRMv37uqr3N$-n7El&k z@{_}?4qX<#&a1n! zDMgL?LwPE&<7yU6Tw5L#*tkcF)2Bj<(s1u=CDddtcQ|g&T1e`>UNvqkJq)sB-)@h1 zGG1kzET6WUKpJ}-8OM29-OnxINSY9_zLM0exu^{&771)Ki!=4q-R10x4KvUP3s@oC za*{`RB!f!!nnBm%7}Iu{gdL-k%*RS%#XDP2-#oH1t-fy`{{i_wZfX8am;dveMRu_T zJ-RjmM#uy@gY9f!FO*|NYz@aJp9e&HUsY}CpzUWRXq?bmJXi(#>`$DID}B10_=_H% zk-Pn@OIzhiHNZ3lg^uKL|G)xLTXn_sn1MjEeDwf) z*QplOQ#Mw^@l~#*-90saeKbAkJA^D*Nv@+3?5FvCV)qFPq-k)7p!xKrX9rYS@r5ZA z+w$=UMaCjeSe^~m9gH#=4OpLy^N5Oh}?VcG0e+*ZkQ~{L^3duK;%0y)$qBk2(vI_+}RYgLV&?ij4muwgN((2!Z5Z#=;RIRtVauBNAYE1*Wj-RH|~y66D|G3=+< z1$Ieti(*ld(DzTCyK#P;F_l#WOSCXlBUPE#r@Ma38QG8k_&FLj6|UPor8V7}L^+z7 zP5(y#-M9vkVcH;4NMA>vI2e_|QU1)GY6fD>TmX=|^7g=z-flMjnTwYdIrm8+gRS`) zd$RTlU<9E$D!p}7T4)R)9drb^bMX3P00czxRZ~`nYqMaB03AzB*YlqX9M~nk`L$dM zml7GafW};iU5Qc&`97d%)|n`*IFN?-Kgw-szm@d-Jm}vcA$diHbI=kSv#j?wK-~{a zNodt*C8ca{sziFQr+7QU{dumxqT996hp5eu@yotHy&ObkL1`;g+*kitA&xa`PN%c+yd{c-yy`!Ohm%w`xk+!J6}qs6pc{VJR+{ihTM3 zShm}kA?t98M!$%VvXZj(&ITHL!p3X7%OAVe&>h!8DUlckIgkmZ`UuLh;%PaY6@$;x z7rYMI^ZtQ&DUplIU@2J8a5Pf&i!@anNoQL&_H#a_?#DV@e-MK;3(HUb@0$EF=pdiW zDSm72Iw?cM?Y!joR{;w4_HlwFOzskZ@U%KbX0nG^rWE#0*JOTE>YWe`(x17zqy%S% zM(#lDDGidTRlqWBfDc(nxPHF?@mzOg^m0ciXW6b*P zo;t$(Sq2~{2X3?P23o)VyYFEDu#wC~?WyVesl)+nDz4_d_ryBCts?->`S->u+M>>we>C=j3Ql6rUF=Y(MKRVcHbzXEn)V_E@LN{hPy=h5}2I zEh5K@L&>{zLJciY8E`4h5%?v~-K0L`KdDnV5ffiHc`Wd=3`NT64qP1_S1t|LkGBR&ZVLJgGuqPj;V&*Ml0P)I3|xA)xHcnUKNw#!mdC z3OZ4sa-j4ZcZVk@y{5)b|H$d)m+C89IzZP+wHvlE^9oa&cOo?=vdgA#982G>8M1mR zg6$OBVdwa@@GwUuZ}xvazxyNYTq-^bw4Jrws@FyJqoO4B9oNZL3S6V3gQViFYcwwZ zEq!HiR+uBS|HeZ@Yo##y(w!NO>!6v3c=X+~T_8GcoeFd-EErT(QtxN-#b|`Mcwwm|P8ukpXjhNxPd#uulRRkVS$VVUJ}Yzng@cFu z$2>T41oQVb9{wZ{?AsM-q-)4L9n`3lx@;Y%!}mQO`YkbTY-#k` zuTF*@xth9cc$iV6XVCK9B87*7JECG~9>w{h55qDlaPq$@V%z`>E_*vFpLzFcY%z6- zr?sxm*i{(lm~NSy4Yj^I5%}T6K!kbEte=|a8*@R7;ZhwU!M50jZ_5(*Hs~u03}w-o z%o4@Ol|Smu}?zg93d-iQuVRfc&a2K5SYvIf% zvh_!GlzQp2|CKv~p~lJ_#2h$)x*k%D8+Od1ni5H&#tIpFk@J;39(vqAYiZ>9<@FO6 z&E2vBT>qQj+v9NWi?Rjo5C%mC3~7T-kZmkNG`1fj6VsR|mc1YWvy zOX5m+=cdK6BKS>Bd3wwXI-YCv_T_DA0U>XU`cfI?bxbB^Umi0*yCa>jOT0@$t+0ad zMy{sW-oiGhp8sR&AT&Xa3!2yH(ZrYhCD^qA#(qp_{jYT{%lB=#=CDZ)4|&eY?BabR`_HBxOAF)f48=W+SHM^Q!9` zTL=86f{W8brgB_;r=>oLFNGY_iW7FqF1#V}b9r?)e^YukA?j%H2?YJ6`X2abvwHu7 zG{aD-KBBJ~mNnTPuK~@*1Z@oOqJ*lal+8fq$isKIrVQ0X+8A1`aRfOIzu)}GQzpZe zVZ?U91kxr30vRrV72+Y9n=t4L0ST8;kg6nQ$$T9e>&BM{A%#_dVeU{-Ja~Yv?zBOJ2O+w& zxy}tGsx?k5M8IJrf0B)%tC@d7xckfWwEQkAD;du>-#k7&Kcx&NsGEhj2pUe4-NMSI z1S@0P5L?~EPQ|L^>z}=M6LMgCAyg*8p{9Iu{;4Dqg&d%e~HMc#)e=fog6ZPqL*$a08(2#(V zf7$uhFWi8$X;SvqH=X0JTh5^*NOh7Nc{RjWjlm_&o604Ou2B~Q7EPK3ciou#QKe@{ z%q_3M8AGiffTM-@j1K;@%n3W|>j;3+aVM6v77~grw)mLl1p3! z^xRsHViDj;=VfIq$)3!7@^w<7}Llu0F)M|Ys`pT zbXV}7gPL7nWs+<4Cj`ON$boXFgejoM4g{r{*DtuQL7f~Han$2hmo68lYS0E{PB>ft zu|2%Kfk?2=V%P%hDbC-5xL+V~TR*+zN#=Y`>WQ4H_0N&##3JpR=q>M7w8smrphTyo zv#a_<4yf9Y7y>%H^NB9kcI$#{5p_|pj5z>YIs!_H0HVCyq%T@xJY1%gU1H%Go4YR< zsyftVpX=u=C~d7Z$%6+I8aN=U2$Ms=niLs8p#8zgt_iZb5I~6PakRd<4Y&%=_Hkk_bR`E)E;5 zxVtvz;9`MO?@2>{7ae@m!K$1wb55o!qLZfOm(YcUm(^MUFgVMzMnHqckjkTk^O!90 z>iR?UQAIEc=+COnO8P;Q&k|2Ui7al8zTn{|(X%~%>1s*9^vHho7tY9@tbQ77%qKSw$Hsus*s^{AphaV;P;&Ub3Ekwy6^Jo zhgCD^c+^cyIh&eT|C^8m|G*C6OKg9r=ygmcxfwc<>X`sru_NRJ{FIR|8P6*4#)` zh5K97G+%I1Dqq4VGVxDW4pWGzLvAqwejW{^U@b*^$2j`)HDe5Omg|`-uGYrv?Y|7M zSkm}5^!upC{E=TP4?A&mq*D;K< z?zmQ9z?bhoNs4jdfIHS_%DCV#sLpXoSv9>p_0L}!b-7ofV^wAGjxrzTae4jo*Tp~;ie3dsHEtmHeUyYK7eWR;5#+=PAM+cer1 zaj*0N8t+;Tdddd69^F);{0x_bz&bJZIwejabR2HWlk{Wr=0{rL9{T)JN# z?{g{}VB*o4RvYf0{!4>9DwktoSHAc8y@$2yJdQ3HLiI-rOgP*J_Ro|3Rk8rsic_md z&uea%;|y;;3X7s)EO6?zooQY=Z~N80cCFK*Ev!?*rCH+7TBlF{S|*f##6W_@@?#H~Jo5a0!q3=GFY1uf9kyJLmY? zbLVX*J1XD}T>DQfhSTaA*nxarGGM(&GBX4t-` zjI|v~a4-Uay>DTJ+kmGGnbb`e`;L3dzI=1ih@;Q~&3Kn)Dmc;XtXwVo&6tlniQGS1 zm+SJWtZuM$*tBb#OuTAzwN^qW>X;uNSekfiN{g`=O{tnx-2r}@xIH^t{; z&4m^b8s_7}6DfxckMmQmM?`q+X2p>j#!p(xzsCI1L+OX-%C~$~vMAR$5{M<#`q5eO zdOouksAIIRzJYGvh4SMPsjL-LI|=4lI|6@1>4@Kx)#3iYw!+Emo6qs`XT_h_>jezC z%?)P6Tf9ri+=*c$6{VD?T8gVVxqb$x`}|<+`SUdgS#qz7$2?J`nQB+u@gZ>o(5;9B zi>bfes>`smJ)Pq19bDTO14~buJm@xT zXx@>mW!@1ln!l;odq>uygD&=Gmaaml`tvPQv(8?4UPN#?rtK*8oHS)Sb)C zCT4{X5M;x0yX!u_prhf(K6c;>DR5s1)D=4V(>XKqx&rb0N~8SCBmqBrKR@1bSmt4> z>C^Y)bneFqdwp{-#}+jQPjEjiKC?XPH{kKj;Z@A?>nG&Oy!58x9jKqz1rVS#-1>F- zc9jt>&3w@b&Oa=4@l3Py&($B{wD9{v3AGpI$?p9ndEH&epLOF98}jdOb?4WB4VeXC zy+~^(9qrva?d%`mVuHZ{OXEYnG08iTyyi?^}P{E_Gw# zjn#Bv`t!td4E2;hv)nU(f4RtHHQK1p8G%RchBH?O?DzYe@#e2f0&LD|&!ec*Km2y> z`Rh}6VM!Tw0(I32 z?>kFsdR_;J3Az(WZbLT!vmrL#9BGw35VOI|+jtc8=ZWG$+s#!t=r@P6t0azy@B8l? z$T*TAesE_wSbb){B`Eq1PM+X1*+-44k*ax!1T(Cs=~Tv=Lf}fOJ`euGsqw3U=X=Wz zE64BuDQ->4QrFV&$%a*XxX4m_9$Y+!wlMfKKX^^S zhFN5jT}yXX_3IyTD6rdBt#hNlet1^<8#pt-SjRH;$#a*u!z=IqJ3Hh%C?+{?cP733 zxkmG|exPBz1WKlJB%uQ*WPP@wAV~*A3`0&s&`|#xaIis@1%giW2W$a`#aAe`2mt=j zAk_J`A>Mf2cH8TCVgtib&U~q2RXLI3KBy>L0OE{pqG$7VFWR?6|55!xGl+5ob$0ez zriI+`X#uVG1)QK#yBw;!B@B zx4=OV3z!YhSGQI=W#%?saFuA2K~FlSvh647rf!2aj_i{}c(^OS+cJJo&AbX6{ILAI zV|crw<(d@130DBTw**AZ)Bye!B$tu&<{L@9ykz5?=4LUnGONV{EXdX@i zhW{eS5yRF)Av(z-7>5uIvgP~>0CB@-#2TJ=VQNhJEZR8|niR1LezLI$BHq{fo>LjD z`?sL3cE^6Z(rBp3!X>Wfv&)LhKyC?ctqT26-djsKkYzh5ql-#>hYn2udyJ zR$r_BT!){;>9xAUL0e2~$lt!e;A~M~0TvhobE^QTjRp@u5Lq&G=mjHUb73)Q&O5Um z(4Wk}@fKV!I1A)xeDeTdViDAhtL`IxAp#7n6vFS$ud$v~ZWAz1_LwLDlL=y=V)!zp zji1{%jG1{8F;=n0)+ zStuUDvuNa;Uis}ve2L=1h_;f^i;WlHgKFzmhK?v-k-kdH~=x4JPrN( zG!@aJb5(fIXLAiIo)4zH`1p7#BbFO{=oIhLqtgc3hVDa^UNH3_uj_p}MpyzOKDB`k z?YjY;nylM^TEz|U9sL|N6)*>%9I_1x%^L5j_>!TtTnro=Q9QgUcd`obS#Z}c|4|L% z-M4dqnPpJzgqYeWXyfKMAvQ1d;{XCMuc1Z+AuKovy&;GP2Ie0_lNFw^#$18&|3M}a z_;v1O%W8;=2Bpn6!oPs7Z2bo{jmJf!!0>{cbZe1%BG_TANkO!HAB0dr9r#^7OzWSY zwgP*kI}vY@>_iVN`-(SC&E<-Yvqc6nPjCrq(JdFhzOYZpQOWlVxn8kA*fb+** ztzvWzeMZmrZZqW*OIX)rdiAJtG%4;kXiqB{CBL57 z7QY!6O!;9^jGKNxyI#6|uDL#GCn8<)lZv@6_ZLzUrUEbEu2~z!Yt^pbqwys{1BBl0 zHyoQAU<73cyH1*bQh+ONx!?$ZdvlNllS@B>^m`!ou6FCpeLw;!n( zXQ;RF$z>HJH|r}v@r?@FKSJcS#>R=nJRgFMz)9s|YpU<&KHmb{V5Asb>#TzI8*Ncz zVUjh5eD%({(irM5=WP7zp%xmV>XXO*-#NEm{&}PR0ro~sz;Tf`iL3UfUHypzGvII+ zT$RBABK2z4Vn@UkVM#;S9Em;pqf8P4n5cV&nl}(*^uuvo4u+ShRgF{XHXx`KNQ6gq z?Er#meL(iD&)7T=kr;@WCq`Mqx)^mTGbg@UgWymy$f1pL0r{nZXD$c)$0eDMXL!A* z)cPhvtzR(hHA(V#q=3FkT%w13U4le`t-=J)MLVi-0M@6ar|55hE#lNNaJgroei_{j z6y$zNp+4VOyS{+k;$u{p_sf^>S<4u{if^#6F3+)sl&} zAc(}-xe9ES4o8iw>F;wzockkqSdcgm6^L@Q-i~ie5E^n%EB00|vn03FyFY88!YeQF2;M1%I zrFo>wBe)csdq)b2RZS_{S$ZZgZ7R(Qb`QSJR-~jT@L0No(n%uP?hgHsLO;FOFh{li-&HPP2*A=2qe?8)mU zE-?ji0@l*FB2Ij`n(%5u%p-yB=B(o?if8*ZZE*~9TXJS=(8!PT1%?2rG6UzGS)*|{JuE@p{OdwdbPd7QrCY&v(aKG z8B7B>?>V{Y7kp?&XiV-+@;*Vt*4ApRs;-+zbe%^iZA}w1t|j&iY_qV$<=VGGhc{6DeMt{1U_-a4p|wKWoWv3}s{dsAk7KVp#`8+uGYCQbGti-4mu0?a zLCVa!$!NKhg<+8N_0Hi}Q5)!|ozL(AlM${I0{uug=H8$svfUJ$N{_VuI0r^;tECdXC1q@h47jYQniO6^#TQE@)VZ)k_$wxGj85@e(R%Zk z6uIsN0G#ON^(IE1tk&6L?(S4RZ8L;kE6|jU`6QR&cC8Q|xLQITqH~5N8V8O9NofV$ z#kzuk2Yg=axSQ|)S@QH#@6;sTzpY+yedWB~;3%g5E%yvPJ7yY`spu99Ef0C6w!r^t zzSeGbW2qJIdz9mLra9dYF;2hlpnE=S+CmFp#>q~taJtj--+AjZNr|(iY_IjFeq0$5 z4_phIePZ?o3-9tL|J<_;FPI8>9AE!)KZB9|Ua_;q7i_j~^veFm|E-2|3HzmO{g6hl zgone@FAiGY;Qe@?=}f*ybJ?Z~7&yLj&^>LIa`#0`Cg;T9-dPR$x(8FH9ra-S0=niT zDmVIm#dr5>KhGNRfChTAp1uO@r3_0gK6H-dXr!3RU$s4Fz6I!Jv&sV(kD0RgG0aI= zzg;!ON23CCKEs8Nz_vx{NvZvyb$7h3o5cza?F-qY#^ZCfY+CXOA#P6n%^_~Wlb-L; z`90xvY7OwVpAFx)g%km6@w=u9=L}cM`8_SUmM8b>PX%a?VCwPm>v#HMH_N!>-)G)? z&PWzCgde-uBJ{*Zj|uY|Oc!P}{m^MP3h`#md#37^8EL1Q_{YI5(%L3oEv0heJ$=qi ze5Fm#LL83ku|=NV75RJT_g|dfiq8Od*8Pq8JLm1m!+-BRu8!v~o2#?i@Y1x(6UH;N z-iDlhetPns`wYO#pL}cIxo3Txeg99|0q+d^j{_3#IyIfdPhmlEH=!Dg| z;N7W~Eg=f&Gh0H!)feQfiVV&KI%@i@jAIiX3!gDPt!eUm_I}|NY6)jQ3I3F=R9S0T zw;_46j`OkWe-^j+X55ZZer|rcLnY^2*|bK96{-Pee{&vix7v5FuFcB+^zNX{e1+X>XvjDuq#@lTxs1KBB^21Dn<3Yh#DnC~5{(5xyPRWU zVZ9buQi3RP03Db2Z3AMM1U_B@V#osbFIgYCisLZ7Ex?{vE`KzVUGUx-a2wb{U^jcz zT5xEO1~MWGjwUHY1{}>m@FB_3;sR0djuscA#RaxXm4?N|AMqnKcTQZra{WI85O})! KxvXQfA literal 0 HcmV?d00001 diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 2b0bb00929..ccf06b17bd 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -81,7 +81,10 @@ import { TwoColumnLayout } from './components/scaffolder/customScaffolderLayouts import { customDevToolsPage } from './components/devtools/CustomDevToolsPage'; import { DevToolsPage } from '@backstage/plugin-devtools'; import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities'; -import { NotificationsPage } from '@backstage/plugin-notifications'; +import { + NotificationsPage, + UserNotificationSettingsCard, +} from '@backstage/plugin-notifications'; const app = createApp({ apis, @@ -206,6 +209,11 @@ const routes = ( + + + }> {customDevToolsPage} diff --git a/packages/backend/package.json b/packages/backend/package.json index 34de57574d..0c9ce14e1a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -54,6 +54,7 @@ "@backstage/plugin-proxy-backend": "workspace:^", "@backstage/plugin-scaffolder-backend": "workspace:^", "@backstage/plugin-scaffolder-backend-module-github": "workspace:^", + "@backstage/plugin-scaffolder-backend-module-notifications": "workspace:^", "@backstage/plugin-search-backend": "workspace:^", "@backstage/plugin-search-backend-module-catalog": "workspace:^", "@backstage/plugin-search-backend-module-explore": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b9130d452f..d8a4bcb98f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -49,6 +49,9 @@ backend.add(import('@backstage/plugin-permission-backend')); backend.add(import('@backstage/plugin-proxy-backend')); backend.add(import('@backstage/plugin-scaffolder-backend')); backend.add(import('@backstage/plugin-scaffolder-backend-module-github')); +backend.add( + import('@backstage/plugin-scaffolder-backend-module-notifications'), +); backend.add( import('@backstage/plugin-catalog-backend-module-backstage-openapi'), ); diff --git a/plugins/notifications-backend/migrations/20240808_settings.js b/plugins/notifications-backend/migrations/20240808_settings.js new file mode 100644 index 0000000000..4d4fbc24cd --- /dev/null +++ b/plugins/notifications-backend/migrations/20240808_settings.js @@ -0,0 +1,34 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +exports.up = async function up(knex) { + await knex.schema.createTable('user_settings', table => { + table.string('user').notNullable(); + table.string('channel').notNullable(); + table.string('origin').notNullable(); + table.boolean('enabled').defaultTo(true).notNullable(); + table.index(['user'], 'user_settings_user_idx'); + table.unique(['user', 'channel', 'origin'], { + indexName: 'user_settings_unique_idx', + }); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('user_settings'); +}; diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts index 0c7193c1fc..90dd3932d0 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts @@ -18,6 +18,7 @@ import { DatabaseNotificationsStore } from './DatabaseNotificationsStore'; import { Knex } from 'knex'; import { Notification, + NotificationSettings, NotificationSeverity, } from '@backstage/plugin-notifications-common'; @@ -151,6 +152,23 @@ const otherUserNotification: Notification = { severity: 'normal', }, }; +const notificationSettings: NotificationSettings = { + channels: [ + { + id: 'Web', + origins: [ + { + id: 'plugin-test', + enabled: true, + }, + { + id: 'plugin-test2', + enabled: false, + }, + ], + }, + ], +}; describe.each(databases.eachSupportedId())( 'DatabaseNotificationsStore (%s)', @@ -712,5 +730,18 @@ describe.each(databases.eachSupportedId())( expect(notification?.saved).toBeNull(); }); }); + + describe('settings', () => { + it('should save and load notification settings', async () => { + await storage.saveNotificationSettings({ + user: 'user:default/test', + settings: notificationSettings, + }); + const settings = await storage.getNotificationSettings({ + user: 'user:default/test', + }); + expect(settings).toEqual(notificationSettings); + }); + }); }, ); diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index 40a76c7486..3a00d1c9b5 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PluginDatabaseManager } from '@backstage/backend-common'; -import { resolvePackagePath } from '@backstage/backend-plugin-api'; +import { + DatabaseService, + resolvePackagePath, +} from '@backstage/backend-plugin-api'; import { NotificationGetOptions, NotificationModifyOptions, @@ -22,8 +24,9 @@ import { } from './NotificationsStore'; import { Notification, - NotificationSeverity, + NotificationSettings, notificationSeverities, + NotificationSeverity, } from '@backstage/plugin-notifications-common'; import { Knex } from 'knex'; @@ -49,6 +52,50 @@ const NOTIFICATION_COLUMNS = [ 'saved', ]; +type NotificationRowType = { + id: string; + user: string; + title: string; + description?: string | null; + severity: string; + link: string | null; + origin: string; + scope: string | null; + topic: string | null; + created: Date; + updated: Date | null; + read: Date | null; + saved: Date | null; + icon: string | null; +}; + +type BroadcastRowType = { + id: string; + title: string; + description: string | null; + link: string | null; + origin: string; + scope: string | null; + topic: string | null; + created: Date; + updated: Date | null; + icon: string | null; +}; + +type BroadcastUserStatusRowType = { + broadcast_id: string; + user: string; + read: Date | null; + saved: Date | null; +}; + +type UserSettingsRowType = { + user: string; + channel: string; + origin: string; + enabled: boolean; +}; + export const normalizeSeverity = (input?: string): NotificationSeverity => { let lower = (input ?? 'normal').toLowerCase() as NotificationSeverity; if (notificationSeverities.indexOf(lower) < 0) { @@ -69,7 +116,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { database, skipMigrations, }: { - database: PluginDatabaseManager; + database: DatabaseService; skipMigrations?: boolean; }): Promise { const client = await database.getClient(); @@ -108,6 +155,29 @@ export class DatabaseNotificationsStore implements NotificationsStore { })); }; + private mapToNotificationSettings = (rows: any[]): NotificationSettings => { + return rows.reduce( + (acc, row) => { + let chan = acc.channels.find( + (channel: { id: string }) => channel.id === row.channel, + ); + if (!chan) { + acc.channels.push({ + id: row.channel, + origins: [], + }); + chan = acc.channels[acc.channels.length - 1]; + } + chan.origins.push({ + id: row.origin, + enabled: Boolean(row.enabled), + }); + return acc; + }, + { channels: [] }, + ); + }; + private mapNotificationToDbRow = (notification: Notification) => { return { id: notification.id, @@ -142,7 +212,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { }; private getBroadcastUnion = (user?: string | null) => { - return this.db('broadcast') + return this.db('broadcast') .leftJoin('broadcast_user_status', function clause() { const join = this.on('id', '=', 'broadcast_user_status.broadcast_id'); if (user !== null && user !== undefined) { @@ -157,7 +227,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { ) => { const { user, orderField } = options; - const subQuery = this.db('notification') + const subQuery = this.db('notification') .select(NOTIFICATION_COLUMNS) .unionAll([this.getBroadcastUnion(user)]) .as('notifications'); @@ -295,7 +365,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { scope: string; origin: string; }) { - const query = this.db('notification') + const query = this.db('notification') .where('user', options.user) .where('scope', options.scope) .where('origin', options.origin) @@ -305,11 +375,11 @@ export class DatabaseNotificationsStore implements NotificationsStore { if (!rows || rows.length === 0) { return null; } - return rows[0] as Notification; + return this.mapToNotifications(rows)[0]; } async getExistingScopeBroadcast(options: { scope: string; origin: string }) { - const query = this.db('broadcast') + const query = this.db('broadcast') .where('scope', options.scope) .where('origin', options.origin) .limit(1); @@ -318,7 +388,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { if (!rows || rows.length === 0) { return null; } - return rows[0] as Notification; + return this.mapToNotifications(rows)[0]; } async restoreExistingNotification({ @@ -358,7 +428,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { const rows = await this.db .select('*') .from( - this.db('notification') + this.db('notification') .select(NOTIFICATION_COLUMNS) .unionAll([this.getBroadcastUnion(options.user)]) .as('notifications'), @@ -377,7 +447,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { read?: Date | null, saved?: Date | null, ) => { - await this.db('notification') + await this.db('notification') .whereIn('id', ids) .where('user', user) .update({ read, saved }); @@ -388,7 +458,7 @@ export class DatabaseNotificationsStore implements NotificationsStore { if (broadcasts.length > 0) if (!this.isSQLite) { - await this.db('broadcast_user_status') + await this.db('broadcast_user_status') .insert( broadcasts.map(b => ({ broadcast_id: b.id, @@ -402,7 +472,9 @@ export class DatabaseNotificationsStore implements NotificationsStore { } else { // SQLite does not support upsert so fall back to this (mostly for tests and local dev) for (const b of broadcasts) { - const baseQuery = this.db('broadcast_user_status') + const baseQuery = this.db( + 'broadcast_user_status', + ) .where('broadcast_id', b.id) .where('user', user); const exists = await baseQuery.clone().limit(1).select().first(); @@ -432,4 +504,63 @@ export class DatabaseNotificationsStore implements NotificationsStore { async markUnsaved(options: NotificationModifyOptions): Promise { await this.markReadSaved(options.ids, options.user, undefined, null); } + + async getUserNotificationOrigins(options: { + user: string; + }): Promise<{ origins: string[] }> { + const rows: { origin: string }[] = await this.db( + 'notification', + ) + .where('user', options.user) + .select('origin') + .distinct(); + return { origins: rows.map(row => row.origin) }; + } + + async getNotificationSettings(options: { + user: string; + origin?: string; + channel?: string; + }): Promise { + const settingsQuery = this.db('user_settings').where( + 'user', + options.user, + ); + if (options.origin) { + settingsQuery.where('origin', options.origin); + } + + if (options.channel) { + settingsQuery.where('channel', options.channel); + } + const settings = await settingsQuery.select(); + return this.mapToNotificationSettings(settings); + } + + async saveNotificationSettings(options: { + user: string; + settings: NotificationSettings; + }): Promise { + const rows: { + user: string; + channel: string; + origin: string; + enabled: boolean; + }[] = []; + options.settings.channels.map(channel => { + channel.origins.map(origin => { + rows.push({ + user: options.user, + channel: channel.id, + origin: origin.id, + enabled: origin.enabled, + }); + }); + }); + + await this.db('user_settings') + .where('user', options.user) + .delete(); + await this.db('user_settings').insert(rows); + } } diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index 3c57d0ac4a..2560406e20 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -16,6 +16,7 @@ import { Notification, + NotificationSettings, NotificationSeverity, NotificationStatus, } from '@backstage/plugin-notifications-common'; @@ -83,4 +84,17 @@ export interface NotificationsStore { markSaved(options: NotificationModifyOptions): Promise; markUnsaved(options: NotificationModifyOptions): Promise; + + getUserNotificationOrigins(options: { + user: string; + }): Promise<{ origins: string[] }>; + + getNotificationSettings(options: { + user: string; + }): Promise; + + saveNotificationSettings(options: { + user: string; + settings: NotificationSettings; + }): Promise; } diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 912651ab34..88cd1fd337 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -37,10 +37,11 @@ import { } from '@backstage/backend-plugin-api'; import { SignalsService } from '@backstage/plugin-signals-node'; import { + isNotificationsEnabledFor, NewNotificationSignal, Notification, - NotificationPayload, NotificationReadSignal, + NotificationSettings, notificationSeverities, NotificationStatus, } from '@backstage/plugin-notifications-common'; @@ -77,6 +78,7 @@ export async function createRouter( signals, } = options; + const WEB_NOTIFICATION_CHANNEL = 'Web'; const store = await DatabaseNotificationsStore.create({ database }); const frontendBaseUrl = config.getString('app.baseUrl'); @@ -86,10 +88,62 @@ export async function createRouter( return info.userEntityRef; }; - const filterProcessors = (payload: NotificationPayload) => { + const getNotificationChannels = () => { + return [WEB_NOTIFICATION_CHANNEL, ...processors.map(p => p.getName())]; + }; + + const getNotificationSettings = async (user: string) => { + const { origins } = await store.getUserNotificationOrigins({ user }); + const settings = await store.getNotificationSettings({ user }); + const channels = getNotificationChannels(); + + const response: NotificationSettings = { + channels: channels.map(channel => { + const channelSettings = settings.channels.find(c => c.id === channel); + if (channelSettings) { + return channelSettings; + } + return { + id: channel, + origins: origins.map(origin => ({ + id: origin, + enabled: true, + })), + }; + }), + }; + return response; + }; + + const isNotificationsEnabled = async (opts: { + user: string; + channel: string; + origin: string; + }) => { + const settings = await getNotificationSettings(opts.user); + return isNotificationsEnabledFor(settings, opts.channel, opts.origin); + }; + + const filterProcessors = async ( + notification: + | Notification + | ({ origin: string; user: null } & NotificationSendOptions), + ) => { const result: NotificationProcessor[] = []; + const { payload, user, origin } = notification; for (const processor of processors) { + if (user) { + const enabled = await isNotificationsEnabled({ + user, + origin, + channel: processor.getName(), + }); + if (!enabled) { + continue; + } + } + if (processor.getNotificationFilters) { const filters = processor.getNotificationFilters(); if (filters.minSeverity) { @@ -122,8 +176,11 @@ export async function createRouter( return result; }; - const processOptions = async (opts: NotificationSendOptions) => { - const filtered = filterProcessors(opts.payload); + const processOptions = async ( + opts: NotificationSendOptions, + origin: string, + ) => { + const filtered = await filterProcessors({ ...opts, origin, user: null }); let ret = opts; for (const processor of filtered) { try { @@ -143,7 +200,7 @@ export async function createRouter( notification: Notification, opts: NotificationSendOptions, ) => { - const filtered = filterProcessors(notification.payload); + const filtered = await filterProcessors(notification); let ret = notification; for (const processor of filtered) { try { @@ -163,7 +220,7 @@ export async function createRouter( notification: Notification, opts: NotificationSendOptions, ) => { - const filtered = filterProcessors(notification.payload); + const filtered = await filterProcessors(notification); for (const processor of filtered) { if (processor.postProcess) { try { @@ -261,6 +318,33 @@ export async function createRouter( res.json(status); }); + router.get( + '/settings', + async (req: Request, res) => { + const user = await getUser(req); + const response = await getNotificationSettings(user); + res.json(response); + }, + ); + + router.post( + '/settings', + async ( + req: Request, + res, + ) => { + const user = await getUser(req); + const channels = getNotificationChannels(); + const settings: NotificationSettings = req.body; + if (settings.channels.some(c => !channels.includes(c.id))) { + throw new InputError('Invalid channel'); + } + await store.saveNotificationSettings({ user, settings }); + const response = await getNotificationSettings(user); + res.json(response); + }, + ); + // Make sure this is the last "GET" handler router.get('/:id', async (req, res) => { const user = await getUser(req); @@ -380,37 +464,45 @@ export async function createRouter( }; const notification = await preProcessNotification(userNotification, opts); - let existingNotification; - if (scope) { - existingNotification = await store.getExistingScopeNotification({ - user, - scope, - origin, - }); - } + const enabled = await isNotificationsEnabled({ + user, + channel: WEB_NOTIFICATION_CHANNEL, + origin: userNotification.origin, + }); let ret = notification; - if (existingNotification) { - const restored = await store.restoreExistingNotification({ - id: existingNotification.id, - notification, - }); - ret = restored ?? notification; - } else { - await store.saveNotification(notification); - } + if (enabled) { + let existingNotification; + if (scope) { + existingNotification = await store.getExistingScopeNotification({ + user, + scope, + origin, + }); + } - notifications.push(ret); + if (existingNotification) { + const restored = await store.restoreExistingNotification({ + id: existingNotification.id, + notification, + }); + ret = restored ?? notification; + } else { + await store.saveNotification(notification); + } - if (signals) { - await signals.publish({ - recipients: { type: 'user', entityRef: [user] }, - message: { - action: 'new_notification', - notification_id: ret.id, - }, - channel: 'notifications', - }); + notifications.push(ret); + + if (signals) { + await signals.publish({ + recipients: { type: 'user', entityRef: [user] }, + message: { + action: 'new_notification', + notification_id: ret.id, + }, + channel: 'notifications', + }); + } } postProcessNotification(ret, opts); } @@ -421,16 +513,16 @@ export async function createRouter( router.post( '/', async (req: Request, res) => { - const opts = await processOptions(req.body); - const { recipients, payload } = opts; - const notifications: Notification[] = []; - let users = []; - const credentials = await httpAuth.credentials(req, { allow: ['service'], }); + const origin = credentials.principal.subject; + const opts = await processOptions(req.body, origin); + const { recipients, payload } = opts; const { title, link } = payload; + const notifications: Notification[] = []; + let users = []; if (!recipients || !title) { logger.error(`Invalid notification request received`); @@ -445,7 +537,6 @@ export async function createRouter( } } - const origin = credentials.principal.subject; const baseNotification = { payload: { ...payload, diff --git a/plugins/notifications-common/report.api.md b/plugins/notifications-common/report.api.md index 0f59a85849..167b37349d 100644 --- a/plugins/notifications-common/report.api.md +++ b/plugins/notifications-common/report.api.md @@ -10,6 +10,13 @@ export const getProcessorFiltersFromConfig: ( config: Config, ) => NotificationProcessorFilters; +// @public (undocumented) +export const isNotificationsEnabledFor: ( + settings: NotificationSettings, + channelId: string, + originId: string, +) => boolean; + // @public (undocumented) export type NewNotificationSignal = { action: 'new_notification'; @@ -53,6 +60,17 @@ export type NotificationReadSignal = { notification_ids: string[]; }; +// @public (undocumented) +export type NotificationSettings = { + channels: { + id: string; + origins: { + id: string; + enabled: boolean; + }[]; + }[]; +}; + // @public export const notificationSeverities: NotificationSeverity[]; diff --git a/plugins/notifications-common/src/index.ts b/plugins/notifications-common/src/index.ts index 9fc7bde2b8..b1036b5da7 100644 --- a/plugins/notifications-common/src/index.ts +++ b/plugins/notifications-common/src/index.ts @@ -23,3 +23,4 @@ export * from './types'; export * from './constants'; export * from './filters'; +export * from './utils'; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index a4be3e4255..237f93f40a 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -125,3 +125,16 @@ export type NotificationProcessorFilters = { maxSeverity?: NotificationSeverity; excludedTopics?: string[]; }; + +/** + * @public + */ +export type NotificationSettings = { + channels: { + id: string; + origins: { + id: string; + enabled: boolean; + }[]; + }[]; +}; diff --git a/plugins/notifications-common/src/utils.ts b/plugins/notifications-common/src/utils.ts new file mode 100644 index 0000000000..c57412bd97 --- /dev/null +++ b/plugins/notifications-common/src/utils.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NotificationSettings } from './types'; + +/** @public */ +export const isNotificationsEnabledFor = ( + settings: NotificationSettings, + channelId: string, + originId: string, +) => { + const channel = settings.channels.find(c => c.id === channelId); + if (!channel) { + return true; + } + + const origin = channel.origins.find(o => o.id === originId); + if (!origin) { + return true; + } + return origin.enabled; +}; diff --git a/plugins/notifications/report.api.md b/plugins/notifications/report.api.md index 5e06ae2616..2f2357e479 100644 --- a/plugins/notifications/report.api.md +++ b/plugins/notifications/report.api.md @@ -12,6 +12,7 @@ import { FetchApi } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react'; import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; +import { NotificationSettings } from '@backstage/plugin-notifications-common'; import { NotificationSeverity } from '@backstage/plugin-notifications-common'; import { NotificationStatus } from '@backstage/plugin-notifications-common'; import { default as React_2 } from 'react'; @@ -46,11 +47,17 @@ export interface NotificationsApi { options?: GetNotificationsOptions, ): Promise; // (undocumented) + getNotificationSettings(): Promise; + // (undocumented) getStatus(): Promise; // (undocumented) updateNotifications( options: UpdateNotificationsOptions, ): Promise; + // (undocumented) + updateNotificationSettings( + settings: NotificationSettings, + ): Promise; } // @public (undocumented) @@ -66,11 +73,17 @@ export class NotificationsClient implements NotificationsApi { options?: GetNotificationsOptions, ): Promise; // (undocumented) + getNotificationSettings(): Promise; + // (undocumented) getStatus(): Promise; // (undocumented) updateNotifications( options: UpdateNotificationsOptions, ): Promise; + // (undocumented) + updateNotificationSettings( + settings: NotificationSettings, + ): Promise; } // @public (undocumented) @@ -176,5 +189,10 @@ export function useNotificationsApi( value: T; }; +// @public (undocumented) +export const UserNotificationSettingsCard: (props: { + originNames?: Record; +}) => React_2.JSX.Element; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts index 234e86ff13..e24e5cad64 100644 --- a/plugins/notifications/src/api/NotificationsApi.ts +++ b/plugins/notifications/src/api/NotificationsApi.ts @@ -16,6 +16,7 @@ import { createApiRef } from '@backstage/core-plugin-api'; import { Notification, + NotificationSettings, NotificationSeverity, NotificationStatus, } from '@backstage/plugin-notifications-common'; @@ -64,4 +65,10 @@ export interface NotificationsApi { updateNotifications( options: UpdateNotificationsOptions, ): Promise; + + getNotificationSettings(): Promise; + + updateNotificationSettings( + settings: NotificationSettings, + ): Promise; } diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts index 10ad58bd92..983065f96e 100644 --- a/plugins/notifications/src/api/NotificationsClient.ts +++ b/plugins/notifications/src/api/NotificationsClient.ts @@ -23,6 +23,7 @@ import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { Notification, + NotificationSettings, NotificationStatus, } from '@backstage/plugin-notifications-common'; @@ -93,6 +94,20 @@ export class NotificationsClient implements NotificationsApi { }); } + async getNotificationSettings(): Promise { + return await this.request('settings'); + } + + async updateNotificationSettings( + settings: NotificationSettings, + ): Promise { + return await this.request('settings', { + method: 'POST', + body: JSON.stringify(settings), + headers: { 'Content-Type': 'application/json' }, + }); + } + private async request(path: string, init?: any): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('notifications')}/`; const url = new URL(path, baseUrl); diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx new file mode 100644 index 0000000000..81d109e5cc --- /dev/null +++ b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { ErrorPanel, InfoCard, Progress } from '@backstage/core-components'; +import { useNotificationsApi } from '../../hooks'; +import { NotificationSettings } from '@backstage/plugin-notifications-common'; +import { notificationsApiRef } from '../../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { UserNotificationSettingsPanel } from './UserNotificationSettingsPanel'; + +/** @public */ +export const UserNotificationSettingsCard = (props: { + originNames?: Record; +}) => { + const [settings, setNotificationSettings] = React.useState< + NotificationSettings | undefined + >(undefined); + + const client = useApi(notificationsApiRef); + const { error, value, loading } = useNotificationsApi(api => { + return api.getNotificationSettings(); + }); + + useEffect(() => { + if (!loading && !error) { + setNotificationSettings(value); + } + }, [loading, value, error]); + + const onUpdate = (newSettings: NotificationSettings) => { + client + .updateNotificationSettings(newSettings) + .then(updatedSettings => setNotificationSettings(updatedSettings)); + }; + + return ( + + {loading && } + {error && } + {settings && ( + + )} + + ); +}; diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx new file mode 100644 index 0000000000..dc9f2a223f --- /dev/null +++ b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx @@ -0,0 +1,140 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + isNotificationsEnabledFor, + NotificationSettings, +} from '@backstage/plugin-notifications-common'; +import Table from '@material-ui/core/Table'; +import MuiTableCell from '@material-ui/core/TableCell'; +import { withStyles } from '@material-ui/core/styles'; +import TableHead from '@material-ui/core/TableHead'; +import Typography from '@material-ui/core/Typography'; +import TableBody from '@material-ui/core/TableBody'; +import TableRow from '@material-ui/core/TableRow'; +import Switch from '@material-ui/core/Switch'; +import { capitalize } from 'lodash'; +import Tooltip from '@material-ui/core/Tooltip'; + +const TableCell = withStyles({ + root: { + borderBottom: 'none', + }, +})(MuiTableCell); + +export const UserNotificationSettingsPanel = (props: { + settings: NotificationSettings; + onChange: (settings: NotificationSettings) => void; + originNames?: Record; +}) => { + const { settings, onChange } = props; + const allOrigins = [ + ...new Set( + settings.channels.flatMap(channel => + channel.origins.map(origin => origin.id), + ), + ), + ]; + + const handleChange = ( + channelId: string, + originId: string, + enabled: boolean, + ) => { + const updatedSettings = { + channels: settings.channels.map(channel => { + if (channel.id !== channelId) { + return channel; + } + return { + ...channel, + origins: channel.origins.map(origin => { + if (origin.id !== originId) { + return origin; + } + return { + ...origin, + enabled, + }; + }), + }; + }), + }; + onChange(updatedSettings); + }; + + const formatOriginName = (originId: string) => { + if (props.originNames && originId in props.originNames) { + return props.originNames[originId]; + } + return capitalize(originId.replaceAll(/[_:]/g, ' ')); + }; + + if (settings.channels.length === 0 || allOrigins.length === 0) { + return ( + + No notification settings available, check back later + + ); + } + + return ( + + + + + Origin + + {settings.channels.map(channel => ( + + {channel.id} + + ))} + + + + {allOrigins.map(origin => ( + + {formatOriginName(origin)} + {settings.channels.map(channel => ( + + + ) => { + handleChange(channel.id, origin, event.target.checked); + }} + /> + + + ))} + + ))} + +
+ ); +}; diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/index.ts b/plugins/notifications/src/components/UserNotificationSettingsCard/index.ts new file mode 100644 index 0000000000..98265b0be1 --- /dev/null +++ b/plugins/notifications/src/components/UserNotificationSettingsCard/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { UserNotificationSettingsCard } from './UserNotificationSettingsCard'; diff --git a/plugins/notifications/src/components/index.ts b/plugins/notifications/src/components/index.ts index 022ae07198..8aefb8835b 100644 --- a/plugins/notifications/src/components/index.ts +++ b/plugins/notifications/src/components/index.ts @@ -15,4 +15,5 @@ */ export * from './NotificationsSideBarItem'; export * from './NotificationsTable'; +export * from './UserNotificationSettingsCard'; export type { NotificationsPageProps } from './NotificationsPage'; diff --git a/plugins/scaffolder-backend/sample-templates/all-templates.yaml b/plugins/scaffolder-backend/sample-templates/all-templates.yaml index 6637c9479b..1e1fb5baf1 100644 --- a/plugins/scaffolder-backend/sample-templates/all-templates.yaml +++ b/plugins/scaffolder-backend/sample-templates/all-templates.yaml @@ -6,6 +6,7 @@ metadata: spec: targets: - ./remote-templates.yaml + - ./notifications-demo/template.yaml # For local development of a template, you can reference your local templates here. # Examples: # diff --git a/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml new file mode 100644 index 0000000000..a85f53b12f --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml @@ -0,0 +1,69 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: notifications-demo + title: Test Notifications template + description: scaffolder v1beta3 template demo sending notification +spec: + owner: backstage/techdocs-core + type: service + parameters: + - title: Notification + required: + - recipients + - title + properties: + recipients: + title: Recipients + type: string + description: Notification recipients + default: entity + enum: + - entity + - broadcast + entityRefs: + title: Entities + type: array + description: Entities to send the notification. Required if recipients is entity + ui:field: MultiEntityPicker + ui:options: + defaultNamespace: default + title: + title: Title + type: string + description: Notification title + description: + title: Description + type: string + description: Notification longer description + link: + title: Link + type: string + description: Notification link + severity: + title: Severity + type: string + description: Notification severity + default: normal + enum: + - low + - normal + - high + - critical + scope: + title: Scope + type: string + description: Notification scope + + steps: + - id: send-notification + name: Send notification + action: notification:send + input: + recipients: ${{ parameters.recipients }} + entityRefs: ${{ parameters.entityRefs }} + title: ${{ parameters.title }} + description: ${{ parameters.description }} + link: ${{ parameters.link }} + severity: ${{ parameters.severity }} + scope: ${{ parameters.scope }} diff --git a/yarn.lock b/yarn.lock index af909aa8c1..4d105cc198 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7212,7 +7212,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-scaffolder-backend-module-notifications@workspace:plugins/scaffolder-backend-module-notifications": +"@backstage/plugin-scaffolder-backend-module-notifications@workspace:^, @backstage/plugin-scaffolder-backend-module-notifications@workspace:plugins/scaffolder-backend-module-notifications": version: 0.0.0-use.local resolution: "@backstage/plugin-scaffolder-backend-module-notifications@workspace:plugins/scaffolder-backend-module-notifications" dependencies: @@ -27080,6 +27080,7 @@ __metadata: "@backstage/plugin-proxy-backend": "workspace:^" "@backstage/plugin-scaffolder-backend": "workspace:^" "@backstage/plugin-scaffolder-backend-module-github": "workspace:^" + "@backstage/plugin-scaffolder-backend-module-notifications": "workspace:^" "@backstage/plugin-search-backend": "workspace:^" "@backstage/plugin-search-backend-module-catalog": "workspace:^" "@backstage/plugin-search-backend-module-explore": "workspace:^"