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