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