##// END OF EJS Templates
fix always new lft and rgt are lft = 1, rgt = 2 (#6579)...
Toshi MARUYAMA -
r12734:3c83d1c64640
parent child
Show More
@@ -1,1572 +1,1570
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 include Redmine::Utils::DateCalculation
20 include Redmine::Utils::DateCalculation
21 include Redmine::I18n
21 include Redmine::I18n
22
22
23 belongs_to :project
23 belongs_to :project
24 belongs_to :tracker
24 belongs_to :tracker
25 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
25 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
26 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
26 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
27 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
27 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
28 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
28 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
29 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
29 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
30 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
30 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
31
31
32 has_many :journals, :as => :journalized, :dependent => :destroy
32 has_many :journals, :as => :journalized, :dependent => :destroy
33 has_many :visible_journals,
33 has_many :visible_journals,
34 :class_name => 'Journal',
34 :class_name => 'Journal',
35 :as => :journalized,
35 :as => :journalized,
36 :conditions => Proc.new {
36 :conditions => Proc.new {
37 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
37 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
38 },
38 },
39 :readonly => true
39 :readonly => true
40
40
41 has_many :time_entries, :dependent => :destroy
41 has_many :time_entries, :dependent => :destroy
42 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
42 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
43
43
44 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
45 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
46
46
47 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
47 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
48 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
48 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
49 acts_as_customizable
49 acts_as_customizable
50 acts_as_watchable
50 acts_as_watchable
51 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
51 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
52 :include => [:project, :visible_journals],
52 :include => [:project, :visible_journals],
53 # sort by id so that limited eager loading doesn't break with postgresql
53 # sort by id so that limited eager loading doesn't break with postgresql
54 :order_column => "#{table_name}.id"
54 :order_column => "#{table_name}.id"
55 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
55 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
56 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
56 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
57 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
57 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
58
58
59 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
59 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
60 :author_key => :author_id
60 :author_key => :author_id
61
61
62 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
62 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
63
63
64 attr_reader :current_journal
64 attr_reader :current_journal
65 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
65 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
66
66
67 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
67 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
68
68
69 validates_length_of :subject, :maximum => 255
69 validates_length_of :subject, :maximum => 255
70 validates_inclusion_of :done_ratio, :in => 0..100
70 validates_inclusion_of :done_ratio, :in => 0..100
71 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
71 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
72 validates :start_date, :date => true
72 validates :start_date, :date => true
73 validates :due_date, :date => true
73 validates :due_date, :date => true
74 validate :validate_issue, :validate_required_fields
74 validate :validate_issue, :validate_required_fields
75
75
76 scope :visible, lambda {|*args|
76 scope :visible, lambda {|*args|
77 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
77 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
78 }
78 }
79
79
80 scope :open, lambda {|*args|
80 scope :open, lambda {|*args|
81 is_closed = args.size > 0 ? !args.first : false
81 is_closed = args.size > 0 ? !args.first : false
82 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
82 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
83 }
83 }
84
84
85 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
85 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
86 scope :on_active_project, lambda {
86 scope :on_active_project, lambda {
87 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
87 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
88 }
88 }
89 scope :fixed_version, lambda {|versions|
89 scope :fixed_version, lambda {|versions|
90 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
90 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
91 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
91 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
92 }
92 }
93
93
94 before_create :default_assign
94 before_create :default_assign
95 before_save :close_duplicates, :update_done_ratio_from_issue_status,
95 before_save :close_duplicates, :update_done_ratio_from_issue_status,
96 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
96 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
97 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
97 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
98 after_save :reschedule_following_issues, :update_nested_set_attributes,
98 after_save :reschedule_following_issues, :update_nested_set_attributes,
99 :update_parent_attributes, :create_journal
99 :update_parent_attributes, :create_journal
100 # Should be after_create but would be called before previous after_save callbacks
100 # Should be after_create but would be called before previous after_save callbacks
101 after_save :after_create_from_copy
101 after_save :after_create_from_copy
102 after_destroy :update_parent_attributes
102 after_destroy :update_parent_attributes
103 after_create :send_notification
103 after_create :send_notification
104 # Keep it at the end of after_save callbacks
104 # Keep it at the end of after_save callbacks
105 after_save :clear_assigned_to_was
105 after_save :clear_assigned_to_was
106
106
107 # Returns a SQL conditions string used to find all issues visible by the specified user
107 # Returns a SQL conditions string used to find all issues visible by the specified user
108 def self.visible_condition(user, options={})
108 def self.visible_condition(user, options={})
109 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
109 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
110 if user.logged?
110 if user.logged?
111 case role.issues_visibility
111 case role.issues_visibility
112 when 'all'
112 when 'all'
113 nil
113 nil
114 when 'default'
114 when 'default'
115 user_ids = [user.id] + user.groups.map(&:id).compact
115 user_ids = [user.id] + user.groups.map(&:id).compact
116 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
116 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
117 when 'own'
117 when 'own'
118 user_ids = [user.id] + user.groups.map(&:id).compact
118 user_ids = [user.id] + user.groups.map(&:id).compact
119 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
119 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
120 else
120 else
121 '1=0'
121 '1=0'
122 end
122 end
123 else
123 else
124 "(#{table_name}.is_private = #{connection.quoted_false})"
124 "(#{table_name}.is_private = #{connection.quoted_false})"
125 end
125 end
126 end
126 end
127 end
127 end
128
128
129 # Returns true if usr or current user is allowed to view the issue
129 # Returns true if usr or current user is allowed to view the issue
130 def visible?(usr=nil)
130 def visible?(usr=nil)
131 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
131 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
132 if user.logged?
132 if user.logged?
133 case role.issues_visibility
133 case role.issues_visibility
134 when 'all'
134 when 'all'
135 true
135 true
136 when 'default'
136 when 'default'
137 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
137 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
138 when 'own'
138 when 'own'
139 self.author == user || user.is_or_belongs_to?(assigned_to)
139 self.author == user || user.is_or_belongs_to?(assigned_to)
140 else
140 else
141 false
141 false
142 end
142 end
143 else
143 else
144 !self.is_private?
144 !self.is_private?
145 end
145 end
146 end
146 end
147 end
147 end
148
148
149 # Returns true if user or current user is allowed to edit or add a note to the issue
149 # Returns true if user or current user is allowed to edit or add a note to the issue
150 def editable?(user=User.current)
150 def editable?(user=User.current)
151 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
151 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
152 end
152 end
153
153
154 def initialize(attributes=nil, *args)
154 def initialize(attributes=nil, *args)
155 super
155 super
156 if new_record?
156 if new_record?
157 # set default values for new records only
157 # set default values for new records only
158 self.status ||= IssueStatus.default
158 self.status ||= IssueStatus.default
159 self.priority ||= IssuePriority.default
159 self.priority ||= IssuePriority.default
160 self.watcher_user_ids = []
160 self.watcher_user_ids = []
161 end
161 end
162 end
162 end
163
163
164 def create_or_update
164 def create_or_update
165 super
165 super
166 ensure
166 ensure
167 @status_was = nil
167 @status_was = nil
168 end
168 end
169 private :create_or_update
169 private :create_or_update
170
170
171 # AR#Persistence#destroy would raise and RecordNotFound exception
171 # AR#Persistence#destroy would raise and RecordNotFound exception
172 # if the issue was already deleted or updated (non matching lock_version).
172 # if the issue was already deleted or updated (non matching lock_version).
173 # This is a problem when bulk deleting issues or deleting a project
173 # This is a problem when bulk deleting issues or deleting a project
174 # (because an issue may already be deleted if its parent was deleted
174 # (because an issue may already be deleted if its parent was deleted
175 # first).
175 # first).
176 # The issue is reloaded by the nested_set before being deleted so
176 # The issue is reloaded by the nested_set before being deleted so
177 # the lock_version condition should not be an issue but we handle it.
177 # the lock_version condition should not be an issue but we handle it.
178 def destroy
178 def destroy
179 super
179 super
180 rescue ActiveRecord::RecordNotFound
180 rescue ActiveRecord::RecordNotFound
181 # Stale or already deleted
181 # Stale or already deleted
182 begin
182 begin
183 reload
183 reload
184 rescue ActiveRecord::RecordNotFound
184 rescue ActiveRecord::RecordNotFound
185 # The issue was actually already deleted
185 # The issue was actually already deleted
186 @destroyed = true
186 @destroyed = true
187 return freeze
187 return freeze
188 end
188 end
189 # The issue was stale, retry to destroy
189 # The issue was stale, retry to destroy
190 super
190 super
191 end
191 end
192
192
193 alias :base_reload :reload
193 alias :base_reload :reload
194 def reload(*args)
194 def reload(*args)
195 @workflow_rule_by_attribute = nil
195 @workflow_rule_by_attribute = nil
196 @assignable_versions = nil
196 @assignable_versions = nil
197 @relations = nil
197 @relations = nil
198 base_reload(*args)
198 base_reload(*args)
199 end
199 end
200
200
201 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
201 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
202 def available_custom_fields
202 def available_custom_fields
203 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
203 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
204 end
204 end
205
205
206 def visible_custom_field_values(user=nil)
206 def visible_custom_field_values(user=nil)
207 user_real = user || User.current
207 user_real = user || User.current
208 custom_field_values.select do |value|
208 custom_field_values.select do |value|
209 value.custom_field.visible_by?(project, user_real)
209 value.custom_field.visible_by?(project, user_real)
210 end
210 end
211 end
211 end
212
212
213 # Copies attributes from another issue, arg can be an id or an Issue
213 # Copies attributes from another issue, arg can be an id or an Issue
214 def copy_from(arg, options={})
214 def copy_from(arg, options={})
215 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
215 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
216 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
216 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
217 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
217 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
218 self.status = issue.status
218 self.status = issue.status
219 self.author = User.current
219 self.author = User.current
220 unless options[:attachments] == false
220 unless options[:attachments] == false
221 self.attachments = issue.attachments.map do |attachement|
221 self.attachments = issue.attachments.map do |attachement|
222 attachement.copy(:container => self)
222 attachement.copy(:container => self)
223 end
223 end
224 end
224 end
225 @copied_from = issue
225 @copied_from = issue
226 @copy_options = options
226 @copy_options = options
227 self
227 self
228 end
228 end
229
229
230 # Returns an unsaved copy of the issue
230 # Returns an unsaved copy of the issue
231 def copy(attributes=nil, copy_options={})
231 def copy(attributes=nil, copy_options={})
232 copy = self.class.new.copy_from(self, copy_options)
232 copy = self.class.new.copy_from(self, copy_options)
233 copy.attributes = attributes if attributes
233 copy.attributes = attributes if attributes
234 copy
234 copy
235 end
235 end
236
236
237 # Returns true if the issue is a copy
237 # Returns true if the issue is a copy
238 def copy?
238 def copy?
239 @copied_from.present?
239 @copied_from.present?
240 end
240 end
241
241
242 # Moves/copies an issue to a new project and tracker
242 # Moves/copies an issue to a new project and tracker
243 # Returns the moved/copied issue on success, false on failure
243 # Returns the moved/copied issue on success, false on failure
244 def move_to_project(new_project, new_tracker=nil, options={})
244 def move_to_project(new_project, new_tracker=nil, options={})
245 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
245 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
246
246
247 if options[:copy]
247 if options[:copy]
248 issue = self.copy
248 issue = self.copy
249 else
249 else
250 issue = self
250 issue = self
251 end
251 end
252
252
253 issue.init_journal(User.current, options[:notes])
253 issue.init_journal(User.current, options[:notes])
254
254
255 # Preserve previous behaviour
255 # Preserve previous behaviour
256 # #move_to_project doesn't change tracker automatically
256 # #move_to_project doesn't change tracker automatically
257 issue.send :project=, new_project, true
257 issue.send :project=, new_project, true
258 if new_tracker
258 if new_tracker
259 issue.tracker = new_tracker
259 issue.tracker = new_tracker
260 end
260 end
261 # Allow bulk setting of attributes on the issue
261 # Allow bulk setting of attributes on the issue
262 if options[:attributes]
262 if options[:attributes]
263 issue.attributes = options[:attributes]
263 issue.attributes = options[:attributes]
264 end
264 end
265
265
266 issue.save ? issue : false
266 issue.save ? issue : false
267 end
267 end
268
268
269 def status_id=(sid)
269 def status_id=(sid)
270 self.status = nil
270 self.status = nil
271 result = write_attribute(:status_id, sid)
271 result = write_attribute(:status_id, sid)
272 @workflow_rule_by_attribute = nil
272 @workflow_rule_by_attribute = nil
273 result
273 result
274 end
274 end
275
275
276 def priority_id=(pid)
276 def priority_id=(pid)
277 self.priority = nil
277 self.priority = nil
278 write_attribute(:priority_id, pid)
278 write_attribute(:priority_id, pid)
279 end
279 end
280
280
281 def category_id=(cid)
281 def category_id=(cid)
282 self.category = nil
282 self.category = nil
283 write_attribute(:category_id, cid)
283 write_attribute(:category_id, cid)
284 end
284 end
285
285
286 def fixed_version_id=(vid)
286 def fixed_version_id=(vid)
287 self.fixed_version = nil
287 self.fixed_version = nil
288 write_attribute(:fixed_version_id, vid)
288 write_attribute(:fixed_version_id, vid)
289 end
289 end
290
290
291 def tracker_id=(tid)
291 def tracker_id=(tid)
292 self.tracker = nil
292 self.tracker = nil
293 result = write_attribute(:tracker_id, tid)
293 result = write_attribute(:tracker_id, tid)
294 @custom_field_values = nil
294 @custom_field_values = nil
295 @workflow_rule_by_attribute = nil
295 @workflow_rule_by_attribute = nil
296 result
296 result
297 end
297 end
298
298
299 def project_id=(project_id)
299 def project_id=(project_id)
300 if project_id.to_s != self.project_id.to_s
300 if project_id.to_s != self.project_id.to_s
301 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
301 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
302 end
302 end
303 end
303 end
304
304
305 def project=(project, keep_tracker=false)
305 def project=(project, keep_tracker=false)
306 project_was = self.project
306 project_was = self.project
307 write_attribute(:project_id, project ? project.id : nil)
307 write_attribute(:project_id, project ? project.id : nil)
308 association_instance_set('project', project)
308 association_instance_set('project', project)
309 if project_was && project && project_was != project
309 if project_was && project && project_was != project
310 @assignable_versions = nil
310 @assignable_versions = nil
311
311
312 unless keep_tracker || project.trackers.include?(tracker)
312 unless keep_tracker || project.trackers.include?(tracker)
313 self.tracker = project.trackers.first
313 self.tracker = project.trackers.first
314 end
314 end
315 # Reassign to the category with same name if any
315 # Reassign to the category with same name if any
316 if category
316 if category
317 self.category = project.issue_categories.find_by_name(category.name)
317 self.category = project.issue_categories.find_by_name(category.name)
318 end
318 end
319 # Keep the fixed_version if it's still valid in the new_project
319 # Keep the fixed_version if it's still valid in the new_project
320 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
320 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
321 self.fixed_version = nil
321 self.fixed_version = nil
322 end
322 end
323 # Clear the parent task if it's no longer valid
323 # Clear the parent task if it's no longer valid
324 unless valid_parent_project?
324 unless valid_parent_project?
325 self.parent_issue_id = nil
325 self.parent_issue_id = nil
326 end
326 end
327 @custom_field_values = nil
327 @custom_field_values = nil
328 end
328 end
329 end
329 end
330
330
331 def description=(arg)
331 def description=(arg)
332 if arg.is_a?(String)
332 if arg.is_a?(String)
333 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
333 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
334 end
334 end
335 write_attribute(:description, arg)
335 write_attribute(:description, arg)
336 end
336 end
337
337
338 # Overrides assign_attributes so that project and tracker get assigned first
338 # Overrides assign_attributes so that project and tracker get assigned first
339 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
339 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
340 return if new_attributes.nil?
340 return if new_attributes.nil?
341 attrs = new_attributes.dup
341 attrs = new_attributes.dup
342 attrs.stringify_keys!
342 attrs.stringify_keys!
343
343
344 %w(project project_id tracker tracker_id).each do |attr|
344 %w(project project_id tracker tracker_id).each do |attr|
345 if attrs.has_key?(attr)
345 if attrs.has_key?(attr)
346 send "#{attr}=", attrs.delete(attr)
346 send "#{attr}=", attrs.delete(attr)
347 end
347 end
348 end
348 end
349 send :assign_attributes_without_project_and_tracker_first, attrs, *args
349 send :assign_attributes_without_project_and_tracker_first, attrs, *args
350 end
350 end
351 # Do not redefine alias chain on reload (see #4838)
351 # Do not redefine alias chain on reload (see #4838)
352 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
352 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
353
353
354 def estimated_hours=(h)
354 def estimated_hours=(h)
355 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
355 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
356 end
356 end
357
357
358 safe_attributes 'project_id',
358 safe_attributes 'project_id',
359 :if => lambda {|issue, user|
359 :if => lambda {|issue, user|
360 if issue.new_record?
360 if issue.new_record?
361 issue.copy?
361 issue.copy?
362 elsif user.allowed_to?(:move_issues, issue.project)
362 elsif user.allowed_to?(:move_issues, issue.project)
363 Issue.allowed_target_projects_on_move.count > 1
363 Issue.allowed_target_projects_on_move.count > 1
364 end
364 end
365 }
365 }
366
366
367 safe_attributes 'tracker_id',
367 safe_attributes 'tracker_id',
368 'status_id',
368 'status_id',
369 'category_id',
369 'category_id',
370 'assigned_to_id',
370 'assigned_to_id',
371 'priority_id',
371 'priority_id',
372 'fixed_version_id',
372 'fixed_version_id',
373 'subject',
373 'subject',
374 'description',
374 'description',
375 'start_date',
375 'start_date',
376 'due_date',
376 'due_date',
377 'done_ratio',
377 'done_ratio',
378 'estimated_hours',
378 'estimated_hours',
379 'custom_field_values',
379 'custom_field_values',
380 'custom_fields',
380 'custom_fields',
381 'lock_version',
381 'lock_version',
382 'notes',
382 'notes',
383 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
383 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
384
384
385 safe_attributes 'status_id',
385 safe_attributes 'status_id',
386 'assigned_to_id',
386 'assigned_to_id',
387 'fixed_version_id',
387 'fixed_version_id',
388 'done_ratio',
388 'done_ratio',
389 'lock_version',
389 'lock_version',
390 'notes',
390 'notes',
391 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
391 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
392
392
393 safe_attributes 'notes',
393 safe_attributes 'notes',
394 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
394 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
395
395
396 safe_attributes 'private_notes',
396 safe_attributes 'private_notes',
397 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
397 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
398
398
399 safe_attributes 'watcher_user_ids',
399 safe_attributes 'watcher_user_ids',
400 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
400 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
401
401
402 safe_attributes 'is_private',
402 safe_attributes 'is_private',
403 :if => lambda {|issue, user|
403 :if => lambda {|issue, user|
404 user.allowed_to?(:set_issues_private, issue.project) ||
404 user.allowed_to?(:set_issues_private, issue.project) ||
405 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
405 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
406 }
406 }
407
407
408 safe_attributes 'parent_issue_id',
408 safe_attributes 'parent_issue_id',
409 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
409 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
410 user.allowed_to?(:manage_subtasks, issue.project)}
410 user.allowed_to?(:manage_subtasks, issue.project)}
411
411
412 def safe_attribute_names(user=nil)
412 def safe_attribute_names(user=nil)
413 names = super
413 names = super
414 names -= disabled_core_fields
414 names -= disabled_core_fields
415 names -= read_only_attribute_names(user)
415 names -= read_only_attribute_names(user)
416 names
416 names
417 end
417 end
418
418
419 # Safely sets attributes
419 # Safely sets attributes
420 # Should be called from controllers instead of #attributes=
420 # Should be called from controllers instead of #attributes=
421 # attr_accessible is too rough because we still want things like
421 # attr_accessible is too rough because we still want things like
422 # Issue.new(:project => foo) to work
422 # Issue.new(:project => foo) to work
423 def safe_attributes=(attrs, user=User.current)
423 def safe_attributes=(attrs, user=User.current)
424 return unless attrs.is_a?(Hash)
424 return unless attrs.is_a?(Hash)
425
425
426 attrs = attrs.dup
426 attrs = attrs.dup
427
427
428 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
428 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
429 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
429 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
430 if allowed_target_projects(user).where(:id => p.to_i).exists?
430 if allowed_target_projects(user).where(:id => p.to_i).exists?
431 self.project_id = p
431 self.project_id = p
432 end
432 end
433 end
433 end
434
434
435 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
435 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
436 self.tracker_id = t
436 self.tracker_id = t
437 end
437 end
438
438
439 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
439 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
440 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
440 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
441 self.status_id = s
441 self.status_id = s
442 end
442 end
443 end
443 end
444
444
445 attrs = delete_unsafe_attributes(attrs, user)
445 attrs = delete_unsafe_attributes(attrs, user)
446 return if attrs.empty?
446 return if attrs.empty?
447
447
448 unless leaf?
448 unless leaf?
449 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
449 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
450 end
450 end
451
451
452 if attrs['parent_issue_id'].present?
452 if attrs['parent_issue_id'].present?
453 s = attrs['parent_issue_id'].to_s
453 s = attrs['parent_issue_id'].to_s
454 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
454 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
455 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
455 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
456 end
456 end
457 end
457 end
458
458
459 if attrs['custom_field_values'].present?
459 if attrs['custom_field_values'].present?
460 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
460 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
461 # TODO: use #select when ruby1.8 support is dropped
461 # TODO: use #select when ruby1.8 support is dropped
462 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| !editable_custom_field_ids.include?(k.to_s)}
462 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| !editable_custom_field_ids.include?(k.to_s)}
463 end
463 end
464
464
465 if attrs['custom_fields'].present?
465 if attrs['custom_fields'].present?
466 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
466 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
467 # TODO: use #select when ruby1.8 support is dropped
467 # TODO: use #select when ruby1.8 support is dropped
468 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| !editable_custom_field_ids.include?(c['id'].to_s)}
468 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| !editable_custom_field_ids.include?(c['id'].to_s)}
469 end
469 end
470
470
471 # mass-assignment security bypass
471 # mass-assignment security bypass
472 assign_attributes attrs, :without_protection => true
472 assign_attributes attrs, :without_protection => true
473 end
473 end
474
474
475 def disabled_core_fields
475 def disabled_core_fields
476 tracker ? tracker.disabled_core_fields : []
476 tracker ? tracker.disabled_core_fields : []
477 end
477 end
478
478
479 # Returns the custom_field_values that can be edited by the given user
479 # Returns the custom_field_values that can be edited by the given user
480 def editable_custom_field_values(user=nil)
480 def editable_custom_field_values(user=nil)
481 visible_custom_field_values(user).reject do |value|
481 visible_custom_field_values(user).reject do |value|
482 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
482 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
483 end
483 end
484 end
484 end
485
485
486 # Returns the names of attributes that are read-only for user or the current user
486 # Returns the names of attributes that are read-only for user or the current user
487 # For users with multiple roles, the read-only fields are the intersection of
487 # For users with multiple roles, the read-only fields are the intersection of
488 # read-only fields of each role
488 # read-only fields of each role
489 # The result is an array of strings where sustom fields are represented with their ids
489 # The result is an array of strings where sustom fields are represented with their ids
490 #
490 #
491 # Examples:
491 # Examples:
492 # issue.read_only_attribute_names # => ['due_date', '2']
492 # issue.read_only_attribute_names # => ['due_date', '2']
493 # issue.read_only_attribute_names(user) # => []
493 # issue.read_only_attribute_names(user) # => []
494 def read_only_attribute_names(user=nil)
494 def read_only_attribute_names(user=nil)
495 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
495 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
496 end
496 end
497
497
498 # Returns the names of required attributes for user or the current user
498 # Returns the names of required attributes for user or the current user
499 # For users with multiple roles, the required fields are the intersection of
499 # For users with multiple roles, the required fields are the intersection of
500 # required fields of each role
500 # required fields of each role
501 # The result is an array of strings where sustom fields are represented with their ids
501 # The result is an array of strings where sustom fields are represented with their ids
502 #
502 #
503 # Examples:
503 # Examples:
504 # issue.required_attribute_names # => ['due_date', '2']
504 # issue.required_attribute_names # => ['due_date', '2']
505 # issue.required_attribute_names(user) # => []
505 # issue.required_attribute_names(user) # => []
506 def required_attribute_names(user=nil)
506 def required_attribute_names(user=nil)
507 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
507 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
508 end
508 end
509
509
510 # Returns true if the attribute is required for user
510 # Returns true if the attribute is required for user
511 def required_attribute?(name, user=nil)
511 def required_attribute?(name, user=nil)
512 required_attribute_names(user).include?(name.to_s)
512 required_attribute_names(user).include?(name.to_s)
513 end
513 end
514
514
515 # Returns a hash of the workflow rule by attribute for the given user
515 # Returns a hash of the workflow rule by attribute for the given user
516 #
516 #
517 # Examples:
517 # Examples:
518 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
518 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
519 def workflow_rule_by_attribute(user=nil)
519 def workflow_rule_by_attribute(user=nil)
520 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
520 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
521
521
522 user_real = user || User.current
522 user_real = user || User.current
523 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
523 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
524 return {} if roles.empty?
524 return {} if roles.empty?
525
525
526 result = {}
526 result = {}
527 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id))
527 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id))
528 if workflow_permissions.any?
528 if workflow_permissions.any?
529 workflow_rules = workflow_permissions.inject({}) do |h, wp|
529 workflow_rules = workflow_permissions.inject({}) do |h, wp|
530 h[wp.field_name] ||= []
530 h[wp.field_name] ||= []
531 h[wp.field_name] << wp.rule
531 h[wp.field_name] << wp.rule
532 h
532 h
533 end
533 end
534 workflow_rules.each do |attr, rules|
534 workflow_rules.each do |attr, rules|
535 next if rules.size < roles.size
535 next if rules.size < roles.size
536 uniq_rules = rules.uniq
536 uniq_rules = rules.uniq
537 if uniq_rules.size == 1
537 if uniq_rules.size == 1
538 result[attr] = uniq_rules.first
538 result[attr] = uniq_rules.first
539 else
539 else
540 result[attr] = 'required'
540 result[attr] = 'required'
541 end
541 end
542 end
542 end
543 end
543 end
544 @workflow_rule_by_attribute = result if user.nil?
544 @workflow_rule_by_attribute = result if user.nil?
545 result
545 result
546 end
546 end
547 private :workflow_rule_by_attribute
547 private :workflow_rule_by_attribute
548
548
549 def done_ratio
549 def done_ratio
550 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
550 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
551 status.default_done_ratio
551 status.default_done_ratio
552 else
552 else
553 read_attribute(:done_ratio)
553 read_attribute(:done_ratio)
554 end
554 end
555 end
555 end
556
556
557 def self.use_status_for_done_ratio?
557 def self.use_status_for_done_ratio?
558 Setting.issue_done_ratio == 'issue_status'
558 Setting.issue_done_ratio == 'issue_status'
559 end
559 end
560
560
561 def self.use_field_for_done_ratio?
561 def self.use_field_for_done_ratio?
562 Setting.issue_done_ratio == 'issue_field'
562 Setting.issue_done_ratio == 'issue_field'
563 end
563 end
564
564
565 def validate_issue
565 def validate_issue
566 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
566 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
567 errors.add :due_date, :greater_than_start_date
567 errors.add :due_date, :greater_than_start_date
568 end
568 end
569
569
570 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
570 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
571 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
571 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
572 end
572 end
573
573
574 if fixed_version
574 if fixed_version
575 if !assignable_versions.include?(fixed_version)
575 if !assignable_versions.include?(fixed_version)
576 errors.add :fixed_version_id, :inclusion
576 errors.add :fixed_version_id, :inclusion
577 elsif reopened? && fixed_version.closed?
577 elsif reopened? && fixed_version.closed?
578 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
578 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
579 end
579 end
580 end
580 end
581
581
582 # Checks that the issue can not be added/moved to a disabled tracker
582 # Checks that the issue can not be added/moved to a disabled tracker
583 if project && (tracker_id_changed? || project_id_changed?)
583 if project && (tracker_id_changed? || project_id_changed?)
584 unless project.trackers.include?(tracker)
584 unless project.trackers.include?(tracker)
585 errors.add :tracker_id, :inclusion
585 errors.add :tracker_id, :inclusion
586 end
586 end
587 end
587 end
588
588
589 # Checks parent issue assignment
589 # Checks parent issue assignment
590 if @invalid_parent_issue_id.present?
590 if @invalid_parent_issue_id.present?
591 errors.add :parent_issue_id, :invalid
591 errors.add :parent_issue_id, :invalid
592 elsif @parent_issue
592 elsif @parent_issue
593 if !valid_parent_project?(@parent_issue)
593 if !valid_parent_project?(@parent_issue)
594 errors.add :parent_issue_id, :invalid
594 errors.add :parent_issue_id, :invalid
595 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
595 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
596 errors.add :parent_issue_id, :invalid
596 errors.add :parent_issue_id, :invalid
597 elsif !new_record?
597 elsif !new_record?
598 # moving an existing issue
598 # moving an existing issue
599 if @parent_issue.root_id != root_id
599 if @parent_issue.root_id != root_id
600 # we can always move to another tree
600 # we can always move to another tree
601 elsif move_possible?(@parent_issue)
601 elsif move_possible?(@parent_issue)
602 # move accepted inside tree
602 # move accepted inside tree
603 else
603 else
604 errors.add :parent_issue_id, :invalid
604 errors.add :parent_issue_id, :invalid
605 end
605 end
606 end
606 end
607 end
607 end
608 end
608 end
609
609
610 # Validates the issue against additional workflow requirements
610 # Validates the issue against additional workflow requirements
611 def validate_required_fields
611 def validate_required_fields
612 user = new_record? ? author : current_journal.try(:user)
612 user = new_record? ? author : current_journal.try(:user)
613
613
614 required_attribute_names(user).each do |attribute|
614 required_attribute_names(user).each do |attribute|
615 if attribute =~ /^\d+$/
615 if attribute =~ /^\d+$/
616 attribute = attribute.to_i
616 attribute = attribute.to_i
617 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
617 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
618 if v && v.value.blank?
618 if v && v.value.blank?
619 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
619 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
620 end
620 end
621 else
621 else
622 if respond_to?(attribute) && send(attribute).blank?
622 if respond_to?(attribute) && send(attribute).blank?
623 errors.add attribute, :blank
623 errors.add attribute, :blank
624 end
624 end
625 end
625 end
626 end
626 end
627 end
627 end
628
628
629 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
629 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
630 # even if the user turns off the setting later
630 # even if the user turns off the setting later
631 def update_done_ratio_from_issue_status
631 def update_done_ratio_from_issue_status
632 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
632 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
633 self.done_ratio = status.default_done_ratio
633 self.done_ratio = status.default_done_ratio
634 end
634 end
635 end
635 end
636
636
637 def init_journal(user, notes = "")
637 def init_journal(user, notes = "")
638 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
638 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
639 if new_record?
639 if new_record?
640 @current_journal.notify = false
640 @current_journal.notify = false
641 else
641 else
642 @attributes_before_change = attributes.dup
642 @attributes_before_change = attributes.dup
643 @custom_values_before_change = {}
643 @custom_values_before_change = {}
644 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
644 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
645 end
645 end
646 @current_journal
646 @current_journal
647 end
647 end
648
648
649 # Returns the id of the last journal or nil
649 # Returns the id of the last journal or nil
650 def last_journal_id
650 def last_journal_id
651 if new_record?
651 if new_record?
652 nil
652 nil
653 else
653 else
654 journals.maximum(:id)
654 journals.maximum(:id)
655 end
655 end
656 end
656 end
657
657
658 # Returns a scope for journals that have an id greater than journal_id
658 # Returns a scope for journals that have an id greater than journal_id
659 def journals_after(journal_id)
659 def journals_after(journal_id)
660 scope = journals.reorder("#{Journal.table_name}.id ASC")
660 scope = journals.reorder("#{Journal.table_name}.id ASC")
661 if journal_id.present?
661 if journal_id.present?
662 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
662 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
663 end
663 end
664 scope
664 scope
665 end
665 end
666
666
667 # Returns the initial status of the issue
667 # Returns the initial status of the issue
668 # Returns nil for a new issue
668 # Returns nil for a new issue
669 def status_was
669 def status_was
670 if status_id_was && status_id_was.to_i > 0
670 if status_id_was && status_id_was.to_i > 0
671 @status_was ||= IssueStatus.find_by_id(status_id_was)
671 @status_was ||= IssueStatus.find_by_id(status_id_was)
672 end
672 end
673 end
673 end
674
674
675 # Return true if the issue is closed, otherwise false
675 # Return true if the issue is closed, otherwise false
676 def closed?
676 def closed?
677 self.status.is_closed?
677 self.status.is_closed?
678 end
678 end
679
679
680 # Return true if the issue is being reopened
680 # Return true if the issue is being reopened
681 def reopened?
681 def reopened?
682 if !new_record? && status_id_changed?
682 if !new_record? && status_id_changed?
683 status_was = IssueStatus.find_by_id(status_id_was)
683 status_was = IssueStatus.find_by_id(status_id_was)
684 status_new = IssueStatus.find_by_id(status_id)
684 status_new = IssueStatus.find_by_id(status_id)
685 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
685 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
686 return true
686 return true
687 end
687 end
688 end
688 end
689 false
689 false
690 end
690 end
691
691
692 # Return true if the issue is being closed
692 # Return true if the issue is being closed
693 def closing?
693 def closing?
694 if !new_record? && status_id_changed?
694 if !new_record? && status_id_changed?
695 if status_was && status && !status_was.is_closed? && status.is_closed?
695 if status_was && status && !status_was.is_closed? && status.is_closed?
696 return true
696 return true
697 end
697 end
698 end
698 end
699 false
699 false
700 end
700 end
701
701
702 # Returns true if the issue is overdue
702 # Returns true if the issue is overdue
703 def overdue?
703 def overdue?
704 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
704 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
705 end
705 end
706
706
707 # Is the amount of work done less than it should for the due date
707 # Is the amount of work done less than it should for the due date
708 def behind_schedule?
708 def behind_schedule?
709 return false if start_date.nil? || due_date.nil?
709 return false if start_date.nil? || due_date.nil?
710 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
710 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
711 return done_date <= Date.today
711 return done_date <= Date.today
712 end
712 end
713
713
714 # Does this issue have children?
714 # Does this issue have children?
715 def children?
715 def children?
716 !leaf?
716 !leaf?
717 end
717 end
718
718
719 # Users the issue can be assigned to
719 # Users the issue can be assigned to
720 def assignable_users
720 def assignable_users
721 users = project.assignable_users
721 users = project.assignable_users
722 users << author if author
722 users << author if author
723 users << assigned_to if assigned_to
723 users << assigned_to if assigned_to
724 users.uniq.sort
724 users.uniq.sort
725 end
725 end
726
726
727 # Versions that the issue can be assigned to
727 # Versions that the issue can be assigned to
728 def assignable_versions
728 def assignable_versions
729 return @assignable_versions if @assignable_versions
729 return @assignable_versions if @assignable_versions
730
730
731 versions = project.shared_versions.open.all
731 versions = project.shared_versions.open.all
732 if fixed_version
732 if fixed_version
733 if fixed_version_id_changed?
733 if fixed_version_id_changed?
734 # nothing to do
734 # nothing to do
735 elsif project_id_changed?
735 elsif project_id_changed?
736 if project.shared_versions.include?(fixed_version)
736 if project.shared_versions.include?(fixed_version)
737 versions << fixed_version
737 versions << fixed_version
738 end
738 end
739 else
739 else
740 versions << fixed_version
740 versions << fixed_version
741 end
741 end
742 end
742 end
743 @assignable_versions = versions.uniq.sort
743 @assignable_versions = versions.uniq.sort
744 end
744 end
745
745
746 # Returns true if this issue is blocked by another issue that is still open
746 # Returns true if this issue is blocked by another issue that is still open
747 def blocked?
747 def blocked?
748 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
748 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
749 end
749 end
750
750
751 # Returns an array of statuses that user is able to apply
751 # Returns an array of statuses that user is able to apply
752 def new_statuses_allowed_to(user=User.current, include_default=false)
752 def new_statuses_allowed_to(user=User.current, include_default=false)
753 if new_record? && @copied_from
753 if new_record? && @copied_from
754 [IssueStatus.default, @copied_from.status].compact.uniq.sort
754 [IssueStatus.default, @copied_from.status].compact.uniq.sort
755 else
755 else
756 initial_status = nil
756 initial_status = nil
757 if new_record?
757 if new_record?
758 initial_status = IssueStatus.default
758 initial_status = IssueStatus.default
759 elsif status_id_was
759 elsif status_id_was
760 initial_status = IssueStatus.find_by_id(status_id_was)
760 initial_status = IssueStatus.find_by_id(status_id_was)
761 end
761 end
762 initial_status ||= status
762 initial_status ||= status
763
763
764 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
764 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
765 assignee_transitions_allowed = initial_assigned_to_id.present? &&
765 assignee_transitions_allowed = initial_assigned_to_id.present? &&
766 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
766 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
767
767
768 statuses = initial_status.find_new_statuses_allowed_to(
768 statuses = initial_status.find_new_statuses_allowed_to(
769 user.admin ? Role.all : user.roles_for_project(project),
769 user.admin ? Role.all : user.roles_for_project(project),
770 tracker,
770 tracker,
771 author == user,
771 author == user,
772 assignee_transitions_allowed
772 assignee_transitions_allowed
773 )
773 )
774 statuses << initial_status unless statuses.empty?
774 statuses << initial_status unless statuses.empty?
775 statuses << IssueStatus.default if include_default
775 statuses << IssueStatus.default if include_default
776 statuses = statuses.compact.uniq.sort
776 statuses = statuses.compact.uniq.sort
777 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
777 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
778 end
778 end
779 end
779 end
780
780
781 # Returns the previous assignee if changed
781 # Returns the previous assignee if changed
782 def assigned_to_was
782 def assigned_to_was
783 # assigned_to_id_was is reset before after_save callbacks
783 # assigned_to_id_was is reset before after_save callbacks
784 user_id = @previous_assigned_to_id || assigned_to_id_was
784 user_id = @previous_assigned_to_id || assigned_to_id_was
785 if user_id && user_id != assigned_to_id
785 if user_id && user_id != assigned_to_id
786 @assigned_to_was ||= User.find_by_id(user_id)
786 @assigned_to_was ||= User.find_by_id(user_id)
787 end
787 end
788 end
788 end
789
789
790 # Returns the users that should be notified
790 # Returns the users that should be notified
791 def notified_users
791 def notified_users
792 notified = []
792 notified = []
793 # Author and assignee are always notified unless they have been
793 # Author and assignee are always notified unless they have been
794 # locked or don't want to be notified
794 # locked or don't want to be notified
795 notified << author if author
795 notified << author if author
796 if assigned_to
796 if assigned_to
797 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
797 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
798 end
798 end
799 if assigned_to_was
799 if assigned_to_was
800 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
800 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
801 end
801 end
802 notified = notified.select {|u| u.active? && u.notify_about?(self)}
802 notified = notified.select {|u| u.active? && u.notify_about?(self)}
803
803
804 notified += project.notified_users
804 notified += project.notified_users
805 notified.uniq!
805 notified.uniq!
806 # Remove users that can not view the issue
806 # Remove users that can not view the issue
807 notified.reject! {|user| !visible?(user)}
807 notified.reject! {|user| !visible?(user)}
808 notified
808 notified
809 end
809 end
810
810
811 # Returns the email addresses that should be notified
811 # Returns the email addresses that should be notified
812 def recipients
812 def recipients
813 notified_users.collect(&:mail)
813 notified_users.collect(&:mail)
814 end
814 end
815
815
816 def each_notification(users, &block)
816 def each_notification(users, &block)
817 if users.any?
817 if users.any?
818 if custom_field_values.detect {|value| !value.custom_field.visible?}
818 if custom_field_values.detect {|value| !value.custom_field.visible?}
819 users_by_custom_field_visibility = users.group_by do |user|
819 users_by_custom_field_visibility = users.group_by do |user|
820 visible_custom_field_values(user).map(&:custom_field_id).sort
820 visible_custom_field_values(user).map(&:custom_field_id).sort
821 end
821 end
822 users_by_custom_field_visibility.values.each do |users|
822 users_by_custom_field_visibility.values.each do |users|
823 yield(users)
823 yield(users)
824 end
824 end
825 else
825 else
826 yield(users)
826 yield(users)
827 end
827 end
828 end
828 end
829 end
829 end
830
830
831 # Returns the number of hours spent on this issue
831 # Returns the number of hours spent on this issue
832 def spent_hours
832 def spent_hours
833 @spent_hours ||= time_entries.sum(:hours) || 0
833 @spent_hours ||= time_entries.sum(:hours) || 0
834 end
834 end
835
835
836 # Returns the total number of hours spent on this issue and its descendants
836 # Returns the total number of hours spent on this issue and its descendants
837 #
837 #
838 # Example:
838 # Example:
839 # spent_hours => 0.0
839 # spent_hours => 0.0
840 # spent_hours => 50.2
840 # spent_hours => 50.2
841 def total_spent_hours
841 def total_spent_hours
842 @total_spent_hours ||=
842 @total_spent_hours ||=
843 self_and_descendants.
843 self_and_descendants.
844 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
844 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
845 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
845 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
846 end
846 end
847
847
848 def relations
848 def relations
849 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
849 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
850 end
850 end
851
851
852 # Preloads relations for a collection of issues
852 # Preloads relations for a collection of issues
853 def self.load_relations(issues)
853 def self.load_relations(issues)
854 if issues.any?
854 if issues.any?
855 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
855 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
856 issues.each do |issue|
856 issues.each do |issue|
857 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
857 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
858 end
858 end
859 end
859 end
860 end
860 end
861
861
862 # Preloads visible spent time for a collection of issues
862 # Preloads visible spent time for a collection of issues
863 def self.load_visible_spent_hours(issues, user=User.current)
863 def self.load_visible_spent_hours(issues, user=User.current)
864 if issues.any?
864 if issues.any?
865 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
865 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
866 issues.each do |issue|
866 issues.each do |issue|
867 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
867 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
868 end
868 end
869 end
869 end
870 end
870 end
871
871
872 # Preloads visible relations for a collection of issues
872 # Preloads visible relations for a collection of issues
873 def self.load_visible_relations(issues, user=User.current)
873 def self.load_visible_relations(issues, user=User.current)
874 if issues.any?
874 if issues.any?
875 issue_ids = issues.map(&:id)
875 issue_ids = issues.map(&:id)
876 # Relations with issue_from in given issues and visible issue_to
876 # Relations with issue_from in given issues and visible issue_to
877 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
877 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
878 # Relations with issue_to in given issues and visible issue_from
878 # Relations with issue_to in given issues and visible issue_from
879 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
879 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
880
880
881 issues.each do |issue|
881 issues.each do |issue|
882 relations =
882 relations =
883 relations_from.select {|relation| relation.issue_from_id == issue.id} +
883 relations_from.select {|relation| relation.issue_from_id == issue.id} +
884 relations_to.select {|relation| relation.issue_to_id == issue.id}
884 relations_to.select {|relation| relation.issue_to_id == issue.id}
885
885
886 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
886 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
887 end
887 end
888 end
888 end
889 end
889 end
890
890
891 # Finds an issue relation given its id.
891 # Finds an issue relation given its id.
892 def find_relation(relation_id)
892 def find_relation(relation_id)
893 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
893 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
894 end
894 end
895
895
896 # Returns all the other issues that depend on the issue
896 # Returns all the other issues that depend on the issue
897 # The algorithm is a modified breadth first search (bfs)
897 # The algorithm is a modified breadth first search (bfs)
898 def all_dependent_issues(except=[])
898 def all_dependent_issues(except=[])
899 # The found dependencies
899 # The found dependencies
900 dependencies = []
900 dependencies = []
901
901
902 # The visited flag for every node (issue) used by the breadth first search
902 # The visited flag for every node (issue) used by the breadth first search
903 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
903 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
904
904
905 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
905 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
906 # the issue when it is processed.
906 # the issue when it is processed.
907
907
908 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
908 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
909 # but its children will not be added to the queue when it is processed.
909 # but its children will not be added to the queue when it is processed.
910
910
911 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
911 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
912 # the queue, but its children have not been added.
912 # the queue, but its children have not been added.
913
913
914 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
914 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
915 # the children still need to be processed.
915 # the children still need to be processed.
916
916
917 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
917 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
918 # added as dependent issues. It needs no further processing.
918 # added as dependent issues. It needs no further processing.
919
919
920 issue_status = Hash.new(eNOT_DISCOVERED)
920 issue_status = Hash.new(eNOT_DISCOVERED)
921
921
922 # The queue
922 # The queue
923 queue = []
923 queue = []
924
924
925 # Initialize the bfs, add start node (self) to the queue
925 # Initialize the bfs, add start node (self) to the queue
926 queue << self
926 queue << self
927 issue_status[self] = ePROCESS_ALL
927 issue_status[self] = ePROCESS_ALL
928
928
929 while (!queue.empty?) do
929 while (!queue.empty?) do
930 current_issue = queue.shift
930 current_issue = queue.shift
931 current_issue_status = issue_status[current_issue]
931 current_issue_status = issue_status[current_issue]
932 dependencies << current_issue
932 dependencies << current_issue
933
933
934 # Add parent to queue, if not already in it.
934 # Add parent to queue, if not already in it.
935 parent = current_issue.parent
935 parent = current_issue.parent
936 parent_status = issue_status[parent]
936 parent_status = issue_status[parent]
937
937
938 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
938 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
939 queue << parent
939 queue << parent
940 issue_status[parent] = ePROCESS_RELATIONS_ONLY
940 issue_status[parent] = ePROCESS_RELATIONS_ONLY
941 end
941 end
942
942
943 # Add children to queue, but only if they are not already in it and
943 # Add children to queue, but only if they are not already in it and
944 # the children of the current node need to be processed.
944 # the children of the current node need to be processed.
945 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
945 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
946 current_issue.children.each do |child|
946 current_issue.children.each do |child|
947 next if except.include?(child)
947 next if except.include?(child)
948
948
949 if (issue_status[child] == eNOT_DISCOVERED)
949 if (issue_status[child] == eNOT_DISCOVERED)
950 queue << child
950 queue << child
951 issue_status[child] = ePROCESS_ALL
951 issue_status[child] = ePROCESS_ALL
952 elsif (issue_status[child] == eRELATIONS_PROCESSED)
952 elsif (issue_status[child] == eRELATIONS_PROCESSED)
953 queue << child
953 queue << child
954 issue_status[child] = ePROCESS_CHILDREN_ONLY
954 issue_status[child] = ePROCESS_CHILDREN_ONLY
955 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
955 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
956 queue << child
956 queue << child
957 issue_status[child] = ePROCESS_ALL
957 issue_status[child] = ePROCESS_ALL
958 end
958 end
959 end
959 end
960 end
960 end
961
961
962 # Add related issues to the queue, if they are not already in it.
962 # Add related issues to the queue, if they are not already in it.
963 current_issue.relations_from.map(&:issue_to).each do |related_issue|
963 current_issue.relations_from.map(&:issue_to).each do |related_issue|
964 next if except.include?(related_issue)
964 next if except.include?(related_issue)
965
965
966 if (issue_status[related_issue] == eNOT_DISCOVERED)
966 if (issue_status[related_issue] == eNOT_DISCOVERED)
967 queue << related_issue
967 queue << related_issue
968 issue_status[related_issue] = ePROCESS_ALL
968 issue_status[related_issue] = ePROCESS_ALL
969 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
969 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
970 queue << related_issue
970 queue << related_issue
971 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
971 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
972 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
972 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
973 queue << related_issue
973 queue << related_issue
974 issue_status[related_issue] = ePROCESS_ALL
974 issue_status[related_issue] = ePROCESS_ALL
975 end
975 end
976 end
976 end
977
977
978 # Set new status for current issue
978 # Set new status for current issue
979 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
979 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
980 issue_status[current_issue] = eALL_PROCESSED
980 issue_status[current_issue] = eALL_PROCESSED
981 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
981 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
982 issue_status[current_issue] = eRELATIONS_PROCESSED
982 issue_status[current_issue] = eRELATIONS_PROCESSED
983 end
983 end
984 end # while
984 end # while
985
985
986 # Remove the issues from the "except" parameter from the result array
986 # Remove the issues from the "except" parameter from the result array
987 dependencies -= except
987 dependencies -= except
988 dependencies.delete(self)
988 dependencies.delete(self)
989
989
990 dependencies
990 dependencies
991 end
991 end
992
992
993 # Returns an array of issues that duplicate this one
993 # Returns an array of issues that duplicate this one
994 def duplicates
994 def duplicates
995 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
995 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
996 end
996 end
997
997
998 # Returns the due date or the target due date if any
998 # Returns the due date or the target due date if any
999 # Used on gantt chart
999 # Used on gantt chart
1000 def due_before
1000 def due_before
1001 due_date || (fixed_version ? fixed_version.effective_date : nil)
1001 due_date || (fixed_version ? fixed_version.effective_date : nil)
1002 end
1002 end
1003
1003
1004 # Returns the time scheduled for this issue.
1004 # Returns the time scheduled for this issue.
1005 #
1005 #
1006 # Example:
1006 # Example:
1007 # Start Date: 2/26/09, End Date: 3/04/09
1007 # Start Date: 2/26/09, End Date: 3/04/09
1008 # duration => 6
1008 # duration => 6
1009 def duration
1009 def duration
1010 (start_date && due_date) ? due_date - start_date : 0
1010 (start_date && due_date) ? due_date - start_date : 0
1011 end
1011 end
1012
1012
1013 # Returns the duration in working days
1013 # Returns the duration in working days
1014 def working_duration
1014 def working_duration
1015 (start_date && due_date) ? working_days(start_date, due_date) : 0
1015 (start_date && due_date) ? working_days(start_date, due_date) : 0
1016 end
1016 end
1017
1017
1018 def soonest_start(reload=false)
1018 def soonest_start(reload=false)
1019 @soonest_start = nil if reload
1019 @soonest_start = nil if reload
1020 @soonest_start ||= (
1020 @soonest_start ||= (
1021 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1021 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1022 [(@parent_issue || parent).try(:soonest_start)]
1022 [(@parent_issue || parent).try(:soonest_start)]
1023 ).compact.max
1023 ).compact.max
1024 end
1024 end
1025
1025
1026 # Sets start_date on the given date or the next working day
1026 # Sets start_date on the given date or the next working day
1027 # and changes due_date to keep the same working duration.
1027 # and changes due_date to keep the same working duration.
1028 def reschedule_on(date)
1028 def reschedule_on(date)
1029 wd = working_duration
1029 wd = working_duration
1030 date = next_working_date(date)
1030 date = next_working_date(date)
1031 self.start_date = date
1031 self.start_date = date
1032 self.due_date = add_working_days(date, wd)
1032 self.due_date = add_working_days(date, wd)
1033 end
1033 end
1034
1034
1035 # Reschedules the issue on the given date or the next working day and saves the record.
1035 # Reschedules the issue on the given date or the next working day and saves the record.
1036 # If the issue is a parent task, this is done by rescheduling its subtasks.
1036 # If the issue is a parent task, this is done by rescheduling its subtasks.
1037 def reschedule_on!(date)
1037 def reschedule_on!(date)
1038 return if date.nil?
1038 return if date.nil?
1039 if leaf?
1039 if leaf?
1040 if start_date.nil? || start_date != date
1040 if start_date.nil? || start_date != date
1041 if start_date && start_date > date
1041 if start_date && start_date > date
1042 # Issue can not be moved earlier than its soonest start date
1042 # Issue can not be moved earlier than its soonest start date
1043 date = [soonest_start(true), date].compact.max
1043 date = [soonest_start(true), date].compact.max
1044 end
1044 end
1045 reschedule_on(date)
1045 reschedule_on(date)
1046 begin
1046 begin
1047 save
1047 save
1048 rescue ActiveRecord::StaleObjectError
1048 rescue ActiveRecord::StaleObjectError
1049 reload
1049 reload
1050 reschedule_on(date)
1050 reschedule_on(date)
1051 save
1051 save
1052 end
1052 end
1053 end
1053 end
1054 else
1054 else
1055 leaves.each do |leaf|
1055 leaves.each do |leaf|
1056 if leaf.start_date
1056 if leaf.start_date
1057 # Only move subtask if it starts at the same date as the parent
1057 # Only move subtask if it starts at the same date as the parent
1058 # or if it starts before the given date
1058 # or if it starts before the given date
1059 if start_date == leaf.start_date || date > leaf.start_date
1059 if start_date == leaf.start_date || date > leaf.start_date
1060 leaf.reschedule_on!(date)
1060 leaf.reschedule_on!(date)
1061 end
1061 end
1062 else
1062 else
1063 leaf.reschedule_on!(date)
1063 leaf.reschedule_on!(date)
1064 end
1064 end
1065 end
1065 end
1066 end
1066 end
1067 end
1067 end
1068
1068
1069 def <=>(issue)
1069 def <=>(issue)
1070 if issue.nil?
1070 if issue.nil?
1071 -1
1071 -1
1072 elsif root_id != issue.root_id
1072 elsif root_id != issue.root_id
1073 (root_id || 0) <=> (issue.root_id || 0)
1073 (root_id || 0) <=> (issue.root_id || 0)
1074 else
1074 else
1075 (lft || 0) <=> (issue.lft || 0)
1075 (lft || 0) <=> (issue.lft || 0)
1076 end
1076 end
1077 end
1077 end
1078
1078
1079 def to_s
1079 def to_s
1080 "#{tracker} ##{id}: #{subject}"
1080 "#{tracker} ##{id}: #{subject}"
1081 end
1081 end
1082
1082
1083 # Returns a string of css classes that apply to the issue
1083 # Returns a string of css classes that apply to the issue
1084 def css_classes(user=User.current)
1084 def css_classes(user=User.current)
1085 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1085 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1086 s << ' closed' if closed?
1086 s << ' closed' if closed?
1087 s << ' overdue' if overdue?
1087 s << ' overdue' if overdue?
1088 s << ' child' if child?
1088 s << ' child' if child?
1089 s << ' parent' unless leaf?
1089 s << ' parent' unless leaf?
1090 s << ' private' if is_private?
1090 s << ' private' if is_private?
1091 if user.logged?
1091 if user.logged?
1092 s << ' created-by-me' if author_id == user.id
1092 s << ' created-by-me' if author_id == user.id
1093 s << ' assigned-to-me' if assigned_to_id == user.id
1093 s << ' assigned-to-me' if assigned_to_id == user.id
1094 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1094 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1095 end
1095 end
1096 s
1096 s
1097 end
1097 end
1098
1098
1099 # Unassigns issues from +version+ if it's no longer shared with issue's project
1099 # Unassigns issues from +version+ if it's no longer shared with issue's project
1100 def self.update_versions_from_sharing_change(version)
1100 def self.update_versions_from_sharing_change(version)
1101 # Update issues assigned to the version
1101 # Update issues assigned to the version
1102 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1102 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1103 end
1103 end
1104
1104
1105 # Unassigns issues from versions that are no longer shared
1105 # Unassigns issues from versions that are no longer shared
1106 # after +project+ was moved
1106 # after +project+ was moved
1107 def self.update_versions_from_hierarchy_change(project)
1107 def self.update_versions_from_hierarchy_change(project)
1108 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1108 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1109 # Update issues of the moved projects and issues assigned to a version of a moved project
1109 # Update issues of the moved projects and issues assigned to a version of a moved project
1110 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
1110 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
1111 end
1111 end
1112
1112
1113 def parent_issue_id=(arg)
1113 def parent_issue_id=(arg)
1114 s = arg.to_s.strip.presence
1114 s = arg.to_s.strip.presence
1115 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1115 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1116 @parent_issue.id
1116 @parent_issue.id
1117 @invalid_parent_issue_id = nil
1117 @invalid_parent_issue_id = nil
1118 elsif s.blank?
1118 elsif s.blank?
1119 @parent_issue = nil
1119 @parent_issue = nil
1120 @invalid_parent_issue_id = nil
1120 @invalid_parent_issue_id = nil
1121 else
1121 else
1122 @parent_issue = nil
1122 @parent_issue = nil
1123 @invalid_parent_issue_id = arg
1123 @invalid_parent_issue_id = arg
1124 end
1124 end
1125 end
1125 end
1126
1126
1127 def parent_issue_id
1127 def parent_issue_id
1128 if @invalid_parent_issue_id
1128 if @invalid_parent_issue_id
1129 @invalid_parent_issue_id
1129 @invalid_parent_issue_id
1130 elsif instance_variable_defined? :@parent_issue
1130 elsif instance_variable_defined? :@parent_issue
1131 @parent_issue.nil? ? nil : @parent_issue.id
1131 @parent_issue.nil? ? nil : @parent_issue.id
1132 else
1132 else
1133 parent_id
1133 parent_id
1134 end
1134 end
1135 end
1135 end
1136
1136
1137 # Returns true if issue's project is a valid
1137 # Returns true if issue's project is a valid
1138 # parent issue project
1138 # parent issue project
1139 def valid_parent_project?(issue=parent)
1139 def valid_parent_project?(issue=parent)
1140 return true if issue.nil? || issue.project_id == project_id
1140 return true if issue.nil? || issue.project_id == project_id
1141
1141
1142 case Setting.cross_project_subtasks
1142 case Setting.cross_project_subtasks
1143 when 'system'
1143 when 'system'
1144 true
1144 true
1145 when 'tree'
1145 when 'tree'
1146 issue.project.root == project.root
1146 issue.project.root == project.root
1147 when 'hierarchy'
1147 when 'hierarchy'
1148 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1148 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1149 when 'descendants'
1149 when 'descendants'
1150 issue.project.is_or_is_ancestor_of?(project)
1150 issue.project.is_or_is_ancestor_of?(project)
1151 else
1151 else
1152 false
1152 false
1153 end
1153 end
1154 end
1154 end
1155
1155
1156 # Extracted from the ReportsController.
1156 # Extracted from the ReportsController.
1157 def self.by_tracker(project)
1157 def self.by_tracker(project)
1158 count_and_group_by(:project => project,
1158 count_and_group_by(:project => project,
1159 :field => 'tracker_id',
1159 :field => 'tracker_id',
1160 :joins => Tracker.table_name)
1160 :joins => Tracker.table_name)
1161 end
1161 end
1162
1162
1163 def self.by_version(project)
1163 def self.by_version(project)
1164 count_and_group_by(:project => project,
1164 count_and_group_by(:project => project,
1165 :field => 'fixed_version_id',
1165 :field => 'fixed_version_id',
1166 :joins => Version.table_name)
1166 :joins => Version.table_name)
1167 end
1167 end
1168
1168
1169 def self.by_priority(project)
1169 def self.by_priority(project)
1170 count_and_group_by(:project => project,
1170 count_and_group_by(:project => project,
1171 :field => 'priority_id',
1171 :field => 'priority_id',
1172 :joins => IssuePriority.table_name)
1172 :joins => IssuePriority.table_name)
1173 end
1173 end
1174
1174
1175 def self.by_category(project)
1175 def self.by_category(project)
1176 count_and_group_by(:project => project,
1176 count_and_group_by(:project => project,
1177 :field => 'category_id',
1177 :field => 'category_id',
1178 :joins => IssueCategory.table_name)
1178 :joins => IssueCategory.table_name)
1179 end
1179 end
1180
1180
1181 def self.by_assigned_to(project)
1181 def self.by_assigned_to(project)
1182 count_and_group_by(:project => project,
1182 count_and_group_by(:project => project,
1183 :field => 'assigned_to_id',
1183 :field => 'assigned_to_id',
1184 :joins => User.table_name)
1184 :joins => User.table_name)
1185 end
1185 end
1186
1186
1187 def self.by_author(project)
1187 def self.by_author(project)
1188 count_and_group_by(:project => project,
1188 count_and_group_by(:project => project,
1189 :field => 'author_id',
1189 :field => 'author_id',
1190 :joins => User.table_name)
1190 :joins => User.table_name)
1191 end
1191 end
1192
1192
1193 def self.by_subproject(project)
1193 def self.by_subproject(project)
1194 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1194 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1195 s.is_closed as closed,
1195 s.is_closed as closed,
1196 #{Issue.table_name}.project_id as project_id,
1196 #{Issue.table_name}.project_id as project_id,
1197 count(#{Issue.table_name}.id) as total
1197 count(#{Issue.table_name}.id) as total
1198 from
1198 from
1199 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1199 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1200 where
1200 where
1201 #{Issue.table_name}.status_id=s.id
1201 #{Issue.table_name}.status_id=s.id
1202 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1202 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1203 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1203 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1204 and #{Issue.table_name}.project_id <> #{project.id}
1204 and #{Issue.table_name}.project_id <> #{project.id}
1205 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1205 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1206 end
1206 end
1207 # End ReportsController extraction
1207 # End ReportsController extraction
1208
1208
1209 # Returns a scope of projects that user can assign the issue to
1209 # Returns a scope of projects that user can assign the issue to
1210 def allowed_target_projects(user=User.current)
1210 def allowed_target_projects(user=User.current)
1211 if new_record?
1211 if new_record?
1212 Project.where(Project.allowed_to_condition(user, :add_issues))
1212 Project.where(Project.allowed_to_condition(user, :add_issues))
1213 else
1213 else
1214 self.class.allowed_target_projects_on_move(user)
1214 self.class.allowed_target_projects_on_move(user)
1215 end
1215 end
1216 end
1216 end
1217
1217
1218 # Returns a scope of projects that user can move issues to
1218 # Returns a scope of projects that user can move issues to
1219 def self.allowed_target_projects_on_move(user=User.current)
1219 def self.allowed_target_projects_on_move(user=User.current)
1220 Project.where(Project.allowed_to_condition(user, :move_issues))
1220 Project.where(Project.allowed_to_condition(user, :move_issues))
1221 end
1221 end
1222
1222
1223 private
1223 private
1224
1224
1225 def after_project_change
1225 def after_project_change
1226 # Update project_id on related time entries
1226 # Update project_id on related time entries
1227 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1227 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1228
1228
1229 # Delete issue relations
1229 # Delete issue relations
1230 unless Setting.cross_project_issue_relations?
1230 unless Setting.cross_project_issue_relations?
1231 relations_from.clear
1231 relations_from.clear
1232 relations_to.clear
1232 relations_to.clear
1233 end
1233 end
1234
1234
1235 # Move subtasks that were in the same project
1235 # Move subtasks that were in the same project
1236 children.each do |child|
1236 children.each do |child|
1237 next unless child.project_id == project_id_was
1237 next unless child.project_id == project_id_was
1238 # Change project and keep project
1238 # Change project and keep project
1239 child.send :project=, project, true
1239 child.send :project=, project, true
1240 unless child.save
1240 unless child.save
1241 raise ActiveRecord::Rollback
1241 raise ActiveRecord::Rollback
1242 end
1242 end
1243 end
1243 end
1244 end
1244 end
1245
1245
1246 # Callback for after the creation of an issue by copy
1246 # Callback for after the creation of an issue by copy
1247 # * adds a "copied to" relation with the copied issue
1247 # * adds a "copied to" relation with the copied issue
1248 # * copies subtasks from the copied issue
1248 # * copies subtasks from the copied issue
1249 def after_create_from_copy
1249 def after_create_from_copy
1250 return unless copy? && !@after_create_from_copy_handled
1250 return unless copy? && !@after_create_from_copy_handled
1251
1251
1252 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1252 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1253 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1253 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1254 unless relation.save
1254 unless relation.save
1255 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1255 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1256 end
1256 end
1257 end
1257 end
1258
1258
1259 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1259 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1260 copy_options = (@copy_options || {}).merge(:subtasks => false)
1260 copy_options = (@copy_options || {}).merge(:subtasks => false)
1261 copied_issue_ids = {@copied_from.id => self.id}
1261 copied_issue_ids = {@copied_from.id => self.id}
1262 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1262 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1263 # Do not copy self when copying an issue as a descendant of the copied issue
1263 # Do not copy self when copying an issue as a descendant of the copied issue
1264 next if child == self
1264 next if child == self
1265 # Do not copy subtasks of issues that were not copied
1265 # Do not copy subtasks of issues that were not copied
1266 next unless copied_issue_ids[child.parent_id]
1266 next unless copied_issue_ids[child.parent_id]
1267 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1267 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1268 unless child.visible?
1268 unless child.visible?
1269 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1269 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1270 next
1270 next
1271 end
1271 end
1272 copy = Issue.new.copy_from(child, copy_options)
1272 copy = Issue.new.copy_from(child, copy_options)
1273 copy.author = author
1273 copy.author = author
1274 copy.project = project
1274 copy.project = project
1275 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1275 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1276 unless copy.save
1276 unless copy.save
1277 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1277 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1278 next
1278 next
1279 end
1279 end
1280 copied_issue_ids[child.id] = copy.id
1280 copied_issue_ids[child.id] = copy.id
1281 end
1281 end
1282 end
1282 end
1283 @after_create_from_copy_handled = true
1283 @after_create_from_copy_handled = true
1284 end
1284 end
1285
1285
1286 def update_nested_set_attributes
1286 def update_nested_set_attributes
1287 if root_id.nil?
1287 if root_id.nil?
1288 # issue was just created
1288 # issue was just created
1289 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1289 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1290 set_default_left_and_right
1290 Issue.where(["id = ?", id]).update_all(["root_id = ?", root_id])
1291 Issue.where(["id = ?", id]).
1292 update_all(["root_id = ?, lft = ?, rgt = ?", root_id, lft, rgt])
1293 if @parent_issue
1291 if @parent_issue
1294 move_to_child_of(@parent_issue)
1292 move_to_child_of(@parent_issue)
1295 end
1293 end
1296 elsif parent_issue_id != parent_id
1294 elsif parent_issue_id != parent_id
1297 update_nested_set_attributes_on_parent_change
1295 update_nested_set_attributes_on_parent_change
1298 end
1296 end
1299 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1297 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1300 end
1298 end
1301
1299
1302 # Updates the nested set for when an existing issue is moved
1300 # Updates the nested set for when an existing issue is moved
1303 def update_nested_set_attributes_on_parent_change
1301 def update_nested_set_attributes_on_parent_change
1304 former_parent_id = parent_id
1302 former_parent_id = parent_id
1305 # moving an existing issue
1303 # moving an existing issue
1306 if @parent_issue && @parent_issue.root_id == root_id
1304 if @parent_issue && @parent_issue.root_id == root_id
1307 # inside the same tree
1305 # inside the same tree
1308 move_to_child_of(@parent_issue)
1306 move_to_child_of(@parent_issue)
1309 else
1307 else
1310 # to another tree
1308 # to another tree
1311 unless root?
1309 unless root?
1312 move_to_right_of(root)
1310 move_to_right_of(root)
1313 end
1311 end
1314 old_root_id = root_id
1312 old_root_id = root_id
1315 in_tenacious_transaction do
1313 in_tenacious_transaction do
1316 @parent_issue.reload_nested_set if @parent_issue
1314 @parent_issue.reload_nested_set if @parent_issue
1317 self.reload_nested_set
1315 self.reload_nested_set
1318 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1316 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1319 cond = ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]
1317 cond = ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]
1320 self.class.base_class.select('id').lock(true).where(cond)
1318 self.class.base_class.select('id').lock(true).where(cond)
1321 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1319 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1322 offset = target_maxright + 1 - lft
1320 offset = target_maxright + 1 - lft
1323 Issue.where(cond).
1321 Issue.where(cond).
1324 update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
1322 update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
1325 self[left_column_name] = lft + offset
1323 self[left_column_name] = lft + offset
1326 self[right_column_name] = rgt + offset
1324 self[right_column_name] = rgt + offset
1327 end
1325 end
1328 if @parent_issue
1326 if @parent_issue
1329 move_to_child_of(@parent_issue)
1327 move_to_child_of(@parent_issue)
1330 end
1328 end
1331 end
1329 end
1332 # delete invalid relations of all descendants
1330 # delete invalid relations of all descendants
1333 self_and_descendants.each do |issue|
1331 self_and_descendants.each do |issue|
1334 issue.relations.each do |relation|
1332 issue.relations.each do |relation|
1335 relation.destroy unless relation.valid?
1333 relation.destroy unless relation.valid?
1336 end
1334 end
1337 end
1335 end
1338 # update former parent
1336 # update former parent
1339 recalculate_attributes_for(former_parent_id) if former_parent_id
1337 recalculate_attributes_for(former_parent_id) if former_parent_id
1340 end
1338 end
1341
1339
1342 def update_parent_attributes
1340 def update_parent_attributes
1343 recalculate_attributes_for(parent_id) if parent_id
1341 recalculate_attributes_for(parent_id) if parent_id
1344 end
1342 end
1345
1343
1346 def recalculate_attributes_for(issue_id)
1344 def recalculate_attributes_for(issue_id)
1347 if issue_id && p = Issue.find_by_id(issue_id)
1345 if issue_id && p = Issue.find_by_id(issue_id)
1348 # priority = highest priority of children
1346 # priority = highest priority of children
1349 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1347 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1350 p.priority = IssuePriority.find_by_position(priority_position)
1348 p.priority = IssuePriority.find_by_position(priority_position)
1351 end
1349 end
1352
1350
1353 # start/due dates = lowest/highest dates of children
1351 # start/due dates = lowest/highest dates of children
1354 p.start_date = p.children.minimum(:start_date)
1352 p.start_date = p.children.minimum(:start_date)
1355 p.due_date = p.children.maximum(:due_date)
1353 p.due_date = p.children.maximum(:due_date)
1356 if p.start_date && p.due_date && p.due_date < p.start_date
1354 if p.start_date && p.due_date && p.due_date < p.start_date
1357 p.start_date, p.due_date = p.due_date, p.start_date
1355 p.start_date, p.due_date = p.due_date, p.start_date
1358 end
1356 end
1359
1357
1360 # done ratio = weighted average ratio of leaves
1358 # done ratio = weighted average ratio of leaves
1361 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1359 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1362 leaves_count = p.leaves.count
1360 leaves_count = p.leaves.count
1363 if leaves_count > 0
1361 if leaves_count > 0
1364 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1362 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1365 if average == 0
1363 if average == 0
1366 average = 1
1364 average = 1
1367 end
1365 end
1368 done = p.leaves.joins(:status).
1366 done = p.leaves.joins(:status).
1369 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1367 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1370 "* (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1368 "* (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1371 progress = done / (average * leaves_count)
1369 progress = done / (average * leaves_count)
1372 p.done_ratio = progress.round
1370 p.done_ratio = progress.round
1373 end
1371 end
1374 end
1372 end
1375
1373
1376 # estimate = sum of leaves estimates
1374 # estimate = sum of leaves estimates
1377 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1375 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1378 p.estimated_hours = nil if p.estimated_hours == 0.0
1376 p.estimated_hours = nil if p.estimated_hours == 0.0
1379
1377
1380 # ancestors will be recursively updated
1378 # ancestors will be recursively updated
1381 p.save(:validate => false)
1379 p.save(:validate => false)
1382 end
1380 end
1383 end
1381 end
1384
1382
1385 # Update issues so their versions are not pointing to a
1383 # Update issues so their versions are not pointing to a
1386 # fixed_version that is not shared with the issue's project
1384 # fixed_version that is not shared with the issue's project
1387 def self.update_versions(conditions=nil)
1385 def self.update_versions(conditions=nil)
1388 # Only need to update issues with a fixed_version from
1386 # Only need to update issues with a fixed_version from
1389 # a different project and that is not systemwide shared
1387 # a different project and that is not systemwide shared
1390 Issue.includes(:project, :fixed_version).
1388 Issue.includes(:project, :fixed_version).
1391 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1389 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1392 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1390 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1393 " AND #{Version.table_name}.sharing <> 'system'").
1391 " AND #{Version.table_name}.sharing <> 'system'").
1394 where(conditions).each do |issue|
1392 where(conditions).each do |issue|
1395 next if issue.project.nil? || issue.fixed_version.nil?
1393 next if issue.project.nil? || issue.fixed_version.nil?
1396 unless issue.project.shared_versions.include?(issue.fixed_version)
1394 unless issue.project.shared_versions.include?(issue.fixed_version)
1397 issue.init_journal(User.current)
1395 issue.init_journal(User.current)
1398 issue.fixed_version = nil
1396 issue.fixed_version = nil
1399 issue.save
1397 issue.save
1400 end
1398 end
1401 end
1399 end
1402 end
1400 end
1403
1401
1404 # Callback on file attachment
1402 # Callback on file attachment
1405 def attachment_added(obj)
1403 def attachment_added(obj)
1406 if @current_journal && !obj.new_record?
1404 if @current_journal && !obj.new_record?
1407 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1405 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1408 end
1406 end
1409 end
1407 end
1410
1408
1411 # Callback on attachment deletion
1409 # Callback on attachment deletion
1412 def attachment_removed(obj)
1410 def attachment_removed(obj)
1413 if @current_journal && !obj.new_record?
1411 if @current_journal && !obj.new_record?
1414 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1412 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1415 @current_journal.save
1413 @current_journal.save
1416 end
1414 end
1417 end
1415 end
1418
1416
1419 # Default assignment based on category
1417 # Default assignment based on category
1420 def default_assign
1418 def default_assign
1421 if assigned_to.nil? && category && category.assigned_to
1419 if assigned_to.nil? && category && category.assigned_to
1422 self.assigned_to = category.assigned_to
1420 self.assigned_to = category.assigned_to
1423 end
1421 end
1424 end
1422 end
1425
1423
1426 # Updates start/due dates of following issues
1424 # Updates start/due dates of following issues
1427 def reschedule_following_issues
1425 def reschedule_following_issues
1428 if start_date_changed? || due_date_changed?
1426 if start_date_changed? || due_date_changed?
1429 relations_from.each do |relation|
1427 relations_from.each do |relation|
1430 relation.set_issue_to_dates
1428 relation.set_issue_to_dates
1431 end
1429 end
1432 end
1430 end
1433 end
1431 end
1434
1432
1435 # Closes duplicates if the issue is being closed
1433 # Closes duplicates if the issue is being closed
1436 def close_duplicates
1434 def close_duplicates
1437 if closing?
1435 if closing?
1438 duplicates.each do |duplicate|
1436 duplicates.each do |duplicate|
1439 # Reload is need in case the duplicate was updated by a previous duplicate
1437 # Reload is need in case the duplicate was updated by a previous duplicate
1440 duplicate.reload
1438 duplicate.reload
1441 # Don't re-close it if it's already closed
1439 # Don't re-close it if it's already closed
1442 next if duplicate.closed?
1440 next if duplicate.closed?
1443 # Same user and notes
1441 # Same user and notes
1444 if @current_journal
1442 if @current_journal
1445 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1443 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1446 end
1444 end
1447 duplicate.update_attribute :status, self.status
1445 duplicate.update_attribute :status, self.status
1448 end
1446 end
1449 end
1447 end
1450 end
1448 end
1451
1449
1452 # Make sure updated_on is updated when adding a note and set updated_on now
1450 # Make sure updated_on is updated when adding a note and set updated_on now
1453 # so we can set closed_on with the same value on closing
1451 # so we can set closed_on with the same value on closing
1454 def force_updated_on_change
1452 def force_updated_on_change
1455 if @current_journal || changed?
1453 if @current_journal || changed?
1456 self.updated_on = current_time_from_proper_timezone
1454 self.updated_on = current_time_from_proper_timezone
1457 if new_record?
1455 if new_record?
1458 self.created_on = updated_on
1456 self.created_on = updated_on
1459 end
1457 end
1460 end
1458 end
1461 end
1459 end
1462
1460
1463 # Callback for setting closed_on when the issue is closed.
1461 # Callback for setting closed_on when the issue is closed.
1464 # The closed_on attribute stores the time of the last closing
1462 # The closed_on attribute stores the time of the last closing
1465 # and is preserved when the issue is reopened.
1463 # and is preserved when the issue is reopened.
1466 def update_closed_on
1464 def update_closed_on
1467 if closing? || (new_record? && closed?)
1465 if closing? || (new_record? && closed?)
1468 self.closed_on = updated_on
1466 self.closed_on = updated_on
1469 end
1467 end
1470 end
1468 end
1471
1469
1472 # Saves the changes in a Journal
1470 # Saves the changes in a Journal
1473 # Called after_save
1471 # Called after_save
1474 def create_journal
1472 def create_journal
1475 if @current_journal
1473 if @current_journal
1476 # attributes changes
1474 # attributes changes
1477 if @attributes_before_change
1475 if @attributes_before_change
1478 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1476 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1479 before = @attributes_before_change[c]
1477 before = @attributes_before_change[c]
1480 after = send(c)
1478 after = send(c)
1481 next if before == after || (before.blank? && after.blank?)
1479 next if before == after || (before.blank? && after.blank?)
1482 @current_journal.details << JournalDetail.new(:property => 'attr',
1480 @current_journal.details << JournalDetail.new(:property => 'attr',
1483 :prop_key => c,
1481 :prop_key => c,
1484 :old_value => before,
1482 :old_value => before,
1485 :value => after)
1483 :value => after)
1486 }
1484 }
1487 end
1485 end
1488 if @custom_values_before_change
1486 if @custom_values_before_change
1489 # custom fields changes
1487 # custom fields changes
1490 custom_field_values.each {|c|
1488 custom_field_values.each {|c|
1491 before = @custom_values_before_change[c.custom_field_id]
1489 before = @custom_values_before_change[c.custom_field_id]
1492 after = c.value
1490 after = c.value
1493 next if before == after || (before.blank? && after.blank?)
1491 next if before == after || (before.blank? && after.blank?)
1494
1492
1495 if before.is_a?(Array) || after.is_a?(Array)
1493 if before.is_a?(Array) || after.is_a?(Array)
1496 before = [before] unless before.is_a?(Array)
1494 before = [before] unless before.is_a?(Array)
1497 after = [after] unless after.is_a?(Array)
1495 after = [after] unless after.is_a?(Array)
1498
1496
1499 # values removed
1497 # values removed
1500 (before - after).reject(&:blank?).each do |value|
1498 (before - after).reject(&:blank?).each do |value|
1501 @current_journal.details << JournalDetail.new(:property => 'cf',
1499 @current_journal.details << JournalDetail.new(:property => 'cf',
1502 :prop_key => c.custom_field_id,
1500 :prop_key => c.custom_field_id,
1503 :old_value => value,
1501 :old_value => value,
1504 :value => nil)
1502 :value => nil)
1505 end
1503 end
1506 # values added
1504 # values added
1507 (after - before).reject(&:blank?).each do |value|
1505 (after - before).reject(&:blank?).each do |value|
1508 @current_journal.details << JournalDetail.new(:property => 'cf',
1506 @current_journal.details << JournalDetail.new(:property => 'cf',
1509 :prop_key => c.custom_field_id,
1507 :prop_key => c.custom_field_id,
1510 :old_value => nil,
1508 :old_value => nil,
1511 :value => value)
1509 :value => value)
1512 end
1510 end
1513 else
1511 else
1514 @current_journal.details << JournalDetail.new(:property => 'cf',
1512 @current_journal.details << JournalDetail.new(:property => 'cf',
1515 :prop_key => c.custom_field_id,
1513 :prop_key => c.custom_field_id,
1516 :old_value => before,
1514 :old_value => before,
1517 :value => after)
1515 :value => after)
1518 end
1516 end
1519 }
1517 }
1520 end
1518 end
1521 @current_journal.save
1519 @current_journal.save
1522 # reset current journal
1520 # reset current journal
1523 init_journal @current_journal.user, @current_journal.notes
1521 init_journal @current_journal.user, @current_journal.notes
1524 end
1522 end
1525 end
1523 end
1526
1524
1527 def send_notification
1525 def send_notification
1528 if Setting.notified_events.include?('issue_added')
1526 if Setting.notified_events.include?('issue_added')
1529 Mailer.deliver_issue_add(self)
1527 Mailer.deliver_issue_add(self)
1530 end
1528 end
1531 end
1529 end
1532
1530
1533 # Stores the previous assignee so we can still have access
1531 # Stores the previous assignee so we can still have access
1534 # to it during after_save callbacks (assigned_to_id_was is reset)
1532 # to it during after_save callbacks (assigned_to_id_was is reset)
1535 def set_assigned_to_was
1533 def set_assigned_to_was
1536 @previous_assigned_to_id = assigned_to_id_was
1534 @previous_assigned_to_id = assigned_to_id_was
1537 end
1535 end
1538
1536
1539 # Clears the previous assignee at the end of after_save callbacks
1537 # Clears the previous assignee at the end of after_save callbacks
1540 def clear_assigned_to_was
1538 def clear_assigned_to_was
1541 @assigned_to_was = nil
1539 @assigned_to_was = nil
1542 @previous_assigned_to_id = nil
1540 @previous_assigned_to_id = nil
1543 end
1541 end
1544
1542
1545 # Query generator for selecting groups of issue counts for a project
1543 # Query generator for selecting groups of issue counts for a project
1546 # based on specific criteria
1544 # based on specific criteria
1547 #
1545 #
1548 # Options
1546 # Options
1549 # * project - Project to search in.
1547 # * project - Project to search in.
1550 # * field - String. Issue field to key off of in the grouping.
1548 # * field - String. Issue field to key off of in the grouping.
1551 # * joins - String. The table name to join against.
1549 # * joins - String. The table name to join against.
1552 def self.count_and_group_by(options)
1550 def self.count_and_group_by(options)
1553 project = options.delete(:project)
1551 project = options.delete(:project)
1554 select_field = options.delete(:field)
1552 select_field = options.delete(:field)
1555 joins = options.delete(:joins)
1553 joins = options.delete(:joins)
1556
1554
1557 where = "#{Issue.table_name}.#{select_field}=j.id"
1555 where = "#{Issue.table_name}.#{select_field}=j.id"
1558
1556
1559 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1557 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1560 s.is_closed as closed,
1558 s.is_closed as closed,
1561 j.id as #{select_field},
1559 j.id as #{select_field},
1562 count(#{Issue.table_name}.id) as total
1560 count(#{Issue.table_name}.id) as total
1563 from
1561 from
1564 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1562 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1565 where
1563 where
1566 #{Issue.table_name}.status_id=s.id
1564 #{Issue.table_name}.status_id=s.id
1567 and #{where}
1565 and #{where}
1568 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1566 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1569 and #{visible_condition(User.current, :project => project)}
1567 and #{visible_condition(User.current, :project => project)}
1570 group by s.id, s.is_closed, j.id")
1568 group by s.id, s.is_closed, j.id")
1571 end
1569 end
1572 end
1570 end
@@ -1,767 +1,769
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
4
5 # This acts provides Nested Set functionality. Nested Set is a smart way to implement
5 # This acts provides Nested Set functionality. Nested Set is a smart way to implement
6 # an _ordered_ tree, with the added feature that you can select the children and all of their
6 # an _ordered_ tree, with the added feature that you can select the children and all of their
7 # descendants with a single query. The drawback is that insertion or move need some complex
7 # descendants with a single query. The drawback is that insertion or move need some complex
8 # sql queries. But everything is done here by this module!
8 # sql queries. But everything is done here by this module!
9 #
9 #
10 # Nested sets are appropriate each time you want either an orderd tree (menus,
10 # Nested sets are appropriate each time you want either an orderd tree (menus,
11 # commercial categories) or an efficient way of querying big trees (threaded posts).
11 # commercial categories) or an efficient way of querying big trees (threaded posts).
12 #
12 #
13 # == API
13 # == API
14 #
14 #
15 # Methods names are aligned with acts_as_tree as much as possible to make replacment from one
15 # Methods names are aligned with acts_as_tree as much as possible to make replacment from one
16 # by another easier.
16 # by another easier.
17 #
17 #
18 # item.children.create(:name => "child1")
18 # item.children.create(:name => "child1")
19 #
19 #
20
20
21 # Configuration options are:
21 # Configuration options are:
22 #
22 #
23 # * +:parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id)
23 # * +:parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id)
24 # * +:left_column+ - column name for left boundry data, default "lft"
24 # * +:left_column+ - column name for left boundry data, default "lft"
25 # * +:right_column+ - column name for right boundry data, default "rgt"
25 # * +:right_column+ - column name for right boundry data, default "rgt"
26 # * +:depth_column+ - column name for the depth data, default "depth"
26 # * +:depth_column+ - column name for the depth data, default "depth"
27 # * +:scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id"
27 # * +:scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id"
28 # (if it hasn't been already) and use that as the foreign key restriction. You
28 # (if it hasn't been already) and use that as the foreign key restriction. You
29 # can also pass an array to scope by multiple attributes.
29 # can also pass an array to scope by multiple attributes.
30 # Example: <tt>acts_as_nested_set :scope => [:notable_id, :notable_type]</tt>
30 # Example: <tt>acts_as_nested_set :scope => [:notable_id, :notable_type]</tt>
31 # * +:dependent+ - behavior for cascading destroy. If set to :destroy, all the
31 # * +:dependent+ - behavior for cascading destroy. If set to :destroy, all the
32 # child objects are destroyed alongside this object by calling their destroy
32 # child objects are destroyed alongside this object by calling their destroy
33 # method. If set to :delete_all (default), all the child objects are deleted
33 # method. If set to :delete_all (default), all the child objects are deleted
34 # without calling their destroy method.
34 # without calling their destroy method.
35 # * +:counter_cache+ adds a counter cache for the number of children.
35 # * +:counter_cache+ adds a counter cache for the number of children.
36 # defaults to false.
36 # defaults to false.
37 # Example: <tt>acts_as_nested_set :counter_cache => :children_count</tt>
37 # Example: <tt>acts_as_nested_set :counter_cache => :children_count</tt>
38 # * +:order_column+ on which column to do sorting, by default it is the left_column_name
38 # * +:order_column+ on which column to do sorting, by default it is the left_column_name
39 # Example: <tt>acts_as_nested_set :order_column => :position</tt>
39 # Example: <tt>acts_as_nested_set :order_column => :position</tt>
40 #
40 #
41 # See CollectiveIdea::Acts::NestedSet::Model::ClassMethods for a list of class methods and
41 # See CollectiveIdea::Acts::NestedSet::Model::ClassMethods for a list of class methods and
42 # CollectiveIdea::Acts::NestedSet::Model for a list of instance methods added
42 # CollectiveIdea::Acts::NestedSet::Model for a list of instance methods added
43 # to acts_as_nested_set models
43 # to acts_as_nested_set models
44 def acts_as_nested_set(options = {})
44 def acts_as_nested_set(options = {})
45 options = {
45 options = {
46 :parent_column => 'parent_id',
46 :parent_column => 'parent_id',
47 :left_column => 'lft',
47 :left_column => 'lft',
48 :right_column => 'rgt',
48 :right_column => 'rgt',
49 :depth_column => 'depth',
49 :depth_column => 'depth',
50 :dependent => :delete_all, # or :destroy
50 :dependent => :delete_all, # or :destroy
51 :polymorphic => false,
51 :polymorphic => false,
52 :counter_cache => false
52 :counter_cache => false
53 }.merge(options)
53 }.merge(options)
54
54
55 if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/
55 if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/
56 options[:scope] = "#{options[:scope]}_id".intern
56 options[:scope] = "#{options[:scope]}_id".intern
57 end
57 end
58
58
59 class_attribute :acts_as_nested_set_options
59 class_attribute :acts_as_nested_set_options
60 self.acts_as_nested_set_options = options
60 self.acts_as_nested_set_options = options
61
61
62 include CollectiveIdea::Acts::NestedSet::Model
62 include CollectiveIdea::Acts::NestedSet::Model
63 include Columns
63 include Columns
64 extend Columns
64 extend Columns
65
65
66 belongs_to :parent, :class_name => self.base_class.to_s,
66 belongs_to :parent, :class_name => self.base_class.to_s,
67 :foreign_key => parent_column_name,
67 :foreign_key => parent_column_name,
68 :counter_cache => options[:counter_cache],
68 :counter_cache => options[:counter_cache],
69 :inverse_of => (:children unless options[:polymorphic]),
69 :inverse_of => (:children unless options[:polymorphic]),
70 :polymorphic => options[:polymorphic]
70 :polymorphic => options[:polymorphic]
71
71
72 has_many_children_options = {
72 has_many_children_options = {
73 :class_name => self.base_class.to_s,
73 :class_name => self.base_class.to_s,
74 :foreign_key => parent_column_name,
74 :foreign_key => parent_column_name,
75 :order => order_column,
75 :order => order_column,
76 :inverse_of => (:parent unless options[:polymorphic]),
76 :inverse_of => (:parent unless options[:polymorphic]),
77 }
77 }
78
78
79 # Add callbacks, if they were supplied.. otherwise, we don't want them.
79 # Add callbacks, if they were supplied.. otherwise, we don't want them.
80 [:before_add, :after_add, :before_remove, :after_remove].each do |ar_callback|
80 [:before_add, :after_add, :before_remove, :after_remove].each do |ar_callback|
81 has_many_children_options.update(ar_callback => options[ar_callback]) if options[ar_callback]
81 has_many_children_options.update(ar_callback => options[ar_callback]) if options[ar_callback]
82 end
82 end
83
83
84 has_many :children, has_many_children_options
84 has_many :children, has_many_children_options
85
85
86 attr_accessor :skip_before_destroy
86 attr_accessor :skip_before_destroy
87
87
88 before_create :set_default_left_and_right
88 before_create :set_default_left_and_right
89 before_save :store_new_parent
89 before_save :store_new_parent
90 after_save :move_to_new_parent, :set_depth!
90 after_save :move_to_new_parent, :set_depth!
91 before_destroy :destroy_descendants
91 before_destroy :destroy_descendants
92
92
93 # no assignment to structure fields
93 # no assignment to structure fields
94 [left_column_name, right_column_name, depth_column_name].each do |column|
94 [left_column_name, right_column_name, depth_column_name].each do |column|
95 module_eval <<-"end_eval", __FILE__, __LINE__
95 module_eval <<-"end_eval", __FILE__, __LINE__
96 def #{column}=(x)
96 def #{column}=(x)
97 raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead."
97 raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead."
98 end
98 end
99 end_eval
99 end_eval
100 end
100 end
101
101
102 define_model_callbacks :move
102 define_model_callbacks :move
103 end
103 end
104
104
105 module Model
105 module Model
106 extend ActiveSupport::Concern
106 extend ActiveSupport::Concern
107
107
108 included do
108 included do
109 delegate :quoted_table_name, :to => self
109 delegate :quoted_table_name, :to => self
110 end
110 end
111
111
112 module ClassMethods
112 module ClassMethods
113 # Returns the first root
113 # Returns the first root
114 def root
114 def root
115 roots.first
115 roots.first
116 end
116 end
117
117
118 def roots
118 def roots
119 where(parent_column_name => nil).order(quoted_left_column_full_name)
119 where(parent_column_name => nil).order(quoted_left_column_full_name)
120 end
120 end
121
121
122 def leaves
122 def leaves
123 where("#{quoted_right_column_full_name} - #{quoted_left_column_full_name} = 1").order(quoted_left_column_full_name)
123 where("#{quoted_right_column_full_name} - #{quoted_left_column_full_name} = 1").order(quoted_left_column_full_name)
124 end
124 end
125
125
126 def valid?
126 def valid?
127 left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid?
127 left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid?
128 end
128 end
129
129
130 def left_and_rights_valid?
130 def left_and_rights_valid?
131 ## AS clause not supported in Oracle in FROM clause for aliasing table name
131 ## AS clause not supported in Oracle in FROM clause for aliasing table name
132 joins("LEFT OUTER JOIN #{quoted_table_name}" +
132 joins("LEFT OUTER JOIN #{quoted_table_name}" +
133 (connection.adapter_name.match(/Oracle/).nil? ? " AS " : " ") +
133 (connection.adapter_name.match(/Oracle/).nil? ? " AS " : " ") +
134 "parent ON " +
134 "parent ON " +
135 "#{quoted_parent_column_full_name} = parent.#{primary_key}").
135 "#{quoted_parent_column_full_name} = parent.#{primary_key}").
136 where(
136 where(
137 "#{quoted_left_column_full_name} IS NULL OR " +
137 "#{quoted_left_column_full_name} IS NULL OR " +
138 "#{quoted_right_column_full_name} IS NULL OR " +
138 "#{quoted_right_column_full_name} IS NULL OR " +
139 "#{quoted_left_column_full_name} >= " +
139 "#{quoted_left_column_full_name} >= " +
140 "#{quoted_right_column_full_name} OR " +
140 "#{quoted_right_column_full_name} OR " +
141 "(#{quoted_parent_column_full_name} IS NOT NULL AND " +
141 "(#{quoted_parent_column_full_name} IS NOT NULL AND " +
142 "(#{quoted_left_column_full_name} <= parent.#{quoted_left_column_name} OR " +
142 "(#{quoted_left_column_full_name} <= parent.#{quoted_left_column_name} OR " +
143 "#{quoted_right_column_full_name} >= parent.#{quoted_right_column_name}))"
143 "#{quoted_right_column_full_name} >= parent.#{quoted_right_column_name}))"
144 ).count == 0
144 ).count == 0
145 end
145 end
146
146
147 def no_duplicates_for_columns?
147 def no_duplicates_for_columns?
148 scope_string = Array(acts_as_nested_set_options[:scope]).map do |c|
148 scope_string = Array(acts_as_nested_set_options[:scope]).map do |c|
149 connection.quote_column_name(c)
149 connection.quote_column_name(c)
150 end.push(nil).join(", ")
150 end.push(nil).join(", ")
151 [quoted_left_column_full_name, quoted_right_column_full_name].all? do |column|
151 [quoted_left_column_full_name, quoted_right_column_full_name].all? do |column|
152 # No duplicates
152 # No duplicates
153 select("#{scope_string}#{column}, COUNT(#{column})").
153 select("#{scope_string}#{column}, COUNT(#{column})").
154 group("#{scope_string}#{column}").
154 group("#{scope_string}#{column}").
155 having("COUNT(#{column}) > 1").
155 having("COUNT(#{column}) > 1").
156 first.nil?
156 first.nil?
157 end
157 end
158 end
158 end
159
159
160 # Wrapper for each_root_valid? that can deal with scope.
160 # Wrapper for each_root_valid? that can deal with scope.
161 def all_roots_valid?
161 def all_roots_valid?
162 if acts_as_nested_set_options[:scope]
162 if acts_as_nested_set_options[:scope]
163 roots.group_by {|record| scope_column_names.collect {|col| record.send(col.to_sym) } }.all? do |scope, grouped_roots|
163 roots.group_by {|record| scope_column_names.collect {|col| record.send(col.to_sym) } }.all? do |scope, grouped_roots|
164 each_root_valid?(grouped_roots)
164 each_root_valid?(grouped_roots)
165 end
165 end
166 else
166 else
167 each_root_valid?(roots)
167 each_root_valid?(roots)
168 end
168 end
169 end
169 end
170
170
171 def each_root_valid?(roots_to_validate)
171 def each_root_valid?(roots_to_validate)
172 left = right = 0
172 left = right = 0
173 roots_to_validate.all? do |root|
173 roots_to_validate.all? do |root|
174 (root.left > left && root.right > right).tap do
174 (root.left > left && root.right > right).tap do
175 left = root.left
175 left = root.left
176 right = root.right
176 right = root.right
177 end
177 end
178 end
178 end
179 end
179 end
180
180
181 # Rebuilds the left & rights if unset or invalid.
181 # Rebuilds the left & rights if unset or invalid.
182 # Also very useful for converting from acts_as_tree.
182 # Also very useful for converting from acts_as_tree.
183 def rebuild!(validate_nodes = true)
183 def rebuild!(validate_nodes = true)
184 # default_scope with order may break database queries so we do all operation without scope
184 # default_scope with order may break database queries so we do all operation without scope
185 unscoped do
185 unscoped do
186 # Don't rebuild a valid tree.
186 # Don't rebuild a valid tree.
187 return true if valid?
187 return true if valid?
188
188
189 scope = lambda{|node|}
189 scope = lambda{|node|}
190 if acts_as_nested_set_options[:scope]
190 if acts_as_nested_set_options[:scope]
191 scope = lambda{|node|
191 scope = lambda{|node|
192 scope_column_names.inject(""){|str, column_name|
192 scope_column_names.inject(""){|str, column_name|
193 str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name.to_sym))} "
193 str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name.to_sym))} "
194 }
194 }
195 }
195 }
196 end
196 end
197 indices = {}
197 indices = {}
198
198
199 set_left_and_rights = lambda do |node|
199 set_left_and_rights = lambda do |node|
200 # set left
200 # set left
201 node[left_column_name] = indices[scope.call(node)] += 1
201 node[left_column_name] = indices[scope.call(node)] += 1
202 # find
202 # find
203 where(["#{quoted_parent_column_full_name} = ? #{scope.call(node)}", node]).order("#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, id").each{|n| set_left_and_rights.call(n) }
203 where(["#{quoted_parent_column_full_name} = ? #{scope.call(node)}", node]).order("#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, id").each{|n| set_left_and_rights.call(n) }
204 # set right
204 # set right
205 node[right_column_name] = indices[scope.call(node)] += 1
205 node[right_column_name] = indices[scope.call(node)] += 1
206 node.save!(:validate => validate_nodes)
206 node.save!(:validate => validate_nodes)
207 end
207 end
208
208
209 # Find root node(s)
209 # Find root node(s)
210 root_nodes = where("#{quoted_parent_column_full_name} IS NULL").order("#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, id").each do |root_node|
210 root_nodes = where("#{quoted_parent_column_full_name} IS NULL").order("#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, id").each do |root_node|
211 # setup index for this scope
211 # setup index for this scope
212 indices[scope.call(root_node)] ||= 0
212 indices[scope.call(root_node)] ||= 0
213 set_left_and_rights.call(root_node)
213 set_left_and_rights.call(root_node)
214 end
214 end
215 end
215 end
216 end
216 end
217
217
218 # Iterates over tree elements and determines the current level in the tree.
218 # Iterates over tree elements and determines the current level in the tree.
219 # Only accepts default ordering, odering by an other column than lft
219 # Only accepts default ordering, odering by an other column than lft
220 # does not work. This method is much more efficent than calling level
220 # does not work. This method is much more efficent than calling level
221 # because it doesn't require any additional database queries.
221 # because it doesn't require any additional database queries.
222 #
222 #
223 # Example:
223 # Example:
224 # Category.each_with_level(Category.root.self_and_descendants) do |o, level|
224 # Category.each_with_level(Category.root.self_and_descendants) do |o, level|
225 #
225 #
226 def each_with_level(objects)
226 def each_with_level(objects)
227 path = [nil]
227 path = [nil]
228 objects.each do |o|
228 objects.each do |o|
229 if o.parent_id != path.last
229 if o.parent_id != path.last
230 # we are on a new level, did we descend or ascend?
230 # we are on a new level, did we descend or ascend?
231 if path.include?(o.parent_id)
231 if path.include?(o.parent_id)
232 # remove wrong wrong tailing paths elements
232 # remove wrong wrong tailing paths elements
233 path.pop while path.last != o.parent_id
233 path.pop while path.last != o.parent_id
234 else
234 else
235 path << o.parent_id
235 path << o.parent_id
236 end
236 end
237 end
237 end
238 yield(o, path.length - 1)
238 yield(o, path.length - 1)
239 end
239 end
240 end
240 end
241
241
242 # Same as each_with_level - Accepts a string as a second argument to sort the list
242 # Same as each_with_level - Accepts a string as a second argument to sort the list
243 # Example:
243 # Example:
244 # Category.each_with_level(Category.root.self_and_descendants, :sort_by_this_column) do |o, level|
244 # Category.each_with_level(Category.root.self_and_descendants, :sort_by_this_column) do |o, level|
245 def sorted_each_with_level(objects, order)
245 def sorted_each_with_level(objects, order)
246 path = [nil]
246 path = [nil]
247 children = []
247 children = []
248 objects.each do |o|
248 objects.each do |o|
249 children << o if o.leaf?
249 children << o if o.leaf?
250 if o.parent_id != path.last
250 if o.parent_id != path.last
251 if !children.empty? && !o.leaf?
251 if !children.empty? && !o.leaf?
252 children.sort_by! &order
252 children.sort_by! &order
253 children.each { |c| yield(c, path.length-1) }
253 children.each { |c| yield(c, path.length-1) }
254 children = []
254 children = []
255 end
255 end
256 # we are on a new level, did we decent or ascent?
256 # we are on a new level, did we decent or ascent?
257 if path.include?(o.parent_id)
257 if path.include?(o.parent_id)
258 # remove wrong wrong tailing paths elements
258 # remove wrong wrong tailing paths elements
259 path.pop while path.last != o.parent_id
259 path.pop while path.last != o.parent_id
260 else
260 else
261 path << o.parent_id
261 path << o.parent_id
262 end
262 end
263 end
263 end
264 yield(o,path.length-1) if !o.leaf?
264 yield(o,path.length-1) if !o.leaf?
265 end
265 end
266 if !children.empty?
266 if !children.empty?
267 children.sort_by! &order
267 children.sort_by! &order
268 children.each { |c| yield(c, path.length-1) }
268 children.each { |c| yield(c, path.length-1) }
269 end
269 end
270 end
270 end
271
271
272 def associate_parents(objects)
272 def associate_parents(objects)
273 if objects.all?{|o| o.respond_to?(:association)}
273 if objects.all?{|o| o.respond_to?(:association)}
274 id_indexed = objects.index_by(&:id)
274 id_indexed = objects.index_by(&:id)
275 objects.each do |object|
275 objects.each do |object|
276 if !(association = object.association(:parent)).loaded? && (parent = id_indexed[object.parent_id])
276 if !(association = object.association(:parent)).loaded? && (parent = id_indexed[object.parent_id])
277 association.target = parent
277 association.target = parent
278 association.set_inverse_instance(parent)
278 association.set_inverse_instance(parent)
279 end
279 end
280 end
280 end
281 else
281 else
282 objects
282 objects
283 end
283 end
284 end
284 end
285 end
285 end
286
286
287 # 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.
287 # 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.
288 #
288 #
289 # category.self_and_descendants.count
289 # category.self_and_descendants.count
290 # category.ancestors.find(:all, :conditions => "name like '%foo%'")
290 # category.ancestors.find(:all, :conditions => "name like '%foo%'")
291 # Value of the parent column
291 # Value of the parent column
292 def parent_id
292 def parent_id
293 self[parent_column_name]
293 self[parent_column_name]
294 end
294 end
295
295
296 # Value of the left column
296 # Value of the left column
297 def left
297 def left
298 self[left_column_name]
298 self[left_column_name]
299 end
299 end
300
300
301 # Value of the right column
301 # Value of the right column
302 def right
302 def right
303 self[right_column_name]
303 self[right_column_name]
304 end
304 end
305
305
306 # Returns true if this is a root node.
306 # Returns true if this is a root node.
307 def root?
307 def root?
308 parent_id.nil?
308 parent_id.nil?
309 end
309 end
310
310
311 # Returns true if this is the end of a branch.
311 # Returns true if this is the end of a branch.
312 def leaf?
312 def leaf?
313 persisted? && right.to_i - left.to_i == 1
313 persisted? && right.to_i - left.to_i == 1
314 end
314 end
315
315
316 # Returns true is this is a child node
316 # Returns true is this is a child node
317 def child?
317 def child?
318 !root?
318 !root?
319 end
319 end
320
320
321 # Returns root
321 # Returns root
322 def root
322 def root
323 if persisted?
323 if persisted?
324 self_and_ancestors.where(parent_column_name => nil).first
324 self_and_ancestors.where(parent_column_name => nil).first
325 else
325 else
326 if parent_id && current_parent = nested_set_scope.find(parent_id)
326 if parent_id && current_parent = nested_set_scope.find(parent_id)
327 current_parent.root
327 current_parent.root
328 else
328 else
329 self
329 self
330 end
330 end
331 end
331 end
332 end
332 end
333
333
334 # Returns the array of all parents and self
334 # Returns the array of all parents and self
335 def self_and_ancestors
335 def self_and_ancestors
336 nested_set_scope.where([
336 nested_set_scope.where([
337 "#{quoted_left_column_full_name} <= ? AND #{quoted_right_column_full_name} >= ?", left, right
337 "#{quoted_left_column_full_name} <= ? AND #{quoted_right_column_full_name} >= ?", left, right
338 ])
338 ])
339 end
339 end
340
340
341 # Returns an array of all parents
341 # Returns an array of all parents
342 def ancestors
342 def ancestors
343 without_self self_and_ancestors
343 without_self self_and_ancestors
344 end
344 end
345
345
346 # Returns the array of all children of the parent, including self
346 # Returns the array of all children of the parent, including self
347 def self_and_siblings
347 def self_and_siblings
348 nested_set_scope.where(parent_column_name => parent_id)
348 nested_set_scope.where(parent_column_name => parent_id)
349 end
349 end
350
350
351 # Returns the array of all children of the parent, except self
351 # Returns the array of all children of the parent, except self
352 def siblings
352 def siblings
353 without_self self_and_siblings
353 without_self self_and_siblings
354 end
354 end
355
355
356 # Returns a set of all of its nested children which do not have children
356 # Returns a set of all of its nested children which do not have children
357 def leaves
357 def leaves
358 descendants.where("#{quoted_right_column_full_name} - #{quoted_left_column_full_name} = 1")
358 descendants.where("#{quoted_right_column_full_name} - #{quoted_left_column_full_name} = 1")
359 end
359 end
360
360
361 # Returns the level of this object in the tree
361 # Returns the level of this object in the tree
362 # root level is 0
362 # root level is 0
363 def level
363 def level
364 parent_id.nil? ? 0 : compute_level
364 parent_id.nil? ? 0 : compute_level
365 end
365 end
366
366
367 # Returns a set of itself and all of its nested children
367 # Returns a set of itself and all of its nested children
368 def self_and_descendants
368 def self_and_descendants
369 nested_set_scope.where([
369 nested_set_scope.where([
370 "#{quoted_left_column_full_name} >= ? AND #{quoted_left_column_full_name} < ?", left, right
370 "#{quoted_left_column_full_name} >= ? AND #{quoted_left_column_full_name} < ?", left, right
371 # using _left_ for both sides here lets us benefit from an index on that column if one exists
371 # using _left_ for both sides here lets us benefit from an index on that column if one exists
372 ])
372 ])
373 end
373 end
374
374
375 # Returns a set of all of its children and nested children
375 # Returns a set of all of its children and nested children
376 def descendants
376 def descendants
377 without_self self_and_descendants
377 without_self self_and_descendants
378 end
378 end
379
379
380 def is_descendant_of?(other)
380 def is_descendant_of?(other)
381 other.left < self.left && self.left < other.right && same_scope?(other)
381 other.left < self.left && self.left < other.right && same_scope?(other)
382 end
382 end
383
383
384 def is_or_is_descendant_of?(other)
384 def is_or_is_descendant_of?(other)
385 other.left <= self.left && self.left < other.right && same_scope?(other)
385 other.left <= self.left && self.left < other.right && same_scope?(other)
386 end
386 end
387
387
388 def is_ancestor_of?(other)
388 def is_ancestor_of?(other)
389 self.left < other.left && other.left < self.right && same_scope?(other)
389 self.left < other.left && other.left < self.right && same_scope?(other)
390 end
390 end
391
391
392 def is_or_is_ancestor_of?(other)
392 def is_or_is_ancestor_of?(other)
393 self.left <= other.left && other.left < self.right && same_scope?(other)
393 self.left <= other.left && other.left < self.right && same_scope?(other)
394 end
394 end
395
395
396 # Check if other model is in the same scope
396 # Check if other model is in the same scope
397 def same_scope?(other)
397 def same_scope?(other)
398 Array(acts_as_nested_set_options[:scope]).all? do |attr|
398 Array(acts_as_nested_set_options[:scope]).all? do |attr|
399 self.send(attr) == other.send(attr)
399 self.send(attr) == other.send(attr)
400 end
400 end
401 end
401 end
402
402
403 # Find the first sibling to the left
403 # Find the first sibling to the left
404 def left_sibling
404 def left_sibling
405 siblings.where(["#{quoted_left_column_full_name} < ?", left]).
405 siblings.where(["#{quoted_left_column_full_name} < ?", left]).
406 order("#{quoted_left_column_full_name} DESC").last
406 order("#{quoted_left_column_full_name} DESC").last
407 end
407 end
408
408
409 # Find the first sibling to the right
409 # Find the first sibling to the right
410 def right_sibling
410 def right_sibling
411 siblings.where(["#{quoted_left_column_full_name} > ?", left]).first
411 siblings.where(["#{quoted_left_column_full_name} > ?", left]).first
412 end
412 end
413
413
414 # Shorthand method for finding the left sibling and moving to the left of it.
414 # Shorthand method for finding the left sibling and moving to the left of it.
415 def move_left
415 def move_left
416 move_to_left_of left_sibling
416 move_to_left_of left_sibling
417 end
417 end
418
418
419 # Shorthand method for finding the right sibling and moving to the right of it.
419 # Shorthand method for finding the right sibling and moving to the right of it.
420 def move_right
420 def move_right
421 move_to_right_of right_sibling
421 move_to_right_of right_sibling
422 end
422 end
423
423
424 # Move the node to the left of another node (you can pass id only)
424 # Move the node to the left of another node (you can pass id only)
425 def move_to_left_of(node)
425 def move_to_left_of(node)
426 move_to node, :left
426 move_to node, :left
427 end
427 end
428
428
429 # Move the node to the left of another node (you can pass id only)
429 # Move the node to the left of another node (you can pass id only)
430 def move_to_right_of(node)
430 def move_to_right_of(node)
431 move_to node, :right
431 move_to node, :right
432 end
432 end
433
433
434 # Move the node to the child of another node (you can pass id only)
434 # Move the node to the child of another node (you can pass id only)
435 def move_to_child_of(node)
435 def move_to_child_of(node)
436 move_to node, :child
436 move_to node, :child
437 end
437 end
438
438
439 # Move the node to the child of another node with specify index (you can pass id only)
439 # Move the node to the child of another node with specify index (you can pass id only)
440 def move_to_child_with_index(node, index)
440 def move_to_child_with_index(node, index)
441 if node.children.empty?
441 if node.children.empty?
442 move_to_child_of(node)
442 move_to_child_of(node)
443 elsif node.children.count == index
443 elsif node.children.count == index
444 move_to_right_of(node.children.last)
444 move_to_right_of(node.children.last)
445 else
445 else
446 move_to_left_of(node.children[index])
446 move_to_left_of(node.children[index])
447 end
447 end
448 end
448 end
449
449
450 # Move the node to root nodes
450 # Move the node to root nodes
451 def move_to_root
451 def move_to_root
452 move_to nil, :root
452 move_to nil, :root
453 end
453 end
454
454
455 # Order children in a nested set by an attribute
455 # Order children in a nested set by an attribute
456 # Can order by any attribute class that uses the Comparable mixin, for example a string or integer
456 # Can order by any attribute class that uses the Comparable mixin, for example a string or integer
457 # Usage example when sorting categories alphabetically: @new_category.move_to_ordered_child_of(@root, "name")
457 # Usage example when sorting categories alphabetically: @new_category.move_to_ordered_child_of(@root, "name")
458 def move_to_ordered_child_of(parent, order_attribute, ascending = true)
458 def move_to_ordered_child_of(parent, order_attribute, ascending = true)
459 self.move_to_root and return unless parent
459 self.move_to_root and return unless parent
460 left = nil # This is needed, at least for the tests.
460 left = nil # This is needed, at least for the tests.
461 parent.children.each do |n| # Find the node immediately to the left of this node.
461 parent.children.each do |n| # Find the node immediately to the left of this node.
462 if ascending
462 if ascending
463 left = n if n.send(order_attribute) < self.send(order_attribute)
463 left = n if n.send(order_attribute) < self.send(order_attribute)
464 else
464 else
465 left = n if n.send(order_attribute) > self.send(order_attribute)
465 left = n if n.send(order_attribute) > self.send(order_attribute)
466 end
466 end
467 end
467 end
468 self.move_to_child_of(parent)
468 self.move_to_child_of(parent)
469 return unless parent.children.count > 1 # Only need to order if there are multiple children.
469 return unless parent.children.count > 1 # Only need to order if there are multiple children.
470 if left # Self has a left neighbor.
470 if left # Self has a left neighbor.
471 self.move_to_right_of(left)
471 self.move_to_right_of(left)
472 else # Self is the left most node.
472 else # Self is the left most node.
473 self.move_to_left_of(parent.children[0])
473 self.move_to_left_of(parent.children[0])
474 end
474 end
475 end
475 end
476
476
477 def move_possible?(target)
477 def move_possible?(target)
478 self != target && # Can't target self
478 self != target && # Can't target self
479 same_scope?(target) && # can't be in different scopes
479 same_scope?(target) && # can't be in different scopes
480 # !(left..right).include?(target.left..target.right) # this needs tested more
480 # !(left..right).include?(target.left..target.right) # this needs tested more
481 # detect impossible move
481 # detect impossible move
482 !((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right))
482 !((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right))
483 end
483 end
484
484
485 def to_text
485 def to_text
486 self_and_descendants.map do |node|
486 self_and_descendants.map do |node|
487 "#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})"
487 "#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})"
488 end.join("\n")
488 end.join("\n")
489 end
489 end
490
490
491 protected
491 protected
492 def compute_level
492 def compute_level
493 node, nesting = self, 0
493 node, nesting = self, 0
494 while (association = node.association(:parent)).loaded? && association.target
494 while (association = node.association(:parent)).loaded? && association.target
495 nesting += 1
495 nesting += 1
496 node = node.parent
496 node = node.parent
497 end if node.respond_to? :association
497 end if node.respond_to? :association
498 node == self ? ancestors.count : node.level + nesting
498 node == self ? ancestors.count : node.level + nesting
499 end
499 end
500
500
501 def without_self(scope)
501 def without_self(scope)
502 scope.where(["#{self.class.quoted_table_name}.#{self.class.primary_key} != ?", self])
502 scope.where(["#{self.class.quoted_table_name}.#{self.class.primary_key} != ?", self])
503 end
503 end
504
504
505 # All nested set queries should use this nested_set_scope, which performs finds on
505 # All nested set queries should use this nested_set_scope, which performs finds on
506 # the base ActiveRecord class, using the :scope declared in the acts_as_nested_set
506 # the base ActiveRecord class, using the :scope declared in the acts_as_nested_set
507 # declaration.
507 # declaration.
508 def nested_set_scope(options = {})
508 def nested_set_scope(options = {})
509 options = {:order => quoted_left_column_full_name}.merge(options)
509 options = {:order => quoted_left_column_full_name}.merge(options)
510 scopes = Array(acts_as_nested_set_options[:scope])
510 scopes = Array(acts_as_nested_set_options[:scope])
511 options[:conditions] = scopes.inject({}) do |conditions,attr|
511 options[:conditions] = scopes.inject({}) do |conditions,attr|
512 conditions.merge attr => self[attr]
512 conditions.merge attr => self[attr]
513 end unless scopes.empty?
513 end unless scopes.empty?
514 self.class.base_class.unscoped.scoped options
514 self.class.base_class.unscoped.scoped options
515 end
515 end
516
516
517 def store_new_parent
517 def store_new_parent
518 @move_to_new_parent_id = send("#{parent_column_name}_changed?") ? parent_id : false
518 @move_to_new_parent_id = send("#{parent_column_name}_changed?") ? parent_id : false
519 true # force callback to return true
519 true # force callback to return true
520 end
520 end
521
521
522 def move_to_new_parent
522 def move_to_new_parent
523 if @move_to_new_parent_id.nil?
523 if @move_to_new_parent_id.nil?
524 move_to_root
524 move_to_root
525 elsif @move_to_new_parent_id
525 elsif @move_to_new_parent_id
526 move_to_child_of(@move_to_new_parent_id)
526 move_to_child_of(@move_to_new_parent_id)
527 end
527 end
528 end
528 end
529
529
530 def set_depth!
530 def set_depth!
531 if nested_set_scope.column_names.map(&:to_s).include?(depth_column_name.to_s)
531 if nested_set_scope.column_names.map(&:to_s).include?(depth_column_name.to_s)
532 in_tenacious_transaction do
532 in_tenacious_transaction do
533 reload
533 reload
534
534
535 nested_set_scope.where(:id => id).update_all(["#{quoted_depth_column_name} = ?", level])
535 nested_set_scope.where(:id => id).update_all(["#{quoted_depth_column_name} = ?", level])
536 end
536 end
537 self[depth_column_name.to_sym] = self.level
537 self[depth_column_name.to_sym] = self.level
538 end
538 end
539 end
539 end
540
540
541 # on creation, set automatically lft and rgt to the end of the tree
541 # on creation, set automatically lft and rgt to the end of the tree
542 def set_default_left_and_right
542 def set_default_left_and_right
543 highest_right_row = nested_set_scope(:order => "#{quoted_right_column_full_name} desc").limit(1).lock(true).first
543 highest_right_row =
544 self.class.base_class.unscoped.
545 order("#{quoted_right_column_full_name} desc").limit(1).lock(true).first
544 maxright = highest_right_row ? (highest_right_row[right_column_name] || 0) : 0
546 maxright = highest_right_row ? (highest_right_row[right_column_name] || 0) : 0
545 # adds the new node to the right of all existing nodes
547 # adds the new node to the right of all existing nodes
546 self[left_column_name] = maxright + 1
548 self[left_column_name] = maxright + 1
547 self[right_column_name] = maxright + 2
549 self[right_column_name] = maxright + 2
548 end
550 end
549
551
550 def in_tenacious_transaction(&block)
552 def in_tenacious_transaction(&block)
551 retry_count = 0
553 retry_count = 0
552 begin
554 begin
553 transaction(&block)
555 transaction(&block)
554 rescue ActiveRecord::StatementInvalid => error
556 rescue ActiveRecord::StatementInvalid => error
555 raise unless connection.open_transactions.zero?
557 raise unless connection.open_transactions.zero?
556 raise unless error.message =~ /Deadlock found when trying to get lock|Lock wait timeout exceeded/
558 raise unless error.message =~ /Deadlock found when trying to get lock|Lock wait timeout exceeded/
557 raise unless retry_count < 10
559 raise unless retry_count < 10
558 retry_count += 1
560 retry_count += 1
559 logger.info "Deadlock detected on retry #{retry_count}, restarting transaction"
561 logger.info "Deadlock detected on retry #{retry_count}, restarting transaction"
560 sleep(rand(retry_count)*0.1) # Aloha protocol
562 sleep(rand(retry_count)*0.1) # Aloha protocol
561 retry
563 retry
562 end
564 end
563 end
565 end
564
566
565 # Prunes a branch off of the tree, shifting all of the elements on the right
567 # Prunes a branch off of the tree, shifting all of the elements on the right
566 # back to the left so the counts still work.
568 # back to the left so the counts still work.
567 def destroy_descendants
569 def destroy_descendants
568 return if right.nil? || left.nil? || skip_before_destroy
570 return if right.nil? || left.nil? || skip_before_destroy
569
571
570 in_tenacious_transaction do
572 in_tenacious_transaction do
571 reload_nested_set
573 reload_nested_set
572 # select the rows in the model that extend past the deletion point and apply a lock
574 # select the rows in the model that extend past the deletion point and apply a lock
573 nested_set_scope.where(["#{quoted_left_column_full_name} >= ?", left]).
575 nested_set_scope.where(["#{quoted_left_column_full_name} >= ?", left]).
574 select(id).lock(true)
576 select(id).lock(true)
575
577
576 if acts_as_nested_set_options[:dependent] == :destroy
578 if acts_as_nested_set_options[:dependent] == :destroy
577 descendants.each do |model|
579 descendants.each do |model|
578 model.skip_before_destroy = true
580 model.skip_before_destroy = true
579 model.destroy
581 model.destroy
580 end
582 end
581 else
583 else
582 nested_set_scope.where(["#{quoted_left_column_name} > ? AND #{quoted_right_column_name} < ?", left, right]).
584 nested_set_scope.where(["#{quoted_left_column_name} > ? AND #{quoted_right_column_name} < ?", left, right]).
583 delete_all
585 delete_all
584 end
586 end
585
587
586 # update lefts and rights for remaining nodes
588 # update lefts and rights for remaining nodes
587 diff = right - left + 1
589 diff = right - left + 1
588 nested_set_scope.where(["#{quoted_left_column_full_name} > ?", right]).update_all(
590 nested_set_scope.where(["#{quoted_left_column_full_name} > ?", right]).update_all(
589 ["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff]
591 ["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff]
590 )
592 )
591
593
592 nested_set_scope.where(["#{quoted_right_column_full_name} > ?", right]).update_all(
594 nested_set_scope.where(["#{quoted_right_column_full_name} > ?", right]).update_all(
593 ["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff]
595 ["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff]
594 )
596 )
595
597
596 # Don't allow multiple calls to destroy to corrupt the set
598 # Don't allow multiple calls to destroy to corrupt the set
597 self.skip_before_destroy = true
599 self.skip_before_destroy = true
598 end
600 end
599 end
601 end
600
602
601 # reload left, right, and parent
603 # reload left, right, and parent
602 def reload_nested_set
604 def reload_nested_set
603 reload(
605 reload(
604 :select => "#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, #{quoted_parent_column_full_name}",
606 :select => "#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, #{quoted_parent_column_full_name}",
605 :lock => true
607 :lock => true
606 )
608 )
607 end
609 end
608
610
609 def move_to(target, position)
611 def move_to(target, position)
610 raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record?
612 raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record?
611 run_callbacks :move do
613 run_callbacks :move do
612 in_tenacious_transaction do
614 in_tenacious_transaction do
613 if target.is_a? self.class.base_class
615 if target.is_a? self.class.base_class
614 target.reload_nested_set
616 target.reload_nested_set
615 elsif position != :root
617 elsif position != :root
616 # load object if node is not an object
618 # load object if node is not an object
617 target = nested_set_scope.find(target)
619 target = nested_set_scope.find(target)
618 end
620 end
619 self.reload_nested_set
621 self.reload_nested_set
620
622
621 unless position == :root || move_possible?(target)
623 unless position == :root || move_possible?(target)
622 raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree."
624 raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree."
623 end
625 end
624
626
625 bound = case position
627 bound = case position
626 when :child; target[right_column_name]
628 when :child; target[right_column_name]
627 when :left; target[left_column_name]
629 when :left; target[left_column_name]
628 when :right; target[right_column_name] + 1
630 when :right; target[right_column_name] + 1
629 when :root; 1
631 when :root; 1
630 else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)."
632 else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)."
631 end
633 end
632
634
633 if bound > self[right_column_name]
635 if bound > self[right_column_name]
634 bound = bound - 1
636 bound = bound - 1
635 other_bound = self[right_column_name] + 1
637 other_bound = self[right_column_name] + 1
636 else
638 else
637 other_bound = self[left_column_name] - 1
639 other_bound = self[left_column_name] - 1
638 end
640 end
639
641
640 # there would be no change
642 # there would be no change
641 return if bound == self[right_column_name] || bound == self[left_column_name]
643 return if bound == self[right_column_name] || bound == self[left_column_name]
642
644
643 # we have defined the boundaries of two non-overlapping intervals,
645 # we have defined the boundaries of two non-overlapping intervals,
644 # so sorting puts both the intervals and their boundaries in order
646 # so sorting puts both the intervals and their boundaries in order
645 a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort
647 a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort
646
648
647 # select the rows in the model between a and d, and apply a lock
649 # select the rows in the model between a and d, and apply a lock
648 self.class.base_class.select('id').lock(true).where(
650 self.class.base_class.select('id').lock(true).where(
649 ["#{quoted_left_column_full_name} >= :a and #{quoted_right_column_full_name} <= :d", {:a => a, :d => d}]
651 ["#{quoted_left_column_full_name} >= :a and #{quoted_right_column_full_name} <= :d", {:a => a, :d => d}]
650 )
652 )
651
653
652 new_parent = case position
654 new_parent = case position
653 when :child; target.id
655 when :child; target.id
654 when :root; nil
656 when :root; nil
655 else target[parent_column_name]
657 else target[parent_column_name]
656 end
658 end
657
659
658 where_statement = ["not (#{quoted_left_column_name} = CASE " +
660 where_statement = ["not (#{quoted_left_column_name} = CASE " +
659 "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " +
661 "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " +
660 "THEN #{quoted_left_column_name} + :d - :b " +
662 "THEN #{quoted_left_column_name} + :d - :b " +
661 "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " +
663 "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " +
662 "THEN #{quoted_left_column_name} + :a - :c " +
664 "THEN #{quoted_left_column_name} + :a - :c " +
663 "ELSE #{quoted_left_column_name} END AND " +
665 "ELSE #{quoted_left_column_name} END AND " +
664 "#{quoted_right_column_name} = CASE " +
666 "#{quoted_right_column_name} = CASE " +
665 "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " +
667 "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " +
666 "THEN #{quoted_right_column_name} + :d - :b " +
668 "THEN #{quoted_right_column_name} + :d - :b " +
667 "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " +
669 "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " +
668 "THEN #{quoted_right_column_name} + :a - :c " +
670 "THEN #{quoted_right_column_name} + :a - :c " +
669 "ELSE #{quoted_right_column_name} END AND " +
671 "ELSE #{quoted_right_column_name} END AND " +
670 "#{quoted_parent_column_name} = CASE " +
672 "#{quoted_parent_column_name} = CASE " +
671 "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " +
673 "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " +
672 "ELSE #{quoted_parent_column_name} END)" ,
674 "ELSE #{quoted_parent_column_name} END)" ,
673 {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent} ]
675 {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent} ]
674
676
675
677
676
678
677
679
678 self.nested_set_scope.where(*where_statement).update_all([
680 self.nested_set_scope.where(*where_statement).update_all([
679 "#{quoted_left_column_name} = CASE " +
681 "#{quoted_left_column_name} = CASE " +
680 "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " +
682 "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " +
681 "THEN #{quoted_left_column_name} + :d - :b " +
683 "THEN #{quoted_left_column_name} + :d - :b " +
682 "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " +
684 "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " +
683 "THEN #{quoted_left_column_name} + :a - :c " +
685 "THEN #{quoted_left_column_name} + :a - :c " +
684 "ELSE #{quoted_left_column_name} END, " +
686 "ELSE #{quoted_left_column_name} END, " +
685 "#{quoted_right_column_name} = CASE " +
687 "#{quoted_right_column_name} = CASE " +
686 "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " +
688 "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " +
687 "THEN #{quoted_right_column_name} + :d - :b " +
689 "THEN #{quoted_right_column_name} + :d - :b " +
688 "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " +
690 "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " +
689 "THEN #{quoted_right_column_name} + :a - :c " +
691 "THEN #{quoted_right_column_name} + :a - :c " +
690 "ELSE #{quoted_right_column_name} END, " +
692 "ELSE #{quoted_right_column_name} END, " +
691 "#{quoted_parent_column_name} = CASE " +
693 "#{quoted_parent_column_name} = CASE " +
692 "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " +
694 "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " +
693 "ELSE #{quoted_parent_column_name} END",
695 "ELSE #{quoted_parent_column_name} END",
694 {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent}
696 {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent}
695 ])
697 ])
696 end
698 end
697 target.reload_nested_set if target
699 target.reload_nested_set if target
698 self.set_depth!
700 self.set_depth!
699 self.descendants.each(&:save)
701 self.descendants.each(&:save)
700 self.reload_nested_set
702 self.reload_nested_set
701 end
703 end
702 end
704 end
703
705
704 end
706 end
705
707
706 # Mixed into both classes and instances to provide easy access to the column names
708 # Mixed into both classes and instances to provide easy access to the column names
707 module Columns
709 module Columns
708 def left_column_name
710 def left_column_name
709 acts_as_nested_set_options[:left_column]
711 acts_as_nested_set_options[:left_column]
710 end
712 end
711
713
712 def right_column_name
714 def right_column_name
713 acts_as_nested_set_options[:right_column]
715 acts_as_nested_set_options[:right_column]
714 end
716 end
715
717
716 def depth_column_name
718 def depth_column_name
717 acts_as_nested_set_options[:depth_column]
719 acts_as_nested_set_options[:depth_column]
718 end
720 end
719
721
720 def parent_column_name
722 def parent_column_name
721 acts_as_nested_set_options[:parent_column]
723 acts_as_nested_set_options[:parent_column]
722 end
724 end
723
725
724 def order_column
726 def order_column
725 acts_as_nested_set_options[:order_column] || left_column_name
727 acts_as_nested_set_options[:order_column] || left_column_name
726 end
728 end
727
729
728 def scope_column_names
730 def scope_column_names
729 Array(acts_as_nested_set_options[:scope])
731 Array(acts_as_nested_set_options[:scope])
730 end
732 end
731
733
732 def quoted_left_column_name
734 def quoted_left_column_name
733 connection.quote_column_name(left_column_name)
735 connection.quote_column_name(left_column_name)
734 end
736 end
735
737
736 def quoted_right_column_name
738 def quoted_right_column_name
737 connection.quote_column_name(right_column_name)
739 connection.quote_column_name(right_column_name)
738 end
740 end
739
741
740 def quoted_depth_column_name
742 def quoted_depth_column_name
741 connection.quote_column_name(depth_column_name)
743 connection.quote_column_name(depth_column_name)
742 end
744 end
743
745
744 def quoted_parent_column_name
746 def quoted_parent_column_name
745 connection.quote_column_name(parent_column_name)
747 connection.quote_column_name(parent_column_name)
746 end
748 end
747
749
748 def quoted_scope_column_names
750 def quoted_scope_column_names
749 scope_column_names.collect {|column_name| connection.quote_column_name(column_name) }
751 scope_column_names.collect {|column_name| connection.quote_column_name(column_name) }
750 end
752 end
751
753
752 def quoted_left_column_full_name
754 def quoted_left_column_full_name
753 "#{quoted_table_name}.#{quoted_left_column_name}"
755 "#{quoted_table_name}.#{quoted_left_column_name}"
754 end
756 end
755
757
756 def quoted_right_column_full_name
758 def quoted_right_column_full_name
757 "#{quoted_table_name}.#{quoted_right_column_name}"
759 "#{quoted_table_name}.#{quoted_right_column_name}"
758 end
760 end
759
761
760 def quoted_parent_column_full_name
762 def quoted_parent_column_full_name
761 "#{quoted_table_name}.#{quoted_parent_column_name}"
763 "#{quoted_table_name}.#{quoted_parent_column_name}"
762 end
764 end
763 end
765 end
764
766
765 end
767 end
766 end
768 end
767 end
769 end
@@ -1,494 +1,495
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 'shoulda'
18 #require 'shoulda'
19 ENV["RAILS_ENV"] = "test"
19 ENV["RAILS_ENV"] = "test"
20 require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
20 require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
21 require 'rails/test_help'
21 require 'rails/test_help'
22 require Rails.root.join('test', 'mocks', 'open_id_authentication_mock.rb').to_s
22 require Rails.root.join('test', 'mocks', 'open_id_authentication_mock.rb').to_s
23
23
24 require File.expand_path(File.dirname(__FILE__) + '/object_helpers')
24 require File.expand_path(File.dirname(__FILE__) + '/object_helpers')
25 include ObjectHelpers
25 include ObjectHelpers
26
26
27 require 'awesome_nested_set/version'
27 require 'awesome_nested_set/version'
28
28
29 class ActiveSupport::TestCase
29 class ActiveSupport::TestCase
30 include ActionDispatch::TestProcess
30 include ActionDispatch::TestProcess
31
31
32 self.use_transactional_fixtures = true
32 self.use_transactional_fixtures = true
33 self.use_instantiated_fixtures = false
33 self.use_instantiated_fixtures = false
34
34
35 ESCAPED_CANT = 'can&#x27;t'
35 ESCAPED_CANT = 'can&#x27;t'
36 ESCAPED_UCANT = 'Can&#x27;t'
36 ESCAPED_UCANT = 'Can&#x27;t'
37 # Rails 4.0.2
37 # Rails 4.0.2
38 #ESCAPED_CANT = 'can&#39;t'
38 #ESCAPED_CANT = 'can&#39;t'
39 #ESCAPED_UCANT = 'Can&#39;t'
39 #ESCAPED_UCANT = 'Can&#39;t'
40
40
41 def log_user(login, password)
41 def log_user(login, password)
42 User.anonymous
42 User.anonymous
43 get "/login"
43 get "/login"
44 assert_equal nil, session[:user_id]
44 assert_equal nil, session[:user_id]
45 assert_response :success
45 assert_response :success
46 assert_template "account/login"
46 assert_template "account/login"
47 post "/login", :username => login, :password => password
47 post "/login", :username => login, :password => password
48 assert_equal login, User.find(session[:user_id]).login
48 assert_equal login, User.find(session[:user_id]).login
49 end
49 end
50
50
51 def uploaded_test_file(name, mime)
51 def uploaded_test_file(name, mime)
52 fixture_file_upload("files/#{name}", mime, true)
52 fixture_file_upload("files/#{name}", mime, true)
53 end
53 end
54
54
55 def credentials(user, password=nil)
55 def credentials(user, password=nil)
56 {'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)}
56 {'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)}
57 end
57 end
58
58
59 # Mock out a file
59 # Mock out a file
60 def self.mock_file
60 def self.mock_file
61 file = 'a_file.png'
61 file = 'a_file.png'
62 file.stubs(:size).returns(32)
62 file.stubs(:size).returns(32)
63 file.stubs(:original_filename).returns('a_file.png')
63 file.stubs(:original_filename).returns('a_file.png')
64 file.stubs(:content_type).returns('image/png')
64 file.stubs(:content_type).returns('image/png')
65 file.stubs(:read).returns(false)
65 file.stubs(:read).returns(false)
66 file
66 file
67 end
67 end
68
68
69 def mock_file
69 def mock_file
70 self.class.mock_file
70 self.class.mock_file
71 end
71 end
72
72
73 def mock_file_with_options(options={})
73 def mock_file_with_options(options={})
74 file = ''
74 file = ''
75 file.stubs(:size).returns(32)
75 file.stubs(:size).returns(32)
76 original_filename = options[:original_filename] || nil
76 original_filename = options[:original_filename] || nil
77 file.stubs(:original_filename).returns(original_filename)
77 file.stubs(:original_filename).returns(original_filename)
78 content_type = options[:content_type] || nil
78 content_type = options[:content_type] || nil
79 file.stubs(:content_type).returns(content_type)
79 file.stubs(:content_type).returns(content_type)
80 file.stubs(:read).returns(false)
80 file.stubs(:read).returns(false)
81 file
81 file
82 end
82 end
83
83
84 # Use a temporary directory for attachment related tests
84 # Use a temporary directory for attachment related tests
85 def set_tmp_attachments_directory
85 def set_tmp_attachments_directory
86 Dir.mkdir "#{Rails.root}/tmp/test" unless File.directory?("#{Rails.root}/tmp/test")
86 Dir.mkdir "#{Rails.root}/tmp/test" unless File.directory?("#{Rails.root}/tmp/test")
87 unless File.directory?("#{Rails.root}/tmp/test/attachments")
87 unless File.directory?("#{Rails.root}/tmp/test/attachments")
88 Dir.mkdir "#{Rails.root}/tmp/test/attachments"
88 Dir.mkdir "#{Rails.root}/tmp/test/attachments"
89 end
89 end
90 Attachment.storage_path = "#{Rails.root}/tmp/test/attachments"
90 Attachment.storage_path = "#{Rails.root}/tmp/test/attachments"
91 end
91 end
92
92
93 def set_fixtures_attachments_directory
93 def set_fixtures_attachments_directory
94 Attachment.storage_path = "#{Rails.root}/test/fixtures/files"
94 Attachment.storage_path = "#{Rails.root}/test/fixtures/files"
95 end
95 end
96
96
97 def with_settings(options, &block)
97 def with_settings(options, &block)
98 saved_settings = options.keys.inject({}) do |h, k|
98 saved_settings = options.keys.inject({}) do |h, k|
99 h[k] = case Setting[k]
99 h[k] = case Setting[k]
100 when Symbol, false, true, nil
100 when Symbol, false, true, nil
101 Setting[k]
101 Setting[k]
102 else
102 else
103 Setting[k].dup
103 Setting[k].dup
104 end
104 end
105 h
105 h
106 end
106 end
107 options.each {|k, v| Setting[k] = v}
107 options.each {|k, v| Setting[k] = v}
108 yield
108 yield
109 ensure
109 ensure
110 saved_settings.each {|k, v| Setting[k] = v} if saved_settings
110 saved_settings.each {|k, v| Setting[k] = v} if saved_settings
111 end
111 end
112
112
113 # Yields the block with user as the current user
113 # Yields the block with user as the current user
114 def with_current_user(user, &block)
114 def with_current_user(user, &block)
115 saved_user = User.current
115 saved_user = User.current
116 User.current = user
116 User.current = user
117 yield
117 yield
118 ensure
118 ensure
119 User.current = saved_user
119 User.current = saved_user
120 end
120 end
121
121
122 def with_locale(locale, &block)
122 def with_locale(locale, &block)
123 saved_localed = ::I18n.locale
123 saved_localed = ::I18n.locale
124 ::I18n.locale = locale
124 ::I18n.locale = locale
125 yield
125 yield
126 ensure
126 ensure
127 ::I18n.locale = saved_localed
127 ::I18n.locale = saved_localed
128 end
128 end
129
129
130 def change_user_password(login, new_password)
130 def change_user_password(login, new_password)
131 user = User.where(:login => login).first
131 user = User.where(:login => login).first
132 user.password, user.password_confirmation = new_password, new_password
132 user.password, user.password_confirmation = new_password, new_password
133 user.save!
133 user.save!
134 end
134 end
135
135
136 def self.ldap_configured?
136 def self.ldap_configured?
137 @test_ldap = Net::LDAP.new(:host => '127.0.0.1', :port => 389)
137 @test_ldap = Net::LDAP.new(:host => '127.0.0.1', :port => 389)
138 return @test_ldap.bind
138 return @test_ldap.bind
139 rescue Exception => e
139 rescue Exception => e
140 # LDAP is not listening
140 # LDAP is not listening
141 return nil
141 return nil
142 end
142 end
143
143
144 def self.convert_installed?
144 def self.convert_installed?
145 Redmine::Thumbnail.convert_available?
145 Redmine::Thumbnail.convert_available?
146 end
146 end
147
147
148 # Returns the path to the test +vendor+ repository
148 # Returns the path to the test +vendor+ repository
149 def self.repository_path(vendor)
149 def self.repository_path(vendor)
150 Rails.root.join("tmp/test/#{vendor.downcase}_repository").to_s
150 Rails.root.join("tmp/test/#{vendor.downcase}_repository").to_s
151 end
151 end
152
152
153 # Returns the url of the subversion test repository
153 # Returns the url of the subversion test repository
154 def self.subversion_repository_url
154 def self.subversion_repository_url
155 path = repository_path('subversion')
155 path = repository_path('subversion')
156 path = '/' + path unless path.starts_with?('/')
156 path = '/' + path unless path.starts_with?('/')
157 "file://#{path}"
157 "file://#{path}"
158 end
158 end
159
159
160 # Returns true if the +vendor+ test repository is configured
160 # Returns true if the +vendor+ test repository is configured
161 def self.repository_configured?(vendor)
161 def self.repository_configured?(vendor)
162 File.directory?(repository_path(vendor))
162 File.directory?(repository_path(vendor))
163 end
163 end
164
164
165 def repository_path_hash(arr)
165 def repository_path_hash(arr)
166 hs = {}
166 hs = {}
167 hs[:path] = arr.join("/")
167 hs[:path] = arr.join("/")
168 hs[:param] = arr.join("/")
168 hs[:param] = arr.join("/")
169 hs
169 hs
170 end
170 end
171
171
172 def assert_save(object)
172 def assert_save(object)
173 saved = object.save
173 saved = object.save
174 message = "#{object.class} could not be saved"
174 message = "#{object.class} could not be saved"
175 errors = object.errors.full_messages.map {|m| "- #{m}"}
175 errors = object.errors.full_messages.map {|m| "- #{m}"}
176 message << ":\n#{errors.join("\n")}" if errors.any?
176 message << ":\n#{errors.join("\n")}" if errors.any?
177 assert_equal true, saved, message
177 assert_equal true, saved, message
178 end
178 end
179
179
180 def assert_error_tag(options={})
180 def assert_error_tag(options={})
181 assert_tag({:attributes => { :id => 'errorExplanation' }}.merge(options))
181 assert_tag({:attributes => { :id => 'errorExplanation' }}.merge(options))
182 end
182 end
183
183
184 def assert_include(expected, s, message=nil)
184 def assert_include(expected, s, message=nil)
185 assert s.include?(expected), (message || "\"#{expected}\" not found in \"#{s}\"")
185 assert s.include?(expected), (message || "\"#{expected}\" not found in \"#{s}\"")
186 end
186 end
187
187
188 def assert_not_include(expected, s, message=nil)
188 def assert_not_include(expected, s, message=nil)
189 assert !s.include?(expected), (message || "\"#{expected}\" found in \"#{s}\"")
189 assert !s.include?(expected), (message || "\"#{expected}\" found in \"#{s}\"")
190 end
190 end
191
191
192 def assert_select_in(text, *args, &block)
192 def assert_select_in(text, *args, &block)
193 d = HTML::Document.new(CGI::unescapeHTML(String.new(text))).root
193 d = HTML::Document.new(CGI::unescapeHTML(String.new(text))).root
194 assert_select(d, *args, &block)
194 assert_select(d, *args, &block)
195 end
195 end
196
196
197 def assert_mail_body_match(expected, mail, message=nil)
197 def assert_mail_body_match(expected, mail, message=nil)
198 if expected.is_a?(String)
198 if expected.is_a?(String)
199 assert_include expected, mail_body(mail), message
199 assert_include expected, mail_body(mail), message
200 else
200 else
201 assert_match expected, mail_body(mail), message
201 assert_match expected, mail_body(mail), message
202 end
202 end
203 end
203 end
204
204
205 def assert_mail_body_no_match(expected, mail, message=nil)
205 def assert_mail_body_no_match(expected, mail, message=nil)
206 if expected.is_a?(String)
206 if expected.is_a?(String)
207 assert_not_include expected, mail_body(mail), message
207 assert_not_include expected, mail_body(mail), message
208 else
208 else
209 assert_no_match expected, mail_body(mail), message
209 assert_no_match expected, mail_body(mail), message
210 end
210 end
211 end
211 end
212
212
213 def mail_body(mail)
213 def mail_body(mail)
214 mail.parts.first.body.encoded
214 mail.parts.first.body.encoded
215 end
215 end
216
216
217 # awesome_nested_set new node lft and rgt value changed this refactor revision.
217 # awesome_nested_set new node lft and rgt value changed this refactor revision.
218 # https://github.com/collectiveidea/awesome_nested_set/commit/199fca9bb938e40200cd90714dc69247ef017c61
218 # https://github.com/collectiveidea/awesome_nested_set/commit/199fca9bb938e40200cd90714dc69247ef017c61
219 # The reason of behavior change is that "self.class.base_class.unscoped" was added to this line.
219 # The reason of behavior change is that "self.class.base_class.unscoped" was added to this line.
220 # https://github.com/collectiveidea/awesome_nested_set/commit/199fca9bb9#diff-f61b59a5e6319024e211b0ffdd0e4ef1R273
220 # https://github.com/collectiveidea/awesome_nested_set/commit/199fca9bb9#diff-f61b59a5e6319024e211b0ffdd0e4ef1R273
221 # It seems correct behavior because of this line comment.
221 # It seems correct behavior because of this line comment.
222 # https://github.com/collectiveidea/awesome_nested_set/blame/199fca9bb9/lib/awesome_nested_set/model.rb#L278
222 # https://github.com/collectiveidea/awesome_nested_set/blame/199fca9bb9/lib/awesome_nested_set/model.rb#L278
223 def new_issue_lft
223 def new_issue_lft
224 ::AwesomeNestedSet::VERSION > "2.1.6" ? Issue.maximum(:rgt) + 1 : 1
224 # ::AwesomeNestedSet::VERSION > "2.1.6" ? Issue.maximum(:rgt) + 1 : 1
225 Issue.maximum(:rgt) + 1
225 end
226 end
226 end
227 end
227
228
228 module Redmine
229 module Redmine
229 module ApiTest
230 module ApiTest
230 # Base class for API tests
231 # Base class for API tests
231 class Base < ActionDispatch::IntegrationTest
232 class Base < ActionDispatch::IntegrationTest
232 # Test that a request allows the three types of API authentication
233 # Test that a request allows the three types of API authentication
233 #
234 #
234 # * HTTP Basic with username and password
235 # * HTTP Basic with username and password
235 # * HTTP Basic with an api key for the username
236 # * HTTP Basic with an api key for the username
236 # * Key based with the key=X parameter
237 # * Key based with the key=X parameter
237 #
238 #
238 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
239 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
239 # @param [String] url the request url
240 # @param [String] url the request url
240 # @param [optional, Hash] parameters additional request parameters
241 # @param [optional, Hash] parameters additional request parameters
241 # @param [optional, Hash] options additional options
242 # @param [optional, Hash] options additional options
242 # @option options [Symbol] :success_code Successful response code (:success)
243 # @option options [Symbol] :success_code Successful response code (:success)
243 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
244 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
244 def self.should_allow_api_authentication(http_method, url, parameters={}, options={})
245 def self.should_allow_api_authentication(http_method, url, parameters={}, options={})
245 should_allow_http_basic_auth_with_username_and_password(http_method, url, parameters, options)
246 should_allow_http_basic_auth_with_username_and_password(http_method, url, parameters, options)
246 should_allow_http_basic_auth_with_key(http_method, url, parameters, options)
247 should_allow_http_basic_auth_with_key(http_method, url, parameters, options)
247 should_allow_key_based_auth(http_method, url, parameters, options)
248 should_allow_key_based_auth(http_method, url, parameters, options)
248 end
249 end
249
250
250 # Test that a request allows the username and password for HTTP BASIC
251 # Test that a request allows the username and password for HTTP BASIC
251 #
252 #
252 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
253 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
253 # @param [String] url the request url
254 # @param [String] url the request url
254 # @param [optional, Hash] parameters additional request parameters
255 # @param [optional, Hash] parameters additional request parameters
255 # @param [optional, Hash] options additional options
256 # @param [optional, Hash] options additional options
256 # @option options [Symbol] :success_code Successful response code (:success)
257 # @option options [Symbol] :success_code Successful response code (:success)
257 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
258 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
258 def self.should_allow_http_basic_auth_with_username_and_password(http_method, url, parameters={}, options={})
259 def self.should_allow_http_basic_auth_with_username_and_password(http_method, url, parameters={}, options={})
259 success_code = options[:success_code] || :success
260 success_code = options[:success_code] || :success
260 failure_code = options[:failure_code] || :unauthorized
261 failure_code = options[:failure_code] || :unauthorized
261
262
262 context "should allow http basic auth using a username and password for #{http_method} #{url}" do
263 context "should allow http basic auth using a username and password for #{http_method} #{url}" do
263 context "with a valid HTTP authentication" do
264 context "with a valid HTTP authentication" do
264 setup do
265 setup do
265 @user = User.generate! do |user|
266 @user = User.generate! do |user|
266 user.admin = true
267 user.admin = true
267 user.password = 'my_password'
268 user.password = 'my_password'
268 end
269 end
269 send(http_method, url, parameters, credentials(@user.login, 'my_password'))
270 send(http_method, url, parameters, credentials(@user.login, 'my_password'))
270 end
271 end
271
272
272 should_respond_with success_code
273 should_respond_with success_code
273 should_respond_with_content_type_based_on_url(url)
274 should_respond_with_content_type_based_on_url(url)
274 should "login as the user" do
275 should "login as the user" do
275 assert_equal @user, User.current
276 assert_equal @user, User.current
276 end
277 end
277 end
278 end
278
279
279 context "with an invalid HTTP authentication" do
280 context "with an invalid HTTP authentication" do
280 setup do
281 setup do
281 @user = User.generate!
282 @user = User.generate!
282 send(http_method, url, parameters, credentials(@user.login, 'wrong_password'))
283 send(http_method, url, parameters, credentials(@user.login, 'wrong_password'))
283 end
284 end
284
285
285 should_respond_with failure_code
286 should_respond_with failure_code
286 should_respond_with_content_type_based_on_url(url)
287 should_respond_with_content_type_based_on_url(url)
287 should "not login as the user" do
288 should "not login as the user" do
288 assert_equal User.anonymous, User.current
289 assert_equal User.anonymous, User.current
289 end
290 end
290 end
291 end
291
292
292 context "without credentials" do
293 context "without credentials" do
293 setup do
294 setup do
294 send(http_method, url, parameters)
295 send(http_method, url, parameters)
295 end
296 end
296
297
297 should_respond_with failure_code
298 should_respond_with failure_code
298 should_respond_with_content_type_based_on_url(url)
299 should_respond_with_content_type_based_on_url(url)
299 should "include_www_authenticate_header" do
300 should "include_www_authenticate_header" do
300 assert @controller.response.headers.has_key?('WWW-Authenticate')
301 assert @controller.response.headers.has_key?('WWW-Authenticate')
301 end
302 end
302 end
303 end
303 end
304 end
304 end
305 end
305
306
306 # Test that a request allows the API key with HTTP BASIC
307 # Test that a request allows the API key with HTTP BASIC
307 #
308 #
308 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
309 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
309 # @param [String] url the request url
310 # @param [String] url the request url
310 # @param [optional, Hash] parameters additional request parameters
311 # @param [optional, Hash] parameters additional request parameters
311 # @param [optional, Hash] options additional options
312 # @param [optional, Hash] options additional options
312 # @option options [Symbol] :success_code Successful response code (:success)
313 # @option options [Symbol] :success_code Successful response code (:success)
313 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
314 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
314 def self.should_allow_http_basic_auth_with_key(http_method, url, parameters={}, options={})
315 def self.should_allow_http_basic_auth_with_key(http_method, url, parameters={}, options={})
315 success_code = options[:success_code] || :success
316 success_code = options[:success_code] || :success
316 failure_code = options[:failure_code] || :unauthorized
317 failure_code = options[:failure_code] || :unauthorized
317
318
318 context "should allow http basic auth with a key for #{http_method} #{url}" do
319 context "should allow http basic auth with a key for #{http_method} #{url}" do
319 context "with a valid HTTP authentication using the API token" do
320 context "with a valid HTTP authentication using the API token" do
320 setup do
321 setup do
321 @user = User.generate! do |user|
322 @user = User.generate! do |user|
322 user.admin = true
323 user.admin = true
323 end
324 end
324 @token = Token.create!(:user => @user, :action => 'api')
325 @token = Token.create!(:user => @user, :action => 'api')
325 send(http_method, url, parameters, credentials(@token.value, 'X'))
326 send(http_method, url, parameters, credentials(@token.value, 'X'))
326 end
327 end
327 should_respond_with success_code
328 should_respond_with success_code
328 should_respond_with_content_type_based_on_url(url)
329 should_respond_with_content_type_based_on_url(url)
329 should_be_a_valid_response_string_based_on_url(url)
330 should_be_a_valid_response_string_based_on_url(url)
330 should "login as the user" do
331 should "login as the user" do
331 assert_equal @user, User.current
332 assert_equal @user, User.current
332 end
333 end
333 end
334 end
334
335
335 context "with an invalid HTTP authentication" do
336 context "with an invalid HTTP authentication" do
336 setup do
337 setup do
337 @user = User.generate!
338 @user = User.generate!
338 @token = Token.create!(:user => @user, :action => 'feeds')
339 @token = Token.create!(:user => @user, :action => 'feeds')
339 send(http_method, url, parameters, credentials(@token.value, 'X'))
340 send(http_method, url, parameters, credentials(@token.value, 'X'))
340 end
341 end
341 should_respond_with failure_code
342 should_respond_with failure_code
342 should_respond_with_content_type_based_on_url(url)
343 should_respond_with_content_type_based_on_url(url)
343 should "not login as the user" do
344 should "not login as the user" do
344 assert_equal User.anonymous, User.current
345 assert_equal User.anonymous, User.current
345 end
346 end
346 end
347 end
347 end
348 end
348 end
349 end
349
350
350 # Test that a request allows full key authentication
351 # Test that a request allows full key authentication
351 #
352 #
352 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
353 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
353 # @param [String] url the request url, without the key=ZXY parameter
354 # @param [String] url the request url, without the key=ZXY parameter
354 # @param [optional, Hash] parameters additional request parameters
355 # @param [optional, Hash] parameters additional request parameters
355 # @param [optional, Hash] options additional options
356 # @param [optional, Hash] options additional options
356 # @option options [Symbol] :success_code Successful response code (:success)
357 # @option options [Symbol] :success_code Successful response code (:success)
357 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
358 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
358 def self.should_allow_key_based_auth(http_method, url, parameters={}, options={})
359 def self.should_allow_key_based_auth(http_method, url, parameters={}, options={})
359 success_code = options[:success_code] || :success
360 success_code = options[:success_code] || :success
360 failure_code = options[:failure_code] || :unauthorized
361 failure_code = options[:failure_code] || :unauthorized
361
362
362 context "should allow key based auth using key=X for #{http_method} #{url}" do
363 context "should allow key based auth using key=X for #{http_method} #{url}" do
363 context "with a valid api token" do
364 context "with a valid api token" do
364 setup do
365 setup do
365 @user = User.generate! do |user|
366 @user = User.generate! do |user|
366 user.admin = true
367 user.admin = true
367 end
368 end
368 @token = Token.create!(:user => @user, :action => 'api')
369 @token = Token.create!(:user => @user, :action => 'api')
369 # Simple url parse to add on ?key= or &key=
370 # Simple url parse to add on ?key= or &key=
370 request_url = if url.match(/\?/)
371 request_url = if url.match(/\?/)
371 url + "&key=#{@token.value}"
372 url + "&key=#{@token.value}"
372 else
373 else
373 url + "?key=#{@token.value}"
374 url + "?key=#{@token.value}"
374 end
375 end
375 send(http_method, request_url, parameters)
376 send(http_method, request_url, parameters)
376 end
377 end
377 should_respond_with success_code
378 should_respond_with success_code
378 should_respond_with_content_type_based_on_url(url)
379 should_respond_with_content_type_based_on_url(url)
379 should_be_a_valid_response_string_based_on_url(url)
380 should_be_a_valid_response_string_based_on_url(url)
380 should "login as the user" do
381 should "login as the user" do
381 assert_equal @user, User.current
382 assert_equal @user, User.current
382 end
383 end
383 end
384 end
384
385
385 context "with an invalid api token" do
386 context "with an invalid api token" do
386 setup do
387 setup do
387 @user = User.generate! do |user|
388 @user = User.generate! do |user|
388 user.admin = true
389 user.admin = true
389 end
390 end
390 @token = Token.create!(:user => @user, :action => 'feeds')
391 @token = Token.create!(:user => @user, :action => 'feeds')
391 # Simple url parse to add on ?key= or &key=
392 # Simple url parse to add on ?key= or &key=
392 request_url = if url.match(/\?/)
393 request_url = if url.match(/\?/)
393 url + "&key=#{@token.value}"
394 url + "&key=#{@token.value}"
394 else
395 else
395 url + "?key=#{@token.value}"
396 url + "?key=#{@token.value}"
396 end
397 end
397 send(http_method, request_url, parameters)
398 send(http_method, request_url, parameters)
398 end
399 end
399 should_respond_with failure_code
400 should_respond_with failure_code
400 should_respond_with_content_type_based_on_url(url)
401 should_respond_with_content_type_based_on_url(url)
401 should "not login as the user" do
402 should "not login as the user" do
402 assert_equal User.anonymous, User.current
403 assert_equal User.anonymous, User.current
403 end
404 end
404 end
405 end
405 end
406 end
406
407
407 context "should allow key based auth using X-Redmine-API-Key header for #{http_method} #{url}" do
408 context "should allow key based auth using X-Redmine-API-Key header for #{http_method} #{url}" do
408 setup do
409 setup do
409 @user = User.generate! do |user|
410 @user = User.generate! do |user|
410 user.admin = true
411 user.admin = true
411 end
412 end
412 @token = Token.create!(:user => @user, :action => 'api')
413 @token = Token.create!(:user => @user, :action => 'api')
413 send(http_method, url, parameters, {'X-Redmine-API-Key' => @token.value.to_s})
414 send(http_method, url, parameters, {'X-Redmine-API-Key' => @token.value.to_s})
414 end
415 end
415 should_respond_with success_code
416 should_respond_with success_code
416 should_respond_with_content_type_based_on_url(url)
417 should_respond_with_content_type_based_on_url(url)
417 should_be_a_valid_response_string_based_on_url(url)
418 should_be_a_valid_response_string_based_on_url(url)
418 should "login as the user" do
419 should "login as the user" do
419 assert_equal @user, User.current
420 assert_equal @user, User.current
420 end
421 end
421 end
422 end
422 end
423 end
423
424
424 # Uses should_respond_with_content_type based on what's in the url:
425 # Uses should_respond_with_content_type based on what's in the url:
425 #
426 #
426 # '/project/issues.xml' => should_respond_with_content_type :xml
427 # '/project/issues.xml' => should_respond_with_content_type :xml
427 # '/project/issues.json' => should_respond_with_content_type :json
428 # '/project/issues.json' => should_respond_with_content_type :json
428 #
429 #
429 # @param [String] url Request
430 # @param [String] url Request
430 def self.should_respond_with_content_type_based_on_url(url)
431 def self.should_respond_with_content_type_based_on_url(url)
431 case
432 case
432 when url.match(/xml/i)
433 when url.match(/xml/i)
433 should "respond with XML" do
434 should "respond with XML" do
434 assert_equal 'application/xml', @response.content_type
435 assert_equal 'application/xml', @response.content_type
435 end
436 end
436 when url.match(/json/i)
437 when url.match(/json/i)
437 should "respond with JSON" do
438 should "respond with JSON" do
438 assert_equal 'application/json', @response.content_type
439 assert_equal 'application/json', @response.content_type
439 end
440 end
440 else
441 else
441 raise "Unknown content type for should_respond_with_content_type_based_on_url: #{url}"
442 raise "Unknown content type for should_respond_with_content_type_based_on_url: #{url}"
442 end
443 end
443 end
444 end
444
445
445 # Uses the url to assert which format the response should be in
446 # Uses the url to assert which format the response should be in
446 #
447 #
447 # '/project/issues.xml' => should_be_a_valid_xml_string
448 # '/project/issues.xml' => should_be_a_valid_xml_string
448 # '/project/issues.json' => should_be_a_valid_json_string
449 # '/project/issues.json' => should_be_a_valid_json_string
449 #
450 #
450 # @param [String] url Request
451 # @param [String] url Request
451 def self.should_be_a_valid_response_string_based_on_url(url)
452 def self.should_be_a_valid_response_string_based_on_url(url)
452 case
453 case
453 when url.match(/xml/i)
454 when url.match(/xml/i)
454 should_be_a_valid_xml_string
455 should_be_a_valid_xml_string
455 when url.match(/json/i)
456 when url.match(/json/i)
456 should_be_a_valid_json_string
457 should_be_a_valid_json_string
457 else
458 else
458 raise "Unknown content type for should_be_a_valid_response_based_on_url: #{url}"
459 raise "Unknown content type for should_be_a_valid_response_based_on_url: #{url}"
459 end
460 end
460 end
461 end
461
462
462 # Checks that the response is a valid JSON string
463 # Checks that the response is a valid JSON string
463 def self.should_be_a_valid_json_string
464 def self.should_be_a_valid_json_string
464 should "be a valid JSON string (or empty)" do
465 should "be a valid JSON string (or empty)" do
465 assert(response.body.blank? || ActiveSupport::JSON.decode(response.body))
466 assert(response.body.blank? || ActiveSupport::JSON.decode(response.body))
466 end
467 end
467 end
468 end
468
469
469 # Checks that the response is a valid XML string
470 # Checks that the response is a valid XML string
470 def self.should_be_a_valid_xml_string
471 def self.should_be_a_valid_xml_string
471 should "be a valid XML string" do
472 should "be a valid XML string" do
472 assert REXML::Document.new(response.body)
473 assert REXML::Document.new(response.body)
473 end
474 end
474 end
475 end
475
476
476 def self.should_respond_with(status)
477 def self.should_respond_with(status)
477 should "respond with #{status}" do
478 should "respond with #{status}" do
478 assert_response status
479 assert_response status
479 end
480 end
480 end
481 end
481 end
482 end
482 end
483 end
483 end
484 end
484
485
485 # URL helpers do not work with config.threadsafe!
486 # URL helpers do not work with config.threadsafe!
486 # https://github.com/rspec/rspec-rails/issues/476#issuecomment-4705454
487 # https://github.com/rspec/rspec-rails/issues/476#issuecomment-4705454
487 ActionView::TestCase::TestController.instance_eval do
488 ActionView::TestCase::TestController.instance_eval do
488 helper Rails.application.routes.url_helpers
489 helper Rails.application.routes.url_helpers
489 end
490 end
490 ActionView::TestCase::TestController.class_eval do
491 ActionView::TestCase::TestController.class_eval do
491 def _routes
492 def _routes
492 Rails.application.routes
493 Rails.application.routes
493 end
494 end
494 end
495 end
General Comments 0
You need to be logged in to leave comments. Login now