From d7c71bef45889cc0d22733d80ca135cde818e64d Mon Sep 17 00:00:00 2001 From: ciukstar Date: Mon, 18 Dec 2023 03:23:43 +0300 Subject: [PATCH] Add html mail --- config/models.persistentmodels | 10 + messages/en.msg | 2 + messages/fr.msg | 2 + messages/ro.msg | 2 + messages/ru.msg | 2 + package.yaml | 3 + src/Admin/Billing.hs | 322 +++++++++++++++--- src/Model.hs | 16 + stack.yaml | 2 +- stack.yaml.lock | 8 +- static/img/Salon-ERD.svg | 579 ++++++++++++++++++++------------- static/img/salon.png | Bin 0 -> 11805 bytes 12 files changed, 679 insertions(+), 269 deletions(-) create mode 100644 static/img/salon.png diff --git a/config/models.persistentmodels b/config/models.persistentmodels index 80f2897..98b0890 100644 --- a/config/models.persistentmodels +++ b/config/models.persistentmodels @@ -1,4 +1,14 @@ +InvoiceMail + invoice InvoiceId OnDeleteCascade + status MailStatus + timemark UTCTime + recipient Text + recipientName Text Maybe + sender Text + senderName Text Maybe + subject Text + body Text ItemBook item ItemId OnDeleteCascade diff --git a/messages/en.msg b/messages/en.msg index 0b11403..9933347 100644 --- a/messages/en.msg +++ b/messages/en.msg @@ -1,3 +1,5 @@ +MessageNotSent: Message not sent +MessageSent: Message sent BodyEmail: Body SubjectEmail: Subject FromEmail: From diff --git a/messages/fr.msg b/messages/fr.msg index 662a1f6..e78fd19 100644 --- a/messages/fr.msg +++ b/messages/fr.msg @@ -1,3 +1,5 @@ +MessageNotSent: Message non envoyé +MessageSent: Message envoyé BodyEmail: Corps SubjectEmail: Sujet FromEmail: De diff --git a/messages/ro.msg b/messages/ro.msg index cfa1fae..0ea15dd 100644 --- a/messages/ro.msg +++ b/messages/ro.msg @@ -1,3 +1,5 @@ +MessageNotSent: Mesajul nu a fost trimis +MessageSent: Mesaj trimis BodyEmail: Mesaj SubjectEmail: Subiect FromEmail: De la diff --git a/messages/ru.msg b/messages/ru.msg index 7d20eef..8c64bb2 100644 --- a/messages/ru.msg +++ b/messages/ru.msg @@ -1,3 +1,5 @@ +MessageNotSent: Сообщение не отправлено +MessageSent: Сообщение отправлено BodyEmail: Текст SubjectEmail: Тема FromEmail: Отправитель diff --git a/package.yaml b/package.yaml index 4495147..59acf7f 100644 --- a/package.yaml +++ b/package.yaml @@ -53,6 +53,9 @@ dependencies: - lens-aeson - wreq - mime-mail +- http-client +- safe-exceptions +- HPDF # The library contains all of our application code. The executable diff --git a/src/Admin/Billing.hs b/src/Admin/Billing.hs index ffdea1f..993cba4 100644 --- a/src/Admin/Billing.hs +++ b/src/Admin/Billing.hs @@ -25,15 +25,19 @@ module Admin.Billing ) where import Control.Applicative ((<|>)) -import Control.Lens ((?~)) -import qualified Control.Lens as L ((^.),(^?)) +import Control.Exception.Safe + ( tryAny, SomeException (SomeException), Exception (fromException)) +import Control.Lens ((?~), sumOf, folded, to) +import qualified Control.Lens as L ((^.), (^?)) import Control.Monad (join) import Control.Monad.IO.Class (liftIO) import Data.Aeson (object, (.=)) import Data.Aeson.Lens (AsValue(_String), key, AsNumber (_Integer)) import Data.Bifunctor (Bifunctor(first, second)) import Data.ByteString (toStrict) +import qualified Data.ByteString.Lazy as L (ByteString) import Data.ByteString.Base64.Lazy (encode) +import Data.Complex (Complex ((:+))) import Data.Fixed (Centi) import Data.Function ((&)) import Data.Maybe (isJust) @@ -41,14 +45,30 @@ import Data.Text (Text, pack, unpack) import Data.Text.Encoding (encodeUtf8, decodeUtf8) import Data.Text.Lazy (fromStrict) import Data.Time.Clock (getCurrentTime, UTCTime (utctDay)) -import Network.Mail.Mime (Address (Address), simpleMail', renderMail') +import Graphics.PDF + ( pdfByteString, PDFDocumentInfo (author), standardViewerPrefs + , PDFRect (PDFRect), PDF, addPage, drawWithPage + , strokeColor, red, setWidth, Shape (stroke), Rectangle (Rectangle) + ) +import Graphics.PDF.Document + ( standardDocInfo, PDFDocumentInfo (subject, compressed, viewerPreferences) + , PDFViewerPreferences (displayDoctitle) + ) +import Network.Mail.Mime + ( Address (Address), simpleMail', renderMail', simpleMailInMemory + ) +import Network.HTTP.Client + ( HttpExceptionContent(StatusCodeException) + , HttpException (HttpExceptionRequest) + ) import Network.Wreq ( post, FormParam ((:=)), responseBody, responseStatus, statusCode , postWith, defaults, auth, oauth2Bearer ) -import Text.Blaze.Html (preEscapedToHtml) +import Text.Blaze.Html (preEscapedToHtml, toHtml) +import Text.Blaze.Html.Renderer.Text (renderHtml) import Text.Read (readMaybe) -import Text.Hamlet (Html) +import Text.Hamlet (Html, HtmlUrlI18n, ihamlet) import Yesod.Auth (Route (LoginR), maybeAuth) import Yesod.Core @@ -56,6 +76,7 @@ import Yesod.Core , MonadHandler (liftHandler), redirect, addMessageI, getMessages , getCurrentRoute, lookupPostParam, whamlet, RenderMessage (renderMessage) , getYesod, languages, lookupSession, getUrlRender, setSession + , getMessageRender, getUrlRenderParams ) import Yesod.Core.Widget (setTitleI) import Yesod.Form.Functions @@ -74,14 +95,14 @@ import Yesod.Form.Fields import Yesod.Persist ( Entity (Entity, entityVal, entityKey) - , PersistStoreWrite (insert_, replace, delete) + , PersistStoreWrite (insert_, replace, delete, insert) ) import Yesod.Persist.Core (YesodPersist(runDB)) import Database.Esqueleto.Experimental ( select, from, table, where_, innerJoin, on, Value (unValue, Value), max_, desc - , (==.), (^.), (:&)((:&)), (?.) + , (==.), (^.), (:&)((:&)), (?.), (=.) , orderBy, asc, fromSqlKey, selectOne, val, isNothing_, not_, just, leftJoin - , toSqlKey, subSelect, sum_, SqlExpr, coalesceDefault + , toSqlKey, subSelect, sum_, SqlExpr, coalesceDefault, update, set ) import Foundation @@ -108,7 +129,7 @@ import Foundation , MsgInvoiceItem, MsgOffer, MsgQuantity, MsgPrice, MsgTax, MsgVat , MsgAmount, MsgCurrency, MsgThumbnail, MsgRecordDeleted, MsgFromEmail , MsgActionCancelled, MsgRecordEdited, MsgSend, MsgToEmail, MsgBodyEmail - , MsgSubjectEmail + , MsgSubjectEmail, MsgMessageSent, MsgMessageNotSent, MsgAppName ), App (appSettings) ) @@ -126,7 +147,8 @@ import Model ( StaffName, InvoiceId, InvoiceCustomer, UserId, InvoiceStaff, StaffId , StaffUser, InvoiceNumber, ItemId, ItemInvoice, OfferService, ServiceId , ServiceName, ThumbnailService, ThumbnailAttribution, BusinessCurrency - , OfferId, ItemOffer, ItemAmount + , OfferId, ItemOffer, ItemAmount, InvoiceMailId, InvoiceMailStatus + , InvoiceMailTimemark ) , Staff (Staff, staffName, staffEmail), InvoiceId, Business (Business) , ItemId @@ -135,6 +157,13 @@ import Model , itemCurrency ) , Offer (Offer, offerQuantity, offerPrice), Service (Service), Thumbnail + , InvoiceMail + ( InvoiceMail, invoiceMailStatus, invoiceMailTimemark, invoiceMailRecipient + , invoiceMailSender, invoiceMailSubject, invoiceMailBody, invoiceMailInvoice + , invoiceMailRecipientName, invoiceMailSenderName + ) + , MailStatus (MailStatusDraft, MailStatusDelivered) + , _itemAmount ) import Menu (menu) @@ -150,7 +179,8 @@ getBillingMailHookR = do let googleClientSecret = appGoogleClientSecret app code <- runInputGet $ ireq textField "code" - + mid <- toSqlKey <$> runInputGet (ireq intField "state") + r <- liftIO $ post "https://oauth2.googleapis.com/token" [ "code" := code , "redirect_uri" := rndr BillingMailHookR @@ -170,18 +200,27 @@ getBillingMailHookR = do setSession gmailAccessToken accessToken setSession gmailRefreshToken refreshToken - let api = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send" - mail <- liftIO $ decodeUtf8 . toStrict . encode <$> renderMail' - ( simpleMail' - (Address (Just "Sergiu Starciuc") "ciukstar@gmail.com") - (Address (Just "Sergiu Starciuc") "ciukstar@gmail.com") - "Mail sample subect" "Mail sample body" - ) + invoiceMail <- runDB $ selectOne $ do + x <- from $ table @InvoiceMail + where_ $ x ^. InvoiceMailId ==. val mid + return x - let opts = defaults & auth ?~ oauth2Bearer (encodeUtf8 accessToken) - _ <- liftIO $ postWith opts api (object ["raw" .= mail]) + case invoiceMail of + Just (Entity _ (InvoiceMail iid _ _ recipient rname sender sname subj body)) -> do + mail <- liftIO $ decodeUtf8 . toStrict . encode <$> renderMail' + ( simpleMail' + (Address rname recipient) + (Address sname sender) + subj (fromStrict body) + ) - redirect $ AdminR AdmInvoicesR + let opts = defaults & auth ?~ oauth2Bearer (encodeUtf8 accessToken) + _ <- liftIO $ postWith opts gmailApi (object ["raw" .= mail]) + addMessageI info MsgMessageSent + redirect $ AdminR $ AdmInvoiceR iid + Nothing -> do + addMessageI info MsgMessageNotSent + redirect $ AdminR AdmInvoicesR postAdmInvoiceSendmailR :: InvoiceId -> Handler Html @@ -195,30 +234,108 @@ postAdmInvoiceSendmailR iid = do where_ $ x ^. InvoiceId ==. val iid return ((c,e),x) return (fst . fst <$> p,snd . fst <$> p,snd <$> p) - + + items <- runDB $ select $ do + x <- from $ table @Item + where_ $ x ^. ItemInvoice ==. val iid + return x + ((fr2,_),_) <- runFormPost $ formInvoiceSendmail customer employee invoice case fr2 of - FormSuccess (to,fr,subject,body) -> do + FormSuccess (recipient,sender,subj,body) -> do + + now <- liftIO getCurrentTime + + let imail = InvoiceMail + { invoiceMailInvoice = iid + , invoiceMailStatus = MailStatusDraft + , invoiceMailTimemark = now + , invoiceMailRecipient = recipient + , invoiceMailRecipientName = (userFullName . entityVal =<< customer) + <|> (userName . entityVal <$> customer) + , invoiceMailSender = sender + , invoiceMailSenderName = staffName . entityVal <$> employee + , invoiceMailSubject = subj + , invoiceMailBody = unTextarea body + } + + mid <- runDB $ insert imail + app <- appSettings <$> getYesod + let googleClientId = appGoogleClientId app + let googleClientSecret = appGoogleClientSecret app accessToken <- lookupSession gmailAccessToken - _refreshToken <- lookupSession gmailRefreshToken - mail <- liftIO $ decodeUtf8 . toStrict . encode <$> renderMail' - ( simpleMail' - (Address ((userFullName . entityVal =<< customer) <|> (userName . entityVal <$> customer)) to) - (Address (staffName . entityVal <$> employee) fr) - subject (fromStrict (unTextarea body)) - ) - - case accessToken of - Just token -> do - let api = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send" + refreshToken <- lookupSession gmailRefreshToken + + case (accessToken,refreshToken) of + (Just atoken,Just rtoken) -> do + msgRndr <- (toHtml .) <$> getMessageRender + urlRndr <- getUrlRenderParams + let m = simpleMailInMemory + (Address (invoiceMailRecipientName imail) (invoiceMailRecipient imail)) + (Address (invoiceMailSenderName imail) (invoiceMailSender imail)) + (invoiceMailSubject imail) + (fromStrict $ invoiceMailBody imail) + (renderHtml $ renderIvoiceBody customer employee invoice items msgRndr urlRndr) + [ ( "application/pdf", "invoice.pdf" + , renderIvoicePdf (invoicePdf customer employee invoice items) + ) + ] + mail <- liftIO $ decodeUtf8 . toStrict . encode <$> renderMail' m + + let opts = defaults & auth ?~ oauth2Bearer (encodeUtf8 atoken) + response <- liftIO $ tryAny $ postWith opts gmailApi (object ["raw" .= mail]) + + + case response of + Left e@(SomeException _) -> case fromException e of + Just (HttpExceptionRequest _ (StatusCodeException r' _)) -> do + case r' L.^. responseStatus . statusCode of + 401 -> do + refreshResponse <- liftIO $ post "https://oauth2.googleapis.com/token" + [ "refresh_token" := rtoken + , "client_id" := googleClientId + , "client_secret" := googleClientSecret + , "grant_type" := ("refresh_token" :: Text) + ] + + let newAccessToken = refreshResponse L.^. responseBody . key "access_token" . _String + + setSession gmailAccessToken newAccessToken + + _ <- liftIO $ postWith + (defaults & auth ?~ oauth2Bearer (encodeUtf8 newAccessToken)) + gmailApi + (object ["raw" .= mail]) + + now' <- liftIO getCurrentTime + runDB $ update $ \x -> do + set x [ InvoiceMailStatus =. val MailStatusDelivered + , InvoiceMailTimemark =. val now' + ] + where_ $ x ^. InvoiceMailId ==. val mid + addMessageI info MsgMessageSent + redirect $ AdminR $ AdmInvoiceR iid + _ -> do + addMessageI info MsgMessageNotSent + redirect $ AdminR $ AdmInvoiceR iid + _other -> do + addMessageI info MsgMessageNotSent + redirect $ AdminR $ AdmInvoiceR iid + Right _ok -> do + + now' <- liftIO getCurrentTime + runDB $ update $ \x -> do + set x [ InvoiceMailStatus =. val MailStatusDelivered + , InvoiceMailTimemark =. val now' + ] + where_ $ x ^. InvoiceMailId ==. val mid + addMessageI info MsgMessageSent + + redirect $ AdminR $ AdmInvoiceR iid + + _notokes -> do - let opts = defaults & auth ?~ oauth2Bearer (encodeUtf8 token) - _ <- liftIO $ postWith opts api (object ["raw" .= mail]) - redirect $ AdminR $ AdmInvoiceR iid - - Nothing -> do - googleClientId <- appGoogleClientId . appSettings <$> getYesod rndr <- getUrlRender r <- liftIO $ post "https://accounts.google.com/o/oauth2/v2/auth" [ "redirect_uri" := rndr BillingMailHookR @@ -227,12 +344,125 @@ postAdmInvoiceSendmailR iid = do , "client_id" := googleClientId , "scope" := ("https://www.googleapis.com/auth/gmail.send" :: Text) , "access_type" := ("offline" :: Text) + , "state" := pack (show $ fromSqlKey mid) ] - - return $ preEscapedToHtml $ decodeUtf8 $ toStrict (r L.^. responseBody) - _ -> redirect $ AdminR $ AdmInvoiceR iid + + return $ preEscapedToHtml $ decodeUtf8 $ toStrict (r L.^. responseBody) + _formNotSuccess -> redirect $ AdminR $ AdmInvoiceR iid + + +invoicePdf :: Maybe (Entity User) + -> Maybe (Entity Staff) + -> Maybe (Entity Invoice) + -> [Entity Item] + -> PDF () +invoicePdf customer employee invoice items = do + page <- addPage Nothing + drawWithPage page $ do + strokeColor red + setWidth 0.5 + stroke $ Rectangle (10 :+ 0) (200 :+ 300) + + +renderIvoicePdf :: PDF () -> L.ByteString +renderIvoicePdf = pdfByteString + standardDocInfo { author = "Sergiu Starciuc" + , subject = "Invoice" + , compressed = True + , viewerPreferences = standardViewerPrefs { displayDoctitle = True } + } + (PDFRect 0 0 612 792) + + +renderIvoiceBody :: Maybe (Entity User) + -> Maybe (Entity Staff) + -> Maybe (Entity Invoice) + -> [Entity Item] + -> HtmlUrlI18n AppMessage (Route App) +renderIvoiceBody customer employee invoice items = [ihamlet| +

_{MsgAppName} +

_{MsgInvoice} +$maybe Entity _ (User uname _ _ _ _ _ cname cemail) <- customer + $maybe Entity _ (Staff ename _ _ _ eemail _) <- employee + $maybe Entity _ (Invoice _ _ no status day due) <- invoice +
+
_{MsgInvoiceNumber} +
#{no} + +
_{MsgBillTo} +
+ $maybe name <- cname + #{name} + $nothing + #{uname} + $maybe email <- cemail +
+ #{email} + +
_{MsgBilledFrom} +
+ #{ename} + $maybe email <- eemail +
+ #{email} + +
_{MsgInvoiceDate} +
#{show day} + +
_{MsgDueDate} +
+ $maybe due <- due + #{show due} + +
_{MsgStatus} +
+ $case status + $of InvoiceStatusDraft + _{MsgDraft} + $of InvoiceStatusOpen + _{MsgOpen} + $of InvoiceStatusPaid + _{MsgPaid} + $of InvoiceStatusUncollectible + _{MsgUncollectible} + $of InvoiceStatusVoid + _{MsgVoid} + +
_{MsgAmount} +
#{show amount} + +

_{MsgDetails} + + + + + $forall Entity _ (Item _ _ q p t v a c) <- items + +
_{MsgQuantity} + _{MsgPrice} + _{MsgTax} + _{MsgVat} + _{MsgAmount} + _{MsgCurrency} +
#{show q} + #{show p} + + $maybe tax <- t + #{show tax} + + $maybe vat <- v + #{show vat} + #{show a} + + $maybe currency <- c + #{currency} +|] + where + amount = items & sumOf (folded . to entityVal . _itemAmount) + + gmailAccessToken :: Text gmailAccessToken = "gmail_access_token" @@ -241,6 +471,10 @@ gmailRefreshToken :: Text gmailRefreshToken = "gmail_refresh_token" +gmailApi :: String +gmailApi = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send" + + formInvoiceSendmail :: Maybe (Entity User) -> Maybe (Entity Staff) -> Maybe (Entity Invoice) -> Html -> MForm Handler (FormResult (Text,Text,Text,Textarea),Widget) formInvoiceSendmail customer employee invoice extra = do @@ -312,7 +546,7 @@ postAdmInvoiceItemDeleteR iid xid = do runDB $ delete xid addMessageI info MsgRecordDeleted redirect $ AdminR $ AdmInvoiceItemsR iid - _ -> do + _x -> do addMessageI info MsgActionCancelled redirect $ AdminR $ AdmInvoiceItemsR iid diff --git a/src/Model.hs b/src/Model.hs index 34c01d6..1e1dba1 100644 --- a/src/Model.hs +++ b/src/Model.hs @@ -71,6 +71,12 @@ data DayType = Weekday | Weekend | Holiday deriving (Show, Read, Eq, Ord) derivePersistField "DayType" + +data MailStatus = MailStatusDraft | MailStatusBounced | MailStatusDelivered + deriving (Show, Read, Eq) +derivePersistField "MailStatus" + + data ServiceStatus = ServiceStatusPulished | ServiceStatusUnpublished deriving (Show, Read, Eq) @@ -215,6 +221,16 @@ makeLensesFor [ ("serviceName","_serviceName") , ("serviceGroup","_serviceGroup") ] ''Service +makeLensesFor [ ("itemOffer","_itemOffer") + , ("itemInvoice","_itemInvoice") + , ("itemQuantity","_itemQuantity") + , ("itemPrice","_itemPrice") + , ("itemTax","_itemTax") + , ("itemVat","_itemVat") + , ("itemAmount","_itemAmount") + , ("itemCurrency","_itemCurrency") + ] ''Item + instance PathPiece Month where toPathPiece :: Month -> Text diff --git a/stack.yaml b/stack.yaml index 0a36ea0..a05d53b 100644 --- a/stack.yaml +++ b/stack.yaml @@ -18,7 +18,7 @@ # resolver: ./custom-snapshot.yaml # resolver: https://example.com/snapshots/2018-01-01.yaml resolver: - url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/21/24.yaml + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/22/0.yaml # User packages to be built. # Various formats can be used as shown in the example below. diff --git a/stack.yaml.lock b/stack.yaml.lock index 8ec3dfe..9b8abff 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -27,8 +27,8 @@ packages: hackage: stripe-haskell-2.6.2@sha256:4ad015702e65b3219b9f2db10b8ae35873254a149f7b735c5d3e6250d7b641a1,1130 snapshots: - completed: - sha256: abcc4a65c15c7c2313f1a87f01bfd4d910516e1930b99653eef1d2d006515916 - size: 640074 - url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/21/24.yaml + sha256: e176944bc843f740e05242fa7a66ca1f440c127e425254f7f1257f9b19add23f + size: 712153 + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/22/0.yaml original: - url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/21/24.yaml + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/22/0.yaml diff --git a/static/img/Salon-ERD.svg b/static/img/Salon-ERD.svg index 6d8effd..403e532 100644 --- a/static/img/Salon-ERD.svg +++ b/static/img/Salon-ERD.svg @@ -432,249 +432,291 @@ >item_bookstatustimemarkrecipientrecipientNamesendersenderNamesubjectbodyinvoice_mailcustomergroup!tWp_U2_R)sAyVd6Wk|xHP>2ix6=Vv7B#;;gP_+)N404At zKoFT^3d$&ip;iSGNRTOkKth#}Fa=h=lRz6AAD=QKQwExSnCd)oO7MMuYK)n z=l6?lPKOVu9e_X}hvCi_JRp!gY7of3g7$p{KAB~DjDo-R-*EQ9K_D(OyFXvVvp;5o z50A$=T#oaM35g?I#a@RH2n3VxXbdjs>W%9rG1$<8B^z*=?;!9CKmT&GaFL%_SmZ@C z{W!%lYy0j&>yy8mFK3=I`~Jj8P+i^LHwkqI`jsD)JA7j+m~XDjEw%K`;Q1!5d}HjO zg1E$>{z~YeUTYTo@W<7VFOL}A?*Y@vg}}fM105RA-Hlo)Ia^A-Wxh zK-ONInc8pkFK$tBIYA?j93icRx+Adf%(c2(vPed{m~}UHcQ(*oF!V3TububK1u1n+ zFuz9v+!0|dR8GBPl5!CtX#4yp>EnaS;8z+eN)Sk*?%f`|e{%FOu(QxtJJ(Z#f`phc`Tn^YxpA(Wla&c0MpO|!f$0`pUu6$R+;lWlxHg`;2Rz%JyN*ESC@~qa9qV!P zd_=&+RR0dOFaMserz2NbJJMmxNYX=ZuU(FzwtOg?v_VaO0r`_OwtO&cu*9Z~LplSG zar$hJ(HXd$jG#@Tr)l&b?>F~K-$Q%d-Kr`rGOXvzHn~e`>JSLNwd$C`vMMK_P>DlH zK@c)Ol|C2##`g(^!lxK*v1sfyfZ*CbV&7tro8E%&i$*~p&zu&ycO3^V+dWF><|3qI zpzzs~{DrmGpFz;Sq7c2{0oie#)YYdH(lc)}HweVYWh1KOv{lwisQco+C>I@lxrOYG zZ4olXY#G4_bURRNGsLV}i!%XlOLeX9TyTbBYcW1PU49X8364iUkx~+rC zg>|Tw22vOHOUSSJJkx_|UBFAI%JM!mUAUxJP%uOPGM2M25z_4RcOM+F0X5lFA`(o! zkSpCr<{q+5xTMuZ{6AgB8Fh$h%0b*m|MK|FPvhn}HgstB;=Y~MF2GD2aR|J$BWv_G zJyV#OFFF~k-+r+buSg&;b|e7*wv&9d{mx;R?^Abc-X; znw>4saJnzO?JHB!ELq6a$htpJ_AR(s#j`0^*b2wyd(C2cmVc9>Lr->{P4ufMg&gRx zXACzqzjX#S^ofpelR2a#EpmnsLJpb=V!E6T(@PQoV>+v%v?@i_u^G4w2PiQG9HHTv>}ki@C|fHCQxcsKrm)n z5aKq%3^Nd{y)gd41|${<^Z}a~AdnDH19wMk4@CZvwjE+uB&<%i%gv}|FSDEMK`_W` z^mC<96-k&NuD=nx@Qa*7@gU0pqxpS1O?s8lzQwP&+K(y^o68cB%FOS}g>CTYd9`XG z8r6bbOqCi{kvZW|hJo4KL68KRwrKC1TbBOqWifzo2@gK$?u=cz0HR+Dt2baAOvn}G zBA5H4bm~YOvJ><8APhHw^XJ&?%?0 zI1m8qb1K?VGgtq+y|S(9cPDjWjQW-)GJW~;t)*2BxK_5re%P6n*bVYgpIhJIDxXn4 zt3QzVgKjRqWdhc1Kimagj{A81fwb77dZcX_r80Usj9axwHP=Q5j$qseXvVmd7`33d zaQ&&-$XsAMe{C8i^cyMQ;n_BWlysQMGary-4CKyYI>On@|jWldEhfIW$V)y3NyZ zN;NdirwDg0?-6?KK}a$nRMjjoIH&_&r{5ysuA?MB?x#RTxF?^!%^@`e7x<*@S8>M{ z!=DetZ%(cvIKY`YRFfY4`LsBZ^#lx~F&!p5CZ8IwDt;EpJS_<(8dyHjlN&Q)f=R8F zl*xQyj|Vo0E}Ey}X)mV#^e0XZ<9hwY4Yth{sme3=;aW|wydz(Eiy9<+dz&o#ldwRS zAv&3M|0_zko4KQ(BGy&wl%S(71-*31J3i9O`tYkdQYkcp`nDwnoe=t;o8E7|A?-lk zyD!ZfVqZZ}$Fz`B7Ze`DTcMS{7AQ-JW5WtN^4q8x!)D=-z%|_1+SsDsAz;E2G5>bH zFc+|hVU!1Zp|exQ1-bH>I|^z0=<�|3_7Xs`ZFvpb$2AnQ-YVp5MdN5h1JZ(}gM7qOMkq*-zRL8r zv$M+5&nQkjEk-abbrmx?c!zRS%PE^db;pDH2|%4mq7GSgyH#u2N( z2qVJ$z5>Mumvw(atTHBJrUssjIpK{kS=8>=9?;`vdeh)7h5M2hej2`g1LTHi`Y&Ps zx10;S5doHO%up4gJLSUCE!JjSJA8z41{<4h9As&xI{?Ri#7EP0U@^1ZKP*#g?!H5% zn9HX|$jx)q{P9N=i#kJMXk-3!v;@rgX`(cQ4jU+je@Wq^`(BwbcwH!*D~%tcm__ zKQQDLeE?L2&=iPr8||uwrkJDZJ<#J!siX7_R6fC;sqvKl?klUKIy#=sPAw0KJ>D0a z2K0^yea6?C>nT8^(pR$WanachE0#w?0`jrYc+VZ;bmYcf$glix=D8q}-ZTcD?!~GgwI|^6s3d^&{)>B>s8AOJo!NP!u6?Vu3v{L?PLb|41F_cph^~f4S;}4t(8>0c+I& zrd*|KaFDIi{Lk_zpK{vy(}jiF%7;Y1H;cOU+v4x>Nf;wbJnfHEXcbJ~ZPOok-hMie zTGRCfuQ@TP40Y6c}v;~ENxnUt&*-uRsFW6zyNx9RmI@Dy9V4Ms2(7r>*TM9&^> zjaj(7O*DB}=%!O6OV8R=T%dC{2WAW#BM7M=;p3`m-n|eF8MQ66_t}iPKW9$-e0KIQyFXezP_NhN%VU5l23k| zq#|Cz>K=^E0ehUq87uCOg?wU(Ud+VwxXbWhrC1e4{8aX_kK587u;Y7?wtZnK0I^+R zcv-qOoiR{BMC^w^D(WoY_v|T3x{((4jtalfEWsd(=s3&W za(xnh#wts7gpxA*pgZYtK6fcXKexERL21(3v=$cQB40pFsDD&17uBzOi-z*cJL=*w z*b7nOYRXFL{j)3SGbOuH00--#o@%h_%>_*Hr1TNt@4IwI5`mXJJn9VamMAReQc)wF z_wuNApKY$du(%Gk%y8QcYsPXD(#~v%xpO?~`llu{0T`25S68muJ>7zYc4dvpg~KUO zIg)YT8Z1cuWpBY9f6wc1EERrz5DE3QpX5q@Td=4D#N z5R0nE!t`UbwQ+wPuk-HBUF#eRGkl1QF+ud=Tj0?eep&Vf^@ZSyXZ4QViSe;PlQG)E z{Y7-?M@^-f{Xa8ZLe(6j9hFMjEUEJXfi%pZKo}Ec)-Tu`GDb1vXMr;Q+Wrq&Td%7G7#9c)_ z?j#h2+4F@fXXa!nTU9h6=uqITPxDddpGKiY(hm@wJzuT(!E!rMmv*vOF z9_`rxyz7WdWyoUm&ci}9g6n$(0=axctu=-h^40V+iD3LHDRVVbvt(J`f(u5l^FCz0 zk$^|+gvwn39V&1*jlw1HFX5QxQmTis!XuQ9mcD44CD3Z4X5LcE72W{ZYFbzEVQl@7 zmi%^PVdV&T`)u(mwB{3L9zaU|HwmxtF_W(%T#pA7iNP);C(^7B%lL3dx^gr$ZYv2CF1MQ% z8fijG`dAXJ?Ud5;Y{@6n*F|p_4UPI7t zoq10GxG1+u?s5l|2m!Sc4HSk=H6OcGBmqd>3;EPdj!7#0d@oGa2en_?XIv0fYzE^o zOG?iZ2|-2+4H6$6!U)A)>)U=Lg_X*3O5Uwuk<`JbXt8@)kqstPB?l;hmmC3VmtSmG zM{Z1PV5|$8g`wp2AHyn-FW=^&|C~%R31Cpt2ff$tp~SW@#m{v_A3&EJtq8Ctu1K@V zEz@AZO}Y@YnGy(ci(^Vtqi6H7KSiZ1Nc1>bphvjxH_}2L1;so)^v4Dwy0iH#m)vcwsJa1*~1j@BvplwmTBU%9OVO9@1$A$k@x+!X&8=ooEO(&|TY(K&+i(To{5cl-z{+CQt8H5z#yNUddu8z`Ke z!jhIRCu7FL+pe0dgS(;k5)u)>h+}f1!l11~My;{Wq-R9EeTb3%5iI+ChW@g`7YmOP zHc!9#7A(lBEeihDEJed}LXpr;c(k4XrwXPDx2{PtA2yswBN=O2pBbfeqr1-3Q=_Ji z+`HeU}9pgzA`#_+P_!o=B zxRWtzDR080Nfy~G`lY0@m{X|ihAoGI(mIg+S#s@gg(*amile<%xj>-gfU&Q5ed|DfNp=gyn1(8Tln$l8EajlaE+EN++(2)sA{E< z&Pv!C71){kxcZ(aB&+d+FB1V*iBO&qEmD_df^~9H+02Tv#Dw{9JR2K0Hm4anLrH3% zfAb@!awr*Gfq#QOJM;2?`%nFGf%pd^5-jAy)SF%rPCXCP>h@7*Vb) z4|LW0^L>hM@gvH`?-?aXp5=`o9l~-{CC70aXTqM$qrD#`#v3RKqD)3MXZ~b%1i6dN zvYS=Kw`_wHZdS?%j2QHd0;0mEVpsBqaPIa9o^Y}TOEwdM`|;?L|JC=!wH0SUqku~p z2wv!ZSpn3}#)RFM!XJ^7JPm>>}3WpaWwj=GNu={gY! zMwjTFoo84UCD-i$|3#Q5d)Ik1-7mR4MD9MxDAFtud1sby+SKjk-#G21??x!{VxUY%n4w_Eqn zr5|jX2HB-U?5pr&xj@0tTQ7?#{i%FLOIx|HkkS$M1Wgu0i86l?=$ZU-K+OP*f? zO}SNFORR~CkiLGTZKGn97=Q@K6TbJHTpI>9J%LttLT_EBcUO_{#qW4{`fQ9y$!q|m zC=vH|N_=&bER}|5ObuMmJk4X0&qlp20zOYDJhXaY1;~waX81=LVxAB1(m~Btph@nGsmU zsZJ2!NF^x0v0%)ufSya-uK;IM^`t>HwjbzE7l!)y)YipPn^30 z_@g4&OMdyLQQ}O(VU?NFX_SK)?B|=cgxXBme0A-~njyj9P0;Khm1P&}x5;nLeu4XV z8e}hl(b&15rtCznP;Pb3LM2sX*eH=V^bPq%Gl;Ocd{A=V^o*fC+nod0t$l!2LM=~p86MnR^9u!>Dn@X-jMz7_iuU^*gv8>QX% zeR$~8NE45+BvCKN=fzUjunG~v_q;*_&ap`k1BG?p`~ zHzV=qv(e!ko9jbc+Adg!(8p*Q2*=XCG)d&UW>w*z(C)#n*BK_EyP;dM*Cz~YSi*or z*Cvd#vu(CD$EaXLD?hI9Dw#HAEGf(nPICJEI8OEO)YSNkv^@YKhhm^Q+i=*54YD~y|1qU1Z%i*(Uk%r;RUZSz8}7vGe^f6IYsc^c>r z%to~87k#4)fgJ1yKaFIrG!Men4b)9cr@TyvS0(LC}gpZp;Y)5zicWN^BYx530 zCw<~CPP6t(ZY&;}c?@*iJPtOzb8dK1FiyK@36tbOM@IxaccJF9|vo@E4L>$-|V96&-%h()lK5+ z9dAS1Bt|b_(`gm}Sc8@q)zva)Jba>1Lr~7iia&4JrDy6U?T_>uL$?TNHnIm|Aqtnh zyPw0yB1=vWG0Wjpn?V!C*B~2RG3kA=v}x2i{U@nRZ-B?1!2^9BGSg<^lzQ5x__8%p z9frg(&Ldf+p3<@4KaM{FGE6K_g=&4uvb5R*w##cL4#sn3qnrhdu%3Uz8_*VWL&ZaSjfk}*hYfLSJfN)))0SC3m`;F;lAOd54AlvG|!Cvbc zoO)`IrrT?(-JdIeJ+>>!Hzt9jX%wdl;Vrl_zwkn|aoXmKB9i#=gp~hkpnu&qQ{_Kb zx~P^0HPF>Yf(I@uG6QTq!!$r4qpM(lPJuvvy1$q1r~{wx@h=RXzCh19MC<%ci5-Lh z-#K=(_w_gAQ5yLwyzZ%nFGPQhLe%v71RR%n+k$e33lOk zW!b9W!YXUy;Ca2xacCYm{`1qn2Aea`T~^m*7Kl#6z||`y!{Dg>5IB-A&+OG}>oNkA??*h`1gymLAeEusQ&EV;9mct%H9FHX4eX}|#*)izcu3AXKlHdZ1mhT4&mPz0j zH~xA3gTOxs{DZ(h2>gS