##// END OF EJS Templates
Makes subtasks rescheduled when a 'precedes' relation is set on a parent task....
Jean-Philippe Lang -
r3460:d550c46160de
parent child
Show More
@@ -1,779 +1,796
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 belongs_to :project
20 20 belongs_to :tracker
21 21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
26 26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27 27
28 28 has_many :journals, :as => :journalized, :dependent => :destroy
29 29 has_many :time_entries, :dependent => :delete_all
30 30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31 31
32 32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34 34
35 35 acts_as_nested_set :scope => 'root_id'
36 36 acts_as_attachable :after_remove => :attachment_removed
37 37 acts_as_customizable
38 38 acts_as_watchable
39 39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
40 40 :include => [:project, :journals],
41 41 # sort by id so that limited eager loading doesn't break with postgresql
42 42 :order_column => "#{table_name}.id"
43 43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
44 44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
45 45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
46 46
47 47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
48 48 :author_key => :author_id
49 49
50 50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
51 51
52 52 attr_reader :current_journal
53 53
54 54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
55 55
56 56 validates_length_of :subject, :maximum => 255
57 57 validates_inclusion_of :done_ratio, :in => 0..100
58 58 validates_numericality_of :estimated_hours, :allow_nil => true
59 59
60 60 named_scope :visible, lambda {|*args| { :include => :project,
61 61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
62 62
63 63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
64 64
65 65 named_scope :recently_updated, :order => "#{self.table_name}.updated_on DESC"
66 66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
67 67 named_scope :on_active_project, :include => [:status, :project, :tracker],
68 68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
69 69
70 70 before_create :default_assign
71 71 before_save :reschedule_following_issues, :close_duplicates, :update_done_ratio_from_issue_status
72 72 after_save :update_nested_set_attributes, :update_parent_attributes, :create_journal
73 73 after_destroy :destroy_children
74 74 after_destroy :update_parent_attributes
75 75
76 76 # Returns true if usr or current user is allowed to view the issue
77 77 def visible?(usr=nil)
78 78 (usr || User.current).allowed_to?(:view_issues, self.project)
79 79 end
80 80
81 81 def after_initialize
82 82 if new_record?
83 83 # set default values for new records only
84 84 self.status ||= IssueStatus.default
85 85 self.priority ||= IssuePriority.default
86 86 end
87 87 end
88 88
89 89 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
90 90 def available_custom_fields
91 91 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
92 92 end
93 93
94 94 def copy_from(arg)
95 95 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
96 96 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
97 97 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
98 98 self.status = issue.status
99 99 self
100 100 end
101 101
102 102 # Moves/copies an issue to a new project and tracker
103 103 # Returns the moved/copied issue on success, false on failure
104 104 def move_to_project(*args)
105 105 ret = Issue.transaction do
106 106 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
107 107 end || false
108 108 end
109 109
110 110 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
111 111 options ||= {}
112 112 issue = options[:copy] ? self.class.new.copy_from(self) : self
113 113
114 114 if new_project && issue.project_id != new_project.id
115 115 # delete issue relations
116 116 unless Setting.cross_project_issue_relations?
117 117 issue.relations_from.clear
118 118 issue.relations_to.clear
119 119 end
120 120 # issue is moved to another project
121 121 # reassign to the category with same name if any
122 122 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
123 123 issue.category = new_category
124 124 # Keep the fixed_version if it's still valid in the new_project
125 125 unless new_project.shared_versions.include?(issue.fixed_version)
126 126 issue.fixed_version = nil
127 127 end
128 128 issue.project = new_project
129 129 if issue.parent && issue.parent.project_id != issue.project_id
130 130 issue.parent_issue_id = nil
131 131 end
132 132 end
133 133 if new_tracker
134 134 issue.tracker = new_tracker
135 135 issue.reset_custom_values!
136 136 end
137 137 if options[:copy]
138 138 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
139 139 issue.status = if options[:attributes] && options[:attributes][:status_id]
140 140 IssueStatus.find_by_id(options[:attributes][:status_id])
141 141 else
142 142 self.status
143 143 end
144 144 end
145 145 # Allow bulk setting of attributes on the issue
146 146 if options[:attributes]
147 147 issue.attributes = options[:attributes]
148 148 end
149 149 if issue.save
150 150 unless options[:copy]
151 151 # Manually update project_id on related time entries
152 152 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
153 153
154 154 issue.children.each do |child|
155 155 unless child.move_to_project_without_transaction(new_project)
156 156 # Move failed and transaction was rollback'd
157 157 return false
158 158 end
159 159 end
160 160 end
161 161 else
162 162 return false
163 163 end
164 164 issue
165 165 end
166 166
167 167 def priority_id=(pid)
168 168 self.priority = nil
169 169 write_attribute(:priority_id, pid)
170 170 end
171 171
172 172 def tracker_id=(tid)
173 173 self.tracker = nil
174 174 result = write_attribute(:tracker_id, tid)
175 175 @custom_field_values = nil
176 176 result
177 177 end
178 178
179 179 # Overrides attributes= so that tracker_id gets assigned first
180 180 def attributes_with_tracker_first=(new_attributes, *args)
181 181 return if new_attributes.nil?
182 182 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
183 183 if new_tracker_id
184 184 self.tracker_id = new_tracker_id
185 185 end
186 186 send :attributes_without_tracker_first=, new_attributes, *args
187 187 end
188 188 # Do not redefine alias chain on reload (see #4838)
189 189 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
190 190
191 191 def estimated_hours=(h)
192 192 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
193 193 end
194 194
195 195 SAFE_ATTRIBUTES = %w(
196 196 tracker_id
197 197 status_id
198 198 parent_issue_id
199 199 category_id
200 200 assigned_to_id
201 201 priority_id
202 202 fixed_version_id
203 203 subject
204 204 description
205 205 start_date
206 206 due_date
207 207 done_ratio
208 208 estimated_hours
209 209 custom_field_values
210 210 ) unless const_defined?(:SAFE_ATTRIBUTES)
211 211
212 212 # Safely sets attributes
213 213 # Should be called from controllers instead of #attributes=
214 214 # attr_accessible is too rough because we still want things like
215 215 # Issue.new(:project => foo) to work
216 216 # TODO: move workflow/permission checks from controllers to here
217 217 def safe_attributes=(attrs, user=User.current)
218 218 return if attrs.nil?
219 219 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
220 220 if attrs['status_id']
221 221 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
222 222 attrs.delete('status_id')
223 223 end
224 224 end
225 225
226 226 unless leaf?
227 227 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
228 228 end
229 229
230 230 if attrs.has_key?('parent_issue_id')
231 231 if !user.allowed_to?(:manage_subtasks, project)
232 232 attrs.delete('parent_issue_id')
233 233 elsif !attrs['parent_issue_id'].blank?
234 234 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'])
235 235 end
236 236 end
237 237
238 238 self.attributes = attrs
239 239 end
240 240
241 241 def done_ratio
242 242 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
243 243 status.default_done_ratio
244 244 else
245 245 read_attribute(:done_ratio)
246 246 end
247 247 end
248 248
249 249 def self.use_status_for_done_ratio?
250 250 Setting.issue_done_ratio == 'issue_status'
251 251 end
252 252
253 253 def self.use_field_for_done_ratio?
254 254 Setting.issue_done_ratio == 'issue_field'
255 255 end
256 256
257 257 def validate
258 258 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
259 259 errors.add :due_date, :not_a_date
260 260 end
261 261
262 262 if self.due_date and self.start_date and self.due_date < self.start_date
263 263 errors.add :due_date, :greater_than_start_date
264 264 end
265 265
266 266 if start_date && soonest_start && start_date < soonest_start
267 267 errors.add :start_date, :invalid
268 268 end
269 269
270 270 if fixed_version
271 271 if !assignable_versions.include?(fixed_version)
272 272 errors.add :fixed_version_id, :inclusion
273 273 elsif reopened? && fixed_version.closed?
274 274 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
275 275 end
276 276 end
277 277
278 278 # Checks that the issue can not be added/moved to a disabled tracker
279 279 if project && (tracker_id_changed? || project_id_changed?)
280 280 unless project.trackers.include?(tracker)
281 281 errors.add :tracker_id, :inclusion
282 282 end
283 283 end
284 284
285 285 # Checks parent issue assignment
286 286 if @parent_issue
287 287 if @parent_issue.project_id != project_id
288 288 errors.add :parent_issue_id, :not_same_project
289 289 elsif !new_record?
290 290 # moving an existing issue
291 291 if @parent_issue.root_id != root_id
292 292 # we can always move to another tree
293 293 elsif move_possible?(@parent_issue)
294 294 # move accepted inside tree
295 295 else
296 296 errors.add :parent_issue_id, :not_a_valid_parent
297 297 end
298 298 end
299 299 end
300 300 end
301 301
302 302 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
303 303 # even if the user turns off the setting later
304 304 def update_done_ratio_from_issue_status
305 305 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
306 306 self.done_ratio = status.default_done_ratio
307 307 end
308 308 end
309 309
310 310 def init_journal(user, notes = "")
311 311 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
312 312 @issue_before_change = self.clone
313 313 @issue_before_change.status = self.status
314 314 @custom_values_before_change = {}
315 315 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
316 316 # Make sure updated_on is updated when adding a note.
317 317 updated_on_will_change!
318 318 @current_journal
319 319 end
320 320
321 321 # Return true if the issue is closed, otherwise false
322 322 def closed?
323 323 self.status.is_closed?
324 324 end
325 325
326 326 # Return true if the issue is being reopened
327 327 def reopened?
328 328 if !new_record? && status_id_changed?
329 329 status_was = IssueStatus.find_by_id(status_id_was)
330 330 status_new = IssueStatus.find_by_id(status_id)
331 331 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
332 332 return true
333 333 end
334 334 end
335 335 false
336 336 end
337 337
338 338 # Return true if the issue is being closed
339 339 def closing?
340 340 if !new_record? && status_id_changed?
341 341 status_was = IssueStatus.find_by_id(status_id_was)
342 342 status_new = IssueStatus.find_by_id(status_id)
343 343 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
344 344 return true
345 345 end
346 346 end
347 347 false
348 348 end
349 349
350 350 # Returns true if the issue is overdue
351 351 def overdue?
352 352 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
353 353 end
354 354
355 355 # Users the issue can be assigned to
356 356 def assignable_users
357 357 project.assignable_users
358 358 end
359 359
360 360 # Versions that the issue can be assigned to
361 361 def assignable_versions
362 362 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
363 363 end
364 364
365 365 # Returns true if this issue is blocked by another issue that is still open
366 366 def blocked?
367 367 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
368 368 end
369 369
370 370 # Returns an array of status that user is able to apply
371 371 def new_statuses_allowed_to(user, include_default=false)
372 372 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
373 373 statuses << status unless statuses.empty?
374 374 statuses << IssueStatus.default if include_default
375 375 statuses = statuses.uniq.sort
376 376 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
377 377 end
378 378
379 379 # Returns the mail adresses of users that should be notified
380 380 def recipients
381 381 notified = project.notified_users
382 382 # Author and assignee are always notified unless they have been locked
383 383 notified << author if author && author.active?
384 384 notified << assigned_to if assigned_to && assigned_to.active?
385 385 notified.uniq!
386 386 # Remove users that can not view the issue
387 387 notified.reject! {|user| !visible?(user)}
388 388 notified.collect(&:mail)
389 389 end
390 390
391 391 # Returns the total number of hours spent on this issue and its descendants
392 392 #
393 393 # Example:
394 394 # spent_hours => 0.0
395 395 # spent_hours => 50.2
396 396 def spent_hours
397 397 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
398 398 end
399 399
400 400 def relations
401 401 (relations_from + relations_to).sort
402 402 end
403 403
404 404 def all_dependent_issues
405 405 dependencies = []
406 406 relations_from.each do |relation|
407 407 dependencies << relation.issue_to
408 408 dependencies += relation.issue_to.all_dependent_issues
409 409 end
410 410 dependencies
411 411 end
412 412
413 413 # Returns an array of issues that duplicate this one
414 414 def duplicates
415 415 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
416 416 end
417 417
418 418 # Returns the due date or the target due date if any
419 419 # Used on gantt chart
420 420 def due_before
421 421 due_date || (fixed_version ? fixed_version.effective_date : nil)
422 422 end
423 423
424 424 # Returns the time scheduled for this issue.
425 425 #
426 426 # Example:
427 427 # Start Date: 2/26/09, End Date: 3/04/09
428 428 # duration => 6
429 429 def duration
430 430 (start_date && due_date) ? due_date - start_date : 0
431 431 end
432 432
433 433 def soonest_start
434 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
434 @soonest_start ||= (
435 relations_to.collect{|relation| relation.successor_soonest_start} +
436 ancestors.collect(&:soonest_start)
437 ).compact.max
438 end
439
440 def reschedule_after(date)
441 return if date.nil?
442 if leaf?
443 if start_date.nil? || start_date < date
444 self.start_date, self.due_date = date, date + duration
445 save
446 end
447 else
448 leaves.each do |leaf|
449 leaf.reschedule_after(date)
450 end
451 end
435 452 end
436 453
437 454 def <=>(issue)
438 455 if issue.nil?
439 456 -1
440 457 elsif root_id != issue.root_id
441 458 (root_id || 0) <=> (issue.root_id || 0)
442 459 else
443 460 (lft || 0) <=> (issue.lft || 0)
444 461 end
445 462 end
446 463
447 464 def to_s
448 465 "#{tracker} ##{id}: #{subject}"
449 466 end
450 467
451 468 # Returns a string of css classes that apply to the issue
452 469 def css_classes
453 470 s = "issue status-#{status.position} priority-#{priority.position}"
454 471 s << ' closed' if closed?
455 472 s << ' overdue' if overdue?
456 473 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
457 474 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
458 475 s
459 476 end
460 477
461 478 # Saves an issue, time_entry, attachments, and a journal from the parameters
462 479 def save_issue_with_child_records(params, existing_time_entry=nil)
463 480 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
464 481 @time_entry = existing_time_entry || TimeEntry.new
465 482 @time_entry.project = project
466 483 @time_entry.issue = self
467 484 @time_entry.user = User.current
468 485 @time_entry.spent_on = Date.today
469 486 @time_entry.attributes = params[:time_entry]
470 487 self.time_entries << @time_entry
471 488 end
472 489
473 490 if valid?
474 491 attachments = Attachment.attach_files(self, params[:attachments])
475 492
476 493 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
477 494 # TODO: Rename hook
478 495 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
479 496 if save
480 497 # TODO: Rename hook
481 498 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
482 499 return true
483 500 end
484 501 end
485 502 # failure, returns false
486 503
487 504 end
488 505
489 506 # Unassigns issues from +version+ if it's no longer shared with issue's project
490 507 def self.update_versions_from_sharing_change(version)
491 508 # Update issues assigned to the version
492 509 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
493 510 end
494 511
495 512 # Unassigns issues from versions that are no longer shared
496 513 # after +project+ was moved
497 514 def self.update_versions_from_hierarchy_change(project)
498 515 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
499 516 # Update issues of the moved projects and issues assigned to a version of a moved project
500 517 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
501 518 end
502 519
503 520 def parent_issue_id=(arg)
504 521 parent_issue_id = arg.blank? ? nil : arg.to_i
505 522 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
506 523 @parent_issue.id
507 524 else
508 525 @parent_issue = nil
509 526 nil
510 527 end
511 528 end
512 529
513 530 def parent_issue_id
514 531 if instance_variable_defined? :@parent_issue
515 532 @parent_issue.nil? ? nil : @parent_issue.id
516 533 else
517 534 parent_id
518 535 end
519 536 end
520 537
521 538 # Extracted from the ReportsController.
522 539 def self.by_tracker(project)
523 540 count_and_group_by(:project => project,
524 541 :field => 'tracker_id',
525 542 :joins => Tracker.table_name)
526 543 end
527 544
528 545 def self.by_version(project)
529 546 count_and_group_by(:project => project,
530 547 :field => 'fixed_version_id',
531 548 :joins => Version.table_name)
532 549 end
533 550
534 551 def self.by_priority(project)
535 552 count_and_group_by(:project => project,
536 553 :field => 'priority_id',
537 554 :joins => IssuePriority.table_name)
538 555 end
539 556
540 557 def self.by_category(project)
541 558 count_and_group_by(:project => project,
542 559 :field => 'category_id',
543 560 :joins => IssueCategory.table_name)
544 561 end
545 562
546 563 def self.by_assigned_to(project)
547 564 count_and_group_by(:project => project,
548 565 :field => 'assigned_to_id',
549 566 :joins => User.table_name)
550 567 end
551 568
552 569 def self.by_author(project)
553 570 count_and_group_by(:project => project,
554 571 :field => 'author_id',
555 572 :joins => User.table_name)
556 573 end
557 574
558 575 def self.by_subproject(project)
559 576 ActiveRecord::Base.connection.select_all("select s.id as status_id,
560 577 s.is_closed as closed,
561 578 i.project_id as project_id,
562 579 count(i.id) as total
563 580 from
564 581 #{Issue.table_name} i, #{IssueStatus.table_name} s
565 582 where
566 583 i.status_id=s.id
567 584 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
568 585 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
569 586 end
570 587 # End ReportsController extraction
571 588
572 589 private
573 590
574 591 def update_nested_set_attributes
575 592 if root_id.nil?
576 593 # issue was just created
577 594 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
578 595 set_default_left_and_right
579 596 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
580 597 if @parent_issue
581 598 move_to_child_of(@parent_issue)
582 599 end
583 600 reload
584 601 elsif parent_issue_id != parent_id
585 602 # moving an existing issue
586 603 if @parent_issue && @parent_issue.root_id == root_id
587 604 # inside the same tree
588 605 move_to_child_of(@parent_issue)
589 606 else
590 607 # to another tree
591 608 unless root?
592 609 move_to_right_of(root)
593 610 reload
594 611 end
595 612 old_root_id = root_id
596 613 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
597 614 target_maxright = nested_set_scope.maximum(right_column_name) || 0
598 615 offset = target_maxright + 1 - lft
599 616 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
600 617 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
601 618 self[left_column_name] = lft + offset
602 619 self[right_column_name] = rgt + offset
603 620 if @parent_issue
604 621 move_to_child_of(@parent_issue)
605 622 end
606 623 end
607 624 reload
608 625 # delete invalid relations of all descendants
609 626 self_and_descendants.each do |issue|
610 627 issue.relations.each do |relation|
611 628 relation.destroy unless relation.valid?
612 629 end
613 630 end
614 631 end
615 632 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
616 633 end
617 634
618 635 def update_parent_attributes
619 636 if parent_id && p = Issue.find_by_id(parent_id)
620 637 # priority = highest priority of children
621 638 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
622 639 p.priority = IssuePriority.find_by_position(priority_position)
623 640 end
624 641
625 642 # start/due dates = lowest/highest dates of children
626 643 p.start_date = p.children.minimum(:start_date)
627 644 p.due_date = p.children.maximum(:due_date)
628 645 if p.start_date && p.due_date && p.due_date < p.start_date
629 646 p.start_date, p.due_date = p.due_date, p.start_date
630 647 end
631 648
632 649 # done ratio = weighted average ratio of leaves
633 650 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio?
634 651 leaves_count = p.leaves.count
635 652 if leaves_count > 0
636 653 average = p.leaves.average(:estimated_hours).to_f
637 654 if average == 0
638 655 average = 1
639 656 end
640 657 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
641 658 progress = done / (average * leaves_count)
642 659 p.done_ratio = progress.round
643 660 end
644 661 end
645 662
646 663 # estimate = sum of leaves estimates
647 664 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
648 665 p.estimated_hours = nil if p.estimated_hours == 0.0
649 666
650 667 # ancestors will be recursively updated
651 668 p.save(false)
652 669 end
653 670 end
654 671
655 672 def destroy_children
656 673 unless leaf?
657 674 children.each do |child|
658 675 child.destroy
659 676 end
660 677 end
661 678 end
662 679
663 680 # Update issues so their versions are not pointing to a
664 681 # fixed_version that is not shared with the issue's project
665 682 def self.update_versions(conditions=nil)
666 683 # Only need to update issues with a fixed_version from
667 684 # a different project and that is not systemwide shared
668 685 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
669 686 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
670 687 " AND #{Version.table_name}.sharing <> 'system'",
671 688 conditions),
672 689 :include => [:project, :fixed_version]
673 690 ).each do |issue|
674 691 next if issue.project.nil? || issue.fixed_version.nil?
675 692 unless issue.project.shared_versions.include?(issue.fixed_version)
676 693 issue.init_journal(User.current)
677 694 issue.fixed_version = nil
678 695 issue.save
679 696 end
680 697 end
681 698 end
682 699
683 700 # Callback on attachment deletion
684 701 def attachment_removed(obj)
685 702 journal = init_journal(User.current)
686 703 journal.details << JournalDetail.new(:property => 'attachment',
687 704 :prop_key => obj.id,
688 705 :old_value => obj.filename)
689 706 journal.save
690 707 end
691 708
692 709 # Default assignment based on category
693 710 def default_assign
694 711 if assigned_to.nil? && category && category.assigned_to
695 712 self.assigned_to = category.assigned_to
696 713 end
697 714 end
698 715
699 716 # Updates start/due dates of following issues
700 717 def reschedule_following_issues
701 718 if start_date_changed? || due_date_changed?
702 719 relations_from.each do |relation|
703 720 relation.set_issue_to_dates
704 721 end
705 722 end
706 723 end
707 724
708 725 # Closes duplicates if the issue is being closed
709 726 def close_duplicates
710 727 if closing?
711 728 duplicates.each do |duplicate|
712 729 # Reload is need in case the duplicate was updated by a previous duplicate
713 730 duplicate.reload
714 731 # Don't re-close it if it's already closed
715 732 next if duplicate.closed?
716 733 # Same user and notes
717 734 if @current_journal
718 735 duplicate.init_journal(@current_journal.user, @current_journal.notes)
719 736 end
720 737 duplicate.update_attribute :status, self.status
721 738 end
722 739 end
723 740 end
724 741
725 742 # Saves the changes in a Journal
726 743 # Called after_save
727 744 def create_journal
728 745 if @current_journal
729 746 # attributes changes
730 747 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
731 748 @current_journal.details << JournalDetail.new(:property => 'attr',
732 749 :prop_key => c,
733 750 :old_value => @issue_before_change.send(c),
734 751 :value => send(c)) unless send(c)==@issue_before_change.send(c)
735 752 }
736 753 # custom fields changes
737 754 custom_values.each {|c|
738 755 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
739 756 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
740 757 @current_journal.details << JournalDetail.new(:property => 'cf',
741 758 :prop_key => c.custom_field_id,
742 759 :old_value => @custom_values_before_change[c.custom_field_id],
743 760 :value => c.value)
744 761 }
745 762 @current_journal.save
746 763 # reset current journal
747 764 init_journal @current_journal.user, @current_journal.notes
748 765 end
749 766 end
750 767
751 768 # Query generator for selecting groups of issue counts for a project
752 769 # based on specific criteria
753 770 #
754 771 # Options
755 772 # * project - Project to search in.
756 773 # * field - String. Issue field to key off of in the grouping.
757 774 # * joins - String. The table name to join against.
758 775 def self.count_and_group_by(options)
759 776 project = options.delete(:project)
760 777 select_field = options.delete(:field)
761 778 joins = options.delete(:joins)
762 779
763 780 where = "i.#{select_field}=j.id"
764 781
765 782 ActiveRecord::Base.connection.select_all("select s.id as status_id,
766 783 s.is_closed as closed,
767 784 j.id as #{select_field},
768 785 count(i.id) as total
769 786 from
770 787 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} as j
771 788 where
772 789 i.status_id=s.id
773 790 and #{where}
774 791 and i.project_id=#{project.id}
775 792 group by s.id, s.is_closed, j.id")
776 793 end
777 794
778 795
779 796 end
@@ -1,102 +1,101
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 IssueRelation < ActiveRecord::Base
19 19 belongs_to :issue_from, :class_name => 'Issue', :foreign_key => 'issue_from_id'
20 20 belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id'
21 21
22 22 TYPE_RELATES = "relates"
23 23 TYPE_DUPLICATES = "duplicates"
24 24 TYPE_DUPLICATED = "duplicated"
25 25 TYPE_BLOCKS = "blocks"
26 26 TYPE_BLOCKED = "blocked"
27 27 TYPE_PRECEDES = "precedes"
28 28 TYPE_FOLLOWS = "follows"
29 29
30 30 TYPES = { TYPE_RELATES => { :name => :label_relates_to, :sym_name => :label_relates_to, :order => 1 },
31 31 TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicated_by, :order => 2 },
32 32 TYPE_DUPLICATED => { :name => :label_duplicated_by, :sym_name => :label_duplicates, :order => 3, :reverse => TYPE_DUPLICATES },
33 33 TYPE_BLOCKS => { :name => :label_blocks, :sym_name => :label_blocked_by, :order => 4 },
34 34 TYPE_BLOCKED => { :name => :label_blocked_by, :sym_name => :label_blocks, :order => 5, :reverse => TYPE_BLOCKS },
35 35 TYPE_PRECEDES => { :name => :label_precedes, :sym_name => :label_follows, :order => 6 },
36 36 TYPE_FOLLOWS => { :name => :label_follows, :sym_name => :label_precedes, :order => 7, :reverse => TYPE_PRECEDES }
37 37 }.freeze
38 38
39 39 validates_presence_of :issue_from, :issue_to, :relation_type
40 40 validates_inclusion_of :relation_type, :in => TYPES.keys
41 41 validates_numericality_of :delay, :allow_nil => true
42 42 validates_uniqueness_of :issue_to_id, :scope => :issue_from_id
43 43
44 44 attr_protected :issue_from_id, :issue_to_id
45 45
46 46 def validate
47 47 if issue_from && issue_to
48 48 errors.add :issue_to_id, :invalid if issue_from_id == issue_to_id
49 49 errors.add :issue_to_id, :not_same_project unless issue_from.project_id == issue_to.project_id || Setting.cross_project_issue_relations?
50 50 errors.add_to_base :circular_dependency if issue_to.all_dependent_issues.include? issue_from
51 51 errors.add_to_base :cant_link_an_issue_with_a_descendant if issue_from.is_descendant_of?(issue_to) || issue_from.is_ancestor_of?(issue_to)
52 52 end
53 53 end
54 54
55 55 def other_issue(issue)
56 56 (self.issue_from_id == issue.id) ? issue_to : issue_from
57 57 end
58 58
59 59 def label_for(issue)
60 60 TYPES[relation_type] ? TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] : :unknow
61 61 end
62 62
63 63 def before_save
64 64 reverse_if_needed
65 65
66 66 if TYPE_PRECEDES == relation_type
67 67 self.delay ||= 0
68 68 else
69 69 self.delay = nil
70 70 end
71 71 set_issue_to_dates
72 72 end
73 73
74 74 def set_issue_to_dates
75 75 soonest_start = self.successor_soonest_start
76 if soonest_start && (!issue_to.start_date || issue_to.start_date < soonest_start)
77 issue_to.start_date, issue_to.due_date = successor_soonest_start, successor_soonest_start + issue_to.duration
78 issue_to.save
76 if soonest_start
77 issue_to.reschedule_after(soonest_start)
79 78 end
80 79 end
81 80
82 81 def successor_soonest_start
83 82 return nil unless (TYPE_PRECEDES == self.relation_type) && (issue_from.start_date || issue_from.due_date)
84 83 (issue_from.due_date || issue_from.start_date) + 1 + delay
85 84 end
86 85
87 86 def <=>(relation)
88 87 TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order]
89 88 end
90 89
91 90 private
92 91
93 92 # Reverses the relation if needed so that it gets stored in the proper way
94 93 def reverse_if_needed
95 94 if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
96 95 issue_tmp = issue_to
97 96 self.issue_to = issue_from
98 97 self.issue_from = issue_tmp
99 98 self.relation_type = TYPES[relation_type][:reverse]
100 99 end
101 100 end
102 101 end
@@ -1,300 +1,315
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class IssueNestedSetTest < ActiveSupport::TestCase
21 21 fixtures :projects, :users, :members, :member_roles, :roles,
22 22 :trackers, :projects_trackers,
23 23 :versions,
24 24 :issue_statuses, :issue_categories, :issue_relations, :workflows,
25 25 :enumerations,
26 26 :issues,
27 27 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
28 28 :time_entries
29 29
30 30 self.use_transactional_fixtures = false
31 31
32 32 def test_create_root_issue
33 33 issue1 = create_issue!
34 34 issue2 = create_issue!
35 35 issue1.reload
36 36 issue2.reload
37 37
38 38 assert_equal [issue1.id, nil, 1, 2], [issue1.root_id, issue1.parent_id, issue1.lft, issue1.rgt]
39 39 assert_equal [issue2.id, nil, 1, 2], [issue2.root_id, issue2.parent_id, issue2.lft, issue2.rgt]
40 40 end
41 41
42 42 def test_create_child_issue
43 43 parent = create_issue!
44 44 child = create_issue!(:parent_issue_id => parent.id)
45 45 parent.reload
46 46 child.reload
47 47
48 48 assert_equal [parent.id, nil, 1, 4], [parent.root_id, parent.parent_id, parent.lft, parent.rgt]
49 49 assert_equal [parent.id, parent.id, 2, 3], [child.root_id, child.parent_id, child.lft, child.rgt]
50 50 end
51 51
52 52 def test_creating_a_child_in_different_project_should_not_validate
53 53 issue = create_issue!
54 54 child = Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1, :subject => 'child', :parent_issue_id => issue.id)
55 55 assert !child.save
56 56 assert_not_nil child.errors.on(:parent_issue_id)
57 57 end
58 58
59 59 def test_move_a_root_to_child
60 60 parent1 = create_issue!
61 61 parent2 = create_issue!
62 62 child = create_issue!(:parent_issue_id => parent1.id)
63 63
64 64 parent2.parent_issue_id = parent1.id
65 65 parent2.save!
66 66 child.reload
67 67 parent1.reload
68 68 parent2.reload
69 69
70 70 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
71 71 assert_equal [parent1.id, 4, 5], [parent2.root_id, parent2.lft, parent2.rgt]
72 72 assert_equal [parent1.id, 2, 3], [child.root_id, child.lft, child.rgt]
73 73 end
74 74
75 75 def test_move_a_child_to_root
76 76 parent1 = create_issue!
77 77 parent2 = create_issue!
78 78 child = create_issue!(:parent_issue_id => parent1.id)
79 79
80 80 child.parent_issue_id = nil
81 81 child.save!
82 82 child.reload
83 83 parent1.reload
84 84 parent2.reload
85 85
86 86 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
87 87 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
88 88 assert_equal [child.id, 1, 2], [child.root_id, child.lft, child.rgt]
89 89 end
90 90
91 91 def test_move_a_child_to_another_issue
92 92 parent1 = create_issue!
93 93 parent2 = create_issue!
94 94 child = create_issue!(:parent_issue_id => parent1.id)
95 95
96 96 child.parent_issue_id = parent2.id
97 97 child.save!
98 98 child.reload
99 99 parent1.reload
100 100 parent2.reload
101 101
102 102 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
103 103 assert_equal [parent2.id, 1, 4], [parent2.root_id, parent2.lft, parent2.rgt]
104 104 assert_equal [parent2.id, 2, 3], [child.root_id, child.lft, child.rgt]
105 105 end
106 106
107 107 def test_move_a_child_with_descendants_to_another_issue
108 108 parent1 = create_issue!
109 109 parent2 = create_issue!
110 110 child = create_issue!(:parent_issue_id => parent1.id)
111 111 grandchild = create_issue!(:parent_issue_id => child.id)
112 112
113 113 parent1.reload
114 114 parent2.reload
115 115 child.reload
116 116 grandchild.reload
117 117
118 118 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
119 119 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
120 120 assert_equal [parent1.id, 2, 5], [child.root_id, child.lft, child.rgt]
121 121 assert_equal [parent1.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
122 122
123 123 child.reload.parent_issue_id = parent2.id
124 124 child.save!
125 125 child.reload
126 126 grandchild.reload
127 127 parent1.reload
128 128 parent2.reload
129 129
130 130 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
131 131 assert_equal [parent2.id, 1, 6], [parent2.root_id, parent2.lft, parent2.rgt]
132 132 assert_equal [parent2.id, 2, 5], [child.root_id, child.lft, child.rgt]
133 133 assert_equal [parent2.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
134 134 end
135 135
136 136 def test_move_a_child_with_descendants_to_another_project
137 137 parent1 = create_issue!
138 138 child = create_issue!(:parent_issue_id => parent1.id)
139 139 grandchild = create_issue!(:parent_issue_id => child.id)
140 140
141 141 assert child.reload.move_to_project(Project.find(2))
142 142 child.reload
143 143 grandchild.reload
144 144 parent1.reload
145 145
146 146 assert_equal [1, parent1.id, 1, 2], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
147 147 assert_equal [2, child.id, 1, 4], [child.project_id, child.root_id, child.lft, child.rgt]
148 148 assert_equal [2, child.id, 2, 3], [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
149 149 end
150 150
151 151 def test_invalid_move_to_another_project
152 152 parent1 = create_issue!
153 153 child = create_issue!(:parent_issue_id => parent1.id)
154 154 grandchild = create_issue!(:parent_issue_id => child.id, :tracker_id => 2)
155 155 Project.find(2).tracker_ids = [1]
156 156
157 157 parent1.reload
158 158 assert_equal [1, parent1.id, 1, 6], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
159 159
160 160 # child can not be moved to Project 2 because its child is on a disabled tracker
161 161 assert_equal false, Issue.find(child.id).move_to_project(Project.find(2))
162 162 child.reload
163 163 grandchild.reload
164 164 parent1.reload
165 165
166 166 # no change
167 167 assert_equal [1, parent1.id, 1, 6], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
168 168 assert_equal [1, parent1.id, 2, 5], [child.project_id, child.root_id, child.lft, child.rgt]
169 169 assert_equal [1, parent1.id, 3, 4], [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
170 170 end
171 171
172 172 def test_moving_an_issue_to_a_descendant_should_not_validate
173 173 parent1 = create_issue!
174 174 parent2 = create_issue!
175 175 child = create_issue!(:parent_issue_id => parent1.id)
176 176 grandchild = create_issue!(:parent_issue_id => child.id)
177 177
178 178 child.reload
179 179 child.parent_issue_id = grandchild.id
180 180 assert !child.save
181 181 assert_not_nil child.errors.on(:parent_issue_id)
182 182 end
183 183
184 184 def test_moving_an_issue_should_keep_valid_relations_only
185 185 issue1 = create_issue!
186 186 issue2 = create_issue!
187 187 issue3 = create_issue!(:parent_issue_id => issue2.id)
188 188 issue4 = create_issue!
189 189 r1 = IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
190 190 r2 = IssueRelation.create!(:issue_from => issue1, :issue_to => issue3, :relation_type => IssueRelation::TYPE_PRECEDES)
191 191 r3 = IssueRelation.create!(:issue_from => issue2, :issue_to => issue4, :relation_type => IssueRelation::TYPE_PRECEDES)
192 192 issue2.reload
193 193 issue2.parent_issue_id = issue1.id
194 194 issue2.save!
195 195 assert !IssueRelation.exists?(r1.id)
196 196 assert !IssueRelation.exists?(r2.id)
197 197 assert IssueRelation.exists?(r3.id)
198 198 end
199 199
200 200 def test_destroy_should_destroy_children
201 201 issue1 = create_issue!
202 202 issue2 = create_issue!
203 203 issue3 = create_issue!(:parent_issue_id => issue2.id)
204 204 issue4 = create_issue!(:parent_issue_id => issue1.id)
205 205 issue2.reload.destroy
206 206 issue1.reload
207 207 issue4.reload
208 208 assert !Issue.exists?(issue2.id)
209 209 assert !Issue.exists?(issue3.id)
210 210 assert_equal [issue1.id, 1, 4], [issue1.root_id, issue1.lft, issue1.rgt]
211 211 assert_equal [issue1.id, 2, 3], [issue4.root_id, issue4.lft, issue4.rgt]
212 212 end
213 213
214 214 def test_parent_priority_should_be_the_highest_child_priority
215 215 parent = create_issue!(:priority => IssuePriority.find_by_name('Normal'))
216 216 # Create children
217 217 child1 = create_issue!(:priority => IssuePriority.find_by_name('High'), :parent_issue_id => parent.id)
218 218 assert_equal 'High', parent.reload.priority.name
219 219 child2 = create_issue!(:priority => IssuePriority.find_by_name('Immediate'), :parent_issue_id => child1.id)
220 220 assert_equal 'Immediate', child1.reload.priority.name
221 221 assert_equal 'Immediate', parent.reload.priority.name
222 222 child3 = create_issue!(:priority => IssuePriority.find_by_name('Low'), :parent_issue_id => parent.id)
223 223 assert_equal 'Immediate', parent.reload.priority.name
224 224 # Destroy a child
225 225 child1.destroy
226 226 assert_equal 'Low', parent.reload.priority.name
227 227 # Update a child
228 228 child3.reload.priority = IssuePriority.find_by_name('Normal')
229 229 child3.save!
230 230 assert_equal 'Normal', parent.reload.priority.name
231 231 end
232 232
233 233 def test_parent_dates_should_be_lowest_start_and_highest_due_dates
234 234 parent = create_issue!
235 235 create_issue!(:start_date => '2010-01-25', :due_date => '2010-02-15', :parent_issue_id => parent.id)
236 236 create_issue!( :due_date => '2010-02-13', :parent_issue_id => parent.id)
237 237 create_issue!(:start_date => '2010-02-01', :due_date => '2010-02-22', :parent_issue_id => parent.id)
238 238 parent.reload
239 239 assert_equal Date.parse('2010-01-25'), parent.start_date
240 240 assert_equal Date.parse('2010-02-22'), parent.due_date
241 241 end
242 242
243 243 def test_parent_done_ratio_should_be_average_done_ratio_of_leaves
244 244 parent = create_issue!
245 245 create_issue!(:done_ratio => 20, :parent_issue_id => parent.id)
246 246 assert_equal 20, parent.reload.done_ratio
247 247 create_issue!(:done_ratio => 70, :parent_issue_id => parent.id)
248 248 assert_equal 45, parent.reload.done_ratio
249 249
250 250 child = create_issue!(:done_ratio => 0, :parent_issue_id => parent.id)
251 251 assert_equal 30, parent.reload.done_ratio
252 252
253 253 create_issue!(:done_ratio => 30, :parent_issue_id => child.id)
254 254 assert_equal 30, child.reload.done_ratio
255 255 assert_equal 40, parent.reload.done_ratio
256 256 end
257 257
258 258 def test_parent_done_ratio_should_be_weighted_by_estimated_times_if_any
259 259 parent = create_issue!
260 260 create_issue!(:estimated_hours => 10, :done_ratio => 20, :parent_issue_id => parent.id)
261 261 assert_equal 20, parent.reload.done_ratio
262 262 create_issue!(:estimated_hours => 20, :done_ratio => 50, :parent_issue_id => parent.id)
263 263 assert_equal (50 * 20 + 20 * 10) / 30, parent.reload.done_ratio
264 264 end
265 265
266 266 def test_parent_estimate_should_be_sum_of_leaves
267 267 parent = create_issue!
268 268 create_issue!(:estimated_hours => nil, :parent_issue_id => parent.id)
269 269 assert_equal nil, parent.reload.estimated_hours
270 270 create_issue!(:estimated_hours => 5, :parent_issue_id => parent.id)
271 271 assert_equal 5, parent.reload.estimated_hours
272 272 create_issue!(:estimated_hours => 7, :parent_issue_id => parent.id)
273 273 assert_equal 12, parent.reload.estimated_hours
274 274 end
275
276
277 def test_reschuling_a_parent_should_reschedule_subtasks
278 parent = create_issue!
279 c1 = create_issue!(:start_date => '2010-05-12', :due_date => '2010-05-18', :parent_issue_id => parent.id)
280 c2 = create_issue!(:start_date => '2010-06-03', :due_date => '2010-06-10', :parent_issue_id => parent.id)
281 parent.reload
282 parent.reschedule_after(Date.parse('2010-06-02'))
283 c1.reload
284 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-08')], [c1.start_date, c1.due_date]
285 c2.reload
286 assert_equal [Date.parse('2010-06-03'), Date.parse('2010-06-10')], [c2.start_date, c2.due_date] # no change
287 parent.reload
288 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-10')], [parent.start_date, parent.due_date]
289 end
275 290
276 291 def test_project_copy_should_copy_issue_tree
277 292 p = Project.create!(:name => 'Tree copy', :identifier => 'tree-copy', :tracker_ids => [1, 2])
278 293 i1 = create_issue!(:project_id => p.id, :subject => 'i1')
279 294 i2 = create_issue!(:project_id => p.id, :subject => 'i2', :parent_issue_id => i1.id)
280 295 i3 = create_issue!(:project_id => p.id, :subject => 'i3', :parent_issue_id => i1.id)
281 296 i4 = create_issue!(:project_id => p.id, :subject => 'i4', :parent_issue_id => i2.id)
282 297 i5 = create_issue!(:project_id => p.id, :subject => 'i5')
283 298 c = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1, 2])
284 299 c.copy(p, :only => 'issues')
285 300 c.reload
286 301
287 302 assert_equal 5, c.issues.count
288 303 ic1, ic2, ic3, ic4, ic5 = c.issues.find(:all, :order => 'subject')
289 304 assert ic1.root?
290 305 assert_equal ic1, ic2.parent
291 306 assert_equal ic1, ic3.parent
292 307 assert_equal ic2, ic4.parent
293 308 assert ic5.root?
294 309 end
295 310
296 311 # Helper that creates an issue with default attributes
297 312 def create_issue!(attributes={})
298 313 Issue.create!({:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'test'}.merge(attributes))
299 314 end
300 315 end
General Comments 0
You need to be logged in to leave comments. Login now