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