##// END OF EJS Templates
Merged r6199 from trunk....
Jean-Philippe Lang -
r6080:3e9ad22fade0
parent child
Show More
@@ -1,134 +1,135
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Enumeration < ActiveRecord::Base
18 class Enumeration < ActiveRecord::Base
19 default_scope :order => "#{Enumeration.table_name}.position ASC"
19 default_scope :order => "#{Enumeration.table_name}.position ASC"
20
20
21 belongs_to :project
21 belongs_to :project
22
22
23 acts_as_list :scope => 'type = \'#{type}\''
23 acts_as_list :scope => 'type = \'#{type}\''
24 acts_as_customizable
24 acts_as_customizable
25 acts_as_tree :order => 'position ASC'
25 acts_as_tree :order => 'position ASC'
26
26
27 before_destroy :check_integrity
27 before_destroy :check_integrity
28
28
29 validates_presence_of :name
29 validates_presence_of :name
30 validates_uniqueness_of :name, :scope => [:type, :project_id]
30 validates_uniqueness_of :name, :scope => [:type, :project_id]
31 validates_length_of :name, :maximum => 30
31 validates_length_of :name, :maximum => 30
32
32
33 named_scope :shared, :conditions => { :project_id => nil }
33 named_scope :shared, :conditions => { :project_id => nil }
34 named_scope :active, :conditions => { :active => true }
34 named_scope :active, :conditions => { :active => true }
35 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
35
36
36 def self.default
37 def self.default
37 # Creates a fake default scope so Enumeration.default will check
38 # Creates a fake default scope so Enumeration.default will check
38 # it's type. STI subclasses will automatically add their own
39 # it's type. STI subclasses will automatically add their own
39 # types to the finder.
40 # types to the finder.
40 if self.descends_from_active_record?
41 if self.descends_from_active_record?
41 find(:first, :conditions => { :is_default => true, :type => 'Enumeration' })
42 find(:first, :conditions => { :is_default => true, :type => 'Enumeration' })
42 else
43 else
43 # STI classes are
44 # STI classes are
44 find(:first, :conditions => { :is_default => true })
45 find(:first, :conditions => { :is_default => true })
45 end
46 end
46 end
47 end
47
48
48 # Overloaded on concrete classes
49 # Overloaded on concrete classes
49 def option_name
50 def option_name
50 nil
51 nil
51 end
52 end
52
53
53 def before_save
54 def before_save
54 if is_default? && is_default_changed?
55 if is_default? && is_default_changed?
55 Enumeration.update_all("is_default = #{connection.quoted_false}", {:type => type})
56 Enumeration.update_all("is_default = #{connection.quoted_false}", {:type => type})
56 end
57 end
57 end
58 end
58
59
59 # Overloaded on concrete classes
60 # Overloaded on concrete classes
60 def objects_count
61 def objects_count
61 0
62 0
62 end
63 end
63
64
64 def in_use?
65 def in_use?
65 self.objects_count != 0
66 self.objects_count != 0
66 end
67 end
67
68
68 # Is this enumeration overiding a system level enumeration?
69 # Is this enumeration overiding a system level enumeration?
69 def is_override?
70 def is_override?
70 !self.parent.nil?
71 !self.parent.nil?
71 end
72 end
72
73
73 alias :destroy_without_reassign :destroy
74 alias :destroy_without_reassign :destroy
74
75
75 # Destroy the enumeration
76 # Destroy the enumeration
76 # If a enumeration is specified, objects are reassigned
77 # If a enumeration is specified, objects are reassigned
77 def destroy(reassign_to = nil)
78 def destroy(reassign_to = nil)
78 if reassign_to && reassign_to.is_a?(Enumeration)
79 if reassign_to && reassign_to.is_a?(Enumeration)
79 self.transfer_relations(reassign_to)
80 self.transfer_relations(reassign_to)
80 end
81 end
81 destroy_without_reassign
82 destroy_without_reassign
82 end
83 end
83
84
84 def <=>(enumeration)
85 def <=>(enumeration)
85 position <=> enumeration.position
86 position <=> enumeration.position
86 end
87 end
87
88
88 def to_s; name end
89 def to_s; name end
89
90
90 # Returns the Subclasses of Enumeration. Each Subclass needs to be
91 # Returns the Subclasses of Enumeration. Each Subclass needs to be
91 # required in development mode.
92 # required in development mode.
92 #
93 #
93 # Note: subclasses is protected in ActiveRecord
94 # Note: subclasses is protected in ActiveRecord
94 def self.get_subclasses
95 def self.get_subclasses
95 @@subclasses[Enumeration]
96 @@subclasses[Enumeration]
96 end
97 end
97
98
98 # Does the +new+ Hash override the previous Enumeration?
99 # Does the +new+ Hash override the previous Enumeration?
99 def self.overridding_change?(new, previous)
100 def self.overridding_change?(new, previous)
100 if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous)
101 if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous)
101 return false
102 return false
102 else
103 else
103 return true
104 return true
104 end
105 end
105 end
106 end
106
107
107 # Does the +new+ Hash have the same custom values as the previous Enumeration?
108 # Does the +new+ Hash have the same custom values as the previous Enumeration?
108 def self.same_custom_values?(new, previous)
109 def self.same_custom_values?(new, previous)
109 previous.custom_field_values.each do |custom_value|
110 previous.custom_field_values.each do |custom_value|
110 if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s]
111 if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s]
111 return false
112 return false
112 end
113 end
113 end
114 end
114
115
115 return true
116 return true
116 end
117 end
117
118
118 # Are the new and previous fields equal?
119 # Are the new and previous fields equal?
119 def self.same_active_state?(new, previous)
120 def self.same_active_state?(new, previous)
120 new = (new == "1" ? true : false)
121 new = (new == "1" ? true : false)
121 return new == previous
122 return new == previous
122 end
123 end
123
124
124 private
125 private
125 def check_integrity
126 def check_integrity
126 raise "Can't delete enumeration" if self.in_use?
127 raise "Can't delete enumeration" if self.in_use?
127 end
128 end
128
129
129 end
130 end
130
131
131 # Force load the subclasses in development mode
132 # Force load the subclasses in development mode
132 require_dependency 'time_entry_activity'
133 require_dependency 'time_entry_activity'
133 require_dependency 'document_category'
134 require_dependency 'document_category'
134 require_dependency 'issue_priority'
135 require_dependency 'issue_priority'
@@ -1,43 +1,45
1 # redMine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class IssueCategory < ActiveRecord::Base
18 class IssueCategory < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
20 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
21 has_many :issues, :foreign_key => 'category_id', :dependent => :nullify
21 has_many :issues, :foreign_key => 'category_id', :dependent => :nullify
22
22
23 validates_presence_of :name
23 validates_presence_of :name
24 validates_uniqueness_of :name, :scope => [:project_id]
24 validates_uniqueness_of :name, :scope => [:project_id]
25 validates_length_of :name, :maximum => 30
25 validates_length_of :name, :maximum => 30
26
26
27 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
28
27 alias :destroy_without_reassign :destroy
29 alias :destroy_without_reassign :destroy
28
30
29 # Destroy the category
31 # Destroy the category
30 # If a category is specified, issues are reassigned to this category
32 # If a category is specified, issues are reassigned to this category
31 def destroy(reassign_to = nil)
33 def destroy(reassign_to = nil)
32 if reassign_to && reassign_to.is_a?(IssueCategory) && reassign_to.project == self.project
34 if reassign_to && reassign_to.is_a?(IssueCategory) && reassign_to.project == self.project
33 Issue.update_all("category_id = #{reassign_to.id}", "category_id = #{id}")
35 Issue.update_all("category_id = #{reassign_to.id}", "category_id = #{id}")
34 end
36 end
35 destroy_without_reassign
37 destroy_without_reassign
36 end
38 end
37
39
38 def <=>(category)
40 def <=>(category)
39 name <=> category.name
41 name <=> category.name
40 end
42 end
41
43
42 def to_s; name end
44 def to_s; name end
43 end
45 end
@@ -1,99 +1,101
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class IssueStatus < ActiveRecord::Base
18 class IssueStatus < ActiveRecord::Base
19 before_destroy :check_integrity
19 before_destroy :check_integrity
20 has_many :workflows, :foreign_key => "old_status_id"
20 has_many :workflows, :foreign_key => "old_status_id"
21 acts_as_list
21 acts_as_list
22
22
23 before_destroy :delete_workflows
23 before_destroy :delete_workflows
24
24
25 validates_presence_of :name
25 validates_presence_of :name
26 validates_uniqueness_of :name
26 validates_uniqueness_of :name
27 validates_length_of :name, :maximum => 30
27 validates_length_of :name, :maximum => 30
28 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
28 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
29
30 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
29
31
30 def after_save
32 def after_save
31 IssueStatus.update_all("is_default=#{connection.quoted_false}", ['id <> ?', id]) if self.is_default?
33 IssueStatus.update_all("is_default=#{connection.quoted_false}", ['id <> ?', id]) if self.is_default?
32 end
34 end
33
35
34 # Returns the default status for new issues
36 # Returns the default status for new issues
35 def self.default
37 def self.default
36 find(:first, :conditions =>["is_default=?", true])
38 find(:first, :conditions =>["is_default=?", true])
37 end
39 end
38
40
39 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
41 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
40 def self.update_issue_done_ratios
42 def self.update_issue_done_ratios
41 if Issue.use_status_for_done_ratio?
43 if Issue.use_status_for_done_ratio?
42 IssueStatus.find(:all, :conditions => ["default_done_ratio >= 0"]).each do |status|
44 IssueStatus.find(:all, :conditions => ["default_done_ratio >= 0"]).each do |status|
43 Issue.update_all(["done_ratio = ?", status.default_done_ratio],
45 Issue.update_all(["done_ratio = ?", status.default_done_ratio],
44 ["status_id = ?", status.id])
46 ["status_id = ?", status.id])
45 end
47 end
46 end
48 end
47
49
48 return Issue.use_status_for_done_ratio?
50 return Issue.use_status_for_done_ratio?
49 end
51 end
50
52
51 # Returns an array of all statuses the given role can switch to
53 # Returns an array of all statuses the given role can switch to
52 # Uses association cache when called more than one time
54 # Uses association cache when called more than one time
53 def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
55 def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
54 if roles && tracker
56 if roles && tracker
55 role_ids = roles.collect(&:id)
57 role_ids = roles.collect(&:id)
56 transitions = workflows.select do |w|
58 transitions = workflows.select do |w|
57 role_ids.include?(w.role_id) &&
59 role_ids.include?(w.role_id) &&
58 w.tracker_id == tracker.id &&
60 w.tracker_id == tracker.id &&
59 (author || !w.author) &&
61 (author || !w.author) &&
60 (assignee || !w.assignee)
62 (assignee || !w.assignee)
61 end
63 end
62 transitions.collect{|w| w.new_status}.compact.sort
64 transitions.collect{|w| w.new_status}.compact.sort
63 else
65 else
64 []
66 []
65 end
67 end
66 end
68 end
67
69
68 # Same thing as above but uses a database query
70 # Same thing as above but uses a database query
69 # More efficient than the previous method if called just once
71 # More efficient than the previous method if called just once
70 def find_new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
72 def find_new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
71 if roles && tracker
73 if roles && tracker
72 conditions = {:role_id => roles.collect(&:id), :tracker_id => tracker.id}
74 conditions = {:role_id => roles.collect(&:id), :tracker_id => tracker.id}
73 conditions[:author] = false unless author
75 conditions[:author] = false unless author
74 conditions[:assignee] = false unless assignee
76 conditions[:assignee] = false unless assignee
75
77
76 workflows.find(:all,
78 workflows.find(:all,
77 :include => :new_status,
79 :include => :new_status,
78 :conditions => conditions).collect{|w| w.new_status}.compact.sort
80 :conditions => conditions).collect{|w| w.new_status}.compact.sort
79 else
81 else
80 []
82 []
81 end
83 end
82 end
84 end
83
85
84 def <=>(status)
86 def <=>(status)
85 position <=> status.position
87 position <=> status.position
86 end
88 end
87
89
88 def to_s; name end
90 def to_s; name end
89
91
90 private
92 private
91 def check_integrity
93 def check_integrity
92 raise "Can't delete status" if Issue.find(:first, :conditions => ["status_id=?", self.id])
94 raise "Can't delete status" if Issue.find(:first, :conditions => ["status_id=?", self.id])
93 end
95 end
94
96
95 # Deletes associated workflows
97 # Deletes associated workflows
96 def delete_workflows
98 def delete_workflows
97 Workflow.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
99 Workflow.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
98 end
100 end
99 end
101 end
@@ -1,366 +1,366
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class MailHandler < ActionMailer::Base
18 class MailHandler < ActionMailer::Base
19 include ActionView::Helpers::SanitizeHelper
19 include ActionView::Helpers::SanitizeHelper
20 include Redmine::I18n
20 include Redmine::I18n
21
21
22 class UnauthorizedAction < StandardError; end
22 class UnauthorizedAction < StandardError; end
23 class MissingInformation < StandardError; end
23 class MissingInformation < StandardError; end
24
24
25 attr_reader :email, :user
25 attr_reader :email, :user
26
26
27 def self.receive(email, options={})
27 def self.receive(email, options={})
28 @@handler_options = options.dup
28 @@handler_options = options.dup
29
29
30 @@handler_options[:issue] ||= {}
30 @@handler_options[:issue] ||= {}
31
31
32 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
32 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
33 @@handler_options[:allow_override] ||= []
33 @@handler_options[:allow_override] ||= []
34 # Project needs to be overridable if not specified
34 # Project needs to be overridable if not specified
35 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
35 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
36 # Status overridable by default
36 # Status overridable by default
37 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
37 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
38
38
39 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
39 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
40 super email
40 super email
41 end
41 end
42
42
43 # Processes incoming emails
43 # Processes incoming emails
44 # Returns the created object (eg. an issue, a message) or false
44 # Returns the created object (eg. an issue, a message) or false
45 def receive(email)
45 def receive(email)
46 @email = email
46 @email = email
47 sender_email = email.from.to_a.first.to_s.strip
47 sender_email = email.from.to_a.first.to_s.strip
48 # Ignore emails received from the application emission address to avoid hell cycles
48 # Ignore emails received from the application emission address to avoid hell cycles
49 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
49 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
50 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
50 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
51 return false
51 return false
52 end
52 end
53 @user = User.find_by_mail(sender_email) if sender_email.present?
53 @user = User.find_by_mail(sender_email) if sender_email.present?
54 if @user && !@user.active?
54 if @user && !@user.active?
55 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]" if logger && logger.info
55 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]" if logger && logger.info
56 return false
56 return false
57 end
57 end
58 if @user.nil?
58 if @user.nil?
59 # Email was submitted by an unknown user
59 # Email was submitted by an unknown user
60 case @@handler_options[:unknown_user]
60 case @@handler_options[:unknown_user]
61 when 'accept'
61 when 'accept'
62 @user = User.anonymous
62 @user = User.anonymous
63 when 'create'
63 when 'create'
64 @user = MailHandler.create_user_from_email(email)
64 @user = MailHandler.create_user_from_email(email)
65 if @user
65 if @user
66 logger.info "MailHandler: [#{@user.login}] account created" if logger && logger.info
66 logger.info "MailHandler: [#{@user.login}] account created" if logger && logger.info
67 Mailer.deliver_account_information(@user, @user.password)
67 Mailer.deliver_account_information(@user, @user.password)
68 else
68 else
69 logger.error "MailHandler: could not create account for [#{sender_email}]" if logger && logger.error
69 logger.error "MailHandler: could not create account for [#{sender_email}]" if logger && logger.error
70 return false
70 return false
71 end
71 end
72 else
72 else
73 # Default behaviour, emails from unknown users are ignored
73 # Default behaviour, emails from unknown users are ignored
74 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]" if logger && logger.info
74 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]" if logger && logger.info
75 return false
75 return false
76 end
76 end
77 end
77 end
78 User.current = @user
78 User.current = @user
79 dispatch
79 dispatch
80 end
80 end
81
81
82 private
82 private
83
83
84 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
84 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
85 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
85 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
86 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
86 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
87
87
88 def dispatch
88 def dispatch
89 headers = [email.in_reply_to, email.references].flatten.compact
89 headers = [email.in_reply_to, email.references].flatten.compact
90 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
90 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
91 klass, object_id = $1, $2.to_i
91 klass, object_id = $1, $2.to_i
92 method_name = "receive_#{klass}_reply"
92 method_name = "receive_#{klass}_reply"
93 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
93 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
94 send method_name, object_id
94 send method_name, object_id
95 else
95 else
96 # ignoring it
96 # ignoring it
97 end
97 end
98 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
98 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
99 receive_issue_reply(m[1].to_i)
99 receive_issue_reply(m[1].to_i)
100 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
100 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
101 receive_message_reply(m[1].to_i)
101 receive_message_reply(m[1].to_i)
102 else
102 else
103 dispatch_to_default
103 dispatch_to_default
104 end
104 end
105 rescue ActiveRecord::RecordInvalid => e
105 rescue ActiveRecord::RecordInvalid => e
106 # TODO: send a email to the user
106 # TODO: send a email to the user
107 logger.error e.message if logger
107 logger.error e.message if logger
108 false
108 false
109 rescue MissingInformation => e
109 rescue MissingInformation => e
110 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
110 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
111 false
111 false
112 rescue UnauthorizedAction => e
112 rescue UnauthorizedAction => e
113 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
113 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
114 false
114 false
115 end
115 end
116
116
117 def dispatch_to_default
117 def dispatch_to_default
118 receive_issue
118 receive_issue
119 end
119 end
120
120
121 # Creates a new issue
121 # Creates a new issue
122 def receive_issue
122 def receive_issue
123 project = target_project
123 project = target_project
124 # check permission
124 # check permission
125 unless @@handler_options[:no_permission_check]
125 unless @@handler_options[:no_permission_check]
126 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
126 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
127 end
127 end
128
128
129 issue = Issue.new(:author => user, :project => project)
129 issue = Issue.new(:author => user, :project => project)
130 issue.safe_attributes = issue_attributes_from_keywords(issue)
130 issue.safe_attributes = issue_attributes_from_keywords(issue)
131 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
131 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
132 issue.subject = email.subject.to_s.chomp[0,255]
132 issue.subject = email.subject.to_s.chomp[0,255]
133 if issue.subject.blank?
133 if issue.subject.blank?
134 issue.subject = '(no subject)'
134 issue.subject = '(no subject)'
135 end
135 end
136 issue.description = cleaned_up_text_body
136 issue.description = cleaned_up_text_body
137
137
138 # add To and Cc as watchers before saving so the watchers can reply to Redmine
138 # add To and Cc as watchers before saving so the watchers can reply to Redmine
139 add_watchers(issue)
139 add_watchers(issue)
140 issue.save!
140 issue.save!
141 add_attachments(issue)
141 add_attachments(issue)
142 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
142 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
143 issue
143 issue
144 end
144 end
145
145
146 # Adds a note to an existing issue
146 # Adds a note to an existing issue
147 def receive_issue_reply(issue_id)
147 def receive_issue_reply(issue_id)
148 issue = Issue.find_by_id(issue_id)
148 issue = Issue.find_by_id(issue_id)
149 return unless issue
149 return unless issue
150 # check permission
150 # check permission
151 unless @@handler_options[:no_permission_check]
151 unless @@handler_options[:no_permission_check]
152 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
152 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
153 end
153 end
154
154
155 # ignore CLI-supplied defaults for new issues
155 # ignore CLI-supplied defaults for new issues
156 @@handler_options[:issue].clear
156 @@handler_options[:issue].clear
157
157
158 journal = issue.init_journal(user)
158 journal = issue.init_journal(user)
159 issue.safe_attributes = issue_attributes_from_keywords(issue)
159 issue.safe_attributes = issue_attributes_from_keywords(issue)
160 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
160 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
161 journal.notes = cleaned_up_text_body
161 journal.notes = cleaned_up_text_body
162 add_attachments(issue)
162 add_attachments(issue)
163 issue.save!
163 issue.save!
164 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
164 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
165 journal
165 journal
166 end
166 end
167
167
168 # Reply will be added to the issue
168 # Reply will be added to the issue
169 def receive_journal_reply(journal_id)
169 def receive_journal_reply(journal_id)
170 journal = Journal.find_by_id(journal_id)
170 journal = Journal.find_by_id(journal_id)
171 if journal && journal.journalized_type == 'Issue'
171 if journal && journal.journalized_type == 'Issue'
172 receive_issue_reply(journal.journalized_id)
172 receive_issue_reply(journal.journalized_id)
173 end
173 end
174 end
174 end
175
175
176 # Receives a reply to a forum message
176 # Receives a reply to a forum message
177 def receive_message_reply(message_id)
177 def receive_message_reply(message_id)
178 message = Message.find_by_id(message_id)
178 message = Message.find_by_id(message_id)
179 if message
179 if message
180 message = message.root
180 message = message.root
181
181
182 unless @@handler_options[:no_permission_check]
182 unless @@handler_options[:no_permission_check]
183 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
183 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
184 end
184 end
185
185
186 if !message.locked?
186 if !message.locked?
187 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
187 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
188 :content => cleaned_up_text_body)
188 :content => cleaned_up_text_body)
189 reply.author = user
189 reply.author = user
190 reply.board = message.board
190 reply.board = message.board
191 message.children << reply
191 message.children << reply
192 add_attachments(reply)
192 add_attachments(reply)
193 reply
193 reply
194 else
194 else
195 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic" if logger && logger.info
195 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic" if logger && logger.info
196 end
196 end
197 end
197 end
198 end
198 end
199
199
200 def add_attachments(obj)
200 def add_attachments(obj)
201 if email.has_attachments?
201 if email.has_attachments?
202 email.attachments.each do |attachment|
202 email.attachments.each do |attachment|
203 Attachment.create(:container => obj,
203 Attachment.create(:container => obj,
204 :file => attachment,
204 :file => attachment,
205 :author => user,
205 :author => user,
206 :content_type => attachment.content_type)
206 :content_type => attachment.content_type)
207 end
207 end
208 end
208 end
209 end
209 end
210
210
211 # Adds To and Cc as watchers of the given object if the sender has the
211 # Adds To and Cc as watchers of the given object if the sender has the
212 # appropriate permission
212 # appropriate permission
213 def add_watchers(obj)
213 def add_watchers(obj)
214 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
214 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
215 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
215 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
216 unless addresses.empty?
216 unless addresses.empty?
217 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
217 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
218 watchers.each {|w| obj.add_watcher(w)}
218 watchers.each {|w| obj.add_watcher(w)}
219 end
219 end
220 end
220 end
221 end
221 end
222
222
223 def get_keyword(attr, options={})
223 def get_keyword(attr, options={})
224 @keywords ||= {}
224 @keywords ||= {}
225 if @keywords.has_key?(attr)
225 if @keywords.has_key?(attr)
226 @keywords[attr]
226 @keywords[attr]
227 else
227 else
228 @keywords[attr] = begin
228 @keywords[attr] = begin
229 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && (v = extract_keyword!(plain_text_body, attr, options[:format]))
229 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && (v = extract_keyword!(plain_text_body, attr, options[:format]))
230 v
230 v
231 elsif !@@handler_options[:issue][attr].blank?
231 elsif !@@handler_options[:issue][attr].blank?
232 @@handler_options[:issue][attr]
232 @@handler_options[:issue][attr]
233 end
233 end
234 end
234 end
235 end
235 end
236 end
236 end
237
237
238 # Destructively extracts the value for +attr+ in +text+
238 # Destructively extracts the value for +attr+ in +text+
239 # Returns nil if no matching keyword found
239 # Returns nil if no matching keyword found
240 def extract_keyword!(text, attr, format=nil)
240 def extract_keyword!(text, attr, format=nil)
241 keys = [attr.to_s.humanize]
241 keys = [attr.to_s.humanize]
242 if attr.is_a?(Symbol)
242 if attr.is_a?(Symbol)
243 keys << l("field_#{attr}", :default => '', :locale => user.language) if user && user.language.present?
243 keys << l("field_#{attr}", :default => '', :locale => user.language) if user && user.language.present?
244 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language) if Setting.default_language.present?
244 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language) if Setting.default_language.present?
245 end
245 end
246 keys.reject! {|k| k.blank?}
246 keys.reject! {|k| k.blank?}
247 keys.collect! {|k| Regexp.escape(k)}
247 keys.collect! {|k| Regexp.escape(k)}
248 format ||= '.+'
248 format ||= '.+'
249 text.gsub!(/^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i, '')
249 text.gsub!(/^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i, '')
250 $2 && $2.strip
250 $2 && $2.strip
251 end
251 end
252
252
253 def target_project
253 def target_project
254 # TODO: other ways to specify project:
254 # TODO: other ways to specify project:
255 # * parse the email To field
255 # * parse the email To field
256 # * specific project (eg. Setting.mail_handler_target_project)
256 # * specific project (eg. Setting.mail_handler_target_project)
257 target = Project.find_by_identifier(get_keyword(:project))
257 target = Project.find_by_identifier(get_keyword(:project))
258 raise MissingInformation.new('Unable to determine target project') if target.nil?
258 raise MissingInformation.new('Unable to determine target project') if target.nil?
259 target
259 target
260 end
260 end
261
261
262 # Returns a Hash of issue attributes extracted from keywords in the email body
262 # Returns a Hash of issue attributes extracted from keywords in the email body
263 def issue_attributes_from_keywords(issue)
263 def issue_attributes_from_keywords(issue)
264 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_user_from_keyword(k)
264 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_user_from_keyword(k)
265 assigned_to = nil if assigned_to && !issue.assignable_users.include?(assigned_to)
265 assigned_to = nil if assigned_to && !issue.assignable_users.include?(assigned_to)
266
266
267 attrs = {
267 attrs = {
268 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.find_by_name(k).try(:id),
268 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
269 'status_id' => (k = get_keyword(:status)) && IssueStatus.find_by_name(k).try(:id),
269 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
270 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.find_by_name(k).try(:id),
270 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
271 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.find_by_name(k).try(:id),
271 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
272 'assigned_to_id' => assigned_to.try(:id),
272 'assigned_to_id' => assigned_to.try(:id),
273 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) && issue.project.shared_versions.find_by_name(k).try(:id),
273 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) && issue.project.shared_versions.named(k).first.try(:id),
274 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
274 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
275 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
275 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
276 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
276 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
277 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
277 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
278 }.delete_if {|k, v| v.blank? }
278 }.delete_if {|k, v| v.blank? }
279
279
280 if issue.new_record? && attrs['tracker_id'].nil?
280 if issue.new_record? && attrs['tracker_id'].nil?
281 attrs['tracker_id'] = issue.project.trackers.find(:first).try(:id)
281 attrs['tracker_id'] = issue.project.trackers.find(:first).try(:id)
282 end
282 end
283
283
284 attrs
284 attrs
285 end
285 end
286
286
287 # Returns a Hash of issue custom field values extracted from keywords in the email body
287 # Returns a Hash of issue custom field values extracted from keywords in the email body
288 def custom_field_values_from_keywords(customized)
288 def custom_field_values_from_keywords(customized)
289 customized.custom_field_values.inject({}) do |h, v|
289 customized.custom_field_values.inject({}) do |h, v|
290 if value = get_keyword(v.custom_field.name, :override => true)
290 if value = get_keyword(v.custom_field.name, :override => true)
291 h[v.custom_field.id.to_s] = value
291 h[v.custom_field.id.to_s] = value
292 end
292 end
293 h
293 h
294 end
294 end
295 end
295 end
296
296
297 # Returns the text/plain part of the email
297 # Returns the text/plain part of the email
298 # If not found (eg. HTML-only email), returns the body with tags removed
298 # If not found (eg. HTML-only email), returns the body with tags removed
299 def plain_text_body
299 def plain_text_body
300 return @plain_text_body unless @plain_text_body.nil?
300 return @plain_text_body unless @plain_text_body.nil?
301 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
301 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
302 if parts.empty?
302 if parts.empty?
303 parts << @email
303 parts << @email
304 end
304 end
305 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
305 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
306 if plain_text_part.nil?
306 if plain_text_part.nil?
307 # no text/plain part found, assuming html-only email
307 # no text/plain part found, assuming html-only email
308 # strip html tags and remove doctype directive
308 # strip html tags and remove doctype directive
309 @plain_text_body = strip_tags(@email.body.to_s)
309 @plain_text_body = strip_tags(@email.body.to_s)
310 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
310 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
311 else
311 else
312 @plain_text_body = plain_text_part.body.to_s
312 @plain_text_body = plain_text_part.body.to_s
313 end
313 end
314 @plain_text_body.strip!
314 @plain_text_body.strip!
315 @plain_text_body
315 @plain_text_body
316 end
316 end
317
317
318 def cleaned_up_text_body
318 def cleaned_up_text_body
319 cleanup_body(plain_text_body)
319 cleanup_body(plain_text_body)
320 end
320 end
321
321
322 def self.full_sanitizer
322 def self.full_sanitizer
323 @full_sanitizer ||= HTML::FullSanitizer.new
323 @full_sanitizer ||= HTML::FullSanitizer.new
324 end
324 end
325
325
326 # Creates a user account for the +email+ sender
326 # Creates a user account for the +email+ sender
327 def self.create_user_from_email(email)
327 def self.create_user_from_email(email)
328 addr = email.from_addrs.to_a.first
328 addr = email.from_addrs.to_a.first
329 if addr && !addr.spec.blank?
329 if addr && !addr.spec.blank?
330 user = User.new
330 user = User.new
331 user.mail = addr.spec
331 user.mail = addr.spec
332
332
333 names = addr.name.blank? ? addr.spec.gsub(/@.*$/, '').split('.') : addr.name.split
333 names = addr.name.blank? ? addr.spec.gsub(/@.*$/, '').split('.') : addr.name.split
334 user.firstname = names.shift
334 user.firstname = names.shift
335 user.lastname = names.join(' ')
335 user.lastname = names.join(' ')
336 user.lastname = '-' if user.lastname.blank?
336 user.lastname = '-' if user.lastname.blank?
337
337
338 user.login = user.mail
338 user.login = user.mail
339 user.password = ActiveSupport::SecureRandom.hex(5)
339 user.password = ActiveSupport::SecureRandom.hex(5)
340 user.language = Setting.default_language
340 user.language = Setting.default_language
341 user.save ? user : nil
341 user.save ? user : nil
342 end
342 end
343 end
343 end
344
344
345 private
345 private
346
346
347 # Removes the email body of text after the truncation configurations.
347 # Removes the email body of text after the truncation configurations.
348 def cleanup_body(body)
348 def cleanup_body(body)
349 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
349 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
350 unless delimiters.empty?
350 unless delimiters.empty?
351 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
351 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
352 body = body.gsub(regex, '')
352 body = body.gsub(regex, '')
353 end
353 end
354 body.strip
354 body.strip
355 end
355 end
356
356
357 def find_user_from_keyword(keyword)
357 def find_user_from_keyword(keyword)
358 user ||= User.find_by_mail(keyword)
358 user ||= User.find_by_mail(keyword)
359 user ||= User.find_by_login(keyword)
359 user ||= User.find_by_login(keyword)
360 if user.nil? && keyword.match(/ /)
360 if user.nil? && keyword.match(/ /)
361 firstname, lastname = *(keyword.split) # "First Last Throwaway"
361 firstname, lastname = *(keyword.split) # "First Last Throwaway"
362 user ||= User.find_by_firstname_and_lastname(firstname, lastname)
362 user ||= User.find_by_firstname_and_lastname(firstname, lastname)
363 end
363 end
364 user
364 user
365 end
365 end
366 end
366 end
@@ -1,66 +1,68
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Tracker < ActiveRecord::Base
18 class Tracker < ActiveRecord::Base
19 before_destroy :check_integrity
19 before_destroy :check_integrity
20 has_many :issues
20 has_many :issues
21 has_many :workflows, :dependent => :delete_all do
21 has_many :workflows, :dependent => :delete_all do
22 def copy(source_tracker)
22 def copy(source_tracker)
23 Workflow.copy(source_tracker, nil, proxy_owner, nil)
23 Workflow.copy(source_tracker, nil, proxy_owner, nil)
24 end
24 end
25 end
25 end
26
26
27 has_and_belongs_to_many :projects
27 has_and_belongs_to_many :projects
28 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
28 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
29 acts_as_list
29 acts_as_list
30
30
31 validates_presence_of :name
31 validates_presence_of :name
32 validates_uniqueness_of :name
32 validates_uniqueness_of :name
33 validates_length_of :name, :maximum => 30
33 validates_length_of :name, :maximum => 30
34
34
35 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
36
35 def to_s; name end
37 def to_s; name end
36
38
37 def <=>(tracker)
39 def <=>(tracker)
38 name <=> tracker.name
40 name <=> tracker.name
39 end
41 end
40
42
41 def self.all
43 def self.all
42 find(:all, :order => 'position')
44 find(:all, :order => 'position')
43 end
45 end
44
46
45 # Returns an array of IssueStatus that are used
47 # Returns an array of IssueStatus that are used
46 # in the tracker's workflows
48 # in the tracker's workflows
47 def issue_statuses
49 def issue_statuses
48 if @issue_statuses
50 if @issue_statuses
49 return @issue_statuses
51 return @issue_statuses
50 elsif new_record?
52 elsif new_record?
51 return []
53 return []
52 end
54 end
53
55
54 ids = Workflow.
56 ids = Workflow.
55 connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{Workflow.table_name} WHERE tracker_id = #{id}").
57 connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{Workflow.table_name} WHERE tracker_id = #{id}").
56 flatten.
58 flatten.
57 uniq
59 uniq
58
60
59 @issue_statuses = IssueStatus.find_all_by_id(ids).sort
61 @issue_statuses = IssueStatus.find_all_by_id(ids).sort
60 end
62 end
61
63
62 private
64 private
63 def check_integrity
65 def check_integrity
64 raise "Can't delete tracker" if Issue.find(:first, :conditions => ["tracker_id=?", self.id])
66 raise "Can't delete tracker" if Issue.find(:first, :conditions => ["tracker_id=?", self.id])
65 end
67 end
66 end
68 end
@@ -1,233 +1,234
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2010 Jean-Philippe Lang
2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Version < ActiveRecord::Base
18 class Version < ActiveRecord::Base
19 after_update :update_issues_from_sharing_change
19 after_update :update_issues_from_sharing_change
20 belongs_to :project
20 belongs_to :project
21 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
21 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
22 acts_as_customizable
22 acts_as_customizable
23 acts_as_attachable :view_permission => :view_files,
23 acts_as_attachable :view_permission => :view_files,
24 :delete_permission => :manage_files
24 :delete_permission => :manage_files
25
25
26 VERSION_STATUSES = %w(open locked closed)
26 VERSION_STATUSES = %w(open locked closed)
27 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
27 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
28
28
29 validates_presence_of :name
29 validates_presence_of :name
30 validates_uniqueness_of :name, :scope => [:project_id]
30 validates_uniqueness_of :name, :scope => [:project_id]
31 validates_length_of :name, :maximum => 60
31 validates_length_of :name, :maximum => 60
32 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
32 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
33 validates_inclusion_of :status, :in => VERSION_STATUSES
33 validates_inclusion_of :status, :in => VERSION_STATUSES
34 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
34 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
35
35
36 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
36 named_scope :open, :conditions => {:status => 'open'}
37 named_scope :open, :conditions => {:status => 'open'}
37 named_scope :visible, lambda {|*args| { :include => :project,
38 named_scope :visible, lambda {|*args| { :include => :project,
38 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
39 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
39
40
40 # Returns true if +user+ or current user is allowed to view the version
41 # Returns true if +user+ or current user is allowed to view the version
41 def visible?(user=User.current)
42 def visible?(user=User.current)
42 user.allowed_to?(:view_issues, self.project)
43 user.allowed_to?(:view_issues, self.project)
43 end
44 end
44
45
45 def start_date
46 def start_date
46 @start_date ||= fixed_issues.minimum('start_date')
47 @start_date ||= fixed_issues.minimum('start_date')
47 end
48 end
48
49
49 def due_date
50 def due_date
50 effective_date
51 effective_date
51 end
52 end
52
53
53 # Returns the total estimated time for this version
54 # Returns the total estimated time for this version
54 # (sum of leaves estimated_hours)
55 # (sum of leaves estimated_hours)
55 def estimated_hours
56 def estimated_hours
56 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
57 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
57 end
58 end
58
59
59 # Returns the total reported time for this version
60 # Returns the total reported time for this version
60 def spent_hours
61 def spent_hours
61 @spent_hours ||= TimeEntry.sum(:hours, :include => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
62 @spent_hours ||= TimeEntry.sum(:hours, :include => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
62 end
63 end
63
64
64 def closed?
65 def closed?
65 status == 'closed'
66 status == 'closed'
66 end
67 end
67
68
68 def open?
69 def open?
69 status == 'open'
70 status == 'open'
70 end
71 end
71
72
72 # Returns true if the version is completed: due date reached and no open issues
73 # Returns true if the version is completed: due date reached and no open issues
73 def completed?
74 def completed?
74 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
75 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
75 end
76 end
76
77
77 def behind_schedule?
78 def behind_schedule?
78 if completed_pourcent == 100
79 if completed_pourcent == 100
79 return false
80 return false
80 elsif due_date && start_date
81 elsif due_date && start_date
81 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
82 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
82 return done_date <= Date.today
83 return done_date <= Date.today
83 else
84 else
84 false # No issues so it's not late
85 false # No issues so it's not late
85 end
86 end
86 end
87 end
87
88
88 # Returns the completion percentage of this version based on the amount of open/closed issues
89 # Returns the completion percentage of this version based on the amount of open/closed issues
89 # and the time spent on the open issues.
90 # and the time spent on the open issues.
90 def completed_pourcent
91 def completed_pourcent
91 if issues_count == 0
92 if issues_count == 0
92 0
93 0
93 elsif open_issues_count == 0
94 elsif open_issues_count == 0
94 100
95 100
95 else
96 else
96 issues_progress(false) + issues_progress(true)
97 issues_progress(false) + issues_progress(true)
97 end
98 end
98 end
99 end
99
100
100 # Returns the percentage of issues that have been marked as 'closed'.
101 # Returns the percentage of issues that have been marked as 'closed'.
101 def closed_pourcent
102 def closed_pourcent
102 if issues_count == 0
103 if issues_count == 0
103 0
104 0
104 else
105 else
105 issues_progress(false)
106 issues_progress(false)
106 end
107 end
107 end
108 end
108
109
109 # Returns true if the version is overdue: due date reached and some open issues
110 # Returns true if the version is overdue: due date reached and some open issues
110 def overdue?
111 def overdue?
111 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
112 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
112 end
113 end
113
114
114 # Returns assigned issues count
115 # Returns assigned issues count
115 def issues_count
116 def issues_count
116 @issue_count ||= fixed_issues.count
117 @issue_count ||= fixed_issues.count
117 end
118 end
118
119
119 # Returns the total amount of open issues for this version.
120 # Returns the total amount of open issues for this version.
120 def open_issues_count
121 def open_issues_count
121 @open_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, false], :include => :status)
122 @open_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, false], :include => :status)
122 end
123 end
123
124
124 # Returns the total amount of closed issues for this version.
125 # Returns the total amount of closed issues for this version.
125 def closed_issues_count
126 def closed_issues_count
126 @closed_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, true], :include => :status)
127 @closed_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, true], :include => :status)
127 end
128 end
128
129
129 def wiki_page
130 def wiki_page
130 if project.wiki && !wiki_page_title.blank?
131 if project.wiki && !wiki_page_title.blank?
131 @wiki_page ||= project.wiki.find_page(wiki_page_title)
132 @wiki_page ||= project.wiki.find_page(wiki_page_title)
132 end
133 end
133 @wiki_page
134 @wiki_page
134 end
135 end
135
136
136 def to_s; name end
137 def to_s; name end
137
138
138 def to_s_with_project
139 def to_s_with_project
139 "#{project} - #{name}"
140 "#{project} - #{name}"
140 end
141 end
141
142
142 # Versions are sorted by effective_date and "Project Name - Version name"
143 # Versions are sorted by effective_date and "Project Name - Version name"
143 # Those with no effective_date are at the end, sorted by "Project Name - Version name"
144 # Those with no effective_date are at the end, sorted by "Project Name - Version name"
144 def <=>(version)
145 def <=>(version)
145 if self.effective_date
146 if self.effective_date
146 if version.effective_date
147 if version.effective_date
147 if self.effective_date == version.effective_date
148 if self.effective_date == version.effective_date
148 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
149 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
149 else
150 else
150 self.effective_date <=> version.effective_date
151 self.effective_date <=> version.effective_date
151 end
152 end
152 else
153 else
153 -1
154 -1
154 end
155 end
155 else
156 else
156 if version.effective_date
157 if version.effective_date
157 1
158 1
158 else
159 else
159 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
160 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
160 end
161 end
161 end
162 end
162 end
163 end
163
164
164 # Returns the sharings that +user+ can set the version to
165 # Returns the sharings that +user+ can set the version to
165 def allowed_sharings(user = User.current)
166 def allowed_sharings(user = User.current)
166 VERSION_SHARINGS.select do |s|
167 VERSION_SHARINGS.select do |s|
167 if sharing == s
168 if sharing == s
168 true
169 true
169 else
170 else
170 case s
171 case s
171 when 'system'
172 when 'system'
172 # Only admin users can set a systemwide sharing
173 # Only admin users can set a systemwide sharing
173 user.admin?
174 user.admin?
174 when 'hierarchy', 'tree'
175 when 'hierarchy', 'tree'
175 # Only users allowed to manage versions of the root project can
176 # Only users allowed to manage versions of the root project can
176 # set sharing to hierarchy or tree
177 # set sharing to hierarchy or tree
177 project.nil? || user.allowed_to?(:manage_versions, project.root)
178 project.nil? || user.allowed_to?(:manage_versions, project.root)
178 else
179 else
179 true
180 true
180 end
181 end
181 end
182 end
182 end
183 end
183 end
184 end
184
185
185 private
186 private
186
187
187 # Update the issue's fixed versions. Used if a version's sharing changes.
188 # Update the issue's fixed versions. Used if a version's sharing changes.
188 def update_issues_from_sharing_change
189 def update_issues_from_sharing_change
189 if sharing_changed?
190 if sharing_changed?
190 if VERSION_SHARINGS.index(sharing_was).nil? ||
191 if VERSION_SHARINGS.index(sharing_was).nil? ||
191 VERSION_SHARINGS.index(sharing).nil? ||
192 VERSION_SHARINGS.index(sharing).nil? ||
192 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
193 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
193 Issue.update_versions_from_sharing_change self
194 Issue.update_versions_from_sharing_change self
194 end
195 end
195 end
196 end
196 end
197 end
197
198
198 # Returns the average estimated time of assigned issues
199 # Returns the average estimated time of assigned issues
199 # or 1 if no issue has an estimated time
200 # or 1 if no issue has an estimated time
200 # Used to weigth unestimated issues in progress calculation
201 # Used to weigth unestimated issues in progress calculation
201 def estimated_average
202 def estimated_average
202 if @estimated_average.nil?
203 if @estimated_average.nil?
203 average = fixed_issues.average(:estimated_hours).to_f
204 average = fixed_issues.average(:estimated_hours).to_f
204 if average == 0
205 if average == 0
205 average = 1
206 average = 1
206 end
207 end
207 @estimated_average = average
208 @estimated_average = average
208 end
209 end
209 @estimated_average
210 @estimated_average
210 end
211 end
211
212
212 # Returns the total progress of open or closed issues. The returned percentage takes into account
213 # Returns the total progress of open or closed issues. The returned percentage takes into account
213 # the amount of estimated time set for this version.
214 # the amount of estimated time set for this version.
214 #
215 #
215 # Examples:
216 # Examples:
216 # issues_progress(true) => returns the progress percentage for open issues.
217 # issues_progress(true) => returns the progress percentage for open issues.
217 # issues_progress(false) => returns the progress percentage for closed issues.
218 # issues_progress(false) => returns the progress percentage for closed issues.
218 def issues_progress(open)
219 def issues_progress(open)
219 @issues_progress ||= {}
220 @issues_progress ||= {}
220 @issues_progress[open] ||= begin
221 @issues_progress[open] ||= begin
221 progress = 0
222 progress = 0
222 if issues_count > 0
223 if issues_count > 0
223 ratio = open ? 'done_ratio' : 100
224 ratio = open ? 'done_ratio' : 100
224
225
225 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
226 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
226 :include => :status,
227 :include => :status,
227 :conditions => ["is_closed = ?", !open]).to_f
228 :conditions => ["is_closed = ?", !open]).to_f
228 progress = done / (estimated_average * issues_count)
229 progress = done / (estimated_average * issues_count)
229 end
230 end
230 progress
231 progress
231 end
232 end
232 end
233 end
233 end
234 end
@@ -1,43 +1,43
1 Return-Path: <jsmith@somenet.foo>
1 Return-Path: <jsmith@somenet.foo>
2 Received: from osiris ([127.0.0.1])
2 Received: from osiris ([127.0.0.1])
3 by OSIRIS
3 by OSIRIS
4 with hMailServer ; Sun, 22 Jun 2008 12:28:07 +0200
4 with hMailServer ; Sun, 22 Jun 2008 12:28:07 +0200
5 Message-ID: <000501c8d452$a95cd7e0$0a00a8c0@osiris>
5 Message-ID: <000501c8d452$a95cd7e0$0a00a8c0@osiris>
6 From: "John Smith" <jsmith@somenet.foo>
6 From: "John Smith" <jsmith@somenet.foo>
7 To: <redmine@somenet.foo>
7 To: <redmine@somenet.foo>
8 Subject: New ticket on a given project
8 Subject: New ticket on a given project
9 Date: Sun, 22 Jun 2008 12:28:07 +0200
9 Date: Sun, 22 Jun 2008 12:28:07 +0200
10 MIME-Version: 1.0
10 MIME-Version: 1.0
11 Content-Type: text/plain;
11 Content-Type: text/plain;
12 format=flowed;
12 format=flowed;
13 charset="iso-8859-1";
13 charset="iso-8859-1";
14 reply-type=original
14 reply-type=original
15 Content-Transfer-Encoding: 7bit
15 Content-Transfer-Encoding: 7bit
16 X-Priority: 3
16 X-Priority: 3
17 X-MSMail-Priority: Normal
17 X-MSMail-Priority: Normal
18 X-Mailer: Microsoft Outlook Express 6.00.2900.2869
18 X-Mailer: Microsoft Outlook Express 6.00.2900.2869
19 X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.2869
19 X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.2869
20
20
21 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas imperdiet
21 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas imperdiet
22 turpis et odio. Integer eget pede vel dolor euismod varius. Phasellus
22 turpis et odio. Integer eget pede vel dolor euismod varius. Phasellus
23 blandit eleifend augue. Nulla facilisi. Duis id diam. Class aptent taciti
23 blandit eleifend augue. Nulla facilisi. Duis id diam. Class aptent taciti
24 sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. In
24 sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. In
25 in urna sed tellus aliquet lobortis. Morbi scelerisque tortor in dolor. Cras
25 in urna sed tellus aliquet lobortis. Morbi scelerisque tortor in dolor. Cras
26 sagittis odio eu lacus. Aliquam sem tortor, consequat sit amet, vestibulum
26 sagittis odio eu lacus. Aliquam sem tortor, consequat sit amet, vestibulum
27 id, iaculis at, lectus. Fusce tortor libero, congue ut, euismod nec, luctus
27 id, iaculis at, lectus. Fusce tortor libero, congue ut, euismod nec, luctus
28 eget, eros. Pellentesque tortor enim, feugiat in, dignissim eget, tristique
28 eget, eros. Pellentesque tortor enim, feugiat in, dignissim eget, tristique
29 sed, mauris. Pellentesque habitant morbi tristique senectus et netus et
29 sed, mauris. Pellentesque habitant morbi tristique senectus et netus et
30 malesuada fames ac turpis egestas. Quisque sit amet libero. In hac habitasse
30 malesuada fames ac turpis egestas. Quisque sit amet libero. In hac habitasse
31 platea dictumst.
31 platea dictumst.
32
32
33 Nulla et nunc. Duis pede. Donec et ipsum. Nam ut dui tincidunt neque
33 Nulla et nunc. Duis pede. Donec et ipsum. Nam ut dui tincidunt neque
34 sollicitudin iaculis. Duis vitae dolor. Vestibulum eget massa. Sed lorem.
34 sollicitudin iaculis. Duis vitae dolor. Vestibulum eget massa. Sed lorem.
35 Nullam volutpat cursus erat. Cras felis dolor, lacinia quis, rutrum et,
35 Nullam volutpat cursus erat. Cras felis dolor, lacinia quis, rutrum et,
36 dictum et, ligula. Sed erat nibh, gravida in, accumsan non, placerat sed,
36 dictum et, ligula. Sed erat nibh, gravida in, accumsan non, placerat sed,
37 massa. Sed sodales, ante fermentum ultricies sollicitudin, massa leo
37 massa. Sed sodales, ante fermentum ultricies sollicitudin, massa leo
38 pulvinar dui, a gravida orci mi eget odio. Nunc a lacus.
38 pulvinar dui, a gravida orci mi eget odio. Nunc a lacus.
39
39
40 Project: onlinestore
40 Project: onlinestore
41 Tracker: Feature request
41 Tracker: Feature Request
42 category: Stock management
42 category: stock management
43 priority: Urgent
43 priority: URGENT
@@ -1,457 +1,457
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # Redmine - project management software
3 # Redmine - project management software
4 # Copyright (C) 2006-2011 Jean-Philippe Lang
4 # Copyright (C) 2006-2011 Jean-Philippe Lang
5 #
5 #
6 # This program is free software; you can redistribute it and/or
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
9 # of the License, or (at your option) any later version.
10 #
10 #
11 # This program is distributed in the hope that it will be useful,
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
14 # GNU General Public License for more details.
15 #
15 #
16 # You should have received a copy of the GNU General Public License
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19
19
20 require File.expand_path('../../test_helper', __FILE__)
20 require File.expand_path('../../test_helper', __FILE__)
21
21
22 class MailHandlerTest < ActiveSupport::TestCase
22 class MailHandlerTest < ActiveSupport::TestCase
23 fixtures :users, :projects,
23 fixtures :users, :projects,
24 :enabled_modules,
24 :enabled_modules,
25 :roles,
25 :roles,
26 :members,
26 :members,
27 :member_roles,
27 :member_roles,
28 :users,
28 :users,
29 :issues,
29 :issues,
30 :issue_statuses,
30 :issue_statuses,
31 :workflows,
31 :workflows,
32 :trackers,
32 :trackers,
33 :projects_trackers,
33 :projects_trackers,
34 :versions,
34 :versions,
35 :enumerations,
35 :enumerations,
36 :issue_categories,
36 :issue_categories,
37 :custom_fields,
37 :custom_fields,
38 :custom_fields_trackers,
38 :custom_fields_trackers,
39 :custom_fields_projects,
39 :custom_fields_projects,
40 :boards,
40 :boards,
41 :messages
41 :messages
42
42
43 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
43 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
44
44
45 def setup
45 def setup
46 ActionMailer::Base.deliveries.clear
46 ActionMailer::Base.deliveries.clear
47 Setting.notified_events = Redmine::Notifiable.all.collect(&:name)
47 Setting.notified_events = Redmine::Notifiable.all.collect(&:name)
48 end
48 end
49
49
50 def test_add_issue
50 def test_add_issue
51 ActionMailer::Base.deliveries.clear
51 ActionMailer::Base.deliveries.clear
52 # This email contains: 'Project: onlinestore'
52 # This email contains: 'Project: onlinestore'
53 issue = submit_email('ticket_on_given_project.eml')
53 issue = submit_email('ticket_on_given_project.eml')
54 assert issue.is_a?(Issue)
54 assert issue.is_a?(Issue)
55 assert !issue.new_record?
55 assert !issue.new_record?
56 issue.reload
56 issue.reload
57 assert_equal Project.find(2), issue.project
57 assert_equal Project.find(2), issue.project
58 assert_equal issue.project.trackers.first, issue.tracker
58 assert_equal issue.project.trackers.first, issue.tracker
59 assert_equal 'New ticket on a given project', issue.subject
59 assert_equal 'New ticket on a given project', issue.subject
60 assert_equal User.find_by_login('jsmith'), issue.author
60 assert_equal User.find_by_login('jsmith'), issue.author
61 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
61 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
62 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
62 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
63 assert_equal '2010-01-01', issue.start_date.to_s
63 assert_equal '2010-01-01', issue.start_date.to_s
64 assert_equal '2010-12-31', issue.due_date.to_s
64 assert_equal '2010-12-31', issue.due_date.to_s
65 assert_equal User.find_by_login('jsmith'), issue.assigned_to
65 assert_equal User.find_by_login('jsmith'), issue.assigned_to
66 assert_equal Version.find_by_name('alpha'), issue.fixed_version
66 assert_equal Version.find_by_name('Alpha'), issue.fixed_version
67 assert_equal 2.5, issue.estimated_hours
67 assert_equal 2.5, issue.estimated_hours
68 assert_equal 30, issue.done_ratio
68 assert_equal 30, issue.done_ratio
69 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
69 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
70 # keywords should be removed from the email body
70 # keywords should be removed from the email body
71 assert !issue.description.match(/^Project:/i)
71 assert !issue.description.match(/^Project:/i)
72 assert !issue.description.match(/^Status:/i)
72 assert !issue.description.match(/^Status:/i)
73 assert !issue.description.match(/^Start Date:/i)
73 assert !issue.description.match(/^Start Date:/i)
74 # Email notification should be sent
74 # Email notification should be sent
75 mail = ActionMailer::Base.deliveries.last
75 mail = ActionMailer::Base.deliveries.last
76 assert_not_nil mail
76 assert_not_nil mail
77 assert mail.subject.include?('New ticket on a given project')
77 assert mail.subject.include?('New ticket on a given project')
78 end
78 end
79
79
80 def test_add_issue_with_default_tracker
80 def test_add_issue_with_default_tracker
81 # This email contains: 'Project: onlinestore'
81 # This email contains: 'Project: onlinestore'
82 issue = submit_email('ticket_on_given_project.eml', :issue => {:tracker => 'Support request'})
82 issue = submit_email('ticket_on_given_project.eml', :issue => {:tracker => 'Support request'})
83 assert issue.is_a?(Issue)
83 assert issue.is_a?(Issue)
84 assert !issue.new_record?
84 assert !issue.new_record?
85 issue.reload
85 issue.reload
86 assert_equal 'Support request', issue.tracker.name
86 assert_equal 'Support request', issue.tracker.name
87 end
87 end
88
88
89 def test_add_issue_with_status
89 def test_add_issue_with_status
90 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
90 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
91 issue = submit_email('ticket_on_given_project.eml')
91 issue = submit_email('ticket_on_given_project.eml')
92 assert issue.is_a?(Issue)
92 assert issue.is_a?(Issue)
93 assert !issue.new_record?
93 assert !issue.new_record?
94 issue.reload
94 issue.reload
95 assert_equal Project.find(2), issue.project
95 assert_equal Project.find(2), issue.project
96 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
96 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
97 end
97 end
98
98
99 def test_add_issue_with_attributes_override
99 def test_add_issue_with_attributes_override
100 issue = submit_email('ticket_with_attributes.eml', :allow_override => 'tracker,category,priority')
100 issue = submit_email('ticket_with_attributes.eml', :allow_override => 'tracker,category,priority')
101 assert issue.is_a?(Issue)
101 assert issue.is_a?(Issue)
102 assert !issue.new_record?
102 assert !issue.new_record?
103 issue.reload
103 issue.reload
104 assert_equal 'New ticket on a given project', issue.subject
104 assert_equal 'New ticket on a given project', issue.subject
105 assert_equal User.find_by_login('jsmith'), issue.author
105 assert_equal User.find_by_login('jsmith'), issue.author
106 assert_equal Project.find(2), issue.project
106 assert_equal Project.find(2), issue.project
107 assert_equal 'Feature request', issue.tracker.to_s
107 assert_equal 'Feature request', issue.tracker.to_s
108 assert_equal 'Stock management', issue.category.to_s
108 assert_equal 'Stock management', issue.category.to_s
109 assert_equal 'Urgent', issue.priority.to_s
109 assert_equal 'Urgent', issue.priority.to_s
110 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
110 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
111 end
111 end
112
112
113 def test_add_issue_with_partial_attributes_override
113 def test_add_issue_with_partial_attributes_override
114 issue = submit_email('ticket_with_attributes.eml', :issue => {:priority => 'High'}, :allow_override => ['tracker'])
114 issue = submit_email('ticket_with_attributes.eml', :issue => {:priority => 'High'}, :allow_override => ['tracker'])
115 assert issue.is_a?(Issue)
115 assert issue.is_a?(Issue)
116 assert !issue.new_record?
116 assert !issue.new_record?
117 issue.reload
117 issue.reload
118 assert_equal 'New ticket on a given project', issue.subject
118 assert_equal 'New ticket on a given project', issue.subject
119 assert_equal User.find_by_login('jsmith'), issue.author
119 assert_equal User.find_by_login('jsmith'), issue.author
120 assert_equal Project.find(2), issue.project
120 assert_equal Project.find(2), issue.project
121 assert_equal 'Feature request', issue.tracker.to_s
121 assert_equal 'Feature request', issue.tracker.to_s
122 assert_nil issue.category
122 assert_nil issue.category
123 assert_equal 'High', issue.priority.to_s
123 assert_equal 'High', issue.priority.to_s
124 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
124 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
125 end
125 end
126
126
127 def test_add_issue_with_spaces_between_attribute_and_separator
127 def test_add_issue_with_spaces_between_attribute_and_separator
128 issue = submit_email('ticket_with_spaces_between_attribute_and_separator.eml', :allow_override => 'tracker,category,priority')
128 issue = submit_email('ticket_with_spaces_between_attribute_and_separator.eml', :allow_override => 'tracker,category,priority')
129 assert issue.is_a?(Issue)
129 assert issue.is_a?(Issue)
130 assert !issue.new_record?
130 assert !issue.new_record?
131 issue.reload
131 issue.reload
132 assert_equal 'New ticket on a given project', issue.subject
132 assert_equal 'New ticket on a given project', issue.subject
133 assert_equal User.find_by_login('jsmith'), issue.author
133 assert_equal User.find_by_login('jsmith'), issue.author
134 assert_equal Project.find(2), issue.project
134 assert_equal Project.find(2), issue.project
135 assert_equal 'Feature request', issue.tracker.to_s
135 assert_equal 'Feature request', issue.tracker.to_s
136 assert_equal 'Stock management', issue.category.to_s
136 assert_equal 'Stock management', issue.category.to_s
137 assert_equal 'Urgent', issue.priority.to_s
137 assert_equal 'Urgent', issue.priority.to_s
138 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
138 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
139 end
139 end
140
140
141 def test_add_issue_with_attachment_to_specific_project
141 def test_add_issue_with_attachment_to_specific_project
142 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
142 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
143 assert issue.is_a?(Issue)
143 assert issue.is_a?(Issue)
144 assert !issue.new_record?
144 assert !issue.new_record?
145 issue.reload
145 issue.reload
146 assert_equal 'Ticket created by email with attachment', issue.subject
146 assert_equal 'Ticket created by email with attachment', issue.subject
147 assert_equal User.find_by_login('jsmith'), issue.author
147 assert_equal User.find_by_login('jsmith'), issue.author
148 assert_equal Project.find(2), issue.project
148 assert_equal Project.find(2), issue.project
149 assert_equal 'This is a new ticket with attachments', issue.description
149 assert_equal 'This is a new ticket with attachments', issue.description
150 # Attachment properties
150 # Attachment properties
151 assert_equal 1, issue.attachments.size
151 assert_equal 1, issue.attachments.size
152 assert_equal 'Paella.jpg', issue.attachments.first.filename
152 assert_equal 'Paella.jpg', issue.attachments.first.filename
153 assert_equal 'image/jpeg', issue.attachments.first.content_type
153 assert_equal 'image/jpeg', issue.attachments.first.content_type
154 assert_equal 10790, issue.attachments.first.filesize
154 assert_equal 10790, issue.attachments.first.filesize
155 end
155 end
156
156
157 def test_add_issue_with_custom_fields
157 def test_add_issue_with_custom_fields
158 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
158 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
159 assert issue.is_a?(Issue)
159 assert issue.is_a?(Issue)
160 assert !issue.new_record?
160 assert !issue.new_record?
161 issue.reload
161 issue.reload
162 assert_equal 'New ticket with custom field values', issue.subject
162 assert_equal 'New ticket with custom field values', issue.subject
163 assert_equal 'Value for a custom field', issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
163 assert_equal 'Value for a custom field', issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
164 assert !issue.description.match(/^searchable field:/i)
164 assert !issue.description.match(/^searchable field:/i)
165 end
165 end
166
166
167 def test_add_issue_with_cc
167 def test_add_issue_with_cc
168 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
168 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
169 assert issue.is_a?(Issue)
169 assert issue.is_a?(Issue)
170 assert !issue.new_record?
170 assert !issue.new_record?
171 issue.reload
171 issue.reload
172 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
172 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
173 assert_equal 1, issue.watcher_user_ids.size
173 assert_equal 1, issue.watcher_user_ids.size
174 end
174 end
175
175
176 def test_add_issue_by_unknown_user
176 def test_add_issue_by_unknown_user
177 assert_no_difference 'User.count' do
177 assert_no_difference 'User.count' do
178 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'})
178 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'})
179 end
179 end
180 end
180 end
181
181
182 def test_add_issue_by_anonymous_user
182 def test_add_issue_by_anonymous_user
183 Role.anonymous.add_permission!(:add_issues)
183 Role.anonymous.add_permission!(:add_issues)
184 assert_no_difference 'User.count' do
184 assert_no_difference 'User.count' do
185 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
185 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
186 assert issue.is_a?(Issue)
186 assert issue.is_a?(Issue)
187 assert issue.author.anonymous?
187 assert issue.author.anonymous?
188 end
188 end
189 end
189 end
190
190
191 def test_add_issue_by_anonymous_user_with_no_from_address
191 def test_add_issue_by_anonymous_user_with_no_from_address
192 Role.anonymous.add_permission!(:add_issues)
192 Role.anonymous.add_permission!(:add_issues)
193 assert_no_difference 'User.count' do
193 assert_no_difference 'User.count' do
194 issue = submit_email('ticket_by_empty_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
194 issue = submit_email('ticket_by_empty_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
195 assert issue.is_a?(Issue)
195 assert issue.is_a?(Issue)
196 assert issue.author.anonymous?
196 assert issue.author.anonymous?
197 end
197 end
198 end
198 end
199
199
200 def test_add_issue_by_anonymous_user_on_private_project
200 def test_add_issue_by_anonymous_user_on_private_project
201 Role.anonymous.add_permission!(:add_issues)
201 Role.anonymous.add_permission!(:add_issues)
202 assert_no_difference 'User.count' do
202 assert_no_difference 'User.count' do
203 assert_no_difference 'Issue.count' do
203 assert_no_difference 'Issue.count' do
204 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :unknown_user => 'accept')
204 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :unknown_user => 'accept')
205 end
205 end
206 end
206 end
207 end
207 end
208
208
209 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
209 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
210 assert_no_difference 'User.count' do
210 assert_no_difference 'User.count' do
211 assert_difference 'Issue.count' do
211 assert_difference 'Issue.count' do
212 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :no_permission_check => '1', :unknown_user => 'accept')
212 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :no_permission_check => '1', :unknown_user => 'accept')
213 assert issue.is_a?(Issue)
213 assert issue.is_a?(Issue)
214 assert issue.author.anonymous?
214 assert issue.author.anonymous?
215 assert !issue.project.is_public?
215 assert !issue.project.is_public?
216 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
216 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
217 end
217 end
218 end
218 end
219 end
219 end
220
220
221 def test_add_issue_by_created_user
221 def test_add_issue_by_created_user
222 Setting.default_language = 'en'
222 Setting.default_language = 'en'
223 assert_difference 'User.count' do
223 assert_difference 'User.count' do
224 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
224 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
225 assert issue.is_a?(Issue)
225 assert issue.is_a?(Issue)
226 assert issue.author.active?
226 assert issue.author.active?
227 assert_equal 'john.doe@somenet.foo', issue.author.mail
227 assert_equal 'john.doe@somenet.foo', issue.author.mail
228 assert_equal 'John', issue.author.firstname
228 assert_equal 'John', issue.author.firstname
229 assert_equal 'Doe', issue.author.lastname
229 assert_equal 'Doe', issue.author.lastname
230
230
231 # account information
231 # account information
232 email = ActionMailer::Base.deliveries.first
232 email = ActionMailer::Base.deliveries.first
233 assert_not_nil email
233 assert_not_nil email
234 assert email.subject.include?('account activation')
234 assert email.subject.include?('account activation')
235 login = email.body.match(/\* Login: (.*)$/)[1]
235 login = email.body.match(/\* Login: (.*)$/)[1]
236 password = email.body.match(/\* Password: (.*)$/)[1]
236 password = email.body.match(/\* Password: (.*)$/)[1]
237 assert_equal issue.author, User.try_to_login(login, password)
237 assert_equal issue.author, User.try_to_login(login, password)
238 end
238 end
239 end
239 end
240
240
241 def test_add_issue_without_from_header
241 def test_add_issue_without_from_header
242 Role.anonymous.add_permission!(:add_issues)
242 Role.anonymous.add_permission!(:add_issues)
243 assert_equal false, submit_email('ticket_without_from_header.eml')
243 assert_equal false, submit_email('ticket_without_from_header.eml')
244 end
244 end
245
245
246 def test_add_issue_with_invalid_attributes
246 def test_add_issue_with_invalid_attributes
247 issue = submit_email('ticket_with_invalid_attributes.eml', :allow_override => 'tracker,category,priority')
247 issue = submit_email('ticket_with_invalid_attributes.eml', :allow_override => 'tracker,category,priority')
248 assert issue.is_a?(Issue)
248 assert issue.is_a?(Issue)
249 assert !issue.new_record?
249 assert !issue.new_record?
250 issue.reload
250 issue.reload
251 assert_nil issue.assigned_to
251 assert_nil issue.assigned_to
252 assert_nil issue.start_date
252 assert_nil issue.start_date
253 assert_nil issue.due_date
253 assert_nil issue.due_date
254 assert_equal 0, issue.done_ratio
254 assert_equal 0, issue.done_ratio
255 assert_equal 'Normal', issue.priority.to_s
255 assert_equal 'Normal', issue.priority.to_s
256 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
256 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
257 end
257 end
258
258
259 def test_add_issue_with_localized_attributes
259 def test_add_issue_with_localized_attributes
260 User.find_by_mail('jsmith@somenet.foo').update_attribute 'language', 'fr'
260 User.find_by_mail('jsmith@somenet.foo').update_attribute 'language', 'fr'
261 issue = submit_email('ticket_with_localized_attributes.eml', :allow_override => 'tracker,category,priority')
261 issue = submit_email('ticket_with_localized_attributes.eml', :allow_override => 'tracker,category,priority')
262 assert issue.is_a?(Issue)
262 assert issue.is_a?(Issue)
263 assert !issue.new_record?
263 assert !issue.new_record?
264 issue.reload
264 issue.reload
265 assert_equal 'New ticket on a given project', issue.subject
265 assert_equal 'New ticket on a given project', issue.subject
266 assert_equal User.find_by_login('jsmith'), issue.author
266 assert_equal User.find_by_login('jsmith'), issue.author
267 assert_equal Project.find(2), issue.project
267 assert_equal Project.find(2), issue.project
268 assert_equal 'Feature request', issue.tracker.to_s
268 assert_equal 'Feature request', issue.tracker.to_s
269 assert_equal 'Stock management', issue.category.to_s
269 assert_equal 'Stock management', issue.category.to_s
270 assert_equal 'Urgent', issue.priority.to_s
270 assert_equal 'Urgent', issue.priority.to_s
271 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
271 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
272 end
272 end
273
273
274 def test_add_issue_with_japanese_keywords
274 def test_add_issue_with_japanese_keywords
275 tracker = Tracker.create!(:name => 'ι–‹η™Ί')
275 tracker = Tracker.create!(:name => 'ι–‹η™Ί')
276 Project.find(1).trackers << tracker
276 Project.find(1).trackers << tracker
277 issue = submit_email('japanese_keywords_iso_2022_jp.eml', :issue => {:project => 'ecookbook'}, :allow_override => 'tracker')
277 issue = submit_email('japanese_keywords_iso_2022_jp.eml', :issue => {:project => 'ecookbook'}, :allow_override => 'tracker')
278 assert_kind_of Issue, issue
278 assert_kind_of Issue, issue
279 assert_equal tracker, issue.tracker
279 assert_equal tracker, issue.tracker
280 end
280 end
281
281
282 def test_should_ignore_emails_from_emission_address
282 def test_should_ignore_emails_from_emission_address
283 Role.anonymous.add_permission!(:add_issues)
283 Role.anonymous.add_permission!(:add_issues)
284 assert_no_difference 'User.count' do
284 assert_no_difference 'User.count' do
285 assert_equal false, submit_email('ticket_from_emission_address.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
285 assert_equal false, submit_email('ticket_from_emission_address.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
286 end
286 end
287 end
287 end
288
288
289 def test_add_issue_should_send_email_notification
289 def test_add_issue_should_send_email_notification
290 Setting.notified_events = ['issue_added']
290 Setting.notified_events = ['issue_added']
291 ActionMailer::Base.deliveries.clear
291 ActionMailer::Base.deliveries.clear
292 # This email contains: 'Project: onlinestore'
292 # This email contains: 'Project: onlinestore'
293 issue = submit_email('ticket_on_given_project.eml')
293 issue = submit_email('ticket_on_given_project.eml')
294 assert issue.is_a?(Issue)
294 assert issue.is_a?(Issue)
295 assert_equal 1, ActionMailer::Base.deliveries.size
295 assert_equal 1, ActionMailer::Base.deliveries.size
296 end
296 end
297
297
298 def test_add_issue_note
298 def test_add_issue_note
299 journal = submit_email('ticket_reply.eml')
299 journal = submit_email('ticket_reply.eml')
300 assert journal.is_a?(Journal)
300 assert journal.is_a?(Journal)
301 assert_equal User.find_by_login('jsmith'), journal.user
301 assert_equal User.find_by_login('jsmith'), journal.user
302 assert_equal Issue.find(2), journal.journalized
302 assert_equal Issue.find(2), journal.journalized
303 assert_match /This is reply/, journal.notes
303 assert_match /This is reply/, journal.notes
304 assert_equal 'Feature request', journal.issue.tracker.name
304 assert_equal 'Feature request', journal.issue.tracker.name
305 end
305 end
306
306
307 def test_add_issue_note_with_attribute_changes
307 def test_add_issue_note_with_attribute_changes
308 # This email contains: 'Status: Resolved'
308 # This email contains: 'Status: Resolved'
309 journal = submit_email('ticket_reply_with_status.eml')
309 journal = submit_email('ticket_reply_with_status.eml')
310 assert journal.is_a?(Journal)
310 assert journal.is_a?(Journal)
311 issue = Issue.find(journal.issue.id)
311 issue = Issue.find(journal.issue.id)
312 assert_equal User.find_by_login('jsmith'), journal.user
312 assert_equal User.find_by_login('jsmith'), journal.user
313 assert_equal Issue.find(2), journal.journalized
313 assert_equal Issue.find(2), journal.journalized
314 assert_match /This is reply/, journal.notes
314 assert_match /This is reply/, journal.notes
315 assert_equal 'Feature request', journal.issue.tracker.name
315 assert_equal 'Feature request', journal.issue.tracker.name
316 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
316 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
317 assert_equal '2010-01-01', issue.start_date.to_s
317 assert_equal '2010-01-01', issue.start_date.to_s
318 assert_equal '2010-12-31', issue.due_date.to_s
318 assert_equal '2010-12-31', issue.due_date.to_s
319 assert_equal User.find_by_login('jsmith'), issue.assigned_to
319 assert_equal User.find_by_login('jsmith'), issue.assigned_to
320 assert_equal "52.6", issue.custom_value_for(CustomField.find_by_name('Float field')).value
320 assert_equal "52.6", issue.custom_value_for(CustomField.find_by_name('Float field')).value
321 # keywords should be removed from the email body
321 # keywords should be removed from the email body
322 assert !journal.notes.match(/^Status:/i)
322 assert !journal.notes.match(/^Status:/i)
323 assert !journal.notes.match(/^Start Date:/i)
323 assert !journal.notes.match(/^Start Date:/i)
324 end
324 end
325
325
326 def test_add_issue_note_should_send_email_notification
326 def test_add_issue_note_should_send_email_notification
327 ActionMailer::Base.deliveries.clear
327 ActionMailer::Base.deliveries.clear
328 journal = submit_email('ticket_reply.eml')
328 journal = submit_email('ticket_reply.eml')
329 assert journal.is_a?(Journal)
329 assert journal.is_a?(Journal)
330 assert_equal 1, ActionMailer::Base.deliveries.size
330 assert_equal 1, ActionMailer::Base.deliveries.size
331 end
331 end
332
332
333 def test_add_issue_note_should_not_set_defaults
333 def test_add_issue_note_should_not_set_defaults
334 journal = submit_email('ticket_reply.eml', :issue => {:tracker => 'Support request', :priority => 'High'})
334 journal = submit_email('ticket_reply.eml', :issue => {:tracker => 'Support request', :priority => 'High'})
335 assert journal.is_a?(Journal)
335 assert journal.is_a?(Journal)
336 assert_match /This is reply/, journal.notes
336 assert_match /This is reply/, journal.notes
337 assert_equal 'Feature request', journal.issue.tracker.name
337 assert_equal 'Feature request', journal.issue.tracker.name
338 assert_equal 'Normal', journal.issue.priority.name
338 assert_equal 'Normal', journal.issue.priority.name
339 end
339 end
340
340
341 def test_reply_to_a_message
341 def test_reply_to_a_message
342 m = submit_email('message_reply.eml')
342 m = submit_email('message_reply.eml')
343 assert m.is_a?(Message)
343 assert m.is_a?(Message)
344 assert !m.new_record?
344 assert !m.new_record?
345 m.reload
345 m.reload
346 assert_equal 'Reply via email', m.subject
346 assert_equal 'Reply via email', m.subject
347 # The email replies to message #2 which is part of the thread of message #1
347 # The email replies to message #2 which is part of the thread of message #1
348 assert_equal Message.find(1), m.parent
348 assert_equal Message.find(1), m.parent
349 end
349 end
350
350
351 def test_reply_to_a_message_by_subject
351 def test_reply_to_a_message_by_subject
352 m = submit_email('message_reply_by_subject.eml')
352 m = submit_email('message_reply_by_subject.eml')
353 assert m.is_a?(Message)
353 assert m.is_a?(Message)
354 assert !m.new_record?
354 assert !m.new_record?
355 m.reload
355 m.reload
356 assert_equal 'Reply to the first post', m.subject
356 assert_equal 'Reply to the first post', m.subject
357 assert_equal Message.find(1), m.parent
357 assert_equal Message.find(1), m.parent
358 end
358 end
359
359
360 def test_should_strip_tags_of_html_only_emails
360 def test_should_strip_tags_of_html_only_emails
361 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
361 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
362 assert issue.is_a?(Issue)
362 assert issue.is_a?(Issue)
363 assert !issue.new_record?
363 assert !issue.new_record?
364 issue.reload
364 issue.reload
365 assert_equal 'HTML email', issue.subject
365 assert_equal 'HTML email', issue.subject
366 assert_equal 'This is a html-only email.', issue.description
366 assert_equal 'This is a html-only email.', issue.description
367 end
367 end
368
368
369 context "truncate emails based on the Setting" do
369 context "truncate emails based on the Setting" do
370 context "with no setting" do
370 context "with no setting" do
371 setup do
371 setup do
372 Setting.mail_handler_body_delimiters = ''
372 Setting.mail_handler_body_delimiters = ''
373 end
373 end
374
374
375 should "add the entire email into the issue" do
375 should "add the entire email into the issue" do
376 issue = submit_email('ticket_on_given_project.eml')
376 issue = submit_email('ticket_on_given_project.eml')
377 assert_issue_created(issue)
377 assert_issue_created(issue)
378 assert issue.description.include?('---')
378 assert issue.description.include?('---')
379 assert issue.description.include?('This paragraph is after the delimiter')
379 assert issue.description.include?('This paragraph is after the delimiter')
380 end
380 end
381 end
381 end
382
382
383 context "with a single string" do
383 context "with a single string" do
384 setup do
384 setup do
385 Setting.mail_handler_body_delimiters = '---'
385 Setting.mail_handler_body_delimiters = '---'
386 end
386 end
387 should "truncate the email at the delimiter for the issue" do
387 should "truncate the email at the delimiter for the issue" do
388 issue = submit_email('ticket_on_given_project.eml')
388 issue = submit_email('ticket_on_given_project.eml')
389 assert_issue_created(issue)
389 assert_issue_created(issue)
390 assert issue.description.include?('This paragraph is before delimiters')
390 assert issue.description.include?('This paragraph is before delimiters')
391 assert issue.description.include?('--- This line starts with a delimiter')
391 assert issue.description.include?('--- This line starts with a delimiter')
392 assert !issue.description.match(/^---$/)
392 assert !issue.description.match(/^---$/)
393 assert !issue.description.include?('This paragraph is after the delimiter')
393 assert !issue.description.include?('This paragraph is after the delimiter')
394 end
394 end
395 end
395 end
396
396
397 context "with a single quoted reply (e.g. reply to a Redmine email notification)" do
397 context "with a single quoted reply (e.g. reply to a Redmine email notification)" do
398 setup do
398 setup do
399 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
399 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
400 end
400 end
401 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
401 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
402 journal = submit_email('issue_update_with_quoted_reply_above.eml')
402 journal = submit_email('issue_update_with_quoted_reply_above.eml')
403 assert journal.is_a?(Journal)
403 assert journal.is_a?(Journal)
404 assert journal.notes.include?('An update to the issue by the sender.')
404 assert journal.notes.include?('An update to the issue by the sender.')
405 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
405 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
406 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
406 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
407 end
407 end
408 end
408 end
409
409
410 context "with multiple quoted replies (e.g. reply to a reply of a Redmine email notification)" do
410 context "with multiple quoted replies (e.g. reply to a reply of a Redmine email notification)" do
411 setup do
411 setup do
412 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
412 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
413 end
413 end
414 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
414 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
415 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
415 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
416 assert journal.is_a?(Journal)
416 assert journal.is_a?(Journal)
417 assert journal.notes.include?('An update to the issue by the sender.')
417 assert journal.notes.include?('An update to the issue by the sender.')
418 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
418 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
419 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
419 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
420 end
420 end
421 end
421 end
422
422
423 context "with multiple strings" do
423 context "with multiple strings" do
424 setup do
424 setup do
425 Setting.mail_handler_body_delimiters = "---\nBREAK"
425 Setting.mail_handler_body_delimiters = "---\nBREAK"
426 end
426 end
427 should "truncate the email at the first delimiter found (BREAK)" do
427 should "truncate the email at the first delimiter found (BREAK)" do
428 issue = submit_email('ticket_on_given_project.eml')
428 issue = submit_email('ticket_on_given_project.eml')
429 assert_issue_created(issue)
429 assert_issue_created(issue)
430 assert issue.description.include?('This paragraph is before delimiters')
430 assert issue.description.include?('This paragraph is before delimiters')
431 assert !issue.description.include?('BREAK')
431 assert !issue.description.include?('BREAK')
432 assert !issue.description.include?('This paragraph is between delimiters')
432 assert !issue.description.include?('This paragraph is between delimiters')
433 assert !issue.description.match(/^---$/)
433 assert !issue.description.match(/^---$/)
434 assert !issue.description.include?('This paragraph is after the delimiter')
434 assert !issue.description.include?('This paragraph is after the delimiter')
435 end
435 end
436 end
436 end
437 end
437 end
438
438
439 def test_email_with_long_subject_line
439 def test_email_with_long_subject_line
440 issue = submit_email('ticket_with_long_subject.eml')
440 issue = submit_email('ticket_with_long_subject.eml')
441 assert issue.is_a?(Issue)
441 assert issue.is_a?(Issue)
442 assert_equal issue.subject, 'New ticket on a given project with a very long subject line which exceeds 255 chars and should not be ignored but chopped off. And if the subject line is still not long enough, we just add more text. And more text. Wow, this is really annoying. Especially, if you have nothing to say...'[0,255]
442 assert_equal issue.subject, 'New ticket on a given project with a very long subject line which exceeds 255 chars and should not be ignored but chopped off. And if the subject line is still not long enough, we just add more text. And more text. Wow, this is really annoying. Especially, if you have nothing to say...'[0,255]
443 end
443 end
444
444
445 private
445 private
446
446
447 def submit_email(filename, options={})
447 def submit_email(filename, options={})
448 raw = IO.read(File.join(FIXTURES_PATH, filename))
448 raw = IO.read(File.join(FIXTURES_PATH, filename))
449 MailHandler.receive(raw, options)
449 MailHandler.receive(raw, options)
450 end
450 end
451
451
452 def assert_issue_created(issue)
452 def assert_issue_created(issue)
453 assert issue.is_a?(Issue)
453 assert issue.is_a?(Issue)
454 assert !issue.new_record?
454 assert !issue.new_record?
455 issue.reload
455 issue.reload
456 end
456 end
457 end
457 end
General Comments 0
You need to be logged in to leave comments. Login now