From f6dc63819055a764654a2212f93022768960f881 Mon Sep 17 00:00:00 2001 From: Josh Puetz Date: Tue, 6 Apr 2021 10:12:32 -0500 Subject: [PATCH] Upgrade to Ahoy 2.0.x (#13085) * First set of changes for Ahoy 2.0 * Fix line length * Change test * Remove unused test * Remove ahoy_messages.opened_at * Simply digest email sending calculation to not rely on last_opened * Remove outdated comment * Fix typo in test * Rework site config email digest to a single value * Fix merge duplication * Remove UTM references from ahoy emails * Fix credits tests * Remove UTM from expected email params --- Gemfile | 2 +- Gemfile.lock | 4 +- app/lib/constants/site_config.rb | 8 +- app/mailers/notify_mailer.rb | 8 +- app/models/email_message.rb | 4 +- app/models/site_config.rb | 3 +- .../email_digest_article_collector.rb | 28 +--- app/views/admin/configs/show.html.erb | 19 +-- app/views/admin/email_messages/show.html.erb | 6 +- config/initializers/ahoy_email.rb | 6 +- ...0323145228_drop_ahoy_messages_opened_at.rb | 7 + db/schema.rb | 1 - spec/mailers/digest_mailer_spec.rb | 12 -- spec/mailers/notify_mailer_spec.rb | 136 +----------------- spec/requests/admin/configs_spec.rb | 16 +-- spec/requests/admin/feedback_messages_spec.rb | 1 - .../email_digest_article_collector_spec.rb | 32 ++--- vendor/cache/ahoy_email-2.0.2.gem | Bin 0 -> 16384 bytes 18 files changed, 55 insertions(+), 238 deletions(-) create mode 100644 db/migrate/20210323145228_drop_ahoy_messages_opened_at.rb create mode 100644 vendor/cache/ahoy_email-2.0.2.gem diff --git a/Gemfile b/Gemfile index 8421175b9..2daae25ee 100644 --- a/Gemfile +++ b/Gemfile @@ -14,7 +14,7 @@ gem "active_record_union", "~> 1.3" # Adds proper union and union_all methods to gem "acts-as-taggable-on", "~> 7.0" # A tagging plugin for Rails applications that allows for custom tagging along dynamic contexts gem "acts_as_follower", github: "forem/acts_as_follower", branch: "master" # Allow any model to follow any other model gem "addressable", "~> 2.7" # A replacement for the URI implementation that is part of Ruby's standard library -gem "ahoy_email", "~> 1.1" # Email analytics for Rails +gem "ahoy_email", "~> 2.0.2" # Email analytics for Rails gem "ahoy_matey", "~> 3.2" # Tracking analytics for Rails gem "ancestry", "~> 3.2" # Ancestry allows the records of a ActiveRecord model to be organized in a tree structure gem "blazer", "~> 2.4.2" # Allows admins to query data diff --git a/Gemfile.lock b/Gemfile.lock index 57e9cc5db..d3ae2d938 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,7 +86,7 @@ GEM activerecord (>= 5.0, < 6.2) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) - ahoy_email (1.1.1) + ahoy_email (2.0.2) actionmailer (>= 5) addressable (>= 2.3.2) nokogiri @@ -849,7 +849,7 @@ DEPENDENCIES acts-as-taggable-on (~> 7.0) acts_as_follower! addressable (~> 2.7) - ahoy_email (~> 1.1) + ahoy_email (~> 2.0.2) ahoy_matey (~> 3.2) amazing_print (~> 1.3) ancestry (~> 3.2) diff --git a/app/lib/constants/site_config.rb b/app/lib/constants/site_config.rb index bd99ee250..f3535912a 100644 --- a/app/lib/constants/site_config.rb +++ b/app/lib/constants/site_config.rb @@ -254,12 +254,8 @@ module Constants "See: https://github.com/thepracticaldev/dev.to/pull/6345", placeholder: "$pay.somethinglikethis.co/value" }, - periodic_email_digest_max: { - description: "Determines the maximum for the periodic email digest", - placeholder: 0 - }, - periodic_email_digest_min: { - description: "Determines the mininum for the periodic email digest", + periodic_email_digest: { + description: "Determines how often periodic email digests are sent", placeholder: 2 }, recaptcha_site_key: { diff --git a/app/mailers/notify_mailer.rb b/app/mailers/notify_mailer.rb index 5077b71e4..7dd8f8382 100644 --- a/app/mailers/notify_mailer.rb +++ b/app/mailers/notify_mailer.rb @@ -1,4 +1,7 @@ class NotifyMailer < ApplicationMailer + has_history extra: -> { { feedback_message_id: params[:feedback_message_id] } }, + only: :feedback_message_resolution_email + def new_reply_email @comment = params[:comment] @user = @comment.parent_user @@ -66,9 +69,6 @@ class NotifyMailer < ApplicationMailer @user = User.find_by(email: params[:email_to]) @email_body = params[:email_body] - track utm_campaign: params[:email_type] - track extra: { feedback_message_id: params[:feedback_message_id] } - mail(to: params[:email_to], subject: params[:email_subject]) end @@ -76,8 +76,6 @@ class NotifyMailer < ApplicationMailer @user = User.find(params[:user_id]) @email_body = params[:email_body] - track utm_campaign: "user_contact" - mail(to: @user.email, subject: params[:email_subject]) end diff --git a/app/models/email_message.rb b/app/models/email_message.rb index ea00da3a4..e98c266f7 100644 --- a/app/models/email_message.rb +++ b/app/models/email_message.rb @@ -6,8 +6,8 @@ class EmailMessage < Ahoy::Message # reasons to define behavior here, similar to how we use the Tag model. def body_html_content doctype_index = content.index("") + 6 - content[doctype_index..closing_html_index] + closing_body_index = content.index("") + 6 + content[doctype_index..closing_body_index] end def self.find_for_reports(feedback_message_ids) diff --git a/app/models/site_config.rb b/app/models/site_config.rb index a8b1e5160..b1bc480c0 100644 --- a/app/models/site_config.rb +++ b/app/models/site_config.rb @@ -89,8 +89,7 @@ class SiteConfig < RailsSettings::Base } # Email digest frequency - field :periodic_email_digest_max, type: :integer, default: 2 - field :periodic_email_digest_min, type: :integer, default: 0 + field :periodic_email_digest, type: :integer, default: 2 # Jobs field :jobs_url, type: :string diff --git a/app/services/email_digest_article_collector.rb b/app/services/email_digest_article_collector.rb index e42d713b3..32dae552b 100644 --- a/app/services/email_digest_article_collector.rb +++ b/app/services/email_digest_article_collector.rb @@ -50,27 +50,7 @@ class EmailDigestArticleCollector return true unless last_email_sent_at # Has it been at least x days since @user received an email? - Time.current - last_email_sent_at >= days_until_next_email - end - - def days_until_next_email - # Relies on hyperbolic tangent function to model the frequency of the digest email - max_day = SiteConfig.periodic_email_digest_max - min_day = SiteConfig.periodic_email_digest_min - result = max_day * (1 - Math.tanh(2 * open_rate)) - result = result.round - - [result, min_day].max - end - - def open_rate - email_count = last_user_emails.count - - # Will stick with 50% open rate if @user has no/not-enough email digest history - return 0.5 if email_count < 10 - - past_opened_emails_count = last_user_emails.count { |msg| msg.opened_at.present? } - past_opened_emails_count / email_count + Time.current - last_email_sent_at >= SiteConfig.periodic_email_digest end def last_email_sent_at @@ -89,10 +69,6 @@ class EmailDigestArticleCollector end def last_user_emails - @last_user_emails ||= @user - .email_messages - .select(:sent_at, :opened_at) - .where(mailer: "DigestMailer#digest_email") - .limit(10) + @last_user_emails ||= @user.email_messages.select(:sent_at).where(mailer: "DigestMailer#digest_email").limit(10) end end diff --git a/app/views/admin/configs/show.html.erb b/app/views/admin/configs/show.html.erb index 23f2d092c..17255a575 100644 --- a/app/views/admin/configs/show.html.erb +++ b/app/views/admin/configs/show.html.erb @@ -652,21 +652,12 @@
- <%= admin_config_label :periodic_email_digest_max %> - <%= admin_config_description Constants::SiteConfig::DETAILS[:periodic_email_digest_max][:description] %> - <%= f.number_field :periodic_email_digest_max, + <%= admin_config_label :periodic_email_digest %> + <%= admin_config_description Constants::SiteConfig::DETAILS[:periodic_email_digest][:description] %> + <%= f.number_field :periodic_email_digest, class: "crayons-textfield", - value: SiteConfig.periodic_email_digest_max, - placeholder: Constants::SiteConfig::DETAILS[:periodic_email_digest_max][:placeholder] %> -
- -
- <%= admin_config_label :periodic_email_digest_min %> - <%= admin_config_description Constants::SiteConfig::DETAILS[:periodic_email_digest_max][:description] %> - <%= f.number_field :periodic_email_digest_min, - class: "crayons-textfield", - value: SiteConfig.periodic_email_digest_min, - placeholder: Constants::SiteConfig::DETAILS[:periodic_email_digest_min][:placeholder] %> + value: SiteConfig.periodic_email_digest, + placeholder: Constants::SiteConfig::DETAILS[:periodic_email_digest][:placeholder] %>
<%= render "form_submission", f: f %> diff --git a/app/views/admin/email_messages/show.html.erb b/app/views/admin/email_messages/show.html.erb index e975943ee..5ff31921d 100644 --- a/app/views/admin/email_messages/show.html.erb +++ b/app/views/admin/email_messages/show.html.erb @@ -24,13 +24,9 @@ Sent at <%= @email.sent_at&.strftime("%b %e '%y") %> - - Opened At - <%= @email.opened_at&.strftime("%b %e '%y") %> - UTM Campaign - <%= @email.utm_campaign %> + <%= @email&.utm_campaign %> Mailer diff --git a/config/initializers/ahoy_email.rb b/config/initializers/ahoy_email.rb index 7da73b1fd..2ae16eeb8 100644 --- a/config/initializers/ahoy_email.rb +++ b/config/initializers/ahoy_email.rb @@ -1,5 +1,5 @@ # enable tracking for open, click and UTM params AhoyEmail.api = true -AhoyEmail.default_options[:open] = true -AhoyEmail.default_options[:click] = true -AhoyEmail.default_options[:utm_params] = true +AhoyEmail.default_options[:click] = false +AhoyEmail.default_options[:utm_params] = false +AhoyEmail.default_options[:message] = true diff --git a/db/migrate/20210323145228_drop_ahoy_messages_opened_at.rb b/db/migrate/20210323145228_drop_ahoy_messages_opened_at.rb new file mode 100644 index 000000000..38ced4564 --- /dev/null +++ b/db/migrate/20210323145228_drop_ahoy_messages_opened_at.rb @@ -0,0 +1,7 @@ +class DropAhoyMessagesOpenedAt < ActiveRecord::Migration[6.0] + def change + safety_assured do + remove_column :ahoy_messages, :opened_at + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c425c4c36..5379512eb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -36,7 +36,6 @@ ActiveRecord::Schema.define(version: 2021_03_31_181505) do t.text "content" t.bigint "feedback_message_id" t.string "mailer" - t.datetime "opened_at" t.datetime "sent_at" t.text "subject" t.text "to" diff --git a/spec/mailers/digest_mailer_spec.rb b/spec/mailers/digest_mailer_spec.rb index 7393589f2..f6294a504 100644 --- a/spec/mailers/digest_mailer_spec.rb +++ b/spec/mailers/digest_mailer_spec.rb @@ -18,17 +18,5 @@ RSpec.describe DigestMailer, type: :mailer do expected_from = "#{SiteConfig.community_name} Digest <#{SiteConfig.email_addresses[:default]}>" expect(email["from"].value).to eq(expected_from) end - - it "includes the tracking pixel" do - email = described_class.with(user: user, articles: [article]).digest_email - expect(email.body).to include("open.gif") - end - - it "includes UTM params" do - email = described_class.with(user: user, articles: [article]).digest_email - expect(email.body).to include(CGI.escape("utm_medium=email")) - expect(email.body).to include(CGI.escape("utm_source=digest_mailer")) - expect(email.body).to include(CGI.escape("utm_campaign=digest_email")) - end end end diff --git a/spec/mailers/notify_mailer_spec.rb b/spec/mailers/notify_mailer_spec.rb index 7bfc62624..1367f453b 100644 --- a/spec/mailers/notify_mailer_spec.rb +++ b/spec/mailers/notify_mailer_spec.rb @@ -23,16 +23,6 @@ RSpec.describe NotifyMailer, type: :mailer do it "renders proper receiver" do expect(email.to).to eq([comment.user.email]) end - - it "includes the tracking pixel" do - expect(email.html_part.body).to include("open.gif") - end - - it "includes UTM params" do - expect(email.html_part.body).to include(CGI.escape("utm_medium=email")) - expect(email.html_part.body).to include(CGI.escape("utm_source=notify_mailer")) - expect(email.html_part.body).to include(CGI.escape("utm_campaign=new_reply_email")) - end end describe "#new_follower_email" do @@ -53,16 +43,6 @@ RSpec.describe NotifyMailer, type: :mailer do it "renders proper receiver" do expect(email.to).to eq([user.email]) end - - it "includes the tracking pixel" do - expect(email.html_part.body).to include("open.gif") - end - - it "includes UTM params" do - expect(email.html_part.body).to include(CGI.escape("utm_medium=email")) - expect(email.html_part.body).to include(CGI.escape("utm_source=notify_mailer")) - expect(email.html_part.body).to include(CGI.escape("utm_campaign=new_follower_email")) - end end describe "#new_mention_email" do @@ -82,16 +62,6 @@ RSpec.describe NotifyMailer, type: :mailer do it "renders proper receiver" do expect(email.to).to eq([user2.email]) end - - it "includes the tracking pixel" do - expect(email.html_part.body).to include("open.gif") - end - - it "includes UTM params" do - expect(email.html_part.body).to include(CGI.escape("utm_medium=email")) - expect(email.html_part.body).to include(CGI.escape("utm_source=notify_mailer")) - expect(email.html_part.body).to include(CGI.escape("utm_campaign=new_mention_email")) - end end describe "#unread_notifications_email" do @@ -110,16 +80,6 @@ RSpec.describe NotifyMailer, type: :mailer do it "renders proper receiver" do expect(email.to).to eq([user.email]) end - - it "includes the tracking pixel" do - expect(email.html_part.body).to include("open.gif") - end - - it "includes UTM params" do - expect(email.html_part.body).to include(CGI.escape("utm_medium=email")) - expect(email.html_part.body).to include(CGI.escape("utm_source=notify_mailer")) - expect(email.html_part.body).to include(CGI.escape("utm_campaign=unread_notifications_email")) - end end describe "#video_upload_complete_email" do @@ -138,16 +98,6 @@ RSpec.describe NotifyMailer, type: :mailer do it "renders proper receiver" do expect(email.to).to eq([article.user.email]) end - - it "includes the tracking pixel" do - expect(email.html_part.body).to include("open.gif") - end - - it "includes UTM params" do - expect(email.html_part.body).to include(CGI.escape("utm_medium=email")) - expect(email.html_part.body).to include(CGI.escape("utm_source=notify_mailer")) - expect(email.html_part.body).to include(CGI.escape("utm_campaign=video_upload_complete_email")) - end end describe "#new_badge_email" do @@ -185,15 +135,13 @@ RSpec.describe NotifyMailer, type: :mailer do context "when rendering the HTML email for badge with credits" do it "includes the listings URL" do expect(email_with_credits.html_part.body).to include( - CGI.escape( - Rails.application.routes.url_helpers.listings_url(host: SiteConfig.app_domain), - ), + Rails.application.routes.url_helpers.listings_url(host: SiteConfig.app_domain), ) end it "includes the about listings URL" do expect(email_with_credits.html_part.body).to include( - CGI.escape(Rails.application.routes.url_helpers.about_listings_url(host: SiteConfig.app_domain)), + Rails.application.routes.url_helpers.about_listings_url(host: SiteConfig.app_domain), ) end @@ -223,18 +171,8 @@ RSpec.describe NotifyMailer, type: :mailer do end context "when rendering the HTML email for badge w/o credits" do - it "includes the tracking pixel" do - expect(email.html_part.body).to include("open.gif") - end - - it "includes UTM params" do - expect(email.html_part.body).to include(CGI.escape("utm_medium=email")) - expect(email.html_part.body).to include(CGI.escape("utm_source=notify_mailer")) - expect(email.html_part.body).to include(CGI.escape("utm_campaign=new_badge_email")) - end - it "includes the user URL" do - expect(email.html_part.body).to include(CGI.escape(URL.user(user))) + expect(email.html_part.body).to include(URL.user(user)) end it "doesn't include the listings URL" do @@ -253,21 +191,21 @@ RSpec.describe NotifyMailer, type: :mailer do it "includes the rewarding_context_message in the email" do expect(email.html_part.body).to include("Hello 4iyYUoPw0wA~M+2WH8)B1+RolZhE#aUq4 zjnxdOh%>0i7;s{cHj>_J+!KhkArJ?-ek3;*x(=|L*4K1?NLXXZF~*<`iA|RgdnSo zOU;868^0jFfqrfn z>*L_${q*vCL><@Z;f#y3KP#S5oT%rilN$%GAb{0YpqienHifOTZ^xKy7nSUsgC07I zUZ6mx%esP%x>MK?M)1}(>)ULj1n$l~+5Nn#B}C;BsUjrRFRsVffh zf$*`YNL`h^7oZkvuXEhpwtq`-pFZ}4JND)kZ!FY+OX!9!Phh_)o7($4x)V?D!$sK` zhmif|7QxW$a8!G5!+f07(mr#EcGWZs0eT@X9K=<^A(IX3By`3;dxfz>WP!5$1hbn1 zG-il_TKmXOPSmI{AQVmAL@f=e@p^H!>CJ5;^|IE5Z5d6LebM+r-^K>xctD|w{YY1` zW?{R^I9M+VZ1rL_?Gc}}p9R>rxOXe0YI})RP;DlW;5MnUFqY?#+>F_$SeumCVZ(-6 zn1kOm1n>SZ)!?u3MI;N1zN&3kdqr zgXWlq_g6(-Pb4IX8;0B}KXg*<74#CN68Vyf%EW@i6U5;m)*cUxpTJi^5wi>HSbzGI{1$2?g|Xd(#ve)2zL;;64Kjv?tXq9{7uMf;aG9jRg75X ztx@b+->`1wx=fKxy7nS`)n?67EaJ8pq_&h&;g0r=D?xqSTDtCnjd%r^qkQ@iYSFrs zdvK~>3jHofeYU~%Yps|~xODbNk<_v~d+amg|1tDv19$jzI&~=Ki3L+G>Oc8~g2V!| zwG=lXuEY)V)z9H7#+6?7Octv?zEK0L(rCiyh{M1~c0@Ii)aqRv(SW+vT{G89 z5_|Ni7W-2uRTSTWyr5lul@9N@!uDDYdExNpBUx%^5vu%ppoN!9Az_VL>v#<=*8*YM zJ1jgIR-euTj>qyf^h&O1ToR42jM(7;`&+{rvbEE4N}u6+(fdAe`u^XK0toRd1~;#) zzEW1M@I@l2HWF8zy@7s#`(HC#&)Vz| z@hIG9@BrvYtojNXyKnByJGe*4NjcHA z8LY&DmTg)gpQCM{`lyPcMiyj_?uC217v6}4u|Sj#t@B4IpccR5EW04Z@DCrjp}5Nd z=LmCmR(sHT9}I>32_Q{OM zC|$$ph)5bIUT%Z+##e)p;B<1;dos7pt1{M8owlp`x=vZKIMD^2zhG+X z-(nJ4Os+3juWZJF_x^Ot1S1D~gqd~x{Gksf(NATDweJsi#(B$4$A z27{SJX@o~YRAEaK-H~JS`WT&RJ-1JqSr3GiInWFuC+e|ci$I`pO&JHPy@ukabshv? z+4XOp+|2P;#E~dlG-q(Mwy7)y@Z_v$4RTQ|((?w;Y9wAI2hvwT;*er46LfB~%VvOGE# znBT0IFp-6)c2zuW7(|Jeu^=CG1wHqk(rdO_O$2rpewz#zKEeYeo7lWcLDPNk#~FBu zV!xL>S{oB|5BMpH^VNG7YdB*yMPG%W1rlH=!7w^w{e&q(+{crYx4C}b;Gd}8&si6U z9!V?g%>88>j`Bzlx1^*lxhXF_$5~8AsG$zBt#9wVDN{0S>X=`s1kDz>NDr}QmFST1 z)~`6}$X;6}udz$~gNEpw2sRDKiR9WdepCO(yzT`g9X>7&S~<=l#{rqK`vhJBtbBZ^ z9D$DZj;Fy@{0kr{bXdB{OjsB6ZGT?Vg%dZTPMbJY1Qva%4)1 zb4Y|B<&hs|lnS~yXPntUmt;ptUSd%|4LFmKxaFKjY-%Y(rv=7p(w{{D^hi@pbr24N4_0)CdS|$Ui~VVwKQ)On%uS zLLFeWpfQtMGyGYA?;2yyd&C$HrSeHU1J%E`%m?KJm7&V76+RWrCVud;@rB2&96n{~ zwO~3@K@&EOQ;9c1f7#cZMPp7Kb0k-)2f>vgIfAFDp?+5DGbpe%_~E_P(~Va4ZV6TH zIspVd4Nkm}z%WvS4*a~$=Z80;jIdO*`w=sOv>7tqA9@eUNveR1oMnzc%9s*$PcvGLG#X5W(x1qRdm*yy6sx9Gn5DVR zK(xnP|9n=IY0rGL5vR*UEXb585W1*R(Te67(#!nL7-C~9-WAyy;vK{pW{*Qy-8a&S zVr~$<8$sG6oE-FniSs!@Fl(A}-ZGJemsQ$wtnN#E+!;BU1X=zp1^`I<-8RSb$(8S{ zL>c%;5hHG;tVyt()eu4)trnOxI?r1Ucj>N)&XoR3%~P7^Y;~*4d34`7_c{61P;F@B+$! zb)%p~En2CF)2kMtJre4M6fXW;knsXS1a=mv21t1m2sEf6B{38$c5y?jIAwNlCiv4t zt@?6gc|wiTXn1zc9QZN}ge397CoB$U^Nc>!!l2tiWI0%qb4V!AgnB%udf8|Bm+}=G zwEPEQ8PjLkBqV0p{2b6f^YMnZp&~sS*}g(Weah>h5xxX$Fa-Bn% zyC2@oEfBQt%vUf9iwFkzgFHmtzC6W?Y8@QVE5J8q?B!qoiZ{YLIAdv6_ON|%I{W16 zUH|%Zwug-Hy-NoaIkB@lItsA*Y2`x!BpUse0^*WEXEuLlsWJPWkRBx?=M{a{t9m_< zUxwcM;1+<+r$M>aPzresWSUnQ{+_=Aapr~O=s!$r;kd_#sq`*cAoyUp4R%Ls(%Yem z!bqf?-a2Q`B7(S2FET_1`vfbSRd+PWNEC2fE9G`0vLiMe zpKpd13f3_A47s#Gmp8{xKRWn~v0@|oUCK5`Vma<-44dQyz_OEg5+%R%yXxNY>_PQR z%$ip-slJ8V?D_87*H?A;=NpKhy$~k_4$q4G09=TiEs7ptZBw|Dd%VwQoT}UamW)34 z`Sb|%O^$QeC=?QEus4)Ig5VGV(flUCKPVF)&)-ZPI9EQ8WeeVnOGIA-s%`7{hZd6- zjZjMM_g&c4Zj62=*k0sQ1J`j9Z!4>4b)>|3F%7V=?bG7{yqQ?ZMNJ5Gv#8XY0t)Jl zEXA-eeh2&*8~h6q6V(*EuBhK#$h+-|A8fu@k=yqk;bk+V>F#Yo(a&yWkRXBJP^VmB zKYi<&jPM~n8^#Hb_LVe&smEVS9N7Lckl$r2iz<3NtTj3KBYPyo%6R&OekqCz{v>1& zx@6;-f8S74DZtbi)k3x&V9gmjK!)o5opEh3MwP39!-i%`8;ED>)EqOO6b7+-O<2JW z9{uMa6qtVht9F3pq+1gjI#9X^l?6&nkD*(N8z}=sUUmLdL}}1-UE(zr+G*yCRS$_6 zk8?cHIlhe4W881?PdVM(Zp61WfgOc6z}zv1FXL4&yY+J zuSCN=2LZ(k`7Mhx#KU*{Svs!qSlG^q){9I;Ah^5EBlb%!P5QjtHd)_#jx!}1rMv<(|l1l`&5}fSb2JNTN`JVWJD1Fts(S=E554>c$46TNn zi6<_BQ>%;*rBR{HISvQi0*D&Sl#{Dy?x7;N z(bpUKi#|316{Ht7VfBoh0<#rlW1-+94|`L$o+u(x65Cn?>jPw(DhiEtgW>*~Cmaq+ z(q6O+m%gNQM3L95$!-qge1sHb1=Hec<93|qv;%h1231O@o5sGJCjsmnMk16Hp}w73 zi+d`cHk8+Ono$gN)!tm!XgzVz&fifevN)cU6%HzCl^S6``q+iJH_L>R?X70E*PNwP zwm~ggZ%gwVDC^g`y^+@<0)=NJALE^LW^^Y{Mm)e+(q=*rz?&Of3j(6Z*q7K2jA;2n zx8I`j5+`9GHW6V6jJ!KRRy*Y7Gvo~dFnBTK$BqFBftndOd7{BOv^CRj6t!rA@r1H6 zZRj`eP2-r_Oj;0IDc%Wv3CP@knOV={W1_XkQ;O*_Zu7k>N{&BZEyP}aqdX!)#TPI< zrTYfCv!SDnLsx?Bw7gQt=i9mj2}#?q=Id3nsYu349`NJcwHv*^M;LhTfX7!d712)U zB|kaO5sQL&Wc3mE*mW|Vi+El|7_l?LZ<;7kDPAVU%b1xKce}Vk zx@J7!oFV_(8Y3BGR)IpH2|SXcfGTV}z$fMC31SZ(;0<<7`wI3}?o(;Rwlk{8-YFW6 zX}*Kh{WQeN_@XyhoZ5eXw<w=qWqTdv{JW&L6vvyLtJ_^km4tEb9F&UZ0{uJdZ z8HZ&Z0!>!<%UrKsiaTsk6fb10W}R;*l^wsk0&`TG4o`bD$3NEl=mhyZe?JN~ zE9))PLV7)|^fxmWARdw<)yZ*gkop)RaE^qA>unI^K7uDJ&P|C*6eux^oR=wjJqe2y zOv|JuU(^}TFGC8Z7vmX%>4H(emr>0ik(nj@SX6{rp-P z8q|(%>OKv)h;!HX$s&3EB}IRPFZ#0$`o4#K$pa04IHNc)rLpc(b11JtI&h=duufQy z=@$;XqQ3#w!fYb5^s`Ofr`&KMN0x$1CqbId+DN)v41XwuDdoTsnMVr-F-#(brO}gQ zLXNw|Rz!_d;G%W}6ROh7ahN}&xMBNSP)PU>*-+(My^7a_3+_aeZ=wBUkVu;gu9%W- z*l+A(!7sm`cdvcHHFQ6Qx@3lA5z^pcgO#TPGvhNArx zSu(YiLo0s;c734W5d6}?{YCFnxZqmy&`7T5 znWF!Q_?7=6u|7jAlzb{&VJySs1AehalnU%>YOpv3x-lcWrvM*ugz~*+1ASFctdDhv z*Iu5_HzmDZvemZ=h~m@y)1H}4A#;2N_U8sC#N|hnvZMHF0Be{Gm-)_?!B%iv@KsMu zBciTSgSy4nlk>JpN8*TkY=YWjq6Otv)7vE7{=Y7{!B*UjT+fT(gbVPd&XL{B3y@?2 zm^;OarTlf9d0V+e7$inlUo309H{c9&`H@F_fIheDJ6SzfR>0+zrXKK*=ayd&c5&Z= zTS8$0N)Dc$G&Np=!*A6>L4zQS0@a-L{uX!?N0R+qBp=dM3mEsbc!CSE&@9oGo5GT+#Mt|HLY&nR2B93 zSxxPF2~KqBK3^>xc5N`Xbhgn@^ZzN|!_+ka->;g4?Mpv`$4)&lR>7;BH?Q|SN7i{0 z!_wD}_tqiH?~-R^P%B(^Y~HGEu?z!oI~pJsM3?Z`zQJAT`Z(9W#h;o_TCU>|_*n!o z)oBPu^3LcJU?mVVok(1;UGlmxYnP?(s*AR6{U&!qVT#!u9Pp52of~pHU%QG3*F~N5 z_>&ZZ)Yttv+M!NE|0(D^iaxm?QNI%JGWEp?`O17cj7(Wzp54i>_+mBuB2G|BmhP&N z(fXi=ZM8O$e?XWU|1!o7&WMt~Th;R=qWp5j4r-Vu_ucU2P zgS8OH>{pl6hLDBuCRum$b)3HmJ+VKc>eZ^bAe8r;fv>*=n;m zVJrOdT_0{u`uk)tmbTzD)(fz{{xAYk-FXpXyv z(nz2gln0eEsmyn7cS)ITH%f~N_1CIZgJU;r(WUi~Ufhs?$7Q~tQ~oMPy@h~q`;`Ei zNg~9PA~PsD1eq%7SG0;ggC7HH2X?o5TVQH0Cd=7_C)*Qca&IEfl&Q)HWW9yG2Co*= z&vp>3j4gj7z6qRWT~G}5Hk8RM_(^YP)Bd3<0l`qCo-&Su?`PTJIWP$dYA3&`otypR zr1snDpPw6@9eaHIEUiHk)Y0F_zw9Vi2J?t%_ka@E%t&R2J$N{sQH(3yJycZ?#e%e| zfHC{3yyJZF+mvLyVSd=sT$x?RkX&4MagH8)bu}N+-8U?qBiQ;Vb+||w~ppv0gS6B6PZsW$o0PQbyDjR~59pe$5Gidh}OyCym z9;8c^-EGTjGga{+UwPYZO-)Z)uJ=b%}Sn?YL7N@CF&dA)!wkHN?eo!v+1d^LkORLK_*_f@ZH zUvEngwR>SgVvl#d5)-1j9T}{#^gvGlnRT@i6XR@7T4l%y@R~I)RKeB0xJWWc4QO-| zUN5&3cW1HVB&8%!&K+$;wcu(o7d!VWD%NcKeEBMWwY+t^J=^=*dz#w6F_zbM28dMV zylgN7@Xnq;9NK8NRLusos_Q9vG6Q zW#=K<(Yr4!v?f=E)`pT96Mp{frMryKNYZ9%nVbQ@dJdF_3M-fU8eL^sRG_2kRY`lE6~|5EV*bHx+RN zF84#(Wvi^kFImeSB_fJU^5*>5)5bX2TjtNi@MD)n5>N67AA4Rp`bnqm&-yMM3OLKP zY0v}=@mj4**?01G9Ruw`)|jaY4Yl^OxH%{3N#LXmd{i@mJU8tsJWdiuR_8nmH9z`rH1IV3V zWU(EhzSk&$saDyk(s9qNgl8(8Q(RYy=ApA>$l$0xlNOPYB1Z}^noRbvwY4Thq*S9i zf-l%xig`@IEQ)f=6~EYipExtck|anT(MiYi!q(-?C_qn~9AU+h(k#YCr&I8#31bQe zA*ZKqZn^j#-#mQWNaEe(TO7YUSbrRBdi*Ddwst(&I&xZP50O{sR8!2bb*(vB|1gz^@?x6L_4G<14N&um!qf`ON$Rr z+gQCx($oc1%|zk2Lra$0xF4LC?Z)O!f~|ITmBJ<~ZCLo#va3xIWKi87SRD4H92>M} zK+tN5?THiNq%w&xPntOh1=wzN&Kwu?ui>J;?saoULyo?l+)yr>3ePhSrJcQ!qn=*E zFMkhEL^g1BZ~536Y=fyIQ03ukN*pu71Bb;0Cc5xvPXfs{O)WDcv=w^Ml_@39S(P!G zC6&O0))zsc8hWn>v2I^7ju0nLj1Z<~&%yV_Xp^hLoS&G&9Cw3szpQ#?xZkFGxeG4e z1abyLx8*AN!lJ3ZnI?2rgZ3TTVhzuLWlS8nMwp1e#Dn8F5SDCQI+RMtIKm0WZB^*_ zy;oFQC6_LPIxCEtMY$RpkBs*+F(Q+45zppFX8UGFqT|zX0=FK2rSnkENV&J=IxO*W zVTSJL{GoOAi5(oiA_!b5|J(UBNHVkinR0cwU})B+dF@aLDY+{2*!{ zZJ_?=J(QUW7v?o`34R9k7UhkrT3KZkkS+|++`b1uXd+M6|I6yS*c3t3)ySmn)%@|U zwFHExu{P?%YG(ra{PZCs0z)IRIKI_9947>VGC!MA)fqPrP!R72=otp_))Ly5 z;_oCH9Gj{$A@r7RoFB6QFfs;e!Yd|C8fbRWo^G=z?3K-verqd?D#4(8&i3vO~eF&ioOm$%p( z7lcP$nk)ExiIlcYsI8{WV@`SLl}&b;$r2Fs@vC+Ukb|$U9GScI`&yTQ;EEI(H3?%H z7!D|H&&5#A&?C+1iSAckPj5(;-Tdy;t*9~jvTE||K+4jsiJ5@$ZKIwZ3zyrrOQiYL z=tR1SnNYpMeB)Yx2D>e6PGxl~u^kCO{2e_pM6C<(8kJtxpz%m4(I9f&b*C@Up|`*( z8l9l1hPNt}7O7*pOoe;ikhLKoK+5E;uqV34txDh!7!Y;a>v9sw#?nA z*qZb%`GHzJsAtB^6^D+E4mR9oe`H@JG!O&q;3rH1dC-UUMcY4E)p)Oln{M9BAt(~s z8I0a-L>3nemtQ<{4Tt3q{PScJ9ly4#Io_Oqj8sbof-J-<)3m5j66tn{;{YA&;{))x zDwSa)cr5+ESPbO)o2kvkA5Y0jDo7Xun>!^FqTl3h2_%C=L!*w*%7Pv2B#D3g(|Tu;k=4Ye#!&EoL4 zx(^*91ZRFTi2d}F>X3D3l?n6sqiMY)Q2RNQp6cu07f6r!zI&r^|8p81xNMBR;lA+TFN8vs1ECm)FI#JO9=n$YJ50ASFh@;uI>^z z?pIlP1{pp4I&t>`6|Y%Bi2rUwS6yXQsIw{yzry&#R@$BXjR__78!y$Cq_b#%p;XTp z0V^gEqvz!b<^%Q&(Bk_Ze<2yqtrjqGA8jxzKaEl+j54I{kb@7bm21Hc_h-zcsy#ht z4J(#<87=IP<46fpX_GR_r^TCS$wP9EvLLuJEt2$Ft8<(8h{d8uoRnZ~DR&yIo?d90 zCHYDkO&IG0Vc1}JVH>%amtspSYcc)EZdg4YHcK@dtn^vPd%qLw_RohhjsLv^duEfN zV!6@#c2YQ%*5u?cf4Yy$>MymW9tKPPRP1PK}?E!)K7%kSysguWcT0yx^Vh z$8it|%dsxIyn13EjMn-2sz4pa15ny2ezt%Ixv2qeFm3@ic65({z6JoLkz2XKfYMQZ zqQa&{Q2%MxV5||}tNfJ#-!+cqdMY&(-&|(qUg*_)@;4FUN1Ay^tc(nJ56J|zA44yL zSi#7tu(U;}VUi2%%dZibF3#`?&5i0bk2lnvMZ<5!7o@$;HotC0iRALq%y(H+yI6>D zb&HzYrYdGC;ZonFv?>aA%K=JSg#PDx!na+(qA_4wYa5s57qIFfz!X9JujE7rD*5Wv z4YU!9;B$p(C(9qO1NRcS3~VUT2&HLwoAgfh*kXIJ#aZn;Ie0^NtEEH}jJdaH7~HIm z_zYBfbe6_W+=qN)2tP2SCZ5o&^_&&pa1m&%~o!EMKE8;S(}yLAxVx+ z>JpLPO|&&&wd53fIBt2($V#V=G5jGxa}woLK=bOIOZ4iEs4Om`0K1h;ReB*$_-)O5 zs{nV{-?`M3dlB=>GG--=nm&2`ygE$wm(hXd3fD*i-M-PJIQtrlDCIOmh4Fcy^tT4n z%Fd=9c?b8^><*BZr^gkmSBO9SHElTs+-exe@GTTIpmG#Ico`7?-PVyb3p}%H(R}Uo zGx!Fs{{Two&yF&G&YEp~)bwK`dNA}QOnJ{+DFLtB_s~98KRX} zFSNi*75`$krw9YF9+Wiugw2ZxBRK4CTffO0a(A>d631`smOD(Z7_tkor(tO?>~wh) zVPvDqA3iiZe1j?;O&Z`&LUFy9nK}bHRa1H?e&+*^-XYZK3ZkElKAl67$QzhsXN2tI z2S@>y+ovW^bzY}b%0Q|mpeJV_RrMM0{%p6MWb=(REO}4o8Gm7I?li~>4Lw!VZ9Suq z`rU8@tvZdJU0L_qPcv-kub$tp1EZvGA*0)Xa-fwN9}{r1MgstBD*)mf{Z=BZyoHg^D|v&8$QAxsO=ynJ~emCfr4-yu1aH z^T|6I%T>*U@6kpyOjET$0dK$wnhANwGI7)B6n;K@Rk(ds?W=7R4y40;lam1X=ZD{> zrOov#psA&$6L`M)4Adiff43wB8Mwma5&u=kj0}D6o9Uud(Xj200l9?;e9I3i>|;XZ ztUsz|06xLx>ADQKWqR?Som;|ZTYVgK`#+O#JjM}B)>A5yofG| zA~16Yc0CCV7E%?izV>*gcA0iibAB@3wA#VTiE#dGau1nI(bfm}Mt=cCQS(2)g!uTf z=zz0r1VEaUw>V7Dk01R8SnQ3A(zIqpMCdxY#6KRv3YfRB6FXm>KTDo~VWh&ZBLST7 zPrXeGK;YIrQJC2~jPlfRj~Hx`+#{ad|2QTd{qBnf-+$80LSNjF&2T3uRy{g;-F;R= z29fjAw$btz`r4JJ^QH!1BLv0mf&J5AtIM-cPR+{*E%0^zQ0UcnA&D`N5^sfq0EW1t#hn&byXU(A7pHdb!6T~eJQFb@^U z&VNwJQ-Be~8@lw?%pr5pP|U&TMdRs1n`$(ZFN1i8cPx+JA2T}#>%Ztf*g2Uw|E>S{ z?|8ZXQ~zNR&-aIb8Ajr>ExIiTJ0lNqQ6Wu`ZqH1$B&u}cVo*L~tgs&Cl<-%NVI0x! zuFA{i;ro}nQ1{nhFW~F(1!9V^e>f&mPU3zCQi{ThYJ=9bCs1{?Hbzd}Ggy=m_T0bQ#ICK&D<8%s8y+!hv@iF