##// END OF EJS Templates
Recalculate inherited attributes on parents when a child is moved under a new parent. #5524...
Eric Davis -
r3707:c6201ae15b4f
parent child
Show More
@@ -1,826 +1,833
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.visible.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 status_id=(sid)
168 168 self.status = nil
169 169 write_attribute(:status_id, sid)
170 170 end
171 171
172 172 def priority_id=(pid)
173 173 self.priority = nil
174 174 write_attribute(:priority_id, pid)
175 175 end
176 176
177 177 def tracker_id=(tid)
178 178 self.tracker = nil
179 179 result = write_attribute(:tracker_id, tid)
180 180 @custom_field_values = nil
181 181 result
182 182 end
183 183
184 184 # Overrides attributes= so that tracker_id gets assigned first
185 185 def attributes_with_tracker_first=(new_attributes, *args)
186 186 return if new_attributes.nil?
187 187 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
188 188 if new_tracker_id
189 189 self.tracker_id = new_tracker_id
190 190 end
191 191 send :attributes_without_tracker_first=, new_attributes, *args
192 192 end
193 193 # Do not redefine alias chain on reload (see #4838)
194 194 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
195 195
196 196 def estimated_hours=(h)
197 197 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
198 198 end
199 199
200 200 SAFE_ATTRIBUTES = %w(
201 201 tracker_id
202 202 status_id
203 203 parent_issue_id
204 204 category_id
205 205 assigned_to_id
206 206 priority_id
207 207 fixed_version_id
208 208 subject
209 209 description
210 210 start_date
211 211 due_date
212 212 done_ratio
213 213 estimated_hours
214 214 custom_field_values
215 215 lock_version
216 216 ) unless const_defined?(:SAFE_ATTRIBUTES)
217 217
218 218 # Safely sets attributes
219 219 # Should be called from controllers instead of #attributes=
220 220 # attr_accessible is too rough because we still want things like
221 221 # Issue.new(:project => foo) to work
222 222 # TODO: move workflow/permission checks from controllers to here
223 223 def safe_attributes=(attrs, user=User.current)
224 224 return if attrs.nil?
225 225 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
226 226 if attrs['status_id']
227 227 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
228 228 attrs.delete('status_id')
229 229 end
230 230 end
231 231
232 232 unless leaf?
233 233 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
234 234 end
235 235
236 236 if attrs.has_key?('parent_issue_id')
237 237 if !user.allowed_to?(:manage_subtasks, project)
238 238 attrs.delete('parent_issue_id')
239 239 elsif !attrs['parent_issue_id'].blank?
240 240 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'])
241 241 end
242 242 end
243 243
244 244 self.attributes = attrs
245 245 end
246 246
247 247 def done_ratio
248 248 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
249 249 status.default_done_ratio
250 250 else
251 251 read_attribute(:done_ratio)
252 252 end
253 253 end
254 254
255 255 def self.use_status_for_done_ratio?
256 256 Setting.issue_done_ratio == 'issue_status'
257 257 end
258 258
259 259 def self.use_field_for_done_ratio?
260 260 Setting.issue_done_ratio == 'issue_field'
261 261 end
262 262
263 263 def validate
264 264 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
265 265 errors.add :due_date, :not_a_date
266 266 end
267 267
268 268 if self.due_date and self.start_date and self.due_date < self.start_date
269 269 errors.add :due_date, :greater_than_start_date
270 270 end
271 271
272 272 if start_date && soonest_start && start_date < soonest_start
273 273 errors.add :start_date, :invalid
274 274 end
275 275
276 276 if fixed_version
277 277 if !assignable_versions.include?(fixed_version)
278 278 errors.add :fixed_version_id, :inclusion
279 279 elsif reopened? && fixed_version.closed?
280 280 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
281 281 end
282 282 end
283 283
284 284 # Checks that the issue can not be added/moved to a disabled tracker
285 285 if project && (tracker_id_changed? || project_id_changed?)
286 286 unless project.trackers.include?(tracker)
287 287 errors.add :tracker_id, :inclusion
288 288 end
289 289 end
290 290
291 291 # Checks parent issue assignment
292 292 if @parent_issue
293 293 if @parent_issue.project_id != project_id
294 294 errors.add :parent_issue_id, :not_same_project
295 295 elsif !new_record?
296 296 # moving an existing issue
297 297 if @parent_issue.root_id != root_id
298 298 # we can always move to another tree
299 299 elsif move_possible?(@parent_issue)
300 300 # move accepted inside tree
301 301 else
302 302 errors.add :parent_issue_id, :not_a_valid_parent
303 303 end
304 304 end
305 305 end
306 306 end
307 307
308 308 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
309 309 # even if the user turns off the setting later
310 310 def update_done_ratio_from_issue_status
311 311 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
312 312 self.done_ratio = status.default_done_ratio
313 313 end
314 314 end
315 315
316 316 def init_journal(user, notes = "")
317 317 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
318 318 @issue_before_change = self.clone
319 319 @issue_before_change.status = self.status
320 320 @custom_values_before_change = {}
321 321 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
322 322 # Make sure updated_on is updated when adding a note.
323 323 updated_on_will_change!
324 324 @current_journal
325 325 end
326 326
327 327 # Return true if the issue is closed, otherwise false
328 328 def closed?
329 329 self.status.is_closed?
330 330 end
331 331
332 332 # Return true if the issue is being reopened
333 333 def reopened?
334 334 if !new_record? && status_id_changed?
335 335 status_was = IssueStatus.find_by_id(status_id_was)
336 336 status_new = IssueStatus.find_by_id(status_id)
337 337 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
338 338 return true
339 339 end
340 340 end
341 341 false
342 342 end
343 343
344 344 # Return true if the issue is being closed
345 345 def closing?
346 346 if !new_record? && status_id_changed?
347 347 status_was = IssueStatus.find_by_id(status_id_was)
348 348 status_new = IssueStatus.find_by_id(status_id)
349 349 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
350 350 return true
351 351 end
352 352 end
353 353 false
354 354 end
355 355
356 356 # Returns true if the issue is overdue
357 357 def overdue?
358 358 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
359 359 end
360 360
361 361 # Users the issue can be assigned to
362 362 def assignable_users
363 363 project.assignable_users
364 364 end
365 365
366 366 # Versions that the issue can be assigned to
367 367 def assignable_versions
368 368 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
369 369 end
370 370
371 371 # Returns true if this issue is blocked by another issue that is still open
372 372 def blocked?
373 373 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
374 374 end
375 375
376 376 # Returns an array of status that user is able to apply
377 377 def new_statuses_allowed_to(user, include_default=false)
378 378 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
379 379 statuses << status unless statuses.empty?
380 380 statuses << IssueStatus.default if include_default
381 381 statuses = statuses.uniq.sort
382 382 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
383 383 end
384 384
385 385 # Returns the mail adresses of users that should be notified
386 386 def recipients
387 387 notified = project.notified_users
388 388 # Author and assignee are always notified unless they have been locked
389 389 notified << author if author && author.active?
390 390 notified << assigned_to if assigned_to && assigned_to.active?
391 391 notified.uniq!
392 392 # Remove users that can not view the issue
393 393 notified.reject! {|user| !visible?(user)}
394 394 notified.collect(&:mail)
395 395 end
396 396
397 397 # Returns the total number of hours spent on this issue and its descendants
398 398 #
399 399 # Example:
400 400 # spent_hours => 0.0
401 401 # spent_hours => 50.2
402 402 def spent_hours
403 403 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
404 404 end
405 405
406 406 def relations
407 407 (relations_from + relations_to).sort
408 408 end
409 409
410 410 def all_dependent_issues
411 411 dependencies = []
412 412 relations_from.each do |relation|
413 413 dependencies << relation.issue_to
414 414 dependencies += relation.issue_to.all_dependent_issues
415 415 end
416 416 dependencies
417 417 end
418 418
419 419 # Returns an array of issues that duplicate this one
420 420 def duplicates
421 421 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
422 422 end
423 423
424 424 # Returns the due date or the target due date if any
425 425 # Used on gantt chart
426 426 def due_before
427 427 due_date || (fixed_version ? fixed_version.effective_date : nil)
428 428 end
429 429
430 430 # Returns the time scheduled for this issue.
431 431 #
432 432 # Example:
433 433 # Start Date: 2/26/09, End Date: 3/04/09
434 434 # duration => 6
435 435 def duration
436 436 (start_date && due_date) ? due_date - start_date : 0
437 437 end
438 438
439 439 def soonest_start
440 440 @soonest_start ||= (
441 441 relations_to.collect{|relation| relation.successor_soonest_start} +
442 442 ancestors.collect(&:soonest_start)
443 443 ).compact.max
444 444 end
445 445
446 446 def reschedule_after(date)
447 447 return if date.nil?
448 448 if leaf?
449 449 if start_date.nil? || start_date < date
450 450 self.start_date, self.due_date = date, date + duration
451 451 save
452 452 end
453 453 else
454 454 leaves.each do |leaf|
455 455 leaf.reschedule_after(date)
456 456 end
457 457 end
458 458 end
459 459
460 460 def <=>(issue)
461 461 if issue.nil?
462 462 -1
463 463 elsif root_id != issue.root_id
464 464 (root_id || 0) <=> (issue.root_id || 0)
465 465 else
466 466 (lft || 0) <=> (issue.lft || 0)
467 467 end
468 468 end
469 469
470 470 def to_s
471 471 "#{tracker} ##{id}: #{subject}"
472 472 end
473 473
474 474 # Returns a string of css classes that apply to the issue
475 475 def css_classes
476 476 s = "issue status-#{status.position} priority-#{priority.position}"
477 477 s << ' closed' if closed?
478 478 s << ' overdue' if overdue?
479 479 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
480 480 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
481 481 s
482 482 end
483 483
484 484 # Saves an issue, time_entry, attachments, and a journal from the parameters
485 485 # Returns false if save fails
486 486 def save_issue_with_child_records(params, existing_time_entry=nil)
487 487 Issue.transaction do
488 488 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
489 489 @time_entry = existing_time_entry || TimeEntry.new
490 490 @time_entry.project = project
491 491 @time_entry.issue = self
492 492 @time_entry.user = User.current
493 493 @time_entry.spent_on = Date.today
494 494 @time_entry.attributes = params[:time_entry]
495 495 self.time_entries << @time_entry
496 496 end
497 497
498 498 if valid?
499 499 attachments = Attachment.attach_files(self, params[:attachments])
500 500
501 501 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
502 502 # TODO: Rename hook
503 503 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
504 504 begin
505 505 if save
506 506 # TODO: Rename hook
507 507 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
508 508 else
509 509 raise ActiveRecord::Rollback
510 510 end
511 511 rescue ActiveRecord::StaleObjectError
512 512 attachments[:files].each(&:destroy)
513 513 errors.add_to_base l(:notice_locking_conflict)
514 514 raise ActiveRecord::Rollback
515 515 end
516 516 end
517 517 end
518 518 end
519 519
520 520 # Unassigns issues from +version+ if it's no longer shared with issue's project
521 521 def self.update_versions_from_sharing_change(version)
522 522 # Update issues assigned to the version
523 523 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
524 524 end
525 525
526 526 # Unassigns issues from versions that are no longer shared
527 527 # after +project+ was moved
528 528 def self.update_versions_from_hierarchy_change(project)
529 529 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
530 530 # Update issues of the moved projects and issues assigned to a version of a moved project
531 531 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
532 532 end
533 533
534 534 def parent_issue_id=(arg)
535 535 parent_issue_id = arg.blank? ? nil : arg.to_i
536 536 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
537 537 @parent_issue.id
538 538 else
539 539 @parent_issue = nil
540 540 nil
541 541 end
542 542 end
543 543
544 544 def parent_issue_id
545 545 if instance_variable_defined? :@parent_issue
546 546 @parent_issue.nil? ? nil : @parent_issue.id
547 547 else
548 548 parent_id
549 549 end
550 550 end
551 551
552 552 # Extracted from the ReportsController.
553 553 def self.by_tracker(project)
554 554 count_and_group_by(:project => project,
555 555 :field => 'tracker_id',
556 556 :joins => Tracker.table_name)
557 557 end
558 558
559 559 def self.by_version(project)
560 560 count_and_group_by(:project => project,
561 561 :field => 'fixed_version_id',
562 562 :joins => Version.table_name)
563 563 end
564 564
565 565 def self.by_priority(project)
566 566 count_and_group_by(:project => project,
567 567 :field => 'priority_id',
568 568 :joins => IssuePriority.table_name)
569 569 end
570 570
571 571 def self.by_category(project)
572 572 count_and_group_by(:project => project,
573 573 :field => 'category_id',
574 574 :joins => IssueCategory.table_name)
575 575 end
576 576
577 577 def self.by_assigned_to(project)
578 578 count_and_group_by(:project => project,
579 579 :field => 'assigned_to_id',
580 580 :joins => User.table_name)
581 581 end
582 582
583 583 def self.by_author(project)
584 584 count_and_group_by(:project => project,
585 585 :field => 'author_id',
586 586 :joins => User.table_name)
587 587 end
588 588
589 589 def self.by_subproject(project)
590 590 ActiveRecord::Base.connection.select_all("select s.id as status_id,
591 591 s.is_closed as closed,
592 592 i.project_id as project_id,
593 593 count(i.id) as total
594 594 from
595 595 #{Issue.table_name} i, #{IssueStatus.table_name} s
596 596 where
597 597 i.status_id=s.id
598 598 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
599 599 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
600 600 end
601 601 # End ReportsController extraction
602 602
603 603 # Returns an array of projects that current user can move issues to
604 604 def self.allowed_target_projects_on_move
605 605 projects = []
606 606 if User.current.admin?
607 607 # admin is allowed to move issues to any active (visible) project
608 608 projects = Project.visible.all
609 609 elsif User.current.logged?
610 610 if Role.non_member.allowed_to?(:move_issues)
611 611 projects = Project.visible.all
612 612 else
613 613 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
614 614 end
615 615 end
616 616 projects
617 617 end
618 618
619 619 private
620 620
621 621 def update_nested_set_attributes
622 622 if root_id.nil?
623 623 # issue was just created
624 624 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
625 625 set_default_left_and_right
626 626 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
627 627 if @parent_issue
628 628 move_to_child_of(@parent_issue)
629 629 end
630 630 reload
631 631 elsif parent_issue_id != parent_id
632 former_parent_id = parent_id
632 633 # moving an existing issue
633 634 if @parent_issue && @parent_issue.root_id == root_id
634 635 # inside the same tree
635 636 move_to_child_of(@parent_issue)
636 637 else
637 638 # to another tree
638 639 unless root?
639 640 move_to_right_of(root)
640 641 reload
641 642 end
642 643 old_root_id = root_id
643 644 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
644 645 target_maxright = nested_set_scope.maximum(right_column_name) || 0
645 646 offset = target_maxright + 1 - lft
646 647 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
647 648 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
648 649 self[left_column_name] = lft + offset
649 650 self[right_column_name] = rgt + offset
650 651 if @parent_issue
651 652 move_to_child_of(@parent_issue)
652 653 end
653 654 end
654 655 reload
655 656 # delete invalid relations of all descendants
656 657 self_and_descendants.each do |issue|
657 658 issue.relations.each do |relation|
658 659 relation.destroy unless relation.valid?
659 660 end
660 661 end
662 # update former parent
663 recalculate_attributes_for(former_parent_id) if former_parent_id
661 664 end
662 665 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
663 666 end
664 667
665 668 def update_parent_attributes
666 if parent_id && p = Issue.find_by_id(parent_id)
669 recalculate_attributes_for(parent_id) if parent_id
670 end
671
672 def recalculate_attributes_for(issue_id)
673 if issue_id && p = Issue.find_by_id(issue_id)
667 674 # priority = highest priority of children
668 675 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
669 676 p.priority = IssuePriority.find_by_position(priority_position)
670 677 end
671 678
672 679 # start/due dates = lowest/highest dates of children
673 680 p.start_date = p.children.minimum(:start_date)
674 681 p.due_date = p.children.maximum(:due_date)
675 682 if p.start_date && p.due_date && p.due_date < p.start_date
676 683 p.start_date, p.due_date = p.due_date, p.start_date
677 684 end
678 685
679 686 # done ratio = weighted average ratio of leaves
680 687 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio?
681 688 leaves_count = p.leaves.count
682 689 if leaves_count > 0
683 690 average = p.leaves.average(:estimated_hours).to_f
684 691 if average == 0
685 692 average = 1
686 693 end
687 694 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
688 695 progress = done / (average * leaves_count)
689 696 p.done_ratio = progress.round
690 697 end
691 698 end
692 699
693 700 # estimate = sum of leaves estimates
694 701 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
695 702 p.estimated_hours = nil if p.estimated_hours == 0.0
696 703
697 704 # ancestors will be recursively updated
698 705 p.save(false)
699 706 end
700 707 end
701 708
702 709 def destroy_children
703 710 unless leaf?
704 711 children.each do |child|
705 712 child.destroy
706 713 end
707 714 end
708 715 end
709 716
710 717 # Update issues so their versions are not pointing to a
711 718 # fixed_version that is not shared with the issue's project
712 719 def self.update_versions(conditions=nil)
713 720 # Only need to update issues with a fixed_version from
714 721 # a different project and that is not systemwide shared
715 722 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
716 723 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
717 724 " AND #{Version.table_name}.sharing <> 'system'",
718 725 conditions),
719 726 :include => [:project, :fixed_version]
720 727 ).each do |issue|
721 728 next if issue.project.nil? || issue.fixed_version.nil?
722 729 unless issue.project.shared_versions.include?(issue.fixed_version)
723 730 issue.init_journal(User.current)
724 731 issue.fixed_version = nil
725 732 issue.save
726 733 end
727 734 end
728 735 end
729 736
730 737 # Callback on attachment deletion
731 738 def attachment_removed(obj)
732 739 journal = init_journal(User.current)
733 740 journal.details << JournalDetail.new(:property => 'attachment',
734 741 :prop_key => obj.id,
735 742 :old_value => obj.filename)
736 743 journal.save
737 744 end
738 745
739 746 # Default assignment based on category
740 747 def default_assign
741 748 if assigned_to.nil? && category && category.assigned_to
742 749 self.assigned_to = category.assigned_to
743 750 end
744 751 end
745 752
746 753 # Updates start/due dates of following issues
747 754 def reschedule_following_issues
748 755 if start_date_changed? || due_date_changed?
749 756 relations_from.each do |relation|
750 757 relation.set_issue_to_dates
751 758 end
752 759 end
753 760 end
754 761
755 762 # Closes duplicates if the issue is being closed
756 763 def close_duplicates
757 764 if closing?
758 765 duplicates.each do |duplicate|
759 766 # Reload is need in case the duplicate was updated by a previous duplicate
760 767 duplicate.reload
761 768 # Don't re-close it if it's already closed
762 769 next if duplicate.closed?
763 770 # Same user and notes
764 771 if @current_journal
765 772 duplicate.init_journal(@current_journal.user, @current_journal.notes)
766 773 end
767 774 duplicate.update_attribute :status, self.status
768 775 end
769 776 end
770 777 end
771 778
772 779 # Saves the changes in a Journal
773 780 # Called after_save
774 781 def create_journal
775 782 if @current_journal
776 783 # attributes changes
777 784 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
778 785 @current_journal.details << JournalDetail.new(:property => 'attr',
779 786 :prop_key => c,
780 787 :old_value => @issue_before_change.send(c),
781 788 :value => send(c)) unless send(c)==@issue_before_change.send(c)
782 789 }
783 790 # custom fields changes
784 791 custom_values.each {|c|
785 792 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
786 793 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
787 794 @current_journal.details << JournalDetail.new(:property => 'cf',
788 795 :prop_key => c.custom_field_id,
789 796 :old_value => @custom_values_before_change[c.custom_field_id],
790 797 :value => c.value)
791 798 }
792 799 @current_journal.save
793 800 # reset current journal
794 801 init_journal @current_journal.user, @current_journal.notes
795 802 end
796 803 end
797 804
798 805 # Query generator for selecting groups of issue counts for a project
799 806 # based on specific criteria
800 807 #
801 808 # Options
802 809 # * project - Project to search in.
803 810 # * field - String. Issue field to key off of in the grouping.
804 811 # * joins - String. The table name to join against.
805 812 def self.count_and_group_by(options)
806 813 project = options.delete(:project)
807 814 select_field = options.delete(:field)
808 815 joins = options.delete(:joins)
809 816
810 817 where = "i.#{select_field}=j.id"
811 818
812 819 ActiveRecord::Base.connection.select_all("select s.id as status_id,
813 820 s.is_closed as closed,
814 821 j.id as #{select_field},
815 822 count(i.id) as total
816 823 from
817 824 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} as j
818 825 where
819 826 i.status_id=s.id
820 827 and #{where}
821 828 and i.project_id=#{project.id}
822 829 group by s.id, s.is_closed, j.id")
823 830 end
824 831
825 832
826 833 end
@@ -1,315 +1,324
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 275
276 def test_move_parent_updates_old_parent_attributes
277 first_parent = create_issue!
278 second_parent = create_issue!
279 child = create_issue!(:estimated_hours => 5, :parent_issue_id => first_parent.id)
280 assert_equal 5, first_parent.reload.estimated_hours
281 child.update_attributes(:estimated_hours => 7, :parent_issue_id => second_parent.id)
282 assert_equal 7, second_parent.reload.estimated_hours
283 assert_nil first_parent.reload.estimated_hours
284 end
276 285
277 286 def test_reschuling_a_parent_should_reschedule_subtasks
278 287 parent = create_issue!
279 288 c1 = create_issue!(:start_date => '2010-05-12', :due_date => '2010-05-18', :parent_issue_id => parent.id)
280 289 c2 = create_issue!(:start_date => '2010-06-03', :due_date => '2010-06-10', :parent_issue_id => parent.id)
281 290 parent.reload
282 291 parent.reschedule_after(Date.parse('2010-06-02'))
283 292 c1.reload
284 293 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-08')], [c1.start_date, c1.due_date]
285 294 c2.reload
286 295 assert_equal [Date.parse('2010-06-03'), Date.parse('2010-06-10')], [c2.start_date, c2.due_date] # no change
287 296 parent.reload
288 297 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-10')], [parent.start_date, parent.due_date]
289 298 end
290 299
291 300 def test_project_copy_should_copy_issue_tree
292 301 p = Project.create!(:name => 'Tree copy', :identifier => 'tree-copy', :tracker_ids => [1, 2])
293 302 i1 = create_issue!(:project_id => p.id, :subject => 'i1')
294 303 i2 = create_issue!(:project_id => p.id, :subject => 'i2', :parent_issue_id => i1.id)
295 304 i3 = create_issue!(:project_id => p.id, :subject => 'i3', :parent_issue_id => i1.id)
296 305 i4 = create_issue!(:project_id => p.id, :subject => 'i4', :parent_issue_id => i2.id)
297 306 i5 = create_issue!(:project_id => p.id, :subject => 'i5')
298 307 c = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1, 2])
299 308 c.copy(p, :only => 'issues')
300 309 c.reload
301 310
302 311 assert_equal 5, c.issues.count
303 312 ic1, ic2, ic3, ic4, ic5 = c.issues.find(:all, :order => 'subject')
304 313 assert ic1.root?
305 314 assert_equal ic1, ic2.parent
306 315 assert_equal ic1, ic3.parent
307 316 assert_equal ic2, ic4.parent
308 317 assert ic5.root?
309 318 end
310 319
311 320 # Helper that creates an issue with default attributes
312 321 def create_issue!(attributes={})
313 322 Issue.create!({:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'test'}.merge(attributes))
314 323 end
315 324 end
General Comments 0
You need to be logged in to leave comments. Login now