Merge pull request #2802 from consul/newsletter-delivery

Newsletter delivery
This commit is contained in:
Alberto
2018-07-26 18:20:50 +02:00
committed by GitHub
24 changed files with 433 additions and 57 deletions

View File

@@ -1,5 +1,5 @@
class Admin::ActivityController < Admin::BaseController class Admin::ActivityController < Admin::BaseController
has_filters %w{all on_users on_proposals on_debates on_comments} has_filters %w{all on_users on_proposals on_debates on_comments on_system_emails}
def show def show
@activity = Activity.for_render.send(@current_filter) @activity = Activity.for_render.send(@current_filter)

View File

@@ -48,10 +48,7 @@ class Admin::NewslettersController < Admin::BaseController
@newsletter = Newsletter.find(params[:id]) @newsletter = Newsletter.find(params[:id])
if @newsletter.valid? if @newsletter.valid?
@newsletter.list_of_recipient_emails.each do |recipient_email| @newsletter.delay.deliver
Mailer.newsletter(@newsletter, recipient_email).deliver_later
end
@newsletter.update(sent_at: Time.current) @newsletter.update(sent_at: Time.current)
flash[:notice] = t("admin.newsletters.send_success") flash[:notice] = t("admin.newsletters.send_success")
else else

View File

@@ -1,6 +1,6 @@
class Admin::SystemEmailsController < Admin::BaseController class Admin::SystemEmailsController < Admin::BaseController
before_action :load_system_email, only: [:view, :preview_pending] before_action :load_system_email, only: [:view, :preview_pending, :moderate_pending]
def index def index
@system_emails = { @system_emails = {
@@ -24,6 +24,19 @@ class Admin::SystemEmailsController < Admin::BaseController
end end
end end
def moderate_pending
ProposalNotification.find(params[:id]).moderate_system_email(current_user)
redirect_to admin_system_email_preview_pending_path("proposal_notification_digest")
end
def send_pending
Notification.delay.send_pending
flash[:notice] = t("admin.system_emails.preview_pending.send_pending_notification")
redirect_to admin_system_emails_path
end
private private
def load_system_email def load_system_email

View File

@@ -2,7 +2,7 @@ class Activity < ActiveRecord::Base
belongs_to :actionable, -> { with_hidden }, polymorphic: true belongs_to :actionable, -> { with_hidden }, polymorphic: true
belongs_to :user, -> { with_hidden } belongs_to :user, -> { with_hidden }
VALID_ACTIONS = %w(hide block restore valuate) VALID_ACTIONS = %w(hide block restore valuate email)
validates :action, inclusion: {in: VALID_ACTIONS} validates :action, inclusion: {in: VALID_ACTIONS}
@@ -11,6 +11,7 @@ class Activity < ActiveRecord::Base
scope :on_users, -> { where(actionable_type: 'User') } scope :on_users, -> { where(actionable_type: 'User') }
scope :on_comments, -> { where(actionable_type: 'Comment') } scope :on_comments, -> { where(actionable_type: 'Comment') }
scope :on_budget_investments, -> { where(actionable_type: 'Budget::Investment') } scope :on_budget_investments, -> { where(actionable_type: 'Budget::Investment') }
scope :on_system_emails, -> { where(actionable_type: 'ProposalNotification') }
scope :for_render, -> { includes(user: [:moderator, :administrator]).includes(:actionable) } scope :for_render, -> { includes(user: [:moderator, :administrator]).includes(:actionable) }
def self.log(user, action, actionable) def self.log(user, action, actionable)

View File

@@ -8,6 +8,9 @@ class Newsletter < ActiveRecord::Base
validates_format_of :from, :with => /@/ validates_format_of :from, :with => /@/
acts_as_paranoid column: :hidden_at
include ActsAsParanoidAliases
def list_of_recipient_emails def list_of_recipient_emails
UserSegments.user_segment_emails(segment_recipient) if valid_segment_recipient? UserSegments.user_segment_emails(segment_recipient) if valid_segment_recipient?
end end
@@ -20,9 +23,47 @@ class Newsletter < ActiveRecord::Base
sent_at.nil? sent_at.nil?
end end
def deliver
run_at = first_batch_run_at
list_of_recipient_emails_in_batches.each do |recipient_emails|
recipient_emails.each do |recipient_email|
if valid_email?(recipient_email)
Mailer.delay(run_at: run_at).newsletter(self, recipient_email)
log_delivery(recipient_email)
end
end
run_at += batch_interval
end
end
def batch_size
10000
end
def batch_interval
20.minutes
end
def first_batch_run_at
Time.current
end
def list_of_recipient_emails_in_batches
list_of_recipient_emails.in_groups_of(batch_size, false)
end
private private
def validate_segment_recipient def validate_segment_recipient
errors.add(:segment_recipient, :invalid) unless valid_segment_recipient? errors.add(:segment_recipient, :invalid) unless valid_segment_recipient?
end end
def valid_email?(email)
email.match(/\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i)
end
def log_delivery(recipient_email)
user = User.where(email: recipient_email).first
Activity.log(user, :email, self)
end
end end

View File

@@ -68,4 +68,29 @@ class Notification < ActiveRecord::Base
end end
end end
def self.send_pending
run_at = first_batch_run_at
User.email_digest.find_in_batches(batch_size: batch_size) do |users|
users.each do |user|
email_digest = EmailDigest.new(user)
email_digest.deliver(run_at)
end
run_at += batch_interval
end
end
private
def self.batch_size
10000
end
def self.batch_interval
20.minutes
end
def self.first_batch_run_at
Time.current
end
end end

View File

@@ -37,6 +37,11 @@ class ProposalNotification < ActiveRecord::Base
proposal proposal
end end
def moderate_system_email(moderator)
Notification.where(notifiable_type: 'ProposalNotification', notifiable: self).destroy_all
Activity.log(moderator, :hide, self)
end
def ignore_flag def ignore_flag
update(ignored_at: Time.current) update(ignored_at: Time.current)
end end

View File

@@ -5,7 +5,7 @@
<tr> <tr>
<th><%= t("admin.shared.title") %></th> <th><%= t("admin.shared.title") %></th>
<th><%= t("admin.shared.description") %></th> <th><%= t("admin.shared.description") %></th>
<th class="small-5 text-right"><%= t("admin.shared.actions") %></th> <th class="small-7 text-right"><%= t("admin.shared.actions") %></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -27,6 +27,10 @@
admin_system_email_preview_pending_path(system_email_title), admin_system_email_preview_pending_path(system_email_title),
class: "button" %> class: "button" %>
<% end %> <% end %>
<%= link_to t("admin.system_emails.preview_pending.send_pending"),
admin_system_email_send_pending_path(system_email_title),
class: "button",
method: :put %>
</td> </td>
</tr> </tr>
<% end %> <% end %>

View File

@@ -1,6 +1,7 @@
<% if preview.proposal.present? %> <% if preview.proposal.present? %>
<div id="<%= dom_id(preview) %>">
<div class="callout highlight"> <div class="callout highlight">
<div class="row"> <div class="row expanded">
<div class="small-12 medium-6 column"> <div class="small-12 medium-6 column">
<strong><%= t("admin.shared.proposal") %></strong><br> <strong><%= t("admin.shared.proposal") %></strong><br>
<%= link_to preview.proposal.title, proposal_url(preview.proposal) %> <%= link_to preview.proposal.title, proposal_url(preview.proposal) %>
@@ -9,8 +10,6 @@
<strong><%= t("admin.shared.title") %></strong><br> <strong><%= t("admin.shared.title") %></strong><br>
<%= link_to preview.title, proposal_url(preview.proposal, anchor: "tab-notifications") %> <%= link_to preview.title, proposal_url(preview.proposal, anchor: "tab-notifications") %>
</div> </div>
</div>
<div class="row">
<div class="small-12 medium-6 column"> <div class="small-12 medium-6 column">
<strong><%= t("admin.shared.author") %></strong><br> <strong><%= t("admin.shared.author") %></strong><br>
<%= preview.proposal.author&.username || '-' %> <%= preview.proposal.author&.username || '-' %>
@@ -25,9 +24,17 @@
<div class="row"> <div class="row">
<div class="column"> <div class="column">
<strong><%= t("admin.shared.content") %></strong> <strong><%= t("admin.shared.content") %></strong>
<p class="help-text" id="phase-description-help-text"> <p id="phase-description-help-text">
<%= preview.body %> <%= preview.body %>
</p> </p>
</div> </div>
</div> </div>
<%= link_to t("admin.system_emails.preview_pending.moderate_pending"),
admin_system_email_moderate_pending_path(system_email_id: 'proposal_notification_digest',
id: preview.id),
method: :put,
class: "button hollow float-right" %>
</div>
<hr>
<% end %> <% end %>

View File

@@ -67,6 +67,7 @@ en:
on_debates: Debates on_debates: Debates
on_proposals: Proposals on_proposals: Proposals
on_users: Users on_users: Users
on_system_emails: System emails
title: Moderator activity title: Moderator activity
type: Type type: Type
no_activity: There are no moderators activity. no_activity: There are no moderators activity.
@@ -655,6 +656,9 @@ en:
action: Preview Pending action: Preview Pending
preview_of: Preview of %{name} preview_of: Preview of %{name}
pending_to_be_sent: This is the content pending to be sent pending_to_be_sent: This is the content pending to be sent
moderate_pending: Moderate notification send
send_pending: Send pending
send_pending_notification: Pending notifications sent succesfully
proposal_notification_digest: proposal_notification_digest:
title: Proposal Notification Digest title: Proposal Notification Digest
description: Gathers all proposal notifications for an user in a single message, to avoid too much emails. description: Gathers all proposal notifications for an user in a single message, to avoid too much emails.

View File

@@ -67,6 +67,7 @@ es:
on_debates: Debates on_debates: Debates
on_proposals: Propuestas on_proposals: Propuestas
on_users: Usuarios on_users: Usuarios
on_system_emails: Emails del sistema
title: Actividad de los Moderadores title: Actividad de los Moderadores
type: Tipo type: Tipo
no_activity: No hay actividad de moderadores. no_activity: No hay actividad de moderadores.
@@ -656,6 +657,9 @@ es:
action: Previsualizar Pendientes action: Previsualizar Pendientes
preview_of: Vista previa de %{name} preview_of: Vista previa de %{name}
pending_to_be_sent: Este es todo el contenido pendiente de enviar pending_to_be_sent: Este es todo el contenido pendiente de enviar
moderate_pending: Moderar envío de notificación
send_pending : Enviar pendientes
send_pending_notification: Notificaciones pendientes enviadas correctamente
proposal_notification_digest: proposal_notification_digest:
title: Resumen de Notificationes de Propuestas title: Resumen de Notificationes de Propuestas
description: Reune todas las notificaciones de propuestas en un único mensaje, para evitar demasiados emails. description: Reune todas las notificaciones de propuestas en un único mensaje, para evitar demasiados emails.

View File

@@ -168,6 +168,8 @@ namespace :admin do
resources :system_emails, only: [:index] do resources :system_emails, only: [:index] do
get :view get :view
get :preview_pending get :preview_pending
put :moderate_pending
put :send_pending
end end
resources :emails_download, only: :index do resources :emails_download, only: :index do

View File

@@ -0,0 +1,5 @@
class AddHiddenAtToNewsletters < ActiveRecord::Migration
def change
add_column :newsletters, :hidden_at, :datetime
end
end

View File

@@ -682,6 +682,7 @@ ActiveRecord::Schema.define(version: 20180711224810) do
t.date "sent_at" t.date "sent_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "hidden_at"
end end
create_table "notifications", force: :cascade do |t| create_table "notifications", force: :cascade do |t|

View File

@@ -14,9 +14,11 @@ class EmailDigest
notifications.any? notifications.any?
end end
def deliver
if pending_notifications? def deliver(run_at)
Mailer.proposal_notification_digest(user, notifications.to_a).deliver_later if valid_email? && pending_notifications?
Mailer.delay(run_at: run_at).proposal_notification_digest(user, notifications.to_a)
mark_as_emailed
end end
end end
@@ -25,4 +27,8 @@ class EmailDigest
user.update(failed_email_digests_count: 0) user.update(failed_email_digests_count: 0)
end end
def valid_email?
user.email.present? && user.email.match(/\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i)
end
end end

View File

@@ -1,17 +0,0 @@
namespace :emails do
desc "Sends email digest of proposal notifications to each user"
task digest: :environment do
User.email_digest.find_each do |user|
email_digest = EmailDigest.new(user)
begin
email_digest.deliver
email_digest.mark_as_emailed
rescue
user.increment_counter(:failed_email_digests_count)
user.save
end
end
end
end

View File

@@ -331,4 +331,22 @@ feature 'Admin activity' do
end end
end end
context "System emails" do
scenario "Shows moderation activity on system emails" do
proposal = create(:proposal, title: 'Proposal A')
proposal_notification = create(:proposal_notification, proposal: proposal,
title: 'Proposal A Title',
body: 'Proposal A Notification Body')
proposal_notification.moderate_system_email(@admin.user)
visit admin_activity_path
within("#activity_#{Activity.last.id}") do
expect(page).to have_content(proposal_notification.title)
expect(page).to have_content("Hidden")
expect(page).to have_content(@admin.user.username)
end
end
end
end end

View File

@@ -65,6 +65,49 @@ feature "System Emails" do
expect(page).to have_link('Proposal B Title', href: proposal_url(proposal_b, expect(page).to have_link('Proposal B Title', href: proposal_url(proposal_b,
anchor: 'tab-notifications')) anchor: 'tab-notifications'))
end end
scenario "#moderate_pending" do
proposal1 = create(:proposal, title: 'Proposal A')
proposal2 = create(:proposal, title: 'Proposal B')
proposal_notification1 = create(:proposal_notification, proposal: proposal1,
title: 'Proposal A Title',
body: 'Proposal A Notification Body')
proposal_notification2 = create(:proposal_notification, proposal: proposal2)
create(:notification, notifiable: proposal_notification1, emailed_at: nil)
create(:notification, notifiable: proposal_notification2, emailed_at: nil)
visit admin_system_email_preview_pending_path('proposal_notification_digest')
within("#proposal_notification_#{proposal_notification1.id}") do
click_on "Moderate notification send"
end
visit admin_system_email_preview_pending_path('proposal_notification_digest')
expect(Notification.count).to equal(1)
expect(Activity.last.actionable_type).to eq('ProposalNotification')
expect(page).not_to have_content("Proposal A Title")
end
scenario "#send_pending" do
proposal = create(:proposal)
proposal_notification = create(:proposal_notification, proposal: proposal,
title: 'Proposal A Title',
body: 'Proposal A Notification Body')
voter = create(:user, :level_two)
create(:notification, notifiable: proposal_notification, user: voter, emailed_at: nil)
create(:follow, user: voter, followable: proposal)
visit admin_system_emails_path
click_on "Send pending"
email = open_last_email
expect(email).to deliver_to(voter)
expect(email).to have_body_text(proposal_notification.body)
expect(page).to have_content("Pending notifications sent succesfully")
end
end end
end end

View File

@@ -294,7 +294,7 @@ feature 'Emails' do
notification3 = create_proposal_notification(proposal3) notification3 = create_proposal_notification(proposal3)
email_digest = EmailDigest.new(user) email_digest = EmailDigest.new(user)
email_digest.deliver email_digest.deliver(Time.current)
email_digest.mark_as_emailed email_digest.mark_as_emailed
email = open_last_email email = open_last_email
@@ -327,6 +327,26 @@ feature 'Emails' do
expect(notification2.emailed_at).to be expect(notification2.emailed_at).to be
end end
scenario "notifications moderated are not sent" do
user = create(:user, email_digest: true)
proposal = create(:proposal)
proposal_notification = create(:proposal_notification, proposal: proposal)
notification = create(:notification, notifiable: proposal_notification)
reset_mailer
proposal_notification.moderate_system_email(create(:administrator).user)
email_digest = EmailDigest.new(user)
email_digest.deliver(Time.current)
email_digest.mark_as_emailed
expect { open_last_email }.to raise_error "No email has been sent!"
end
xscenario "Delete all Notifications included in the digest after email sent" do
end
end end
context "User invites" do context "User invites" do

View File

@@ -182,4 +182,67 @@ feature "Notifications" do
end end
end end
describe "#send_pending" do
let!(:user1) { create(:user) }
let!(:user2) { create(:user) }
let!(:user3) { create(:user) }
let!(:proposal_notification) { create(:proposal_notification) }
let!(:notification1) { create(:notification, notifiable: proposal_notification, user: user1) }
let!(:notification2) { create(:notification, notifiable: proposal_notification, user: user2) }
let!(:notification3) { create(:notification, notifiable: proposal_notification, user: user3) }
before do
reset_mailer
Delayed::Worker.delay_jobs = true
end
after do
Delayed::Worker.delay_jobs = false
end
it "sends pending proposal notifications" do
Delayed::Worker.delay_jobs = false
Notification.send_pending
email = open_last_email
expect(email).to have_subject("Proposal notifications in CONSUL")
end
it "sends emails in batches" do
Notification.send_pending
expect(Delayed::Job.count).to eq(3)
end
it "sends batches in time intervals" do
allow(Notification).to receive(:batch_size).and_return(1)
allow(Notification).to receive(:batch_interval).and_return(1.second)
allow(Notification).to receive(:first_batch_run_at).and_return(Time.current)
remove_users_without_pending_notifications
Notification.send_pending
now = Notification.first_batch_run_at
first_batch_run_at = now.change(usec: 0)
second_batch_run_at = (now + 1.second).change(usec: 0)
third_batch_run_at = (now + 2.seconds).change(usec: 0)
expect(Delayed::Job.count).to eq(3)
expect(Delayed::Job.first.run_at.change(usec: 0)).to eq(first_batch_run_at)
expect(Delayed::Job.second.run_at.change(usec: 0)).to eq(second_batch_run_at)
expect(Delayed::Job.third.run_at.change(usec: 0)).to eq(third_batch_run_at)
end
end
def remove_users_without_pending_notifications
users_without_notifications.each { |user| user.destroy }
end
def users_without_notifications
User.all.select { |user| user.notifications.not_emailed
.where(notifiable_type: 'ProposalNotification').blank? }
end
end end

View File

@@ -75,7 +75,7 @@ describe EmailDigest do
reset_mailer reset_mailer
email_digest = described_class.new(user) email_digest = described_class.new(user)
email_digest.deliver email_digest.deliver(Time.current)
email = open_last_email email = open_last_email
expect(email).to have_subject("Proposal notifications in CONSUL") expect(email).to have_subject("Proposal notifications in CONSUL")
@@ -89,7 +89,7 @@ describe EmailDigest do
reset_mailer reset_mailer
email_digest = described_class.new(user) email_digest = described_class.new(user)
email_digest.deliver email_digest.deliver(Time.current)
expect(all_emails.count).to eq(0) expect(all_emails.count).to eq(0)
end end
@@ -137,4 +137,29 @@ describe EmailDigest do
end end
describe "#valid_email?" do
it "returns a MatchData if email is valid" do
user = create(:user, email: 'valid_email@email.com')
email_digest = described_class.new(user)
expect(email_digest.valid_email?).to be_a(MatchData)
end
it "returns nil if email is invalid" do
user = create(:user, email: 'invalid_email@email..com')
email_digest = described_class.new(user)
expect(email_digest.valid_email?).to be(nil)
end
it "returns false if email does not exist" do
user = create(:user)
user.update_attribute(:email, nil)
email_digest = described_class.new(user)
expect(email_digest.valid_email?).to be(false)
end
end
end end

View File

@@ -7,12 +7,14 @@ describe Activity do
expect(build(:activity, actionable: create(:debate))).to be_valid expect(build(:activity, actionable: create(:debate))).to be_valid
expect(build(:activity, actionable: create(:comment))).to be_valid expect(build(:activity, actionable: create(:comment))).to be_valid
expect(build(:activity, actionable: create(:user))).to be_valid expect(build(:activity, actionable: create(:user))).to be_valid
expect(build(:activity, actionable: create(:newsletter))).to be_valid
end end
it "is a valid only with allowed actions" do it "is a valid only with allowed actions" do
expect(build(:activity, action: "hide")).to be_valid expect(build(:activity, action: "hide")).to be_valid
expect(build(:activity, action: "block")).to be_valid expect(build(:activity, action: "block")).to be_valid
expect(build(:activity, action: "restore")).to be_valid expect(build(:activity, action: "restore")).to be_valid
expect(build(:activity, action: "email")).to be_valid
expect(build(:activity, action: "dissapear")).not_to be_valid expect(build(:activity, action: "dissapear")).not_to be_valid
end end

View File

@@ -63,4 +63,95 @@ describe Newsletter do
expect(newsletter.list_of_recipient_emails).not_to include('erased_user@consul.dev') expect(newsletter.list_of_recipient_emails).not_to include('erased_user@consul.dev')
end end
end end
describe "#deliver" do
let!(:proposals) { Array.new(3) { create(:proposal) } }
let!(:debate) { create(:debate) }
let!(:recipients) { proposals.map(&:author).map(&:email) }
let!(:newsletter) { create(:newsletter, segment_recipient: "proposal_authors") }
before do
reset_mailer
Delayed::Worker.delay_jobs = true
end
after do
Delayed::Worker.delay_jobs = false
end
it "sends an email with the newsletter to every recipient" do
newsletter.deliver
recipients.each do |recipient|
email = Mailer.newsletter(newsletter, recipient)
expect(email).to deliver_to(recipient)
end
Delayed::Job.all.map(&:invoke_job)
expect(ActionMailer::Base.deliveries.count).to eq(3)
end
it "sends emails in batches" do
allow(newsletter).to receive(:batch_size).and_return(1)
newsletter.deliver
expect(Delayed::Job.count).to eq(3)
end
it "sends batches in time intervals" do
allow(newsletter).to receive(:batch_size).and_return(1)
allow(newsletter).to receive(:batch_interval).and_return(1.second)
allow(newsletter).to receive(:first_batch_run_at).and_return(Time.current)
newsletter.deliver
now = newsletter.first_batch_run_at
first_batch_run_at = now.change(usec: 0)
second_batch_run_at = (now + 1.second).change(usec: 0)
third_batch_run_at = (now + 2.seconds).change(usec: 0)
expect(Delayed::Job.count).to eq(3)
expect(Delayed::Job.first.run_at.change(usec: 0)).to eq(first_batch_run_at)
expect(Delayed::Job.second.run_at.change(usec: 0)).to eq(second_batch_run_at)
expect(Delayed::Job.third.run_at.change(usec: 0)).to eq(third_batch_run_at)
end
it "logs users that have received the newsletter" do
newsletter.deliver
expect(Activity.count).to eq(3)
recipients.each do |email|
user = User.where(email: email).first
activity = Activity.where(user: user).first
expect(activity.user_id).to eq(user.id)
expect(activity.action).to eq("email")
expect(activity.actionable).to eq(newsletter)
end
end
it "skips invalid emails" do
Proposal.destroy_all
valid_email = "john@gmail.com"
invalid_email = "john@gmail..com"
valid_email_user = create(:user, email: valid_email)
proposal = create(:proposal, author: valid_email_user)
invalid_email_user = create(:user, email: invalid_email)
proposal = create(:proposal, author: invalid_email_user)
newsletter.deliver
expect(Activity.count).to eq(1)
expect(Activity.first.user_id).to eq(valid_email_user.id)
expect(Activity.first.action).to eq("email")
expect(Activity.first.actionable).to eq(newsletter)
end
end
end end

View File

@@ -152,5 +152,21 @@ describe ProposalNotification do
end end
describe "#moderate_system_email" do
let(:admin) { create(:administrator) }
let(:proposal) { create(:proposal) }
let(:proposal_notification) { build(:proposal_notification, proposal: proposal) }
let(:notification) { create(:notification, notifiable: proposal_notification) }
it "removes all notifications related to the proposal notification" do
proposal_notification.moderate_system_email(admin.user)
expect(Notification.all.count).to be(0)
end
it "records the moderation action in the Activity table" do
proposal_notification.moderate_system_email(admin.user)
expect(Activity.last.actionable_type).to eq('ProposalNotification')
end
end
end end
end end