##// END OF EJS Templates
Makes MailHandler accept all issue attributes and custom fields that can be set/updated (#4071, #4807, #5622, #6110)....
Jean-Philippe Lang -
r4280:e0e8c14c2aef
parent child
Show More
@@ -1,884 +1,887
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 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 Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :tracker
20 belongs_to :tracker
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27
27
28 has_many :journals, :as => :journalized, :dependent => :destroy
28 has_many :journals, :as => :journalized, :dependent => :destroy
29 has_many :time_entries, :dependent => :delete_all
29 has_many :time_entries, :dependent => :delete_all
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31
31
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34
34
35 acts_as_nested_set :scope => 'root_id'
35 acts_as_nested_set :scope => 'root_id'
36 acts_as_attachable :after_remove => :attachment_removed
36 acts_as_attachable :after_remove => :attachment_removed
37 acts_as_customizable
37 acts_as_customizable
38 acts_as_watchable
38 acts_as_watchable
39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
40 :include => [:project, :journals],
40 :include => [:project, :journals],
41 # sort by id so that limited eager loading doesn't break with postgresql
41 # sort by id so that limited eager loading doesn't break with postgresql
42 :order_column => "#{table_name}.id"
42 :order_column => "#{table_name}.id"
43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
46
46
47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
48 :author_key => :author_id
48 :author_key => :author_id
49
49
50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
51
51
52 attr_reader :current_journal
52 attr_reader :current_journal
53
53
54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
55
55
56 validates_length_of :subject, :maximum => 255
56 validates_length_of :subject, :maximum => 255
57 validates_inclusion_of :done_ratio, :in => 0..100
57 validates_inclusion_of :done_ratio, :in => 0..100
58 validates_numericality_of :estimated_hours, :allow_nil => true
58 validates_numericality_of :estimated_hours, :allow_nil => true
59
59
60 named_scope :visible, lambda {|*args| { :include => :project,
60 named_scope :visible, lambda {|*args| { :include => :project,
61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
62
62
63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
64
64
65 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
65 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
67 named_scope :on_active_project, :include => [:status, :project, :tracker],
67 named_scope :on_active_project, :include => [:status, :project, :tracker],
68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
69 named_scope :for_gantt, lambda {
69 named_scope :for_gantt, lambda {
70 {
70 {
71 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
71 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
72 :order => "#{Issue.table_name}.due_date ASC, #{Issue.table_name}.start_date ASC, #{Issue.table_name}.id ASC"
72 :order => "#{Issue.table_name}.due_date ASC, #{Issue.table_name}.start_date ASC, #{Issue.table_name}.id ASC"
73 }
73 }
74 }
74 }
75
75
76 named_scope :without_version, lambda {
76 named_scope :without_version, lambda {
77 {
77 {
78 :conditions => { :fixed_version_id => nil}
78 :conditions => { :fixed_version_id => nil}
79 }
79 }
80 }
80 }
81
81
82 named_scope :with_query, lambda {|query|
82 named_scope :with_query, lambda {|query|
83 {
83 {
84 :conditions => Query.merge_conditions(query.statement)
84 :conditions => Query.merge_conditions(query.statement)
85 }
85 }
86 }
86 }
87
87
88 before_create :default_assign
88 before_create :default_assign
89 before_save :close_duplicates, :update_done_ratio_from_issue_status
89 before_save :close_duplicates, :update_done_ratio_from_issue_status
90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
91 after_destroy :destroy_children
91 after_destroy :destroy_children
92 after_destroy :update_parent_attributes
92 after_destroy :update_parent_attributes
93
93
94 # Returns true if usr or current user is allowed to view the issue
94 # Returns true if usr or current user is allowed to view the issue
95 def visible?(usr=nil)
95 def visible?(usr=nil)
96 (usr || User.current).allowed_to?(:view_issues, self.project)
96 (usr || User.current).allowed_to?(:view_issues, self.project)
97 end
97 end
98
98
99 def after_initialize
99 def after_initialize
100 if new_record?
100 if new_record?
101 # set default values for new records only
101 # set default values for new records only
102 self.status ||= IssueStatus.default
102 self.status ||= IssueStatus.default
103 self.priority ||= IssuePriority.default
103 self.priority ||= IssuePriority.default
104 end
104 end
105 end
105 end
106
106
107 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
107 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
108 def available_custom_fields
108 def available_custom_fields
109 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
109 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
110 end
110 end
111
111
112 def copy_from(arg)
112 def copy_from(arg)
113 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
113 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
114 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
114 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
115 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
115 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
116 self.status = issue.status
116 self.status = issue.status
117 self
117 self
118 end
118 end
119
119
120 # Moves/copies an issue to a new project and tracker
120 # Moves/copies an issue to a new project and tracker
121 # Returns the moved/copied issue on success, false on failure
121 # Returns the moved/copied issue on success, false on failure
122 def move_to_project(*args)
122 def move_to_project(*args)
123 ret = Issue.transaction do
123 ret = Issue.transaction do
124 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
124 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
125 end || false
125 end || false
126 end
126 end
127
127
128 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
128 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
129 options ||= {}
129 options ||= {}
130 issue = options[:copy] ? self.class.new.copy_from(self) : self
130 issue = options[:copy] ? self.class.new.copy_from(self) : self
131
131
132 if new_project && issue.project_id != new_project.id
132 if new_project && issue.project_id != new_project.id
133 # delete issue relations
133 # delete issue relations
134 unless Setting.cross_project_issue_relations?
134 unless Setting.cross_project_issue_relations?
135 issue.relations_from.clear
135 issue.relations_from.clear
136 issue.relations_to.clear
136 issue.relations_to.clear
137 end
137 end
138 # issue is moved to another project
138 # issue is moved to another project
139 # reassign to the category with same name if any
139 # reassign to the category with same name if any
140 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
140 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
141 issue.category = new_category
141 issue.category = new_category
142 # Keep the fixed_version if it's still valid in the new_project
142 # Keep the fixed_version if it's still valid in the new_project
143 unless new_project.shared_versions.include?(issue.fixed_version)
143 unless new_project.shared_versions.include?(issue.fixed_version)
144 issue.fixed_version = nil
144 issue.fixed_version = nil
145 end
145 end
146 issue.project = new_project
146 issue.project = new_project
147 if issue.parent && issue.parent.project_id != issue.project_id
147 if issue.parent && issue.parent.project_id != issue.project_id
148 issue.parent_issue_id = nil
148 issue.parent_issue_id = nil
149 end
149 end
150 end
150 end
151 if new_tracker
151 if new_tracker
152 issue.tracker = new_tracker
152 issue.tracker = new_tracker
153 issue.reset_custom_values!
153 issue.reset_custom_values!
154 end
154 end
155 if options[:copy]
155 if options[:copy]
156 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
156 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
157 issue.status = if options[:attributes] && options[:attributes][:status_id]
157 issue.status = if options[:attributes] && options[:attributes][:status_id]
158 IssueStatus.find_by_id(options[:attributes][:status_id])
158 IssueStatus.find_by_id(options[:attributes][:status_id])
159 else
159 else
160 self.status
160 self.status
161 end
161 end
162 end
162 end
163 # Allow bulk setting of attributes on the issue
163 # Allow bulk setting of attributes on the issue
164 if options[:attributes]
164 if options[:attributes]
165 issue.attributes = options[:attributes]
165 issue.attributes = options[:attributes]
166 end
166 end
167 if issue.save
167 if issue.save
168 unless options[:copy]
168 unless options[:copy]
169 # Manually update project_id on related time entries
169 # Manually update project_id on related time entries
170 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
170 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
171
171
172 issue.children.each do |child|
172 issue.children.each do |child|
173 unless child.move_to_project_without_transaction(new_project)
173 unless child.move_to_project_without_transaction(new_project)
174 # Move failed and transaction was rollback'd
174 # Move failed and transaction was rollback'd
175 return false
175 return false
176 end
176 end
177 end
177 end
178 end
178 end
179 else
179 else
180 return false
180 return false
181 end
181 end
182 issue
182 issue
183 end
183 end
184
184
185 def status_id=(sid)
185 def status_id=(sid)
186 self.status = nil
186 self.status = nil
187 write_attribute(:status_id, sid)
187 write_attribute(:status_id, sid)
188 end
188 end
189
189
190 def priority_id=(pid)
190 def priority_id=(pid)
191 self.priority = nil
191 self.priority = nil
192 write_attribute(:priority_id, pid)
192 write_attribute(:priority_id, pid)
193 end
193 end
194
194
195 def tracker_id=(tid)
195 def tracker_id=(tid)
196 self.tracker = nil
196 self.tracker = nil
197 result = write_attribute(:tracker_id, tid)
197 result = write_attribute(:tracker_id, tid)
198 @custom_field_values = nil
198 @custom_field_values = nil
199 result
199 result
200 end
200 end
201
201
202 # Overrides attributes= so that tracker_id gets assigned first
202 # Overrides attributes= so that tracker_id gets assigned first
203 def attributes_with_tracker_first=(new_attributes, *args)
203 def attributes_with_tracker_first=(new_attributes, *args)
204 return if new_attributes.nil?
204 return if new_attributes.nil?
205 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
205 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
206 if new_tracker_id
206 if new_tracker_id
207 self.tracker_id = new_tracker_id
207 self.tracker_id = new_tracker_id
208 end
208 end
209 send :attributes_without_tracker_first=, new_attributes, *args
209 send :attributes_without_tracker_first=, new_attributes, *args
210 end
210 end
211 # Do not redefine alias chain on reload (see #4838)
211 # Do not redefine alias chain on reload (see #4838)
212 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
212 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
213
213
214 def estimated_hours=(h)
214 def estimated_hours=(h)
215 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
215 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
216 end
216 end
217
217
218 SAFE_ATTRIBUTES = %w(
218 SAFE_ATTRIBUTES = %w(
219 tracker_id
219 tracker_id
220 status_id
220 status_id
221 parent_issue_id
221 parent_issue_id
222 category_id
222 category_id
223 assigned_to_id
223 assigned_to_id
224 priority_id
224 priority_id
225 fixed_version_id
225 fixed_version_id
226 subject
226 subject
227 description
227 description
228 start_date
228 start_date
229 due_date
229 due_date
230 done_ratio
230 done_ratio
231 estimated_hours
231 estimated_hours
232 custom_field_values
232 custom_field_values
233 lock_version
233 lock_version
234 ) unless const_defined?(:SAFE_ATTRIBUTES)
234 ) unless const_defined?(:SAFE_ATTRIBUTES)
235
235
236 SAFE_ATTRIBUTES_ON_TRANSITION = %w(
236 SAFE_ATTRIBUTES_ON_TRANSITION = %w(
237 status_id
237 status_id
238 assigned_to_id
238 assigned_to_id
239 fixed_version_id
239 fixed_version_id
240 done_ratio
240 done_ratio
241 ) unless const_defined?(:SAFE_ATTRIBUTES_ON_TRANSITION)
241 ) unless const_defined?(:SAFE_ATTRIBUTES_ON_TRANSITION)
242
242
243 # Safely sets attributes
243 # Safely sets attributes
244 # Should be called from controllers instead of #attributes=
244 # Should be called from controllers instead of #attributes=
245 # attr_accessible is too rough because we still want things like
245 # attr_accessible is too rough because we still want things like
246 # Issue.new(:project => foo) to work
246 # Issue.new(:project => foo) to work
247 # TODO: move workflow/permission checks from controllers to here
247 # TODO: move workflow/permission checks from controllers to here
248 def safe_attributes=(attrs, user=User.current)
248 def safe_attributes=(attrs, user=User.current)
249 return unless attrs.is_a?(Hash)
249 return unless attrs.is_a?(Hash)
250
250
251 new_statuses_allowed = new_statuses_allowed_to(user)
252
253 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
251 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
254 if new_record? || user.allowed_to?(:edit_issues, project)
252 if new_record? || user.allowed_to?(:edit_issues, project)
255 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
253 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
256 elsif new_statuses_allowed.any?
254 elsif new_statuses_allowed_to(user).any?
257 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES_ON_TRANSITION.include?(k)}
255 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES_ON_TRANSITION.include?(k)}
258 else
256 else
259 return
257 return
260 end
258 end
261
259
260 # Tracker must be set before since new_statuses_allowed_to depends on it.
261 if t = attrs.delete('tracker_id')
262 self.tracker_id = t
263 end
264
262 if attrs['status_id']
265 if attrs['status_id']
263 unless new_statuses_allowed.collect(&:id).include?(attrs['status_id'].to_i)
266 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
264 attrs.delete('status_id')
267 attrs.delete('status_id')
265 end
268 end
266 end
269 end
267
270
268 unless leaf?
271 unless leaf?
269 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
272 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
270 end
273 end
271
274
272 if attrs.has_key?('parent_issue_id')
275 if attrs.has_key?('parent_issue_id')
273 if !user.allowed_to?(:manage_subtasks, project)
276 if !user.allowed_to?(:manage_subtasks, project)
274 attrs.delete('parent_issue_id')
277 attrs.delete('parent_issue_id')
275 elsif !attrs['parent_issue_id'].blank?
278 elsif !attrs['parent_issue_id'].blank?
276 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'])
279 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'])
277 end
280 end
278 end
281 end
279
282
280 self.attributes = attrs
283 self.attributes = attrs
281 end
284 end
282
285
283 def done_ratio
286 def done_ratio
284 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
287 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
285 status.default_done_ratio
288 status.default_done_ratio
286 else
289 else
287 read_attribute(:done_ratio)
290 read_attribute(:done_ratio)
288 end
291 end
289 end
292 end
290
293
291 def self.use_status_for_done_ratio?
294 def self.use_status_for_done_ratio?
292 Setting.issue_done_ratio == 'issue_status'
295 Setting.issue_done_ratio == 'issue_status'
293 end
296 end
294
297
295 def self.use_field_for_done_ratio?
298 def self.use_field_for_done_ratio?
296 Setting.issue_done_ratio == 'issue_field'
299 Setting.issue_done_ratio == 'issue_field'
297 end
300 end
298
301
299 def validate
302 def validate
300 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
303 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
301 errors.add :due_date, :not_a_date
304 errors.add :due_date, :not_a_date
302 end
305 end
303
306
304 if self.due_date and self.start_date and self.due_date < self.start_date
307 if self.due_date and self.start_date and self.due_date < self.start_date
305 errors.add :due_date, :greater_than_start_date
308 errors.add :due_date, :greater_than_start_date
306 end
309 end
307
310
308 if start_date && soonest_start && start_date < soonest_start
311 if start_date && soonest_start && start_date < soonest_start
309 errors.add :start_date, :invalid
312 errors.add :start_date, :invalid
310 end
313 end
311
314
312 if fixed_version
315 if fixed_version
313 if !assignable_versions.include?(fixed_version)
316 if !assignable_versions.include?(fixed_version)
314 errors.add :fixed_version_id, :inclusion
317 errors.add :fixed_version_id, :inclusion
315 elsif reopened? && fixed_version.closed?
318 elsif reopened? && fixed_version.closed?
316 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
319 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
317 end
320 end
318 end
321 end
319
322
320 # Checks that the issue can not be added/moved to a disabled tracker
323 # Checks that the issue can not be added/moved to a disabled tracker
321 if project && (tracker_id_changed? || project_id_changed?)
324 if project && (tracker_id_changed? || project_id_changed?)
322 unless project.trackers.include?(tracker)
325 unless project.trackers.include?(tracker)
323 errors.add :tracker_id, :inclusion
326 errors.add :tracker_id, :inclusion
324 end
327 end
325 end
328 end
326
329
327 # Checks parent issue assignment
330 # Checks parent issue assignment
328 if @parent_issue
331 if @parent_issue
329 if @parent_issue.project_id != project_id
332 if @parent_issue.project_id != project_id
330 errors.add :parent_issue_id, :not_same_project
333 errors.add :parent_issue_id, :not_same_project
331 elsif !new_record?
334 elsif !new_record?
332 # moving an existing issue
335 # moving an existing issue
333 if @parent_issue.root_id != root_id
336 if @parent_issue.root_id != root_id
334 # we can always move to another tree
337 # we can always move to another tree
335 elsif move_possible?(@parent_issue)
338 elsif move_possible?(@parent_issue)
336 # move accepted inside tree
339 # move accepted inside tree
337 else
340 else
338 errors.add :parent_issue_id, :not_a_valid_parent
341 errors.add :parent_issue_id, :not_a_valid_parent
339 end
342 end
340 end
343 end
341 end
344 end
342 end
345 end
343
346
344 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
347 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
345 # even if the user turns off the setting later
348 # even if the user turns off the setting later
346 def update_done_ratio_from_issue_status
349 def update_done_ratio_from_issue_status
347 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
350 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
348 self.done_ratio = status.default_done_ratio
351 self.done_ratio = status.default_done_ratio
349 end
352 end
350 end
353 end
351
354
352 def init_journal(user, notes = "")
355 def init_journal(user, notes = "")
353 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
356 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
354 @issue_before_change = self.clone
357 @issue_before_change = self.clone
355 @issue_before_change.status = self.status
358 @issue_before_change.status = self.status
356 @custom_values_before_change = {}
359 @custom_values_before_change = {}
357 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
360 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
358 # Make sure updated_on is updated when adding a note.
361 # Make sure updated_on is updated when adding a note.
359 updated_on_will_change!
362 updated_on_will_change!
360 @current_journal
363 @current_journal
361 end
364 end
362
365
363 # Return true if the issue is closed, otherwise false
366 # Return true if the issue is closed, otherwise false
364 def closed?
367 def closed?
365 self.status.is_closed?
368 self.status.is_closed?
366 end
369 end
367
370
368 # Return true if the issue is being reopened
371 # Return true if the issue is being reopened
369 def reopened?
372 def reopened?
370 if !new_record? && status_id_changed?
373 if !new_record? && status_id_changed?
371 status_was = IssueStatus.find_by_id(status_id_was)
374 status_was = IssueStatus.find_by_id(status_id_was)
372 status_new = IssueStatus.find_by_id(status_id)
375 status_new = IssueStatus.find_by_id(status_id)
373 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
376 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
374 return true
377 return true
375 end
378 end
376 end
379 end
377 false
380 false
378 end
381 end
379
382
380 # Return true if the issue is being closed
383 # Return true if the issue is being closed
381 def closing?
384 def closing?
382 if !new_record? && status_id_changed?
385 if !new_record? && status_id_changed?
383 status_was = IssueStatus.find_by_id(status_id_was)
386 status_was = IssueStatus.find_by_id(status_id_was)
384 status_new = IssueStatus.find_by_id(status_id)
387 status_new = IssueStatus.find_by_id(status_id)
385 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
388 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
386 return true
389 return true
387 end
390 end
388 end
391 end
389 false
392 false
390 end
393 end
391
394
392 # Returns true if the issue is overdue
395 # Returns true if the issue is overdue
393 def overdue?
396 def overdue?
394 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
397 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
395 end
398 end
396
399
397 # Is the amount of work done less than it should for the due date
400 # Is the amount of work done less than it should for the due date
398 def behind_schedule?
401 def behind_schedule?
399 return false if start_date.nil? || due_date.nil?
402 return false if start_date.nil? || due_date.nil?
400 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
403 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
401 return done_date <= Date.today
404 return done_date <= Date.today
402 end
405 end
403
406
404 # Does this issue have children?
407 # Does this issue have children?
405 def children?
408 def children?
406 !leaf?
409 !leaf?
407 end
410 end
408
411
409 # Users the issue can be assigned to
412 # Users the issue can be assigned to
410 def assignable_users
413 def assignable_users
411 users = project.assignable_users
414 users = project.assignable_users
412 users << author if author
415 users << author if author
413 users.uniq.sort
416 users.uniq.sort
414 end
417 end
415
418
416 # Versions that the issue can be assigned to
419 # Versions that the issue can be assigned to
417 def assignable_versions
420 def assignable_versions
418 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
421 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
419 end
422 end
420
423
421 # Returns true if this issue is blocked by another issue that is still open
424 # Returns true if this issue is blocked by another issue that is still open
422 def blocked?
425 def blocked?
423 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
426 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
424 end
427 end
425
428
426 # Returns an array of status that user is able to apply
429 # Returns an array of status that user is able to apply
427 def new_statuses_allowed_to(user, include_default=false)
430 def new_statuses_allowed_to(user, include_default=false)
428 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
431 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
429 statuses << status unless statuses.empty?
432 statuses << status unless statuses.empty?
430 statuses << IssueStatus.default if include_default
433 statuses << IssueStatus.default if include_default
431 statuses = statuses.uniq.sort
434 statuses = statuses.uniq.sort
432 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
435 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
433 end
436 end
434
437
435 # Returns the mail adresses of users that should be notified
438 # Returns the mail adresses of users that should be notified
436 def recipients
439 def recipients
437 notified = project.notified_users
440 notified = project.notified_users
438 # Author and assignee are always notified unless they have been
441 # Author and assignee are always notified unless they have been
439 # locked or don't want to be notified
442 # locked or don't want to be notified
440 notified << author if author && author.active? && author.notify_about?(self)
443 notified << author if author && author.active? && author.notify_about?(self)
441 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
444 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
442 notified.uniq!
445 notified.uniq!
443 # Remove users that can not view the issue
446 # Remove users that can not view the issue
444 notified.reject! {|user| !visible?(user)}
447 notified.reject! {|user| !visible?(user)}
445 notified.collect(&:mail)
448 notified.collect(&:mail)
446 end
449 end
447
450
448 # Returns the total number of hours spent on this issue and its descendants
451 # Returns the total number of hours spent on this issue and its descendants
449 #
452 #
450 # Example:
453 # Example:
451 # spent_hours => 0.0
454 # spent_hours => 0.0
452 # spent_hours => 50.2
455 # spent_hours => 50.2
453 def spent_hours
456 def spent_hours
454 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
457 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
455 end
458 end
456
459
457 def relations
460 def relations
458 (relations_from + relations_to).sort
461 (relations_from + relations_to).sort
459 end
462 end
460
463
461 def all_dependent_issues
464 def all_dependent_issues
462 dependencies = []
465 dependencies = []
463 relations_from.each do |relation|
466 relations_from.each do |relation|
464 dependencies << relation.issue_to
467 dependencies << relation.issue_to
465 dependencies += relation.issue_to.all_dependent_issues
468 dependencies += relation.issue_to.all_dependent_issues
466 end
469 end
467 dependencies
470 dependencies
468 end
471 end
469
472
470 # Returns an array of issues that duplicate this one
473 # Returns an array of issues that duplicate this one
471 def duplicates
474 def duplicates
472 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
475 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
473 end
476 end
474
477
475 # Returns the due date or the target due date if any
478 # Returns the due date or the target due date if any
476 # Used on gantt chart
479 # Used on gantt chart
477 def due_before
480 def due_before
478 due_date || (fixed_version ? fixed_version.effective_date : nil)
481 due_date || (fixed_version ? fixed_version.effective_date : nil)
479 end
482 end
480
483
481 # Returns the time scheduled for this issue.
484 # Returns the time scheduled for this issue.
482 #
485 #
483 # Example:
486 # Example:
484 # Start Date: 2/26/09, End Date: 3/04/09
487 # Start Date: 2/26/09, End Date: 3/04/09
485 # duration => 6
488 # duration => 6
486 def duration
489 def duration
487 (start_date && due_date) ? due_date - start_date : 0
490 (start_date && due_date) ? due_date - start_date : 0
488 end
491 end
489
492
490 def soonest_start
493 def soonest_start
491 @soonest_start ||= (
494 @soonest_start ||= (
492 relations_to.collect{|relation| relation.successor_soonest_start} +
495 relations_to.collect{|relation| relation.successor_soonest_start} +
493 ancestors.collect(&:soonest_start)
496 ancestors.collect(&:soonest_start)
494 ).compact.max
497 ).compact.max
495 end
498 end
496
499
497 def reschedule_after(date)
500 def reschedule_after(date)
498 return if date.nil?
501 return if date.nil?
499 if leaf?
502 if leaf?
500 if start_date.nil? || start_date < date
503 if start_date.nil? || start_date < date
501 self.start_date, self.due_date = date, date + duration
504 self.start_date, self.due_date = date, date + duration
502 save
505 save
503 end
506 end
504 else
507 else
505 leaves.each do |leaf|
508 leaves.each do |leaf|
506 leaf.reschedule_after(date)
509 leaf.reschedule_after(date)
507 end
510 end
508 end
511 end
509 end
512 end
510
513
511 def <=>(issue)
514 def <=>(issue)
512 if issue.nil?
515 if issue.nil?
513 -1
516 -1
514 elsif root_id != issue.root_id
517 elsif root_id != issue.root_id
515 (root_id || 0) <=> (issue.root_id || 0)
518 (root_id || 0) <=> (issue.root_id || 0)
516 else
519 else
517 (lft || 0) <=> (issue.lft || 0)
520 (lft || 0) <=> (issue.lft || 0)
518 end
521 end
519 end
522 end
520
523
521 def to_s
524 def to_s
522 "#{tracker} ##{id}: #{subject}"
525 "#{tracker} ##{id}: #{subject}"
523 end
526 end
524
527
525 # Returns a string of css classes that apply to the issue
528 # Returns a string of css classes that apply to the issue
526 def css_classes
529 def css_classes
527 s = "issue status-#{status.position} priority-#{priority.position}"
530 s = "issue status-#{status.position} priority-#{priority.position}"
528 s << ' closed' if closed?
531 s << ' closed' if closed?
529 s << ' overdue' if overdue?
532 s << ' overdue' if overdue?
530 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
533 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
531 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
534 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
532 s
535 s
533 end
536 end
534
537
535 # Saves an issue, time_entry, attachments, and a journal from the parameters
538 # Saves an issue, time_entry, attachments, and a journal from the parameters
536 # Returns false if save fails
539 # Returns false if save fails
537 def save_issue_with_child_records(params, existing_time_entry=nil)
540 def save_issue_with_child_records(params, existing_time_entry=nil)
538 Issue.transaction do
541 Issue.transaction do
539 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
542 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
540 @time_entry = existing_time_entry || TimeEntry.new
543 @time_entry = existing_time_entry || TimeEntry.new
541 @time_entry.project = project
544 @time_entry.project = project
542 @time_entry.issue = self
545 @time_entry.issue = self
543 @time_entry.user = User.current
546 @time_entry.user = User.current
544 @time_entry.spent_on = Date.today
547 @time_entry.spent_on = Date.today
545 @time_entry.attributes = params[:time_entry]
548 @time_entry.attributes = params[:time_entry]
546 self.time_entries << @time_entry
549 self.time_entries << @time_entry
547 end
550 end
548
551
549 if valid?
552 if valid?
550 attachments = Attachment.attach_files(self, params[:attachments])
553 attachments = Attachment.attach_files(self, params[:attachments])
551
554
552 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
555 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
553 # TODO: Rename hook
556 # TODO: Rename hook
554 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
557 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
555 begin
558 begin
556 if save
559 if save
557 # TODO: Rename hook
560 # TODO: Rename hook
558 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
561 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
559 else
562 else
560 raise ActiveRecord::Rollback
563 raise ActiveRecord::Rollback
561 end
564 end
562 rescue ActiveRecord::StaleObjectError
565 rescue ActiveRecord::StaleObjectError
563 attachments[:files].each(&:destroy)
566 attachments[:files].each(&:destroy)
564 errors.add_to_base l(:notice_locking_conflict)
567 errors.add_to_base l(:notice_locking_conflict)
565 raise ActiveRecord::Rollback
568 raise ActiveRecord::Rollback
566 end
569 end
567 end
570 end
568 end
571 end
569 end
572 end
570
573
571 # Unassigns issues from +version+ if it's no longer shared with issue's project
574 # Unassigns issues from +version+ if it's no longer shared with issue's project
572 def self.update_versions_from_sharing_change(version)
575 def self.update_versions_from_sharing_change(version)
573 # Update issues assigned to the version
576 # Update issues assigned to the version
574 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
577 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
575 end
578 end
576
579
577 # Unassigns issues from versions that are no longer shared
580 # Unassigns issues from versions that are no longer shared
578 # after +project+ was moved
581 # after +project+ was moved
579 def self.update_versions_from_hierarchy_change(project)
582 def self.update_versions_from_hierarchy_change(project)
580 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
583 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
581 # Update issues of the moved projects and issues assigned to a version of a moved project
584 # Update issues of the moved projects and issues assigned to a version of a moved project
582 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
585 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
583 end
586 end
584
587
585 def parent_issue_id=(arg)
588 def parent_issue_id=(arg)
586 parent_issue_id = arg.blank? ? nil : arg.to_i
589 parent_issue_id = arg.blank? ? nil : arg.to_i
587 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
590 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
588 @parent_issue.id
591 @parent_issue.id
589 else
592 else
590 @parent_issue = nil
593 @parent_issue = nil
591 nil
594 nil
592 end
595 end
593 end
596 end
594
597
595 def parent_issue_id
598 def parent_issue_id
596 if instance_variable_defined? :@parent_issue
599 if instance_variable_defined? :@parent_issue
597 @parent_issue.nil? ? nil : @parent_issue.id
600 @parent_issue.nil? ? nil : @parent_issue.id
598 else
601 else
599 parent_id
602 parent_id
600 end
603 end
601 end
604 end
602
605
603 # Extracted from the ReportsController.
606 # Extracted from the ReportsController.
604 def self.by_tracker(project)
607 def self.by_tracker(project)
605 count_and_group_by(:project => project,
608 count_and_group_by(:project => project,
606 :field => 'tracker_id',
609 :field => 'tracker_id',
607 :joins => Tracker.table_name)
610 :joins => Tracker.table_name)
608 end
611 end
609
612
610 def self.by_version(project)
613 def self.by_version(project)
611 count_and_group_by(:project => project,
614 count_and_group_by(:project => project,
612 :field => 'fixed_version_id',
615 :field => 'fixed_version_id',
613 :joins => Version.table_name)
616 :joins => Version.table_name)
614 end
617 end
615
618
616 def self.by_priority(project)
619 def self.by_priority(project)
617 count_and_group_by(:project => project,
620 count_and_group_by(:project => project,
618 :field => 'priority_id',
621 :field => 'priority_id',
619 :joins => IssuePriority.table_name)
622 :joins => IssuePriority.table_name)
620 end
623 end
621
624
622 def self.by_category(project)
625 def self.by_category(project)
623 count_and_group_by(:project => project,
626 count_and_group_by(:project => project,
624 :field => 'category_id',
627 :field => 'category_id',
625 :joins => IssueCategory.table_name)
628 :joins => IssueCategory.table_name)
626 end
629 end
627
630
628 def self.by_assigned_to(project)
631 def self.by_assigned_to(project)
629 count_and_group_by(:project => project,
632 count_and_group_by(:project => project,
630 :field => 'assigned_to_id',
633 :field => 'assigned_to_id',
631 :joins => User.table_name)
634 :joins => User.table_name)
632 end
635 end
633
636
634 def self.by_author(project)
637 def self.by_author(project)
635 count_and_group_by(:project => project,
638 count_and_group_by(:project => project,
636 :field => 'author_id',
639 :field => 'author_id',
637 :joins => User.table_name)
640 :joins => User.table_name)
638 end
641 end
639
642
640 def self.by_subproject(project)
643 def self.by_subproject(project)
641 ActiveRecord::Base.connection.select_all("select s.id as status_id,
644 ActiveRecord::Base.connection.select_all("select s.id as status_id,
642 s.is_closed as closed,
645 s.is_closed as closed,
643 i.project_id as project_id,
646 i.project_id as project_id,
644 count(i.id) as total
647 count(i.id) as total
645 from
648 from
646 #{Issue.table_name} i, #{IssueStatus.table_name} s
649 #{Issue.table_name} i, #{IssueStatus.table_name} s
647 where
650 where
648 i.status_id=s.id
651 i.status_id=s.id
649 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
652 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
650 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
653 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
651 end
654 end
652 # End ReportsController extraction
655 # End ReportsController extraction
653
656
654 # Returns an array of projects that current user can move issues to
657 # Returns an array of projects that current user can move issues to
655 def self.allowed_target_projects_on_move
658 def self.allowed_target_projects_on_move
656 projects = []
659 projects = []
657 if User.current.admin?
660 if User.current.admin?
658 # admin is allowed to move issues to any active (visible) project
661 # admin is allowed to move issues to any active (visible) project
659 projects = Project.visible.all
662 projects = Project.visible.all
660 elsif User.current.logged?
663 elsif User.current.logged?
661 if Role.non_member.allowed_to?(:move_issues)
664 if Role.non_member.allowed_to?(:move_issues)
662 projects = Project.visible.all
665 projects = Project.visible.all
663 else
666 else
664 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
667 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
665 end
668 end
666 end
669 end
667 projects
670 projects
668 end
671 end
669
672
670 private
673 private
671
674
672 def update_nested_set_attributes
675 def update_nested_set_attributes
673 if root_id.nil?
676 if root_id.nil?
674 # issue was just created
677 # issue was just created
675 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
678 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
676 set_default_left_and_right
679 set_default_left_and_right
677 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
680 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
678 if @parent_issue
681 if @parent_issue
679 move_to_child_of(@parent_issue)
682 move_to_child_of(@parent_issue)
680 end
683 end
681 reload
684 reload
682 elsif parent_issue_id != parent_id
685 elsif parent_issue_id != parent_id
683 former_parent_id = parent_id
686 former_parent_id = parent_id
684 # moving an existing issue
687 # moving an existing issue
685 if @parent_issue && @parent_issue.root_id == root_id
688 if @parent_issue && @parent_issue.root_id == root_id
686 # inside the same tree
689 # inside the same tree
687 move_to_child_of(@parent_issue)
690 move_to_child_of(@parent_issue)
688 else
691 else
689 # to another tree
692 # to another tree
690 unless root?
693 unless root?
691 move_to_right_of(root)
694 move_to_right_of(root)
692 reload
695 reload
693 end
696 end
694 old_root_id = root_id
697 old_root_id = root_id
695 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
698 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
696 target_maxright = nested_set_scope.maximum(right_column_name) || 0
699 target_maxright = nested_set_scope.maximum(right_column_name) || 0
697 offset = target_maxright + 1 - lft
700 offset = target_maxright + 1 - lft
698 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
701 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
699 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
702 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
700 self[left_column_name] = lft + offset
703 self[left_column_name] = lft + offset
701 self[right_column_name] = rgt + offset
704 self[right_column_name] = rgt + offset
702 if @parent_issue
705 if @parent_issue
703 move_to_child_of(@parent_issue)
706 move_to_child_of(@parent_issue)
704 end
707 end
705 end
708 end
706 reload
709 reload
707 # delete invalid relations of all descendants
710 # delete invalid relations of all descendants
708 self_and_descendants.each do |issue|
711 self_and_descendants.each do |issue|
709 issue.relations.each do |relation|
712 issue.relations.each do |relation|
710 relation.destroy unless relation.valid?
713 relation.destroy unless relation.valid?
711 end
714 end
712 end
715 end
713 # update former parent
716 # update former parent
714 recalculate_attributes_for(former_parent_id) if former_parent_id
717 recalculate_attributes_for(former_parent_id) if former_parent_id
715 end
718 end
716 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
719 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
717 end
720 end
718
721
719 def update_parent_attributes
722 def update_parent_attributes
720 recalculate_attributes_for(parent_id) if parent_id
723 recalculate_attributes_for(parent_id) if parent_id
721 end
724 end
722
725
723 def recalculate_attributes_for(issue_id)
726 def recalculate_attributes_for(issue_id)
724 if issue_id && p = Issue.find_by_id(issue_id)
727 if issue_id && p = Issue.find_by_id(issue_id)
725 # priority = highest priority of children
728 # priority = highest priority of children
726 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
729 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
727 p.priority = IssuePriority.find_by_position(priority_position)
730 p.priority = IssuePriority.find_by_position(priority_position)
728 end
731 end
729
732
730 # start/due dates = lowest/highest dates of children
733 # start/due dates = lowest/highest dates of children
731 p.start_date = p.children.minimum(:start_date)
734 p.start_date = p.children.minimum(:start_date)
732 p.due_date = p.children.maximum(:due_date)
735 p.due_date = p.children.maximum(:due_date)
733 if p.start_date && p.due_date && p.due_date < p.start_date
736 if p.start_date && p.due_date && p.due_date < p.start_date
734 p.start_date, p.due_date = p.due_date, p.start_date
737 p.start_date, p.due_date = p.due_date, p.start_date
735 end
738 end
736
739
737 # done ratio = weighted average ratio of leaves
740 # done ratio = weighted average ratio of leaves
738 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
741 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
739 leaves_count = p.leaves.count
742 leaves_count = p.leaves.count
740 if leaves_count > 0
743 if leaves_count > 0
741 average = p.leaves.average(:estimated_hours).to_f
744 average = p.leaves.average(:estimated_hours).to_f
742 if average == 0
745 if average == 0
743 average = 1
746 average = 1
744 end
747 end
745 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
748 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
746 progress = done / (average * leaves_count)
749 progress = done / (average * leaves_count)
747 p.done_ratio = progress.round
750 p.done_ratio = progress.round
748 end
751 end
749 end
752 end
750
753
751 # estimate = sum of leaves estimates
754 # estimate = sum of leaves estimates
752 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
755 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
753 p.estimated_hours = nil if p.estimated_hours == 0.0
756 p.estimated_hours = nil if p.estimated_hours == 0.0
754
757
755 # ancestors will be recursively updated
758 # ancestors will be recursively updated
756 p.save(false)
759 p.save(false)
757 end
760 end
758 end
761 end
759
762
760 def destroy_children
763 def destroy_children
761 unless leaf?
764 unless leaf?
762 children.each do |child|
765 children.each do |child|
763 child.destroy
766 child.destroy
764 end
767 end
765 end
768 end
766 end
769 end
767
770
768 # Update issues so their versions are not pointing to a
771 # Update issues so their versions are not pointing to a
769 # fixed_version that is not shared with the issue's project
772 # fixed_version that is not shared with the issue's project
770 def self.update_versions(conditions=nil)
773 def self.update_versions(conditions=nil)
771 # Only need to update issues with a fixed_version from
774 # Only need to update issues with a fixed_version from
772 # a different project and that is not systemwide shared
775 # a different project and that is not systemwide shared
773 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
776 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
774 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
777 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
775 " AND #{Version.table_name}.sharing <> 'system'",
778 " AND #{Version.table_name}.sharing <> 'system'",
776 conditions),
779 conditions),
777 :include => [:project, :fixed_version]
780 :include => [:project, :fixed_version]
778 ).each do |issue|
781 ).each do |issue|
779 next if issue.project.nil? || issue.fixed_version.nil?
782 next if issue.project.nil? || issue.fixed_version.nil?
780 unless issue.project.shared_versions.include?(issue.fixed_version)
783 unless issue.project.shared_versions.include?(issue.fixed_version)
781 issue.init_journal(User.current)
784 issue.init_journal(User.current)
782 issue.fixed_version = nil
785 issue.fixed_version = nil
783 issue.save
786 issue.save
784 end
787 end
785 end
788 end
786 end
789 end
787
790
788 # Callback on attachment deletion
791 # Callback on attachment deletion
789 def attachment_removed(obj)
792 def attachment_removed(obj)
790 journal = init_journal(User.current)
793 journal = init_journal(User.current)
791 journal.details << JournalDetail.new(:property => 'attachment',
794 journal.details << JournalDetail.new(:property => 'attachment',
792 :prop_key => obj.id,
795 :prop_key => obj.id,
793 :old_value => obj.filename)
796 :old_value => obj.filename)
794 journal.save
797 journal.save
795 end
798 end
796
799
797 # Default assignment based on category
800 # Default assignment based on category
798 def default_assign
801 def default_assign
799 if assigned_to.nil? && category && category.assigned_to
802 if assigned_to.nil? && category && category.assigned_to
800 self.assigned_to = category.assigned_to
803 self.assigned_to = category.assigned_to
801 end
804 end
802 end
805 end
803
806
804 # Updates start/due dates of following issues
807 # Updates start/due dates of following issues
805 def reschedule_following_issues
808 def reschedule_following_issues
806 if start_date_changed? || due_date_changed?
809 if start_date_changed? || due_date_changed?
807 relations_from.each do |relation|
810 relations_from.each do |relation|
808 relation.set_issue_to_dates
811 relation.set_issue_to_dates
809 end
812 end
810 end
813 end
811 end
814 end
812
815
813 # Closes duplicates if the issue is being closed
816 # Closes duplicates if the issue is being closed
814 def close_duplicates
817 def close_duplicates
815 if closing?
818 if closing?
816 duplicates.each do |duplicate|
819 duplicates.each do |duplicate|
817 # Reload is need in case the duplicate was updated by a previous duplicate
820 # Reload is need in case the duplicate was updated by a previous duplicate
818 duplicate.reload
821 duplicate.reload
819 # Don't re-close it if it's already closed
822 # Don't re-close it if it's already closed
820 next if duplicate.closed?
823 next if duplicate.closed?
821 # Same user and notes
824 # Same user and notes
822 if @current_journal
825 if @current_journal
823 duplicate.init_journal(@current_journal.user, @current_journal.notes)
826 duplicate.init_journal(@current_journal.user, @current_journal.notes)
824 end
827 end
825 duplicate.update_attribute :status, self.status
828 duplicate.update_attribute :status, self.status
826 end
829 end
827 end
830 end
828 end
831 end
829
832
830 # Saves the changes in a Journal
833 # Saves the changes in a Journal
831 # Called after_save
834 # Called after_save
832 def create_journal
835 def create_journal
833 if @current_journal
836 if @current_journal
834 # attributes changes
837 # attributes changes
835 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
838 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
836 @current_journal.details << JournalDetail.new(:property => 'attr',
839 @current_journal.details << JournalDetail.new(:property => 'attr',
837 :prop_key => c,
840 :prop_key => c,
838 :old_value => @issue_before_change.send(c),
841 :old_value => @issue_before_change.send(c),
839 :value => send(c)) unless send(c)==@issue_before_change.send(c)
842 :value => send(c)) unless send(c)==@issue_before_change.send(c)
840 }
843 }
841 # custom fields changes
844 # custom fields changes
842 custom_values.each {|c|
845 custom_values.each {|c|
843 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
846 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
844 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
847 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
845 @current_journal.details << JournalDetail.new(:property => 'cf',
848 @current_journal.details << JournalDetail.new(:property => 'cf',
846 :prop_key => c.custom_field_id,
849 :prop_key => c.custom_field_id,
847 :old_value => @custom_values_before_change[c.custom_field_id],
850 :old_value => @custom_values_before_change[c.custom_field_id],
848 :value => c.value)
851 :value => c.value)
849 }
852 }
850 @current_journal.save
853 @current_journal.save
851 # reset current journal
854 # reset current journal
852 init_journal @current_journal.user, @current_journal.notes
855 init_journal @current_journal.user, @current_journal.notes
853 end
856 end
854 end
857 end
855
858
856 # Query generator for selecting groups of issue counts for a project
859 # Query generator for selecting groups of issue counts for a project
857 # based on specific criteria
860 # based on specific criteria
858 #
861 #
859 # Options
862 # Options
860 # * project - Project to search in.
863 # * project - Project to search in.
861 # * field - String. Issue field to key off of in the grouping.
864 # * field - String. Issue field to key off of in the grouping.
862 # * joins - String. The table name to join against.
865 # * joins - String. The table name to join against.
863 def self.count_and_group_by(options)
866 def self.count_and_group_by(options)
864 project = options.delete(:project)
867 project = options.delete(:project)
865 select_field = options.delete(:field)
868 select_field = options.delete(:field)
866 joins = options.delete(:joins)
869 joins = options.delete(:joins)
867
870
868 where = "i.#{select_field}=j.id"
871 where = "i.#{select_field}=j.id"
869
872
870 ActiveRecord::Base.connection.select_all("select s.id as status_id,
873 ActiveRecord::Base.connection.select_all("select s.id as status_id,
871 s.is_closed as closed,
874 s.is_closed as closed,
872 j.id as #{select_field},
875 j.id as #{select_field},
873 count(i.id) as total
876 count(i.id) as total
874 from
877 from
875 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
878 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
876 where
879 where
877 i.status_id=s.id
880 i.status_id=s.id
878 and #{where}
881 and #{where}
879 and i.project_id=#{project.id}
882 and i.project_id=#{project.id}
880 group by s.id, s.is_closed, j.id")
883 group by s.id, s.is_closed, j.id")
881 end
884 end
882
885
883
886
884 end
887 end
@@ -1,336 +1,333
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 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
20
21 class UnauthorizedAction < StandardError; end
21 class UnauthorizedAction < StandardError; end
22 class MissingInformation < StandardError; end
22 class MissingInformation < StandardError; end
23
23
24 attr_reader :email, :user
24 attr_reader :email, :user
25
25
26 def self.receive(email, options={})
26 def self.receive(email, options={})
27 @@handler_options = options.dup
27 @@handler_options = options.dup
28
28
29 @@handler_options[:issue] ||= {}
29 @@handler_options[:issue] ||= {}
30
30
31 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
31 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
32 @@handler_options[:allow_override] ||= []
32 @@handler_options[:allow_override] ||= []
33 # Project needs to be overridable if not specified
33 # Project needs to be overridable if not specified
34 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
34 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
35 # Status overridable by default
35 # Status overridable by default
36 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
36 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
37
37
38 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
38 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
39 super email
39 super email
40 end
40 end
41
41
42 # Processes incoming emails
42 # Processes incoming emails
43 # Returns the created object (eg. an issue, a message) or false
43 # Returns the created object (eg. an issue, a message) or false
44 def receive(email)
44 def receive(email)
45 @email = email
45 @email = email
46 sender_email = email.from.to_a.first.to_s.strip
46 sender_email = email.from.to_a.first.to_s.strip
47 # Ignore emails received from the application emission address to avoid hell cycles
47 # Ignore emails received from the application emission address to avoid hell cycles
48 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
48 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
49 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
49 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
50 return false
50 return false
51 end
51 end
52 @user = User.find_by_mail(sender_email) if sender_email.present?
52 @user = User.find_by_mail(sender_email) if sender_email.present?
53 if @user && !@user.active?
53 if @user && !@user.active?
54 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]" if logger && logger.info
54 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]" if logger && logger.info
55 return false
55 return false
56 end
56 end
57 if @user.nil?
57 if @user.nil?
58 # Email was submitted by an unknown user
58 # Email was submitted by an unknown user
59 case @@handler_options[:unknown_user]
59 case @@handler_options[:unknown_user]
60 when 'accept'
60 when 'accept'
61 @user = User.anonymous
61 @user = User.anonymous
62 when 'create'
62 when 'create'
63 @user = MailHandler.create_user_from_email(email)
63 @user = MailHandler.create_user_from_email(email)
64 if @user
64 if @user
65 logger.info "MailHandler: [#{@user.login}] account created" if logger && logger.info
65 logger.info "MailHandler: [#{@user.login}] account created" if logger && logger.info
66 Mailer.deliver_account_information(@user, @user.password)
66 Mailer.deliver_account_information(@user, @user.password)
67 else
67 else
68 logger.error "MailHandler: could not create account for [#{sender_email}]" if logger && logger.error
68 logger.error "MailHandler: could not create account for [#{sender_email}]" if logger && logger.error
69 return false
69 return false
70 end
70 end
71 else
71 else
72 # Default behaviour, emails from unknown users are ignored
72 # Default behaviour, emails from unknown users are ignored
73 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]" if logger && logger.info
73 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]" if logger && logger.info
74 return false
74 return false
75 end
75 end
76 end
76 end
77 User.current = @user
77 User.current = @user
78 dispatch
78 dispatch
79 end
79 end
80
80
81 private
81 private
82
82
83 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
83 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
84 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
84 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
85 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
85 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
86
86
87 def dispatch
87 def dispatch
88 headers = [email.in_reply_to, email.references].flatten.compact
88 headers = [email.in_reply_to, email.references].flatten.compact
89 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
89 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
90 klass, object_id = $1, $2.to_i
90 klass, object_id = $1, $2.to_i
91 method_name = "receive_#{klass}_reply"
91 method_name = "receive_#{klass}_reply"
92 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
92 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
93 send method_name, object_id
93 send method_name, object_id
94 else
94 else
95 # ignoring it
95 # ignoring it
96 end
96 end
97 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
97 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
98 receive_issue_reply(m[1].to_i)
98 receive_issue_reply(m[1].to_i)
99 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
99 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
100 receive_message_reply(m[1].to_i)
100 receive_message_reply(m[1].to_i)
101 else
101 else
102 receive_issue
102 receive_issue
103 end
103 end
104 rescue ActiveRecord::RecordInvalid => e
104 rescue ActiveRecord::RecordInvalid => e
105 # TODO: send a email to the user
105 # TODO: send a email to the user
106 logger.error e.message if logger
106 logger.error e.message if logger
107 false
107 false
108 rescue MissingInformation => e
108 rescue MissingInformation => e
109 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
109 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
110 false
110 false
111 rescue UnauthorizedAction => e
111 rescue UnauthorizedAction => e
112 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
112 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
113 false
113 false
114 end
114 end
115
115
116 # Creates a new issue
116 # Creates a new issue
117 def receive_issue
117 def receive_issue
118 project = target_project
118 project = target_project
119 tracker = (get_keyword(:tracker) && project.trackers.find_by_name(get_keyword(:tracker))) || project.trackers.find(:first)
120 category = (get_keyword(:category) && project.issue_categories.find_by_name(get_keyword(:category)))
121 priority = (get_keyword(:priority) && IssuePriority.find_by_name(get_keyword(:priority)))
122 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
123 assigned_to = (get_keyword(:assigned_to, :override => true) && find_user_from_keyword(get_keyword(:assigned_to, :override => true)))
124 due_date = get_keyword(:due_date, :override => true)
125 start_date = get_keyword(:start_date, :override => true)
126
127 # check permission
119 # check permission
128 unless @@handler_options[:no_permission_check]
120 unless @@handler_options[:no_permission_check]
129 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
121 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
130 end
122 end
131
123
132 issue = Issue.new(:author => user, :project => project, :tracker => tracker, :category => category, :priority => priority, :due_date => due_date, :start_date => start_date, :assigned_to => assigned_to)
124 issue = Issue.new(:author => user, :project => project)
133 # check workflow
125 issue.safe_attributes = issue_attributes_from_keywords(issue)
134 if status && issue.new_statuses_allowed_to(user).include?(status)
126 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
135 issue.status = status
127 issue.subject = email.subject.to_s.chomp[0,255]
136 end
137 issue.subject = email.subject.chomp[0,255]
138 if issue.subject.blank?
128 if issue.subject.blank?
139 issue.subject = '(no subject)'
129 issue.subject = '(no subject)'
140 end
130 end
141 # custom fields
142 issue.custom_field_values = issue.available_custom_fields.inject({}) do |h, c|
143 if value = get_keyword(c.name, :override => true)
144 h[c.id] = value
145 end
146 h
147 end
148 issue.description = cleaned_up_text_body
131 issue.description = cleaned_up_text_body
132
149 # add To and Cc as watchers before saving so the watchers can reply to Redmine
133 # add To and Cc as watchers before saving so the watchers can reply to Redmine
150 add_watchers(issue)
134 add_watchers(issue)
151 issue.save!
135 issue.save!
152 add_attachments(issue)
136 add_attachments(issue)
153 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
137 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
154 issue
138 issue
155 end
139 end
156
140
157 def target_project
158 # TODO: other ways to specify project:
159 # * parse the email To field
160 # * specific project (eg. Setting.mail_handler_target_project)
161 target = Project.find_by_identifier(get_keyword(:project))
162 raise MissingInformation.new('Unable to determine target project') if target.nil?
163 target
164 end
165
166 # Adds a note to an existing issue
141 # Adds a note to an existing issue
167 def receive_issue_reply(issue_id)
142 def receive_issue_reply(issue_id)
168 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
169 due_date = get_keyword(:due_date, :override => true)
170 start_date = get_keyword(:start_date, :override => true)
171 assigned_to = (get_keyword(:assigned_to, :override => true) && find_user_from_keyword(get_keyword(:assigned_to, :override => true)))
172
173 issue = Issue.find_by_id(issue_id)
143 issue = Issue.find_by_id(issue_id)
174 return unless issue
144 return unless issue
175 # check permission
145 # check permission
176 unless @@handler_options[:no_permission_check]
146 unless @@handler_options[:no_permission_check]
177 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
147 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
178 raise UnauthorizedAction unless status.nil? || user.allowed_to?(:edit_issues, issue.project)
179 end
148 end
180
149
181 # add the note
182 journal = issue.init_journal(user, cleaned_up_text_body)
150 journal = issue.init_journal(user, cleaned_up_text_body)
151 issue.safe_attributes = issue_attributes_from_keywords(issue)
152 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
183 add_attachments(issue)
153 add_attachments(issue)
184 # check workflow
185 if status && issue.new_statuses_allowed_to(user).include?(status)
186 issue.status = status
187 end
188 issue.start_date = start_date if start_date
189 issue.due_date = due_date if due_date
190 issue.assigned_to = assigned_to if assigned_to
191
192 issue.save!
154 issue.save!
193 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
155 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
194 journal
156 journal
195 end
157 end
196
158
197 # Reply will be added to the issue
159 # Reply will be added to the issue
198 def receive_journal_reply(journal_id)
160 def receive_journal_reply(journal_id)
199 journal = Journal.find_by_id(journal_id)
161 journal = Journal.find_by_id(journal_id)
200 if journal && journal.journalized_type == 'Issue'
162 if journal && journal.journalized_type == 'Issue'
201 receive_issue_reply(journal.journalized_id)
163 receive_issue_reply(journal.journalized_id)
202 end
164 end
203 end
165 end
204
166
205 # Receives a reply to a forum message
167 # Receives a reply to a forum message
206 def receive_message_reply(message_id)
168 def receive_message_reply(message_id)
207 message = Message.find_by_id(message_id)
169 message = Message.find_by_id(message_id)
208 if message
170 if message
209 message = message.root
171 message = message.root
210
172
211 unless @@handler_options[:no_permission_check]
173 unless @@handler_options[:no_permission_check]
212 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
174 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
213 end
175 end
214
176
215 if !message.locked?
177 if !message.locked?
216 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
178 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
217 :content => cleaned_up_text_body)
179 :content => cleaned_up_text_body)
218 reply.author = user
180 reply.author = user
219 reply.board = message.board
181 reply.board = message.board
220 message.children << reply
182 message.children << reply
221 add_attachments(reply)
183 add_attachments(reply)
222 reply
184 reply
223 else
185 else
224 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic" if logger && logger.info
186 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic" if logger && logger.info
225 end
187 end
226 end
188 end
227 end
189 end
228
190
229 def add_attachments(obj)
191 def add_attachments(obj)
230 if email.has_attachments?
192 if email.has_attachments?
231 email.attachments.each do |attachment|
193 email.attachments.each do |attachment|
232 Attachment.create(:container => obj,
194 Attachment.create(:container => obj,
233 :file => attachment,
195 :file => attachment,
234 :author => user,
196 :author => user,
235 :content_type => attachment.content_type)
197 :content_type => attachment.content_type)
236 end
198 end
237 end
199 end
238 end
200 end
239
201
240 # Adds To and Cc as watchers of the given object if the sender has the
202 # Adds To and Cc as watchers of the given object if the sender has the
241 # appropriate permission
203 # appropriate permission
242 def add_watchers(obj)
204 def add_watchers(obj)
243 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
205 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
244 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
206 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
245 unless addresses.empty?
207 unless addresses.empty?
246 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
208 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
247 watchers.each {|w| obj.add_watcher(w)}
209 watchers.each {|w| obj.add_watcher(w)}
248 end
210 end
249 end
211 end
250 end
212 end
251
213
252 def get_keyword(attr, options={})
214 def get_keyword(attr, options={})
253 @keywords ||= {}
215 @keywords ||= {}
254 if @keywords.has_key?(attr)
216 if @keywords.has_key?(attr)
255 @keywords[attr]
217 @keywords[attr]
256 else
218 else
257 @keywords[attr] = begin
219 @keywords[attr] = begin
258 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && plain_text_body.gsub!(/^#{attr.to_s.humanize}[ \t]*:[ \t]*(.+)\s*$/i, '')
220 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && plain_text_body.gsub!(/^#{attr.to_s.humanize}[ \t]*:[ \t]*(.+)\s*$/i, '')
259 $1.strip
221 $1.strip
260 elsif !@@handler_options[:issue][attr].blank?
222 elsif !@@handler_options[:issue][attr].blank?
261 @@handler_options[:issue][attr]
223 @@handler_options[:issue][attr]
262 end
224 end
263 end
225 end
264 end
226 end
265 end
227 end
228
229 def target_project
230 # TODO: other ways to specify project:
231 # * parse the email To field
232 # * specific project (eg. Setting.mail_handler_target_project)
233 target = Project.find_by_identifier(get_keyword(:project))
234 raise MissingInformation.new('Unable to determine target project') if target.nil?
235 target
236 end
237
238 # Returns a Hash of issue attributes extracted from keywords in the email body
239 def issue_attributes_from_keywords(issue)
240 {
241 'tracker_id' => ((k = get_keyword(:tracker)) && issue.project.trackers.find_by_name(k).try(:id)) || issue.project.trackers.find(:first).try(:id),
242 'status_id' => (k = get_keyword(:status)) && IssueStatus.find_by_name(k).try(:id),
243 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.find_by_name(k).try(:id),
244 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.find_by_name(k).try(:id),
245 'assigned_to_id' => (k = get_keyword(:assigned_to, :override => true)) && find_user_from_keyword(k).try(:id),
246 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) && issue.project.shared_versions.find_by_name(k).try(:id),
247 'start_date' => get_keyword(:start_date, :override => true),
248 'due_date' => get_keyword(:due_date, :override => true),
249 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
250 'done_ratio' => get_keyword(:done_ratio, :override => true),
251 }.delete_if {|k, v| v.blank? }
252 end
253
254 # Returns a Hash of issue custom field values extracted from keywords in the email body
255 def custom_field_values_from_keywords(customized)
256 customized.custom_field_values.inject({}) do |h, v|
257 if value = get_keyword(v.custom_field.name, :override => true)
258 h[v.custom_field.id.to_s] = value
259 end
260 h
261 end
262 end
266
263
267 # Returns the text/plain part of the email
264 # Returns the text/plain part of the email
268 # If not found (eg. HTML-only email), returns the body with tags removed
265 # If not found (eg. HTML-only email), returns the body with tags removed
269 def plain_text_body
266 def plain_text_body
270 return @plain_text_body unless @plain_text_body.nil?
267 return @plain_text_body unless @plain_text_body.nil?
271 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
268 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
272 if parts.empty?
269 if parts.empty?
273 parts << @email
270 parts << @email
274 end
271 end
275 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
272 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
276 if plain_text_part.nil?
273 if plain_text_part.nil?
277 # no text/plain part found, assuming html-only email
274 # no text/plain part found, assuming html-only email
278 # strip html tags and remove doctype directive
275 # strip html tags and remove doctype directive
279 @plain_text_body = strip_tags(@email.body.to_s)
276 @plain_text_body = strip_tags(@email.body.to_s)
280 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
277 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
281 else
278 else
282 @plain_text_body = plain_text_part.body.to_s
279 @plain_text_body = plain_text_part.body.to_s
283 end
280 end
284 @plain_text_body.strip!
281 @plain_text_body.strip!
285 @plain_text_body
282 @plain_text_body
286 end
283 end
287
284
288 def cleaned_up_text_body
285 def cleaned_up_text_body
289 cleanup_body(plain_text_body)
286 cleanup_body(plain_text_body)
290 end
287 end
291
288
292 def self.full_sanitizer
289 def self.full_sanitizer
293 @full_sanitizer ||= HTML::FullSanitizer.new
290 @full_sanitizer ||= HTML::FullSanitizer.new
294 end
291 end
295
292
296 # Creates a user account for the +email+ sender
293 # Creates a user account for the +email+ sender
297 def self.create_user_from_email(email)
294 def self.create_user_from_email(email)
298 addr = email.from_addrs.to_a.first
295 addr = email.from_addrs.to_a.first
299 if addr && !addr.spec.blank?
296 if addr && !addr.spec.blank?
300 user = User.new
297 user = User.new
301 user.mail = addr.spec
298 user.mail = addr.spec
302
299
303 names = addr.name.blank? ? addr.spec.gsub(/@.*$/, '').split('.') : addr.name.split
300 names = addr.name.blank? ? addr.spec.gsub(/@.*$/, '').split('.') : addr.name.split
304 user.firstname = names.shift
301 user.firstname = names.shift
305 user.lastname = names.join(' ')
302 user.lastname = names.join(' ')
306 user.lastname = '-' if user.lastname.blank?
303 user.lastname = '-' if user.lastname.blank?
307
304
308 user.login = user.mail
305 user.login = user.mail
309 user.password = ActiveSupport::SecureRandom.hex(5)
306 user.password = ActiveSupport::SecureRandom.hex(5)
310 user.language = Setting.default_language
307 user.language = Setting.default_language
311 user.save ? user : nil
308 user.save ? user : nil
312 end
309 end
313 end
310 end
314
311
315 private
312 private
316
313
317 # Removes the email body of text after the truncation configurations.
314 # Removes the email body of text after the truncation configurations.
318 def cleanup_body(body)
315 def cleanup_body(body)
319 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
316 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
320 unless delimiters.empty?
317 unless delimiters.empty?
321 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
318 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
322 body = body.gsub(regex, '')
319 body = body.gsub(regex, '')
323 end
320 end
324 body.strip
321 body.strip
325 end
322 end
326
323
327 def find_user_from_keyword(keyword)
324 def find_user_from_keyword(keyword)
328 user ||= User.find_by_mail(keyword)
325 user ||= User.find_by_mail(keyword)
329 user ||= User.find_by_login(keyword)
326 user ||= User.find_by_login(keyword)
330 if user.nil? && keyword.match(/ /)
327 if user.nil? && keyword.match(/ /)
331 firstname, lastname = *(keyword.split) # "First Last Throwaway"
328 firstname, lastname = *(keyword.split) # "First Last Throwaway"
332 user ||= User.find_by_firstname_and_lastname(firstname, lastname)
329 user ||= User.find_by_firstname_and_lastname(firstname, lastname)
333 end
330 end
334 user
331 user
335 end
332 end
336 end
333 end
@@ -1,57 +1,60
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 --- This line starts with a delimiter and should not be stripped
33 --- This line starts with a delimiter and should not be stripped
34
34
35 This paragraph is before delimiters.
35 This paragraph is before delimiters.
36
36
37 BREAK
37 BREAK
38
38
39 This paragraph is between delimiters.
39 This paragraph is between delimiters.
40
40
41 ---
41 ---
42
42
43 This paragraph is after the delimiter so it shouldn't appear.
43 This paragraph is after the delimiter so it shouldn't appear.
44
44
45 Nulla et nunc. Duis pede. Donec et ipsum. Nam ut dui tincidunt neque
45 Nulla et nunc. Duis pede. Donec et ipsum. Nam ut dui tincidunt neque
46 sollicitudin iaculis. Duis vitae dolor. Vestibulum eget massa. Sed lorem.
46 sollicitudin iaculis. Duis vitae dolor. Vestibulum eget massa. Sed lorem.
47 Nullam volutpat cursus erat. Cras felis dolor, lacinia quis, rutrum et,
47 Nullam volutpat cursus erat. Cras felis dolor, lacinia quis, rutrum et,
48 dictum et, ligula. Sed erat nibh, gravida in, accumsan non, placerat sed,
48 dictum et, ligula. Sed erat nibh, gravida in, accumsan non, placerat sed,
49 massa. Sed sodales, ante fermentum ultricies sollicitudin, massa leo
49 massa. Sed sodales, ante fermentum ultricies sollicitudin, massa leo
50 pulvinar dui, a gravida orci mi eget odio. Nunc a lacus.
50 pulvinar dui, a gravida orci mi eget odio. Nunc a lacus.
51
51
52 Project: onlinestore
52 Project: onlinestore
53 Status: Resolved
53 Status: Resolved
54 due date: 2010-12-31
54 due date: 2010-12-31
55 Start Date:2010-01-01
55 Start Date:2010-01-01
56 Assigned to: John Smith
56 Assigned to: John Smith
57 fixed version: alpha
58 estimated hours: 2.5
59 done ratio: 30
57
60
@@ -1,79 +1,80
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 ; Sat, 21 Jun 2008 18:41:39 +0200
4 with hMailServer ; Sat, 21 Jun 2008 18:41:39 +0200
5 Message-ID: <006a01c8d3bd$ad9baec0$0a00a8c0@osiris>
5 Message-ID: <006a01c8d3bd$ad9baec0$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 References: <485d0ad366c88_d7014663a025f@osiris.tmail>
8 References: <485d0ad366c88_d7014663a025f@osiris.tmail>
9 Subject: Re: [Cookbook - Feature #2] (New) Add ingredients categories
9 Subject: Re: [Cookbook - Feature #2] (New) Add ingredients categories
10 Date: Sat, 21 Jun 2008 18:41:39 +0200
10 Date: Sat, 21 Jun 2008 18:41:39 +0200
11 MIME-Version: 1.0
11 MIME-Version: 1.0
12 Content-Type: multipart/alternative;
12 Content-Type: multipart/alternative;
13 boundary="----=_NextPart_000_0067_01C8D3CE.711F9CC0"
13 boundary="----=_NextPart_000_0067_01C8D3CE.711F9CC0"
14 X-Priority: 3
14 X-Priority: 3
15 X-MSMail-Priority: Normal
15 X-MSMail-Priority: Normal
16 X-Mailer: Microsoft Outlook Express 6.00.2900.2869
16 X-Mailer: Microsoft Outlook Express 6.00.2900.2869
17 X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.2869
17 X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.2869
18
18
19 This is a multi-part message in MIME format.
19 This is a multi-part message in MIME format.
20
20
21 ------=_NextPart_000_0067_01C8D3CE.711F9CC0
21 ------=_NextPart_000_0067_01C8D3CE.711F9CC0
22 Content-Type: text/plain;
22 Content-Type: text/plain;
23 charset="utf-8"
23 charset="utf-8"
24 Content-Transfer-Encoding: quoted-printable
24 Content-Transfer-Encoding: quoted-printable
25
25
26 This is reply
26 This is reply
27
27
28 Status: Resolved
28 Status: Resolved
29 due date: 2010-12-31
29 due date: 2010-12-31
30 Start Date:2010-01-01
30 Start Date:2010-01-01
31 Assigned to: jsmith@somenet.foo
31 Assigned to: jsmith@somenet.foo
32 searchable field: Updated custom value
32
33
33 ------=_NextPart_000_0067_01C8D3CE.711F9CC0
34 ------=_NextPart_000_0067_01C8D3CE.711F9CC0
34 Content-Type: text/html;
35 Content-Type: text/html;
35 charset="utf-8"
36 charset="utf-8"
36 Content-Transfer-Encoding: quoted-printable
37 Content-Transfer-Encoding: quoted-printable
37
38
38 =EF=BB=BF<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
39 =EF=BB=BF<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
39 <HTML><HEAD>
40 <HTML><HEAD>
40 <META http-equiv=3DContent-Type content=3D"text/html; charset=3Dutf-8">
41 <META http-equiv=3DContent-Type content=3D"text/html; charset=3Dutf-8">
41 <STYLE>BODY {
42 <STYLE>BODY {
42 FONT-SIZE: 0.8em; COLOR: #484848; FONT-FAMILY: Verdana, sans-serif
43 FONT-SIZE: 0.8em; COLOR: #484848; FONT-FAMILY: Verdana, sans-serif
43 }
44 }
44 BODY H1 {
45 BODY H1 {
45 FONT-SIZE: 1.2em; MARGIN: 0px; FONT-FAMILY: "Trebuchet MS", Verdana, =
46 FONT-SIZE: 1.2em; MARGIN: 0px; FONT-FAMILY: "Trebuchet MS", Verdana, =
46 sans-serif
47 sans-serif
47 }
48 }
48 A {
49 A {
49 COLOR: #2a5685
50 COLOR: #2a5685
50 }
51 }
51 A:link {
52 A:link {
52 COLOR: #2a5685
53 COLOR: #2a5685
53 }
54 }
54 A:visited {
55 A:visited {
55 COLOR: #2a5685
56 COLOR: #2a5685
56 }
57 }
57 A:hover {
58 A:hover {
58 COLOR: #c61a1a
59 COLOR: #c61a1a
59 }
60 }
60 A:active {
61 A:active {
61 COLOR: #c61a1a
62 COLOR: #c61a1a
62 }
63 }
63 HR {
64 HR {
64 BORDER-RIGHT: 0px; BORDER-TOP: 0px; BACKGROUND: #ccc; BORDER-LEFT: 0px; =
65 BORDER-RIGHT: 0px; BORDER-TOP: 0px; BACKGROUND: #ccc; BORDER-LEFT: 0px; =
65 WIDTH: 100%; BORDER-BOTTOM: 0px; HEIGHT: 1px
66 WIDTH: 100%; BORDER-BOTTOM: 0px; HEIGHT: 1px
66 }
67 }
67 .footer {
68 .footer {
68 FONT-SIZE: 0.8em; FONT-STYLE: italic
69 FONT-SIZE: 0.8em; FONT-STYLE: italic
69 }
70 }
70 </STYLE>
71 </STYLE>
71
72
72 <META content=3D"MSHTML 6.00.2900.2883" name=3DGENERATOR></HEAD>
73 <META content=3D"MSHTML 6.00.2900.2883" name=3DGENERATOR></HEAD>
73 <BODY bgColor=3D#ffffff>
74 <BODY bgColor=3D#ffffff>
74 <DIV><SPAN class=3Dfooter><FONT face=3DArial color=3D#000000 =
75 <DIV><SPAN class=3Dfooter><FONT face=3DArial color=3D#000000 =
75 size=3D2>This is=20
76 size=3D2>This is=20
76 reply Status: Resolved</FONT></DIV></SPAN></BODY></HTML>
77 reply Status: Resolved</FONT></DIV></SPAN></BODY></HTML>
77
78
78 ------=_NextPart_000_0067_01C8D3CE.711F9CC0--
79 ------=_NextPart_000_0067_01C8D3CE.711F9CC0--
79
80
@@ -1,405 +1,410
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # Redmine - project management software
3 # Redmine - project management software
4 # Copyright (C) 2006-2009 Jean-Philippe Lang
4 # Copyright (C) 2006-2009 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.dirname(__FILE__) + '/../test_helper'
20 require File.dirname(__FILE__) + '/../test_helper'
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 :issues,
28 :issues,
29 :issue_statuses,
29 :issue_statuses,
30 :workflows,
30 :workflows,
31 :trackers,
31 :trackers,
32 :projects_trackers,
32 :projects_trackers,
33 :versions,
33 :enumerations,
34 :enumerations,
34 :issue_categories,
35 :issue_categories,
35 :custom_fields,
36 :custom_fields,
36 :custom_fields_trackers,
37 :custom_fields_trackers,
37 :boards,
38 :boards,
38 :messages
39 :messages
39
40
40 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
41 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
41
42
42 def setup
43 def setup
43 ActionMailer::Base.deliveries.clear
44 ActionMailer::Base.deliveries.clear
44 Setting.notified_events = Redmine::Notifiable.all.collect(&:name)
45 Setting.notified_events = Redmine::Notifiable.all.collect(&:name)
45 end
46 end
46
47
47 def test_add_issue
48 def test_add_issue
48 ActionMailer::Base.deliveries.clear
49 ActionMailer::Base.deliveries.clear
49 # This email contains: 'Project: onlinestore'
50 # This email contains: 'Project: onlinestore'
50 issue = submit_email('ticket_on_given_project.eml')
51 issue = submit_email('ticket_on_given_project.eml')
51 assert issue.is_a?(Issue)
52 assert issue.is_a?(Issue)
52 assert !issue.new_record?
53 assert !issue.new_record?
53 issue.reload
54 issue.reload
54 assert_equal 'New ticket on a given project', issue.subject
55 assert_equal 'New ticket on a given project', issue.subject
55 assert_equal User.find_by_login('jsmith'), issue.author
56 assert_equal User.find_by_login('jsmith'), issue.author
56 assert_equal Project.find(2), issue.project
57 assert_equal Project.find(2), issue.project
57 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
58 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
58 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
59 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
59 assert_equal '2010-01-01', issue.start_date.to_s
60 assert_equal '2010-01-01', issue.start_date.to_s
60 assert_equal '2010-12-31', issue.due_date.to_s
61 assert_equal '2010-12-31', issue.due_date.to_s
61 assert_equal User.find_by_login('jsmith'), issue.assigned_to
62 assert_equal User.find_by_login('jsmith'), issue.assigned_to
63 assert_equal Version.find_by_name('alpha'), issue.fixed_version
64 assert_equal 2.5, issue.estimated_hours
65 assert_equal 30, issue.done_ratio
62 # keywords should be removed from the email body
66 # keywords should be removed from the email body
63 assert !issue.description.match(/^Project:/i)
67 assert !issue.description.match(/^Project:/i)
64 assert !issue.description.match(/^Status:/i)
68 assert !issue.description.match(/^Status:/i)
65 # Email notification should be sent
69 # Email notification should be sent
66 mail = ActionMailer::Base.deliveries.last
70 mail = ActionMailer::Base.deliveries.last
67 assert_not_nil mail
71 assert_not_nil mail
68 assert mail.subject.include?('New ticket on a given project')
72 assert mail.subject.include?('New ticket on a given project')
69 end
73 end
70
74
71 def test_add_issue_with_status
75 def test_add_issue_with_status
72 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
76 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
73 issue = submit_email('ticket_on_given_project.eml')
77 issue = submit_email('ticket_on_given_project.eml')
74 assert issue.is_a?(Issue)
78 assert issue.is_a?(Issue)
75 assert !issue.new_record?
79 assert !issue.new_record?
76 issue.reload
80 issue.reload
77 assert_equal Project.find(2), issue.project
81 assert_equal Project.find(2), issue.project
78 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
82 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
79 end
83 end
80
84
81 def test_add_issue_with_attributes_override
85 def test_add_issue_with_attributes_override
82 issue = submit_email('ticket_with_attributes.eml', :allow_override => 'tracker,category,priority')
86 issue = submit_email('ticket_with_attributes.eml', :allow_override => 'tracker,category,priority')
83 assert issue.is_a?(Issue)
87 assert issue.is_a?(Issue)
84 assert !issue.new_record?
88 assert !issue.new_record?
85 issue.reload
89 issue.reload
86 assert_equal 'New ticket on a given project', issue.subject
90 assert_equal 'New ticket on a given project', issue.subject
87 assert_equal User.find_by_login('jsmith'), issue.author
91 assert_equal User.find_by_login('jsmith'), issue.author
88 assert_equal Project.find(2), issue.project
92 assert_equal Project.find(2), issue.project
89 assert_equal 'Feature request', issue.tracker.to_s
93 assert_equal 'Feature request', issue.tracker.to_s
90 assert_equal 'Stock management', issue.category.to_s
94 assert_equal 'Stock management', issue.category.to_s
91 assert_equal 'Urgent', issue.priority.to_s
95 assert_equal 'Urgent', issue.priority.to_s
92 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
96 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
93 end
97 end
94
98
95 def test_add_issue_with_partial_attributes_override
99 def test_add_issue_with_partial_attributes_override
96 issue = submit_email('ticket_with_attributes.eml', :issue => {:priority => 'High'}, :allow_override => ['tracker'])
100 issue = submit_email('ticket_with_attributes.eml', :issue => {:priority => 'High'}, :allow_override => ['tracker'])
97 assert issue.is_a?(Issue)
101 assert issue.is_a?(Issue)
98 assert !issue.new_record?
102 assert !issue.new_record?
99 issue.reload
103 issue.reload
100 assert_equal 'New ticket on a given project', issue.subject
104 assert_equal 'New ticket on a given project', issue.subject
101 assert_equal User.find_by_login('jsmith'), issue.author
105 assert_equal User.find_by_login('jsmith'), issue.author
102 assert_equal Project.find(2), issue.project
106 assert_equal Project.find(2), issue.project
103 assert_equal 'Feature request', issue.tracker.to_s
107 assert_equal 'Feature request', issue.tracker.to_s
104 assert_nil issue.category
108 assert_nil issue.category
105 assert_equal 'High', issue.priority.to_s
109 assert_equal 'High', issue.priority.to_s
106 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.')
107 end
111 end
108
112
109 def test_add_issue_with_spaces_between_attribute_and_separator
113 def test_add_issue_with_spaces_between_attribute_and_separator
110 issue = submit_email('ticket_with_spaces_between_attribute_and_separator.eml', :allow_override => 'tracker,category,priority')
114 issue = submit_email('ticket_with_spaces_between_attribute_and_separator.eml', :allow_override => 'tracker,category,priority')
111 assert issue.is_a?(Issue)
115 assert issue.is_a?(Issue)
112 assert !issue.new_record?
116 assert !issue.new_record?
113 issue.reload
117 issue.reload
114 assert_equal 'New ticket on a given project', issue.subject
118 assert_equal 'New ticket on a given project', issue.subject
115 assert_equal User.find_by_login('jsmith'), issue.author
119 assert_equal User.find_by_login('jsmith'), issue.author
116 assert_equal Project.find(2), issue.project
120 assert_equal Project.find(2), issue.project
117 assert_equal 'Feature request', issue.tracker.to_s
121 assert_equal 'Feature request', issue.tracker.to_s
118 assert_equal 'Stock management', issue.category.to_s
122 assert_equal 'Stock management', issue.category.to_s
119 assert_equal 'Urgent', issue.priority.to_s
123 assert_equal 'Urgent', issue.priority.to_s
120 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.')
121 end
125 end
122
126
123
127
124 def test_add_issue_with_attachment_to_specific_project
128 def test_add_issue_with_attachment_to_specific_project
125 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
129 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
126 assert issue.is_a?(Issue)
130 assert issue.is_a?(Issue)
127 assert !issue.new_record?
131 assert !issue.new_record?
128 issue.reload
132 issue.reload
129 assert_equal 'Ticket created by email with attachment', issue.subject
133 assert_equal 'Ticket created by email with attachment', issue.subject
130 assert_equal User.find_by_login('jsmith'), issue.author
134 assert_equal User.find_by_login('jsmith'), issue.author
131 assert_equal Project.find(2), issue.project
135 assert_equal Project.find(2), issue.project
132 assert_equal 'This is a new ticket with attachments', issue.description
136 assert_equal 'This is a new ticket with attachments', issue.description
133 # Attachment properties
137 # Attachment properties
134 assert_equal 1, issue.attachments.size
138 assert_equal 1, issue.attachments.size
135 assert_equal 'Paella.jpg', issue.attachments.first.filename
139 assert_equal 'Paella.jpg', issue.attachments.first.filename
136 assert_equal 'image/jpeg', issue.attachments.first.content_type
140 assert_equal 'image/jpeg', issue.attachments.first.content_type
137 assert_equal 10790, issue.attachments.first.filesize
141 assert_equal 10790, issue.attachments.first.filesize
138 end
142 end
139
143
140 def test_add_issue_with_custom_fields
144 def test_add_issue_with_custom_fields
141 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
145 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
142 assert issue.is_a?(Issue)
146 assert issue.is_a?(Issue)
143 assert !issue.new_record?
147 assert !issue.new_record?
144 issue.reload
148 issue.reload
145 assert_equal 'New ticket with custom field values', issue.subject
149 assert_equal 'New ticket with custom field values', issue.subject
146 assert_equal 'Value for a custom field', issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
150 assert_equal 'Value for a custom field', issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
147 assert !issue.description.match(/^searchable field:/i)
151 assert !issue.description.match(/^searchable field:/i)
148 end
152 end
149
153
150 def test_add_issue_with_cc
154 def test_add_issue_with_cc
151 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
155 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
152 assert issue.is_a?(Issue)
156 assert issue.is_a?(Issue)
153 assert !issue.new_record?
157 assert !issue.new_record?
154 issue.reload
158 issue.reload
155 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
159 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
156 assert_equal 1, issue.watcher_user_ids.size
160 assert_equal 1, issue.watcher_user_ids.size
157 end
161 end
158
162
159 def test_add_issue_by_unknown_user
163 def test_add_issue_by_unknown_user
160 assert_no_difference 'User.count' do
164 assert_no_difference 'User.count' do
161 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'})
165 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'})
162 end
166 end
163 end
167 end
164
168
165 def test_add_issue_by_anonymous_user
169 def test_add_issue_by_anonymous_user
166 Role.anonymous.add_permission!(:add_issues)
170 Role.anonymous.add_permission!(:add_issues)
167 assert_no_difference 'User.count' do
171 assert_no_difference 'User.count' do
168 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
172 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
169 assert issue.is_a?(Issue)
173 assert issue.is_a?(Issue)
170 assert issue.author.anonymous?
174 assert issue.author.anonymous?
171 end
175 end
172 end
176 end
173
177
174 def test_add_issue_by_anonymous_user_with_no_from_address
178 def test_add_issue_by_anonymous_user_with_no_from_address
175 Role.anonymous.add_permission!(:add_issues)
179 Role.anonymous.add_permission!(:add_issues)
176 assert_no_difference 'User.count' do
180 assert_no_difference 'User.count' do
177 issue = submit_email('ticket_by_empty_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
181 issue = submit_email('ticket_by_empty_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
178 assert issue.is_a?(Issue)
182 assert issue.is_a?(Issue)
179 assert issue.author.anonymous?
183 assert issue.author.anonymous?
180 end
184 end
181 end
185 end
182
186
183 def test_add_issue_by_anonymous_user_on_private_project
187 def test_add_issue_by_anonymous_user_on_private_project
184 Role.anonymous.add_permission!(:add_issues)
188 Role.anonymous.add_permission!(:add_issues)
185 assert_no_difference 'User.count' do
189 assert_no_difference 'User.count' do
186 assert_no_difference 'Issue.count' do
190 assert_no_difference 'Issue.count' do
187 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :unknown_user => 'accept')
191 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :unknown_user => 'accept')
188 end
192 end
189 end
193 end
190 end
194 end
191
195
192 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
196 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
193 assert_no_difference 'User.count' do
197 assert_no_difference 'User.count' do
194 assert_difference 'Issue.count' do
198 assert_difference 'Issue.count' do
195 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :no_permission_check => '1', :unknown_user => 'accept')
199 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :no_permission_check => '1', :unknown_user => 'accept')
196 assert issue.is_a?(Issue)
200 assert issue.is_a?(Issue)
197 assert issue.author.anonymous?
201 assert issue.author.anonymous?
198 assert !issue.project.is_public?
202 assert !issue.project.is_public?
199 end
203 end
200 end
204 end
201 end
205 end
202
206
203 def test_add_issue_by_created_user
207 def test_add_issue_by_created_user
204 Setting.default_language = 'en'
208 Setting.default_language = 'en'
205 assert_difference 'User.count' do
209 assert_difference 'User.count' do
206 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
210 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
207 assert issue.is_a?(Issue)
211 assert issue.is_a?(Issue)
208 assert issue.author.active?
212 assert issue.author.active?
209 assert_equal 'john.doe@somenet.foo', issue.author.mail
213 assert_equal 'john.doe@somenet.foo', issue.author.mail
210 assert_equal 'John', issue.author.firstname
214 assert_equal 'John', issue.author.firstname
211 assert_equal 'Doe', issue.author.lastname
215 assert_equal 'Doe', issue.author.lastname
212
216
213 # account information
217 # account information
214 email = ActionMailer::Base.deliveries.first
218 email = ActionMailer::Base.deliveries.first
215 assert_not_nil email
219 assert_not_nil email
216 assert email.subject.include?('account activation')
220 assert email.subject.include?('account activation')
217 login = email.body.match(/\* Login: (.*)$/)[1]
221 login = email.body.match(/\* Login: (.*)$/)[1]
218 password = email.body.match(/\* Password: (.*)$/)[1]
222 password = email.body.match(/\* Password: (.*)$/)[1]
219 assert_equal issue.author, User.try_to_login(login, password)
223 assert_equal issue.author, User.try_to_login(login, password)
220 end
224 end
221 end
225 end
222
226
223 def test_add_issue_without_from_header
227 def test_add_issue_without_from_header
224 Role.anonymous.add_permission!(:add_issues)
228 Role.anonymous.add_permission!(:add_issues)
225 assert_equal false, submit_email('ticket_without_from_header.eml')
229 assert_equal false, submit_email('ticket_without_from_header.eml')
226 end
230 end
227
231
228 def test_add_issue_with_japanese_keywords
232 def test_add_issue_with_japanese_keywords
229 tracker = Tracker.create!(:name => 'ι–‹η™Ί')
233 tracker = Tracker.create!(:name => 'ι–‹η™Ί')
230 Project.find(1).trackers << tracker
234 Project.find(1).trackers << tracker
231 issue = submit_email('japanese_keywords_iso_2022_jp.eml', :issue => {:project => 'ecookbook'}, :allow_override => 'tracker')
235 issue = submit_email('japanese_keywords_iso_2022_jp.eml', :issue => {:project => 'ecookbook'}, :allow_override => 'tracker')
232 assert_kind_of Issue, issue
236 assert_kind_of Issue, issue
233 assert_equal tracker, issue.tracker
237 assert_equal tracker, issue.tracker
234 end
238 end
235
239
236 def test_should_ignore_emails_from_emission_address
240 def test_should_ignore_emails_from_emission_address
237 Role.anonymous.add_permission!(:add_issues)
241 Role.anonymous.add_permission!(:add_issues)
238 assert_no_difference 'User.count' do
242 assert_no_difference 'User.count' do
239 assert_equal false, submit_email('ticket_from_emission_address.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
243 assert_equal false, submit_email('ticket_from_emission_address.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
240 end
244 end
241 end
245 end
242
246
243 def test_add_issue_should_send_email_notification
247 def test_add_issue_should_send_email_notification
244 Setting.notified_events = ['issue_added']
248 Setting.notified_events = ['issue_added']
245 ActionMailer::Base.deliveries.clear
249 ActionMailer::Base.deliveries.clear
246 # This email contains: 'Project: onlinestore'
250 # This email contains: 'Project: onlinestore'
247 issue = submit_email('ticket_on_given_project.eml')
251 issue = submit_email('ticket_on_given_project.eml')
248 assert issue.is_a?(Issue)
252 assert issue.is_a?(Issue)
249 assert_equal 1, ActionMailer::Base.deliveries.size
253 assert_equal 1, ActionMailer::Base.deliveries.size
250 end
254 end
251
255
252 def test_add_issue_note
256 def test_add_issue_note
253 journal = submit_email('ticket_reply.eml')
257 journal = submit_email('ticket_reply.eml')
254 assert journal.is_a?(Journal)
258 assert journal.is_a?(Journal)
255 assert_equal User.find_by_login('jsmith'), journal.user
259 assert_equal User.find_by_login('jsmith'), journal.user
256 assert_equal Issue.find(2), journal.journalized
260 assert_equal Issue.find(2), journal.journalized
257 assert_match /This is reply/, journal.notes
261 assert_match /This is reply/, journal.notes
258 end
262 end
259
263
260 def test_add_issue_note_with_attribute_changes
264 def test_add_issue_note_with_attribute_changes
261 # This email contains: 'Status: Resolved'
265 # This email contains: 'Status: Resolved'
262 journal = submit_email('ticket_reply_with_status.eml')
266 journal = submit_email('ticket_reply_with_status.eml')
263 assert journal.is_a?(Journal)
267 assert journal.is_a?(Journal)
264 issue = Issue.find(journal.issue.id)
268 issue = Issue.find(journal.issue.id)
265 assert_equal User.find_by_login('jsmith'), journal.user
269 assert_equal User.find_by_login('jsmith'), journal.user
266 assert_equal Issue.find(2), journal.journalized
270 assert_equal Issue.find(2), journal.journalized
267 assert_match /This is reply/, journal.notes
271 assert_match /This is reply/, journal.notes
268 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
272 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
269 assert_equal '2010-01-01', issue.start_date.to_s
273 assert_equal '2010-01-01', issue.start_date.to_s
270 assert_equal '2010-12-31', issue.due_date.to_s
274 assert_equal '2010-12-31', issue.due_date.to_s
271 assert_equal User.find_by_login('jsmith'), issue.assigned_to
275 assert_equal User.find_by_login('jsmith'), issue.assigned_to
276 assert_equal 'Updated custom value', issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
272 end
277 end
273
278
274 def test_add_issue_note_should_send_email_notification
279 def test_add_issue_note_should_send_email_notification
275 ActionMailer::Base.deliveries.clear
280 ActionMailer::Base.deliveries.clear
276 journal = submit_email('ticket_reply.eml')
281 journal = submit_email('ticket_reply.eml')
277 assert journal.is_a?(Journal)
282 assert journal.is_a?(Journal)
278 assert_equal 1, ActionMailer::Base.deliveries.size
283 assert_equal 1, ActionMailer::Base.deliveries.size
279 end
284 end
280
285
281 def test_reply_to_a_message
286 def test_reply_to_a_message
282 m = submit_email('message_reply.eml')
287 m = submit_email('message_reply.eml')
283 assert m.is_a?(Message)
288 assert m.is_a?(Message)
284 assert !m.new_record?
289 assert !m.new_record?
285 m.reload
290 m.reload
286 assert_equal 'Reply via email', m.subject
291 assert_equal 'Reply via email', m.subject
287 # The email replies to message #2 which is part of the thread of message #1
292 # The email replies to message #2 which is part of the thread of message #1
288 assert_equal Message.find(1), m.parent
293 assert_equal Message.find(1), m.parent
289 end
294 end
290
295
291 def test_reply_to_a_message_by_subject
296 def test_reply_to_a_message_by_subject
292 m = submit_email('message_reply_by_subject.eml')
297 m = submit_email('message_reply_by_subject.eml')
293 assert m.is_a?(Message)
298 assert m.is_a?(Message)
294 assert !m.new_record?
299 assert !m.new_record?
295 m.reload
300 m.reload
296 assert_equal 'Reply to the first post', m.subject
301 assert_equal 'Reply to the first post', m.subject
297 assert_equal Message.find(1), m.parent
302 assert_equal Message.find(1), m.parent
298 end
303 end
299
304
300 def test_should_strip_tags_of_html_only_emails
305 def test_should_strip_tags_of_html_only_emails
301 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
306 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
302 assert issue.is_a?(Issue)
307 assert issue.is_a?(Issue)
303 assert !issue.new_record?
308 assert !issue.new_record?
304 issue.reload
309 issue.reload
305 assert_equal 'HTML email', issue.subject
310 assert_equal 'HTML email', issue.subject
306 assert_equal 'This is a html-only email.', issue.description
311 assert_equal 'This is a html-only email.', issue.description
307 end
312 end
308
313
309 context "truncate emails based on the Setting" do
314 context "truncate emails based on the Setting" do
310 context "with no setting" do
315 context "with no setting" do
311 setup do
316 setup do
312 Setting.mail_handler_body_delimiters = ''
317 Setting.mail_handler_body_delimiters = ''
313 end
318 end
314
319
315 should "add the entire email into the issue" do
320 should "add the entire email into the issue" do
316 issue = submit_email('ticket_on_given_project.eml')
321 issue = submit_email('ticket_on_given_project.eml')
317 assert_issue_created(issue)
322 assert_issue_created(issue)
318 assert issue.description.include?('---')
323 assert issue.description.include?('---')
319 assert issue.description.include?('This paragraph is after the delimiter')
324 assert issue.description.include?('This paragraph is after the delimiter')
320 end
325 end
321 end
326 end
322
327
323 context "with a single string" do
328 context "with a single string" do
324 setup do
329 setup do
325 Setting.mail_handler_body_delimiters = '---'
330 Setting.mail_handler_body_delimiters = '---'
326 end
331 end
327
332
328 should "truncate the email at the delimiter for the issue" do
333 should "truncate the email at the delimiter for the issue" do
329 issue = submit_email('ticket_on_given_project.eml')
334 issue = submit_email('ticket_on_given_project.eml')
330 assert_issue_created(issue)
335 assert_issue_created(issue)
331 assert issue.description.include?('This paragraph is before delimiters')
336 assert issue.description.include?('This paragraph is before delimiters')
332 assert issue.description.include?('--- This line starts with a delimiter')
337 assert issue.description.include?('--- This line starts with a delimiter')
333 assert !issue.description.match(/^---$/)
338 assert !issue.description.match(/^---$/)
334 assert !issue.description.include?('This paragraph is after the delimiter')
339 assert !issue.description.include?('This paragraph is after the delimiter')
335 end
340 end
336 end
341 end
337
342
338 context "with a single quoted reply (e.g. reply to a Redmine email notification)" do
343 context "with a single quoted reply (e.g. reply to a Redmine email notification)" do
339 setup do
344 setup do
340 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
345 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
341 end
346 end
342
347
343 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
348 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
344 journal = submit_email('issue_update_with_quoted_reply_above.eml')
349 journal = submit_email('issue_update_with_quoted_reply_above.eml')
345 assert journal.is_a?(Journal)
350 assert journal.is_a?(Journal)
346 assert journal.notes.include?('An update to the issue by the sender.')
351 assert journal.notes.include?('An update to the issue by the sender.')
347 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
352 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
348 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
353 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
349
354
350 end
355 end
351
356
352 end
357 end
353
358
354 context "with multiple quoted replies (e.g. reply to a reply of a Redmine email notification)" do
359 context "with multiple quoted replies (e.g. reply to a reply of a Redmine email notification)" do
355 setup do
360 setup do
356 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
361 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
357 end
362 end
358
363
359 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
364 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
360 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
365 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
361 assert journal.is_a?(Journal)
366 assert journal.is_a?(Journal)
362 assert journal.notes.include?('An update to the issue by the sender.')
367 assert journal.notes.include?('An update to the issue by the sender.')
363 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
368 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
364 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
369 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
365
370
366 end
371 end
367
372
368 end
373 end
369
374
370 context "with multiple strings" do
375 context "with multiple strings" do
371 setup do
376 setup do
372 Setting.mail_handler_body_delimiters = "---\nBREAK"
377 Setting.mail_handler_body_delimiters = "---\nBREAK"
373 end
378 end
374
379
375 should "truncate the email at the first delimiter found (BREAK)" do
380 should "truncate the email at the first delimiter found (BREAK)" do
376 issue = submit_email('ticket_on_given_project.eml')
381 issue = submit_email('ticket_on_given_project.eml')
377 assert_issue_created(issue)
382 assert_issue_created(issue)
378 assert issue.description.include?('This paragraph is before delimiters')
383 assert issue.description.include?('This paragraph is before delimiters')
379 assert !issue.description.include?('BREAK')
384 assert !issue.description.include?('BREAK')
380 assert !issue.description.include?('This paragraph is between delimiters')
385 assert !issue.description.include?('This paragraph is between delimiters')
381 assert !issue.description.match(/^---$/)
386 assert !issue.description.match(/^---$/)
382 assert !issue.description.include?('This paragraph is after the delimiter')
387 assert !issue.description.include?('This paragraph is after the delimiter')
383 end
388 end
384 end
389 end
385 end
390 end
386
391
387 def test_email_with_long_subject_line
392 def test_email_with_long_subject_line
388 issue = submit_email('ticket_with_long_subject.eml')
393 issue = submit_email('ticket_with_long_subject.eml')
389 assert issue.is_a?(Issue)
394 assert issue.is_a?(Issue)
390 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]
395 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]
391 end
396 end
392
397
393 private
398 private
394
399
395 def submit_email(filename, options={})
400 def submit_email(filename, options={})
396 raw = IO.read(File.join(FIXTURES_PATH, filename))
401 raw = IO.read(File.join(FIXTURES_PATH, filename))
397 MailHandler.receive(raw, options)
402 MailHandler.receive(raw, options)
398 end
403 end
399
404
400 def assert_issue_created(issue)
405 def assert_issue_created(issue)
401 assert issue.is_a?(Issue)
406 assert issue.is_a?(Issue)
402 assert !issue.new_record?
407 assert !issue.new_record?
403 issue.reload
408 issue.reload
404 end
409 end
405 end
410 end
General Comments 0
You need to be logged in to leave comments. Login now