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