##// END OF EJS Templates
Fixed: subtasks are deleted (not destroyed) when destroying parent issue (#7385)....
Jean-Philippe Lang -
r4615:419b195019a2
parent child
Show More
@@ -1,885 +1,876
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 include Redmine::SafeAttributes
20 20
21 21 belongs_to :project
22 22 belongs_to :tracker
23 23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 25 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
26 26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29 29
30 30 has_many :journals, :as => :journalized, :dependent => :destroy
31 31 has_many :time_entries, :dependent => :delete_all
32 32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33 33
34 34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36 36
37 acts_as_nested_set :scope => 'root_id'
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 38 acts_as_attachable :after_remove => :attachment_removed
39 39 acts_as_customizable
40 40 acts_as_watchable
41 41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 42 :include => [:project, :journals],
43 43 # sort by id so that limited eager loading doesn't break with postgresql
44 44 :order_column => "#{table_name}.id"
45 45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48 48
49 49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 50 :author_key => :author_id
51 51
52 52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53 53
54 54 attr_reader :current_journal
55 55
56 56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57 57
58 58 validates_length_of :subject, :maximum => 255
59 59 validates_inclusion_of :done_ratio, :in => 0..100
60 60 validates_numericality_of :estimated_hours, :allow_nil => true
61 61
62 62 named_scope :visible, lambda {|*args| { :include => :project,
63 63 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
64 64
65 65 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
66 66
67 67 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
68 68 named_scope :with_limit, lambda { |limit| { :limit => limit} }
69 69 named_scope :on_active_project, :include => [:status, :project, :tracker],
70 70 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
71 71 named_scope :for_gantt, lambda {
72 72 {
73 73 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version]
74 74 }
75 75 }
76 76
77 77 named_scope :without_version, lambda {
78 78 {
79 79 :conditions => { :fixed_version_id => nil}
80 80 }
81 81 }
82 82
83 83 named_scope :with_query, lambda {|query|
84 84 {
85 85 :conditions => Query.merge_conditions(query.statement)
86 86 }
87 87 }
88 88
89 89 before_create :default_assign
90 90 before_save :close_duplicates, :update_done_ratio_from_issue_status
91 91 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
92 after_destroy :destroy_children
93 92 after_destroy :update_parent_attributes
94 93
95 94 # Returns true if usr or current user is allowed to view the issue
96 95 def visible?(usr=nil)
97 96 (usr || User.current).allowed_to?(:view_issues, self.project)
98 97 end
99 98
100 99 def after_initialize
101 100 if new_record?
102 101 # set default values for new records only
103 102 self.status ||= IssueStatus.default
104 103 self.priority ||= IssuePriority.default
105 104 end
106 105 end
107 106
108 107 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
109 108 def available_custom_fields
110 109 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
111 110 end
112 111
113 112 def copy_from(arg)
114 113 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
115 114 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
116 115 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
117 116 self.status = issue.status
118 117 self
119 118 end
120 119
121 120 # Moves/copies an issue to a new project and tracker
122 121 # Returns the moved/copied issue on success, false on failure
123 122 def move_to_project(*args)
124 123 ret = Issue.transaction do
125 124 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
126 125 end || false
127 126 end
128 127
129 128 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
130 129 options ||= {}
131 130 issue = options[:copy] ? self.class.new.copy_from(self) : self
132 131
133 132 if new_project && issue.project_id != new_project.id
134 133 # delete issue relations
135 134 unless Setting.cross_project_issue_relations?
136 135 issue.relations_from.clear
137 136 issue.relations_to.clear
138 137 end
139 138 # issue is moved to another project
140 139 # reassign to the category with same name if any
141 140 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
142 141 issue.category = new_category
143 142 # Keep the fixed_version if it's still valid in the new_project
144 143 unless new_project.shared_versions.include?(issue.fixed_version)
145 144 issue.fixed_version = nil
146 145 end
147 146 issue.project = new_project
148 147 if issue.parent && issue.parent.project_id != issue.project_id
149 148 issue.parent_issue_id = nil
150 149 end
151 150 end
152 151 if new_tracker
153 152 issue.tracker = new_tracker
154 153 issue.reset_custom_values!
155 154 end
156 155 if options[:copy]
157 156 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
158 157 issue.status = if options[:attributes] && options[:attributes][:status_id]
159 158 IssueStatus.find_by_id(options[:attributes][:status_id])
160 159 else
161 160 self.status
162 161 end
163 162 end
164 163 # Allow bulk setting of attributes on the issue
165 164 if options[:attributes]
166 165 issue.attributes = options[:attributes]
167 166 end
168 167 if issue.save
169 168 unless options[:copy]
170 169 # Manually update project_id on related time entries
171 170 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
172 171
173 172 issue.children.each do |child|
174 173 unless child.move_to_project_without_transaction(new_project)
175 174 # Move failed and transaction was rollback'd
176 175 return false
177 176 end
178 177 end
179 178 end
180 179 else
181 180 return false
182 181 end
183 182 issue
184 183 end
185 184
186 185 def status_id=(sid)
187 186 self.status = nil
188 187 write_attribute(:status_id, sid)
189 188 end
190 189
191 190 def priority_id=(pid)
192 191 self.priority = nil
193 192 write_attribute(:priority_id, pid)
194 193 end
195 194
196 195 def tracker_id=(tid)
197 196 self.tracker = nil
198 197 result = write_attribute(:tracker_id, tid)
199 198 @custom_field_values = nil
200 199 result
201 200 end
202 201
203 202 # Overrides attributes= so that tracker_id gets assigned first
204 203 def attributes_with_tracker_first=(new_attributes, *args)
205 204 return if new_attributes.nil?
206 205 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
207 206 if new_tracker_id
208 207 self.tracker_id = new_tracker_id
209 208 end
210 209 send :attributes_without_tracker_first=, new_attributes, *args
211 210 end
212 211 # Do not redefine alias chain on reload (see #4838)
213 212 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
214 213
215 214 def estimated_hours=(h)
216 215 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
217 216 end
218 217
219 218 safe_attributes 'tracker_id',
220 219 'status_id',
221 220 'parent_issue_id',
222 221 'category_id',
223 222 'assigned_to_id',
224 223 'priority_id',
225 224 'fixed_version_id',
226 225 'subject',
227 226 'description',
228 227 'start_date',
229 228 'due_date',
230 229 'done_ratio',
231 230 'estimated_hours',
232 231 'custom_field_values',
233 232 'custom_fields',
234 233 'lock_version',
235 234 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
236 235
237 236 safe_attributes 'status_id',
238 237 'assigned_to_id',
239 238 'fixed_version_id',
240 239 'done_ratio',
241 240 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
242 241
243 242 # Safely sets attributes
244 243 # Should be called from controllers instead of #attributes=
245 244 # attr_accessible is too rough because we still want things like
246 245 # Issue.new(:project => foo) to work
247 246 # TODO: move workflow/permission checks from controllers to here
248 247 def safe_attributes=(attrs, user=User.current)
249 248 return unless attrs.is_a?(Hash)
250 249
251 250 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
252 251 attrs = delete_unsafe_attributes(attrs, user)
253 252 return if attrs.empty?
254 253
255 254 # Tracker must be set before since new_statuses_allowed_to depends on it.
256 255 if t = attrs.delete('tracker_id')
257 256 self.tracker_id = t
258 257 end
259 258
260 259 if attrs['status_id']
261 260 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
262 261 attrs.delete('status_id')
263 262 end
264 263 end
265 264
266 265 unless leaf?
267 266 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
268 267 end
269 268
270 269 if attrs.has_key?('parent_issue_id')
271 270 if !user.allowed_to?(:manage_subtasks, project)
272 271 attrs.delete('parent_issue_id')
273 272 elsif !attrs['parent_issue_id'].blank?
274 273 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
275 274 end
276 275 end
277 276
278 277 self.attributes = attrs
279 278 end
280 279
281 280 def done_ratio
282 281 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
283 282 status.default_done_ratio
284 283 else
285 284 read_attribute(:done_ratio)
286 285 end
287 286 end
288 287
289 288 def self.use_status_for_done_ratio?
290 289 Setting.issue_done_ratio == 'issue_status'
291 290 end
292 291
293 292 def self.use_field_for_done_ratio?
294 293 Setting.issue_done_ratio == 'issue_field'
295 294 end
296 295
297 296 def validate
298 297 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
299 298 errors.add :due_date, :not_a_date
300 299 end
301 300
302 301 if self.due_date and self.start_date and self.due_date < self.start_date
303 302 errors.add :due_date, :greater_than_start_date
304 303 end
305 304
306 305 if start_date && soonest_start && start_date < soonest_start
307 306 errors.add :start_date, :invalid
308 307 end
309 308
310 309 if fixed_version
311 310 if !assignable_versions.include?(fixed_version)
312 311 errors.add :fixed_version_id, :inclusion
313 312 elsif reopened? && fixed_version.closed?
314 313 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
315 314 end
316 315 end
317 316
318 317 # Checks that the issue can not be added/moved to a disabled tracker
319 318 if project && (tracker_id_changed? || project_id_changed?)
320 319 unless project.trackers.include?(tracker)
321 320 errors.add :tracker_id, :inclusion
322 321 end
323 322 end
324 323
325 324 # Checks parent issue assignment
326 325 if @parent_issue
327 326 if @parent_issue.project_id != project_id
328 327 errors.add :parent_issue_id, :not_same_project
329 328 elsif !new_record?
330 329 # moving an existing issue
331 330 if @parent_issue.root_id != root_id
332 331 # we can always move to another tree
333 332 elsif move_possible?(@parent_issue)
334 333 # move accepted inside tree
335 334 else
336 335 errors.add :parent_issue_id, :not_a_valid_parent
337 336 end
338 337 end
339 338 end
340 339 end
341 340
342 341 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
343 342 # even if the user turns off the setting later
344 343 def update_done_ratio_from_issue_status
345 344 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
346 345 self.done_ratio = status.default_done_ratio
347 346 end
348 347 end
349 348
350 349 def init_journal(user, notes = "")
351 350 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
352 351 @issue_before_change = self.clone
353 352 @issue_before_change.status = self.status
354 353 @custom_values_before_change = {}
355 354 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
356 355 # Make sure updated_on is updated when adding a note.
357 356 updated_on_will_change!
358 357 @current_journal
359 358 end
360 359
361 360 # Return true if the issue is closed, otherwise false
362 361 def closed?
363 362 self.status.is_closed?
364 363 end
365 364
366 365 # Return true if the issue is being reopened
367 366 def reopened?
368 367 if !new_record? && status_id_changed?
369 368 status_was = IssueStatus.find_by_id(status_id_was)
370 369 status_new = IssueStatus.find_by_id(status_id)
371 370 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
372 371 return true
373 372 end
374 373 end
375 374 false
376 375 end
377 376
378 377 # Return true if the issue is being closed
379 378 def closing?
380 379 if !new_record? && status_id_changed?
381 380 status_was = IssueStatus.find_by_id(status_id_was)
382 381 status_new = IssueStatus.find_by_id(status_id)
383 382 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
384 383 return true
385 384 end
386 385 end
387 386 false
388 387 end
389 388
390 389 # Returns true if the issue is overdue
391 390 def overdue?
392 391 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
393 392 end
394 393
395 394 # Is the amount of work done less than it should for the due date
396 395 def behind_schedule?
397 396 return false if start_date.nil? || due_date.nil?
398 397 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
399 398 return done_date <= Date.today
400 399 end
401 400
402 401 # Does this issue have children?
403 402 def children?
404 403 !leaf?
405 404 end
406 405
407 406 # Users the issue can be assigned to
408 407 def assignable_users
409 408 users = project.assignable_users
410 409 users << author if author
411 410 users.uniq.sort
412 411 end
413 412
414 413 # Versions that the issue can be assigned to
415 414 def assignable_versions
416 415 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
417 416 end
418 417
419 418 # Returns true if this issue is blocked by another issue that is still open
420 419 def blocked?
421 420 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
422 421 end
423 422
424 423 # Returns an array of status that user is able to apply
425 424 def new_statuses_allowed_to(user, include_default=false)
426 425 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
427 426 statuses << status unless statuses.empty?
428 427 statuses << IssueStatus.default if include_default
429 428 statuses = statuses.uniq.sort
430 429 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
431 430 end
432 431
433 432 # Returns the mail adresses of users that should be notified
434 433 def recipients
435 434 notified = project.notified_users
436 435 # Author and assignee are always notified unless they have been
437 436 # locked or don't want to be notified
438 437 notified << author if author && author.active? && author.notify_about?(self)
439 438 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
440 439 notified.uniq!
441 440 # Remove users that can not view the issue
442 441 notified.reject! {|user| !visible?(user)}
443 442 notified.collect(&:mail)
444 443 end
445 444
446 445 # Returns the total number of hours spent on this issue and its descendants
447 446 #
448 447 # Example:
449 448 # spent_hours => 0.0
450 449 # spent_hours => 50.2
451 450 def spent_hours
452 451 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
453 452 end
454 453
455 454 def relations
456 455 (relations_from + relations_to).sort
457 456 end
458 457
459 458 def all_dependent_issues(except=nil)
460 459 except ||= self
461 460 dependencies = []
462 461 relations_from.each do |relation|
463 462 if relation.issue_to && relation.issue_to != except
464 463 dependencies << relation.issue_to
465 464 dependencies += relation.issue_to.all_dependent_issues(except)
466 465 end
467 466 end
468 467 dependencies
469 468 end
470 469
471 470 # Returns an array of issues that duplicate this one
472 471 def duplicates
473 472 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
474 473 end
475 474
476 475 # Returns the due date or the target due date if any
477 476 # Used on gantt chart
478 477 def due_before
479 478 due_date || (fixed_version ? fixed_version.effective_date : nil)
480 479 end
481 480
482 481 # Returns the time scheduled for this issue.
483 482 #
484 483 # Example:
485 484 # Start Date: 2/26/09, End Date: 3/04/09
486 485 # duration => 6
487 486 def duration
488 487 (start_date && due_date) ? due_date - start_date : 0
489 488 end
490 489
491 490 def soonest_start
492 491 @soonest_start ||= (
493 492 relations_to.collect{|relation| relation.successor_soonest_start} +
494 493 ancestors.collect(&:soonest_start)
495 494 ).compact.max
496 495 end
497 496
498 497 def reschedule_after(date)
499 498 return if date.nil?
500 499 if leaf?
501 500 if start_date.nil? || start_date < date
502 501 self.start_date, self.due_date = date, date + duration
503 502 save
504 503 end
505 504 else
506 505 leaves.each do |leaf|
507 506 leaf.reschedule_after(date)
508 507 end
509 508 end
510 509 end
511 510
512 511 def <=>(issue)
513 512 if issue.nil?
514 513 -1
515 514 elsif root_id != issue.root_id
516 515 (root_id || 0) <=> (issue.root_id || 0)
517 516 else
518 517 (lft || 0) <=> (issue.lft || 0)
519 518 end
520 519 end
521 520
522 521 def to_s
523 522 "#{tracker} ##{id}: #{subject}"
524 523 end
525 524
526 525 # Returns a string of css classes that apply to the issue
527 526 def css_classes
528 527 s = "issue status-#{status.position} priority-#{priority.position}"
529 528 s << ' closed' if closed?
530 529 s << ' overdue' if overdue?
531 530 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
532 531 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
533 532 s
534 533 end
535 534
536 535 # Saves an issue, time_entry, attachments, and a journal from the parameters
537 536 # Returns false if save fails
538 537 def save_issue_with_child_records(params, existing_time_entry=nil)
539 538 Issue.transaction do
540 539 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
541 540 @time_entry = existing_time_entry || TimeEntry.new
542 541 @time_entry.project = project
543 542 @time_entry.issue = self
544 543 @time_entry.user = User.current
545 544 @time_entry.spent_on = Date.today
546 545 @time_entry.attributes = params[:time_entry]
547 546 self.time_entries << @time_entry
548 547 end
549 548
550 549 if valid?
551 550 attachments = Attachment.attach_files(self, params[:attachments])
552 551
553 552 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
554 553 # TODO: Rename hook
555 554 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
556 555 begin
557 556 if save
558 557 # TODO: Rename hook
559 558 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
560 559 else
561 560 raise ActiveRecord::Rollback
562 561 end
563 562 rescue ActiveRecord::StaleObjectError
564 563 attachments[:files].each(&:destroy)
565 564 errors.add_to_base l(:notice_locking_conflict)
566 565 raise ActiveRecord::Rollback
567 566 end
568 567 end
569 568 end
570 569 end
571 570
572 571 # Unassigns issues from +version+ if it's no longer shared with issue's project
573 572 def self.update_versions_from_sharing_change(version)
574 573 # Update issues assigned to the version
575 574 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
576 575 end
577 576
578 577 # Unassigns issues from versions that are no longer shared
579 578 # after +project+ was moved
580 579 def self.update_versions_from_hierarchy_change(project)
581 580 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
582 581 # Update issues of the moved projects and issues assigned to a version of a moved project
583 582 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
584 583 end
585 584
586 585 def parent_issue_id=(arg)
587 586 parent_issue_id = arg.blank? ? nil : arg.to_i
588 587 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
589 588 @parent_issue.id
590 589 else
591 590 @parent_issue = nil
592 591 nil
593 592 end
594 593 end
595 594
596 595 def parent_issue_id
597 596 if instance_variable_defined? :@parent_issue
598 597 @parent_issue.nil? ? nil : @parent_issue.id
599 598 else
600 599 parent_id
601 600 end
602 601 end
603 602
604 603 # Extracted from the ReportsController.
605 604 def self.by_tracker(project)
606 605 count_and_group_by(:project => project,
607 606 :field => 'tracker_id',
608 607 :joins => Tracker.table_name)
609 608 end
610 609
611 610 def self.by_version(project)
612 611 count_and_group_by(:project => project,
613 612 :field => 'fixed_version_id',
614 613 :joins => Version.table_name)
615 614 end
616 615
617 616 def self.by_priority(project)
618 617 count_and_group_by(:project => project,
619 618 :field => 'priority_id',
620 619 :joins => IssuePriority.table_name)
621 620 end
622 621
623 622 def self.by_category(project)
624 623 count_and_group_by(:project => project,
625 624 :field => 'category_id',
626 625 :joins => IssueCategory.table_name)
627 626 end
628 627
629 628 def self.by_assigned_to(project)
630 629 count_and_group_by(:project => project,
631 630 :field => 'assigned_to_id',
632 631 :joins => User.table_name)
633 632 end
634 633
635 634 def self.by_author(project)
636 635 count_and_group_by(:project => project,
637 636 :field => 'author_id',
638 637 :joins => User.table_name)
639 638 end
640 639
641 640 def self.by_subproject(project)
642 641 ActiveRecord::Base.connection.select_all("select s.id as status_id,
643 642 s.is_closed as closed,
644 643 i.project_id as project_id,
645 644 count(i.id) as total
646 645 from
647 646 #{Issue.table_name} i, #{IssueStatus.table_name} s
648 647 where
649 648 i.status_id=s.id
650 649 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
651 650 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
652 651 end
653 652 # End ReportsController extraction
654 653
655 654 # Returns an array of projects that current user can move issues to
656 655 def self.allowed_target_projects_on_move
657 656 projects = []
658 657 if User.current.admin?
659 658 # admin is allowed to move issues to any active (visible) project
660 659 projects = Project.visible.all
661 660 elsif User.current.logged?
662 661 if Role.non_member.allowed_to?(:move_issues)
663 662 projects = Project.visible.all
664 663 else
665 664 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
666 665 end
667 666 end
668 667 projects
669 668 end
670 669
671 670 private
672 671
673 672 def update_nested_set_attributes
674 673 if root_id.nil?
675 674 # issue was just created
676 675 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
677 676 set_default_left_and_right
678 677 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
679 678 if @parent_issue
680 679 move_to_child_of(@parent_issue)
681 680 end
682 681 reload
683 682 elsif parent_issue_id != parent_id
684 683 former_parent_id = parent_id
685 684 # moving an existing issue
686 685 if @parent_issue && @parent_issue.root_id == root_id
687 686 # inside the same tree
688 687 move_to_child_of(@parent_issue)
689 688 else
690 689 # to another tree
691 690 unless root?
692 691 move_to_right_of(root)
693 692 reload
694 693 end
695 694 old_root_id = root_id
696 695 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
697 696 target_maxright = nested_set_scope.maximum(right_column_name) || 0
698 697 offset = target_maxright + 1 - lft
699 698 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
700 699 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
701 700 self[left_column_name] = lft + offset
702 701 self[right_column_name] = rgt + offset
703 702 if @parent_issue
704 703 move_to_child_of(@parent_issue)
705 704 end
706 705 end
707 706 reload
708 707 # delete invalid relations of all descendants
709 708 self_and_descendants.each do |issue|
710 709 issue.relations.each do |relation|
711 710 relation.destroy unless relation.valid?
712 711 end
713 712 end
714 713 # update former parent
715 714 recalculate_attributes_for(former_parent_id) if former_parent_id
716 715 end
717 716 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
718 717 end
719 718
720 719 def update_parent_attributes
721 720 recalculate_attributes_for(parent_id) if parent_id
722 721 end
723 722
724 723 def recalculate_attributes_for(issue_id)
725 724 if issue_id && p = Issue.find_by_id(issue_id)
726 725 # priority = highest priority of children
727 726 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
728 727 p.priority = IssuePriority.find_by_position(priority_position)
729 728 end
730 729
731 730 # start/due dates = lowest/highest dates of children
732 731 p.start_date = p.children.minimum(:start_date)
733 732 p.due_date = p.children.maximum(:due_date)
734 733 if p.start_date && p.due_date && p.due_date < p.start_date
735 734 p.start_date, p.due_date = p.due_date, p.start_date
736 735 end
737 736
738 737 # done ratio = weighted average ratio of leaves
739 738 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
740 739 leaves_count = p.leaves.count
741 740 if leaves_count > 0
742 741 average = p.leaves.average(:estimated_hours).to_f
743 742 if average == 0
744 743 average = 1
745 744 end
746 745 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
747 746 progress = done / (average * leaves_count)
748 747 p.done_ratio = progress.round
749 748 end
750 749 end
751 750
752 751 # estimate = sum of leaves estimates
753 752 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
754 753 p.estimated_hours = nil if p.estimated_hours == 0.0
755 754
756 755 # ancestors will be recursively updated
757 756 p.save(false)
758 757 end
759 758 end
760 759
761 def destroy_children
762 unless leaf?
763 children.each do |child|
764 child.destroy
765 end
766 end
767 end
768
769 760 # Update issues so their versions are not pointing to a
770 761 # fixed_version that is not shared with the issue's project
771 762 def self.update_versions(conditions=nil)
772 763 # Only need to update issues with a fixed_version from
773 764 # a different project and that is not systemwide shared
774 765 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
775 766 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
776 767 " AND #{Version.table_name}.sharing <> 'system'",
777 768 conditions),
778 769 :include => [:project, :fixed_version]
779 770 ).each do |issue|
780 771 next if issue.project.nil? || issue.fixed_version.nil?
781 772 unless issue.project.shared_versions.include?(issue.fixed_version)
782 773 issue.init_journal(User.current)
783 774 issue.fixed_version = nil
784 775 issue.save
785 776 end
786 777 end
787 778 end
788 779
789 780 # Callback on attachment deletion
790 781 def attachment_removed(obj)
791 782 journal = init_journal(User.current)
792 783 journal.details << JournalDetail.new(:property => 'attachment',
793 784 :prop_key => obj.id,
794 785 :old_value => obj.filename)
795 786 journal.save
796 787 end
797 788
798 789 # Default assignment based on category
799 790 def default_assign
800 791 if assigned_to.nil? && category && category.assigned_to
801 792 self.assigned_to = category.assigned_to
802 793 end
803 794 end
804 795
805 796 # Updates start/due dates of following issues
806 797 def reschedule_following_issues
807 798 if start_date_changed? || due_date_changed?
808 799 relations_from.each do |relation|
809 800 relation.set_issue_to_dates
810 801 end
811 802 end
812 803 end
813 804
814 805 # Closes duplicates if the issue is being closed
815 806 def close_duplicates
816 807 if closing?
817 808 duplicates.each do |duplicate|
818 809 # Reload is need in case the duplicate was updated by a previous duplicate
819 810 duplicate.reload
820 811 # Don't re-close it if it's already closed
821 812 next if duplicate.closed?
822 813 # Same user and notes
823 814 if @current_journal
824 815 duplicate.init_journal(@current_journal.user, @current_journal.notes)
825 816 end
826 817 duplicate.update_attribute :status, self.status
827 818 end
828 819 end
829 820 end
830 821
831 822 # Saves the changes in a Journal
832 823 # Called after_save
833 824 def create_journal
834 825 if @current_journal
835 826 # attributes changes
836 827 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
837 828 @current_journal.details << JournalDetail.new(:property => 'attr',
838 829 :prop_key => c,
839 830 :old_value => @issue_before_change.send(c),
840 831 :value => send(c)) unless send(c)==@issue_before_change.send(c)
841 832 }
842 833 # custom fields changes
843 834 custom_values.each {|c|
844 835 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
845 836 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
846 837 @current_journal.details << JournalDetail.new(:property => 'cf',
847 838 :prop_key => c.custom_field_id,
848 839 :old_value => @custom_values_before_change[c.custom_field_id],
849 840 :value => c.value)
850 841 }
851 842 @current_journal.save
852 843 # reset current journal
853 844 init_journal @current_journal.user, @current_journal.notes
854 845 end
855 846 end
856 847
857 848 # Query generator for selecting groups of issue counts for a project
858 849 # based on specific criteria
859 850 #
860 851 # Options
861 852 # * project - Project to search in.
862 853 # * field - String. Issue field to key off of in the grouping.
863 854 # * joins - String. The table name to join against.
864 855 def self.count_and_group_by(options)
865 856 project = options.delete(:project)
866 857 select_field = options.delete(:field)
867 858 joins = options.delete(:joins)
868 859
869 860 where = "i.#{select_field}=j.id"
870 861
871 862 ActiveRecord::Base.connection.select_all("select s.id as status_id,
872 863 s.is_closed as closed,
873 864 j.id as #{select_field},
874 865 count(i.id) as total
875 866 from
876 867 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
877 868 where
878 869 i.status_id=s.id
879 870 and #{where}
880 871 and i.project_id=#{project.id}
881 872 group by s.id, s.is_closed, j.id")
882 873 end
883 874
884 875
885 876 end
@@ -1,324 +1,356
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.expand_path('../../test_helper', __FILE__)
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 issue2.reload.destroy
205
206 issue3.init_journal(User.find(2))
207 issue3.subject = 'child with journal'
208 issue3.save!
209
210 assert_difference 'Issue.count', -2 do
211 assert_difference 'Journal.count', -1 do
212 assert_difference 'JournalDetail.count', -1 do
213 Issue.find(issue2.id).destroy
214 end
215 end
216 end
217
206 218 issue1.reload
207 219 issue4.reload
208 220 assert !Issue.exists?(issue2.id)
209 221 assert !Issue.exists?(issue3.id)
210 222 assert_equal [issue1.id, 1, 4], [issue1.root_id, issue1.lft, issue1.rgt]
211 223 assert_equal [issue1.id, 2, 3], [issue4.root_id, issue4.lft, issue4.rgt]
212 224 end
213 225
226 def test_destroy_child_issue_with_children
227 root = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'root')
228 child = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => root.id)
229 leaf = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'leaf', :parent_issue_id => child.id)
230 leaf.init_journal(User.find(2))
231 leaf.subject = 'leaf with journal'
232 leaf.save!
233
234 assert_difference 'Issue.count', -2 do
235 assert_difference 'Journal.count', -1 do
236 assert_difference 'JournalDetail.count', -1 do
237 Issue.find(child.id).destroy
238 end
239 end
240 end
241
242 root = Issue.find(root.id)
243 assert root.leaf?, "Root issue is not a leaf (lft: #{root.lft}, rgt: #{root.rgt})"
244 end
245
214 246 def test_parent_priority_should_be_the_highest_child_priority
215 247 parent = create_issue!(:priority => IssuePriority.find_by_name('Normal'))
216 248 # Create children
217 249 child1 = create_issue!(:priority => IssuePriority.find_by_name('High'), :parent_issue_id => parent.id)
218 250 assert_equal 'High', parent.reload.priority.name
219 251 child2 = create_issue!(:priority => IssuePriority.find_by_name('Immediate'), :parent_issue_id => child1.id)
220 252 assert_equal 'Immediate', child1.reload.priority.name
221 253 assert_equal 'Immediate', parent.reload.priority.name
222 254 child3 = create_issue!(:priority => IssuePriority.find_by_name('Low'), :parent_issue_id => parent.id)
223 255 assert_equal 'Immediate', parent.reload.priority.name
224 256 # Destroy a child
225 257 child1.destroy
226 258 assert_equal 'Low', parent.reload.priority.name
227 259 # Update a child
228 260 child3.reload.priority = IssuePriority.find_by_name('Normal')
229 261 child3.save!
230 262 assert_equal 'Normal', parent.reload.priority.name
231 263 end
232 264
233 265 def test_parent_dates_should_be_lowest_start_and_highest_due_dates
234 266 parent = create_issue!
235 267 create_issue!(:start_date => '2010-01-25', :due_date => '2010-02-15', :parent_issue_id => parent.id)
236 268 create_issue!( :due_date => '2010-02-13', :parent_issue_id => parent.id)
237 269 create_issue!(:start_date => '2010-02-01', :due_date => '2010-02-22', :parent_issue_id => parent.id)
238 270 parent.reload
239 271 assert_equal Date.parse('2010-01-25'), parent.start_date
240 272 assert_equal Date.parse('2010-02-22'), parent.due_date
241 273 end
242 274
243 275 def test_parent_done_ratio_should_be_average_done_ratio_of_leaves
244 276 parent = create_issue!
245 277 create_issue!(:done_ratio => 20, :parent_issue_id => parent.id)
246 278 assert_equal 20, parent.reload.done_ratio
247 279 create_issue!(:done_ratio => 70, :parent_issue_id => parent.id)
248 280 assert_equal 45, parent.reload.done_ratio
249 281
250 282 child = create_issue!(:done_ratio => 0, :parent_issue_id => parent.id)
251 283 assert_equal 30, parent.reload.done_ratio
252 284
253 285 create_issue!(:done_ratio => 30, :parent_issue_id => child.id)
254 286 assert_equal 30, child.reload.done_ratio
255 287 assert_equal 40, parent.reload.done_ratio
256 288 end
257 289
258 290 def test_parent_done_ratio_should_be_weighted_by_estimated_times_if_any
259 291 parent = create_issue!
260 292 create_issue!(:estimated_hours => 10, :done_ratio => 20, :parent_issue_id => parent.id)
261 293 assert_equal 20, parent.reload.done_ratio
262 294 create_issue!(:estimated_hours => 20, :done_ratio => 50, :parent_issue_id => parent.id)
263 295 assert_equal (50 * 20 + 20 * 10) / 30, parent.reload.done_ratio
264 296 end
265 297
266 298 def test_parent_estimate_should_be_sum_of_leaves
267 299 parent = create_issue!
268 300 create_issue!(:estimated_hours => nil, :parent_issue_id => parent.id)
269 301 assert_equal nil, parent.reload.estimated_hours
270 302 create_issue!(:estimated_hours => 5, :parent_issue_id => parent.id)
271 303 assert_equal 5, parent.reload.estimated_hours
272 304 create_issue!(:estimated_hours => 7, :parent_issue_id => parent.id)
273 305 assert_equal 12, parent.reload.estimated_hours
274 306 end
275 307
276 308 def test_move_parent_updates_old_parent_attributes
277 309 first_parent = create_issue!
278 310 second_parent = create_issue!
279 311 child = create_issue!(:estimated_hours => 5, :parent_issue_id => first_parent.id)
280 312 assert_equal 5, first_parent.reload.estimated_hours
281 313 child.update_attributes(:estimated_hours => 7, :parent_issue_id => second_parent.id)
282 314 assert_equal 7, second_parent.reload.estimated_hours
283 315 assert_nil first_parent.reload.estimated_hours
284 316 end
285 317
286 318 def test_reschuling_a_parent_should_reschedule_subtasks
287 319 parent = create_issue!
288 320 c1 = create_issue!(:start_date => '2010-05-12', :due_date => '2010-05-18', :parent_issue_id => parent.id)
289 321 c2 = create_issue!(:start_date => '2010-06-03', :due_date => '2010-06-10', :parent_issue_id => parent.id)
290 322 parent.reload
291 323 parent.reschedule_after(Date.parse('2010-06-02'))
292 324 c1.reload
293 325 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-08')], [c1.start_date, c1.due_date]
294 326 c2.reload
295 327 assert_equal [Date.parse('2010-06-03'), Date.parse('2010-06-10')], [c2.start_date, c2.due_date] # no change
296 328 parent.reload
297 329 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-10')], [parent.start_date, parent.due_date]
298 330 end
299 331
300 332 def test_project_copy_should_copy_issue_tree
301 333 p = Project.create!(:name => 'Tree copy', :identifier => 'tree-copy', :tracker_ids => [1, 2])
302 334 i1 = create_issue!(:project_id => p.id, :subject => 'i1')
303 335 i2 = create_issue!(:project_id => p.id, :subject => 'i2', :parent_issue_id => i1.id)
304 336 i3 = create_issue!(:project_id => p.id, :subject => 'i3', :parent_issue_id => i1.id)
305 337 i4 = create_issue!(:project_id => p.id, :subject => 'i4', :parent_issue_id => i2.id)
306 338 i5 = create_issue!(:project_id => p.id, :subject => 'i5')
307 339 c = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1, 2])
308 340 c.copy(p, :only => 'issues')
309 341 c.reload
310 342
311 343 assert_equal 5, c.issues.count
312 344 ic1, ic2, ic3, ic4, ic5 = c.issues.find(:all, :order => 'subject')
313 345 assert ic1.root?
314 346 assert_equal ic1, ic2.parent
315 347 assert_equal ic1, ic3.parent
316 348 assert_equal ic2, ic4.parent
317 349 assert ic5.root?
318 350 end
319 351
320 352 # Helper that creates an issue with default attributes
321 353 def create_issue!(attributes={})
322 354 Issue.create!({:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'test'}.merge(attributes))
323 355 end
324 356 end
@@ -1,547 +1,549
1 1 module CollectiveIdea #:nodoc:
2 2 module Acts #:nodoc:
3 3 module NestedSet #:nodoc:
4 4 def self.included(base)
5 5 base.extend(SingletonMethods)
6 6 end
7 7
8 8 # This acts provides Nested Set functionality. Nested Set is a smart way to implement
9 9 # an _ordered_ tree, with the added feature that you can select the children and all of their
10 10 # descendants with a single query. The drawback is that insertion or move need some complex
11 11 # sql queries. But everything is done here by this module!
12 12 #
13 13 # Nested sets are appropriate each time you want either an orderd tree (menus,
14 14 # commercial categories) or an efficient way of querying big trees (threaded posts).
15 15 #
16 16 # == API
17 17 #
18 18 # Methods names are aligned with acts_as_tree as much as possible, to make replacment from one
19 19 # by another easier, except for the creation:
20 20 #
21 21 # in acts_as_tree:
22 22 # item.children.create(:name => "child1")
23 23 #
24 24 # in acts_as_nested_set:
25 25 # # adds a new item at the "end" of the tree, i.e. with child.left = max(tree.right)+1
26 26 # child = MyClass.new(:name => "child1")
27 27 # child.save
28 28 # # now move the item to its right place
29 29 # child.move_to_child_of my_item
30 30 #
31 31 # You can pass an id or an object to:
32 32 # * <tt>#move_to_child_of</tt>
33 33 # * <tt>#move_to_right_of</tt>
34 34 # * <tt>#move_to_left_of</tt>
35 35 #
36 36 module SingletonMethods
37 37 # Configuration options are:
38 38 #
39 39 # * +:parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id)
40 40 # * +:left_column+ - column name for left boundry data, default "lft"
41 41 # * +:right_column+ - column name for right boundry data, default "rgt"
42 42 # * +:scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id"
43 43 # (if it hasn't been already) and use that as the foreign key restriction. You
44 44 # can also pass an array to scope by multiple attributes.
45 45 # Example: <tt>acts_as_nested_set :scope => [:notable_id, :notable_type]</tt>
46 46 # * +:dependent+ - behavior for cascading destroy. If set to :destroy, all the
47 47 # child objects are destroyed alongside this object by calling their destroy
48 48 # method. If set to :delete_all (default), all the child objects are deleted
49 49 # without calling their destroy method.
50 50 #
51 51 # See CollectiveIdea::Acts::NestedSet::ClassMethods for a list of class methods and
52 52 # CollectiveIdea::Acts::NestedSet::InstanceMethods for a list of instance methods added
53 53 # to acts_as_nested_set models
54 54 def acts_as_nested_set(options = {})
55 55 options = {
56 56 :parent_column => 'parent_id',
57 57 :left_column => 'lft',
58 58 :right_column => 'rgt',
59 59 :order => 'id',
60 60 :dependent => :delete_all, # or :destroy
61 61 }.merge(options)
62 62
63 63 if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/
64 64 options[:scope] = "#{options[:scope]}_id".intern
65 65 end
66 66
67 67 write_inheritable_attribute :acts_as_nested_set_options, options
68 68 class_inheritable_reader :acts_as_nested_set_options
69 69
70 70 include Comparable
71 71 include Columns
72 72 include InstanceMethods
73 73 extend Columns
74 74 extend ClassMethods
75 75
76 76 # no bulk assignment
77 77 attr_protected left_column_name.intern,
78 78 right_column_name.intern,
79 79 parent_column_name.intern
80 80
81 81 before_create :set_default_left_and_right
82 82 before_destroy :prune_from_tree
83 83
84 84 # no assignment to structure fields
85 85 [left_column_name, right_column_name, parent_column_name].each do |column|
86 86 module_eval <<-"end_eval", __FILE__, __LINE__
87 87 def #{column}=(x)
88 88 raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead."
89 89 end
90 90 end_eval
91 91 end
92 92
93 93 named_scope :roots, :conditions => {parent_column_name => nil}, :order => quoted_left_column_name
94 94 named_scope :leaves, :conditions => "#{quoted_right_column_name} - #{quoted_left_column_name} = 1", :order => quoted_left_column_name
95 95 if self.respond_to?(:define_callbacks)
96 96 define_callbacks("before_move", "after_move")
97 97 end
98 98
99 99
100 100 end
101 101
102 102 end
103 103
104 104 module ClassMethods
105 105
106 106 # Returns the first root
107 107 def root
108 108 roots.find(:first)
109 109 end
110 110
111 111 def valid?
112 112 left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid?
113 113 end
114 114
115 115 def left_and_rights_valid?
116 116 count(
117 117 :joins => "LEFT OUTER JOIN #{quoted_table_name} AS parent ON " +
118 118 "#{quoted_table_name}.#{quoted_parent_column_name} = parent.#{primary_key}",
119 119 :conditions =>
120 120 "#{quoted_table_name}.#{quoted_left_column_name} IS NULL OR " +
121 121 "#{quoted_table_name}.#{quoted_right_column_name} IS NULL OR " +
122 122 "#{quoted_table_name}.#{quoted_left_column_name} >= " +
123 123 "#{quoted_table_name}.#{quoted_right_column_name} OR " +
124 124 "(#{quoted_table_name}.#{quoted_parent_column_name} IS NOT NULL AND " +
125 125 "(#{quoted_table_name}.#{quoted_left_column_name} <= parent.#{quoted_left_column_name} OR " +
126 126 "#{quoted_table_name}.#{quoted_right_column_name} >= parent.#{quoted_right_column_name}))"
127 127 ) == 0
128 128 end
129 129
130 130 def no_duplicates_for_columns?
131 131 scope_string = Array(acts_as_nested_set_options[:scope]).map do |c|
132 132 connection.quote_column_name(c)
133 133 end.push(nil).join(", ")
134 134 [quoted_left_column_name, quoted_right_column_name].all? do |column|
135 135 # No duplicates
136 136 find(:first,
137 137 :select => "#{scope_string}#{column}, COUNT(#{column})",
138 138 :group => "#{scope_string}#{column}
139 139 HAVING COUNT(#{column}) > 1").nil?
140 140 end
141 141 end
142 142
143 143 # Wrapper for each_root_valid? that can deal with scope.
144 144 def all_roots_valid?
145 145 if acts_as_nested_set_options[:scope]
146 146 roots(:group => scope_column_names).group_by{|record| scope_column_names.collect{|col| record.send(col.to_sym)}}.all? do |scope, grouped_roots|
147 147 each_root_valid?(grouped_roots)
148 148 end
149 149 else
150 150 each_root_valid?(roots)
151 151 end
152 152 end
153 153
154 154 def each_root_valid?(roots_to_validate)
155 155 left = right = 0
156 156 roots_to_validate.all? do |root|
157 157 (root.left > left && root.right > right).tap do
158 158 left = root.left
159 159 right = root.right
160 160 end
161 161 end
162 162 end
163 163
164 164 # Rebuilds the left & rights if unset or invalid. Also very useful for converting from acts_as_tree.
165 165 def rebuild!
166 166 # Don't rebuild a valid tree.
167 167 return true if valid?
168 168
169 169 scope = lambda{|node|}
170 170 if acts_as_nested_set_options[:scope]
171 171 scope = lambda{|node|
172 172 scope_column_names.inject(""){|str, column_name|
173 173 str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name.to_sym))} "
174 174 }
175 175 }
176 176 end
177 177 indices = {}
178 178
179 179 set_left_and_rights = lambda do |node|
180 180 # set left
181 181 node[left_column_name] = indices[scope.call(node)] += 1
182 182 # find
183 183 find(:all, :conditions => ["#{quoted_parent_column_name} = ? #{scope.call(node)}", node], :order => "#{quoted_left_column_name}, #{quoted_right_column_name}, #{acts_as_nested_set_options[:order]}").each{|n| set_left_and_rights.call(n) }
184 184 # set right
185 185 node[right_column_name] = indices[scope.call(node)] += 1
186 186 node.save!
187 187 end
188 188
189 189 # Find root node(s)
190 190 root_nodes = find(:all, :conditions => "#{quoted_parent_column_name} IS NULL", :order => "#{quoted_left_column_name}, #{quoted_right_column_name}, #{acts_as_nested_set_options[:order]}").each do |root_node|
191 191 # setup index for this scope
192 192 indices[scope.call(root_node)] ||= 0
193 193 set_left_and_rights.call(root_node)
194 194 end
195 195 end
196 196 end
197 197
198 198 # Mixed into both classes and instances to provide easy access to the column names
199 199 module Columns
200 200 def left_column_name
201 201 acts_as_nested_set_options[:left_column]
202 202 end
203 203
204 204 def right_column_name
205 205 acts_as_nested_set_options[:right_column]
206 206 end
207 207
208 208 def parent_column_name
209 209 acts_as_nested_set_options[:parent_column]
210 210 end
211 211
212 212 def scope_column_names
213 213 Array(acts_as_nested_set_options[:scope])
214 214 end
215 215
216 216 def quoted_left_column_name
217 217 connection.quote_column_name(left_column_name)
218 218 end
219 219
220 220 def quoted_right_column_name
221 221 connection.quote_column_name(right_column_name)
222 222 end
223 223
224 224 def quoted_parent_column_name
225 225 connection.quote_column_name(parent_column_name)
226 226 end
227 227
228 228 def quoted_scope_column_names
229 229 scope_column_names.collect {|column_name| connection.quote_column_name(column_name) }
230 230 end
231 231 end
232 232
233 233 # Any instance method that returns a collection makes use of Rails 2.1's named_scope (which is bundled for Rails 2.0), so it can be treated as a finder.
234 234 #
235 235 # category.self_and_descendants.count
236 236 # category.ancestors.find(:all, :conditions => "name like '%foo%'")
237 237 module InstanceMethods
238 238 # Value of the parent column
239 239 def parent_id
240 240 self[parent_column_name]
241 241 end
242 242
243 243 # Value of the left column
244 244 def left
245 245 self[left_column_name]
246 246 end
247 247
248 248 # Value of the right column
249 249 def right
250 250 self[right_column_name]
251 251 end
252 252
253 253 # Returns true if this is a root node.
254 254 def root?
255 255 parent_id.nil?
256 256 end
257 257
258 258 def leaf?
259 259 new_record? || (right - left == 1)
260 260 end
261 261
262 262 # Returns true is this is a child node
263 263 def child?
264 264 !parent_id.nil?
265 265 end
266 266
267 267 # order by left column
268 268 def <=>(x)
269 269 left <=> x.left
270 270 end
271 271
272 272 # Redefine to act like active record
273 273 def ==(comparison_object)
274 274 comparison_object.equal?(self) ||
275 275 (comparison_object.instance_of?(self.class) &&
276 276 comparison_object.id == id &&
277 277 !comparison_object.new_record?)
278 278 end
279 279
280 280 # Returns root
281 281 def root
282 282 self_and_ancestors.find(:first)
283 283 end
284 284
285 285 # Returns the immediate parent
286 286 def parent
287 287 nested_set_scope.find_by_id(parent_id) if parent_id
288 288 end
289 289
290 290 # Returns the array of all parents and self
291 291 def self_and_ancestors
292 292 nested_set_scope.scoped :conditions => [
293 293 "#{self.class.table_name}.#{quoted_left_column_name} <= ? AND #{self.class.table_name}.#{quoted_right_column_name} >= ?", left, right
294 294 ]
295 295 end
296 296
297 297 # Returns an array of all parents
298 298 def ancestors
299 299 without_self self_and_ancestors
300 300 end
301 301
302 302 # Returns the array of all children of the parent, including self
303 303 def self_and_siblings
304 304 nested_set_scope.scoped :conditions => {parent_column_name => parent_id}
305 305 end
306 306
307 307 # Returns the array of all children of the parent, except self
308 308 def siblings
309 309 without_self self_and_siblings
310 310 end
311 311
312 312 # Returns a set of all of its nested children which do not have children
313 313 def leaves
314 314 descendants.scoped :conditions => "#{self.class.table_name}.#{quoted_right_column_name} - #{self.class.table_name}.#{quoted_left_column_name} = 1"
315 315 end
316 316
317 317 # Returns the level of this object in the tree
318 318 # root level is 0
319 319 def level
320 320 parent_id.nil? ? 0 : ancestors.count
321 321 end
322 322
323 323 # Returns a set of itself and all of its nested children
324 324 def self_and_descendants
325 325 nested_set_scope.scoped :conditions => [
326 326 "#{self.class.table_name}.#{quoted_left_column_name} >= ? AND #{self.class.table_name}.#{quoted_right_column_name} <= ?", left, right
327 327 ]
328 328 end
329 329
330 330 # Returns a set of all of its children and nested children
331 331 def descendants
332 332 without_self self_and_descendants
333 333 end
334 334
335 335 # Returns a set of only this entry's immediate children
336 336 def children
337 337 nested_set_scope.scoped :conditions => {parent_column_name => self}
338 338 end
339 339
340 340 def is_descendant_of?(other)
341 341 other.left < self.left && self.left < other.right && same_scope?(other)
342 342 end
343 343
344 344 def is_or_is_descendant_of?(other)
345 345 other.left <= self.left && self.left < other.right && same_scope?(other)
346 346 end
347 347
348 348 def is_ancestor_of?(other)
349 349 self.left < other.left && other.left < self.right && same_scope?(other)
350 350 end
351 351
352 352 def is_or_is_ancestor_of?(other)
353 353 self.left <= other.left && other.left < self.right && same_scope?(other)
354 354 end
355 355
356 356 # Check if other model is in the same scope
357 357 def same_scope?(other)
358 358 Array(acts_as_nested_set_options[:scope]).all? do |attr|
359 359 self.send(attr) == other.send(attr)
360 360 end
361 361 end
362 362
363 363 # Find the first sibling to the left
364 364 def left_sibling
365 365 siblings.find(:first, :conditions => ["#{self.class.table_name}.#{quoted_left_column_name} < ?", left],
366 366 :order => "#{self.class.table_name}.#{quoted_left_column_name} DESC")
367 367 end
368 368
369 369 # Find the first sibling to the right
370 370 def right_sibling
371 371 siblings.find(:first, :conditions => ["#{self.class.table_name}.#{quoted_left_column_name} > ?", left])
372 372 end
373 373
374 374 # Shorthand method for finding the left sibling and moving to the left of it.
375 375 def move_left
376 376 move_to_left_of left_sibling
377 377 end
378 378
379 379 # Shorthand method for finding the right sibling and moving to the right of it.
380 380 def move_right
381 381 move_to_right_of right_sibling
382 382 end
383 383
384 384 # Move the node to the left of another node (you can pass id only)
385 385 def move_to_left_of(node)
386 386 move_to node, :left
387 387 end
388 388
389 389 # Move the node to the left of another node (you can pass id only)
390 390 def move_to_right_of(node)
391 391 move_to node, :right
392 392 end
393 393
394 394 # Move the node to the child of another node (you can pass id only)
395 395 def move_to_child_of(node)
396 396 move_to node, :child
397 397 end
398 398
399 399 # Move the node to root nodes
400 400 def move_to_root
401 401 move_to nil, :root
402 402 end
403 403
404 404 def move_possible?(target)
405 405 self != target && # Can't target self
406 406 same_scope?(target) && # can't be in different scopes
407 407 # !(left..right).include?(target.left..target.right) # this needs tested more
408 408 # detect impossible move
409 409 !((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right))
410 410 end
411 411
412 412 def to_text
413 413 self_and_descendants.map do |node|
414 414 "#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})"
415 415 end.join("\n")
416 416 end
417 417
418 418 protected
419 419
420 420 def without_self(scope)
421 421 scope.scoped :conditions => ["#{self.class.table_name}.#{self.class.primary_key} != ?", self]
422 422 end
423 423
424 424 # All nested set queries should use this nested_set_scope, which performs finds on
425 425 # the base ActiveRecord class, using the :scope declared in the acts_as_nested_set
426 426 # declaration.
427 427 def nested_set_scope
428 428 options = {:order => quoted_left_column_name}
429 429 scopes = Array(acts_as_nested_set_options[:scope])
430 430 options[:conditions] = scopes.inject({}) do |conditions,attr|
431 431 conditions.merge attr => self[attr]
432 432 end unless scopes.empty?
433 433 self.class.base_class.scoped options
434 434 end
435 435
436 436 # on creation, set automatically lft and rgt to the end of the tree
437 437 def set_default_left_and_right
438 438 maxright = nested_set_scope.maximum(right_column_name) || 0
439 439 # adds the new node to the right of all existing nodes
440 440 self[left_column_name] = maxright + 1
441 441 self[right_column_name] = maxright + 2
442 442 end
443 443
444 444 # Prunes a branch off of the tree, shifting all of the elements on the right
445 445 # back to the left so the counts still work.
446 446 def prune_from_tree
447 return if right.nil? || left.nil?
448 diff = right - left + 1
447 return if right.nil? || left.nil? || !self.class.exists?(id)
449 448
450 449 delete_method = acts_as_nested_set_options[:dependent] == :destroy ?
451 450 :destroy_all : :delete_all
452 451
453 452 self.class.base_class.transaction do
453 reload_nested_set
454 454 nested_set_scope.send(delete_method,
455 455 ["#{quoted_left_column_name} > ? AND #{quoted_right_column_name} < ?",
456 456 left, right]
457 457 )
458 reload_nested_set
459 diff = right - left + 1
458 460 nested_set_scope.update_all(
459 461 ["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff],
460 462 ["#{quoted_left_column_name} >= ?", right]
461 463 )
462 464 nested_set_scope.update_all(
463 465 ["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff],
464 466 ["#{quoted_right_column_name} >= ?", right]
465 467 )
466 468 end
467 469 end
468 470
469 471 # reload left, right, and parent
470 472 def reload_nested_set
471 473 reload(:select => "#{quoted_left_column_name}, " +
472 474 "#{quoted_right_column_name}, #{quoted_parent_column_name}")
473 475 end
474 476
475 477 def move_to(target, position)
476 478 raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record?
477 479 return if callback(:before_move) == false
478 480 transaction do
479 481 if target.is_a? self.class.base_class
480 482 target.reload_nested_set
481 483 elsif position != :root
482 484 # load object if node is not an object
483 485 target = nested_set_scope.find(target)
484 486 end
485 487 self.reload_nested_set
486 488
487 489 unless position == :root || move_possible?(target)
488 490 raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree."
489 491 end
490 492
491 493 bound = case position
492 494 when :child; target[right_column_name]
493 495 when :left; target[left_column_name]
494 496 when :right; target[right_column_name] + 1
495 497 when :root; 1
496 498 else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)."
497 499 end
498 500
499 501 if bound > self[right_column_name]
500 502 bound = bound - 1
501 503 other_bound = self[right_column_name] + 1
502 504 else
503 505 other_bound = self[left_column_name] - 1
504 506 end
505 507
506 508 # there would be no change
507 509 return if bound == self[right_column_name] || bound == self[left_column_name]
508 510
509 511 # we have defined the boundaries of two non-overlapping intervals,
510 512 # so sorting puts both the intervals and their boundaries in order
511 513 a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort
512 514
513 515 new_parent = case position
514 516 when :child; target.id
515 517 when :root; nil
516 518 else target[parent_column_name]
517 519 end
518 520
519 521 self.class.base_class.update_all([
520 522 "#{quoted_left_column_name} = CASE " +
521 523 "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " +
522 524 "THEN #{quoted_left_column_name} + :d - :b " +
523 525 "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " +
524 526 "THEN #{quoted_left_column_name} + :a - :c " +
525 527 "ELSE #{quoted_left_column_name} END, " +
526 528 "#{quoted_right_column_name} = CASE " +
527 529 "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " +
528 530 "THEN #{quoted_right_column_name} + :d - :b " +
529 531 "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " +
530 532 "THEN #{quoted_right_column_name} + :a - :c " +
531 533 "ELSE #{quoted_right_column_name} END, " +
532 534 "#{quoted_parent_column_name} = CASE " +
533 535 "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " +
534 536 "ELSE #{quoted_parent_column_name} END",
535 537 {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent}
536 538 ], nested_set_scope.proxy_options[:conditions])
537 539 end
538 540 target.reload_nested_set if target
539 541 self.reload_nested_set
540 542 callback(:after_move)
541 543 end
542 544
543 545 end
544 546
545 547 end
546 548 end
547 549 end
General Comments 0
You need to be logged in to leave comments. Login now