[Git][noosfero/noosfero][master] 3 commits: improvements in core required for the newsletter plugin

Antonio Terceiro gitlab at gitlab.com
Fri Sep 11 17:06:00 BRT 2015


Antonio Terceiro pushed to branch master at Noosfero / noosfero


Commits:
296a549d by Larissa Reis at 2015-09-11T16:51:52Z
improvements in core required for the newsletter plugin

* models should use acts_as_having_image transparently
* force margin zero in HTML mailing
* using require_dependency instead of require to avoid error:
  "superclass mismatch for class MailingJob"
* now plugins can schedule jobs via whenever rubygem
* improve 'sample-data' scripts to create some blog posts

signed-off-by: Joenio Costa <joenio at colivre.coop.br>

- - - - -
4df54eb8 by Joenio Costa at 2015-09-11T17:01:26Z
new plugin: newsletter

a plugin that periodically sends newsletter via email to network users

signed-off-by: Larissa Reis <larissa at colivre.coop.br>
signed-off-by: Melissa Wen <melissa at colivre.coop.br>

- - - - -
1d587511 by Antonio Terceiro at 2015-09-11T17:05:26Z
Merge branch 'newsletter-plugin' of https://gitlab.com/joenio/noosfero

- - - - -


28 changed files:

- app/mailers/mailing.rb
- app/models/textile_article.rb
- app/views/mailing/sender/notification.html.erb
- config/schedule.rb
- lib/acts_as_having_image.rb
- + plugins/newsletter/config/schedule.rb
- + plugins/newsletter/controllers/newsletter_plugin_admin_controller.rb
- + plugins/newsletter/controllers/newsletter_plugin_controller.rb
- + plugins/newsletter/db/migrate/20150717195546_newsletter_plugin_newsletters.rb
- + plugins/newsletter/features/newsletter_plugin.feature
- + plugins/newsletter/lib/newsletter_plugin.rb
- + plugins/newsletter/lib/newsletter_plugin/moderate_newsletter.rb
- + plugins/newsletter/lib/newsletter_plugin/newsletter.rb
- + plugins/newsletter/lib/newsletter_plugin/newsletter_mailing.rb
- + plugins/newsletter/public/newsletter_plugin.js
- + plugins/newsletter/public/style.css
- + plugins/newsletter/test/functional/newsletter_plugin_admin_controller_test.rb
- + plugins/newsletter/test/functional/newsletter_plugin_controller_test.rb
- + plugins/newsletter/test/unit/newsletter_plugin_moderate_newsletter_test.rb
- + plugins/newsletter/test/unit/newsletter_plugin_newsletter_mailing_test.rb
- + plugins/newsletter/test/unit/newsletter_plugin_newsletter_test.rb
- + plugins/newsletter/test/unit/newsletter_plugin_test.rb
- + plugins/newsletter/views/newsletter_plugin/mailing_not_found.html.erb
- + plugins/newsletter/views/newsletter_plugin/unsubscribe.html.erb
- + plugins/newsletter/views/newsletter_plugin_admin/index.html.erb
- + plugins/newsletter/views/newsletter_plugin_admin/recipients.html.erb
- + plugins/newsletter/views/tasks/newsletter_plugin/_moderate_newsletter_accept_details.html.erb
- script/sample-articles


Changes:

=====================================
app/mailers/mailing.rb
=====================================
--- a/app/mailers/mailing.rb
+++ b/app/mailers/mailing.rb
@@ -1,4 +1,4 @@
-require 'mailing_job'
+require_dependency 'mailing_job'
 
 class Mailing < ActiveRecord::Base
 
@@ -40,10 +40,8 @@ class Mailing < ActiveRecord::Base
       begin
         Mailing::Sender.notification(self, recipient.email).deliver
         self.mailing_sents.create(:person => recipient)
-      rescue Exception
-        # FIXME should not discard errors silently. An idea is to collect all
-        # errors and generate a task (notification) for the +source+
-        # (environment/organization) listing these errors.
+      rescue Exception => ex
+        Rails.logger.error("#{ex.class.to_s} - #{ex.to_s} at #{__FILE__}:#{__LINE__}")
       end
     end
   end


=====================================
app/models/textile_article.rb
=====================================
--- a/app/models/textile_article.rb
+++ b/app/models/textile_article.rb
@@ -12,7 +12,7 @@ class TextileArticle < TextArticle
     convert_to_html(body)
   end
 
-  def lead
+  def lead(length = nil)
     if abstract.blank?
       super
     else


=====================================
app/views/mailing/sender/notification.html.erb
=====================================
--- a/app/views/mailing/sender/notification.html.erb
+++ b/app/views/mailing/sender/notification.html.erb
@@ -3,7 +3,7 @@
   <head>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
   </head>
-  <body>
+  <body style="margin: 0">
     <%= word_wrap(@message) %>
     <p>
       --<br/>


=====================================
config/schedule.rb
=====================================
--- a/config/schedule.rb
+++ b/config/schedule.rb
@@ -28,3 +28,15 @@ end
 every 30.days do
   runner "ProfileSuggestion.generate_all_profile_suggestions"
 end
+
+# Loads "schedule.rb" files from plugins
+#
+# Allows Noosfero's plugins schedule jobs using `whenever` Ruby gem the same
+# way we do here, just create the file "config/schedule.rb" into the plugin
+# root directory and write jobs using the same syntax used here (see example in
+# the `newsletter` plugin)
+
+Dir.glob("config/plugins/*/config/schedule.rb").each do |filename|
+  filecontent = IO.read(filename)
+  instance_eval(Whenever::NumericSeconds.process_string(filecontent), filename)
+end


=====================================
lib/acts_as_having_image.rb
=====================================
--- a/lib/acts_as_having_image.rb
+++ b/lib/acts_as_having_image.rb
@@ -5,6 +5,7 @@ module ActsAsHavingImage
       belongs_to :image, dependent: :destroy
       scope :with_image, :conditions => [ "#{table_name}.image_id IS NOT NULL" ]
       scope :without_image, :conditions => [ "#{table_name}.image_id IS NULL" ]
+      attr_accessible :image_builder
       self.send(:include, ActsAsHavingImage)
     end
   end
@@ -19,4 +20,4 @@ module ActsAsHavingImage
 
 end
 
-ActiveRecord::Base.extend(ActsAsHavingImage::ClassMethods)
\ No newline at end of file
+ActiveRecord::Base.extend(ActsAsHavingImage::ClassMethods)


=====================================
plugins/newsletter/config/schedule.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/config/schedule.rb
@@ -0,0 +1,3 @@
+every 1.day do
+  runner "NewsletterPlugin.compile_and_send_newsletters"
+end


=====================================
plugins/newsletter/controllers/newsletter_plugin_admin_controller.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/controllers/newsletter_plugin_admin_controller.rb
@@ -0,0 +1,48 @@
+class NewsletterPluginAdminController < PluginAdminController
+
+  def index
+    @newsletter = NewsletterPlugin::Newsletter.where(environment_id: environment.id).first_or_initialize
+
+    if request.post?
+      # token input gives the param as a comma separated string
+      params[:newsletter][:blog_ids] = (params[:newsletter][:blog_ids] || '').split(',')
+
+      params[:newsletter][:person_id] = user.id
+
+      file = params[:file]
+      if file && file[:recipients].present?
+        @newsletter.import_recipients(file[:recipients], file[:name], file[:email], file[:headers].present?)
+      end
+
+      if !@newsletter.errors.any? && @newsletter.update_attributes(params[:newsletter])
+        if params['visualize']
+          @message = @newsletter.body
+          render :file => 'mailing/sender/notification', :layout => false
+        else
+          session[:notice] = _('Newsletter updated.')
+        end
+      else
+        session[:notice] = _('Newsletter could not be saved.')
+      end
+    end
+
+    @blogs = Blog.includes(:profile).find_all_by_id(@newsletter.blog_ids)
+  end
+
+  #TODO: Make this query faster
+  def search_communities
+    communities = environment.communities
+    blogs = Blog.joins(:profile).where(profiles: {environment_id: environment.id})
+
+    found_communities = find_by_contents(:communities, environment, communities, params['q'], {:page => 1})[:results]
+    found_blogs = find_by_contents(:blogs, environment, blogs, params['q'], {:page => 1})[:results]
+
+    results = (found_blogs + found_communities.map(&:blogs).flatten).uniq
+    render :text => results.map { |blog| {:id => blog.id, :name => _("%s in %s") % [blog.name, blog.profile.name]} }.to_json
+  end
+
+  def recipients
+    @additional_recipients = NewsletterPlugin::Newsletter.where(environment_id: environment.id).first_or_initialize.additional_recipients
+  end
+
+end


=====================================
plugins/newsletter/controllers/newsletter_plugin_controller.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/controllers/newsletter_plugin_controller.rb
@@ -0,0 +1,24 @@
+class NewsletterPluginController < PublicController
+
+  before_filter :login_required, :only => :confirm_unsubscription
+
+  def mailing
+    if NewsletterPlugin::NewsletterMailing.exists?(params[:id])
+      mailing = NewsletterPlugin::NewsletterMailing.find(params[:id])
+      @message = mailing.body
+      render :file => 'mailing/sender/notification', :layout => false
+    else
+      render :action => 'mailing_not_found'
+    end
+  end
+
+  def confirm_unsubscription
+    if request.post?
+      session[:notice] = _('You was unsubscribed from newsletter.')
+      @newsletter = NewsletterPlugin::Newsletter.where(environment_id: environment.id).first
+      @newsletter.unsubscribe(current_user.email)
+      redirect_to :controller => :home
+    end
+  end
+
+end


=====================================
plugins/newsletter/db/migrate/20150717195546_newsletter_plugin_newsletters.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/db/migrate/20150717195546_newsletter_plugin_newsletters.rb
@@ -0,0 +1,24 @@
+class NewsletterPluginNewsletters < ActiveRecord::Migration
+  def up
+    create_table :newsletter_plugin_newsletters do |t|
+      t.references :environment, :null => false
+      t.references :person, :null => false
+      t.boolean :enabled, :default => false
+      t.string :subject
+      t.integer :periodicity, :default => 0
+      t.integer :posts_per_blog, :default => 0
+      t.integer :image_id
+      t.text :footer
+      t.text :blog_ids
+      t.text :additional_recipients
+      t.boolean :moderated
+      t.text :unsubscribers
+    end
+    add_index :newsletter_plugin_newsletters, :environment_id, :uniq => true
+  end
+
+  def down
+    remove_index :newsletter_plugin_newsletters, :environment_id
+    drop_table :newsletter_plugin_newsletters
+  end
+end


=====================================
plugins/newsletter/features/newsletter_plugin.feature
=====================================
--- /dev/null
+++ b/plugins/newsletter/features/newsletter_plugin.feature
@@ -0,0 +1,40 @@
+Feature: newsletter plugin
+
+  Background:
+    Given the following users
+      | login | name |
+      | joaosilva | Joao Silva |
+    And I am logged in as "joaosilva"
+
+  Scenario: as admin I can configure plugin
+    Given I am logged in as admin
+    When I go to the environment control panel
+    And I follow "Plugins"
+    Then I should see "Configuration" linking to "/admin/plugin/newsletter"
+
+  Scenario: in the newsletter settings I can see the field to enable/disable
+    Given I am logged in as admin
+    When I go to the environment control panel
+    And I follow "Plugins"
+    And I follow "Configuration"
+    Then I should see "Enable send of newsletter to members on this environment"
+
+  Scenario: redirect to newsletter visualization after save and visualize
+    Given I am logged in as admin
+    And "NewsletterPlugin" plugin is enabled
+    When I go to the environment control panel
+    And I follow "Plugins"
+    And I follow "Configuration"
+    And I press "Save and visualize"
+    Then I should see "If you can't view this email, click here"
+    And I should not see "Newsletter settings"
+
+  Scenario: stay on newsletter settings page after save
+    Given I am logged in as admin
+    And "NewsletterPlugin" plugin is enabled
+    When I go to the environment control panel
+    And I follow "Plugins"
+    And I follow "Configuration"
+    And I press "Save"
+    Then I should see "Newsletter settings"
+    And I should not see "If you can't view this email, click here"


=====================================
plugins/newsletter/lib/newsletter_plugin.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/lib/newsletter_plugin.rb
@@ -0,0 +1,41 @@
+class NewsletterPlugin < Noosfero::Plugin
+
+  def self.plugin_name
+    "Newsletter"
+  end
+
+  def self.plugin_description
+    _("Periodically sends newsletter via email to network users")
+  end
+
+  def js_files
+    'newsletter_plugin.js'
+  end
+
+  def stylesheet?
+    true
+  end
+
+  def self.compile_and_send_newsletters
+    NewsletterPlugin::Newsletter.enabled.each do |newsletter|
+      if newsletter.must_be_sent_today? && newsletter.has_posts_in_the_period?
+        if newsletter.moderated
+          NewsletterPlugin::ModerateNewsletter.create!(
+            :newsletter_id => newsletter.id,
+            :environment => newsletter.environment
+          )
+        else
+          mailing = NewsletterPlugin::NewsletterMailing.create!(
+            :source => newsletter,
+            :subject => newsletter.subject,
+            :body => newsletter.body,
+            :person => newsletter.person,
+            :locale => newsletter.environment.default_locale,
+          )
+          mailing.update_attribute(:body, mailing.body.gsub('{mailing_url}', mailing.url))
+        end
+      end
+    end
+  end
+
+end


=====================================
plugins/newsletter/lib/newsletter_plugin/moderate_newsletter.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/lib/newsletter_plugin/moderate_newsletter.rb
@@ -0,0 +1,53 @@
+class NewsletterPlugin::ModerateNewsletter < Task
+
+  settings_items :newsletter_id, :post_ids
+  validates_presence_of :newsletter_id
+
+  alias :environment :target
+  alias :environment= :target=
+
+  def perform
+    newsletter = NewsletterPlugin::Newsletter.find(newsletter_id)
+    self.post_ids ||= []
+    mailing = NewsletterPlugin::NewsletterMailing.create!(
+      :source => newsletter,
+      :subject => newsletter.subject,
+      :body => newsletter.body(:post_ids => self.post_ids.reject{|id| id.to_i.zero?}),
+      :person => newsletter.person,
+      :locale => newsletter.environment.default_locale,
+    )
+    mailing.update_attribute(:body, mailing.body.gsub('{mailing_url}', mailing.url))
+  end
+
+  def title
+    _("Moderate newsletter")
+  end
+
+  def subject
+    nil
+  end
+
+  def linked_subject
+    nil
+  end
+
+  def information
+    {:message => _('You have to moderate a newsletter.') }
+  end
+
+  def accept_details
+    true
+  end
+
+  def icon
+    {:type => :defined_image, :src => "/images/control-panel/email.png", :name => 'Newsletter'}
+  end
+
+  def target_notification_message
+    _('A newsletter was generated and you need to review it before it is sent to users.')
+  end
+
+  def target_notification_description
+    _('You need to moderate a newsletter.')
+  end
+end


=====================================
plugins/newsletter/lib/newsletter_plugin/newsletter.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/lib/newsletter_plugin/newsletter.rb
@@ -0,0 +1,191 @@
+require 'csv'
+
+class NewsletterPlugin::Newsletter < Noosfero::Plugin::ActiveRecord
+
+  belongs_to :environment
+  belongs_to :person
+  validates_presence_of :environment, :person
+  validates_uniqueness_of :environment_id
+  validates_numericality_of :periodicity, only_integer: true, greater_than: -1, message: _('must be a positive number')
+  validates_numericality_of :posts_per_blog, only_integer: true, greater_than: -1, message: _('must be a positive number')
+
+  attr_accessible :environment, :enabled, :periodicity, :subject, :posts_per_blog, :footer, :blog_ids, :additional_recipients, :person, :person_id, :moderated
+
+  scope :enabled, :conditions => { :enabled => true }
+
+  # These methods are used by NewsletterMailing
+  def people
+    list = unsubscribers.map{|i| "'#{i}'"}.join(',')
+    if list.empty?
+      environment.people
+    else
+      environment.people.all(
+        :joins => "LEFT OUTER JOIN users ON (users.id = profiles.user_id)",
+        :conditions => "users.email NOT IN (#{list})"
+      )
+    end
+  end
+
+  def name
+    environment.name
+  end
+
+  def contact_email
+    environment.noreply_email
+  end
+
+  def top_url
+    environment.top_url
+  end
+
+  def unsubscribe_url
+    "#{top_url}/plugin/newsletter/unsubscribe"
+  end
+
+  serialize :blog_ids, Array
+  serialize :additional_recipients, Array
+
+  def blog_ids
+    self[:blog_ids].map(&:to_i) || []
+  end
+
+  validates_each :blog_ids do |record, attr, value|
+    if record.environment
+      unless value.delete_if(&:zero?).select { |id| !Blog.find_by_id(id) || Blog.find(id).environment != record.environment }.empty?
+        record.errors.add(attr, _('must be valid'))
+      end
+    end
+    unless value.uniq.length == value.length
+      record.errors.add(attr, _('must not have duplicates'))
+    end
+  end
+
+  validates_each :additional_recipients do |record, attr, value|
+    unless value.reject { |recipient| recipient[:email] =~ Noosfero::Constants::EMAIL_FORMAT }.empty?
+      record.errors.add(attr, _('must have only valid emails'))
+    end
+  end
+
+  def next_send_at
+    (self.last_send_at || DateTime.now) + self.periodicity.days
+  end
+
+  def must_be_sent_today?
+    return true unless self.last_send_at
+    Date.today >= self.next_send_at.to_date
+  end
+
+  def blogs
+    Blog.where(:id => blog_ids)
+  end
+
+  def posts(data = {})
+    limit = self.posts_per_blog.zero? ? nil : self.posts_per_blog
+    posts = if self.last_send_at.nil?
+      self.blogs.map{|blog| blog.posts.all(:limit => limit)}.flatten
+    else
+      self.blogs.map{|blog| blog.posts.where("published_at >= :last_send_at", {last_send_at: self.last_send_at}).all(:limit => limit)}.flatten
+    end
+    data[:post_ids].nil? ? posts : posts.select{|post| data[:post_ids].include?(post.id.to_s)}
+  end
+
+  CSS = {
+    'breakingnews-wrap' => 'background-color: #EFEFEF; padding: 40px 0',
+    'breakingnews' => 'width: 640px; margin: auto; background-color: white; border: 1px solid #ddd; border-spacing: 0; padding: 0',
+    'newsletter-public-link' => 'width: 640px; margin: auto; font-size: small; color: #555; font-style: italic; text-align: right; margin-bottom: 15px; font-family: sans;',
+    'newsletter-header' => 'padding: 0',
+    'header-image' => 'width: 100%',
+    'post-image' => 'padding-left: 20px; width: 25%; border-bottom: 1px dashed #DDD',
+    'post-info' => 'font-family: Arial, Verdana; padding: 20px; width: 75%; border-bottom: 1px dashed #DDD',
+    'post-date' => 'font-size: 12px;',
+    'post-lead' => 'font-size: 14px; text-align: justify',
+    'post-title' => 'color: #000; text-decoration: none; font-size: 16px; text-align: justify',
+    'read-more-line' => 'text-align: right',
+    'read-more-link' => 'color: #000; font-size: 12px;',
+    'newsletter-unsubscribe' => 'width: 640px; margin: auto; font-size: small; color: #555; font-style: italic; text-align: center; margin-top: 15px; font-family: sans;'
+  }
+
+  # to be able to generate HTML
+  include ActionView::Helpers
+  include Rails.application.routes.url_helpers
+  include DatesHelper
+
+  def message_to_public_link
+    content_tag(:p, N_("If you can't view this email, %s.") % link_to(N_('click here'), '{mailing_url}'), :id => 'newsletter-public-link')
+  end
+
+  def message_to_unsubscribe
+    content_tag(:div, N_("This is an automatically generated email, please do not reply. If you do not wish to receive future newsletter emails, %s.") % link_to(N_("cancel your subscription here"), self.unsubscribe_url, :style => CSS['public-link']), :style => CSS['newsletter-unsubscribe'], :id => 'newsletter-unsubscribe')
+  end
+
+  def read_more(link_address)
+    content_tag(:p, link_to(N_('Read more'), link_address, :style => CSS['read-more-link']), :style => CSS['read-more-line'])
+  end
+
+  def post_with_image(post)
+    content_tag(:tr,content_tag(:td,tag(:img, :src => "#{self.environment.top_url}#{post.image.public_filename(:big)}", :id => post.id),:style => CSS['post-image'])+content_tag(:td,content_tag(:span, show_date(post.published_at), :style => CSS['post-date'])+content_tag(:h3, link_to(h(post.title), post.url, :style => CSS['post-title']))+content_tag(:p,sanitize(post.lead(190)),:style => CSS['post-lead'])+read_more(post.url), :style => CSS['post-info']))
+  end
+
+  def post_without_image(post)
+    content_tag(:tr, content_tag(:td,content_tag(:span, show_date(post.published_at),:style => CSS['post-date'], :id => post.id)+content_tag(:h3, link_to(h(post.title), post.url,:style => CSS['post-title']))+content_tag(:p,sanitize(post.lead(360)),:style => CSS['post-lead'])+read_more(post.url),:colspan => 2, :style => CSS['post-info']))
+  end
+
+  def body(data = {})
+    content_tag(:div, content_tag(:div, message_to_public_link, :style => CSS['newsletter-public-link'])+content_tag(:table,(self.image.nil? ? '' : content_tag(:tr, content_tag(:th, tag(:img, :src => "#{self.environment.top_url}#{self.image.public_filename}", :style => CSS['header-image']),:colspan => 2),:style => CSS['newsletter-header']))+self.posts(data).map do |post|
+        if post.image
+          post_with_image(post)
+        else
+          post_without_image(post)
+        end
+      end.join()+content_tag(:tr, content_tag(:td, self.footer, :colspan => 2)),:style => CSS['breakingnews'])+content_tag(:div,message_to_unsubscribe, :style => CSS['newsletter-unsubscribe']),:style => CSS['breakingnews-wrap'])
+  end
+
+  def default_subject
+    N_('Breaking news')
+  end
+
+  def subject
+    self[:subject] || default_subject
+  end
+
+  def import_recipients(file, name_column = nil, email_column = nil, headers = nil)
+    name_column ||= 1
+    email_column ||= 2
+    headers ||= false
+
+    if File.extname(file.original_filename) == '.csv'
+      parsed_recipients = []
+      CSV.foreach(file.path, headers: headers) do |row|
+        parsed_recipients << {name: row[name_column.to_i - 1], email: row[email_column.to_i - 1]}
+      end
+      self.additional_recipients = parsed_recipients
+    else
+      #FIXME find a better way to deal with errors
+      self.errors.add(:additional_recipients, _("have unknown file type: %s" % file.original_filename))
+    end
+  end
+
+  acts_as_having_image
+
+  def last_send_at
+    last_mailing = NewsletterPlugin::NewsletterMailing.last(
+      :conditions => {:source_id => self.id}
+    )
+    last_mailing.nil? ? nil : last_mailing.created_at
+  end
+
+  def sanitize(html)
+    html.gsub(/<\/?p>/, '')
+  end
+
+  def has_posts_in_the_period?
+    ! self.posts.empty?
+  end
+
+  serialize :unsubscribers, Array
+
+  def unsubscribe(email)
+    unsubscribers.push(email).uniq!
+  end
+
+end


=====================================
plugins/newsletter/lib/newsletter_plugin/newsletter_mailing.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/lib/newsletter_plugin/newsletter_mailing.rb
@@ -0,0 +1,26 @@
+class NewsletterPlugin::NewsletterMailing < EnvironmentMailing
+
+  attr_accessible :source, :person, :locale
+
+  validates_presence_of :person
+
+  def url
+    "#{self.source.top_url}/plugin/newsletter/mailing/#{self.id}"
+  end
+
+  def source
+    NewsletterPlugin::Newsletter.find(source_id)
+  end
+
+  def deliver
+    source.additional_recipients.each do |recipient|
+      begin
+        Mailing::Sender.notification(self, recipient[:email]).deliver
+      rescue Exception => ex
+        Rails.logger.error("#{ex.class.to_s} - #{ex.to_s} at #{__FILE__}:#{__LINE__}")
+      end
+    end
+    super
+  end
+
+end


=====================================
plugins/newsletter/public/newsletter_plugin.js
=====================================
--- /dev/null
+++ b/plugins/newsletter/public/newsletter_plugin.js
@@ -0,0 +1,21 @@
+jQuery(function($) {
+  $(".newsletter-toggle-link").live('click', function(){
+    element_id = this.getAttribute('element_id');
+    toggle_link = this;
+    $(element_id).slideToggle(400, function() {
+      if ($(toggle_link).find('.ui-icon').hasClass('ui-icon-triangle-1-s'))
+        $(toggle_link).find('.ui-icon')
+          .removeClass('ui-icon-triangle-1-s')
+          .addClass('ui-icon-triangle-1-n');
+      else
+        $(toggle_link).find('.ui-icon')
+          .removeClass('ui-icon-triangle-1-n')
+          .addClass('ui-icon-triangle-1-s');
+    });
+    return false;
+  });
+
+  $('#file_recipients').change(function(){
+    $('#newsletter-file-options input').enable();
+  });
+});


=====================================
plugins/newsletter/public/style.css
=====================================
--- /dev/null
+++ b/plugins/newsletter/public/style.css
@@ -0,0 +1,24 @@
+.newsletter-toggle-link {
+  cursor: pointer;
+}
+#newsletter-footer-field {
+  display: none;
+}
+#newsletter-enabled-field input#settings_enabled {
+  margin-right: 6px;
+}
+
+#newsletter-moderation-preview #newsletter-public-link,
+#newsletter-moderation-preview #newsletter-unsubscribe {
+  display: none;
+}
+
+#newsletter-moderation-preview {
+  margin-left: 25px;
+}
+
+#newsletter-moderation-preview input[type=checkbox] {
+  margin-left: -27px;
+  margin-top: 16px;
+  float: left;
+}


=====================================
plugins/newsletter/test/functional/newsletter_plugin_admin_controller_test.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/test/functional/newsletter_plugin_admin_controller_test.rb
@@ -0,0 +1,176 @@
+require File.dirname(__FILE__) + '/../../../../test/test_helper'
+
+class NewsletterPluginAdminControllerTest < ActionController::TestCase
+
+  def setup
+    @controller = NewsletterPluginAdminController.new
+    @request    = ActionController::TestRequest.new
+    @response   = ActionController::TestResponse.new
+
+    @admin = create_user('admin_newsletter').person
+    @environment = @admin.environment
+    @environment.add_admin(@admin)
+
+    @environment.enable_plugin(NewsletterPlugin)
+    @controller.stubs(:environment).returns(@environment)
+  end
+
+  should 'allow access to admin' do
+    login_as @admin.identifier
+    get :index
+    assert_response :success
+  end
+
+  should 'save footer setting' do
+    login_as @admin.identifier
+    post :index,
+      :newsletter => { :footer => 'footer of newsletter' }
+
+    assert_equal 'footer of newsletter', assigns(:newsletter).footer
+  end
+
+
+  should 'save header image' do
+    login_as @admin.identifier
+    post :index,
+      :newsletter => {
+        :image_builder => {
+          :uploaded_data => fixture_file_upload('/files/rails.png', 'image/png')
+        }
+      }
+    assert_equal 'rails.png', assigns(:newsletter).image.filename
+  end
+
+  should 'save enabled newsletter information' do
+    login_as @admin.identifier
+    post :index,
+      :newsletter => { :enabled => 'true' }
+
+    newsletter = NewsletterPlugin::Newsletter.find_by_environment_id(@environment.id)
+
+    assert newsletter.enabled
+  end
+
+  should 'save periodicity newsletter information' do
+    login_as @admin.identifier
+    post :index,
+      :newsletter => { :periodicity => '10' }
+
+    newsletter = NewsletterPlugin::Newsletter.find_by_environment_id(@environment.id)
+
+    assert_equal 10, newsletter.periodicity
+  end
+
+  should 'save number of posts per blog setting' do
+    login_as @admin.identifier
+    post :index,
+      :newsletter => { :posts_per_blog => '6' }
+
+    assert_equal 6, assigns(:newsletter).posts_per_blog
+  end
+
+  should 'show error if number of posts per blog is not a positive number' do
+    login_as @admin.identifier
+    post :index,
+      :newsletter => { :posts_per_blog => '-4' }
+
+    assert_select 'li', 'Posts per blog must be a positive number'
+  end
+
+  should 'save blogs for compiling newsletter setting' do
+    login_as @admin.identifier
+
+    blog1 = fast_create(Blog)
+    blog1.profile = fast_create(Profile, environment_id: @environment.id)
+    blog1.save
+
+    blog2 = fast_create(Blog)
+    blog2.profile = fast_create(Profile, environment_id: @environment.id)
+    blog2.save
+
+    post :index,
+      :newsletter => { :blog_ids => "#{blog1.id},#{blog2.id}" }
+
+    assert_equivalent [blog1.id,blog2.id], assigns(:newsletter).blog_ids
+  end
+
+  should 'show error if blog is not in environment' do
+    login_as @admin.identifier
+
+    blog = fast_create(Blog)
+    blog.profile = fast_create(Profile, environment_id: fast_create(Environment).id)
+    blog.save
+
+    post :index,
+      :newsletter => { :blog_ids => "#{blog.id}" }
+
+    assert_select 'li', 'Blog ids must be valid'
+  end
+
+  should 'save logged in admin as person' do
+    login_as @admin.identifier
+    post :index, :newsletter => { }
+
+    assert_equal @admin, assigns(:newsletter).person
+  end
+
+  should 'receive csv file from user' do
+    content = <<-EOS
+Coop1,name1 at example.com
+Coop2,name2 at example.com
+Coop3,name3 at example.com
+EOS
+
+    file = Tempfile.new(['recipients', '.csv'])
+    file.write(content)
+    file.rewind
+
+    login_as @admin.identifier
+    post :index, newsletter: {}, :file => { recipients: Rack::Test::UploadedFile.new(file, 'text/csv') }
+
+    file.close
+    file.unlink
+
+    assert_equivalent ["name1 at example.com", "name2 at example.com", "name3 at example.com"], assigns(:newsletter).additional_recipients.map { |recipient| recipient[:email] }
+    assert_equivalent ["Coop1", "Coop2", "Coop3"], assigns(:newsletter).additional_recipients.map { |recipient| recipient[:name] }
+  end
+
+  should 'parse csv file with configuration set by user' do
+    content = <<-EOS
+Id,Name,City,Email
+1,Coop1,Moscow,name1 at example.com
+2,Coop2,Beijing,name2 at example.com
+3,Coop3,Paris,name3 at example.com
+EOS
+
+    file = Tempfile.new(['recipients', '.csv'])
+    file.write(content)
+    file.rewind
+
+    login_as @admin.identifier
+    post :index, newsletter: {}, :file => { recipients: Rack::Test::UploadedFile.new(file, 'text/csv'), headers: 1, name: 2, email: 4 }
+
+    file.close
+    file.unlink
+
+    assert_equivalent ["name1 at example.com", "name2 at example.com", "name3 at example.com"], assigns(:newsletter).additional_recipients.map { |recipient| recipient[:email] }
+    assert_equivalent ["Coop1", "Coop2", "Coop3"], assigns(:newsletter).additional_recipients.map { |recipient| recipient[:name] }
+  end
+
+  should 'list additional recipients' do
+    login_as @admin.identifier
+    get :recipients
+    assert_select 'p', 'There are no additional recipients.'
+
+    newsletter = NewsletterPlugin::Newsletter.create!(environment: @environment, person: fast_create(Person))
+    newsletter.additional_recipients = [ {name: 'Coop1', email: 'name1 at example.com'} ]
+    newsletter.save!
+
+    get :recipients
+    assert_select 'tr' do
+      assert_select 'td', 'Coop1'
+      assert_select 'td', 'name1 at example.com'
+    end
+  end
+
+end


=====================================
plugins/newsletter/test/functional/newsletter_plugin_controller_test.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/test/functional/newsletter_plugin_controller_test.rb
@@ -0,0 +1,37 @@
+require File.dirname(__FILE__) + '/../../../../test/test_helper'
+
+class NewsletterPluginControllerTest < ActionController::TestCase
+
+  def setup
+    @controller = NewsletterPluginController.new
+    @request    = ActionController::TestRequest.new
+    @response   = ActionController::TestResponse.new
+    environment = fast_create(Environment)
+    environment.enable_plugin(NewsletterPlugin)
+    @controller.stubs(:environment).returns(environment)
+  end
+
+  should 'require login to confirm unsubscription' do
+    post :confirm_unsubscription
+    assert_response 302
+  end
+
+  should 'open unsubscription page for anonymous' do
+    get :unsubscribe
+    assert_response :success
+  end
+
+  should 'add user email from unsubscribers list' do
+    NewsletterPlugin::Newsletter.create!(
+      :environment => @controller.environment,
+      :person => fast_create(Person)
+    )
+    maria = create_user("maria").person
+    login_as("maria")
+    post :confirm_unsubscription
+    assert_response :redirect
+    assert_redirected_to :controller => 'home'
+    assert_includes assigns(:newsletter).unsubscribers, maria.email
+  end
+
+end


=====================================
plugins/newsletter/test/unit/newsletter_plugin_moderate_newsletter_test.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/test/unit/newsletter_plugin_moderate_newsletter_test.rb
@@ -0,0 +1,50 @@
+require 'test_helper'
+
+class NewsletterPluginModerateNewsletterTest < ActiveSupport::TestCase
+
+  should 'validates presence of newsletter_id' do
+    task = NewsletterPlugin::ModerateNewsletter.new
+    task.valid?
+    assert task.errors.include?(:newsletter_id)
+
+    task.newsletter_id = 1
+    task.valid?
+    refute task.errors.include?(:newsletter_id)
+  end
+
+  should 'create mailing on perform' do
+    person = create_user('john').person
+    newsletter = NewsletterPlugin::Newsletter.create!(:environment => fast_create(Environment), :person => person, :enabled => true)
+    task = NewsletterPlugin::ModerateNewsletter.create!(
+      :newsletter_id => newsletter.id,
+      :target => newsletter.environment
+    )
+
+    assert_difference 'NewsletterPlugin::NewsletterMailing.count', 1 do
+      task.finish
+    end
+  end
+
+  should 'set posts for mailing body on perform' do
+    person = create_user('john').person
+    blog = fast_create(Blog, profile_id: person.id)
+    post_1 = fast_create(TextileArticle, :name => 'First post', :profile_id => person.id, :parent_id => blog.id, :body => 'Test')
+    post_2 = fast_create(TextileArticle, :name => 'Second post', :profile_id => person.id, :parent_id => blog.id, :body => 'Test')
+    post_3 = fast_create(TextileArticle, :name => 'Third post', :profile_id => person.id, :parent_id => blog.id, :body => 'Test')
+
+    newsletter = NewsletterPlugin::Newsletter.create!(:environment => person.environment, :person => person, :enabled => true)
+    newsletter.blog_ids = [blog.id]
+    newsletter.save!
+
+    task = NewsletterPlugin::ModerateNewsletter.create!(
+      :newsletter_id => newsletter.id,
+      :target => newsletter.environment,
+      :post_ids => [post_1.id.to_s,post_2.id.to_s]
+    )
+
+    task.finish
+    assert_match /First post/, NewsletterPlugin::NewsletterMailing.last.body
+    assert_match /Second post/, NewsletterPlugin::NewsletterMailing.last.body
+    assert_not_match /Third post/, NewsletterPlugin::NewsletterMailing.last.body
+  end
+end


=====================================
plugins/newsletter/test/unit/newsletter_plugin_newsletter_mailing_test.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/test/unit/newsletter_plugin_newsletter_mailing_test.rb
@@ -0,0 +1,72 @@
+require 'test_helper'
+
+class NewsletterPluginNewsletterMailingTest < ActiveSupport::TestCase
+
+  def setup
+    ActionMailer::Base.delivery_method = :test
+    ActionMailer::Base.perform_deliveries = true
+    ActionMailer::Base.deliveries = []
+  end
+
+  should 'require source id' do
+    mailing = NewsletterPlugin::NewsletterMailing.new
+    mailing.valid?
+    assert mailing.errors[:source_id].any?
+
+    mailing.source_id = NewsletterPlugin::Newsletter.create!(:environment => fast_create(Environment), :person => fast_create(Person)).id
+    mailing.valid?
+    refute mailing.errors[:source_id].any?
+  end
+
+  should 'deliver mail from noreply environment email address' do
+    environment = fast_create(Environment, :noreply_email => 'noreply at localhost')
+    person = fast_create Person
+    newsletter = NewsletterPlugin::Newsletter.create!(:environment => environment, :person => person, :enabled => true)
+    mailing = NewsletterPlugin::NewsletterMailing.create!(
+      :source => newsletter,
+      :subject => newsletter.subject,
+      :body => newsletter.body,
+      :person => newsletter.person,
+      :locale => environment.default_locale,
+    )
+    response = NewsletterPlugin::NewsletterMailing::Sender.notification(mailing, 'recipient at example.com').deliver
+    assert_equal 'noreply at localhost', response.from.join
+  end
+
+  should 'also send to additional recipients' do
+    environment = fast_create(Environment, :name => 'Network')
+    person = create_user('betty', :environment_id => environment.id).person
+    newsletter = NewsletterPlugin::Newsletter.create!(:environment => environment, :person => person)
+
+    newsletter.additional_recipients = [{name: 'example', email: 'exemple at mail.co'}, {name: 'jon', email: 'jonsnow at mail.co'}]
+    newsletter.save!
+
+    mailing = NewsletterPlugin::NewsletterMailing.create!(
+      :source => newsletter,
+      :subject => newsletter.subject,
+      :body => newsletter.body,
+      :person => newsletter.person,
+      :locale => newsletter.environment.default_locale,
+    )
+
+    process_delayed_job_queue
+    assert_equal 3, ActionMailer::Base.deliveries.count
+  end
+
+  should 'generate url to view mailing' do
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :person => fast_create(Person),
+      :enabled => true
+    )
+    mailing = NewsletterPlugin::NewsletterMailing.create!(
+      :source => newsletter,
+      :subject => newsletter.subject,
+      :body => newsletter.body,
+      :person => newsletter.person,
+      :locale => newsletter.environment.default_locale,
+    )
+    assert_equal "http://localhost/plugin/newsletter/mailing/#{mailing.id}", mailing.url
+  end
+
+end


=====================================
plugins/newsletter/test/unit/newsletter_plugin_newsletter_test.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/test/unit/newsletter_plugin_newsletter_test.rb
@@ -0,0 +1,414 @@
+require 'test_helper'
+
+class NewsletterPluginNewsletterTest < ActiveSupport::TestCase
+
+  should 'throws exception when try to create newsletters without reference do environment' do
+    assert_raises ActiveRecord::RecordInvalid do |e|
+      NewsletterPlugin::Newsletter.create!
+      assert_match /Profile can't be blank/, e.to_s
+    end
+  end
+
+  should 'allow save only one newsletter by environment' do
+    environment = fast_create Environment
+    NewsletterPlugin::Newsletter.create!(:environment => environment, :person => fast_create(Person))
+    assert_raises ActiveRecord::RecordInvalid do |e|
+      NewsletterPlugin::Newsletter.create!(:environment => environment, :person => fast_create(Person))
+      assert_match /Profile has already been taken/, e.to_s
+    end
+  end
+
+  should 'collect enabled newsletters' do
+    enabled_newsletters = []
+    5.times do
+      environment = fast_create(Environment)
+      enabled = environment.id % 2 == 0
+      newsletter = NewsletterPlugin::Newsletter.create!(
+        :environment => environment,
+        :enabled => enabled,
+        :person => fast_create(Person))
+      enabled_newsletters << newsletter.id if enabled
+    end
+    assert_equal enabled_newsletters, NewsletterPlugin::Newsletter.enabled.map(&:id)
+  end
+
+  should 'people of newsletters are the same environment members' do
+    3.times do
+      environment = fast_create(Environment)
+      3.times do
+        fast_create(Person, environment_id: environment)
+      end
+      NewsletterPlugin::Newsletter.create!(
+        :environment => environment,
+        :enabled => true,
+        :person => fast_create(Person))
+    end
+    NewsletterPlugin::Newsletter.enabled.each do |newsletter|
+      assert_not_equal [], newsletter.people
+      assert_equal newsletter.environment.people, newsletter.people
+    end
+  end
+
+  should 'save period for newsletter' do
+    environment = fast_create Environment
+    NewsletterPlugin::Newsletter.create!(
+      :environment => environment,
+      :periodicity => '3',
+      :person => fast_create(Person))
+
+    assert_equal 3, NewsletterPlugin::Newsletter.find_by_environment_id(environment.id).periodicity
+  end
+
+  should 'save period as number only' do
+    environment = fast_create Environment
+    assert_raises ActiveRecord::RecordInvalid do |e|
+      NewsletterPlugin::Newsletter.create!(:environment => environment, :periodicity => 'one week' )
+      assert_match /Periodicity must be a positive number/, e.to_s
+    end
+  end
+
+  should 'save period as a positive number only' do
+    environment = fast_create Environment
+    assert_raises ActiveRecord::RecordInvalid do |e|
+      NewsletterPlugin::Newsletter.create!(:environment => environment, :periodicity => -1 )
+      assert_match /Periodicity must be a positive number/, e.to_s
+    end
+  end
+
+  should 'save reference to environment blog' do
+    environment = fast_create Environment
+    blog = fast_create(Blog)
+    blog.profile = fast_create(Profile, environment_id: environment.id)
+    blog.save
+    assert_nothing_raised ActiveRecord::RecordInvalid do
+      NewsletterPlugin::Newsletter.create!(
+        :environment => environment,
+        :blog_ids => [blog.id],
+        :person => fast_create(Person))
+    end
+  end
+
+  should 'not save reference to unknown blog' do
+    environment = fast_create Environment
+    blog = fast_create(Blog)
+    blog.profile = fast_create(Profile, environment_id: fast_create(Environment).id)
+    blog.save
+    assert_raises ActiveRecord::RecordInvalid do |e|
+      NewsletterPlugin::Newsletter.create!(:environment => environment, :blog_ids => [blog.id])
+      assert_match /Blog ids must be valid/, e.to_s
+    end
+    assert_raises ActiveRecord::RecordInvalid do |e|
+      NewsletterPlugin::Newsletter.create!(:environment => environment, :blog_ids => [blog.id*2])
+      assert_match /Blog ids must be valid/, e.to_s
+    end
+  end
+
+  should 'not save duplicates for blog ids' do
+    environment = fast_create Environment
+    blog = fast_create(Blog)
+    blog.profile = fast_create(Profile, environment_id: environment.id)
+    blog.save
+    assert_raises ActiveRecord::RecordInvalid do |e|
+      NewsletterPlugin::Newsletter.create!(:environment => environment, :blog_ids => [blog.id, blog.id])
+      assert_match /Blog ids must not have duplicates/, e.to_s
+    end
+  end
+
+  should "not send newsletters if periodicity isn't expired" do
+    newsletter = NewsletterPlugin::Newsletter.new
+    newsletter.periodicity = 10
+    newsletter.stubs(:last_send_at).returns(DateTime.parse("2015-01-01"))
+    Date.stubs(:today).returns(Date.parse("2015-01-07"))
+    assert_equal false, newsletter.must_be_sent_today?
+  end
+
+  should 'send newsletters when periodicity expires' do
+    newsletter = NewsletterPlugin::Newsletter.new
+    newsletter.periodicity = 10
+    newsletter.stubs(:last_send_at).returns(DateTime.parse("2015-01-01"))
+    Date.stubs(:today).returns(Date.parse("2015-01-15"))
+    assert_equal true, newsletter.must_be_sent_today?
+  end
+
+  should 'send now if never send before' do
+    newsletter = NewsletterPlugin::Newsletter.new(:environment => fast_create(Environment))
+    newsletter.periodicity = 10
+    assert newsletter.must_be_sent_today?
+  end
+
+  should 'validate email format for additional recipients' do
+    environment = fast_create Environment
+    assert_raises ActiveRecord::RecordInvalid do |e|
+      NewsletterPlugin::Newsletter.create!(:environment => environment, :person => fast_create(Person), additional_recipients: [{name: 'Cooperative', email: 'cooperative at example'}])
+      assert_match /Additional recipients must have only valid emails/, e.to_s
+    end
+    assert_nothing_raised ActiveRecord::RecordInvalid do |e|
+      NewsletterPlugin::Newsletter.create!(:environment => environment, :person => fast_create(Person), additional_recipients: [{name: 'Cooperative', email: 'cooperative at example.com'}])
+    end
+  end
+
+  should 'parse additional recipients' do
+    content = <<-EOS
+Coop1,name1 at example.com
+Coop2,name2 at example.com
+Coop3,name3 at example.com
+EOS
+
+    file = Tempfile.new(['recipients', '.csv'])
+    file.write(content)
+    file.rewind
+
+    environment = fast_create Environment
+    newsletter = NewsletterPlugin::Newsletter.create!(:environment => environment, :person => fast_create(Person))
+    newsletter.import_recipients(Rack::Test::UploadedFile.new(file, 'text/csv'))
+
+    file.close
+    file.unlink
+
+    assert_equivalent ["name1 at example.com", "name2 at example.com", "name3 at example.com"], newsletter.additional_recipients.map { |recipient| recipient[:email] }
+    assert_equivalent ["Coop1", "Coop2", "Coop3"], newsletter.additional_recipients.map { |recipient| recipient[:name] }
+  end
+
+  should 'only parse csv files' do
+    content = <<-EOS
+Coop1,name1 at example.com
+Coop2,name2 at example.com
+Coop3,name3 at example.com
+EOS
+
+    file = Tempfile.new(['recipients', '.txt'])
+    file.write(content)
+    file.rewind
+
+    environment = fast_create Environment
+    newsletter = NewsletterPlugin::Newsletter.create!(:environment => environment, :person => fast_create(Person))
+    newsletter.import_recipients(Rack::Test::UploadedFile.new(file))
+
+    file.close
+    file.unlink
+
+    assert_equal [], newsletter.additional_recipients
+    assert_match /Additional recipients have unknown file type.*/, newsletter.errors.full_messages[0]
+  end
+
+  should 'parse additional recipients with given column number and header' do
+    content = <<-EOS
+Id,Name,City,Email
+1,Coop1,Moscow,name1 at example.com
+2,Coop2,Beijing,name2 at example.com
+3,Coop3,Paris,name3 at example.com
+EOS
+
+    file = Tempfile.new(['recipients', '.csv'])
+    file.write(content)
+    file.rewind
+
+    environment = fast_create Environment
+    newsletter = NewsletterPlugin::Newsletter.create!(:environment => environment, :person => fast_create(Person))
+    newsletter.import_recipients(Rack::Test::UploadedFile.new(file, 'text/csv'), 2, 4, true)
+
+    file.close
+    file.unlink
+
+    assert_equivalent ["name1 at example.com", "name2 at example.com", "name3 at example.com"], newsletter.additional_recipients.map { |recipient| recipient[:email] }
+    assert_equivalent ["Coop1", "Coop2", "Coop3"], newsletter.additional_recipients.map { |recipient| recipient[:name] }
+  end
+
+  should 'retrieve blogs related to newsletter' do
+    environment = fast_create Environment
+    community = fast_create(Community, :environment_id => environment.id)
+    blog1 = fast_create(Blog, :profile_id => community.id)
+    blog2 = fast_create(Blog, :profile_id => community.id)
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => environment, :blog_ids => [blog1.id, blog2.id], :person => fast_create(Person)
+    )
+    assert_equivalent [blog1, blog2], newsletter.blogs
+  end
+
+  should 'return empty if has no related blogs' do
+    environment = fast_create Environment
+    newsletter = NewsletterPlugin::Newsletter.create!(:environment => environment, :person => fast_create(Person))
+    assert_empty newsletter.blogs
+  end
+
+  should 'list posts for all selected blogs' do
+    environment = fast_create Environment
+    community = fast_create(Community, :environment_id => environment.id)
+    blog = fast_create(Blog, :profile_id => community.id)
+    post = fast_create(TextArticle, :parent_id => blog.id, :name => 'the last news')
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => environment,
+      :blog_ids => [blog.id],
+      :person => fast_create(Person))
+    assert_includes newsletter.posts, post
+  end
+
+  should 'generate HTML content using posts of selected blogs' do
+    environment = fast_create Environment
+    community = fast_create(Community, :environment_id => environment.id)
+    blog = fast_create(Blog, :profile_id => community.id)
+    fast_create(TextArticle, :profile_id => community.id, :parent_id => blog.id, :name => 'the last news')
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => environment,
+      :blog_ids => [blog.id],
+      :person => fast_create(Person))
+    assert_tag_in_string newsletter.body, :tag => 'a', :content => 'the last news'
+  end
+
+  should 'limit the number of posts per blog' do
+    environment = fast_create Environment
+    community = fast_create(Community, :environment_id => environment.id)
+    blog = fast_create(Blog, :profile_id => community.id)
+    fast_create(TextArticle, :parent_id => blog.id, :name => 'the last news 1')
+    fast_create(TextArticle, :parent_id => blog.id, :name => 'the last news 2')
+    fast_create(TextArticle, :parent_id => blog.id, :name => 'the last news 3')
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => environment,
+      :blog_ids => [blog.id],
+      :person => fast_create(Person),
+      :posts_per_blog => 2)
+    assert_equal 2, newsletter.posts.count
+  end
+
+  should 'include all posts before today' do
+    environment = fast_create Environment
+    community = fast_create(Community, :environment_id => environment.id)
+    blog = fast_create(Blog, :profile_id => community.id)
+
+    post1 = fast_create(TextArticle, :parent_id => blog.id, :name => 'the last news 1',
+                :published_at => DateTime.parse("2015-01-01"))
+    post2 = fast_create(TextArticle, :parent_id => blog.id, :name => 'the last news 2',
+                :published_at => DateTime.parse("2015-01-09"))
+
+    Date.stubs(:today).returns(DateTime.parse("2015-01-10").to_date)
+
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => environment,
+      :blog_ids => [blog.id],
+      :person => fast_create(Person))
+
+    newsletter_posts = newsletter.posts
+    assert_includes newsletter_posts, post1
+    assert_includes newsletter_posts, post2
+  end
+
+  should 'not include posts already sent' do
+    environment = fast_create Environment
+    community = fast_create(Community, :environment_id => environment.id)
+    blog = fast_create(Blog, :profile_id => community.id)
+
+    post1 = fast_create(TextArticle, :parent_id => blog.id, :name => 'the last news 1',
+                :published_at => DateTime.parse("2015-01-01"))
+    post2 = fast_create(TextArticle, :parent_id => blog.id, :name => 'the last news 2',
+                :published_at => DateTime.parse("2015-01-09"))
+
+    Date.stubs(:today).returns(DateTime.parse("2015-01-10").to_date)
+
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => environment,
+      :blog_ids => [blog.id],
+      :person => fast_create(Person))
+    newsletter.stubs(:last_send_at).returns(DateTime.parse("2015-01-05"))
+
+    newsletter_posts = newsletter.posts
+    assert_not_includes newsletter_posts, post1
+    assert_includes newsletter_posts, post2
+  end
+
+  should 'sanitize tags <p> from news lead' do
+    environment = fast_create Environment
+    community = fast_create(Community, :environment_id => environment.id)
+    blog = fast_create(Blog, :profile_id => community.id)
+    post = fast_create(TextArticle, :parent_id => blog.id,
+                :name => 'the last news 1',
+                :profile_id => community.id,
+                :body => "<p>paragraph of news</p>")
+
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => environment,
+      :blog_ids => [blog.id],
+      :person => fast_create(Person))
+
+    assert_match /<p>paragraph of news<\/p>/, post.body
+    assert_not_match /<p>paragraph of news<\/p>/, newsletter.body
+  end
+
+  should 'filter posts when listing posts for newsletter' do
+    person = fast_create(Person)
+    blog = fast_create(Blog, profile_id: person.id)
+
+    post_1 = fast_create(TextileArticle, :name => 'First post', :profile_id => person.id, :parent_id => blog.id, :body => 'Test')
+    post_2 = fast_create(TextileArticle, :name => 'Second post', :profile_id => person.id, :parent_id => blog.id, :body => 'Test')
+    post_3 = fast_create(TextileArticle, :name => 'Third post', :profile_id => person.id, :parent_id => blog.id, :body => 'Test')
+
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => person.environment,
+      :blog_ids => [blog.id],
+      :person => person)
+
+    assert_equivalent [post_2.id, post_3.id], newsletter.posts({post_ids: [post_2.id.to_s, post_3.id.to_s]}).map(&:id)
+  end
+
+  should 'filter posts in body for newsletter' do
+    person = fast_create(Person)
+    blog = fast_create(Blog, profile_id: person.id)
+
+    post_1 = fast_create(TextileArticle, :name => 'First post', :profile_id => person.id, :parent_id => blog.id, :body => 'Test')
+    post_2 = fast_create(TextileArticle, :name => 'Second post', :profile_id => person.id, :parent_id => blog.id, :body => 'Test')
+    post_3 = fast_create(TextileArticle, :name => 'Third post', :profile_id => person.id, :parent_id => blog.id, :body => 'Test')
+
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => person.environment,
+      :blog_ids => [blog.id],
+      :person => person)
+
+    assert_match /First post/, NewsletterPlugin::Newsletter.last.body({post_ids: [post_1.id.to_s, post_3.id.to_s]})
+    assert_not_match /Second post/, NewsletterPlugin::Newsletter.last.body({post_ids: [post_1.id.to_s, post_3.id.to_s]})
+    assert_match /Third post/, NewsletterPlugin::Newsletter.last.body({post_ids: [post_1.id.to_s, post_3.id.to_s]})
+  end
+
+  should 'add email to unsubscribers list' do
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :person => fast_create(Person)
+    )
+    newsletter.unsubscribe("ze at localhost.localdomain")
+    assert_includes newsletter.unsubscribers, "ze at localhost.localdomain"
+  end
+
+  should 'not add same email twice to unsubscribers list' do
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :person => fast_create(Person)
+    )
+    newsletter.unsubscribe("ze at localhost.localdomain")
+    newsletter.unsubscribe("ze at localhost.localdomain")
+    assert_equal ["ze at localhost.localdomain"], newsletter.unsubscribers
+  end
+
+  should "filter newsletter's recipients using unsubscribers list" do
+    environment = fast_create Environment
+    p1 = create_user("person1", :environment_id => environment.id).person
+    p2 = create_user("person2", :environment_id => environment.id).person
+    p3 = create_user("person3", :environment_id => environment.id).person
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => environment,
+      :person => fast_create(Person)
+    )
+    newsletter.unsubscribe(p2.email)
+    assert_equivalent [p1, p3], newsletter.people
+  end
+
+  should "no filter newsletter's recipients if unsubscribers list empty" do
+    environment = fast_create Environment
+    p1 = create_user("person1", :environment_id => environment.id).person
+    p2 = create_user("person2", :environment_id => environment.id).person
+    p3 = create_user("person3", :environment_id => environment.id).person
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => environment,
+      :person => fast_create(Person)
+    )
+    assert_equivalent [p1, p2, p3], newsletter.people
+  end
+
+end


=====================================
plugins/newsletter/test/unit/newsletter_plugin_test.rb
=====================================
--- /dev/null
+++ b/plugins/newsletter/test/unit/newsletter_plugin_test.rb
@@ -0,0 +1,113 @@
+require 'test_helper'
+
+class NewsletterPluginTest < ActiveSupport::TestCase
+
+  def setup
+    NewsletterPlugin::Newsletter.any_instance.stubs(:must_be_sent_today?).returns(true)
+    NewsletterPlugin::Newsletter.any_instance.stubs(:has_posts_in_the_period?).returns(true)
+  end
+
+  should 'update newsletter send date only for enabled newsletters' do
+    newsletter_enabled = NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :enabled => true,
+      :subject => 'newsletter test',
+      :person => fast_create(Person))
+
+    newsletter_disabled = NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :enabled => false,
+      :subject => 'newsletter test',
+      :person => fast_create(Person))
+
+    NewsletterPlugin.compile_and_send_newsletters
+
+    newsletter_enabled.reload
+    newsletter_disabled.reload
+
+    assert_not_nil newsletter_enabled.last_send_at
+    assert_nil newsletter_disabled.last_send_at
+  end
+
+  should 'create and schedule newsletter mailing if not moderated' do
+    NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :enabled => true,
+      :moderated => false,
+      :subject => 'newsletter test',
+      :person => fast_create(Person))
+
+    assert_difference 'NewsletterPlugin::NewsletterMailing.count', 1 do
+      NewsletterPlugin.compile_and_send_newsletters
+    end
+
+    assert_equal 0, NewsletterPlugin::ModerateNewsletter.count
+  end
+
+  should 'use same environment locale on mailing' do
+    NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment, :default_language => 'pt_BR'),
+      :enabled => true,
+      :subject => 'newsletter test',
+      :person => fast_create(Person))
+
+    NewsletterPlugin.compile_and_send_newsletters
+    assert_equal 'pt_BR', NewsletterPlugin::NewsletterMailing.last.locale
+  end
+
+  should 'create newsletter moderation task if newsletter is moderated' do
+    adminuser = create_user.person
+    Environment.any_instance.stubs(:admins).returns([adminuser])
+
+    NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :enabled => true,
+      :moderated => true,
+      :subject => 'newsletter test',
+      :person => fast_create(Person))
+
+    assert_difference 'NewsletterPlugin::ModerateNewsletter.count', 1 do
+      NewsletterPlugin.compile_and_send_newsletters
+    end
+
+    assert_equal 0, NewsletterPlugin::NewsletterMailing.count
+  end
+
+  should 'not create mailing if has no posts in the period' do
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :person => fast_create(Person),
+      :enabled => true
+    )
+    NewsletterPlugin::Newsletter.any_instance.stubs(:must_be_sent_today?).returns(true)
+    NewsletterPlugin::Newsletter.any_instance.stubs(:has_posts_in_the_period?).returns(false)
+    assert_no_difference 'NewsletterPlugin::NewsletterMailing.count' do
+      NewsletterPlugin.compile_and_send_newsletters
+    end
+  end
+
+  should 'not create mailing if doesnt must be sent today' do
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :person => fast_create(Person),
+      :enabled => true
+    )
+    NewsletterPlugin::Newsletter.any_instance.stubs(:must_be_sent_today?).returns(false)
+    NewsletterPlugin::Newsletter.any_instance.stubs(:has_posts_in_the_period?).returns(true)
+    assert_no_difference 'NewsletterPlugin::NewsletterMailing.count' do
+      NewsletterPlugin.compile_and_send_newsletters
+    end
+  end
+
+  should 'create mailing' do
+    newsletter = NewsletterPlugin::Newsletter.create!(
+      :environment => fast_create(Environment),
+      :person => fast_create(Person),
+      :enabled => true
+    )
+    assert_difference 'NewsletterPlugin::NewsletterMailing.count' do
+      NewsletterPlugin.compile_and_send_newsletters
+    end
+  end
+
+end


=====================================
plugins/newsletter/views/newsletter_plugin/mailing_not_found.html.erb
=====================================
--- /dev/null
+++ b/plugins/newsletter/views/newsletter_plugin/mailing_not_found.html.erb
@@ -0,0 +1,3 @@
+<h2><%= _("Mailing not found") %></h2>
+
+<p><%= _("There is no mailing with id #%s") % params[:id] %></p>


=====================================
plugins/newsletter/views/newsletter_plugin/unsubscribe.html.erb
=====================================
--- /dev/null
+++ b/plugins/newsletter/views/newsletter_plugin/unsubscribe.html.erb
@@ -0,0 +1,11 @@
+<h1><%= _('Cancel newsletter subscription') %></h1>
+
+<h4>
+<%= _("I don't want to receive future newsletter emails from this network.") %>
+</h4>
+
+<%= _('Send an email to %s requesting your unsubscription or click on the button below.') % link_to(environment.contact_email, "mailto:#{environment.contact_email}?subject=#{_('Cancel newsletter subscription')}") %>
+
+<% button_bar do %>
+  <%= button :ok, _('Confirm unsubscription'), {:action => 'confirm_unsubscription'}, :method => 'post' %>
+<% end %>


=====================================
plugins/newsletter/views/newsletter_plugin_admin/index.html.erb
=====================================
--- /dev/null
+++ b/plugins/newsletter/views/newsletter_plugin_admin/index.html.erb
@@ -0,0 +1,92 @@
+<h1><%= _('Newsletter settings') %></h1>
+
+<%= render :file => 'shared/tiny_mce' %>
+
+<%= error_messages_for :newsletter %>
+
+<%= form_for(:newsletter, html: { multipart: true }) do |f| %>
+  <%= labelled_form_field(
+     content_tag('h3', hidden_field_tag('newsletter[enabled]', false) +
+                  f.check_box('enabled') +
+                  _('Enable send of newsletter to members on this environment'), :id => 'newsletter-enabled-field'),
+    nil)
+  %>
+
+  <%= labelled_form_field(
+    content_tag('span', hidden_field_tag('newsletter[moderated]', false) + f.check_box('moderated') + _('Moderate newsletter each time before sending to users.')), nil)
+  %>
+
+  <h2>
+  <%= _('Content') %>
+  </h2>
+
+  <%= labelled_form_field(
+    _('Period (in days) for news compilation'), f.number_field(:periodicity, min: '0'))
+  %>
+
+  <%= labelled_form_field(
+    _('Number of posts compiled per blog (choose 0 for all posts since last newsletter)'), f.number_field(:posts_per_blog, min: '0'))
+  %>
+
+  <p><%= _('Blogs from which news will be compiled') %></p>
+  <% search_action = url_for(:action => 'search_communities') %>
+  <% selected_blogs = @blogs.map { |blog| {:id => blog.id, :name => _("%s in %s") % [blog.name, blog.profile.name]} } %>
+  <%= token_input_field_tag(
+    'newsletter[blog_ids]', 'search-communities', search_action,
+    { hint_text: _('Type in the communities\' or blogs\' names'),
+      focus: false, pre_populate: selected_blogs }) %>
+
+  <br/>
+
+  <h2>
+  <%= _('Recipients') %>
+  </h2>
+
+  <p>
+    <%= _('You can follow the link below to see which e-mails are currently being used as additional recipients for this newsletter.') %>
+  </p>
+  <p>
+  <%= link_to 'Currently set additional recipients', {action: :recipients}, target: '_blank' %>
+  </p>
+
+  <p><%= _('You can set additional e-mails to send the newsletter to in addition to all environment\'s users that already receive the newsletter by default. To do that, you need to upload a CSV file that contains a column for the person\'s or enterprise\'s name as well as a column with their e-mail.') %></p>
+
+  <%= labelled_form_field(
+    _('Additional recipients for newsletter'), file_field_tag('file[recipients]', accept: '.csv'))
+  %>
+
+  <div id='newsletter-file-options'>
+    <%= labelled_form_field(
+      content_tag('span', check_box_tag('file[headers]', 1, false, disabled: true) + _('The CSV file contains a header row')), nil)
+    %>
+
+    <%= labelled_form_field(
+      _('Number of colunm with name field'), number_field_tag('file[name]', '1', min: '1', disabled: true))
+    %>
+
+    <%= labelled_form_field(
+      _('Number of colunm with email field'), number_field_tag('file[email]', '2', min: '1', disabled: true))
+    %>
+  </div>
+
+  <h2>
+  <%= _('Layout') %>
+  </h2>
+
+  <%= f.fields_for :image_builder, @newsletter.image do |i| %>
+    <%= file_field_or_thumbnail(_('Header image (images with 640px width):'), @newsletter.image, i) %>
+  <% end %>
+
+  <%= labelled_form_field(
+    content_tag('h3', ui_icon('ui-icon-triangle-1-s') +
+                _('Newsletter footer'), :class => 'newsletter-toggle-link', :element_id => '#newsletter-footer-field'),
+    content_tag('div',
+      f.text_area(:footer, :style => 'width: 100%', :class => 'mceEditor'),
+      :id => 'newsletter-footer-field'
+    ))
+  %>
+  <% button_bar do %>
+    <%= submit_button :save, _('Save') %>
+    <%= submit_button :save, _('Save and visualize'), :name => "visualize", :cancel => {:controller => 'plugins'}  %>
+  <% end %>
+<% end %>


=====================================
plugins/newsletter/views/newsletter_plugin_admin/recipients.html.erb
=====================================
--- /dev/null
+++ b/plugins/newsletter/views/newsletter_plugin_admin/recipients.html.erb
@@ -0,0 +1,21 @@
+<h1><%= _('Additional recipients') %></h1>
+
+<% if @additional_recipients.present? %>
+  <table border="0" cellspacing="5" cellpadding="5">
+    <tr>
+      <th><%= _('Name') %></th>
+      <th><%= _('E-mail') %></th>
+    </tr>
+    <% @additional_recipients.each do |recipient| %>
+      <tr>
+        <td><%= recipient[:name] %></td>
+        <td><%= recipient[:email] %></td>
+      </tr>
+    <% end %>
+  </table>
+<% else %>
+  <p><%= _('There are no additional recipients.') %></p>
+<% end %>
+
+<br/>
+<%= button :back, _('Back'), action: :index %>


=====================================
plugins/newsletter/views/tasks/newsletter_plugin/_moderate_newsletter_accept_details.html.erb
=====================================
--- /dev/null
+++ b/plugins/newsletter/views/tasks/newsletter_plugin/_moderate_newsletter_accept_details.html.erb
@@ -0,0 +1,17 @@
+<% newsletter = NewsletterPlugin::Newsletter.find(task.newsletter_id) %>
+
+<h1><%= _('Check posts you want to include') %></h1>
+
+<div id='newsletter-moderation-preview'>
+  <% newsletter_content = newsletter.body.gsub(/width: 640px;/,'').sub(/#{NewsletterPlugin::Newsletter::CSS['breaking-news-wrap']}/, '') %>
+
+  <% newsletter.posts.each do |post| %>
+    <% input_name = "tasks[#{task.id}][task][post_ids][]" %>
+    <% post_check_box = hidden_field_tag(input_name, '0') +check_box_tag(input_name, post.id, true) %>
+
+    <% newsletter_content.gsub!(/<span id="#{post.id}"/, post_check_box+ '<span') %>
+    <% newsletter_content.gsub!(/<img id="#{post.id}"/, post_check_box+ '<img') %>
+  <% end %>
+
+  <%= newsletter_content %>
+</div>


=====================================
script/sample-articles
=====================================
--- a/script/sample-articles
+++ b/script/sample-articles
@@ -65,3 +65,22 @@ for subject in EVENT_SUBJECTS
   end
 end
 done
+
+print "Creating some posts: "
+for subject in SUBJECTS
+  rand(20).times do |i|
+    profile = profiles.sample
+    name = "%s #{subject}" % profile.name
+    article = TinyMceArticle.new(
+      :name => name,
+      :body => name,
+      :tag_list => [TAGS.sample, TAGS.sample],
+      :profile => profile,
+      :parent_id => profile.blog.id
+    )
+    save article do
+      article.add_category categories.sample
+    end
+  end
+end
+done



View it on GitLab: https://gitlab.com/noosfero/noosfero/compare/ee4ad0411d760cc2a598747439e6b4a86204cf11...1d587511ce773f8686ed050f05ef96ac79c0d7fc
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://listas.softwarelivre.org/pipermail/noosfero-dev/attachments/20150911/850188c4/attachment-0001.html>


More information about the Noosfero-dev mailing list