##// END OF EJS Templates
Fixed that issue nested set update is triggered even if parent is not changed (#15135)....
Jean-Philippe Lang -
r11996:99bf8c95aba2
parent child
Show More
@@ -1,1534 +1,1543
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2013 Jean-Philippe Lang
2 # Copyright (C) 2006-2013 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 include Redmine::Utils::DateCalculation
20 include Redmine::Utils::DateCalculation
21 include Redmine::I18n
21 include Redmine::I18n
22
22
23 belongs_to :project
23 belongs_to :project
24 belongs_to :tracker
24 belongs_to :tracker
25 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
25 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
26 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
26 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
27 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
27 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
28 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
28 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
29 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
29 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
30 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
30 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
31
31
32 has_many :journals, :as => :journalized, :dependent => :destroy
32 has_many :journals, :as => :journalized, :dependent => :destroy
33 has_many :visible_journals,
33 has_many :visible_journals,
34 :class_name => 'Journal',
34 :class_name => 'Journal',
35 :as => :journalized,
35 :as => :journalized,
36 :conditions => Proc.new {
36 :conditions => Proc.new {
37 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
37 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
38 },
38 },
39 :readonly => true
39 :readonly => true
40
40
41 has_many :time_entries, :dependent => :delete_all
41 has_many :time_entries, :dependent => :delete_all
42 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
42 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
43
43
44 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
45 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
46
46
47 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
47 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
48 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
48 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
49 acts_as_customizable
49 acts_as_customizable
50 acts_as_watchable
50 acts_as_watchable
51 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
51 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
52 :include => [:project, :visible_journals],
52 :include => [:project, :visible_journals],
53 # sort by id so that limited eager loading doesn't break with postgresql
53 # sort by id so that limited eager loading doesn't break with postgresql
54 :order_column => "#{table_name}.id"
54 :order_column => "#{table_name}.id"
55 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
55 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
56 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
56 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
57 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
57 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
58
58
59 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
59 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
60 :author_key => :author_id
60 :author_key => :author_id
61
61
62 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
62 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
63
63
64 attr_reader :current_journal
64 attr_reader :current_journal
65 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
65 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
66
66
67 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
67 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
68
68
69 validates_length_of :subject, :maximum => 255
69 validates_length_of :subject, :maximum => 255
70 validates_inclusion_of :done_ratio, :in => 0..100
70 validates_inclusion_of :done_ratio, :in => 0..100
71 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
71 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
72 validates :start_date, :date => true
72 validates :start_date, :date => true
73 validates :due_date, :date => true
73 validates :due_date, :date => true
74 validate :validate_issue, :validate_required_fields
74 validate :validate_issue, :validate_required_fields
75
75
76 scope :visible, lambda {|*args|
76 scope :visible, lambda {|*args|
77 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
77 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
78 }
78 }
79
79
80 scope :open, lambda {|*args|
80 scope :open, lambda {|*args|
81 is_closed = args.size > 0 ? !args.first : false
81 is_closed = args.size > 0 ? !args.first : false
82 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
82 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
83 }
83 }
84
84
85 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
85 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
86 scope :on_active_project, lambda {
86 scope :on_active_project, lambda {
87 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
87 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
88 }
88 }
89 scope :fixed_version, lambda {|versions|
89 scope :fixed_version, lambda {|versions|
90 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
90 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
91 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
91 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
92 }
92 }
93
93
94 before_create :default_assign
94 before_create :default_assign
95 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change, :update_closed_on
95 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change, :update_closed_on
96 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
96 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
97 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
97 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
98 # Should be after_create but would be called before previous after_save callbacks
98 # Should be after_create but would be called before previous after_save callbacks
99 after_save :after_create_from_copy
99 after_save :after_create_from_copy
100 after_destroy :update_parent_attributes
100 after_destroy :update_parent_attributes
101 after_create :send_notification
101 after_create :send_notification
102
102
103 # Returns a SQL conditions string used to find all issues visible by the specified user
103 # Returns a SQL conditions string used to find all issues visible by the specified user
104 def self.visible_condition(user, options={})
104 def self.visible_condition(user, options={})
105 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
105 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
106 if user.logged?
106 if user.logged?
107 case role.issues_visibility
107 case role.issues_visibility
108 when 'all'
108 when 'all'
109 nil
109 nil
110 when 'default'
110 when 'default'
111 user_ids = [user.id] + user.groups.map(&:id).compact
111 user_ids = [user.id] + user.groups.map(&:id).compact
112 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
112 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
113 when 'own'
113 when 'own'
114 user_ids = [user.id] + user.groups.map(&:id).compact
114 user_ids = [user.id] + user.groups.map(&:id).compact
115 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
115 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
116 else
116 else
117 '1=0'
117 '1=0'
118 end
118 end
119 else
119 else
120 "(#{table_name}.is_private = #{connection.quoted_false})"
120 "(#{table_name}.is_private = #{connection.quoted_false})"
121 end
121 end
122 end
122 end
123 end
123 end
124
124
125 # Returns true if usr or current user is allowed to view the issue
125 # Returns true if usr or current user is allowed to view the issue
126 def visible?(usr=nil)
126 def visible?(usr=nil)
127 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
127 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
128 if user.logged?
128 if user.logged?
129 case role.issues_visibility
129 case role.issues_visibility
130 when 'all'
130 when 'all'
131 true
131 true
132 when 'default'
132 when 'default'
133 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
133 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
134 when 'own'
134 when 'own'
135 self.author == user || user.is_or_belongs_to?(assigned_to)
135 self.author == user || user.is_or_belongs_to?(assigned_to)
136 else
136 else
137 false
137 false
138 end
138 end
139 else
139 else
140 !self.is_private?
140 !self.is_private?
141 end
141 end
142 end
142 end
143 end
143 end
144
144
145 # Returns true if user or current user is allowed to edit or add a note to the issue
145 # Returns true if user or current user is allowed to edit or add a note to the issue
146 def editable?(user=User.current)
146 def editable?(user=User.current)
147 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
147 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
148 end
148 end
149
149
150 def initialize(attributes=nil, *args)
150 def initialize(attributes=nil, *args)
151 super
151 super
152 if new_record?
152 if new_record?
153 # set default values for new records only
153 # set default values for new records only
154 self.status ||= IssueStatus.default
154 self.status ||= IssueStatus.default
155 self.priority ||= IssuePriority.default
155 self.priority ||= IssuePriority.default
156 self.watcher_user_ids = []
156 self.watcher_user_ids = []
157 end
157 end
158 end
158 end
159
159
160 def create_or_update
160 def create_or_update
161 super
161 super
162 ensure
162 ensure
163 @status_was = nil
163 @status_was = nil
164 end
164 end
165 private :create_or_update
165 private :create_or_update
166
166
167 # AR#Persistence#destroy would raise and RecordNotFound exception
167 # AR#Persistence#destroy would raise and RecordNotFound exception
168 # if the issue was already deleted or updated (non matching lock_version).
168 # if the issue was already deleted or updated (non matching lock_version).
169 # This is a problem when bulk deleting issues or deleting a project
169 # This is a problem when bulk deleting issues or deleting a project
170 # (because an issue may already be deleted if its parent was deleted
170 # (because an issue may already be deleted if its parent was deleted
171 # first).
171 # first).
172 # The issue is reloaded by the nested_set before being deleted so
172 # The issue is reloaded by the nested_set before being deleted so
173 # the lock_version condition should not be an issue but we handle it.
173 # the lock_version condition should not be an issue but we handle it.
174 def destroy
174 def destroy
175 super
175 super
176 rescue ActiveRecord::RecordNotFound
176 rescue ActiveRecord::RecordNotFound
177 # Stale or already deleted
177 # Stale or already deleted
178 begin
178 begin
179 reload
179 reload
180 rescue ActiveRecord::RecordNotFound
180 rescue ActiveRecord::RecordNotFound
181 # The issue was actually already deleted
181 # The issue was actually already deleted
182 @destroyed = true
182 @destroyed = true
183 return freeze
183 return freeze
184 end
184 end
185 # The issue was stale, retry to destroy
185 # The issue was stale, retry to destroy
186 super
186 super
187 end
187 end
188
188
189 alias :base_reload :reload
189 alias :base_reload :reload
190 def reload(*args)
190 def reload(*args)
191 @workflow_rule_by_attribute = nil
191 @workflow_rule_by_attribute = nil
192 @assignable_versions = nil
192 @assignable_versions = nil
193 @relations = nil
193 @relations = nil
194 base_reload(*args)
194 base_reload(*args)
195 end
195 end
196
196
197 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
197 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
198 def available_custom_fields
198 def available_custom_fields
199 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
199 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
200 end
200 end
201
201
202 def visible_custom_field_values(user=nil)
202 def visible_custom_field_values(user=nil)
203 user_real = user || User.current
203 user_real = user || User.current
204 custom_field_values.select do |value|
204 custom_field_values.select do |value|
205 value.custom_field.visible_by?(project, user_real)
205 value.custom_field.visible_by?(project, user_real)
206 end
206 end
207 end
207 end
208
208
209 # Copies attributes from another issue, arg can be an id or an Issue
209 # Copies attributes from another issue, arg can be an id or an Issue
210 def copy_from(arg, options={})
210 def copy_from(arg, options={})
211 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
211 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
212 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
212 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
213 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
213 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
214 self.status = issue.status
214 self.status = issue.status
215 self.author = User.current
215 self.author = User.current
216 unless options[:attachments] == false
216 unless options[:attachments] == false
217 self.attachments = issue.attachments.map do |attachement|
217 self.attachments = issue.attachments.map do |attachement|
218 attachement.copy(:container => self)
218 attachement.copy(:container => self)
219 end
219 end
220 end
220 end
221 @copied_from = issue
221 @copied_from = issue
222 @copy_options = options
222 @copy_options = options
223 self
223 self
224 end
224 end
225
225
226 # Returns an unsaved copy of the issue
226 # Returns an unsaved copy of the issue
227 def copy(attributes=nil, copy_options={})
227 def copy(attributes=nil, copy_options={})
228 copy = self.class.new.copy_from(self, copy_options)
228 copy = self.class.new.copy_from(self, copy_options)
229 copy.attributes = attributes if attributes
229 copy.attributes = attributes if attributes
230 copy
230 copy
231 end
231 end
232
232
233 # Returns true if the issue is a copy
233 # Returns true if the issue is a copy
234 def copy?
234 def copy?
235 @copied_from.present?
235 @copied_from.present?
236 end
236 end
237
237
238 # Moves/copies an issue to a new project and tracker
238 # Moves/copies an issue to a new project and tracker
239 # Returns the moved/copied issue on success, false on failure
239 # Returns the moved/copied issue on success, false on failure
240 def move_to_project(new_project, new_tracker=nil, options={})
240 def move_to_project(new_project, new_tracker=nil, options={})
241 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
241 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
242
242
243 if options[:copy]
243 if options[:copy]
244 issue = self.copy
244 issue = self.copy
245 else
245 else
246 issue = self
246 issue = self
247 end
247 end
248
248
249 issue.init_journal(User.current, options[:notes])
249 issue.init_journal(User.current, options[:notes])
250
250
251 # Preserve previous behaviour
251 # Preserve previous behaviour
252 # #move_to_project doesn't change tracker automatically
252 # #move_to_project doesn't change tracker automatically
253 issue.send :project=, new_project, true
253 issue.send :project=, new_project, true
254 if new_tracker
254 if new_tracker
255 issue.tracker = new_tracker
255 issue.tracker = new_tracker
256 end
256 end
257 # Allow bulk setting of attributes on the issue
257 # Allow bulk setting of attributes on the issue
258 if options[:attributes]
258 if options[:attributes]
259 issue.attributes = options[:attributes]
259 issue.attributes = options[:attributes]
260 end
260 end
261
261
262 issue.save ? issue : false
262 issue.save ? issue : false
263 end
263 end
264
264
265 def status_id=(sid)
265 def status_id=(sid)
266 self.status = nil
266 self.status = nil
267 result = write_attribute(:status_id, sid)
267 result = write_attribute(:status_id, sid)
268 @workflow_rule_by_attribute = nil
268 @workflow_rule_by_attribute = nil
269 result
269 result
270 end
270 end
271
271
272 def priority_id=(pid)
272 def priority_id=(pid)
273 self.priority = nil
273 self.priority = nil
274 write_attribute(:priority_id, pid)
274 write_attribute(:priority_id, pid)
275 end
275 end
276
276
277 def category_id=(cid)
277 def category_id=(cid)
278 self.category = nil
278 self.category = nil
279 write_attribute(:category_id, cid)
279 write_attribute(:category_id, cid)
280 end
280 end
281
281
282 def fixed_version_id=(vid)
282 def fixed_version_id=(vid)
283 self.fixed_version = nil
283 self.fixed_version = nil
284 write_attribute(:fixed_version_id, vid)
284 write_attribute(:fixed_version_id, vid)
285 end
285 end
286
286
287 def tracker_id=(tid)
287 def tracker_id=(tid)
288 self.tracker = nil
288 self.tracker = nil
289 result = write_attribute(:tracker_id, tid)
289 result = write_attribute(:tracker_id, tid)
290 @custom_field_values = nil
290 @custom_field_values = nil
291 @workflow_rule_by_attribute = nil
291 @workflow_rule_by_attribute = nil
292 result
292 result
293 end
293 end
294
294
295 def project_id=(project_id)
295 def project_id=(project_id)
296 if project_id.to_s != self.project_id.to_s
296 if project_id.to_s != self.project_id.to_s
297 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
297 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
298 end
298 end
299 end
299 end
300
300
301 def project=(project, keep_tracker=false)
301 def project=(project, keep_tracker=false)
302 project_was = self.project
302 project_was = self.project
303 write_attribute(:project_id, project ? project.id : nil)
303 write_attribute(:project_id, project ? project.id : nil)
304 association_instance_set('project', project)
304 association_instance_set('project', project)
305 if project_was && project && project_was != project
305 if project_was && project && project_was != project
306 @assignable_versions = nil
306 @assignable_versions = nil
307
307
308 unless keep_tracker || project.trackers.include?(tracker)
308 unless keep_tracker || project.trackers.include?(tracker)
309 self.tracker = project.trackers.first
309 self.tracker = project.trackers.first
310 end
310 end
311 # Reassign to the category with same name if any
311 # Reassign to the category with same name if any
312 if category
312 if category
313 self.category = project.issue_categories.find_by_name(category.name)
313 self.category = project.issue_categories.find_by_name(category.name)
314 end
314 end
315 # Keep the fixed_version if it's still valid in the new_project
315 # Keep the fixed_version if it's still valid in the new_project
316 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
316 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
317 self.fixed_version = nil
317 self.fixed_version = nil
318 end
318 end
319 # Clear the parent task if it's no longer valid
319 # Clear the parent task if it's no longer valid
320 unless valid_parent_project?
320 unless valid_parent_project?
321 self.parent_issue_id = nil
321 self.parent_issue_id = nil
322 end
322 end
323 @custom_field_values = nil
323 @custom_field_values = nil
324 end
324 end
325 end
325 end
326
326
327 def description=(arg)
327 def description=(arg)
328 if arg.is_a?(String)
328 if arg.is_a?(String)
329 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
329 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
330 end
330 end
331 write_attribute(:description, arg)
331 write_attribute(:description, arg)
332 end
332 end
333
333
334 # Overrides assign_attributes so that project and tracker get assigned first
334 # Overrides assign_attributes so that project and tracker get assigned first
335 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
335 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
336 return if new_attributes.nil?
336 return if new_attributes.nil?
337 attrs = new_attributes.dup
337 attrs = new_attributes.dup
338 attrs.stringify_keys!
338 attrs.stringify_keys!
339
339
340 %w(project project_id tracker tracker_id).each do |attr|
340 %w(project project_id tracker tracker_id).each do |attr|
341 if attrs.has_key?(attr)
341 if attrs.has_key?(attr)
342 send "#{attr}=", attrs.delete(attr)
342 send "#{attr}=", attrs.delete(attr)
343 end
343 end
344 end
344 end
345 send :assign_attributes_without_project_and_tracker_first, attrs, *args
345 send :assign_attributes_without_project_and_tracker_first, attrs, *args
346 end
346 end
347 # Do not redefine alias chain on reload (see #4838)
347 # Do not redefine alias chain on reload (see #4838)
348 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
348 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
349
349
350 def estimated_hours=(h)
350 def estimated_hours=(h)
351 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
351 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
352 end
352 end
353
353
354 safe_attributes 'project_id',
354 safe_attributes 'project_id',
355 :if => lambda {|issue, user|
355 :if => lambda {|issue, user|
356 if issue.new_record?
356 if issue.new_record?
357 issue.copy?
357 issue.copy?
358 elsif user.allowed_to?(:move_issues, issue.project)
358 elsif user.allowed_to?(:move_issues, issue.project)
359 Issue.allowed_target_projects_on_move.count > 1
359 Issue.allowed_target_projects_on_move.count > 1
360 end
360 end
361 }
361 }
362
362
363 safe_attributes 'tracker_id',
363 safe_attributes 'tracker_id',
364 'status_id',
364 'status_id',
365 'category_id',
365 'category_id',
366 'assigned_to_id',
366 'assigned_to_id',
367 'priority_id',
367 'priority_id',
368 'fixed_version_id',
368 'fixed_version_id',
369 'subject',
369 'subject',
370 'description',
370 'description',
371 'start_date',
371 'start_date',
372 'due_date',
372 'due_date',
373 'done_ratio',
373 'done_ratio',
374 'estimated_hours',
374 'estimated_hours',
375 'custom_field_values',
375 'custom_field_values',
376 'custom_fields',
376 'custom_fields',
377 'lock_version',
377 'lock_version',
378 'notes',
378 'notes',
379 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
379 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
380
380
381 safe_attributes 'status_id',
381 safe_attributes 'status_id',
382 'assigned_to_id',
382 'assigned_to_id',
383 'fixed_version_id',
383 'fixed_version_id',
384 'done_ratio',
384 'done_ratio',
385 'lock_version',
385 'lock_version',
386 'notes',
386 'notes',
387 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
387 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
388
388
389 safe_attributes 'notes',
389 safe_attributes 'notes',
390 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
390 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
391
391
392 safe_attributes 'private_notes',
392 safe_attributes 'private_notes',
393 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
393 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
394
394
395 safe_attributes 'watcher_user_ids',
395 safe_attributes 'watcher_user_ids',
396 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
396 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
397
397
398 safe_attributes 'is_private',
398 safe_attributes 'is_private',
399 :if => lambda {|issue, user|
399 :if => lambda {|issue, user|
400 user.allowed_to?(:set_issues_private, issue.project) ||
400 user.allowed_to?(:set_issues_private, issue.project) ||
401 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
401 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
402 }
402 }
403
403
404 safe_attributes 'parent_issue_id',
404 safe_attributes 'parent_issue_id',
405 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
405 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
406 user.allowed_to?(:manage_subtasks, issue.project)}
406 user.allowed_to?(:manage_subtasks, issue.project)}
407
407
408 def safe_attribute_names(user=nil)
408 def safe_attribute_names(user=nil)
409 names = super
409 names = super
410 names -= disabled_core_fields
410 names -= disabled_core_fields
411 names -= read_only_attribute_names(user)
411 names -= read_only_attribute_names(user)
412 names
412 names
413 end
413 end
414
414
415 # Safely sets attributes
415 # Safely sets attributes
416 # Should be called from controllers instead of #attributes=
416 # Should be called from controllers instead of #attributes=
417 # attr_accessible is too rough because we still want things like
417 # attr_accessible is too rough because we still want things like
418 # Issue.new(:project => foo) to work
418 # Issue.new(:project => foo) to work
419 def safe_attributes=(attrs, user=User.current)
419 def safe_attributes=(attrs, user=User.current)
420 return unless attrs.is_a?(Hash)
420 return unless attrs.is_a?(Hash)
421
421
422 attrs = attrs.dup
422 attrs = attrs.dup
423
423
424 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
424 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
425 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
425 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
426 if allowed_target_projects(user).where(:id => p.to_i).exists?
426 if allowed_target_projects(user).where(:id => p.to_i).exists?
427 self.project_id = p
427 self.project_id = p
428 end
428 end
429 end
429 end
430
430
431 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
431 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
432 self.tracker_id = t
432 self.tracker_id = t
433 end
433 end
434
434
435 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
435 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
436 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
436 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
437 self.status_id = s
437 self.status_id = s
438 end
438 end
439 end
439 end
440
440
441 attrs = delete_unsafe_attributes(attrs, user)
441 attrs = delete_unsafe_attributes(attrs, user)
442 return if attrs.empty?
442 return if attrs.empty?
443
443
444 unless leaf?
444 unless leaf?
445 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
445 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
446 end
446 end
447
447
448 if attrs['parent_issue_id'].present?
448 if attrs['parent_issue_id'].present?
449 s = attrs['parent_issue_id'].to_s
449 s = attrs['parent_issue_id'].to_s
450 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
450 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
451 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
451 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
452 end
452 end
453 end
453 end
454
454
455 if attrs['custom_field_values'].present?
455 if attrs['custom_field_values'].present?
456 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
456 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
457 # TODO: use #select when ruby1.8 support is dropped
457 # TODO: use #select when ruby1.8 support is dropped
458 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| !editable_custom_field_ids.include?(k.to_s)}
458 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| !editable_custom_field_ids.include?(k.to_s)}
459 end
459 end
460
460
461 if attrs['custom_fields'].present?
461 if attrs['custom_fields'].present?
462 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
462 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
463 # TODO: use #select when ruby1.8 support is dropped
463 # TODO: use #select when ruby1.8 support is dropped
464 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| !editable_custom_field_ids.include?(c['id'].to_s)}
464 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| !editable_custom_field_ids.include?(c['id'].to_s)}
465 end
465 end
466
466
467 # mass-assignment security bypass
467 # mass-assignment security bypass
468 assign_attributes attrs, :without_protection => true
468 assign_attributes attrs, :without_protection => true
469 end
469 end
470
470
471 def disabled_core_fields
471 def disabled_core_fields
472 tracker ? tracker.disabled_core_fields : []
472 tracker ? tracker.disabled_core_fields : []
473 end
473 end
474
474
475 # Returns the custom_field_values that can be edited by the given user
475 # Returns the custom_field_values that can be edited by the given user
476 def editable_custom_field_values(user=nil)
476 def editable_custom_field_values(user=nil)
477 visible_custom_field_values(user).reject do |value|
477 visible_custom_field_values(user).reject do |value|
478 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
478 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
479 end
479 end
480 end
480 end
481
481
482 # Returns the names of attributes that are read-only for user or the current user
482 # Returns the names of attributes that are read-only for user or the current user
483 # For users with multiple roles, the read-only fields are the intersection of
483 # For users with multiple roles, the read-only fields are the intersection of
484 # read-only fields of each role
484 # read-only fields of each role
485 # The result is an array of strings where sustom fields are represented with their ids
485 # The result is an array of strings where sustom fields are represented with their ids
486 #
486 #
487 # Examples:
487 # Examples:
488 # issue.read_only_attribute_names # => ['due_date', '2']
488 # issue.read_only_attribute_names # => ['due_date', '2']
489 # issue.read_only_attribute_names(user) # => []
489 # issue.read_only_attribute_names(user) # => []
490 def read_only_attribute_names(user=nil)
490 def read_only_attribute_names(user=nil)
491 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
491 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
492 end
492 end
493
493
494 # Returns the names of required attributes for user or the current user
494 # Returns the names of required attributes for user or the current user
495 # For users with multiple roles, the required fields are the intersection of
495 # For users with multiple roles, the required fields are the intersection of
496 # required fields of each role
496 # required fields of each role
497 # The result is an array of strings where sustom fields are represented with their ids
497 # The result is an array of strings where sustom fields are represented with their ids
498 #
498 #
499 # Examples:
499 # Examples:
500 # issue.required_attribute_names # => ['due_date', '2']
500 # issue.required_attribute_names # => ['due_date', '2']
501 # issue.required_attribute_names(user) # => []
501 # issue.required_attribute_names(user) # => []
502 def required_attribute_names(user=nil)
502 def required_attribute_names(user=nil)
503 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
503 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
504 end
504 end
505
505
506 # Returns true if the attribute is required for user
506 # Returns true if the attribute is required for user
507 def required_attribute?(name, user=nil)
507 def required_attribute?(name, user=nil)
508 required_attribute_names(user).include?(name.to_s)
508 required_attribute_names(user).include?(name.to_s)
509 end
509 end
510
510
511 # Returns a hash of the workflow rule by attribute for the given user
511 # Returns a hash of the workflow rule by attribute for the given user
512 #
512 #
513 # Examples:
513 # Examples:
514 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
514 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
515 def workflow_rule_by_attribute(user=nil)
515 def workflow_rule_by_attribute(user=nil)
516 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
516 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
517
517
518 user_real = user || User.current
518 user_real = user || User.current
519 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
519 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
520 return {} if roles.empty?
520 return {} if roles.empty?
521
521
522 result = {}
522 result = {}
523 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
523 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
524 if workflow_permissions.any?
524 if workflow_permissions.any?
525 workflow_rules = workflow_permissions.inject({}) do |h, wp|
525 workflow_rules = workflow_permissions.inject({}) do |h, wp|
526 h[wp.field_name] ||= []
526 h[wp.field_name] ||= []
527 h[wp.field_name] << wp.rule
527 h[wp.field_name] << wp.rule
528 h
528 h
529 end
529 end
530 workflow_rules.each do |attr, rules|
530 workflow_rules.each do |attr, rules|
531 next if rules.size < roles.size
531 next if rules.size < roles.size
532 uniq_rules = rules.uniq
532 uniq_rules = rules.uniq
533 if uniq_rules.size == 1
533 if uniq_rules.size == 1
534 result[attr] = uniq_rules.first
534 result[attr] = uniq_rules.first
535 else
535 else
536 result[attr] = 'required'
536 result[attr] = 'required'
537 end
537 end
538 end
538 end
539 end
539 end
540 @workflow_rule_by_attribute = result if user.nil?
540 @workflow_rule_by_attribute = result if user.nil?
541 result
541 result
542 end
542 end
543 private :workflow_rule_by_attribute
543 private :workflow_rule_by_attribute
544
544
545 def done_ratio
545 def done_ratio
546 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
546 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
547 status.default_done_ratio
547 status.default_done_ratio
548 else
548 else
549 read_attribute(:done_ratio)
549 read_attribute(:done_ratio)
550 end
550 end
551 end
551 end
552
552
553 def self.use_status_for_done_ratio?
553 def self.use_status_for_done_ratio?
554 Setting.issue_done_ratio == 'issue_status'
554 Setting.issue_done_ratio == 'issue_status'
555 end
555 end
556
556
557 def self.use_field_for_done_ratio?
557 def self.use_field_for_done_ratio?
558 Setting.issue_done_ratio == 'issue_field'
558 Setting.issue_done_ratio == 'issue_field'
559 end
559 end
560
560
561 def validate_issue
561 def validate_issue
562 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
562 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
563 errors.add :due_date, :greater_than_start_date
563 errors.add :due_date, :greater_than_start_date
564 end
564 end
565
565
566 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
566 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
567 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
567 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
568 end
568 end
569
569
570 if fixed_version
570 if fixed_version
571 if !assignable_versions.include?(fixed_version)
571 if !assignable_versions.include?(fixed_version)
572 errors.add :fixed_version_id, :inclusion
572 errors.add :fixed_version_id, :inclusion
573 elsif reopened? && fixed_version.closed?
573 elsif reopened? && fixed_version.closed?
574 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
574 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
575 end
575 end
576 end
576 end
577
577
578 # Checks that the issue can not be added/moved to a disabled tracker
578 # Checks that the issue can not be added/moved to a disabled tracker
579 if project && (tracker_id_changed? || project_id_changed?)
579 if project && (tracker_id_changed? || project_id_changed?)
580 unless project.trackers.include?(tracker)
580 unless project.trackers.include?(tracker)
581 errors.add :tracker_id, :inclusion
581 errors.add :tracker_id, :inclusion
582 end
582 end
583 end
583 end
584
584
585 # Checks parent issue assignment
585 # Checks parent issue assignment
586 if @invalid_parent_issue_id.present?
586 if @invalid_parent_issue_id.present?
587 errors.add :parent_issue_id, :invalid
587 errors.add :parent_issue_id, :invalid
588 elsif @parent_issue
588 elsif @parent_issue
589 if !valid_parent_project?(@parent_issue)
589 if !valid_parent_project?(@parent_issue)
590 errors.add :parent_issue_id, :invalid
590 errors.add :parent_issue_id, :invalid
591 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
591 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
592 errors.add :parent_issue_id, :invalid
592 errors.add :parent_issue_id, :invalid
593 elsif !new_record?
593 elsif !new_record?
594 # moving an existing issue
594 # moving an existing issue
595 if @parent_issue.root_id != root_id
595 if @parent_issue.root_id != root_id
596 # we can always move to another tree
596 # we can always move to another tree
597 elsif move_possible?(@parent_issue)
597 elsif move_possible?(@parent_issue)
598 # move accepted inside tree
598 # move accepted inside tree
599 else
599 else
600 errors.add :parent_issue_id, :invalid
600 errors.add :parent_issue_id, :invalid
601 end
601 end
602 end
602 end
603 end
603 end
604 end
604 end
605
605
606 # Validates the issue against additional workflow requirements
606 # Validates the issue against additional workflow requirements
607 def validate_required_fields
607 def validate_required_fields
608 user = new_record? ? author : current_journal.try(:user)
608 user = new_record? ? author : current_journal.try(:user)
609
609
610 required_attribute_names(user).each do |attribute|
610 required_attribute_names(user).each do |attribute|
611 if attribute =~ /^\d+$/
611 if attribute =~ /^\d+$/
612 attribute = attribute.to_i
612 attribute = attribute.to_i
613 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
613 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
614 if v && v.value.blank?
614 if v && v.value.blank?
615 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
615 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
616 end
616 end
617 else
617 else
618 if respond_to?(attribute) && send(attribute).blank?
618 if respond_to?(attribute) && send(attribute).blank?
619 errors.add attribute, :blank
619 errors.add attribute, :blank
620 end
620 end
621 end
621 end
622 end
622 end
623 end
623 end
624
624
625 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
625 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
626 # even if the user turns off the setting later
626 # even if the user turns off the setting later
627 def update_done_ratio_from_issue_status
627 def update_done_ratio_from_issue_status
628 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
628 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
629 self.done_ratio = status.default_done_ratio
629 self.done_ratio = status.default_done_ratio
630 end
630 end
631 end
631 end
632
632
633 def init_journal(user, notes = "")
633 def init_journal(user, notes = "")
634 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
634 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
635 if new_record?
635 if new_record?
636 @current_journal.notify = false
636 @current_journal.notify = false
637 else
637 else
638 @attributes_before_change = attributes.dup
638 @attributes_before_change = attributes.dup
639 @custom_values_before_change = {}
639 @custom_values_before_change = {}
640 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
640 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
641 end
641 end
642 @current_journal
642 @current_journal
643 end
643 end
644
644
645 # Returns the id of the last journal or nil
645 # Returns the id of the last journal or nil
646 def last_journal_id
646 def last_journal_id
647 if new_record?
647 if new_record?
648 nil
648 nil
649 else
649 else
650 journals.maximum(:id)
650 journals.maximum(:id)
651 end
651 end
652 end
652 end
653
653
654 # Returns a scope for journals that have an id greater than journal_id
654 # Returns a scope for journals that have an id greater than journal_id
655 def journals_after(journal_id)
655 def journals_after(journal_id)
656 scope = journals.reorder("#{Journal.table_name}.id ASC")
656 scope = journals.reorder("#{Journal.table_name}.id ASC")
657 if journal_id.present?
657 if journal_id.present?
658 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
658 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
659 end
659 end
660 scope
660 scope
661 end
661 end
662
662
663 # Returns the initial status of the issue
663 # Returns the initial status of the issue
664 # Returns nil for a new issue
664 # Returns nil for a new issue
665 def status_was
665 def status_was
666 if status_id_was && status_id_was.to_i > 0
666 if status_id_was && status_id_was.to_i > 0
667 @status_was ||= IssueStatus.find_by_id(status_id_was)
667 @status_was ||= IssueStatus.find_by_id(status_id_was)
668 end
668 end
669 end
669 end
670
670
671 # Return true if the issue is closed, otherwise false
671 # Return true if the issue is closed, otherwise false
672 def closed?
672 def closed?
673 self.status.is_closed?
673 self.status.is_closed?
674 end
674 end
675
675
676 # Return true if the issue is being reopened
676 # Return true if the issue is being reopened
677 def reopened?
677 def reopened?
678 if !new_record? && status_id_changed?
678 if !new_record? && status_id_changed?
679 status_was = IssueStatus.find_by_id(status_id_was)
679 status_was = IssueStatus.find_by_id(status_id_was)
680 status_new = IssueStatus.find_by_id(status_id)
680 status_new = IssueStatus.find_by_id(status_id)
681 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
681 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
682 return true
682 return true
683 end
683 end
684 end
684 end
685 false
685 false
686 end
686 end
687
687
688 # Return true if the issue is being closed
688 # Return true if the issue is being closed
689 def closing?
689 def closing?
690 if !new_record? && status_id_changed?
690 if !new_record? && status_id_changed?
691 if status_was && status && !status_was.is_closed? && status.is_closed?
691 if status_was && status && !status_was.is_closed? && status.is_closed?
692 return true
692 return true
693 end
693 end
694 end
694 end
695 false
695 false
696 end
696 end
697
697
698 # Returns true if the issue is overdue
698 # Returns true if the issue is overdue
699 def overdue?
699 def overdue?
700 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
700 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
701 end
701 end
702
702
703 # Is the amount of work done less than it should for the due date
703 # Is the amount of work done less than it should for the due date
704 def behind_schedule?
704 def behind_schedule?
705 return false if start_date.nil? || due_date.nil?
705 return false if start_date.nil? || due_date.nil?
706 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
706 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
707 return done_date <= Date.today
707 return done_date <= Date.today
708 end
708 end
709
709
710 # Does this issue have children?
710 # Does this issue have children?
711 def children?
711 def children?
712 !leaf?
712 !leaf?
713 end
713 end
714
714
715 # Users the issue can be assigned to
715 # Users the issue can be assigned to
716 def assignable_users
716 def assignable_users
717 users = project.assignable_users
717 users = project.assignable_users
718 users << author if author
718 users << author if author
719 users << assigned_to if assigned_to
719 users << assigned_to if assigned_to
720 users.uniq.sort
720 users.uniq.sort
721 end
721 end
722
722
723 # Versions that the issue can be assigned to
723 # Versions that the issue can be assigned to
724 def assignable_versions
724 def assignable_versions
725 return @assignable_versions if @assignable_versions
725 return @assignable_versions if @assignable_versions
726
726
727 versions = project.shared_versions.open.all
727 versions = project.shared_versions.open.all
728 if fixed_version
728 if fixed_version
729 if fixed_version_id_changed?
729 if fixed_version_id_changed?
730 # nothing to do
730 # nothing to do
731 elsif project_id_changed?
731 elsif project_id_changed?
732 if project.shared_versions.include?(fixed_version)
732 if project.shared_versions.include?(fixed_version)
733 versions << fixed_version
733 versions << fixed_version
734 end
734 end
735 else
735 else
736 versions << fixed_version
736 versions << fixed_version
737 end
737 end
738 end
738 end
739 @assignable_versions = versions.uniq.sort
739 @assignable_versions = versions.uniq.sort
740 end
740 end
741
741
742 # Returns true if this issue is blocked by another issue that is still open
742 # Returns true if this issue is blocked by another issue that is still open
743 def blocked?
743 def blocked?
744 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
744 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
745 end
745 end
746
746
747 # Returns an array of statuses that user is able to apply
747 # Returns an array of statuses that user is able to apply
748 def new_statuses_allowed_to(user=User.current, include_default=false)
748 def new_statuses_allowed_to(user=User.current, include_default=false)
749 if new_record? && @copied_from
749 if new_record? && @copied_from
750 [IssueStatus.default, @copied_from.status].compact.uniq.sort
750 [IssueStatus.default, @copied_from.status].compact.uniq.sort
751 else
751 else
752 initial_status = nil
752 initial_status = nil
753 if new_record?
753 if new_record?
754 initial_status = IssueStatus.default
754 initial_status = IssueStatus.default
755 elsif status_id_was
755 elsif status_id_was
756 initial_status = IssueStatus.find_by_id(status_id_was)
756 initial_status = IssueStatus.find_by_id(status_id_was)
757 end
757 end
758 initial_status ||= status
758 initial_status ||= status
759
759
760 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
760 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
761 assignee_transitions_allowed = initial_assigned_to_id.present? &&
761 assignee_transitions_allowed = initial_assigned_to_id.present? &&
762 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
762 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
763
763
764 statuses = initial_status.find_new_statuses_allowed_to(
764 statuses = initial_status.find_new_statuses_allowed_to(
765 user.admin ? Role.all : user.roles_for_project(project),
765 user.admin ? Role.all : user.roles_for_project(project),
766 tracker,
766 tracker,
767 author == user,
767 author == user,
768 assignee_transitions_allowed
768 assignee_transitions_allowed
769 )
769 )
770 statuses << initial_status unless statuses.empty?
770 statuses << initial_status unless statuses.empty?
771 statuses << IssueStatus.default if include_default
771 statuses << IssueStatus.default if include_default
772 statuses = statuses.compact.uniq.sort
772 statuses = statuses.compact.uniq.sort
773 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
773 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
774 end
774 end
775 end
775 end
776
776
777 def assigned_to_was
777 def assigned_to_was
778 if assigned_to_id_changed? && assigned_to_id_was.present?
778 if assigned_to_id_changed? && assigned_to_id_was.present?
779 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
779 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
780 end
780 end
781 end
781 end
782
782
783 # Returns the users that should be notified
783 # Returns the users that should be notified
784 def notified_users
784 def notified_users
785 notified = []
785 notified = []
786 # Author and assignee are always notified unless they have been
786 # Author and assignee are always notified unless they have been
787 # locked or don't want to be notified
787 # locked or don't want to be notified
788 notified << author if author
788 notified << author if author
789 if assigned_to
789 if assigned_to
790 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
790 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
791 end
791 end
792 if assigned_to_was
792 if assigned_to_was
793 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
793 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
794 end
794 end
795 notified = notified.select {|u| u.active? && u.notify_about?(self)}
795 notified = notified.select {|u| u.active? && u.notify_about?(self)}
796
796
797 notified += project.notified_users
797 notified += project.notified_users
798 notified.uniq!
798 notified.uniq!
799 # Remove users that can not view the issue
799 # Remove users that can not view the issue
800 notified.reject! {|user| !visible?(user)}
800 notified.reject! {|user| !visible?(user)}
801 notified
801 notified
802 end
802 end
803
803
804 # Returns the email addresses that should be notified
804 # Returns the email addresses that should be notified
805 def recipients
805 def recipients
806 notified_users.collect(&:mail)
806 notified_users.collect(&:mail)
807 end
807 end
808
808
809 def each_notification(users, &block)
809 def each_notification(users, &block)
810 if users.any?
810 if users.any?
811 if custom_field_values.detect {|value| !value.custom_field.visible?}
811 if custom_field_values.detect {|value| !value.custom_field.visible?}
812 users_by_custom_field_visibility = users.group_by do |user|
812 users_by_custom_field_visibility = users.group_by do |user|
813 visible_custom_field_values(user).map(&:custom_field_id).sort
813 visible_custom_field_values(user).map(&:custom_field_id).sort
814 end
814 end
815 users_by_custom_field_visibility.values.each do |users|
815 users_by_custom_field_visibility.values.each do |users|
816 yield(users)
816 yield(users)
817 end
817 end
818 else
818 else
819 yield(users)
819 yield(users)
820 end
820 end
821 end
821 end
822 end
822 end
823
823
824 # Returns the number of hours spent on this issue
824 # Returns the number of hours spent on this issue
825 def spent_hours
825 def spent_hours
826 @spent_hours ||= time_entries.sum(:hours) || 0
826 @spent_hours ||= time_entries.sum(:hours) || 0
827 end
827 end
828
828
829 # Returns the total number of hours spent on this issue and its descendants
829 # Returns the total number of hours spent on this issue and its descendants
830 #
830 #
831 # Example:
831 # Example:
832 # spent_hours => 0.0
832 # spent_hours => 0.0
833 # spent_hours => 50.2
833 # spent_hours => 50.2
834 def total_spent_hours
834 def total_spent_hours
835 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
835 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
836 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
836 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
837 end
837 end
838
838
839 def relations
839 def relations
840 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
840 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
841 end
841 end
842
842
843 # Preloads relations for a collection of issues
843 # Preloads relations for a collection of issues
844 def self.load_relations(issues)
844 def self.load_relations(issues)
845 if issues.any?
845 if issues.any?
846 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
846 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
847 issues.each do |issue|
847 issues.each do |issue|
848 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
848 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
849 end
849 end
850 end
850 end
851 end
851 end
852
852
853 # Preloads visible spent time for a collection of issues
853 # Preloads visible spent time for a collection of issues
854 def self.load_visible_spent_hours(issues, user=User.current)
854 def self.load_visible_spent_hours(issues, user=User.current)
855 if issues.any?
855 if issues.any?
856 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
856 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
857 issues.each do |issue|
857 issues.each do |issue|
858 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
858 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
859 end
859 end
860 end
860 end
861 end
861 end
862
862
863 # Preloads visible relations for a collection of issues
863 # Preloads visible relations for a collection of issues
864 def self.load_visible_relations(issues, user=User.current)
864 def self.load_visible_relations(issues, user=User.current)
865 if issues.any?
865 if issues.any?
866 issue_ids = issues.map(&:id)
866 issue_ids = issues.map(&:id)
867 # Relations with issue_from in given issues and visible issue_to
867 # Relations with issue_from in given issues and visible issue_to
868 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
868 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
869 # Relations with issue_to in given issues and visible issue_from
869 # Relations with issue_to in given issues and visible issue_from
870 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
870 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
871
871
872 issues.each do |issue|
872 issues.each do |issue|
873 relations =
873 relations =
874 relations_from.select {|relation| relation.issue_from_id == issue.id} +
874 relations_from.select {|relation| relation.issue_from_id == issue.id} +
875 relations_to.select {|relation| relation.issue_to_id == issue.id}
875 relations_to.select {|relation| relation.issue_to_id == issue.id}
876
876
877 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
877 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
878 end
878 end
879 end
879 end
880 end
880 end
881
881
882 # Finds an issue relation given its id.
882 # Finds an issue relation given its id.
883 def find_relation(relation_id)
883 def find_relation(relation_id)
884 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
884 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
885 end
885 end
886
886
887 # Returns all the other issues that depend on the issue
887 # Returns all the other issues that depend on the issue
888 # The algorithm is a modified breadth first search (bfs)
888 # The algorithm is a modified breadth first search (bfs)
889 def all_dependent_issues(except=[])
889 def all_dependent_issues(except=[])
890 # The found dependencies
890 # The found dependencies
891 dependencies = []
891 dependencies = []
892
892
893 # The visited flag for every node (issue) used by the breadth first search
893 # The visited flag for every node (issue) used by the breadth first search
894 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
894 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
895
895
896 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
896 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
897 # the issue when it is processed.
897 # the issue when it is processed.
898
898
899 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
899 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
900 # but its children will not be added to the queue when it is processed.
900 # but its children will not be added to the queue when it is processed.
901
901
902 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
902 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
903 # the queue, but its children have not been added.
903 # the queue, but its children have not been added.
904
904
905 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
905 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
906 # the children still need to be processed.
906 # the children still need to be processed.
907
907
908 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
908 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
909 # added as dependent issues. It needs no further processing.
909 # added as dependent issues. It needs no further processing.
910
910
911 issue_status = Hash.new(eNOT_DISCOVERED)
911 issue_status = Hash.new(eNOT_DISCOVERED)
912
912
913 # The queue
913 # The queue
914 queue = []
914 queue = []
915
915
916 # Initialize the bfs, add start node (self) to the queue
916 # Initialize the bfs, add start node (self) to the queue
917 queue << self
917 queue << self
918 issue_status[self] = ePROCESS_ALL
918 issue_status[self] = ePROCESS_ALL
919
919
920 while (!queue.empty?) do
920 while (!queue.empty?) do
921 current_issue = queue.shift
921 current_issue = queue.shift
922 current_issue_status = issue_status[current_issue]
922 current_issue_status = issue_status[current_issue]
923 dependencies << current_issue
923 dependencies << current_issue
924
924
925 # Add parent to queue, if not already in it.
925 # Add parent to queue, if not already in it.
926 parent = current_issue.parent
926 parent = current_issue.parent
927 parent_status = issue_status[parent]
927 parent_status = issue_status[parent]
928
928
929 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
929 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
930 queue << parent
930 queue << parent
931 issue_status[parent] = ePROCESS_RELATIONS_ONLY
931 issue_status[parent] = ePROCESS_RELATIONS_ONLY
932 end
932 end
933
933
934 # Add children to queue, but only if they are not already in it and
934 # Add children to queue, but only if they are not already in it and
935 # the children of the current node need to be processed.
935 # the children of the current node need to be processed.
936 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
936 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
937 current_issue.children.each do |child|
937 current_issue.children.each do |child|
938 next if except.include?(child)
938 next if except.include?(child)
939
939
940 if (issue_status[child] == eNOT_DISCOVERED)
940 if (issue_status[child] == eNOT_DISCOVERED)
941 queue << child
941 queue << child
942 issue_status[child] = ePROCESS_ALL
942 issue_status[child] = ePROCESS_ALL
943 elsif (issue_status[child] == eRELATIONS_PROCESSED)
943 elsif (issue_status[child] == eRELATIONS_PROCESSED)
944 queue << child
944 queue << child
945 issue_status[child] = ePROCESS_CHILDREN_ONLY
945 issue_status[child] = ePROCESS_CHILDREN_ONLY
946 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
946 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
947 queue << child
947 queue << child
948 issue_status[child] = ePROCESS_ALL
948 issue_status[child] = ePROCESS_ALL
949 end
949 end
950 end
950 end
951 end
951 end
952
952
953 # Add related issues to the queue, if they are not already in it.
953 # Add related issues to the queue, if they are not already in it.
954 current_issue.relations_from.map(&:issue_to).each do |related_issue|
954 current_issue.relations_from.map(&:issue_to).each do |related_issue|
955 next if except.include?(related_issue)
955 next if except.include?(related_issue)
956
956
957 if (issue_status[related_issue] == eNOT_DISCOVERED)
957 if (issue_status[related_issue] == eNOT_DISCOVERED)
958 queue << related_issue
958 queue << related_issue
959 issue_status[related_issue] = ePROCESS_ALL
959 issue_status[related_issue] = ePROCESS_ALL
960 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
960 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
961 queue << related_issue
961 queue << related_issue
962 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
962 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
963 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
963 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
964 queue << related_issue
964 queue << related_issue
965 issue_status[related_issue] = ePROCESS_ALL
965 issue_status[related_issue] = ePROCESS_ALL
966 end
966 end
967 end
967 end
968
968
969 # Set new status for current issue
969 # Set new status for current issue
970 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
970 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
971 issue_status[current_issue] = eALL_PROCESSED
971 issue_status[current_issue] = eALL_PROCESSED
972 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
972 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
973 issue_status[current_issue] = eRELATIONS_PROCESSED
973 issue_status[current_issue] = eRELATIONS_PROCESSED
974 end
974 end
975 end # while
975 end # while
976
976
977 # Remove the issues from the "except" parameter from the result array
977 # Remove the issues from the "except" parameter from the result array
978 dependencies -= except
978 dependencies -= except
979 dependencies.delete(self)
979 dependencies.delete(self)
980
980
981 dependencies
981 dependencies
982 end
982 end
983
983
984 # Returns an array of issues that duplicate this one
984 # Returns an array of issues that duplicate this one
985 def duplicates
985 def duplicates
986 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
986 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
987 end
987 end
988
988
989 # Returns the due date or the target due date if any
989 # Returns the due date or the target due date if any
990 # Used on gantt chart
990 # Used on gantt chart
991 def due_before
991 def due_before
992 due_date || (fixed_version ? fixed_version.effective_date : nil)
992 due_date || (fixed_version ? fixed_version.effective_date : nil)
993 end
993 end
994
994
995 # Returns the time scheduled for this issue.
995 # Returns the time scheduled for this issue.
996 #
996 #
997 # Example:
997 # Example:
998 # Start Date: 2/26/09, End Date: 3/04/09
998 # Start Date: 2/26/09, End Date: 3/04/09
999 # duration => 6
999 # duration => 6
1000 def duration
1000 def duration
1001 (start_date && due_date) ? due_date - start_date : 0
1001 (start_date && due_date) ? due_date - start_date : 0
1002 end
1002 end
1003
1003
1004 # Returns the duration in working days
1004 # Returns the duration in working days
1005 def working_duration
1005 def working_duration
1006 (start_date && due_date) ? working_days(start_date, due_date) : 0
1006 (start_date && due_date) ? working_days(start_date, due_date) : 0
1007 end
1007 end
1008
1008
1009 def soonest_start(reload=false)
1009 def soonest_start(reload=false)
1010 @soonest_start = nil if reload
1010 @soonest_start = nil if reload
1011 @soonest_start ||= (
1011 @soonest_start ||= (
1012 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1012 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1013 [(@parent_issue || parent).try(:soonest_start)]
1013 [(@parent_issue || parent).try(:soonest_start)]
1014 ).compact.max
1014 ).compact.max
1015 end
1015 end
1016
1016
1017 # Sets start_date on the given date or the next working day
1017 # Sets start_date on the given date or the next working day
1018 # and changes due_date to keep the same working duration.
1018 # and changes due_date to keep the same working duration.
1019 def reschedule_on(date)
1019 def reschedule_on(date)
1020 wd = working_duration
1020 wd = working_duration
1021 date = next_working_date(date)
1021 date = next_working_date(date)
1022 self.start_date = date
1022 self.start_date = date
1023 self.due_date = add_working_days(date, wd)
1023 self.due_date = add_working_days(date, wd)
1024 end
1024 end
1025
1025
1026 # Reschedules the issue on the given date or the next working day and saves the record.
1026 # Reschedules the issue on the given date or the next working day and saves the record.
1027 # If the issue is a parent task, this is done by rescheduling its subtasks.
1027 # If the issue is a parent task, this is done by rescheduling its subtasks.
1028 def reschedule_on!(date)
1028 def reschedule_on!(date)
1029 return if date.nil?
1029 return if date.nil?
1030 if leaf?
1030 if leaf?
1031 if start_date.nil? || start_date != date
1031 if start_date.nil? || start_date != date
1032 if start_date && start_date > date
1032 if start_date && start_date > date
1033 # Issue can not be moved earlier than its soonest start date
1033 # Issue can not be moved earlier than its soonest start date
1034 date = [soonest_start(true), date].compact.max
1034 date = [soonest_start(true), date].compact.max
1035 end
1035 end
1036 reschedule_on(date)
1036 reschedule_on(date)
1037 begin
1037 begin
1038 save
1038 save
1039 rescue ActiveRecord::StaleObjectError
1039 rescue ActiveRecord::StaleObjectError
1040 reload
1040 reload
1041 reschedule_on(date)
1041 reschedule_on(date)
1042 save
1042 save
1043 end
1043 end
1044 end
1044 end
1045 else
1045 else
1046 leaves.each do |leaf|
1046 leaves.each do |leaf|
1047 if leaf.start_date
1047 if leaf.start_date
1048 # Only move subtask if it starts at the same date as the parent
1048 # Only move subtask if it starts at the same date as the parent
1049 # or if it starts before the given date
1049 # or if it starts before the given date
1050 if start_date == leaf.start_date || date > leaf.start_date
1050 if start_date == leaf.start_date || date > leaf.start_date
1051 leaf.reschedule_on!(date)
1051 leaf.reschedule_on!(date)
1052 end
1052 end
1053 else
1053 else
1054 leaf.reschedule_on!(date)
1054 leaf.reschedule_on!(date)
1055 end
1055 end
1056 end
1056 end
1057 end
1057 end
1058 end
1058 end
1059
1059
1060 def <=>(issue)
1060 def <=>(issue)
1061 if issue.nil?
1061 if issue.nil?
1062 -1
1062 -1
1063 elsif root_id != issue.root_id
1063 elsif root_id != issue.root_id
1064 (root_id || 0) <=> (issue.root_id || 0)
1064 (root_id || 0) <=> (issue.root_id || 0)
1065 else
1065 else
1066 (lft || 0) <=> (issue.lft || 0)
1066 (lft || 0) <=> (issue.lft || 0)
1067 end
1067 end
1068 end
1068 end
1069
1069
1070 def to_s
1070 def to_s
1071 "#{tracker} ##{id}: #{subject}"
1071 "#{tracker} ##{id}: #{subject}"
1072 end
1072 end
1073
1073
1074 # Returns a string of css classes that apply to the issue
1074 # Returns a string of css classes that apply to the issue
1075 def css_classes(user=User.current)
1075 def css_classes(user=User.current)
1076 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1076 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1077 s << ' closed' if closed?
1077 s << ' closed' if closed?
1078 s << ' overdue' if overdue?
1078 s << ' overdue' if overdue?
1079 s << ' child' if child?
1079 s << ' child' if child?
1080 s << ' parent' unless leaf?
1080 s << ' parent' unless leaf?
1081 s << ' private' if is_private?
1081 s << ' private' if is_private?
1082 if user.logged?
1082 if user.logged?
1083 s << ' created-by-me' if author_id == user.id
1083 s << ' created-by-me' if author_id == user.id
1084 s << ' assigned-to-me' if assigned_to_id == user.id
1084 s << ' assigned-to-me' if assigned_to_id == user.id
1085 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id = assigned_to_id}
1085 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id = assigned_to_id}
1086 end
1086 end
1087 s
1087 s
1088 end
1088 end
1089
1089
1090 # Unassigns issues from +version+ if it's no longer shared with issue's project
1090 # Unassigns issues from +version+ if it's no longer shared with issue's project
1091 def self.update_versions_from_sharing_change(version)
1091 def self.update_versions_from_sharing_change(version)
1092 # Update issues assigned to the version
1092 # Update issues assigned to the version
1093 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1093 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1094 end
1094 end
1095
1095
1096 # Unassigns issues from versions that are no longer shared
1096 # Unassigns issues from versions that are no longer shared
1097 # after +project+ was moved
1097 # after +project+ was moved
1098 def self.update_versions_from_hierarchy_change(project)
1098 def self.update_versions_from_hierarchy_change(project)
1099 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1099 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1100 # Update issues of the moved projects and issues assigned to a version of a moved project
1100 # Update issues of the moved projects and issues assigned to a version of a moved project
1101 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
1101 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
1102 end
1102 end
1103
1103
1104 def parent_issue_id=(arg)
1104 def parent_issue_id=(arg)
1105 s = arg.to_s.strip.presence
1105 s = arg.to_s.strip.presence
1106 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1106 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1107 @parent_issue.id
1107 @parent_issue.id
1108 @invalid_parent_issue_id = nil
1109 elsif s.blank?
1110 @parent_issue = nil
1111 @invalid_parent_issue_id = nil
1108 else
1112 else
1109 @parent_issue = nil
1113 @parent_issue = nil
1110 @invalid_parent_issue_id = arg
1114 @invalid_parent_issue_id = arg
1111 end
1115 end
1112 end
1116 end
1113
1117
1114 def parent_issue_id
1118 def parent_issue_id
1115 if @invalid_parent_issue_id
1119 if @invalid_parent_issue_id
1116 @invalid_parent_issue_id
1120 @invalid_parent_issue_id
1117 elsif instance_variable_defined? :@parent_issue
1121 elsif instance_variable_defined? :@parent_issue
1118 @parent_issue.nil? ? nil : @parent_issue.id
1122 @parent_issue.nil? ? nil : @parent_issue.id
1119 else
1123 else
1120 parent_id
1124 parent_id
1121 end
1125 end
1122 end
1126 end
1123
1127
1124 # Returns true if issue's project is a valid
1128 # Returns true if issue's project is a valid
1125 # parent issue project
1129 # parent issue project
1126 def valid_parent_project?(issue=parent)
1130 def valid_parent_project?(issue=parent)
1127 return true if issue.nil? || issue.project_id == project_id
1131 return true if issue.nil? || issue.project_id == project_id
1128
1132
1129 case Setting.cross_project_subtasks
1133 case Setting.cross_project_subtasks
1130 when 'system'
1134 when 'system'
1131 true
1135 true
1132 when 'tree'
1136 when 'tree'
1133 issue.project.root == project.root
1137 issue.project.root == project.root
1134 when 'hierarchy'
1138 when 'hierarchy'
1135 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1139 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1136 when 'descendants'
1140 when 'descendants'
1137 issue.project.is_or_is_ancestor_of?(project)
1141 issue.project.is_or_is_ancestor_of?(project)
1138 else
1142 else
1139 false
1143 false
1140 end
1144 end
1141 end
1145 end
1142
1146
1143 # Extracted from the ReportsController.
1147 # Extracted from the ReportsController.
1144 def self.by_tracker(project)
1148 def self.by_tracker(project)
1145 count_and_group_by(:project => project,
1149 count_and_group_by(:project => project,
1146 :field => 'tracker_id',
1150 :field => 'tracker_id',
1147 :joins => Tracker.table_name)
1151 :joins => Tracker.table_name)
1148 end
1152 end
1149
1153
1150 def self.by_version(project)
1154 def self.by_version(project)
1151 count_and_group_by(:project => project,
1155 count_and_group_by(:project => project,
1152 :field => 'fixed_version_id',
1156 :field => 'fixed_version_id',
1153 :joins => Version.table_name)
1157 :joins => Version.table_name)
1154 end
1158 end
1155
1159
1156 def self.by_priority(project)
1160 def self.by_priority(project)
1157 count_and_group_by(:project => project,
1161 count_and_group_by(:project => project,
1158 :field => 'priority_id',
1162 :field => 'priority_id',
1159 :joins => IssuePriority.table_name)
1163 :joins => IssuePriority.table_name)
1160 end
1164 end
1161
1165
1162 def self.by_category(project)
1166 def self.by_category(project)
1163 count_and_group_by(:project => project,
1167 count_and_group_by(:project => project,
1164 :field => 'category_id',
1168 :field => 'category_id',
1165 :joins => IssueCategory.table_name)
1169 :joins => IssueCategory.table_name)
1166 end
1170 end
1167
1171
1168 def self.by_assigned_to(project)
1172 def self.by_assigned_to(project)
1169 count_and_group_by(:project => project,
1173 count_and_group_by(:project => project,
1170 :field => 'assigned_to_id',
1174 :field => 'assigned_to_id',
1171 :joins => User.table_name)
1175 :joins => User.table_name)
1172 end
1176 end
1173
1177
1174 def self.by_author(project)
1178 def self.by_author(project)
1175 count_and_group_by(:project => project,
1179 count_and_group_by(:project => project,
1176 :field => 'author_id',
1180 :field => 'author_id',
1177 :joins => User.table_name)
1181 :joins => User.table_name)
1178 end
1182 end
1179
1183
1180 def self.by_subproject(project)
1184 def self.by_subproject(project)
1181 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1185 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1182 s.is_closed as closed,
1186 s.is_closed as closed,
1183 #{Issue.table_name}.project_id as project_id,
1187 #{Issue.table_name}.project_id as project_id,
1184 count(#{Issue.table_name}.id) as total
1188 count(#{Issue.table_name}.id) as total
1185 from
1189 from
1186 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1190 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1187 where
1191 where
1188 #{Issue.table_name}.status_id=s.id
1192 #{Issue.table_name}.status_id=s.id
1189 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1193 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1190 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1194 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1191 and #{Issue.table_name}.project_id <> #{project.id}
1195 and #{Issue.table_name}.project_id <> #{project.id}
1192 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1196 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1193 end
1197 end
1194 # End ReportsController extraction
1198 # End ReportsController extraction
1195
1199
1196 # Returns a scope of projects that user can assign the issue to
1200 # Returns a scope of projects that user can assign the issue to
1197 def allowed_target_projects(user=User.current)
1201 def allowed_target_projects(user=User.current)
1198 if new_record?
1202 if new_record?
1199 Project.where(Project.allowed_to_condition(user, :add_issues))
1203 Project.where(Project.allowed_to_condition(user, :add_issues))
1200 else
1204 else
1201 self.class.allowed_target_projects_on_move(user)
1205 self.class.allowed_target_projects_on_move(user)
1202 end
1206 end
1203 end
1207 end
1204
1208
1205 # Returns a scope of projects that user can move issues to
1209 # Returns a scope of projects that user can move issues to
1206 def self.allowed_target_projects_on_move(user=User.current)
1210 def self.allowed_target_projects_on_move(user=User.current)
1207 Project.where(Project.allowed_to_condition(user, :move_issues))
1211 Project.where(Project.allowed_to_condition(user, :move_issues))
1208 end
1212 end
1209
1213
1210 private
1214 private
1211
1215
1212 def after_project_change
1216 def after_project_change
1213 # Update project_id on related time entries
1217 # Update project_id on related time entries
1214 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1218 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1215
1219
1216 # Delete issue relations
1220 # Delete issue relations
1217 unless Setting.cross_project_issue_relations?
1221 unless Setting.cross_project_issue_relations?
1218 relations_from.clear
1222 relations_from.clear
1219 relations_to.clear
1223 relations_to.clear
1220 end
1224 end
1221
1225
1222 # Move subtasks that were in the same project
1226 # Move subtasks that were in the same project
1223 children.each do |child|
1227 children.each do |child|
1224 next unless child.project_id == project_id_was
1228 next unless child.project_id == project_id_was
1225 # Change project and keep project
1229 # Change project and keep project
1226 child.send :project=, project, true
1230 child.send :project=, project, true
1227 unless child.save
1231 unless child.save
1228 raise ActiveRecord::Rollback
1232 raise ActiveRecord::Rollback
1229 end
1233 end
1230 end
1234 end
1231 end
1235 end
1232
1236
1233 # Callback for after the creation of an issue by copy
1237 # Callback for after the creation of an issue by copy
1234 # * adds a "copied to" relation with the copied issue
1238 # * adds a "copied to" relation with the copied issue
1235 # * copies subtasks from the copied issue
1239 # * copies subtasks from the copied issue
1236 def after_create_from_copy
1240 def after_create_from_copy
1237 return unless copy? && !@after_create_from_copy_handled
1241 return unless copy? && !@after_create_from_copy_handled
1238
1242
1239 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1243 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1240 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1244 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1241 unless relation.save
1245 unless relation.save
1242 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1246 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1243 end
1247 end
1244 end
1248 end
1245
1249
1246 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1250 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1247 copy_options = (@copy_options || {}).merge(:subtasks => false)
1251 copy_options = (@copy_options || {}).merge(:subtasks => false)
1248 copied_issue_ids = {@copied_from.id => self.id}
1252 copied_issue_ids = {@copied_from.id => self.id}
1249 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1253 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1250 # Do not copy self when copying an issue as a descendant of the copied issue
1254 # Do not copy self when copying an issue as a descendant of the copied issue
1251 next if child == self
1255 next if child == self
1252 # Do not copy subtasks of issues that were not copied
1256 # Do not copy subtasks of issues that were not copied
1253 next unless copied_issue_ids[child.parent_id]
1257 next unless copied_issue_ids[child.parent_id]
1254 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1258 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1255 unless child.visible?
1259 unless child.visible?
1256 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1260 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1257 next
1261 next
1258 end
1262 end
1259 copy = Issue.new.copy_from(child, copy_options)
1263 copy = Issue.new.copy_from(child, copy_options)
1260 copy.author = author
1264 copy.author = author
1261 copy.project = project
1265 copy.project = project
1262 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1266 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1263 unless copy.save
1267 unless copy.save
1264 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1268 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1265 next
1269 next
1266 end
1270 end
1267 copied_issue_ids[child.id] = copy.id
1271 copied_issue_ids[child.id] = copy.id
1268 end
1272 end
1269 end
1273 end
1270 @after_create_from_copy_handled = true
1274 @after_create_from_copy_handled = true
1271 end
1275 end
1272
1276
1273 def update_nested_set_attributes
1277 def update_nested_set_attributes
1274 if root_id.nil?
1278 if root_id.nil?
1275 # issue was just created
1279 # issue was just created
1276 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1280 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1277 set_default_left_and_right
1281 set_default_left_and_right
1278 Issue.update_all(["root_id = ?, lft = ?, rgt = ?", root_id, lft, rgt], ["id = ?", id])
1282 Issue.update_all(["root_id = ?, lft = ?, rgt = ?", root_id, lft, rgt], ["id = ?", id])
1279 if @parent_issue
1283 if @parent_issue
1280 move_to_child_of(@parent_issue)
1284 move_to_child_of(@parent_issue)
1281 end
1285 end
1282 elsif parent_issue_id != parent_id
1286 elsif parent_issue_id != parent_id
1283 former_parent_id = parent_id
1287 update_nested_set_attributes_on_parent_change
1284 # moving an existing issue
1288 end
1285 if @parent_issue && @parent_issue.root_id == root_id
1289 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1286 # inside the same tree
1290 end
1291
1292 # Updates the nested set for when an existing issue is moved
1293 def update_nested_set_attributes_on_parent_change
1294 former_parent_id = parent_id
1295 # moving an existing issue
1296 if @parent_issue && @parent_issue.root_id == root_id
1297 # inside the same tree
1298 move_to_child_of(@parent_issue)
1299 else
1300 # to another tree
1301 unless root?
1302 move_to_right_of(root)
1303 end
1304 old_root_id = root_id
1305 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1306 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1307 offset = target_maxright + 1 - lft
1308 Issue.update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset],
1309 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1310 self[left_column_name] = lft + offset
1311 self[right_column_name] = rgt + offset
1312 if @parent_issue
1287 move_to_child_of(@parent_issue)
1313 move_to_child_of(@parent_issue)
1288 else
1289 # to another tree
1290 unless root?
1291 move_to_right_of(root)
1292 end
1293 old_root_id = root_id
1294 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1295 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1296 offset = target_maxright + 1 - lft
1297 Issue.update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset],
1298 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1299 self[left_column_name] = lft + offset
1300 self[right_column_name] = rgt + offset
1301 if @parent_issue
1302 move_to_child_of(@parent_issue)
1303 end
1304 end
1314 end
1305 # delete invalid relations of all descendants
1315 end
1306 self_and_descendants.each do |issue|
1316 # delete invalid relations of all descendants
1307 issue.relations.each do |relation|
1317 self_and_descendants.each do |issue|
1308 relation.destroy unless relation.valid?
1318 issue.relations.each do |relation|
1309 end
1319 relation.destroy unless relation.valid?
1310 end
1320 end
1311 # update former parent
1312 recalculate_attributes_for(former_parent_id) if former_parent_id
1313 end
1321 end
1314 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1322 # update former parent
1323 recalculate_attributes_for(former_parent_id) if former_parent_id
1315 end
1324 end
1316
1325
1317 def update_parent_attributes
1326 def update_parent_attributes
1318 recalculate_attributes_for(parent_id) if parent_id
1327 recalculate_attributes_for(parent_id) if parent_id
1319 end
1328 end
1320
1329
1321 def recalculate_attributes_for(issue_id)
1330 def recalculate_attributes_for(issue_id)
1322 if issue_id && p = Issue.find_by_id(issue_id)
1331 if issue_id && p = Issue.find_by_id(issue_id)
1323 # priority = highest priority of children
1332 # priority = highest priority of children
1324 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1333 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1325 p.priority = IssuePriority.find_by_position(priority_position)
1334 p.priority = IssuePriority.find_by_position(priority_position)
1326 end
1335 end
1327
1336
1328 # start/due dates = lowest/highest dates of children
1337 # start/due dates = lowest/highest dates of children
1329 p.start_date = p.children.minimum(:start_date)
1338 p.start_date = p.children.minimum(:start_date)
1330 p.due_date = p.children.maximum(:due_date)
1339 p.due_date = p.children.maximum(:due_date)
1331 if p.start_date && p.due_date && p.due_date < p.start_date
1340 if p.start_date && p.due_date && p.due_date < p.start_date
1332 p.start_date, p.due_date = p.due_date, p.start_date
1341 p.start_date, p.due_date = p.due_date, p.start_date
1333 end
1342 end
1334
1343
1335 # done ratio = weighted average ratio of leaves
1344 # done ratio = weighted average ratio of leaves
1336 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1345 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1337 leaves_count = p.leaves.count
1346 leaves_count = p.leaves.count
1338 if leaves_count > 0
1347 if leaves_count > 0
1339 average = p.leaves.average(:estimated_hours).to_f
1348 average = p.leaves.average(:estimated_hours).to_f
1340 if average == 0
1349 if average == 0
1341 average = 1
1350 average = 1
1342 end
1351 end
1343 done = p.leaves.sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1352 done = p.leaves.sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1344 "* (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
1353 "* (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
1345 progress = done / (average * leaves_count)
1354 progress = done / (average * leaves_count)
1346 p.done_ratio = progress.round
1355 p.done_ratio = progress.round
1347 end
1356 end
1348 end
1357 end
1349
1358
1350 # estimate = sum of leaves estimates
1359 # estimate = sum of leaves estimates
1351 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1360 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1352 p.estimated_hours = nil if p.estimated_hours == 0.0
1361 p.estimated_hours = nil if p.estimated_hours == 0.0
1353
1362
1354 # ancestors will be recursively updated
1363 # ancestors will be recursively updated
1355 p.save(:validate => false)
1364 p.save(:validate => false)
1356 end
1365 end
1357 end
1366 end
1358
1367
1359 # Update issues so their versions are not pointing to a
1368 # Update issues so their versions are not pointing to a
1360 # fixed_version that is not shared with the issue's project
1369 # fixed_version that is not shared with the issue's project
1361 def self.update_versions(conditions=nil)
1370 def self.update_versions(conditions=nil)
1362 # Only need to update issues with a fixed_version from
1371 # Only need to update issues with a fixed_version from
1363 # a different project and that is not systemwide shared
1372 # a different project and that is not systemwide shared
1364 Issue.includes(:project, :fixed_version).
1373 Issue.includes(:project, :fixed_version).
1365 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1374 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1366 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1375 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1367 " AND #{Version.table_name}.sharing <> 'system'").
1376 " AND #{Version.table_name}.sharing <> 'system'").
1368 where(conditions).each do |issue|
1377 where(conditions).each do |issue|
1369 next if issue.project.nil? || issue.fixed_version.nil?
1378 next if issue.project.nil? || issue.fixed_version.nil?
1370 unless issue.project.shared_versions.include?(issue.fixed_version)
1379 unless issue.project.shared_versions.include?(issue.fixed_version)
1371 issue.init_journal(User.current)
1380 issue.init_journal(User.current)
1372 issue.fixed_version = nil
1381 issue.fixed_version = nil
1373 issue.save
1382 issue.save
1374 end
1383 end
1375 end
1384 end
1376 end
1385 end
1377
1386
1378 # Callback on file attachment
1387 # Callback on file attachment
1379 def attachment_added(obj)
1388 def attachment_added(obj)
1380 if @current_journal && !obj.new_record?
1389 if @current_journal && !obj.new_record?
1381 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1390 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1382 end
1391 end
1383 end
1392 end
1384
1393
1385 # Callback on attachment deletion
1394 # Callback on attachment deletion
1386 def attachment_removed(obj)
1395 def attachment_removed(obj)
1387 if @current_journal && !obj.new_record?
1396 if @current_journal && !obj.new_record?
1388 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1397 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1389 @current_journal.save
1398 @current_journal.save
1390 end
1399 end
1391 end
1400 end
1392
1401
1393 # Default assignment based on category
1402 # Default assignment based on category
1394 def default_assign
1403 def default_assign
1395 if assigned_to.nil? && category && category.assigned_to
1404 if assigned_to.nil? && category && category.assigned_to
1396 self.assigned_to = category.assigned_to
1405 self.assigned_to = category.assigned_to
1397 end
1406 end
1398 end
1407 end
1399
1408
1400 # Updates start/due dates of following issues
1409 # Updates start/due dates of following issues
1401 def reschedule_following_issues
1410 def reschedule_following_issues
1402 if start_date_changed? || due_date_changed?
1411 if start_date_changed? || due_date_changed?
1403 relations_from.each do |relation|
1412 relations_from.each do |relation|
1404 relation.set_issue_to_dates
1413 relation.set_issue_to_dates
1405 end
1414 end
1406 end
1415 end
1407 end
1416 end
1408
1417
1409 # Closes duplicates if the issue is being closed
1418 # Closes duplicates if the issue is being closed
1410 def close_duplicates
1419 def close_duplicates
1411 if closing?
1420 if closing?
1412 duplicates.each do |duplicate|
1421 duplicates.each do |duplicate|
1413 # Reload is need in case the duplicate was updated by a previous duplicate
1422 # Reload is need in case the duplicate was updated by a previous duplicate
1414 duplicate.reload
1423 duplicate.reload
1415 # Don't re-close it if it's already closed
1424 # Don't re-close it if it's already closed
1416 next if duplicate.closed?
1425 next if duplicate.closed?
1417 # Same user and notes
1426 # Same user and notes
1418 if @current_journal
1427 if @current_journal
1419 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1428 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1420 end
1429 end
1421 duplicate.update_attribute :status, self.status
1430 duplicate.update_attribute :status, self.status
1422 end
1431 end
1423 end
1432 end
1424 end
1433 end
1425
1434
1426 # Make sure updated_on is updated when adding a note and set updated_on now
1435 # Make sure updated_on is updated when adding a note and set updated_on now
1427 # so we can set closed_on with the same value on closing
1436 # so we can set closed_on with the same value on closing
1428 def force_updated_on_change
1437 def force_updated_on_change
1429 if @current_journal || changed?
1438 if @current_journal || changed?
1430 self.updated_on = current_time_from_proper_timezone
1439 self.updated_on = current_time_from_proper_timezone
1431 if new_record?
1440 if new_record?
1432 self.created_on = updated_on
1441 self.created_on = updated_on
1433 end
1442 end
1434 end
1443 end
1435 end
1444 end
1436
1445
1437 # Callback for setting closed_on when the issue is closed.
1446 # Callback for setting closed_on when the issue is closed.
1438 # The closed_on attribute stores the time of the last closing
1447 # The closed_on attribute stores the time of the last closing
1439 # and is preserved when the issue is reopened.
1448 # and is preserved when the issue is reopened.
1440 def update_closed_on
1449 def update_closed_on
1441 if closing? || (new_record? && closed?)
1450 if closing? || (new_record? && closed?)
1442 self.closed_on = updated_on
1451 self.closed_on = updated_on
1443 end
1452 end
1444 end
1453 end
1445
1454
1446 # Saves the changes in a Journal
1455 # Saves the changes in a Journal
1447 # Called after_save
1456 # Called after_save
1448 def create_journal
1457 def create_journal
1449 if @current_journal
1458 if @current_journal
1450 # attributes changes
1459 # attributes changes
1451 if @attributes_before_change
1460 if @attributes_before_change
1452 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1461 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1453 before = @attributes_before_change[c]
1462 before = @attributes_before_change[c]
1454 after = send(c)
1463 after = send(c)
1455 next if before == after || (before.blank? && after.blank?)
1464 next if before == after || (before.blank? && after.blank?)
1456 @current_journal.details << JournalDetail.new(:property => 'attr',
1465 @current_journal.details << JournalDetail.new(:property => 'attr',
1457 :prop_key => c,
1466 :prop_key => c,
1458 :old_value => before,
1467 :old_value => before,
1459 :value => after)
1468 :value => after)
1460 }
1469 }
1461 end
1470 end
1462 if @custom_values_before_change
1471 if @custom_values_before_change
1463 # custom fields changes
1472 # custom fields changes
1464 custom_field_values.each {|c|
1473 custom_field_values.each {|c|
1465 before = @custom_values_before_change[c.custom_field_id]
1474 before = @custom_values_before_change[c.custom_field_id]
1466 after = c.value
1475 after = c.value
1467 next if before == after || (before.blank? && after.blank?)
1476 next if before == after || (before.blank? && after.blank?)
1468
1477
1469 if before.is_a?(Array) || after.is_a?(Array)
1478 if before.is_a?(Array) || after.is_a?(Array)
1470 before = [before] unless before.is_a?(Array)
1479 before = [before] unless before.is_a?(Array)
1471 after = [after] unless after.is_a?(Array)
1480 after = [after] unless after.is_a?(Array)
1472
1481
1473 # values removed
1482 # values removed
1474 (before - after).reject(&:blank?).each do |value|
1483 (before - after).reject(&:blank?).each do |value|
1475 @current_journal.details << JournalDetail.new(:property => 'cf',
1484 @current_journal.details << JournalDetail.new(:property => 'cf',
1476 :prop_key => c.custom_field_id,
1485 :prop_key => c.custom_field_id,
1477 :old_value => value,
1486 :old_value => value,
1478 :value => nil)
1487 :value => nil)
1479 end
1488 end
1480 # values added
1489 # values added
1481 (after - before).reject(&:blank?).each do |value|
1490 (after - before).reject(&:blank?).each do |value|
1482 @current_journal.details << JournalDetail.new(:property => 'cf',
1491 @current_journal.details << JournalDetail.new(:property => 'cf',
1483 :prop_key => c.custom_field_id,
1492 :prop_key => c.custom_field_id,
1484 :old_value => nil,
1493 :old_value => nil,
1485 :value => value)
1494 :value => value)
1486 end
1495 end
1487 else
1496 else
1488 @current_journal.details << JournalDetail.new(:property => 'cf',
1497 @current_journal.details << JournalDetail.new(:property => 'cf',
1489 :prop_key => c.custom_field_id,
1498 :prop_key => c.custom_field_id,
1490 :old_value => before,
1499 :old_value => before,
1491 :value => after)
1500 :value => after)
1492 end
1501 end
1493 }
1502 }
1494 end
1503 end
1495 @current_journal.save
1504 @current_journal.save
1496 # reset current journal
1505 # reset current journal
1497 init_journal @current_journal.user, @current_journal.notes
1506 init_journal @current_journal.user, @current_journal.notes
1498 end
1507 end
1499 end
1508 end
1500
1509
1501 def send_notification
1510 def send_notification
1502 if Setting.notified_events.include?('issue_added')
1511 if Setting.notified_events.include?('issue_added')
1503 Mailer.deliver_issue_add(self)
1512 Mailer.deliver_issue_add(self)
1504 end
1513 end
1505 end
1514 end
1506
1515
1507 # Query generator for selecting groups of issue counts for a project
1516 # Query generator for selecting groups of issue counts for a project
1508 # based on specific criteria
1517 # based on specific criteria
1509 #
1518 #
1510 # Options
1519 # Options
1511 # * project - Project to search in.
1520 # * project - Project to search in.
1512 # * field - String. Issue field to key off of in the grouping.
1521 # * field - String. Issue field to key off of in the grouping.
1513 # * joins - String. The table name to join against.
1522 # * joins - String. The table name to join against.
1514 def self.count_and_group_by(options)
1523 def self.count_and_group_by(options)
1515 project = options.delete(:project)
1524 project = options.delete(:project)
1516 select_field = options.delete(:field)
1525 select_field = options.delete(:field)
1517 joins = options.delete(:joins)
1526 joins = options.delete(:joins)
1518
1527
1519 where = "#{Issue.table_name}.#{select_field}=j.id"
1528 where = "#{Issue.table_name}.#{select_field}=j.id"
1520
1529
1521 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1530 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1522 s.is_closed as closed,
1531 s.is_closed as closed,
1523 j.id as #{select_field},
1532 j.id as #{select_field},
1524 count(#{Issue.table_name}.id) as total
1533 count(#{Issue.table_name}.id) as total
1525 from
1534 from
1526 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1535 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1527 where
1536 where
1528 #{Issue.table_name}.status_id=s.id
1537 #{Issue.table_name}.status_id=s.id
1529 and #{where}
1538 and #{where}
1530 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1539 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1531 and #{visible_condition(User.current, :project => project)}
1540 and #{visible_condition(User.current, :project => project)}
1532 group by s.id, s.is_closed, j.id")
1541 group by s.id, s.is_closed, j.id")
1533 end
1542 end
1534 end
1543 end
@@ -1,369 +1,404
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2013 Jean-Philippe Lang
2 # Copyright (C) 2006-2013 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class IssueNestedSetTest < ActiveSupport::TestCase
20 class IssueNestedSetTest < ActiveSupport::TestCase
21 fixtures :projects, :users, :roles,
21 fixtures :projects, :users, :roles,
22 :trackers, :projects_trackers,
22 :trackers, :projects_trackers,
23 :issue_statuses, :issue_categories, :issue_relations,
23 :issue_statuses, :issue_categories, :issue_relations,
24 :enumerations,
24 :enumerations,
25 :issues
25 :issues
26
26
27 def test_create_root_issue
27 def test_create_root_issue
28 issue1 = Issue.generate!
28 issue1 = Issue.generate!
29 issue2 = Issue.generate!
29 issue2 = Issue.generate!
30 issue1.reload
30 issue1.reload
31 issue2.reload
31 issue2.reload
32
32
33 assert_equal [issue1.id, nil, 1, 2], [issue1.root_id, issue1.parent_id, issue1.lft, issue1.rgt]
33 assert_equal [issue1.id, nil, 1, 2], [issue1.root_id, issue1.parent_id, issue1.lft, issue1.rgt]
34 assert_equal [issue2.id, nil, 1, 2], [issue2.root_id, issue2.parent_id, issue2.lft, issue2.rgt]
34 assert_equal [issue2.id, nil, 1, 2], [issue2.root_id, issue2.parent_id, issue2.lft, issue2.rgt]
35 end
35 end
36
36
37 def test_create_child_issue
37 def test_create_child_issue
38 parent = Issue.generate!
38 parent = Issue.generate!
39 child = Issue.generate!(:parent_issue_id => parent.id)
39 child = Issue.generate!(:parent_issue_id => parent.id)
40 parent.reload
40 parent.reload
41 child.reload
41 child.reload
42
42
43 assert_equal [parent.id, nil, 1, 4], [parent.root_id, parent.parent_id, parent.lft, parent.rgt]
43 assert_equal [parent.id, nil, 1, 4], [parent.root_id, parent.parent_id, parent.lft, parent.rgt]
44 assert_equal [parent.id, parent.id, 2, 3], [child.root_id, child.parent_id, child.lft, child.rgt]
44 assert_equal [parent.id, parent.id, 2, 3], [child.root_id, child.parent_id, child.lft, child.rgt]
45 end
45 end
46
46
47 def test_creating_a_child_in_a_subproject_should_validate
47 def test_creating_a_child_in_a_subproject_should_validate
48 issue = Issue.generate!
48 issue = Issue.generate!
49 child = Issue.new(:project_id => 3, :tracker_id => 2, :author_id => 1,
49 child = Issue.new(:project_id => 3, :tracker_id => 2, :author_id => 1,
50 :subject => 'child', :parent_issue_id => issue.id)
50 :subject => 'child', :parent_issue_id => issue.id)
51 assert_save child
51 assert_save child
52 assert_equal issue, child.reload.parent
52 assert_equal issue, child.reload.parent
53 end
53 end
54
54
55 def test_creating_a_child_in_an_invalid_project_should_not_validate
55 def test_creating_a_child_in_an_invalid_project_should_not_validate
56 issue = Issue.generate!
56 issue = Issue.generate!
57 child = Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1,
57 child = Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1,
58 :subject => 'child', :parent_issue_id => issue.id)
58 :subject => 'child', :parent_issue_id => issue.id)
59 assert !child.save
59 assert !child.save
60 assert_not_equal [], child.errors[:parent_issue_id]
60 assert_not_equal [], child.errors[:parent_issue_id]
61 end
61 end
62
62
63 def test_move_a_root_to_child
63 def test_move_a_root_to_child
64 parent1 = Issue.generate!
64 parent1 = Issue.generate!
65 parent2 = Issue.generate!
65 parent2 = Issue.generate!
66 child = Issue.generate!(:parent_issue_id => parent1.id)
66 child = Issue.generate!(:parent_issue_id => parent1.id)
67
67
68 parent2.parent_issue_id = parent1.id
68 parent2.parent_issue_id = parent1.id
69 parent2.save!
69 parent2.save!
70 child.reload
70 child.reload
71 parent1.reload
71 parent1.reload
72 parent2.reload
72 parent2.reload
73
73
74 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
74 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
75 assert_equal [parent1.id, 4, 5], [parent2.root_id, parent2.lft, parent2.rgt]
75 assert_equal [parent1.id, 4, 5], [parent2.root_id, parent2.lft, parent2.rgt]
76 assert_equal [parent1.id, 2, 3], [child.root_id, child.lft, child.rgt]
76 assert_equal [parent1.id, 2, 3], [child.root_id, child.lft, child.rgt]
77 end
77 end
78
78
79 def test_move_a_child_to_root
79 def test_move_a_child_to_root
80 parent1 = Issue.generate!
80 parent1 = Issue.generate!
81 parent2 = Issue.generate!
81 parent2 = Issue.generate!
82 child = Issue.generate!(:parent_issue_id => parent1.id)
82 child = Issue.generate!(:parent_issue_id => parent1.id)
83
83
84 child.parent_issue_id = nil
84 child.parent_issue_id = nil
85 child.save!
85 child.save!
86 child.reload
86 child.reload
87 parent1.reload
87 parent1.reload
88 parent2.reload
88 parent2.reload
89
89
90 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
90 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
91 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
91 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
92 assert_equal [child.id, 1, 2], [child.root_id, child.lft, child.rgt]
92 assert_equal [child.id, 1, 2], [child.root_id, child.lft, child.rgt]
93 end
93 end
94
94
95 def test_move_a_child_to_another_issue
95 def test_move_a_child_to_another_issue
96 parent1 = Issue.generate!
96 parent1 = Issue.generate!
97 parent2 = Issue.generate!
97 parent2 = Issue.generate!
98 child = Issue.generate!(:parent_issue_id => parent1.id)
98 child = Issue.generate!(:parent_issue_id => parent1.id)
99
99
100 child.parent_issue_id = parent2.id
100 child.parent_issue_id = parent2.id
101 child.save!
101 child.save!
102 child.reload
102 child.reload
103 parent1.reload
103 parent1.reload
104 parent2.reload
104 parent2.reload
105
105
106 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
106 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
107 assert_equal [parent2.id, 1, 4], [parent2.root_id, parent2.lft, parent2.rgt]
107 assert_equal [parent2.id, 1, 4], [parent2.root_id, parent2.lft, parent2.rgt]
108 assert_equal [parent2.id, 2, 3], [child.root_id, child.lft, child.rgt]
108 assert_equal [parent2.id, 2, 3], [child.root_id, child.lft, child.rgt]
109 end
109 end
110
110
111 def test_move_a_child_with_descendants_to_another_issue
111 def test_move_a_child_with_descendants_to_another_issue
112 parent1 = Issue.generate!
112 parent1 = Issue.generate!
113 parent2 = Issue.generate!
113 parent2 = Issue.generate!
114 child = Issue.generate!(:parent_issue_id => parent1.id)
114 child = Issue.generate!(:parent_issue_id => parent1.id)
115 grandchild = Issue.generate!(:parent_issue_id => child.id)
115 grandchild = Issue.generate!(:parent_issue_id => child.id)
116
116
117 parent1.reload
117 parent1.reload
118 parent2.reload
118 parent2.reload
119 child.reload
119 child.reload
120 grandchild.reload
120 grandchild.reload
121
121
122 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
122 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
123 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
123 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
124 assert_equal [parent1.id, 2, 5], [child.root_id, child.lft, child.rgt]
124 assert_equal [parent1.id, 2, 5], [child.root_id, child.lft, child.rgt]
125 assert_equal [parent1.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
125 assert_equal [parent1.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
126
126
127 child.reload.parent_issue_id = parent2.id
127 child.reload.parent_issue_id = parent2.id
128 child.save!
128 child.save!
129 child.reload
129 child.reload
130 grandchild.reload
130 grandchild.reload
131 parent1.reload
131 parent1.reload
132 parent2.reload
132 parent2.reload
133
133
134 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
134 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
135 assert_equal [parent2.id, 1, 6], [parent2.root_id, parent2.lft, parent2.rgt]
135 assert_equal [parent2.id, 1, 6], [parent2.root_id, parent2.lft, parent2.rgt]
136 assert_equal [parent2.id, 2, 5], [child.root_id, child.lft, child.rgt]
136 assert_equal [parent2.id, 2, 5], [child.root_id, child.lft, child.rgt]
137 assert_equal [parent2.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
137 assert_equal [parent2.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
138 end
138 end
139
139
140 def test_move_a_child_with_descendants_to_another_project
140 def test_move_a_child_with_descendants_to_another_project
141 parent1 = Issue.generate!
141 parent1 = Issue.generate!
142 child = Issue.generate!(:parent_issue_id => parent1.id)
142 child = Issue.generate!(:parent_issue_id => parent1.id)
143 grandchild = Issue.generate!(:parent_issue_id => child.id)
143 grandchild = Issue.generate!(:parent_issue_id => child.id)
144
144
145 child.reload
145 child.reload
146 child.project = Project.find(2)
146 child.project = Project.find(2)
147 assert child.save
147 assert child.save
148 child.reload
148 child.reload
149 grandchild.reload
149 grandchild.reload
150 parent1.reload
150 parent1.reload
151
151
152 assert_equal [1, parent1.id, 1, 2], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
152 assert_equal [1, parent1.id, 1, 2], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
153 assert_equal [2, child.id, 1, 4], [child.project_id, child.root_id, child.lft, child.rgt]
153 assert_equal [2, child.id, 1, 4], [child.project_id, child.root_id, child.lft, child.rgt]
154 assert_equal [2, child.id, 2, 3], [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
154 assert_equal [2, child.id, 2, 3], [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
155 end
155 end
156
156
157 def test_moving_an_issue_to_a_descendant_should_not_validate
157 def test_moving_an_issue_to_a_descendant_should_not_validate
158 parent1 = Issue.generate!
158 parent1 = Issue.generate!
159 parent2 = Issue.generate!
159 parent2 = Issue.generate!
160 child = Issue.generate!(:parent_issue_id => parent1.id)
160 child = Issue.generate!(:parent_issue_id => parent1.id)
161 grandchild = Issue.generate!(:parent_issue_id => child.id)
161 grandchild = Issue.generate!(:parent_issue_id => child.id)
162
162
163 child.reload
163 child.reload
164 child.parent_issue_id = grandchild.id
164 child.parent_issue_id = grandchild.id
165 assert !child.save
165 assert !child.save
166 assert_not_equal [], child.errors[:parent_issue_id]
166 assert_not_equal [], child.errors[:parent_issue_id]
167 end
167 end
168
168
169 def test_updating_a_root_issue_should_not_trigger_update_nested_set_attributes_on_parent_change
170 issue = Issue.find(Issue.generate!.id)
171 issue.parent_issue_id = ""
172 issue.expects(:update_nested_set_attributes_on_parent_change).never
173 issue.save!
174 end
175
176 def test_updating_a_child_issue_should_not_trigger_update_nested_set_attributes_on_parent_change
177 issue = Issue.find(Issue.generate!(:parent_issue_id => 1).id)
178 issue.parent_issue_id = "1"
179 issue.expects(:update_nested_set_attributes_on_parent_change).never
180 issue.save!
181 end
182
183 def test_moving_a_root_issue_should_trigger_update_nested_set_attributes_on_parent_change
184 issue = Issue.find(Issue.generate!.id)
185 issue.parent_issue_id = "1"
186 issue.expects(:update_nested_set_attributes_on_parent_change).once
187 issue.save!
188 end
189
190 def test_moving_a_child_issue_to_another_parent_should_trigger_update_nested_set_attributes_on_parent_change
191 issue = Issue.find(Issue.generate!(:parent_issue_id => 1).id)
192 issue.parent_issue_id = "2"
193 issue.expects(:update_nested_set_attributes_on_parent_change).once
194 issue.save!
195 end
196
197 def test_moving_a_child_issue_to_root_should_trigger_update_nested_set_attributes_on_parent_change
198 issue = Issue.find(Issue.generate!(:parent_issue_id => 1).id)
199 issue.parent_issue_id = ""
200 issue.expects(:update_nested_set_attributes_on_parent_change).once
201 issue.save!
202 end
203
169 def test_destroy_should_destroy_children
204 def test_destroy_should_destroy_children
170 issue1 = Issue.generate!
205 issue1 = Issue.generate!
171 issue2 = Issue.generate!
206 issue2 = Issue.generate!
172 issue3 = Issue.generate!(:parent_issue_id => issue2.id)
207 issue3 = Issue.generate!(:parent_issue_id => issue2.id)
173 issue4 = Issue.generate!(:parent_issue_id => issue1.id)
208 issue4 = Issue.generate!(:parent_issue_id => issue1.id)
174
209
175 issue3.init_journal(User.find(2))
210 issue3.init_journal(User.find(2))
176 issue3.subject = 'child with journal'
211 issue3.subject = 'child with journal'
177 issue3.save!
212 issue3.save!
178
213
179 assert_difference 'Issue.count', -2 do
214 assert_difference 'Issue.count', -2 do
180 assert_difference 'Journal.count', -1 do
215 assert_difference 'Journal.count', -1 do
181 assert_difference 'JournalDetail.count', -1 do
216 assert_difference 'JournalDetail.count', -1 do
182 Issue.find(issue2.id).destroy
217 Issue.find(issue2.id).destroy
183 end
218 end
184 end
219 end
185 end
220 end
186
221
187 issue1.reload
222 issue1.reload
188 issue4.reload
223 issue4.reload
189 assert !Issue.exists?(issue2.id)
224 assert !Issue.exists?(issue2.id)
190 assert !Issue.exists?(issue3.id)
225 assert !Issue.exists?(issue3.id)
191 assert_equal [issue1.id, 1, 4], [issue1.root_id, issue1.lft, issue1.rgt]
226 assert_equal [issue1.id, 1, 4], [issue1.root_id, issue1.lft, issue1.rgt]
192 assert_equal [issue1.id, 2, 3], [issue4.root_id, issue4.lft, issue4.rgt]
227 assert_equal [issue1.id, 2, 3], [issue4.root_id, issue4.lft, issue4.rgt]
193 end
228 end
194
229
195 def test_destroy_child_should_update_parent
230 def test_destroy_child_should_update_parent
196 issue = Issue.generate!
231 issue = Issue.generate!
197 child1 = Issue.generate!(:parent_issue_id => issue.id)
232 child1 = Issue.generate!(:parent_issue_id => issue.id)
198 child2 = Issue.generate!(:parent_issue_id => issue.id)
233 child2 = Issue.generate!(:parent_issue_id => issue.id)
199
234
200 issue.reload
235 issue.reload
201 assert_equal [issue.id, 1, 6], [issue.root_id, issue.lft, issue.rgt]
236 assert_equal [issue.id, 1, 6], [issue.root_id, issue.lft, issue.rgt]
202
237
203 child2.reload.destroy
238 child2.reload.destroy
204
239
205 issue.reload
240 issue.reload
206 assert_equal [issue.id, 1, 4], [issue.root_id, issue.lft, issue.rgt]
241 assert_equal [issue.id, 1, 4], [issue.root_id, issue.lft, issue.rgt]
207 end
242 end
208
243
209 def test_destroy_parent_issue_updated_during_children_destroy
244 def test_destroy_parent_issue_updated_during_children_destroy
210 parent = Issue.generate!
245 parent = Issue.generate!
211 Issue.generate!(:start_date => Date.today, :parent_issue_id => parent.id)
246 Issue.generate!(:start_date => Date.today, :parent_issue_id => parent.id)
212 Issue.generate!(:start_date => 2.days.from_now, :parent_issue_id => parent.id)
247 Issue.generate!(:start_date => 2.days.from_now, :parent_issue_id => parent.id)
213
248
214 assert_difference 'Issue.count', -3 do
249 assert_difference 'Issue.count', -3 do
215 Issue.find(parent.id).destroy
250 Issue.find(parent.id).destroy
216 end
251 end
217 end
252 end
218
253
219 def test_destroy_child_issue_with_children
254 def test_destroy_child_issue_with_children
220 root = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'root')
255 root = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'root')
221 child = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => root.id)
256 child = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => root.id)
222 leaf = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'leaf', :parent_issue_id => child.id)
257 leaf = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'leaf', :parent_issue_id => child.id)
223 leaf.init_journal(User.find(2))
258 leaf.init_journal(User.find(2))
224 leaf.subject = 'leaf with journal'
259 leaf.subject = 'leaf with journal'
225 leaf.save!
260 leaf.save!
226
261
227 assert_difference 'Issue.count', -2 do
262 assert_difference 'Issue.count', -2 do
228 assert_difference 'Journal.count', -1 do
263 assert_difference 'Journal.count', -1 do
229 assert_difference 'JournalDetail.count', -1 do
264 assert_difference 'JournalDetail.count', -1 do
230 Issue.find(child.id).destroy
265 Issue.find(child.id).destroy
231 end
266 end
232 end
267 end
233 end
268 end
234
269
235 root = Issue.find(root.id)
270 root = Issue.find(root.id)
236 assert root.leaf?, "Root issue is not a leaf (lft: #{root.lft}, rgt: #{root.rgt})"
271 assert root.leaf?, "Root issue is not a leaf (lft: #{root.lft}, rgt: #{root.rgt})"
237 end
272 end
238
273
239 def test_destroy_issue_with_grand_child
274 def test_destroy_issue_with_grand_child
240 parent = Issue.generate!
275 parent = Issue.generate!
241 issue = Issue.generate!(:parent_issue_id => parent.id)
276 issue = Issue.generate!(:parent_issue_id => parent.id)
242 child = Issue.generate!(:parent_issue_id => issue.id)
277 child = Issue.generate!(:parent_issue_id => issue.id)
243 grandchild1 = Issue.generate!(:parent_issue_id => child.id)
278 grandchild1 = Issue.generate!(:parent_issue_id => child.id)
244 grandchild2 = Issue.generate!(:parent_issue_id => child.id)
279 grandchild2 = Issue.generate!(:parent_issue_id => child.id)
245
280
246 assert_difference 'Issue.count', -4 do
281 assert_difference 'Issue.count', -4 do
247 Issue.find(issue.id).destroy
282 Issue.find(issue.id).destroy
248 parent.reload
283 parent.reload
249 assert_equal [1, 2], [parent.lft, parent.rgt]
284 assert_equal [1, 2], [parent.lft, parent.rgt]
250 end
285 end
251 end
286 end
252
287
253 def test_parent_priority_should_be_the_highest_child_priority
288 def test_parent_priority_should_be_the_highest_child_priority
254 parent = Issue.generate!(:priority => IssuePriority.find_by_name('Normal'))
289 parent = Issue.generate!(:priority => IssuePriority.find_by_name('Normal'))
255 # Create children
290 # Create children
256 child1 = Issue.generate!(:priority => IssuePriority.find_by_name('High'), :parent_issue_id => parent.id)
291 child1 = Issue.generate!(:priority => IssuePriority.find_by_name('High'), :parent_issue_id => parent.id)
257 assert_equal 'High', parent.reload.priority.name
292 assert_equal 'High', parent.reload.priority.name
258 child2 = Issue.generate!(:priority => IssuePriority.find_by_name('Immediate'), :parent_issue_id => child1.id)
293 child2 = Issue.generate!(:priority => IssuePriority.find_by_name('Immediate'), :parent_issue_id => child1.id)
259 assert_equal 'Immediate', child1.reload.priority.name
294 assert_equal 'Immediate', child1.reload.priority.name
260 assert_equal 'Immediate', parent.reload.priority.name
295 assert_equal 'Immediate', parent.reload.priority.name
261 child3 = Issue.generate!(:priority => IssuePriority.find_by_name('Low'), :parent_issue_id => parent.id)
296 child3 = Issue.generate!(:priority => IssuePriority.find_by_name('Low'), :parent_issue_id => parent.id)
262 assert_equal 'Immediate', parent.reload.priority.name
297 assert_equal 'Immediate', parent.reload.priority.name
263 # Destroy a child
298 # Destroy a child
264 child1.destroy
299 child1.destroy
265 assert_equal 'Low', parent.reload.priority.name
300 assert_equal 'Low', parent.reload.priority.name
266 # Update a child
301 # Update a child
267 child3.reload.priority = IssuePriority.find_by_name('Normal')
302 child3.reload.priority = IssuePriority.find_by_name('Normal')
268 child3.save!
303 child3.save!
269 assert_equal 'Normal', parent.reload.priority.name
304 assert_equal 'Normal', parent.reload.priority.name
270 end
305 end
271
306
272 def test_parent_dates_should_be_lowest_start_and_highest_due_dates
307 def test_parent_dates_should_be_lowest_start_and_highest_due_dates
273 parent = Issue.generate!
308 parent = Issue.generate!
274 Issue.generate!(:start_date => '2010-01-25', :due_date => '2010-02-15', :parent_issue_id => parent.id)
309 Issue.generate!(:start_date => '2010-01-25', :due_date => '2010-02-15', :parent_issue_id => parent.id)
275 Issue.generate!( :due_date => '2010-02-13', :parent_issue_id => parent.id)
310 Issue.generate!( :due_date => '2010-02-13', :parent_issue_id => parent.id)
276 Issue.generate!(:start_date => '2010-02-01', :due_date => '2010-02-22', :parent_issue_id => parent.id)
311 Issue.generate!(:start_date => '2010-02-01', :due_date => '2010-02-22', :parent_issue_id => parent.id)
277 parent.reload
312 parent.reload
278 assert_equal Date.parse('2010-01-25'), parent.start_date
313 assert_equal Date.parse('2010-01-25'), parent.start_date
279 assert_equal Date.parse('2010-02-22'), parent.due_date
314 assert_equal Date.parse('2010-02-22'), parent.due_date
280 end
315 end
281
316
282 def test_parent_done_ratio_should_be_average_done_ratio_of_leaves
317 def test_parent_done_ratio_should_be_average_done_ratio_of_leaves
283 parent = Issue.generate!
318 parent = Issue.generate!
284 Issue.generate!(:done_ratio => 20, :parent_issue_id => parent.id)
319 Issue.generate!(:done_ratio => 20, :parent_issue_id => parent.id)
285 assert_equal 20, parent.reload.done_ratio
320 assert_equal 20, parent.reload.done_ratio
286 Issue.generate!(:done_ratio => 70, :parent_issue_id => parent.id)
321 Issue.generate!(:done_ratio => 70, :parent_issue_id => parent.id)
287 assert_equal 45, parent.reload.done_ratio
322 assert_equal 45, parent.reload.done_ratio
288
323
289 child = Issue.generate!(:done_ratio => 0, :parent_issue_id => parent.id)
324 child = Issue.generate!(:done_ratio => 0, :parent_issue_id => parent.id)
290 assert_equal 30, parent.reload.done_ratio
325 assert_equal 30, parent.reload.done_ratio
291
326
292 Issue.generate!(:done_ratio => 30, :parent_issue_id => child.id)
327 Issue.generate!(:done_ratio => 30, :parent_issue_id => child.id)
293 assert_equal 30, child.reload.done_ratio
328 assert_equal 30, child.reload.done_ratio
294 assert_equal 40, parent.reload.done_ratio
329 assert_equal 40, parent.reload.done_ratio
295 end
330 end
296
331
297 def test_parent_done_ratio_should_be_weighted_by_estimated_times_if_any
332 def test_parent_done_ratio_should_be_weighted_by_estimated_times_if_any
298 parent = Issue.generate!
333 parent = Issue.generate!
299 Issue.generate!(:estimated_hours => 10, :done_ratio => 20, :parent_issue_id => parent.id)
334 Issue.generate!(:estimated_hours => 10, :done_ratio => 20, :parent_issue_id => parent.id)
300 assert_equal 20, parent.reload.done_ratio
335 assert_equal 20, parent.reload.done_ratio
301 Issue.generate!(:estimated_hours => 20, :done_ratio => 50, :parent_issue_id => parent.id)
336 Issue.generate!(:estimated_hours => 20, :done_ratio => 50, :parent_issue_id => parent.id)
302 assert_equal (50 * 20 + 20 * 10) / 30, parent.reload.done_ratio
337 assert_equal (50 * 20 + 20 * 10) / 30, parent.reload.done_ratio
303 end
338 end
304
339
305 def test_parent_done_ratio_with_child_estimate_to_0_should_reach_100
340 def test_parent_done_ratio_with_child_estimate_to_0_should_reach_100
306 parent = Issue.generate!
341 parent = Issue.generate!
307 issue1 = Issue.generate!(:parent_issue_id => parent.id)
342 issue1 = Issue.generate!(:parent_issue_id => parent.id)
308 issue2 = Issue.generate!(:parent_issue_id => parent.id, :estimated_hours => 0)
343 issue2 = Issue.generate!(:parent_issue_id => parent.id, :estimated_hours => 0)
309 assert_equal 0, parent.reload.done_ratio
344 assert_equal 0, parent.reload.done_ratio
310 issue1.reload.update_attribute :status_id, 5
345 issue1.reload.update_attribute :status_id, 5
311 assert_equal 50, parent.reload.done_ratio
346 assert_equal 50, parent.reload.done_ratio
312 issue2.reload.update_attribute :status_id, 5
347 issue2.reload.update_attribute :status_id, 5
313 assert_equal 100, parent.reload.done_ratio
348 assert_equal 100, parent.reload.done_ratio
314 end
349 end
315
350
316 def test_parent_estimate_should_be_sum_of_leaves
351 def test_parent_estimate_should_be_sum_of_leaves
317 parent = Issue.generate!
352 parent = Issue.generate!
318 Issue.generate!(:estimated_hours => nil, :parent_issue_id => parent.id)
353 Issue.generate!(:estimated_hours => nil, :parent_issue_id => parent.id)
319 assert_equal nil, parent.reload.estimated_hours
354 assert_equal nil, parent.reload.estimated_hours
320 Issue.generate!(:estimated_hours => 5, :parent_issue_id => parent.id)
355 Issue.generate!(:estimated_hours => 5, :parent_issue_id => parent.id)
321 assert_equal 5, parent.reload.estimated_hours
356 assert_equal 5, parent.reload.estimated_hours
322 Issue.generate!(:estimated_hours => 7, :parent_issue_id => parent.id)
357 Issue.generate!(:estimated_hours => 7, :parent_issue_id => parent.id)
323 assert_equal 12, parent.reload.estimated_hours
358 assert_equal 12, parent.reload.estimated_hours
324 end
359 end
325
360
326 def test_move_parent_updates_old_parent_attributes
361 def test_move_parent_updates_old_parent_attributes
327 first_parent = Issue.generate!
362 first_parent = Issue.generate!
328 second_parent = Issue.generate!
363 second_parent = Issue.generate!
329 child = Issue.generate!(:estimated_hours => 5, :parent_issue_id => first_parent.id)
364 child = Issue.generate!(:estimated_hours => 5, :parent_issue_id => first_parent.id)
330 assert_equal 5, first_parent.reload.estimated_hours
365 assert_equal 5, first_parent.reload.estimated_hours
331 child.update_attributes(:estimated_hours => 7, :parent_issue_id => second_parent.id)
366 child.update_attributes(:estimated_hours => 7, :parent_issue_id => second_parent.id)
332 assert_equal 7, second_parent.reload.estimated_hours
367 assert_equal 7, second_parent.reload.estimated_hours
333 assert_nil first_parent.reload.estimated_hours
368 assert_nil first_parent.reload.estimated_hours
334 end
369 end
335
370
336 def test_reschuling_a_parent_should_reschedule_subtasks
371 def test_reschuling_a_parent_should_reschedule_subtasks
337 parent = Issue.generate!
372 parent = Issue.generate!
338 c1 = Issue.generate!(:start_date => '2010-05-12', :due_date => '2010-05-18', :parent_issue_id => parent.id)
373 c1 = Issue.generate!(:start_date => '2010-05-12', :due_date => '2010-05-18', :parent_issue_id => parent.id)
339 c2 = Issue.generate!(:start_date => '2010-06-03', :due_date => '2010-06-10', :parent_issue_id => parent.id)
374 c2 = Issue.generate!(:start_date => '2010-06-03', :due_date => '2010-06-10', :parent_issue_id => parent.id)
340 parent.reload
375 parent.reload
341 parent.reschedule_on!(Date.parse('2010-06-02'))
376 parent.reschedule_on!(Date.parse('2010-06-02'))
342 c1.reload
377 c1.reload
343 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-08')], [c1.start_date, c1.due_date]
378 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-08')], [c1.start_date, c1.due_date]
344 c2.reload
379 c2.reload
345 assert_equal [Date.parse('2010-06-03'), Date.parse('2010-06-10')], [c2.start_date, c2.due_date] # no change
380 assert_equal [Date.parse('2010-06-03'), Date.parse('2010-06-10')], [c2.start_date, c2.due_date] # no change
346 parent.reload
381 parent.reload
347 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-10')], [parent.start_date, parent.due_date]
382 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-10')], [parent.start_date, parent.due_date]
348 end
383 end
349
384
350 def test_project_copy_should_copy_issue_tree
385 def test_project_copy_should_copy_issue_tree
351 p = Project.create!(:name => 'Tree copy', :identifier => 'tree-copy', :tracker_ids => [1, 2])
386 p = Project.create!(:name => 'Tree copy', :identifier => 'tree-copy', :tracker_ids => [1, 2])
352 i1 = Issue.generate!(:project => p, :subject => 'i1')
387 i1 = Issue.generate!(:project => p, :subject => 'i1')
353 i2 = Issue.generate!(:project => p, :subject => 'i2', :parent_issue_id => i1.id)
388 i2 = Issue.generate!(:project => p, :subject => 'i2', :parent_issue_id => i1.id)
354 i3 = Issue.generate!(:project => p, :subject => 'i3', :parent_issue_id => i1.id)
389 i3 = Issue.generate!(:project => p, :subject => 'i3', :parent_issue_id => i1.id)
355 i4 = Issue.generate!(:project => p, :subject => 'i4', :parent_issue_id => i2.id)
390 i4 = Issue.generate!(:project => p, :subject => 'i4', :parent_issue_id => i2.id)
356 i5 = Issue.generate!(:project => p, :subject => 'i5')
391 i5 = Issue.generate!(:project => p, :subject => 'i5')
357 c = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1, 2])
392 c = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1, 2])
358 c.copy(p, :only => 'issues')
393 c.copy(p, :only => 'issues')
359 c.reload
394 c.reload
360
395
361 assert_equal 5, c.issues.count
396 assert_equal 5, c.issues.count
362 ic1, ic2, ic3, ic4, ic5 = c.issues.order('subject').all
397 ic1, ic2, ic3, ic4, ic5 = c.issues.order('subject').all
363 assert ic1.root?
398 assert ic1.root?
364 assert_equal ic1, ic2.parent
399 assert_equal ic1, ic2.parent
365 assert_equal ic1, ic3.parent
400 assert_equal ic1, ic3.parent
366 assert_equal ic2, ic4.parent
401 assert_equal ic2, ic4.parent
367 assert ic5.root?
402 assert ic5.root?
368 end
403 end
369 end
404 end
General Comments 0
You need to be logged in to leave comments. Login now