##// END OF EJS Templates
Code cleanup....
Jean-Philippe Lang -
r8078:cd0113a2875f
parent child
Show More
@@ -1,983 +1,982
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 belongs_to :project
21 belongs_to :project
22 belongs_to :tracker
22 belongs_to :tracker
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29
29
30 has_many :journals, :as => :journalized, :dependent => :destroy
30 has_many :journals, :as => :journalized, :dependent => :destroy
31 has_many :time_entries, :dependent => :delete_all
31 has_many :time_entries, :dependent => :delete_all
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33
33
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36
36
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
39 acts_as_customizable
39 acts_as_customizable
40 acts_as_watchable
40 acts_as_watchable
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 :include => [:project, :journals],
42 :include => [:project, :journals],
43 # sort by id so that limited eager loading doesn't break with postgresql
43 # sort by id so that limited eager loading doesn't break with postgresql
44 :order_column => "#{table_name}.id"
44 :order_column => "#{table_name}.id"
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48
48
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 :author_key => :author_id
50 :author_key => :author_id
51
51
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53
53
54 attr_reader :current_journal
54 attr_reader :current_journal
55
55
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57
57
58 validates_length_of :subject, :maximum => 255
58 validates_length_of :subject, :maximum => 255
59 validates_inclusion_of :done_ratio, :in => 0..100
59 validates_inclusion_of :done_ratio, :in => 0..100
60 validates_numericality_of :estimated_hours, :allow_nil => true
60 validates_numericality_of :estimated_hours, :allow_nil => true
61 validate :validate_issue
61 validate :validate_issue
62
62
63 named_scope :visible, lambda {|*args| { :include => :project,
63 named_scope :visible, lambda {|*args| { :include => :project,
64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
65
65
66 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
66 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
67
67
68 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
68 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
69 named_scope :with_limit, lambda { |limit| { :limit => limit} }
69 named_scope :with_limit, lambda { |limit| { :limit => limit} }
70 named_scope :on_active_project, :include => [:status, :project, :tracker],
70 named_scope :on_active_project, :include => [:status, :project, :tracker],
71 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
71 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
72
72
73 named_scope :without_version, lambda {
73 named_scope :without_version, lambda {
74 {
74 {
75 :conditions => { :fixed_version_id => nil}
75 :conditions => { :fixed_version_id => nil}
76 }
76 }
77 }
77 }
78
78
79 named_scope :with_query, lambda {|query|
79 named_scope :with_query, lambda {|query|
80 {
80 {
81 :conditions => Query.merge_conditions(query.statement)
81 :conditions => Query.merge_conditions(query.statement)
82 }
82 }
83 }
83 }
84
84
85 before_create :default_assign
85 before_create :default_assign
86 before_save :close_duplicates, :update_done_ratio_from_issue_status
86 before_save :close_duplicates, :update_done_ratio_from_issue_status
87 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
87 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
88 after_destroy :update_parent_attributes
88 after_destroy :update_parent_attributes
89
89
90 # Returns a SQL conditions string used to find all issues visible by the specified user
90 # Returns a SQL conditions string used to find all issues visible by the specified user
91 def self.visible_condition(user, options={})
91 def self.visible_condition(user, options={})
92 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
92 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
93 case role.issues_visibility
93 case role.issues_visibility
94 when 'all'
94 when 'all'
95 nil
95 nil
96 when 'default'
96 when 'default'
97 user_ids = [user.id] + user.groups.map(&:id)
97 user_ids = [user.id] + user.groups.map(&:id)
98 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
98 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
99 when 'own'
99 when 'own'
100 user_ids = [user.id] + user.groups.map(&:id)
100 user_ids = [user.id] + user.groups.map(&:id)
101 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
101 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
102 else
102 else
103 '1=0'
103 '1=0'
104 end
104 end
105 end
105 end
106 end
106 end
107
107
108 # Returns true if usr or current user is allowed to view the issue
108 # Returns true if usr or current user is allowed to view the issue
109 def visible?(usr=nil)
109 def visible?(usr=nil)
110 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
110 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
111 case role.issues_visibility
111 case role.issues_visibility
112 when 'all'
112 when 'all'
113 true
113 true
114 when 'default'
114 when 'default'
115 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
115 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
116 when 'own'
116 when 'own'
117 self.author == user || user.is_or_belongs_to?(assigned_to)
117 self.author == user || user.is_or_belongs_to?(assigned_to)
118 else
118 else
119 false
119 false
120 end
120 end
121 end
121 end
122 end
122 end
123
123
124 def after_initialize
124 def after_initialize
125 if new_record?
125 if new_record?
126 # set default values for new records only
126 # set default values for new records only
127 self.status ||= IssueStatus.default
127 self.status ||= IssueStatus.default
128 self.priority ||= IssuePriority.default
128 self.priority ||= IssuePriority.default
129 end
129 end
130 end
130 end
131
131
132 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
132 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
133 def available_custom_fields
133 def available_custom_fields
134 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
134 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
135 end
135 end
136
136
137 def copy_from(arg)
137 def copy_from(arg)
138 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
138 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
139 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
139 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
140 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
140 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
141 self.status = issue.status
141 self.status = issue.status
142 self
142 self
143 end
143 end
144
144
145 # Moves/copies an issue to a new project and tracker
145 # Moves/copies an issue to a new project and tracker
146 # Returns the moved/copied issue on success, false on failure
146 # Returns the moved/copied issue on success, false on failure
147 def move_to_project(*args)
147 def move_to_project(*args)
148 ret = Issue.transaction do
148 ret = Issue.transaction do
149 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
149 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
150 end || false
150 end || false
151 end
151 end
152
152
153 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
153 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
154 options ||= {}
154 options ||= {}
155 issue = options[:copy] ? self.class.new.copy_from(self) : self
155 issue = options[:copy] ? self.class.new.copy_from(self) : self
156
156
157 if new_project && issue.project_id != new_project.id
157 if new_project && issue.project_id != new_project.id
158 # delete issue relations
158 # delete issue relations
159 unless Setting.cross_project_issue_relations?
159 unless Setting.cross_project_issue_relations?
160 issue.relations_from.clear
160 issue.relations_from.clear
161 issue.relations_to.clear
161 issue.relations_to.clear
162 end
162 end
163 # issue is moved to another project
163 # issue is moved to another project
164 # reassign to the category with same name if any
164 # reassign to the category with same name if any
165 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
165 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
166 issue.category = new_category
166 issue.category = new_category
167 # Keep the fixed_version if it's still valid in the new_project
167 # Keep the fixed_version if it's still valid in the new_project
168 unless new_project.shared_versions.include?(issue.fixed_version)
168 unless new_project.shared_versions.include?(issue.fixed_version)
169 issue.fixed_version = nil
169 issue.fixed_version = nil
170 end
170 end
171 issue.project = new_project
171 issue.project = new_project
172 if issue.parent && issue.parent.project_id != issue.project_id
172 if issue.parent && issue.parent.project_id != issue.project_id
173 issue.parent_issue_id = nil
173 issue.parent_issue_id = nil
174 end
174 end
175 end
175 end
176 if new_tracker
176 if new_tracker
177 issue.tracker = new_tracker
177 issue.tracker = new_tracker
178 issue.reset_custom_values!
178 issue.reset_custom_values!
179 end
179 end
180 if options[:copy]
180 if options[:copy]
181 issue.author = User.current
181 issue.author = User.current
182 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
182 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
183 issue.status = if options[:attributes] && options[:attributes][:status_id]
183 issue.status = if options[:attributes] && options[:attributes][:status_id]
184 IssueStatus.find_by_id(options[:attributes][:status_id])
184 IssueStatus.find_by_id(options[:attributes][:status_id])
185 else
185 else
186 self.status
186 self.status
187 end
187 end
188 end
188 end
189 # Allow bulk setting of attributes on the issue
189 # Allow bulk setting of attributes on the issue
190 if options[:attributes]
190 if options[:attributes]
191 issue.attributes = options[:attributes]
191 issue.attributes = options[:attributes]
192 end
192 end
193 if issue.save
193 if issue.save
194 if options[:copy]
194 if options[:copy]
195 if current_journal && current_journal.notes.present?
195 if current_journal && current_journal.notes.present?
196 issue.init_journal(current_journal.user, current_journal.notes)
196 issue.init_journal(current_journal.user, current_journal.notes)
197 issue.current_journal.notify = false
197 issue.current_journal.notify = false
198 issue.save
198 issue.save
199 end
199 end
200 else
200 else
201 # Manually update project_id on related time entries
201 # Manually update project_id on related time entries
202 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
202 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
203
203
204 issue.children.each do |child|
204 issue.children.each do |child|
205 unless child.move_to_project_without_transaction(new_project)
205 unless child.move_to_project_without_transaction(new_project)
206 # Move failed and transaction was rollback'd
206 # Move failed and transaction was rollback'd
207 return false
207 return false
208 end
208 end
209 end
209 end
210 end
210 end
211 else
211 else
212 return false
212 return false
213 end
213 end
214 issue
214 issue
215 end
215 end
216
216
217 def status_id=(sid)
217 def status_id=(sid)
218 self.status = nil
218 self.status = nil
219 write_attribute(:status_id, sid)
219 write_attribute(:status_id, sid)
220 end
220 end
221
221
222 def priority_id=(pid)
222 def priority_id=(pid)
223 self.priority = nil
223 self.priority = nil
224 write_attribute(:priority_id, pid)
224 write_attribute(:priority_id, pid)
225 end
225 end
226
226
227 def tracker_id=(tid)
227 def tracker_id=(tid)
228 self.tracker = nil
228 self.tracker = nil
229 result = write_attribute(:tracker_id, tid)
229 result = write_attribute(:tracker_id, tid)
230 @custom_field_values = nil
230 @custom_field_values = nil
231 result
231 result
232 end
232 end
233
233
234 def description=(arg)
234 def description=(arg)
235 if arg.is_a?(String)
235 if arg.is_a?(String)
236 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
236 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
237 end
237 end
238 write_attribute(:description, arg)
238 write_attribute(:description, arg)
239 end
239 end
240
240
241 # Overrides attributes= so that project and tracker get assigned first
241 # Overrides attributes= so that project and tracker get assigned first
242 def attributes_with_project_and_tracker_first=(new_attributes, *args)
242 def attributes_with_project_and_tracker_first=(new_attributes, *args)
243 return if new_attributes.nil?
243 return if new_attributes.nil?
244 attrs = new_attributes.dup
244 attrs = new_attributes.dup
245 attrs.stringify_keys!
245 attrs.stringify_keys!
246
246
247 %w(project project_id tracker tracker_id).each do |attr|
247 %w(project project_id tracker tracker_id).each do |attr|
248 if attrs.has_key?(attr)
248 if attrs.has_key?(attr)
249 send "#{attr}=", attrs.delete(attr)
249 send "#{attr}=", attrs.delete(attr)
250 end
250 end
251 end
251 end
252 send :attributes_without_project_and_tracker_first=, attrs, *args
252 send :attributes_without_project_and_tracker_first=, attrs, *args
253 end
253 end
254 # Do not redefine alias chain on reload (see #4838)
254 # Do not redefine alias chain on reload (see #4838)
255 alias_method_chain(:attributes=, :project_and_tracker_first) unless method_defined?(:attributes_without_project_and_tracker_first=)
255 alias_method_chain(:attributes=, :project_and_tracker_first) unless method_defined?(:attributes_without_project_and_tracker_first=)
256
256
257 def estimated_hours=(h)
257 def estimated_hours=(h)
258 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
258 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
259 end
259 end
260
260
261 safe_attributes 'tracker_id',
261 safe_attributes 'tracker_id',
262 'status_id',
262 'status_id',
263 'parent_issue_id',
264 'category_id',
263 'category_id',
265 'assigned_to_id',
264 'assigned_to_id',
266 'priority_id',
265 'priority_id',
267 'fixed_version_id',
266 'fixed_version_id',
268 'subject',
267 'subject',
269 'description',
268 'description',
270 'start_date',
269 'start_date',
271 'due_date',
270 'due_date',
272 'done_ratio',
271 'done_ratio',
273 'estimated_hours',
272 'estimated_hours',
274 'custom_field_values',
273 'custom_field_values',
275 'custom_fields',
274 'custom_fields',
276 'lock_version',
275 'lock_version',
277 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
276 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
278
277
279 safe_attributes 'status_id',
278 safe_attributes 'status_id',
280 'assigned_to_id',
279 'assigned_to_id',
281 'fixed_version_id',
280 'fixed_version_id',
282 'done_ratio',
281 'done_ratio',
283 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
282 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
284
283
285 safe_attributes 'watcher_user_ids',
284 safe_attributes 'watcher_user_ids',
286 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
285 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
287
286
288 safe_attributes 'is_private',
287 safe_attributes 'is_private',
289 :if => lambda {|issue, user|
288 :if => lambda {|issue, user|
290 user.allowed_to?(:set_issues_private, issue.project) ||
289 user.allowed_to?(:set_issues_private, issue.project) ||
291 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
290 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
292 }
291 }
293
292
293 safe_attributes 'parent_issue_id',
294 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
295 user.allowed_to?(:manage_subtasks, issue.project)}
296
294 # Safely sets attributes
297 # Safely sets attributes
295 # Should be called from controllers instead of #attributes=
298 # Should be called from controllers instead of #attributes=
296 # attr_accessible is too rough because we still want things like
299 # attr_accessible is too rough because we still want things like
297 # Issue.new(:project => foo) to work
300 # Issue.new(:project => foo) to work
298 # TODO: move workflow/permission checks from controllers to here
301 # TODO: move workflow/permission checks from controllers to here
299 def safe_attributes=(attrs, user=User.current)
302 def safe_attributes=(attrs, user=User.current)
300 return unless attrs.is_a?(Hash)
303 return unless attrs.is_a?(Hash)
301
304
302 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
305 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
303 attrs = delete_unsafe_attributes(attrs, user)
306 attrs = delete_unsafe_attributes(attrs, user)
304 return if attrs.empty?
307 return if attrs.empty?
305
308
306 # Tracker must be set before since new_statuses_allowed_to depends on it.
309 # Tracker must be set before since new_statuses_allowed_to depends on it.
307 if t = attrs.delete('tracker_id')
310 if t = attrs.delete('tracker_id')
308 self.tracker_id = t
311 self.tracker_id = t
309 end
312 end
310
313
311 if attrs['status_id']
314 if attrs['status_id']
312 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
315 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
313 attrs.delete('status_id')
316 attrs.delete('status_id')
314 end
317 end
315 end
318 end
316
319
317 unless leaf?
320 unless leaf?
318 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
321 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
319 end
322 end
320
323
321 if attrs.has_key?('parent_issue_id')
324 if attrs['parent_issue_id'].present?
322 if !user.allowed_to?(:manage_subtasks, project)
325 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
323 attrs.delete('parent_issue_id')
324 elsif !attrs['parent_issue_id'].blank?
325 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
326 end
327 end
326 end
328
327
329 # mass-assignment security bypass
328 # mass-assignment security bypass
330 self.send :attributes=, attrs, false
329 self.send :attributes=, attrs, false
331 end
330 end
332
331
333 def done_ratio
332 def done_ratio
334 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
333 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
335 status.default_done_ratio
334 status.default_done_ratio
336 else
335 else
337 read_attribute(:done_ratio)
336 read_attribute(:done_ratio)
338 end
337 end
339 end
338 end
340
339
341 def self.use_status_for_done_ratio?
340 def self.use_status_for_done_ratio?
342 Setting.issue_done_ratio == 'issue_status'
341 Setting.issue_done_ratio == 'issue_status'
343 end
342 end
344
343
345 def self.use_field_for_done_ratio?
344 def self.use_field_for_done_ratio?
346 Setting.issue_done_ratio == 'issue_field'
345 Setting.issue_done_ratio == 'issue_field'
347 end
346 end
348
347
349 def validate_issue
348 def validate_issue
350 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
349 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
351 errors.add :due_date, :not_a_date
350 errors.add :due_date, :not_a_date
352 end
351 end
353
352
354 if self.due_date and self.start_date and self.due_date < self.start_date
353 if self.due_date and self.start_date and self.due_date < self.start_date
355 errors.add :due_date, :greater_than_start_date
354 errors.add :due_date, :greater_than_start_date
356 end
355 end
357
356
358 if start_date && soonest_start && start_date < soonest_start
357 if start_date && soonest_start && start_date < soonest_start
359 errors.add :start_date, :invalid
358 errors.add :start_date, :invalid
360 end
359 end
361
360
362 if fixed_version
361 if fixed_version
363 if !assignable_versions.include?(fixed_version)
362 if !assignable_versions.include?(fixed_version)
364 errors.add :fixed_version_id, :inclusion
363 errors.add :fixed_version_id, :inclusion
365 elsif reopened? && fixed_version.closed?
364 elsif reopened? && fixed_version.closed?
366 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
365 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
367 end
366 end
368 end
367 end
369
368
370 # Checks that the issue can not be added/moved to a disabled tracker
369 # Checks that the issue can not be added/moved to a disabled tracker
371 if project && (tracker_id_changed? || project_id_changed?)
370 if project && (tracker_id_changed? || project_id_changed?)
372 unless project.trackers.include?(tracker)
371 unless project.trackers.include?(tracker)
373 errors.add :tracker_id, :inclusion
372 errors.add :tracker_id, :inclusion
374 end
373 end
375 end
374 end
376
375
377 # Checks parent issue assignment
376 # Checks parent issue assignment
378 if @parent_issue
377 if @parent_issue
379 if @parent_issue.project_id != project_id
378 if @parent_issue.project_id != project_id
380 errors.add :parent_issue_id, :not_same_project
379 errors.add :parent_issue_id, :not_same_project
381 elsif !new_record?
380 elsif !new_record?
382 # moving an existing issue
381 # moving an existing issue
383 if @parent_issue.root_id != root_id
382 if @parent_issue.root_id != root_id
384 # we can always move to another tree
383 # we can always move to another tree
385 elsif move_possible?(@parent_issue)
384 elsif move_possible?(@parent_issue)
386 # move accepted inside tree
385 # move accepted inside tree
387 else
386 else
388 errors.add :parent_issue_id, :not_a_valid_parent
387 errors.add :parent_issue_id, :not_a_valid_parent
389 end
388 end
390 end
389 end
391 end
390 end
392 end
391 end
393
392
394 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
393 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
395 # even if the user turns off the setting later
394 # even if the user turns off the setting later
396 def update_done_ratio_from_issue_status
395 def update_done_ratio_from_issue_status
397 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
396 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
398 self.done_ratio = status.default_done_ratio
397 self.done_ratio = status.default_done_ratio
399 end
398 end
400 end
399 end
401
400
402 def init_journal(user, notes = "")
401 def init_journal(user, notes = "")
403 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
402 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
404 @issue_before_change = self.clone
403 @issue_before_change = self.clone
405 @issue_before_change.status = self.status
404 @issue_before_change.status = self.status
406 @custom_values_before_change = {}
405 @custom_values_before_change = {}
407 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
406 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
408 # Make sure updated_on is updated when adding a note.
407 # Make sure updated_on is updated when adding a note.
409 updated_on_will_change!
408 updated_on_will_change!
410 @current_journal
409 @current_journal
411 end
410 end
412
411
413 # Return true if the issue is closed, otherwise false
412 # Return true if the issue is closed, otherwise false
414 def closed?
413 def closed?
415 self.status.is_closed?
414 self.status.is_closed?
416 end
415 end
417
416
418 # Return true if the issue is being reopened
417 # Return true if the issue is being reopened
419 def reopened?
418 def reopened?
420 if !new_record? && status_id_changed?
419 if !new_record? && status_id_changed?
421 status_was = IssueStatus.find_by_id(status_id_was)
420 status_was = IssueStatus.find_by_id(status_id_was)
422 status_new = IssueStatus.find_by_id(status_id)
421 status_new = IssueStatus.find_by_id(status_id)
423 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
422 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
424 return true
423 return true
425 end
424 end
426 end
425 end
427 false
426 false
428 end
427 end
429
428
430 # Return true if the issue is being closed
429 # Return true if the issue is being closed
431 def closing?
430 def closing?
432 if !new_record? && status_id_changed?
431 if !new_record? && status_id_changed?
433 status_was = IssueStatus.find_by_id(status_id_was)
432 status_was = IssueStatus.find_by_id(status_id_was)
434 status_new = IssueStatus.find_by_id(status_id)
433 status_new = IssueStatus.find_by_id(status_id)
435 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
434 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
436 return true
435 return true
437 end
436 end
438 end
437 end
439 false
438 false
440 end
439 end
441
440
442 # Returns true if the issue is overdue
441 # Returns true if the issue is overdue
443 def overdue?
442 def overdue?
444 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
443 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
445 end
444 end
446
445
447 # Is the amount of work done less than it should for the due date
446 # Is the amount of work done less than it should for the due date
448 def behind_schedule?
447 def behind_schedule?
449 return false if start_date.nil? || due_date.nil?
448 return false if start_date.nil? || due_date.nil?
450 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
449 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
451 return done_date <= Date.today
450 return done_date <= Date.today
452 end
451 end
453
452
454 # Does this issue have children?
453 # Does this issue have children?
455 def children?
454 def children?
456 !leaf?
455 !leaf?
457 end
456 end
458
457
459 # Users the issue can be assigned to
458 # Users the issue can be assigned to
460 def assignable_users
459 def assignable_users
461 users = project.assignable_users
460 users = project.assignable_users
462 users << author if author
461 users << author if author
463 users << assigned_to if assigned_to
462 users << assigned_to if assigned_to
464 users.uniq.sort
463 users.uniq.sort
465 end
464 end
466
465
467 # Versions that the issue can be assigned to
466 # Versions that the issue can be assigned to
468 def assignable_versions
467 def assignable_versions
469 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
468 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
470 end
469 end
471
470
472 # Returns true if this issue is blocked by another issue that is still open
471 # Returns true if this issue is blocked by another issue that is still open
473 def blocked?
472 def blocked?
474 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
473 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
475 end
474 end
476
475
477 # Returns an array of status that user is able to apply
476 # Returns an array of status that user is able to apply
478 def new_statuses_allowed_to(user, include_default=false)
477 def new_statuses_allowed_to(user, include_default=false)
479 statuses = status.find_new_statuses_allowed_to(
478 statuses = status.find_new_statuses_allowed_to(
480 user.roles_for_project(project),
479 user.roles_for_project(project),
481 tracker,
480 tracker,
482 author == user,
481 author == user,
483 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
482 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
484 )
483 )
485 statuses << status unless statuses.empty?
484 statuses << status unless statuses.empty?
486 statuses << IssueStatus.default if include_default
485 statuses << IssueStatus.default if include_default
487 statuses = statuses.uniq.sort
486 statuses = statuses.uniq.sort
488 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
487 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
489 end
488 end
490
489
491 # Returns the mail adresses of users that should be notified
490 # Returns the mail adresses of users that should be notified
492 def recipients
491 def recipients
493 notified = project.notified_users
492 notified = project.notified_users
494 # Author and assignee are always notified unless they have been
493 # Author and assignee are always notified unless they have been
495 # locked or don't want to be notified
494 # locked or don't want to be notified
496 notified << author if author && author.active? && author.notify_about?(self)
495 notified << author if author && author.active? && author.notify_about?(self)
497 if assigned_to
496 if assigned_to
498 if assigned_to.is_a?(Group)
497 if assigned_to.is_a?(Group)
499 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
498 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
500 else
499 else
501 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
500 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
502 end
501 end
503 end
502 end
504 notified.uniq!
503 notified.uniq!
505 # Remove users that can not view the issue
504 # Remove users that can not view the issue
506 notified.reject! {|user| !visible?(user)}
505 notified.reject! {|user| !visible?(user)}
507 notified.collect(&:mail)
506 notified.collect(&:mail)
508 end
507 end
509
508
510 # Returns the number of hours spent on this issue
509 # Returns the number of hours spent on this issue
511 def spent_hours
510 def spent_hours
512 @spent_hours ||= time_entries.sum(:hours) || 0
511 @spent_hours ||= time_entries.sum(:hours) || 0
513 end
512 end
514
513
515 # Returns the total number of hours spent on this issue and its descendants
514 # Returns the total number of hours spent on this issue and its descendants
516 #
515 #
517 # Example:
516 # Example:
518 # spent_hours => 0.0
517 # spent_hours => 0.0
519 # spent_hours => 50.2
518 # spent_hours => 50.2
520 def total_spent_hours
519 def total_spent_hours
521 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
520 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
522 end
521 end
523
522
524 def relations
523 def relations
525 @relations ||= (relations_from + relations_to).sort
524 @relations ||= (relations_from + relations_to).sort
526 end
525 end
527
526
528 # Preloads relations for a collection of issues
527 # Preloads relations for a collection of issues
529 def self.load_relations(issues)
528 def self.load_relations(issues)
530 if issues.any?
529 if issues.any?
531 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
530 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
532 issues.each do |issue|
531 issues.each do |issue|
533 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
532 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
534 end
533 end
535 end
534 end
536 end
535 end
537
536
538 # Preloads visible spent time for a collection of issues
537 # Preloads visible spent time for a collection of issues
539 def self.load_visible_spent_hours(issues, user=User.current)
538 def self.load_visible_spent_hours(issues, user=User.current)
540 if issues.any?
539 if issues.any?
541 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
540 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
542 issues.each do |issue|
541 issues.each do |issue|
543 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
542 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
544 end
543 end
545 end
544 end
546 end
545 end
547
546
548 # Finds an issue relation given its id.
547 # Finds an issue relation given its id.
549 def find_relation(relation_id)
548 def find_relation(relation_id)
550 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
549 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
551 end
550 end
552
551
553 def all_dependent_issues(except=[])
552 def all_dependent_issues(except=[])
554 except << self
553 except << self
555 dependencies = []
554 dependencies = []
556 relations_from.each do |relation|
555 relations_from.each do |relation|
557 if relation.issue_to && !except.include?(relation.issue_to)
556 if relation.issue_to && !except.include?(relation.issue_to)
558 dependencies << relation.issue_to
557 dependencies << relation.issue_to
559 dependencies += relation.issue_to.all_dependent_issues(except)
558 dependencies += relation.issue_to.all_dependent_issues(except)
560 end
559 end
561 end
560 end
562 dependencies
561 dependencies
563 end
562 end
564
563
565 # Returns an array of issues that duplicate this one
564 # Returns an array of issues that duplicate this one
566 def duplicates
565 def duplicates
567 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
566 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
568 end
567 end
569
568
570 # Returns the due date or the target due date if any
569 # Returns the due date or the target due date if any
571 # Used on gantt chart
570 # Used on gantt chart
572 def due_before
571 def due_before
573 due_date || (fixed_version ? fixed_version.effective_date : nil)
572 due_date || (fixed_version ? fixed_version.effective_date : nil)
574 end
573 end
575
574
576 # Returns the time scheduled for this issue.
575 # Returns the time scheduled for this issue.
577 #
576 #
578 # Example:
577 # Example:
579 # Start Date: 2/26/09, End Date: 3/04/09
578 # Start Date: 2/26/09, End Date: 3/04/09
580 # duration => 6
579 # duration => 6
581 def duration
580 def duration
582 (start_date && due_date) ? due_date - start_date : 0
581 (start_date && due_date) ? due_date - start_date : 0
583 end
582 end
584
583
585 def soonest_start
584 def soonest_start
586 @soonest_start ||= (
585 @soonest_start ||= (
587 relations_to.collect{|relation| relation.successor_soonest_start} +
586 relations_to.collect{|relation| relation.successor_soonest_start} +
588 ancestors.collect(&:soonest_start)
587 ancestors.collect(&:soonest_start)
589 ).compact.max
588 ).compact.max
590 end
589 end
591
590
592 def reschedule_after(date)
591 def reschedule_after(date)
593 return if date.nil?
592 return if date.nil?
594 if leaf?
593 if leaf?
595 if start_date.nil? || start_date < date
594 if start_date.nil? || start_date < date
596 self.start_date, self.due_date = date, date + duration
595 self.start_date, self.due_date = date, date + duration
597 save
596 save
598 end
597 end
599 else
598 else
600 leaves.each do |leaf|
599 leaves.each do |leaf|
601 leaf.reschedule_after(date)
600 leaf.reschedule_after(date)
602 end
601 end
603 end
602 end
604 end
603 end
605
604
606 def <=>(issue)
605 def <=>(issue)
607 if issue.nil?
606 if issue.nil?
608 -1
607 -1
609 elsif root_id != issue.root_id
608 elsif root_id != issue.root_id
610 (root_id || 0) <=> (issue.root_id || 0)
609 (root_id || 0) <=> (issue.root_id || 0)
611 else
610 else
612 (lft || 0) <=> (issue.lft || 0)
611 (lft || 0) <=> (issue.lft || 0)
613 end
612 end
614 end
613 end
615
614
616 def to_s
615 def to_s
617 "#{tracker} ##{id}: #{subject}"
616 "#{tracker} ##{id}: #{subject}"
618 end
617 end
619
618
620 # Returns a string of css classes that apply to the issue
619 # Returns a string of css classes that apply to the issue
621 def css_classes
620 def css_classes
622 s = "issue status-#{status.position} priority-#{priority.position}"
621 s = "issue status-#{status.position} priority-#{priority.position}"
623 s << ' closed' if closed?
622 s << ' closed' if closed?
624 s << ' overdue' if overdue?
623 s << ' overdue' if overdue?
625 s << ' child' if child?
624 s << ' child' if child?
626 s << ' parent' unless leaf?
625 s << ' parent' unless leaf?
627 s << ' private' if is_private?
626 s << ' private' if is_private?
628 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
627 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
629 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
628 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
630 s
629 s
631 end
630 end
632
631
633 # Saves an issue, time_entry, attachments, and a journal from the parameters
632 # Saves an issue, time_entry, attachments, and a journal from the parameters
634 # Returns false if save fails
633 # Returns false if save fails
635 def save_issue_with_child_records(params, existing_time_entry=nil)
634 def save_issue_with_child_records(params, existing_time_entry=nil)
636 Issue.transaction do
635 Issue.transaction do
637 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
636 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
638 @time_entry = existing_time_entry || TimeEntry.new
637 @time_entry = existing_time_entry || TimeEntry.new
639 @time_entry.project = project
638 @time_entry.project = project
640 @time_entry.issue = self
639 @time_entry.issue = self
641 @time_entry.user = User.current
640 @time_entry.user = User.current
642 @time_entry.spent_on = User.current.today
641 @time_entry.spent_on = User.current.today
643 @time_entry.attributes = params[:time_entry]
642 @time_entry.attributes = params[:time_entry]
644 self.time_entries << @time_entry
643 self.time_entries << @time_entry
645 end
644 end
646
645
647 if valid?
646 if valid?
648 attachments = Attachment.attach_files(self, params[:attachments])
647 attachments = Attachment.attach_files(self, params[:attachments])
649 # TODO: Rename hook
648 # TODO: Rename hook
650 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
649 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
651 begin
650 begin
652 if save
651 if save
653 # TODO: Rename hook
652 # TODO: Rename hook
654 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
653 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
655 else
654 else
656 raise ActiveRecord::Rollback
655 raise ActiveRecord::Rollback
657 end
656 end
658 rescue ActiveRecord::StaleObjectError
657 rescue ActiveRecord::StaleObjectError
659 attachments[:files].each(&:destroy)
658 attachments[:files].each(&:destroy)
660 errors.add :base, l(:notice_locking_conflict)
659 errors.add :base, l(:notice_locking_conflict)
661 raise ActiveRecord::Rollback
660 raise ActiveRecord::Rollback
662 end
661 end
663 end
662 end
664 end
663 end
665 end
664 end
666
665
667 # Unassigns issues from +version+ if it's no longer shared with issue's project
666 # Unassigns issues from +version+ if it's no longer shared with issue's project
668 def self.update_versions_from_sharing_change(version)
667 def self.update_versions_from_sharing_change(version)
669 # Update issues assigned to the version
668 # Update issues assigned to the version
670 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
669 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
671 end
670 end
672
671
673 # Unassigns issues from versions that are no longer shared
672 # Unassigns issues from versions that are no longer shared
674 # after +project+ was moved
673 # after +project+ was moved
675 def self.update_versions_from_hierarchy_change(project)
674 def self.update_versions_from_hierarchy_change(project)
676 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
675 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
677 # Update issues of the moved projects and issues assigned to a version of a moved project
676 # Update issues of the moved projects and issues assigned to a version of a moved project
678 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
677 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
679 end
678 end
680
679
681 def parent_issue_id=(arg)
680 def parent_issue_id=(arg)
682 parent_issue_id = arg.blank? ? nil : arg.to_i
681 parent_issue_id = arg.blank? ? nil : arg.to_i
683 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
682 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
684 @parent_issue.id
683 @parent_issue.id
685 else
684 else
686 @parent_issue = nil
685 @parent_issue = nil
687 nil
686 nil
688 end
687 end
689 end
688 end
690
689
691 def parent_issue_id
690 def parent_issue_id
692 if instance_variable_defined? :@parent_issue
691 if instance_variable_defined? :@parent_issue
693 @parent_issue.nil? ? nil : @parent_issue.id
692 @parent_issue.nil? ? nil : @parent_issue.id
694 else
693 else
695 parent_id
694 parent_id
696 end
695 end
697 end
696 end
698
697
699 # Extracted from the ReportsController.
698 # Extracted from the ReportsController.
700 def self.by_tracker(project)
699 def self.by_tracker(project)
701 count_and_group_by(:project => project,
700 count_and_group_by(:project => project,
702 :field => 'tracker_id',
701 :field => 'tracker_id',
703 :joins => Tracker.table_name)
702 :joins => Tracker.table_name)
704 end
703 end
705
704
706 def self.by_version(project)
705 def self.by_version(project)
707 count_and_group_by(:project => project,
706 count_and_group_by(:project => project,
708 :field => 'fixed_version_id',
707 :field => 'fixed_version_id',
709 :joins => Version.table_name)
708 :joins => Version.table_name)
710 end
709 end
711
710
712 def self.by_priority(project)
711 def self.by_priority(project)
713 count_and_group_by(:project => project,
712 count_and_group_by(:project => project,
714 :field => 'priority_id',
713 :field => 'priority_id',
715 :joins => IssuePriority.table_name)
714 :joins => IssuePriority.table_name)
716 end
715 end
717
716
718 def self.by_category(project)
717 def self.by_category(project)
719 count_and_group_by(:project => project,
718 count_and_group_by(:project => project,
720 :field => 'category_id',
719 :field => 'category_id',
721 :joins => IssueCategory.table_name)
720 :joins => IssueCategory.table_name)
722 end
721 end
723
722
724 def self.by_assigned_to(project)
723 def self.by_assigned_to(project)
725 count_and_group_by(:project => project,
724 count_and_group_by(:project => project,
726 :field => 'assigned_to_id',
725 :field => 'assigned_to_id',
727 :joins => User.table_name)
726 :joins => User.table_name)
728 end
727 end
729
728
730 def self.by_author(project)
729 def self.by_author(project)
731 count_and_group_by(:project => project,
730 count_and_group_by(:project => project,
732 :field => 'author_id',
731 :field => 'author_id',
733 :joins => User.table_name)
732 :joins => User.table_name)
734 end
733 end
735
734
736 def self.by_subproject(project)
735 def self.by_subproject(project)
737 ActiveRecord::Base.connection.select_all("select s.id as status_id,
736 ActiveRecord::Base.connection.select_all("select s.id as status_id,
738 s.is_closed as closed,
737 s.is_closed as closed,
739 #{Issue.table_name}.project_id as project_id,
738 #{Issue.table_name}.project_id as project_id,
740 count(#{Issue.table_name}.id) as total
739 count(#{Issue.table_name}.id) as total
741 from
740 from
742 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
741 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
743 where
742 where
744 #{Issue.table_name}.status_id=s.id
743 #{Issue.table_name}.status_id=s.id
745 and #{Issue.table_name}.project_id = #{Project.table_name}.id
744 and #{Issue.table_name}.project_id = #{Project.table_name}.id
746 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
745 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
747 and #{Issue.table_name}.project_id <> #{project.id}
746 and #{Issue.table_name}.project_id <> #{project.id}
748 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
747 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
749 end
748 end
750 # End ReportsController extraction
749 # End ReportsController extraction
751
750
752 # Returns an array of projects that current user can move issues to
751 # Returns an array of projects that current user can move issues to
753 def self.allowed_target_projects_on_move
752 def self.allowed_target_projects_on_move
754 projects = []
753 projects = []
755 if User.current.admin?
754 if User.current.admin?
756 # admin is allowed to move issues to any active (visible) project
755 # admin is allowed to move issues to any active (visible) project
757 projects = Project.visible.all
756 projects = Project.visible.all
758 elsif User.current.logged?
757 elsif User.current.logged?
759 if Role.non_member.allowed_to?(:move_issues)
758 if Role.non_member.allowed_to?(:move_issues)
760 projects = Project.visible.all
759 projects = Project.visible.all
761 else
760 else
762 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
761 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
763 end
762 end
764 end
763 end
765 projects
764 projects
766 end
765 end
767
766
768 private
767 private
769
768
770 def update_nested_set_attributes
769 def update_nested_set_attributes
771 if root_id.nil?
770 if root_id.nil?
772 # issue was just created
771 # issue was just created
773 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
772 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
774 set_default_left_and_right
773 set_default_left_and_right
775 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
774 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
776 if @parent_issue
775 if @parent_issue
777 move_to_child_of(@parent_issue)
776 move_to_child_of(@parent_issue)
778 end
777 end
779 reload
778 reload
780 elsif parent_issue_id != parent_id
779 elsif parent_issue_id != parent_id
781 former_parent_id = parent_id
780 former_parent_id = parent_id
782 # moving an existing issue
781 # moving an existing issue
783 if @parent_issue && @parent_issue.root_id == root_id
782 if @parent_issue && @parent_issue.root_id == root_id
784 # inside the same tree
783 # inside the same tree
785 move_to_child_of(@parent_issue)
784 move_to_child_of(@parent_issue)
786 else
785 else
787 # to another tree
786 # to another tree
788 unless root?
787 unless root?
789 move_to_right_of(root)
788 move_to_right_of(root)
790 reload
789 reload
791 end
790 end
792 old_root_id = root_id
791 old_root_id = root_id
793 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
792 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
794 target_maxright = nested_set_scope.maximum(right_column_name) || 0
793 target_maxright = nested_set_scope.maximum(right_column_name) || 0
795 offset = target_maxright + 1 - lft
794 offset = target_maxright + 1 - lft
796 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
795 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
797 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
796 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
798 self[left_column_name] = lft + offset
797 self[left_column_name] = lft + offset
799 self[right_column_name] = rgt + offset
798 self[right_column_name] = rgt + offset
800 if @parent_issue
799 if @parent_issue
801 move_to_child_of(@parent_issue)
800 move_to_child_of(@parent_issue)
802 end
801 end
803 end
802 end
804 reload
803 reload
805 # delete invalid relations of all descendants
804 # delete invalid relations of all descendants
806 self_and_descendants.each do |issue|
805 self_and_descendants.each do |issue|
807 issue.relations.each do |relation|
806 issue.relations.each do |relation|
808 relation.destroy unless relation.valid?
807 relation.destroy unless relation.valid?
809 end
808 end
810 end
809 end
811 # update former parent
810 # update former parent
812 recalculate_attributes_for(former_parent_id) if former_parent_id
811 recalculate_attributes_for(former_parent_id) if former_parent_id
813 end
812 end
814 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
813 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
815 end
814 end
816
815
817 def update_parent_attributes
816 def update_parent_attributes
818 recalculate_attributes_for(parent_id) if parent_id
817 recalculate_attributes_for(parent_id) if parent_id
819 end
818 end
820
819
821 def recalculate_attributes_for(issue_id)
820 def recalculate_attributes_for(issue_id)
822 if issue_id && p = Issue.find_by_id(issue_id)
821 if issue_id && p = Issue.find_by_id(issue_id)
823 # priority = highest priority of children
822 # priority = highest priority of children
824 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
823 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
825 p.priority = IssuePriority.find_by_position(priority_position)
824 p.priority = IssuePriority.find_by_position(priority_position)
826 end
825 end
827
826
828 # start/due dates = lowest/highest dates of children
827 # start/due dates = lowest/highest dates of children
829 p.start_date = p.children.minimum(:start_date)
828 p.start_date = p.children.minimum(:start_date)
830 p.due_date = p.children.maximum(:due_date)
829 p.due_date = p.children.maximum(:due_date)
831 if p.start_date && p.due_date && p.due_date < p.start_date
830 if p.start_date && p.due_date && p.due_date < p.start_date
832 p.start_date, p.due_date = p.due_date, p.start_date
831 p.start_date, p.due_date = p.due_date, p.start_date
833 end
832 end
834
833
835 # done ratio = weighted average ratio of leaves
834 # done ratio = weighted average ratio of leaves
836 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
835 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
837 leaves_count = p.leaves.count
836 leaves_count = p.leaves.count
838 if leaves_count > 0
837 if leaves_count > 0
839 average = p.leaves.average(:estimated_hours).to_f
838 average = p.leaves.average(:estimated_hours).to_f
840 if average == 0
839 if average == 0
841 average = 1
840 average = 1
842 end
841 end
843 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
842 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
844 progress = done / (average * leaves_count)
843 progress = done / (average * leaves_count)
845 p.done_ratio = progress.round
844 p.done_ratio = progress.round
846 end
845 end
847 end
846 end
848
847
849 # estimate = sum of leaves estimates
848 # estimate = sum of leaves estimates
850 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
849 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
851 p.estimated_hours = nil if p.estimated_hours == 0.0
850 p.estimated_hours = nil if p.estimated_hours == 0.0
852
851
853 # ancestors will be recursively updated
852 # ancestors will be recursively updated
854 p.save(false)
853 p.save(false)
855 end
854 end
856 end
855 end
857
856
858 # Update issues so their versions are not pointing to a
857 # Update issues so their versions are not pointing to a
859 # fixed_version that is not shared with the issue's project
858 # fixed_version that is not shared with the issue's project
860 def self.update_versions(conditions=nil)
859 def self.update_versions(conditions=nil)
861 # Only need to update issues with a fixed_version from
860 # Only need to update issues with a fixed_version from
862 # a different project and that is not systemwide shared
861 # a different project and that is not systemwide shared
863 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
862 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
864 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
863 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
865 " AND #{Version.table_name}.sharing <> 'system'",
864 " AND #{Version.table_name}.sharing <> 'system'",
866 conditions),
865 conditions),
867 :include => [:project, :fixed_version]
866 :include => [:project, :fixed_version]
868 ).each do |issue|
867 ).each do |issue|
869 next if issue.project.nil? || issue.fixed_version.nil?
868 next if issue.project.nil? || issue.fixed_version.nil?
870 unless issue.project.shared_versions.include?(issue.fixed_version)
869 unless issue.project.shared_versions.include?(issue.fixed_version)
871 issue.init_journal(User.current)
870 issue.init_journal(User.current)
872 issue.fixed_version = nil
871 issue.fixed_version = nil
873 issue.save
872 issue.save
874 end
873 end
875 end
874 end
876 end
875 end
877
876
878 # Callback on attachment deletion
877 # Callback on attachment deletion
879 def attachment_added(obj)
878 def attachment_added(obj)
880 if @current_journal && !obj.new_record?
879 if @current_journal && !obj.new_record?
881 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
880 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
882 end
881 end
883 end
882 end
884
883
885 # Callback on attachment deletion
884 # Callback on attachment deletion
886 def attachment_removed(obj)
885 def attachment_removed(obj)
887 journal = init_journal(User.current)
886 journal = init_journal(User.current)
888 journal.details << JournalDetail.new(:property => 'attachment',
887 journal.details << JournalDetail.new(:property => 'attachment',
889 :prop_key => obj.id,
888 :prop_key => obj.id,
890 :old_value => obj.filename)
889 :old_value => obj.filename)
891 journal.save
890 journal.save
892 end
891 end
893
892
894 # Default assignment based on category
893 # Default assignment based on category
895 def default_assign
894 def default_assign
896 if assigned_to.nil? && category && category.assigned_to
895 if assigned_to.nil? && category && category.assigned_to
897 self.assigned_to = category.assigned_to
896 self.assigned_to = category.assigned_to
898 end
897 end
899 end
898 end
900
899
901 # Updates start/due dates of following issues
900 # Updates start/due dates of following issues
902 def reschedule_following_issues
901 def reschedule_following_issues
903 if start_date_changed? || due_date_changed?
902 if start_date_changed? || due_date_changed?
904 relations_from.each do |relation|
903 relations_from.each do |relation|
905 relation.set_issue_to_dates
904 relation.set_issue_to_dates
906 end
905 end
907 end
906 end
908 end
907 end
909
908
910 # Closes duplicates if the issue is being closed
909 # Closes duplicates if the issue is being closed
911 def close_duplicates
910 def close_duplicates
912 if closing?
911 if closing?
913 duplicates.each do |duplicate|
912 duplicates.each do |duplicate|
914 # Reload is need in case the duplicate was updated by a previous duplicate
913 # Reload is need in case the duplicate was updated by a previous duplicate
915 duplicate.reload
914 duplicate.reload
916 # Don't re-close it if it's already closed
915 # Don't re-close it if it's already closed
917 next if duplicate.closed?
916 next if duplicate.closed?
918 # Same user and notes
917 # Same user and notes
919 if @current_journal
918 if @current_journal
920 duplicate.init_journal(@current_journal.user, @current_journal.notes)
919 duplicate.init_journal(@current_journal.user, @current_journal.notes)
921 end
920 end
922 duplicate.update_attribute :status, self.status
921 duplicate.update_attribute :status, self.status
923 end
922 end
924 end
923 end
925 end
924 end
926
925
927 # Saves the changes in a Journal
926 # Saves the changes in a Journal
928 # Called after_save
927 # Called after_save
929 def create_journal
928 def create_journal
930 if @current_journal
929 if @current_journal
931 # attributes changes
930 # attributes changes
932 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
931 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
933 before = @issue_before_change.send(c)
932 before = @issue_before_change.send(c)
934 after = send(c)
933 after = send(c)
935 next if before == after || (before.blank? && after.blank?)
934 next if before == after || (before.blank? && after.blank?)
936 @current_journal.details << JournalDetail.new(:property => 'attr',
935 @current_journal.details << JournalDetail.new(:property => 'attr',
937 :prop_key => c,
936 :prop_key => c,
938 :old_value => @issue_before_change.send(c),
937 :old_value => @issue_before_change.send(c),
939 :value => send(c))
938 :value => send(c))
940 }
939 }
941 # custom fields changes
940 # custom fields changes
942 custom_values.each {|c|
941 custom_values.each {|c|
943 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
942 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
944 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
943 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
945 @current_journal.details << JournalDetail.new(:property => 'cf',
944 @current_journal.details << JournalDetail.new(:property => 'cf',
946 :prop_key => c.custom_field_id,
945 :prop_key => c.custom_field_id,
947 :old_value => @custom_values_before_change[c.custom_field_id],
946 :old_value => @custom_values_before_change[c.custom_field_id],
948 :value => c.value)
947 :value => c.value)
949 }
948 }
950 @current_journal.save
949 @current_journal.save
951 # reset current journal
950 # reset current journal
952 init_journal @current_journal.user, @current_journal.notes
951 init_journal @current_journal.user, @current_journal.notes
953 end
952 end
954 end
953 end
955
954
956 # Query generator for selecting groups of issue counts for a project
955 # Query generator for selecting groups of issue counts for a project
957 # based on specific criteria
956 # based on specific criteria
958 #
957 #
959 # Options
958 # Options
960 # * project - Project to search in.
959 # * project - Project to search in.
961 # * field - String. Issue field to key off of in the grouping.
960 # * field - String. Issue field to key off of in the grouping.
962 # * joins - String. The table name to join against.
961 # * joins - String. The table name to join against.
963 def self.count_and_group_by(options)
962 def self.count_and_group_by(options)
964 project = options.delete(:project)
963 project = options.delete(:project)
965 select_field = options.delete(:field)
964 select_field = options.delete(:field)
966 joins = options.delete(:joins)
965 joins = options.delete(:joins)
967
966
968 where = "#{Issue.table_name}.#{select_field}=j.id"
967 where = "#{Issue.table_name}.#{select_field}=j.id"
969
968
970 ActiveRecord::Base.connection.select_all("select s.id as status_id,
969 ActiveRecord::Base.connection.select_all("select s.id as status_id,
971 s.is_closed as closed,
970 s.is_closed as closed,
972 j.id as #{select_field},
971 j.id as #{select_field},
973 count(#{Issue.table_name}.id) as total
972 count(#{Issue.table_name}.id) as total
974 from
973 from
975 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
974 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
976 where
975 where
977 #{Issue.table_name}.status_id=s.id
976 #{Issue.table_name}.status_id=s.id
978 and #{where}
977 and #{where}
979 and #{Issue.table_name}.project_id=#{Project.table_name}.id
978 and #{Issue.table_name}.project_id=#{Project.table_name}.id
980 and #{visible_condition(User.current, :project => project)}
979 and #{visible_condition(User.current, :project => project)}
981 group by s.id, s.is_closed, j.id")
980 group by s.id, s.is_closed, j.id")
982 end
981 end
983 end
982 end
General Comments 0
You need to be logged in to leave comments. Login now