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