]> source.dussan.org Git - redmine.git/commitdiff
Add support for multiple email addresses per user (#4244).
authorJean-Philippe Lang <jp_lang@yahoo.fr>
Sat, 17 Jan 2015 14:14:12 +0000 (14:14 +0000)
committerJean-Philippe Lang <jp_lang@yahoo.fr>
Sat, 17 Jan 2015 14:14:12 +0000 (14:14 +0000)
git-svn-id: http://svn.redmine.org/redmine/trunk@13886 e93f8b46-1217-0410-a6f0-8f06a7374b81

65 files changed:
app/controllers/email_addresses_controller.rb [new file with mode: 0644]
app/controllers/users_controller.rb
app/helpers/email_addresses_helper.rb [new file with mode: 0644]
app/helpers/users_helper.rb
app/models/document.rb
app/models/email_address.rb [new file with mode: 0644]
app/models/mail_handler.rb
app/models/mailer.rb
app/models/message.rb
app/models/news.rb
app/models/principal.rb
app/models/user.rb
app/models/wiki_content.rb
app/views/email_addresses/_index.html.erb [new file with mode: 0644]
app/views/email_addresses/index.html.erb [new file with mode: 0644]
app/views/email_addresses/index.js.erb [new file with mode: 0644]
app/views/my/account.html.erb
app/views/settings/_authentication.html.erb
app/views/users/edit.html.erb
config/initializers/10-patches.rb
config/locales/en.yml
config/locales/fr.yml
config/routes.rb
config/settings.yml
db/migrate/20150113194759_create_email_addresses.rb [new file with mode: 0644]
db/migrate/20150113211532_populate_email_addresses.rb [new file with mode: 0644]
db/migrate/20150113213922_remove_users_mail.rb [new file with mode: 0644]
db/migrate/20150113213955_add_email_addresses_user_id_index.rb [new file with mode: 0644]
public/images/email.png [new file with mode: 0644]
public/images/email_add.png [new file with mode: 0644]
public/images/email_disabled.png [new file with mode: 0644]
public/javascripts/application.js
public/stylesheets/application.css
test/fixtures/email_addresses.yml [new file with mode: 0644]
test/fixtures/users.yml
test/functional/admin_controller_test.rb
test/functional/documents_controller_test.rb
test/functional/email_addresses_controller_test.rb [new file with mode: 0644]
test/functional/issues_controller_test.rb
test/functional/issues_custom_fields_visibility_test.rb
test/functional/mail_handler_controller_test.rb
test/functional/messages_controller_test.rb
test/functional/my_controller_test.rb
test/functional/news_controller_test.rb
test/functional/projects_controller_test.rb
test/functional/repositories_bazaar_controller_test.rb
test/functional/repositories_controller_test.rb
test/functional/repositories_cvs_controller_test.rb
test/functional/repositories_darcs_controller_test.rb
test/functional/repositories_filesystem_controller_test.rb
test/functional/repositories_git_controller_test.rb
test/functional/repositories_mercurial_controller_test.rb
test/functional/repositories_subversion_controller_test.rb
test/functional/users_controller_test.rb
test/functional/wiki_controller_test.rb
test/integration/account_test.rb
test/integration/api_test/users_test.rb
test/integration/issues_test.rb
test/integration/users_test.rb
test/unit/document_test.rb
test/unit/issue_test.rb
test/unit/mail_handler_test.rb
test/unit/mailer_test.rb
test/unit/user_test.rb
test/unit/watcher_test.rb

diff --git a/app/controllers/email_addresses_controller.rb b/app/controllers/email_addresses_controller.rb
new file mode 100644 (file)
index 0000000..373be00
--- /dev/null
@@ -0,0 +1,105 @@
+# Redmine - project management software
+# Copyright (C) 2006-2015  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+class EmailAddressesController < ApplicationController
+  before_filter :find_user, :require_admin_or_current_user
+  before_filter :find_email_address, :only => [:update, :destroy]
+
+  def index
+    @addresses = @user.email_addresses.order(:id).where(:is_default => false).to_a
+    @address ||= EmailAddress.new
+  end
+
+  def create
+    saved = false
+    if @user.email_addresses.count <= Setting.max_additional_emails.to_i
+      @address = EmailAddress.new(:user => @user, :is_default => false)
+      attrs = params[:email_address]
+      if attrs.is_a?(Hash)
+        @address.address = attrs[:address].to_s
+      end
+      saved = @address.save
+    end
+
+    respond_to do |format|
+      format.html {
+        if saved
+          redirect_to user_email_addresses_path(@user)
+        else
+          index
+          render :action => 'index'
+        end
+      }
+      format.js {
+        @address = nil if saved
+        index
+        render :action => 'index'
+      }
+    end
+  end
+
+  def update
+    if params[:notify].present?
+      @address.notify = params[:notify].to_s
+    end
+    @address.save
+
+    respond_to do |format|
+      format.html {
+        redirect_to user_email_addresses_path(@user)
+      }
+      format.js {
+        @address = nil
+        index
+        render :action => 'index'
+      }
+    end
+  end
+
+  def destroy
+    @address.destroy
+
+    respond_to do |format|
+      format.html {
+        redirect_to user_email_addresses_path(@user)
+      }
+      format.js {
+        @address = nil
+        index
+        render :action => 'index'
+      }
+    end
+  end
+
+  private
+
+  def find_user
+    @user = User.find(params[:user_id])
+  end
+
+  def find_email_address
+    @address = @user.email_addresses.where(:is_default => false).find(params[:id])
+  rescue ActiveRecord::RecordNotFound
+    render_404
+  end
+
+  def require_admin_or_current_user
+    unless @user == User.current
+      require_admin
+    end
+  end
+end
index b57ee8deb5776459a0cc78d4dd72f546e7c21efa..5958e36f8ee38ad061a690f0f13e4f3d9d951d26 100644 (file)
@@ -41,7 +41,7 @@ class UsersController < ApplicationController
 
     @status = params[:status] || 1
 
-    scope = User.logged.status(@status)
+    scope = User.logged.status(@status).preload(:email_address)
     scope = scope.like(params[:name]) if params[:name].present?
     scope = scope.in_group(params[:group_id]) if params[:group_id].present?
 
diff --git a/app/helpers/email_addresses_helper.rb b/app/helpers/email_addresses_helper.rb
new file mode 100644 (file)
index 0000000..f397e99
--- /dev/null
@@ -0,0 +1,38 @@
+# encoding: utf-8
+#
+# Redmine - project management software
+# Copyright (C) 2006-2015  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module EmailAddressesHelper
+
+  # Returns a link to enable or disable notifications for the address
+  def toggle_email_address_notify_link(address)
+    if address.notify?
+      link_to image_tag('email.png'),
+        user_email_address_path(address.user, address, :notify => '0'),
+        :method => :put,
+        :title => l(:label_disable_notifications),
+        :remote => true
+    else
+      link_to image_tag('email_disabled.png'),
+        user_email_address_path(address.user, address, :notify => '1'),
+        :method => :put,
+        :title => l(:label_enable_notifications),
+        :remote => true
+    end
+  end
+end
index 1bd1d9a91827ca911d9deec2940cc2875e359bc3..2ba41fa62b289db4eec793a1b9b27d0fa4d42845 100644 (file)
@@ -42,6 +42,12 @@ module UsersHelper
     end
   end
 
+  def additional_emails_link(user)
+    if user.email_addresses.count > 1 || Setting.max_additional_emails.to_i > 0
+      link_to l(:label_email_address_plural), user_email_addresses_path(@user), :class => 'icon icon-email-add', :remote => true
+    end
+  end
+
   def user_settings_tabs
     tabs = [{:name => 'general', :partial => 'users/general', :label => :label_general},
             {:name => 'memberships', :partial => 'users/memberships', :label => :label_project_plural}
index f73f8ed63522fc553284dcdc13014e570010daa6..29f9031fc896634ddc8a9afa95bf31a391d9d8d8 100644 (file)
@@ -60,6 +60,10 @@ class Document < ActiveRecord::Base
     @updated_on
   end
 
+  def notified_users
+    project.notified_users.reject {|user| !visible?(user)}
+  end
+
   private
 
   def send_notification
diff --git a/app/models/email_address.rb b/app/models/email_address.rb
new file mode 100644 (file)
index 0000000..01fd75b
--- /dev/null
@@ -0,0 +1,54 @@
+# Redmine - project management software
+# Copyright (C) 2006-2015  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+class EmailAddress < ActiveRecord::Base
+  belongs_to :user
+  attr_protected :id
+
+  after_update :destroy_tokens
+  after_destroy :destroy_tokens
+
+  validates_presence_of :address
+  validates_format_of :address, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
+  validates_length_of :address, :maximum => User::MAIL_LENGTH_LIMIT, :allow_nil => true
+  validates_uniqueness_of :address, :case_sensitive => false,
+    :if => Proc.new {|email| email.address_changed? && email.address.present?}
+
+  def address=(arg)
+    write_attribute(:address, arg.to_s.strip)
+  end
+
+  def destroy
+    if is_default?
+      false
+    else
+      super
+    end
+  end
+
+  private
+
+  # Delete all outstanding password reset tokens on email change.
+  # This helps to keep the account secure in case the associated email account
+  # was compromised.
+  def destroy_tokens
+    if address_changed? || destroyed?
+      tokens = ['recovery']
+      Token.where(:user_id => user_id, :action => tokens).delete_all
+    end
+  end
+end
index cff50e6aff72797f4c1c244f62dff54d79ff14fa..825fdf546c496b88db41b0ce1bb069e909f3e3f4 100644 (file)
@@ -306,7 +306,7 @@ class MailHandler < ActionMailer::Base
     if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
       addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
       unless addresses.empty?
-        User.active.where('LOWER(mail) IN (?)', addresses).each do |w|
+        User.active.having_mail(addresses).each do |w|
           obj.add_watcher(w)
         end
       end
index bc279b6918bcd9ee5db6ae660e6f83f9b50d4f66..a859c039b4e58eddfbeb245d752d8feae5279ffa 100644 (file)
@@ -39,8 +39,8 @@ class Mailer < ActionMailer::Base
     @issue = issue
     @users = to_users + cc_users
     @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue)
-    mail :to => to_users.map(&:mail),
-      :cc => cc_users.map(&:mail),
+    mail :to => to_users,
+      :cc => cc_users,
       :subject => "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] (#{issue.status.name}) #{issue.subject}"
   end
 
@@ -71,8 +71,8 @@ class Mailer < ActionMailer::Base
     @journal = journal
     @journal_details = journal.visible_details(@users.first)
     @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue, :anchor => "change-#{journal.id}")
-    mail :to => to_users.map(&:mail),
-      :cc => cc_users.map(&:mail),
+    mail :to => to_users,
+      :cc => cc_users,
       :subject => s
   end
 
@@ -95,7 +95,7 @@ class Mailer < ActionMailer::Base
     @issues_url = url_for(:controller => 'issues', :action => 'index',
                                 :set_filter => 1, :assigned_to_id => user.id,
                                 :sort => 'due_date:asc')
-    mail :to => user.mail,
+    mail :to => user,
       :subject => l(:mail_subject_reminder, :count => issues.size, :days => days)
   end
 
@@ -109,7 +109,7 @@ class Mailer < ActionMailer::Base
     @author = User.current
     @document = document
     @document_url = url_for(:controller => 'documents', :action => 'show', :id => document)
-    mail :to => document.recipients,
+    mail :to => document.notified_users,
       :subject => "[#{document.project.name}] #{l(:label_document_new)}: #{document.title}"
   end
 
@@ -127,15 +127,15 @@ class Mailer < ActionMailer::Base
     when 'Project'
       added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container)
       added_to = "#{l(:label_project)}: #{container}"
-      recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}.collect  {|u| u.mail}
+      recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}
     when 'Version'
       added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container.project)
       added_to = "#{l(:label_version)}: #{container.name}"
-      recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}.collect  {|u| u.mail}
+      recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}
     when 'Document'
       added_to_url = url_for(:controller => 'documents', :action => 'show', :id => container.id)
       added_to = "#{l(:label_document)}: #{container.title}"
-      recipients = container.recipients
+      recipients = container.notified_users
     end
     redmine_headers 'Project' => container.project.identifier
     @attachments = attachments
@@ -157,8 +157,8 @@ class Mailer < ActionMailer::Base
     references news
     @news = news
     @news_url = url_for(:controller => 'news', :action => 'show', :id => news)
-    mail :to => news.recipients,
-      :cc => news.cc_for_added_news,
+    mail :to => news.notified_users,
+      :cc => news.notified_watchers_for_added_news,
       :subject => "[#{news.project.name}] #{l(:label_news)}: #{news.title}"
   end
 
@@ -176,8 +176,8 @@ class Mailer < ActionMailer::Base
     @news = news
     @comment = comment
     @news_url = url_for(:controller => 'news', :action => 'show', :id => news)
-    mail :to => news.recipients,
-     :cc => news.watcher_recipients,
+    mail :to => news.notified_users,
+     :cc => news.notified_watchers,
      :subject => "Re: [#{news.project.name}] #{l(:label_news)}: #{news.title}"
   end
 
@@ -192,8 +192,8 @@ class Mailer < ActionMailer::Base
     @author = message.author
     message_id message
     references message.root
-    recipients = message.recipients
-    cc = ((message.root.watcher_recipients + message.board.watcher_recipients).uniq - recipients)
+    recipients = message.notified_users
+    cc = ((message.root.notified_watchers + message.board.notified_watchers).uniq - recipients)
     @message = message
     @message_url = url_for(message.event_url)
     mail :to => recipients,
@@ -211,8 +211,8 @@ class Mailer < ActionMailer::Base
                     'Wiki-Page-Id' => wiki_content.page.id
     @author = wiki_content.author
     message_id wiki_content
-    recipients = wiki_content.recipients
-    cc = wiki_content.page.wiki.watcher_recipients - recipients
+    recipients = wiki_content.notified_users
+    cc = wiki_content.page.wiki.notified_watchers - recipients
     @wiki_content = wiki_content
     @wiki_content_url = url_for(:controller => 'wiki', :action => 'show',
                                       :project_id => wiki_content.project,
@@ -232,8 +232,8 @@ class Mailer < ActionMailer::Base
                     'Wiki-Page-Id' => wiki_content.page.id
     @author = wiki_content.author
     message_id wiki_content
-    recipients = wiki_content.recipients
-    cc = wiki_content.page.wiki.watcher_recipients + wiki_content.page.watcher_recipients - recipients
+    recipients = wiki_content.notified_users
+    cc = wiki_content.page.wiki.notified_watchers + wiki_content.page.notified_watchers - recipients
     @wiki_content = wiki_content
     @wiki_content_url = url_for(:controller => 'wiki', :action => 'show',
                                       :project_id => wiki_content.project,
@@ -267,7 +267,7 @@ class Mailer < ActionMailer::Base
   #   Mailer.account_activation_request(user).deliver => sends an email to all active administrators
   def account_activation_request(user)
     # Send the email to all active administrators
-    recipients = User.active.where(:admin => true).collect { |u| u.mail }.compact
+    recipients = User.active.where(:admin => true)
     @user = user
     @url = url_for(:controller => 'users', :action => 'index',
                          :status => User::STATUS_REGISTERED,
@@ -378,12 +378,20 @@ class Mailer < ActionMailer::Base
             'From' => Setting.mail_from,
             'List-Id' => "<#{Setting.mail_from.to_s.gsub('@', '.')}>"
 
+    # Replaces users with their email addresses
+    [:to, :cc, :bcc].each do |key|
+      if headers[key].present?
+        headers[key] = self.class.email_addresses(headers[key])
+      end
+    end
+
     # Removes the author from the recipients and cc
     # if the author does not want to receive notifications
     # about what the author do
     if @author && @author.logged? && @author.pref.no_self_notified
-      headers[:to].delete(@author.mail) if headers[:to].is_a?(Array)
-      headers[:cc].delete(@author.mail) if headers[:cc].is_a?(Array)
+      addresses = @author.mails
+      headers[:to] -= addresses if headers[:to].is_a?(Array)
+      headers[:cc] -= addresses if headers[:cc].is_a?(Array)
     end
 
     if @author && @author.logged?
@@ -447,6 +455,25 @@ class Mailer < ActionMailer::Base
     end
   end
 
+  # Returns an array of email addresses to notify by
+  # replacing users in arg with their notified email addresses
+  #
+  # Example:
+  #   Mailer.email_addresses(users)
+  #   => ["foo@example.net", "bar@example.net"]
+  def self.email_addresses(arg)
+    arr = Array.wrap(arg)
+    mails = arr.reject {|a| a.is_a? Principal}
+    users = arr - mails
+    if users.any?
+      mails += EmailAddress.
+        where(:user_id => users.map(&:id)).
+        where("is_default = ? OR notify = ?", true, true).
+        pluck(:address)
+    end
+    mails
+  end
+
   private
 
   # Appends a Redmine header field (name is prepended with 'X-Redmine-')
index 5ca8d7ea7f655e5f3c6e89c2e4a3927d62097a4f..de7ebaab562c60b235a6acd0c004311b690daa83 100644 (file)
@@ -103,6 +103,10 @@ class Message < ActiveRecord::Base
     usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
   end
 
+  def notified_users
+    project.notified_users.reject {|user| !visible?(user)}
+  end
+
   private
 
   def add_author_as_watcher
index e0793d9a8fa15fd6033d1c430af72aebceac7339..b8bc35f39f6e1fbef52c63361bd169cec5b9fb11 100644 (file)
@@ -54,20 +54,29 @@ class News < ActiveRecord::Base
     user.allowed_to?(:comment_news, project)
   end
 
+  def notified_users
+    project.users.select {|user| user.notify_about?(self) && user.allowed_to?(:view_news, project)}
+  end
+
   def recipients
-    project.users.select {|user| user.notify_about?(self) && user.allowed_to?(:view_news, project)}.map(&:mail)
+    notified_users.map(&:mail)
   end
 
-  # Returns the email addresses that should be cc'd when a new news is added
-  def cc_for_added_news
-    cc = []
+  # Returns the users that should be cc'd when a new news is added
+  def notified_watchers_for_added_news
+    watchers = []
     if m = project.enabled_module('news')
-      cc = m.notified_watchers
+      watchers = m.notified_watchers
       unless project.is_public?
-        cc = cc.select {|user| project.users.include?(user)}
+        watchers = watchers.select {|user| project.users.include?(user)}
       end
     end
-    cc.map(&:mail)
+    watchers
+  end
+
+  # Returns the email addresses that should be cc'd when a new news is added
+  def cc_for_added_news
+    notified_watchers_for_added_news.map(&:mail)
   end
 
   # returns latest news for projects visible by user
index 7e87091f3f37ec1225fe31b07c412e649c1abd3f..355e8907eb0d3e8a3c0c120a5c6b62416638855a 100644 (file)
@@ -68,7 +68,8 @@ class Principal < ActiveRecord::Base
       where({})
     else
       pattern = "%#{q}%"
-      sql = %w(login firstname lastname mail).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
+      sql = %w(login firstname lastname).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
+      sql << " OR #{table_name}.id IN (SELECT user_id FROM #{EmailAddress.table_name} WHERE LOWER(address) LIKE LOWER(:p))"
       params = {:p => pattern}
       if q =~ /^(.+)\s+(.+)$/
         a, b = "#{$1}%", "#{$2}%"
@@ -108,6 +109,14 @@ class Principal < ActiveRecord::Base
     to_s
   end
 
+  def mail=(*args)
+    nil
+  end
+
+  def mail
+    nil
+  end
+
   def visible?(user=User.current)
     Principal.visible(user).where(:id => id).first == self
   end
@@ -145,7 +154,6 @@ class Principal < ActiveRecord::Base
     self.hashed_password ||= ''
     self.firstname ||= ''
     self.lastname ||= ''
-    self.mail ||= ''
     true
   end
 end
index b680fe52d67bbdfaa677511a94a038f70ef86dc2..2175d06822b59b13072d6a48c4e11ef4003dc2b0 100644 (file)
@@ -81,6 +81,8 @@ class User < Principal
   has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
   has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
   has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
+  has_one :email_address, lambda {where :is_default => true}, :autosave => true
+  has_many :email_addresses, :dependent => :delete_all
   belongs_to :auth_source
 
   scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
@@ -96,15 +98,12 @@ class User < Principal
   LOGIN_LENGTH_LIMIT = 60
   MAIL_LENGTH_LIMIT = 60
 
-  validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
+  validates_presence_of :login, :firstname, :lastname, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
   validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
-  validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
   # Login must contain letters, numbers, underscores only
   validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
   validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
   validates_length_of :firstname, :lastname, :maximum => 30
-  validates_format_of :mail, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
-  validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
   validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
   validate :validate_password_length
   validate do
@@ -113,6 +112,7 @@ class User < Principal
     end
   end
 
+  before_validation :instantiate_email_address
   before_create :set_mail_notification
   before_save   :generate_password_if_needed, :update_hashed_password
   before_destroy :remove_references_before_destroy
@@ -127,6 +127,14 @@ class User < Principal
     where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
   }
   scope :sorted, lambda { order(*User.fields_for_order_statement)}
+  scope :having_mail, lambda {|arg|
+    addresses = Array.wrap(arg).map {|a| a.to_s.downcase}
+    if addresses.any?
+      joins(:email_addresses).where("LOWER(address) IN (?)", addresses).uniq
+    else
+      none
+    end
+  }
 
   def set_mail_notification
     self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
@@ -152,8 +160,21 @@ class User < Principal
     base_reload(*args)
   end
 
+  def mail
+    email_address.try(:address)
+  end
+
   def mail=(arg)
-    write_attribute(:mail, arg.to_s.strip)
+    email = email_address || build_email_address
+    email.address = arg
+  end
+
+  def mail_changed?
+    email_address.try(:address_changed?)
+  end
+
+  def mails
+    email_addresses.pluck(:address)
   end
 
   def self.find_or_initialize_by_identity_url(url)
@@ -421,7 +442,7 @@ class User < Principal
 
   # Makes find_by_mail case-insensitive
   def self.find_by_mail(mail)
-    where("LOWER(mail) = ?", mail.to_s.downcase).first
+    having_mail(mail).first
   end
 
   # Returns true if the default admin account can no longer be used
@@ -669,7 +690,7 @@ class User < Principal
   def self.anonymous
     anonymous_user = AnonymousUser.first
     if anonymous_user.nil?
-      anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
+      anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
       raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
     end
     anonymous_user
@@ -699,6 +720,10 @@ class User < Principal
     end
   end
 
+  def instantiate_email_address
+    email_address || build_email_address
+  end
+
   private
 
   def generate_password_if_needed
@@ -708,16 +733,13 @@ class User < Principal
     end
   end
 
-  # Delete all outstanding password reset tokens on password or email change.
+  # Delete all outstanding password reset tokens on password change.
   # Delete the autologin tokens on password change to prohibit session leakage.
   # This helps to keep the account secure in case the associated email account
   # was compromised.
   def destroy_tokens
-    tokens  = []
-    tokens |= ['recovery', 'autologin'] if hashed_password_changed?
-    tokens |= ['recovery'] if mail_changed?
-
-    if tokens.any?
+    if hashed_password_changed?
+      tokens = ['recovery', 'autologin']
       Token.where(:user_id => id, :action => tokens).delete_all
     end
   end
@@ -779,6 +801,7 @@ class AnonymousUser < User
   def logged?; false end
   def admin; false end
   def name(*args); I18n.t(:label_user_anonymous) end
+  def mail=(*args); nil end
   def mail; nil end
   def time_zone; nil end
   def rss_key; nil end
@@ -804,4 +827,9 @@ class AnonymousUser < User
   def destroy
     false
   end
+
+  protected
+
+  def instantiate_email_address
+  end
 end
index 90947d5893054b854351d90afda90061ba621658..992c7da34e08e376ca48d7b309caafbe2809c705 100644 (file)
@@ -41,11 +41,13 @@ class WikiContent < ActiveRecord::Base
     page.nil? ? [] : page.attachments
   end
 
+  def notified_users
+    project.notified_users.reject {|user| !visible?(user)}
+  end
+
   # Returns the mail addresses of users that should be notified
   def recipients
-    notified = project.notified_users
-    notified.reject! {|user| !visible?(user)}
-    notified.collect(&:mail)
+    notified_users.collect(&:mail)
   end
 
   # Return true if the content is the current page content
diff --git a/app/views/email_addresses/_index.html.erb b/app/views/email_addresses/_index.html.erb
new file mode 100644 (file)
index 0000000..644cd75
--- /dev/null
@@ -0,0 +1,26 @@
+<% if @addresses.present? %>
+  <table class="list email_addresses">
+  <% @addresses.each do |address| %>
+    <tr class="<%= cycle("odd", "even") %>">
+      <td class="email"><%= address.address %></td>
+      <td class="buttons">
+        <%= toggle_email_address_notify_link(address) %>
+        <%= delete_link user_email_address_path(@user, address), :remote => true %>
+      </td>
+    </tr>
+  <% end %>
+  </table>
+<% end %>
+
+<% unless @addresses.size >= Setting.max_additional_emails.to_i %>
+  <div>
+  <%= form_for @address, :url => user_email_addresses_path(@user), :remote => true do |f| %>
+    <p><%= l(:label_email_address_add) %></p>
+    <%= error_messages_for @address %>
+    <p>
+      <%= f.text_field :address, :size => 40 %>
+      <%= submit_tag l(:button_add) %>
+    </p>
+  <% end %>
+  </div>
+<% end %>
diff --git a/app/views/email_addresses/index.html.erb b/app/views/email_addresses/index.html.erb
new file mode 100644 (file)
index 0000000..7de1d37
--- /dev/null
@@ -0,0 +1,2 @@
+<h2><%= @user.name %></h2>
+<%= render :partial => 'email_addresses/index' %>
diff --git a/app/views/email_addresses/index.js.erb b/app/views/email_addresses/index.js.erb
new file mode 100644 (file)
index 0000000..2a7147f
--- /dev/null
@@ -0,0 +1,3 @@
+$('#ajax-modal').html('<%= escape_javascript(render :partial => 'email_addresses/index') %>');
+showModal('ajax-modal', '600px', '<%= escape_javascript l(:label_email_address_plural) %>');
+$('#email_address_address').focus();
index 0ce223b0a7963617f78478f81cd5883aa071e2f5..cadf0e830e107daac2dda84570e2aba3ef6d6e1e 100644 (file)
@@ -1,4 +1,5 @@
 <div class="contextual">
+<%= additional_emails_link(@user) %>
 <%= link_to(l(:button_change_password), {:action => 'password'}, :class => 'icon icon-passwd') if @user.change_password_allowed? %>
 <%= call_hook(:view_my_account_contextual, :user => @user)%>
 </div>
index d190fab6653779628fb97d6b2227483bdd8710b6..77b5afced4eb8069783d39ece37b069a946e0b4b 100644 (file)
@@ -16,6 +16,8 @@
 
 <p><%= setting_check_box :lost_password, :label => :label_password_lost %></p>
 
+<p><%= setting_text_field :max_additional_emails, :size => 6 %></p>
+
 <p><%= setting_check_box :openid, :disabled => !Object.const_defined?(:OpenID) %></p>
 
 <p><%= setting_check_box :rest_api_enabled %></p>
index d46521730e2d731d2a702c32d9be7e2b5f930cf2..67b57f802080745571a8d0c50bda428798d0bb71 100644 (file)
@@ -1,5 +1,6 @@
 <div class="contextual">
 <%= link_to l(:label_profile), user_path(@user), :class => 'icon icon-user' %>
+<%= additional_emails_link(@user) %>
 <%= change_status_link(@user) %>
 <%= delete_link user_path(@user) if User.current != @user %>
 </div>
index 63532b491deeefd2a7b5e2122f3bbb79a41ddfab..e01b4c2d78c2a4463a4e0c17a7898a3a376ffe0a 100644 (file)
@@ -5,8 +5,7 @@ module ActiveRecord
     include Redmine::I18n
     # Translate attribute names for validation errors display
     def self.human_attribute_name(attr, *args)
-      attr = attr.to_s.sub(/_id$/, '')
-
+      attr = attr.to_s.sub(/_id$/, '').sub(/^.+\./, '')
       l("field_#{name.underscore.gsub('/', '_')}_#{attr}", :default => ["field_#{attr}".to_sym, attr])
     end
   end
index 86d9b50b073da499c6228438ddd62c7a59dc4c8c..afa401f1014401bd28e171259cb3a00a9f35f8bb 100644 (file)
@@ -227,6 +227,7 @@ en:
   field_firstname: First name
   field_lastname: Last name
   field_mail: Email
+  field_address: Email
   field_filename: File
   field_filesize: Size
   field_downloads: Downloads
@@ -413,6 +414,7 @@ en:
   setting_force_default_language_for_anonymous: Force default language for anonymous users
   setting_force_default_language_for_loggedin: Force default language for logged-in users
   setting_link_copied_issue: Link issues on copy
+  setting_max_additional_emails: Maximum number of additional email addresses
 
   permission_add_project: Create project
   permission_add_subprojects: Create subprojects
@@ -931,6 +933,10 @@ en:
   label_search_attachments_no: Do not search attachments
   label_search_attachments_only: Search attachments only
   label_search_open_issues_only: Open issues only
+  label_email_address_plural: Emails
+  label_email_address_add: Add email address
+  label_enable_notifications: Enable notifications
+  label_disable_notifications: Disable notifications
 
   button_login: Login
   button_submit: Submit
index 1c5fbf8896fb86fc0e216d66b8cd887c6c0d4b55..369eac349119ed0738476ec389ae8d43c4fd1c94 100644 (file)
@@ -247,6 +247,7 @@ fr:
   field_firstname: Prénom
   field_lastname: Nom
   field_mail: Email
+  field_address: Email
   field_filename: Fichier
   field_filesize: Taille
   field_downloads: Téléchargements
@@ -433,6 +434,7 @@ fr:
   setting_force_default_language_for_anonymous: Forcer la langue par défault pour les utilisateurs anonymes
   setting_force_default_language_for_loggedin: Forcer la langue par défault pour les utilisateurs identifiés
   setting_link_copied_issue: Lier les demandes lors de la copie
+  setting_max_additional_emails: Nombre maximal d'adresses email additionnelles
 
   permission_add_project: Créer un projet
   permission_add_subprojects: Créer des sous-projets
@@ -951,6 +953,10 @@ fr:
   label_search_attachments_no: Ne pas rechercher les fichiers
   label_search_attachments_only: Rechercher les fichiers uniquement
   label_search_open_issues_only: Demandes ouvertes uniquement
+  label_email_address_plural: Emails
+  label_email_address_add: Ajouter une adresse email
+  label_enable_notifications: Activer les notifications
+  label_disable_notifications: Désactiver les notifications
 
   button_login: Connexion
   button_submit: Soumettre
index d330802ded23ee04f5af90c998e2abcf91192fdf..8eb9b4d27b45c06ba67a50c714c6692c408b1f5e 100644 (file)
@@ -75,6 +75,7 @@ Rails.application.routes.draw do
 
   resources :users do
     resources :memberships, :controller => 'principal_memberships'
+    resources :email_addresses, :only => [:index, :create, :update, :destroy]
   end
 
   post 'watchers/watch', :to => 'watchers#watch', :as => 'watch'
index 40e4428f0dadb5eea9db469287491299a97c21c2..a0f920da06d38b654d1d10ad2b6242a89dca1530 100644 (file)
@@ -36,6 +36,10 @@ unsubscribe:
 password_min_length:
   format: int
   default: 8
+# Maximum number of additional email addresses per user
+max_additional_emails:
+  format: int
+  default: 5
 # Maximum lifetime of user sessions in minutes
 session_lifetime:
   format: int
diff --git a/db/migrate/20150113194759_create_email_addresses.rb b/db/migrate/20150113194759_create_email_addresses.rb
new file mode 100644 (file)
index 0000000..a0babce
--- /dev/null
@@ -0,0 +1,12 @@
+class CreateEmailAddresses < ActiveRecord::Migration
+  def change
+    create_table :email_addresses do |t|
+      t.column :user_id, :integer, :null => false
+      t.column :address, :string, :null => false
+      t.column :is_default, :boolean, :null => false, :default => false
+      t.column :notify, :boolean, :null => false, :default => true
+      t.column :created_on, :timestamp, :null => false
+      t.column :updated_on, :timestamp, :null => false
+    end
+  end
+end
diff --git a/db/migrate/20150113211532_populate_email_addresses.rb b/db/migrate/20150113211532_populate_email_addresses.rb
new file mode 100644 (file)
index 0000000..80a5fb0
--- /dev/null
@@ -0,0 +1,14 @@
+class PopulateEmailAddresses < ActiveRecord::Migration
+  def self.up
+    t = EmailAddress.connection.quoted_true
+    n = EmailAddress.connection.quoted_date(Time.now)
+
+    sql = "INSERT INTO #{EmailAddress.table_name} (user_id, address, is_default, notify, created_on, updated_on)" +
+          " SELECT id, mail, #{t}, #{t}, '#{n}', '#{n}' FROM #{User.table_name} WHERE type = 'User' ORDER BY id"
+    EmailAddress.connection.execute(sql)
+  end
+
+  def self.down
+    EmailAddress.delete_all
+  end
+end
diff --git a/db/migrate/20150113213922_remove_users_mail.rb b/db/migrate/20150113213922_remove_users_mail.rb
new file mode 100644 (file)
index 0000000..8a8b484
--- /dev/null
@@ -0,0 +1,9 @@
+class RemoveUsersMail < ActiveRecord::Migration
+  def self.up
+    remove_column :users, :mail
+  end
+
+  def self.down
+    raise IrreversibleMigration
+  end
+end
diff --git a/db/migrate/20150113213955_add_email_addresses_user_id_index.rb b/db/migrate/20150113213955_add_email_addresses_user_id_index.rb
new file mode 100644 (file)
index 0000000..b7fb90c
--- /dev/null
@@ -0,0 +1,9 @@
+class AddEmailAddressesUserIdIndex < ActiveRecord::Migration
+  def up
+    add_index :email_addresses, :user_id
+  end
+
+  def down
+    remove_index :email_addresses, :user_id
+  end
+end
diff --git a/public/images/email.png b/public/images/email.png
new file mode 100644 (file)
index 0000000..7348aed
Binary files /dev/null and b/public/images/email.png differ
diff --git a/public/images/email_add.png b/public/images/email_add.png
new file mode 100644 (file)
index 0000000..6c93368
Binary files /dev/null and b/public/images/email_add.png differ
diff --git a/public/images/email_disabled.png b/public/images/email_disabled.png
new file mode 100644 (file)
index 0000000..7aa93e8
Binary files /dev/null and b/public/images/email_disabled.png differ
index 80d833966eb0069a2a51ae2d4b4b4c6b55d54150..a3ead6b236afca0ac3d546b1a2ef3dcf3ca9b6a5 100644 (file)
@@ -378,10 +378,10 @@ function setPredecessorFieldsVisibility() {
   }
 }
 
-function showModal(id, width) {
+function showModal(id, width, title) {
   var el = $('#'+id).first();
   if (el.length === 0 || el.is(':visible')) {return;}
-  var title = el.find('h3.title').text();
+  if (!title) title = el.find('h3.title').text();
   el.dialog({
     width: width,
     modal: true,
index 4b9a4ff4979cb157c342a5282111b23392b0b115..0efd900de4acbd12af74b15e846c33a134b82cac 100644 (file)
@@ -132,6 +132,7 @@ table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
 table.list td.checkbox input {padding:0px;}
 table.list td.buttons { width: 15%; white-space:nowrap; text-align: right; }
 table.list td.buttons a { padding-right: 0.6em; }
+table.list td.buttons img {vertical-align:middle;}
 table.list td.reorder {width:15%; white-space:nowrap; text-align:center; }
 table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
 
@@ -209,7 +210,7 @@ tr.version.shared td.name { background: url(../images/link.png) no-repeat 0% 70%
 tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
 
 tr.user td {width:13%;white-space: nowrap;}
-tr.user td.username, tr.user td.firstname, tr.user td.lastname, tr.user td.email {text-align:left;}
+td.username, td.firstname, td.lastname, td.email {text-align:left !important;}
 tr.user td.email { width:18%; }
 tr.user.locked, tr.user.registered { color: #aaa; }
 tr.user.locked a, tr.user.registered a { color: #aaa; }
@@ -1046,6 +1047,7 @@ a.close-icon:hover {background-image:url('../images/close_hl.png');}
 .icon-zoom-out { background-image: url(../images/zoom_out.png); }
 .icon-passwd { background-image: url(../images/textfield_key.png); }
 .icon-test { background-image: url(../images/bullet_go.png); }
+.icon-email-add { background-image: url(../images/email_add.png); }
 
 .icon-file { background-image: url(../images/files/default.png); }
 .icon-file.text-plain { background-image: url(../images/files/text.png); }
diff --git a/test/fixtures/email_addresses.yml b/test/fixtures/email_addresses.yml
new file mode 100644 (file)
index 0000000..a83f81e
--- /dev/null
@@ -0,0 +1,57 @@
+--- 
+email_address_001:
+  id: 1
+  user_id: 1
+  address: admin@somenet.foo
+  is_default: true
+  created_on: 2006-07-19 19:34:07 +02:00
+  updated_on: 2006-07-19 19:34:07 +02:00
+email_address_002:
+  id: 2
+  user_id: 2
+  address: jsmith@somenet.foo
+  is_default: true
+  created_on: 2006-07-19 19:34:07 +02:00
+  updated_on: 2006-07-19 19:34:07 +02:00
+email_address_003:
+  id: 3
+  user_id: 3
+  address: dlopper@somenet.foo
+  is_default: true
+  created_on: 2006-07-19 19:34:07 +02:00
+  updated_on: 2006-07-19 19:34:07 +02:00
+email_address_004:
+  id: 4
+  user_id: 4
+  address: rhill@somenet.foo
+  is_default: true
+  created_on: 2006-07-19 19:34:07 +02:00
+  updated_on: 2006-07-19 19:34:07 +02:00
+email_address_005:
+  id: 5
+  user_id: 5
+  address: dlopper2@somenet.foo
+  is_default: true
+  created_on: 2006-07-19 19:34:07 +02:00
+  updated_on: 2006-07-19 19:34:07 +02:00
+email_address_007:
+  id: 7
+  user_id: 7
+  address: someone@foo.bar
+  is_default: true
+  created_on: 2006-07-19 19:34:07 +02:00
+  updated_on: 2006-07-19 19:34:07 +02:00
+email_address_008:
+  id: 8
+  user_id: 8
+  address: miscuser8@foo.bar
+  is_default: true
+  created_on: 2006-07-19 19:34:07 +02:00
+  updated_on: 2006-07-19 19:34:07 +02:00
+email_address_009:
+  id: 9
+  user_id: 9
+  address: miscuser9@foo.bar
+  is_default: true
+  created_on: 2006-07-19 19:34:07 +02:00
+  updated_on: 2006-07-19 19:34:07 +02:00
index 9adab5edd7161b690d63debe4d5e188a831779d8..986ed5d67c33c33b55ceaa34a089e3feaaae9fd1 100644 (file)
@@ -1,22 +1,4 @@
 --- 
-users_004: 
-  created_on: 2006-07-19 19:34:07 +02:00
-  status: 1
-  last_login_on: 
-  language: en
-  # password = foo
-  salt: 3126f764c3c5ac61cbfc103f25f934cf
-  hashed_password: 9e4dd7eeb172c12a0691a6d9d3a269f7e9fe671b
-  updated_on: 2006-07-19 19:34:07 +02:00
-  admin: false
-  mail: rhill@somenet.foo
-  lastname: Hill
-  firstname: Robert
-  id: 4
-  auth_source_id: 
-  mail_notification: all
-  login: rhill
-  type: User
 users_001: 
   created_on: 2006-07-19 19:12:21 +02:00
   status: 1
@@ -27,7 +9,6 @@ users_001:
   hashed_password: b5b6ff9543bf1387374cdfa27a54c96d236a7150
   updated_on: 2006-07-19 22:57:52 +02:00
   admin: true
-  mail: admin@somenet.foo
   lastname: Admin
   firstname: Redmine
   id: 1
@@ -45,7 +26,6 @@ users_002:
   hashed_password: bfbe06043353a677d0215b26a5800d128d5413bc
   updated_on: 2006-07-19 22:42:15 +02:00
   admin: false
-  mail: jsmith@somenet.foo
   lastname: Smith
   firstname: John
   id: 2
@@ -63,7 +43,6 @@ users_003:
   hashed_password: 8f659c8d7c072f189374edacfa90d6abbc26d8ed
   updated_on: 2006-07-19 19:33:19 +02:00
   admin: false
-  mail: dlopper@somenet.foo
   lastname: Lopper
   firstname: Dave
   id: 3
@@ -71,6 +50,23 @@ users_003:
   mail_notification: all
   login: dlopper
   type: User
+users_004: 
+  created_on: 2006-07-19 19:34:07 +02:00
+  status: 1
+  last_login_on: 
+  language: en
+  # password = foo
+  salt: 3126f764c3c5ac61cbfc103f25f934cf
+  hashed_password: 9e4dd7eeb172c12a0691a6d9d3a269f7e9fe671b
+  updated_on: 2006-07-19 19:34:07 +02:00
+  admin: false
+  lastname: Hill
+  firstname: Robert
+  id: 4
+  auth_source_id: 
+  mail_notification: all
+  login: rhill
+  type: User
 users_005: 
   id: 5
   created_on: 2006-07-19 19:33:19 +02:00
@@ -81,7 +77,6 @@ users_005:
   hashed_password: 1
   updated_on: 2006-07-19 19:33:19 +02:00
   admin: false
-  mail: dlopper2@somenet.foo
   lastname: Lopper2
   firstname: Dave2
   auth_source_id: 
@@ -97,7 +92,6 @@ users_006:
   hashed_password: 1
   updated_on: 2006-07-19 19:33:19 +02:00
   admin: false
-  mail: ''
   lastname: Anonymous
   firstname: ''
   auth_source_id: 
@@ -116,7 +110,6 @@ users_007:
   hashed_password: 8f659c8d7c072f189374edacfa90d6abbc26d8ed
   updated_on: 2006-07-19 19:33:19 +02:00
   admin: false
-  mail: someone@foo.bar
   lastname: One
   firstname: Some
   auth_source_id: 
@@ -134,7 +127,6 @@ users_008:
   hashed_password: 8f659c8d7c072f189374edacfa90d6abbc26d8ed
   updated_on: 2006-07-19 19:33:19 +02:00
   admin: false
-  mail: miscuser8@foo.bar
   lastname: Misc
   firstname: User
   auth_source_id: 
@@ -150,7 +142,6 @@ users_009:
   hashed_password: 1
   updated_on: 2006-07-19 19:33:19 +02:00
   admin: false
-  mail: miscuser9@foo.bar
   lastname: Misc
   firstname: User
   auth_source_id: 
index 00a503051c653d28763513ee3c0556a2ee6a27c9..bef3e4284e6a3ed418d5fe1a0e9e62e15aca1eb4 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class AdminControllerTest < ActionController::TestCase
-  fixtures :projects, :users, :roles
+  fixtures :projects, :users, :email_addresses, :roles
 
   def setup
     User.current = nil
index 065922a56c2c450875523350bce2521984051fc8..c53142b5be89d31eb8fed8034e4ed7b60be2ea1f 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class DocumentsControllerTest < ActionController::TestCase
-  fixtures :projects, :users, :roles, :members, :member_roles,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
            :enabled_modules, :documents, :enumerations,
            :groups_users, :attachments
 
diff --git a/test/functional/email_addresses_controller_test.rb b/test/functional/email_addresses_controller_test.rb
new file mode 100644 (file)
index 0000000..7c52d9c
--- /dev/null
@@ -0,0 +1,144 @@
+# Redmine - project management software
+# Copyright (C) 2006-2015  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require File.expand_path('../../test_helper', __FILE__)
+
+class EmailAddressesControllerTest < ActionController::TestCase
+  fixtures :users, :email_addresses
+
+  def setup
+    User.current = nil
+  end
+
+  def test_index_with_no_additional_emails
+    @request.session[:user_id] = 2
+    get :index, :user_id => 2
+    assert_response :success
+    assert_template 'index'
+  end
+
+  def test_index_with_additional_emails
+    @request.session[:user_id] = 2
+    EmailAddress.create!(:user_id => 2, :address => 'another@somenet.foo')
+
+    get :index, :user_id => 2
+    assert_response :success
+    assert_template 'index'
+    assert_select '.email', :text => 'another@somenet.foo'
+  end
+
+  def test_index_with_additional_emails_as_js
+    @request.session[:user_id] = 2
+    EmailAddress.create!(:user_id => 2, :address => 'another@somenet.foo')
+
+    xhr :get, :index, :user_id => 2
+    assert_response :success
+    assert_template 'index'
+    assert_include 'another@somenet.foo', response.body
+  end
+
+  def test_index_by_admin_should_be_allowed
+    @request.session[:user_id] = 1
+    get :index, :user_id => 2
+    assert_response :success
+    assert_template 'index'
+  end
+
+  def test_index_by_another_user_should_be_denied
+    @request.session[:user_id] = 3
+    get :index, :user_id => 2
+    assert_response 403
+  end
+
+  def test_create
+    @request.session[:user_id] = 2
+    assert_difference 'EmailAddress.count' do
+      post :create, :user_id => 2, :email_address => {:address => 'another@somenet.foo'}
+      assert_response 302
+      assert_redirected_to '/users/2/email_addresses'
+    end
+    email = EmailAddress.order('id DESC').first
+    assert_equal 2, email.user_id
+    assert_equal 'another@somenet.foo', email.address
+  end
+
+  def test_create_as_js
+    @request.session[:user_id] = 2
+    assert_difference 'EmailAddress.count' do
+      xhr :post, :create, :user_id => 2, :email_address => {:address => 'another@somenet.foo'}
+      assert_response 200
+    end
+  end
+
+  def test_create_with_failure
+    @request.session[:user_id] = 2
+    assert_no_difference 'EmailAddress.count' do
+      post :create, :user_id => 2, :email_address => {:address => 'invalid'}
+      assert_response 200
+    end
+  end
+
+  def test_update
+    @request.session[:user_id] = 2
+    email = EmailAddress.create!(:user_id => 2, :address => 'another@somenet.foo')
+
+    put :update, :user_id => 2, :id => email.id, :notify => '0'
+    assert_response 302
+
+    assert_equal false, email.reload.notify
+  end
+
+  def test_update_as_js
+    @request.session[:user_id] = 2
+    email = EmailAddress.create!(:user_id => 2, :address => 'another@somenet.foo')
+
+    xhr :put, :update, :user_id => 2, :id => email.id, :notify => '0'
+    assert_response 200
+
+    assert_equal false, email.reload.notify
+  end
+
+  def test_destroy
+    @request.session[:user_id] = 2
+    email = EmailAddress.create!(:user_id => 2, :address => 'another@somenet.foo')
+
+    assert_difference 'EmailAddress.count', -1 do
+      delete :destroy, :user_id => 2, :id => email.id
+      assert_response 302
+      assert_redirected_to '/users/2/email_addresses'
+    end
+  end
+
+  def test_destroy_as_js
+    @request.session[:user_id] = 2
+    email = EmailAddress.create!(:user_id => 2, :address => 'another@somenet.foo')
+
+    assert_difference 'EmailAddress.count', -1 do
+      xhr :delete, :destroy, :user_id => 2, :id => email.id
+      assert_response 200
+    end
+  end
+
+  def test_should_not_destroy_default
+    @request.session[:user_id] = 2
+
+    assert_no_difference 'EmailAddress.count' do
+      delete :destroy, :user_id => 2, :id => User.find(2).email_address.id
+      assert_response 404
+    end
+  end
+end
index 04ebec0df7fcddc95782189b9d7e452c6886490a..86c5cdb167d612810fb12b8f5bf49816885693a3 100644 (file)
@@ -19,7 +19,7 @@ require File.expand_path('../../test_helper', __FILE__)
 
 class IssuesControllerTest < ActionController::TestCase
   fixtures :projects,
-           :users,
+           :users, :email_addresses,
            :roles,
            :members,
            :member_roles,
index 7d8cfa9a467466668b66d1a638538259a46889a1..6e9ec5f110beff7d943f86a99ee69b9f594ce341 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class IssuesCustomFieldsVisibilityTest < ActionController::TestCase
   tests IssuesController
   fixtures :projects,
-           :users,
+           :users, :email_addresses,
            :roles,
            :members,
            :member_roles,
index 91ae34122bec86776d2bbbab05960e51b1d5ec01..3487d5eb21fad60a3c090c40f4a2dd204c0770e3 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class MailHandlerControllerTest < ActionController::TestCase
-  fixtures :users, :projects, :enabled_modules, :roles, :members, :member_roles, :issues, :issue_statuses,
+  fixtures :users, :email_addresses, :projects, :enabled_modules, :roles, :members, :member_roles, :issues, :issue_statuses,
            :trackers, :projects_trackers, :enumerations
 
   FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
index 9669379d9d32909252ba8b290205ea47b4da503b..cc61e5a1481824ade98dbf62097b3ce705d7034c 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class MessagesControllerTest < ActionController::TestCase
-  fixtures :projects, :users, :members, :member_roles, :roles, :boards, :messages, :enabled_modules
+  fixtures :projects, :users, :email_addresses, :members, :member_roles, :roles, :boards, :messages, :enabled_modules
 
   def setup
     User.current = nil
index 32317cd7c1ac002d5912aea8eaee4170b6075836..5f14c57917cb62f4739e9ee9c6cb8b363379bf94 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class MyControllerTest < ActionController::TestCase
-  fixtures :users, :user_preferences, :roles, :projects, :members, :member_roles,
+  fixtures :users, :email_addresses, :user_preferences, :roles, :projects, :members, :member_roles,
   :issues, :issue_statuses, :trackers, :enumerations, :custom_fields, :auth_sources
 
   def setup
index 554d1b2902ba936105a642213c53af9ff77164fb..b957dde6e5488d86aaa3999b0e06b4dd654669bf 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class NewsControllerTest < ActionController::TestCase
-  fixtures :projects, :users, :roles, :members, :member_roles,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
            :enabled_modules, :news, :comments,
            :attachments
 
index 379fe04da5b490305c004ecd326990c565f29ecd..fa86f1a0fa5557f644c9c548de47b2e5396a2eb2 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class ProjectsControllerTest < ActionController::TestCase
-  fixtures :projects, :versions, :users, :roles, :members,
+  fixtures :projects, :versions, :users, :email_addresses, :roles, :members,
            :member_roles, :issues, :journals, :journal_details,
            :trackers, :projects_trackers, :issue_statuses,
            :enabled_modules, :enumerations, :boards, :messages,
index 0374559ba7b8f179ee75deb7969d74d88e591263..cde2d1cf76b054dd4ffe11a98a8e7de9c8c78ac4 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class RepositoriesBazaarControllerTest < ActionController::TestCase
   tests RepositoriesController
 
-  fixtures :projects, :users, :roles, :members, :member_roles,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
            :repositories, :enabled_modules
 
   REPOSITORY_PATH = Rails.root.join('tmp/test/bazaar_repository').to_s
index 4dedaf0f0ace953781bd552f7fe1ea3dd6350f6d..14f6a63551fb01e6d11d17083864442cc621968b 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class RepositoriesControllerTest < ActionController::TestCase
-  fixtures :projects, :users, :roles, :members, :member_roles, :enabled_modules,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles, :enabled_modules,
            :repositories, :issues, :issue_statuses, :changesets, :changes,
            :issue_categories, :enumerations, :custom_fields, :custom_values, :trackers
 
index 094d027e96e91a36ae590e4ad379dbb3911642c2..ba35e1ea094ee06ea0d6105f2fd188300cc157a0 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class RepositoriesCvsControllerTest < ActionController::TestCase
   tests RepositoriesController
 
-  fixtures :projects, :users, :roles, :members, :member_roles,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
            :repositories, :enabled_modules
 
   REPOSITORY_PATH = Rails.root.join('tmp/test/cvs_repository').to_s
index dd41f51ab5b7b4d382d5a355d820fce354767206..9df8b2a24e1ac95c2347b26714a57bac339eb974 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class RepositoriesDarcsControllerTest < ActionController::TestCase
   tests RepositoriesController
 
-  fixtures :projects, :users, :roles, :members, :member_roles,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
            :repositories, :enabled_modules
 
   REPOSITORY_PATH = Rails.root.join('tmp/test/darcs_repository').to_s
index 0de6f6c61d68ff964c79c51ebe3df8304b2edcba..34333cf894ae89de20c85dc8fd172de0cd97b91b 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class RepositoriesFilesystemControllerTest < ActionController::TestCase
   tests RepositoriesController
 
-  fixtures :projects, :users, :roles, :members, :member_roles,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
            :repositories, :enabled_modules
 
   REPOSITORY_PATH = Rails.root.join('tmp/test/filesystem_repository').to_s
index f085661ddbc19e5cb91c8969d8b16665b7cf9b1c..c6d7ada9b72bf8a2f41aa457bc13ad2fed9b6e5f 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class RepositoriesGitControllerTest < ActionController::TestCase
   tests RepositoriesController
 
-  fixtures :projects, :users, :roles, :members, :member_roles,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
            :repositories, :enabled_modules
 
   REPOSITORY_PATH = Rails.root.join('tmp/test/git_repository').to_s
index e4485b91cd614cd2c91cb768cf64db3745853c23..734fe0e236c3c52a49bbaef772172ed907757ce5 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class RepositoriesMercurialControllerTest < ActionController::TestCase
   tests RepositoriesController
 
-  fixtures :projects, :users, :roles, :members, :member_roles,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
            :repositories, :enabled_modules
 
   REPOSITORY_PATH = Rails.root.join('tmp/test/mercurial_repository').to_s
index 9afced4e48ce3d7877b0882eb63535d330153ba0..664330ff4ad133a5887f8be96fa390ac8b9680fd 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class RepositoriesSubversionControllerTest < ActionController::TestCase
   tests RepositoriesController
 
-  fixtures :projects, :users, :roles, :members, :member_roles, :enabled_modules,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles, :enabled_modules,
            :repositories, :issues, :issue_statuses, :changesets, :changes,
            :issue_categories, :enumerations, :custom_fields, :custom_values, :trackers
 
index 2fc48dbcce1073f3ee3668de049c0743ed746d61..b34c80945e4c658008a06fdd32e22e87cfd29f7c 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class UsersControllerTest < ActionController::TestCase
   include Redmine::I18n
 
-  fixtures :users, :projects, :members, :member_roles, :roles,
+  fixtures :users, :email_addresses, :projects, :members, :member_roles, :roles,
            :custom_fields, :custom_values, :groups_users,
            :auth_sources,
            :enabled_modules,
index fb9537a04d2a1ccd219b313b8ae95f0763222692..9bffe66c7dade64ed22861240d065a7d20db394c 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class WikiControllerTest < ActionController::TestCase
-  fixtures :projects, :users, :roles, :members, :member_roles,
+  fixtures :projects, :users, :email_addresses, :roles, :members, :member_roles,
            :enabled_modules, :wikis, :wiki_pages, :wiki_contents,
            :wiki_content_versions, :attachments,
            :issues, :issue_statuses
index bf458b734524dfcedbf88883fa61783a96c6aae7..7f2f0e4fff6c24a3cfcc97f1e2c2308fdd72860f 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class AccountTest < Redmine::IntegrationTest
-  fixtures :users, :roles
+  fixtures :users, :email_addresses, :roles
 
   def test_login
     get "/my/page"
index f1a0e1055005abc77ac3454698074ccc2efb77ac..d8d78ad0ee4dad3f0b894471c79c7da6c5bbdaba 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../../test_helper', __FILE__)
 
 class Redmine::ApiTest::UsersTest < Redmine::ApiTest::Base
-  fixtures :users, :members, :member_roles, :roles, :projects
+  fixtures :users, :email_addresses, :members, :member_roles, :roles, :projects
 
   test "GET /users.xml should return users" do
     get '/users.xml', {}, credentials('admin')
index d2628117271f60aab1005298be2480c755987273..981fdb760720b830a2213e9f9ac9c41a0022d5e0 100644 (file)
@@ -19,7 +19,7 @@ require File.expand_path('../../test_helper', __FILE__)
 
 class IssuesTest < Redmine::IntegrationTest
   fixtures :projects,
-           :users,
+           :users, :email_addresses,
            :roles,
            :members,
            :member_roles,
index ea7f06c12ba61557443b433d3e7ffa5d2526ea0b..1ae2f27d6ce32ba4fbe804c2177d6b04010da3b2 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class UsersTest < Redmine::IntegrationTest
-  fixtures :users
+  fixtures :users, :email_addresses
 
   def test_destroy_should_not_accept_get_requests
     assert_no_difference 'User.count' do
index 98d15c0e08a27cef213823458a35ec73d0a4dd83..50decb7c7c3d21bac293ff92464ecbad000544af 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class DocumentTest < ActiveSupport::TestCase
   fixtures :projects, :enumerations, :documents, :attachments,
            :enabled_modules,
-           :users, :members, :member_roles, :roles,
+           :users, :email_addresses, :members, :member_roles, :roles,
            :groups_users
 
   def test_create
index 03208d397f8f3a572a26ab3a6eb1dec0e040c838..0384dfb0e913d5366bed1eb553f1d8071ec67bb0 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class IssueTest < ActiveSupport::TestCase
-  fixtures :projects, :users, :members, :member_roles, :roles,
+  fixtures :projects, :users, :email_addresses, :members, :member_roles, :roles,
            :groups_users,
            :trackers, :projects_trackers,
            :enabled_modules,
index a8a7846ba2eab3ce7685c0b6f22e378961b2675e..bc7e0a0d0dc70b1f2002b69c9cff62f37cd5ae26 100644 (file)
@@ -223,6 +223,17 @@ class MailHandlerTest < ActiveSupport::TestCase
     assert_equal 1, issue.watcher_user_ids.size
   end
 
+  def test_add_issue_from_additional_email_address
+    user = User.find(2)
+    user.mail = 'mainaddress@somenet.foo'
+    user.save!
+    EmailAddress.create!(:user => user, :address => 'jsmith@somenet.foo')
+
+    issue = submit_email('ticket_on_given_project.eml')
+    assert issue
+    assert_equal user, issue.author
+  end
+
   def test_add_issue_by_unknown_user
     assert_no_difference 'User.count' do
       assert_equal false,
index 9728387a4b8fa762084fb7cb1201005d70cb1ce5..b020e5303b0b6c45b4f6db86e0037d3a60af7aa2 100644 (file)
@@ -20,7 +20,7 @@ require File.expand_path('../../test_helper', __FILE__)
 class MailerTest < ActiveSupport::TestCase
   include Redmine::I18n
   include ActionDispatch::Assertions::SelectorAssertions
-  fixtures :projects, :enabled_modules, :issues, :users, :members,
+  fixtures :projects, :enabled_modules, :issues, :users, :email_addresses, :members,
            :member_roles, :roles, :documents, :attachments, :news,
            :tokens, :journals, :journal_details, :changesets,
            :trackers, :projects_trackers,
@@ -298,6 +298,14 @@ class MailerTest < ActiveSupport::TestCase
     assert last_email.bcc.include?('dlopper@somenet.foo')
   end
 
+  def test_issue_add_should_send_mail_to_all_user_email_address
+    EmailAddress.create!(:user_id => 3, :address => 'otheremail@somenet.foo')
+    issue = Issue.find(1)
+    assert Mailer.deliver_issue_add(issue)
+    assert last_email.bcc.include?('dlopper@somenet.foo')
+    assert last_email.bcc.include?('otheremail@somenet.foo')
+  end
+
   test "#issue_add should not notify project members that are not allow to view the issue" do
     issue = Issue.find(1)
     Role.find(2).remove_permission!(:view_issues)
@@ -771,6 +779,30 @@ class MailerTest < ActiveSupport::TestCase
     ActionMailer::Base.delivery_method = :test
   end
 
+  def test_email_addresses_should_keep_addresses
+    assert_equal ["foo@example.net"],
+      Mailer.email_addresses("foo@example.net")
+
+    assert_equal ["foo@example.net", "bar@example.net"],
+      Mailer.email_addresses(["foo@example.net", "bar@example.net"])
+  end
+
+  def test_email_addresses_should_replace_users_with_their_email_addresses
+    assert_equal ["admin@somenet.foo"],
+      Mailer.email_addresses(User.find(1))
+
+    assert_equal ["admin@somenet.foo", "jsmith@somenet.foo"],
+      Mailer.email_addresses(User.where(:id => [1,2])).sort
+  end
+
+  def test_email_addresses_should_include_notified_emails_addresses_only
+    EmailAddress.create!(:user_id => 2, :address => "another@somenet.foo", :notify => false)
+    EmailAddress.create!(:user_id => 2, :address => "another2@somenet.foo")
+
+    assert_equal ["another2@somenet.foo", "jsmith@somenet.foo"],
+      Mailer.email_addresses(User.find(2)).sort
+  end
+
   private
 
   def last_email
index fbe35f2535fbcb128c4b2a6985c975c1d47cdc67..429f2908b2360e3bdaf7382b0301b433370ddbcd 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class UserTest < ActiveSupport::TestCase
-  fixtures :users, :members, :projects, :roles, :member_roles, :auth_sources,
+  fixtures :users, :email_addresses, :members, :projects, :roles, :member_roles, :auth_sources,
             :trackers, :issue_statuses,
             :projects_trackers,
             :watchers,
@@ -57,11 +57,41 @@ class UserTest < ActiveSupport::TestCase
     assert_equal "foo@bar.com", u.mail
   end
 
-  def test_mail_validation
-    u = User.new
+  def test_should_create_email_address
+    u = User.new(:firstname => "new", :lastname => "user")
+    u.login = "create_email_address"
+    u.mail = "defaultemail@somenet.foo"
+    assert u.save
+    u.reload
+    assert u.email_address
+    assert_equal "defaultemail@somenet.foo", u.email_address.address
+    assert_equal true, u.email_address.is_default
+    assert_equal true, u.email_address.notify
+  end
+
+  def test_should_not_create_user_without_mail
+    set_language_if_valid 'en'
+    u = User.new(:firstname => "new", :lastname => "user")
+    u.login = "user_without_mail"
+    assert !u.save
+    assert_equal ["Email #{I18n.translate('activerecord.errors.messages.blank')}"], u.errors.full_messages
+  end
+
+  def test_should_not_create_user_with_blank_mail
+    set_language_if_valid 'en'
+    u = User.new(:firstname => "new", :lastname => "user")
+    u.login = "user_with_blank_mail"
+    u.mail = ''
+    assert !u.save
+    assert_equal ["Email #{I18n.translate('activerecord.errors.messages.blank')}"], u.errors.full_messages
+  end
+
+  def test_should_not_update_user_with_blank_mail
+    set_language_if_valid 'en'
+    u = User.find(2)
     u.mail = ''
-    assert !u.valid?
-    assert_include I18n.translate('activerecord.errors.messages.blank'), u.errors[:mail]
+    assert !u.save
+    assert_equal ["Email #{I18n.translate('activerecord.errors.messages.blank')}"], u.errors.full_messages
   end
 
   def test_login_length_validation
@@ -151,6 +181,7 @@ class UserTest < ActiveSupport::TestCase
   end
 
   def test_mail_uniqueness_should_not_be_case_sensitive
+    set_language_if_valid 'en'
     u = User.new(:firstname => "new", :lastname => "user", :mail => "newuser@somenet.foo")
     u.login = 'newuser1'
     u.password, u.password_confirmation = "password", "password"
@@ -160,7 +191,7 @@ class UserTest < ActiveSupport::TestCase
     u.login = 'newuser2'
     u.password, u.password_confirmation = "password", "password"
     assert !u.save
-    assert_include I18n.translate('activerecord.errors.messages.taken'), u.errors[:mail]
+    assert_include "Email #{I18n.translate('activerecord.errors.messages.taken')}", u.errors.full_messages
   end
 
   def test_update
@@ -677,7 +708,7 @@ class UserTest < ActiveSupport::TestCase
     assert_kind_of AnonymousUser, anon1
     anon2 = AnonymousUser.create(
                 :lastname => 'Anonymous', :firstname => '',
-                :mail => '', :login => '', :status => 0)
+                :login => '', :status => 0)
     assert_equal 1, anon2.errors.count
   end
 
index f10276ccb6a20ef380a701a3aedfdfb9888c1d26..e7417332c4e25f9fa97fbe148df9cb5ea239f9db 100644 (file)
@@ -18,7 +18,7 @@
 require File.expand_path('../../test_helper', __FILE__)
 
 class WatcherTest < ActiveSupport::TestCase
-  fixtures :projects, :users, :members, :member_roles, :roles, :enabled_modules,
+  fixtures :projects, :users, :email_addresses, :members, :member_roles, :roles, :enabled_modules,
            :issues, :issue_statuses, :enumerations, :trackers, :projects_trackers,
            :boards, :messages,
            :wikis, :wiki_pages,