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