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