##// END OF EJS Templates
Rails3: model: replace deprecated errors.add_to_base at validate_issue of Issue...
Toshi MARUYAMA -
r7470:41a6c8be6529
parent child
Show More
@@ -1,960 +1,960
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}))"
98 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids}))"
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}))"
101 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids}))"
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 tracker_id gets assigned first
241 # Overrides attributes= so that tracker_id gets assigned first
242 def attributes_with_tracker_first=(new_attributes, *args)
242 def attributes_with_tracker_first=(new_attributes, *args)
243 return if new_attributes.nil?
243 return if new_attributes.nil?
244 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
244 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
245 if new_tracker_id
245 if new_tracker_id
246 self.tracker_id = new_tracker_id
246 self.tracker_id = new_tracker_id
247 end
247 end
248 send :attributes_without_tracker_first=, new_attributes, *args
248 send :attributes_without_tracker_first=, new_attributes, *args
249 end
249 end
250 # Do not redefine alias chain on reload (see #4838)
250 # Do not redefine alias chain on reload (see #4838)
251 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
251 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
252
252
253 def estimated_hours=(h)
253 def estimated_hours=(h)
254 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
254 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
255 end
255 end
256
256
257 safe_attributes 'tracker_id',
257 safe_attributes 'tracker_id',
258 'status_id',
258 'status_id',
259 'parent_issue_id',
259 'parent_issue_id',
260 'category_id',
260 'category_id',
261 'assigned_to_id',
261 'assigned_to_id',
262 'priority_id',
262 'priority_id',
263 'fixed_version_id',
263 'fixed_version_id',
264 'subject',
264 'subject',
265 'description',
265 'description',
266 'start_date',
266 'start_date',
267 'due_date',
267 'due_date',
268 'done_ratio',
268 'done_ratio',
269 'estimated_hours',
269 'estimated_hours',
270 'custom_field_values',
270 'custom_field_values',
271 'custom_fields',
271 'custom_fields',
272 'lock_version',
272 'lock_version',
273 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
273 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
274
274
275 safe_attributes 'status_id',
275 safe_attributes 'status_id',
276 'assigned_to_id',
276 'assigned_to_id',
277 'fixed_version_id',
277 'fixed_version_id',
278 'done_ratio',
278 'done_ratio',
279 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
279 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
280
280
281 safe_attributes 'is_private',
281 safe_attributes 'is_private',
282 :if => lambda {|issue, user|
282 :if => lambda {|issue, user|
283 user.allowed_to?(:set_issues_private, issue.project) ||
283 user.allowed_to?(:set_issues_private, issue.project) ||
284 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
284 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
285 }
285 }
286
286
287 # Safely sets attributes
287 # Safely sets attributes
288 # Should be called from controllers instead of #attributes=
288 # Should be called from controllers instead of #attributes=
289 # attr_accessible is too rough because we still want things like
289 # attr_accessible is too rough because we still want things like
290 # Issue.new(:project => foo) to work
290 # Issue.new(:project => foo) to work
291 # TODO: move workflow/permission checks from controllers to here
291 # TODO: move workflow/permission checks from controllers to here
292 def safe_attributes=(attrs, user=User.current)
292 def safe_attributes=(attrs, user=User.current)
293 return unless attrs.is_a?(Hash)
293 return unless attrs.is_a?(Hash)
294
294
295 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
295 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
296 attrs = delete_unsafe_attributes(attrs, user)
296 attrs = delete_unsafe_attributes(attrs, user)
297 return if attrs.empty?
297 return if attrs.empty?
298
298
299 # Tracker must be set before since new_statuses_allowed_to depends on it.
299 # Tracker must be set before since new_statuses_allowed_to depends on it.
300 if t = attrs.delete('tracker_id')
300 if t = attrs.delete('tracker_id')
301 self.tracker_id = t
301 self.tracker_id = t
302 end
302 end
303
303
304 if attrs['status_id']
304 if attrs['status_id']
305 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
305 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
306 attrs.delete('status_id')
306 attrs.delete('status_id')
307 end
307 end
308 end
308 end
309
309
310 unless leaf?
310 unless leaf?
311 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
311 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
312 end
312 end
313
313
314 if attrs.has_key?('parent_issue_id')
314 if attrs.has_key?('parent_issue_id')
315 if !user.allowed_to?(:manage_subtasks, project)
315 if !user.allowed_to?(:manage_subtasks, project)
316 attrs.delete('parent_issue_id')
316 attrs.delete('parent_issue_id')
317 elsif !attrs['parent_issue_id'].blank?
317 elsif !attrs['parent_issue_id'].blank?
318 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
318 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
319 end
319 end
320 end
320 end
321
321
322 self.attributes = attrs
322 self.attributes = attrs
323 end
323 end
324
324
325 def done_ratio
325 def done_ratio
326 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
326 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
327 status.default_done_ratio
327 status.default_done_ratio
328 else
328 else
329 read_attribute(:done_ratio)
329 read_attribute(:done_ratio)
330 end
330 end
331 end
331 end
332
332
333 def self.use_status_for_done_ratio?
333 def self.use_status_for_done_ratio?
334 Setting.issue_done_ratio == 'issue_status'
334 Setting.issue_done_ratio == 'issue_status'
335 end
335 end
336
336
337 def self.use_field_for_done_ratio?
337 def self.use_field_for_done_ratio?
338 Setting.issue_done_ratio == 'issue_field'
338 Setting.issue_done_ratio == 'issue_field'
339 end
339 end
340
340
341 def validate_issue
341 def validate_issue
342 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
342 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
343 errors.add :due_date, :not_a_date
343 errors.add :due_date, :not_a_date
344 end
344 end
345
345
346 if self.due_date and self.start_date and self.due_date < self.start_date
346 if self.due_date and self.start_date and self.due_date < self.start_date
347 errors.add :due_date, :greater_than_start_date
347 errors.add :due_date, :greater_than_start_date
348 end
348 end
349
349
350 if start_date && soonest_start && start_date < soonest_start
350 if start_date && soonest_start && start_date < soonest_start
351 errors.add :start_date, :invalid
351 errors.add :start_date, :invalid
352 end
352 end
353
353
354 if fixed_version
354 if fixed_version
355 if !assignable_versions.include?(fixed_version)
355 if !assignable_versions.include?(fixed_version)
356 errors.add :fixed_version_id, :inclusion
356 errors.add :fixed_version_id, :inclusion
357 elsif reopened? && fixed_version.closed?
357 elsif reopened? && fixed_version.closed?
358 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
358 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
359 end
359 end
360 end
360 end
361
361
362 # Checks that the issue can not be added/moved to a disabled tracker
362 # Checks that the issue can not be added/moved to a disabled tracker
363 if project && (tracker_id_changed? || project_id_changed?)
363 if project && (tracker_id_changed? || project_id_changed?)
364 unless project.trackers.include?(tracker)
364 unless project.trackers.include?(tracker)
365 errors.add :tracker_id, :inclusion
365 errors.add :tracker_id, :inclusion
366 end
366 end
367 end
367 end
368
368
369 # Checks parent issue assignment
369 # Checks parent issue assignment
370 if @parent_issue
370 if @parent_issue
371 if @parent_issue.project_id != project_id
371 if @parent_issue.project_id != project_id
372 errors.add :parent_issue_id, :not_same_project
372 errors.add :parent_issue_id, :not_same_project
373 elsif !new_record?
373 elsif !new_record?
374 # moving an existing issue
374 # moving an existing issue
375 if @parent_issue.root_id != root_id
375 if @parent_issue.root_id != root_id
376 # we can always move to another tree
376 # we can always move to another tree
377 elsif move_possible?(@parent_issue)
377 elsif move_possible?(@parent_issue)
378 # move accepted inside tree
378 # move accepted inside tree
379 else
379 else
380 errors.add :parent_issue_id, :not_a_valid_parent
380 errors.add :parent_issue_id, :not_a_valid_parent
381 end
381 end
382 end
382 end
383 end
383 end
384 end
384 end
385
385
386 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
386 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
387 # even if the user turns off the setting later
387 # even if the user turns off the setting later
388 def update_done_ratio_from_issue_status
388 def update_done_ratio_from_issue_status
389 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
389 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
390 self.done_ratio = status.default_done_ratio
390 self.done_ratio = status.default_done_ratio
391 end
391 end
392 end
392 end
393
393
394 def init_journal(user, notes = "")
394 def init_journal(user, notes = "")
395 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
395 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
396 @issue_before_change = self.clone
396 @issue_before_change = self.clone
397 @issue_before_change.status = self.status
397 @issue_before_change.status = self.status
398 @custom_values_before_change = {}
398 @custom_values_before_change = {}
399 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
399 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
400 # Make sure updated_on is updated when adding a note.
400 # Make sure updated_on is updated when adding a note.
401 updated_on_will_change!
401 updated_on_will_change!
402 @current_journal
402 @current_journal
403 end
403 end
404
404
405 # Return true if the issue is closed, otherwise false
405 # Return true if the issue is closed, otherwise false
406 def closed?
406 def closed?
407 self.status.is_closed?
407 self.status.is_closed?
408 end
408 end
409
409
410 # Return true if the issue is being reopened
410 # Return true if the issue is being reopened
411 def reopened?
411 def reopened?
412 if !new_record? && status_id_changed?
412 if !new_record? && status_id_changed?
413 status_was = IssueStatus.find_by_id(status_id_was)
413 status_was = IssueStatus.find_by_id(status_id_was)
414 status_new = IssueStatus.find_by_id(status_id)
414 status_new = IssueStatus.find_by_id(status_id)
415 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
415 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
416 return true
416 return true
417 end
417 end
418 end
418 end
419 false
419 false
420 end
420 end
421
421
422 # Return true if the issue is being closed
422 # Return true if the issue is being closed
423 def closing?
423 def closing?
424 if !new_record? && status_id_changed?
424 if !new_record? && status_id_changed?
425 status_was = IssueStatus.find_by_id(status_id_was)
425 status_was = IssueStatus.find_by_id(status_id_was)
426 status_new = IssueStatus.find_by_id(status_id)
426 status_new = IssueStatus.find_by_id(status_id)
427 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
427 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
428 return true
428 return true
429 end
429 end
430 end
430 end
431 false
431 false
432 end
432 end
433
433
434 # Returns true if the issue is overdue
434 # Returns true if the issue is overdue
435 def overdue?
435 def overdue?
436 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
436 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
437 end
437 end
438
438
439 # Is the amount of work done less than it should for the due date
439 # Is the amount of work done less than it should for the due date
440 def behind_schedule?
440 def behind_schedule?
441 return false if start_date.nil? || due_date.nil?
441 return false if start_date.nil? || due_date.nil?
442 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
442 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
443 return done_date <= Date.today
443 return done_date <= Date.today
444 end
444 end
445
445
446 # Does this issue have children?
446 # Does this issue have children?
447 def children?
447 def children?
448 !leaf?
448 !leaf?
449 end
449 end
450
450
451 # Users the issue can be assigned to
451 # Users the issue can be assigned to
452 def assignable_users
452 def assignable_users
453 users = project.assignable_users
453 users = project.assignable_users
454 users << author if author
454 users << author if author
455 users << assigned_to if assigned_to
455 users << assigned_to if assigned_to
456 users.uniq.sort
456 users.uniq.sort
457 end
457 end
458
458
459 # Versions that the issue can be assigned to
459 # Versions that the issue can be assigned to
460 def assignable_versions
460 def assignable_versions
461 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
461 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
462 end
462 end
463
463
464 # Returns true if this issue is blocked by another issue that is still open
464 # Returns true if this issue is blocked by another issue that is still open
465 def blocked?
465 def blocked?
466 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
466 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
467 end
467 end
468
468
469 # Returns an array of status that user is able to apply
469 # Returns an array of status that user is able to apply
470 def new_statuses_allowed_to(user, include_default=false)
470 def new_statuses_allowed_to(user, include_default=false)
471 statuses = status.find_new_statuses_allowed_to(
471 statuses = status.find_new_statuses_allowed_to(
472 user.roles_for_project(project),
472 user.roles_for_project(project),
473 tracker,
473 tracker,
474 author == user,
474 author == user,
475 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
475 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
476 )
476 )
477 statuses << status unless statuses.empty?
477 statuses << status unless statuses.empty?
478 statuses << IssueStatus.default if include_default
478 statuses << IssueStatus.default if include_default
479 statuses = statuses.uniq.sort
479 statuses = statuses.uniq.sort
480 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
480 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
481 end
481 end
482
482
483 # Returns the mail adresses of users that should be notified
483 # Returns the mail adresses of users that should be notified
484 def recipients
484 def recipients
485 notified = project.notified_users
485 notified = project.notified_users
486 # Author and assignee are always notified unless they have been
486 # Author and assignee are always notified unless they have been
487 # locked or don't want to be notified
487 # locked or don't want to be notified
488 notified << author if author && author.active? && author.notify_about?(self)
488 notified << author if author && author.active? && author.notify_about?(self)
489 if assigned_to
489 if assigned_to
490 if assigned_to.is_a?(Group)
490 if assigned_to.is_a?(Group)
491 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
491 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
492 else
492 else
493 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
493 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
494 end
494 end
495 end
495 end
496 notified.uniq!
496 notified.uniq!
497 # Remove users that can not view the issue
497 # Remove users that can not view the issue
498 notified.reject! {|user| !visible?(user)}
498 notified.reject! {|user| !visible?(user)}
499 notified.collect(&:mail)
499 notified.collect(&:mail)
500 end
500 end
501
501
502 # Returns the total number of hours spent on this issue and its descendants
502 # Returns the total number of hours spent on this issue and its descendants
503 #
503 #
504 # Example:
504 # Example:
505 # spent_hours => 0.0
505 # spent_hours => 0.0
506 # spent_hours => 50.2
506 # spent_hours => 50.2
507 def spent_hours
507 def spent_hours
508 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
508 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
509 end
509 end
510
510
511 def relations
511 def relations
512 @relations ||= (relations_from + relations_to).sort
512 @relations ||= (relations_from + relations_to).sort
513 end
513 end
514
514
515 # Preloads relations for a collection of issues
515 # Preloads relations for a collection of issues
516 def self.load_relations(issues)
516 def self.load_relations(issues)
517 if issues.any?
517 if issues.any?
518 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
518 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
519 issues.each do |issue|
519 issues.each do |issue|
520 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
520 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
521 end
521 end
522 end
522 end
523 end
523 end
524
524
525 # Finds an issue relation given its id.
525 # Finds an issue relation given its id.
526 def find_relation(relation_id)
526 def find_relation(relation_id)
527 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
527 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
528 end
528 end
529
529
530 def all_dependent_issues(except=[])
530 def all_dependent_issues(except=[])
531 except << self
531 except << self
532 dependencies = []
532 dependencies = []
533 relations_from.each do |relation|
533 relations_from.each do |relation|
534 if relation.issue_to && !except.include?(relation.issue_to)
534 if relation.issue_to && !except.include?(relation.issue_to)
535 dependencies << relation.issue_to
535 dependencies << relation.issue_to
536 dependencies += relation.issue_to.all_dependent_issues(except)
536 dependencies += relation.issue_to.all_dependent_issues(except)
537 end
537 end
538 end
538 end
539 dependencies
539 dependencies
540 end
540 end
541
541
542 # Returns an array of issues that duplicate this one
542 # Returns an array of issues that duplicate this one
543 def duplicates
543 def duplicates
544 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
544 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
545 end
545 end
546
546
547 # Returns the due date or the target due date if any
547 # Returns the due date or the target due date if any
548 # Used on gantt chart
548 # Used on gantt chart
549 def due_before
549 def due_before
550 due_date || (fixed_version ? fixed_version.effective_date : nil)
550 due_date || (fixed_version ? fixed_version.effective_date : nil)
551 end
551 end
552
552
553 # Returns the time scheduled for this issue.
553 # Returns the time scheduled for this issue.
554 #
554 #
555 # Example:
555 # Example:
556 # Start Date: 2/26/09, End Date: 3/04/09
556 # Start Date: 2/26/09, End Date: 3/04/09
557 # duration => 6
557 # duration => 6
558 def duration
558 def duration
559 (start_date && due_date) ? due_date - start_date : 0
559 (start_date && due_date) ? due_date - start_date : 0
560 end
560 end
561
561
562 def soonest_start
562 def soonest_start
563 @soonest_start ||= (
563 @soonest_start ||= (
564 relations_to.collect{|relation| relation.successor_soonest_start} +
564 relations_to.collect{|relation| relation.successor_soonest_start} +
565 ancestors.collect(&:soonest_start)
565 ancestors.collect(&:soonest_start)
566 ).compact.max
566 ).compact.max
567 end
567 end
568
568
569 def reschedule_after(date)
569 def reschedule_after(date)
570 return if date.nil?
570 return if date.nil?
571 if leaf?
571 if leaf?
572 if start_date.nil? || start_date < date
572 if start_date.nil? || start_date < date
573 self.start_date, self.due_date = date, date + duration
573 self.start_date, self.due_date = date, date + duration
574 save
574 save
575 end
575 end
576 else
576 else
577 leaves.each do |leaf|
577 leaves.each do |leaf|
578 leaf.reschedule_after(date)
578 leaf.reschedule_after(date)
579 end
579 end
580 end
580 end
581 end
581 end
582
582
583 def <=>(issue)
583 def <=>(issue)
584 if issue.nil?
584 if issue.nil?
585 -1
585 -1
586 elsif root_id != issue.root_id
586 elsif root_id != issue.root_id
587 (root_id || 0) <=> (issue.root_id || 0)
587 (root_id || 0) <=> (issue.root_id || 0)
588 else
588 else
589 (lft || 0) <=> (issue.lft || 0)
589 (lft || 0) <=> (issue.lft || 0)
590 end
590 end
591 end
591 end
592
592
593 def to_s
593 def to_s
594 "#{tracker} ##{id}: #{subject}"
594 "#{tracker} ##{id}: #{subject}"
595 end
595 end
596
596
597 # Returns a string of css classes that apply to the issue
597 # Returns a string of css classes that apply to the issue
598 def css_classes
598 def css_classes
599 s = "issue status-#{status.position} priority-#{priority.position}"
599 s = "issue status-#{status.position} priority-#{priority.position}"
600 s << ' closed' if closed?
600 s << ' closed' if closed?
601 s << ' overdue' if overdue?
601 s << ' overdue' if overdue?
602 s << ' child' if child?
602 s << ' child' if child?
603 s << ' parent' unless leaf?
603 s << ' parent' unless leaf?
604 s << ' private' if is_private?
604 s << ' private' if is_private?
605 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
605 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
606 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
606 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
607 s
607 s
608 end
608 end
609
609
610 # Saves an issue, time_entry, attachments, and a journal from the parameters
610 # Saves an issue, time_entry, attachments, and a journal from the parameters
611 # Returns false if save fails
611 # Returns false if save fails
612 def save_issue_with_child_records(params, existing_time_entry=nil)
612 def save_issue_with_child_records(params, existing_time_entry=nil)
613 Issue.transaction do
613 Issue.transaction do
614 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
614 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
615 @time_entry = existing_time_entry || TimeEntry.new
615 @time_entry = existing_time_entry || TimeEntry.new
616 @time_entry.project = project
616 @time_entry.project = project
617 @time_entry.issue = self
617 @time_entry.issue = self
618 @time_entry.user = User.current
618 @time_entry.user = User.current
619 @time_entry.spent_on = Date.today
619 @time_entry.spent_on = Date.today
620 @time_entry.attributes = params[:time_entry]
620 @time_entry.attributes = params[:time_entry]
621 self.time_entries << @time_entry
621 self.time_entries << @time_entry
622 end
622 end
623
623
624 if valid?
624 if valid?
625 attachments = Attachment.attach_files(self, params[:attachments])
625 attachments = Attachment.attach_files(self, params[:attachments])
626 # TODO: Rename hook
626 # TODO: Rename hook
627 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
627 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
628 begin
628 begin
629 if save
629 if save
630 # TODO: Rename hook
630 # TODO: Rename hook
631 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
631 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
632 else
632 else
633 raise ActiveRecord::Rollback
633 raise ActiveRecord::Rollback
634 end
634 end
635 rescue ActiveRecord::StaleObjectError
635 rescue ActiveRecord::StaleObjectError
636 attachments[:files].each(&:destroy)
636 attachments[:files].each(&:destroy)
637 errors.add :base, l(:notice_locking_conflict)
637 errors.add :base, l(:notice_locking_conflict)
638 raise ActiveRecord::Rollback
638 raise ActiveRecord::Rollback
639 end
639 end
640 end
640 end
641 end
641 end
642 end
642 end
643
643
644 # Unassigns issues from +version+ if it's no longer shared with issue's project
644 # Unassigns issues from +version+ if it's no longer shared with issue's project
645 def self.update_versions_from_sharing_change(version)
645 def self.update_versions_from_sharing_change(version)
646 # Update issues assigned to the version
646 # Update issues assigned to the version
647 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
647 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
648 end
648 end
649
649
650 # Unassigns issues from versions that are no longer shared
650 # Unassigns issues from versions that are no longer shared
651 # after +project+ was moved
651 # after +project+ was moved
652 def self.update_versions_from_hierarchy_change(project)
652 def self.update_versions_from_hierarchy_change(project)
653 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
653 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
654 # Update issues of the moved projects and issues assigned to a version of a moved project
654 # Update issues of the moved projects and issues assigned to a version of a moved project
655 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
655 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
656 end
656 end
657
657
658 def parent_issue_id=(arg)
658 def parent_issue_id=(arg)
659 parent_issue_id = arg.blank? ? nil : arg.to_i
659 parent_issue_id = arg.blank? ? nil : arg.to_i
660 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
660 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
661 @parent_issue.id
661 @parent_issue.id
662 else
662 else
663 @parent_issue = nil
663 @parent_issue = nil
664 nil
664 nil
665 end
665 end
666 end
666 end
667
667
668 def parent_issue_id
668 def parent_issue_id
669 if instance_variable_defined? :@parent_issue
669 if instance_variable_defined? :@parent_issue
670 @parent_issue.nil? ? nil : @parent_issue.id
670 @parent_issue.nil? ? nil : @parent_issue.id
671 else
671 else
672 parent_id
672 parent_id
673 end
673 end
674 end
674 end
675
675
676 # Extracted from the ReportsController.
676 # Extracted from the ReportsController.
677 def self.by_tracker(project)
677 def self.by_tracker(project)
678 count_and_group_by(:project => project,
678 count_and_group_by(:project => project,
679 :field => 'tracker_id',
679 :field => 'tracker_id',
680 :joins => Tracker.table_name)
680 :joins => Tracker.table_name)
681 end
681 end
682
682
683 def self.by_version(project)
683 def self.by_version(project)
684 count_and_group_by(:project => project,
684 count_and_group_by(:project => project,
685 :field => 'fixed_version_id',
685 :field => 'fixed_version_id',
686 :joins => Version.table_name)
686 :joins => Version.table_name)
687 end
687 end
688
688
689 def self.by_priority(project)
689 def self.by_priority(project)
690 count_and_group_by(:project => project,
690 count_and_group_by(:project => project,
691 :field => 'priority_id',
691 :field => 'priority_id',
692 :joins => IssuePriority.table_name)
692 :joins => IssuePriority.table_name)
693 end
693 end
694
694
695 def self.by_category(project)
695 def self.by_category(project)
696 count_and_group_by(:project => project,
696 count_and_group_by(:project => project,
697 :field => 'category_id',
697 :field => 'category_id',
698 :joins => IssueCategory.table_name)
698 :joins => IssueCategory.table_name)
699 end
699 end
700
700
701 def self.by_assigned_to(project)
701 def self.by_assigned_to(project)
702 count_and_group_by(:project => project,
702 count_and_group_by(:project => project,
703 :field => 'assigned_to_id',
703 :field => 'assigned_to_id',
704 :joins => User.table_name)
704 :joins => User.table_name)
705 end
705 end
706
706
707 def self.by_author(project)
707 def self.by_author(project)
708 count_and_group_by(:project => project,
708 count_and_group_by(:project => project,
709 :field => 'author_id',
709 :field => 'author_id',
710 :joins => User.table_name)
710 :joins => User.table_name)
711 end
711 end
712
712
713 def self.by_subproject(project)
713 def self.by_subproject(project)
714 ActiveRecord::Base.connection.select_all("select s.id as status_id,
714 ActiveRecord::Base.connection.select_all("select s.id as status_id,
715 s.is_closed as closed,
715 s.is_closed as closed,
716 #{Issue.table_name}.project_id as project_id,
716 #{Issue.table_name}.project_id as project_id,
717 count(#{Issue.table_name}.id) as total
717 count(#{Issue.table_name}.id) as total
718 from
718 from
719 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
719 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
720 where
720 where
721 #{Issue.table_name}.status_id=s.id
721 #{Issue.table_name}.status_id=s.id
722 and #{Issue.table_name}.project_id = #{Project.table_name}.id
722 and #{Issue.table_name}.project_id = #{Project.table_name}.id
723 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
723 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
724 and #{Issue.table_name}.project_id <> #{project.id}
724 and #{Issue.table_name}.project_id <> #{project.id}
725 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
725 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
726 end
726 end
727 # End ReportsController extraction
727 # End ReportsController extraction
728
728
729 # Returns an array of projects that current user can move issues to
729 # Returns an array of projects that current user can move issues to
730 def self.allowed_target_projects_on_move
730 def self.allowed_target_projects_on_move
731 projects = []
731 projects = []
732 if User.current.admin?
732 if User.current.admin?
733 # admin is allowed to move issues to any active (visible) project
733 # admin is allowed to move issues to any active (visible) project
734 projects = Project.visible.all
734 projects = Project.visible.all
735 elsif User.current.logged?
735 elsif User.current.logged?
736 if Role.non_member.allowed_to?(:move_issues)
736 if Role.non_member.allowed_to?(:move_issues)
737 projects = Project.visible.all
737 projects = Project.visible.all
738 else
738 else
739 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
739 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
740 end
740 end
741 end
741 end
742 projects
742 projects
743 end
743 end
744
744
745 private
745 private
746
746
747 def update_nested_set_attributes
747 def update_nested_set_attributes
748 if root_id.nil?
748 if root_id.nil?
749 # issue was just created
749 # issue was just created
750 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
750 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
751 set_default_left_and_right
751 set_default_left_and_right
752 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
752 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
753 if @parent_issue
753 if @parent_issue
754 move_to_child_of(@parent_issue)
754 move_to_child_of(@parent_issue)
755 end
755 end
756 reload
756 reload
757 elsif parent_issue_id != parent_id
757 elsif parent_issue_id != parent_id
758 former_parent_id = parent_id
758 former_parent_id = parent_id
759 # moving an existing issue
759 # moving an existing issue
760 if @parent_issue && @parent_issue.root_id == root_id
760 if @parent_issue && @parent_issue.root_id == root_id
761 # inside the same tree
761 # inside the same tree
762 move_to_child_of(@parent_issue)
762 move_to_child_of(@parent_issue)
763 else
763 else
764 # to another tree
764 # to another tree
765 unless root?
765 unless root?
766 move_to_right_of(root)
766 move_to_right_of(root)
767 reload
767 reload
768 end
768 end
769 old_root_id = root_id
769 old_root_id = root_id
770 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
770 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
771 target_maxright = nested_set_scope.maximum(right_column_name) || 0
771 target_maxright = nested_set_scope.maximum(right_column_name) || 0
772 offset = target_maxright + 1 - lft
772 offset = target_maxright + 1 - lft
773 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
773 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
774 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
774 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
775 self[left_column_name] = lft + offset
775 self[left_column_name] = lft + offset
776 self[right_column_name] = rgt + offset
776 self[right_column_name] = rgt + offset
777 if @parent_issue
777 if @parent_issue
778 move_to_child_of(@parent_issue)
778 move_to_child_of(@parent_issue)
779 end
779 end
780 end
780 end
781 reload
781 reload
782 # delete invalid relations of all descendants
782 # delete invalid relations of all descendants
783 self_and_descendants.each do |issue|
783 self_and_descendants.each do |issue|
784 issue.relations.each do |relation|
784 issue.relations.each do |relation|
785 relation.destroy unless relation.valid?
785 relation.destroy unless relation.valid?
786 end
786 end
787 end
787 end
788 # update former parent
788 # update former parent
789 recalculate_attributes_for(former_parent_id) if former_parent_id
789 recalculate_attributes_for(former_parent_id) if former_parent_id
790 end
790 end
791 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
791 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
792 end
792 end
793
793
794 def update_parent_attributes
794 def update_parent_attributes
795 recalculate_attributes_for(parent_id) if parent_id
795 recalculate_attributes_for(parent_id) if parent_id
796 end
796 end
797
797
798 def recalculate_attributes_for(issue_id)
798 def recalculate_attributes_for(issue_id)
799 if issue_id && p = Issue.find_by_id(issue_id)
799 if issue_id && p = Issue.find_by_id(issue_id)
800 # priority = highest priority of children
800 # priority = highest priority of children
801 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
801 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
802 p.priority = IssuePriority.find_by_position(priority_position)
802 p.priority = IssuePriority.find_by_position(priority_position)
803 end
803 end
804
804
805 # start/due dates = lowest/highest dates of children
805 # start/due dates = lowest/highest dates of children
806 p.start_date = p.children.minimum(:start_date)
806 p.start_date = p.children.minimum(:start_date)
807 p.due_date = p.children.maximum(:due_date)
807 p.due_date = p.children.maximum(:due_date)
808 if p.start_date && p.due_date && p.due_date < p.start_date
808 if p.start_date && p.due_date && p.due_date < p.start_date
809 p.start_date, p.due_date = p.due_date, p.start_date
809 p.start_date, p.due_date = p.due_date, p.start_date
810 end
810 end
811
811
812 # done ratio = weighted average ratio of leaves
812 # done ratio = weighted average ratio of leaves
813 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
813 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
814 leaves_count = p.leaves.count
814 leaves_count = p.leaves.count
815 if leaves_count > 0
815 if leaves_count > 0
816 average = p.leaves.average(:estimated_hours).to_f
816 average = p.leaves.average(:estimated_hours).to_f
817 if average == 0
817 if average == 0
818 average = 1
818 average = 1
819 end
819 end
820 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
820 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
821 progress = done / (average * leaves_count)
821 progress = done / (average * leaves_count)
822 p.done_ratio = progress.round
822 p.done_ratio = progress.round
823 end
823 end
824 end
824 end
825
825
826 # estimate = sum of leaves estimates
826 # estimate = sum of leaves estimates
827 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
827 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
828 p.estimated_hours = nil if p.estimated_hours == 0.0
828 p.estimated_hours = nil if p.estimated_hours == 0.0
829
829
830 # ancestors will be recursively updated
830 # ancestors will be recursively updated
831 p.save(false)
831 p.save(false)
832 end
832 end
833 end
833 end
834
834
835 # Update issues so their versions are not pointing to a
835 # Update issues so their versions are not pointing to a
836 # fixed_version that is not shared with the issue's project
836 # fixed_version that is not shared with the issue's project
837 def self.update_versions(conditions=nil)
837 def self.update_versions(conditions=nil)
838 # Only need to update issues with a fixed_version from
838 # Only need to update issues with a fixed_version from
839 # a different project and that is not systemwide shared
839 # a different project and that is not systemwide shared
840 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
840 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
841 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
841 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
842 " AND #{Version.table_name}.sharing <> 'system'",
842 " AND #{Version.table_name}.sharing <> 'system'",
843 conditions),
843 conditions),
844 :include => [:project, :fixed_version]
844 :include => [:project, :fixed_version]
845 ).each do |issue|
845 ).each do |issue|
846 next if issue.project.nil? || issue.fixed_version.nil?
846 next if issue.project.nil? || issue.fixed_version.nil?
847 unless issue.project.shared_versions.include?(issue.fixed_version)
847 unless issue.project.shared_versions.include?(issue.fixed_version)
848 issue.init_journal(User.current)
848 issue.init_journal(User.current)
849 issue.fixed_version = nil
849 issue.fixed_version = nil
850 issue.save
850 issue.save
851 end
851 end
852 end
852 end
853 end
853 end
854
854
855 # Callback on attachment deletion
855 # Callback on attachment deletion
856 def attachment_added(obj)
856 def attachment_added(obj)
857 if @current_journal && !obj.new_record?
857 if @current_journal && !obj.new_record?
858 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
858 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
859 end
859 end
860 end
860 end
861
861
862 # Callback on attachment deletion
862 # Callback on attachment deletion
863 def attachment_removed(obj)
863 def attachment_removed(obj)
864 journal = init_journal(User.current)
864 journal = init_journal(User.current)
865 journal.details << JournalDetail.new(:property => 'attachment',
865 journal.details << JournalDetail.new(:property => 'attachment',
866 :prop_key => obj.id,
866 :prop_key => obj.id,
867 :old_value => obj.filename)
867 :old_value => obj.filename)
868 journal.save
868 journal.save
869 end
869 end
870
870
871 # Default assignment based on category
871 # Default assignment based on category
872 def default_assign
872 def default_assign
873 if assigned_to.nil? && category && category.assigned_to
873 if assigned_to.nil? && category && category.assigned_to
874 self.assigned_to = category.assigned_to
874 self.assigned_to = category.assigned_to
875 end
875 end
876 end
876 end
877
877
878 # Updates start/due dates of following issues
878 # Updates start/due dates of following issues
879 def reschedule_following_issues
879 def reschedule_following_issues
880 if start_date_changed? || due_date_changed?
880 if start_date_changed? || due_date_changed?
881 relations_from.each do |relation|
881 relations_from.each do |relation|
882 relation.set_issue_to_dates
882 relation.set_issue_to_dates
883 end
883 end
884 end
884 end
885 end
885 end
886
886
887 # Closes duplicates if the issue is being closed
887 # Closes duplicates if the issue is being closed
888 def close_duplicates
888 def close_duplicates
889 if closing?
889 if closing?
890 duplicates.each do |duplicate|
890 duplicates.each do |duplicate|
891 # Reload is need in case the duplicate was updated by a previous duplicate
891 # Reload is need in case the duplicate was updated by a previous duplicate
892 duplicate.reload
892 duplicate.reload
893 # Don't re-close it if it's already closed
893 # Don't re-close it if it's already closed
894 next if duplicate.closed?
894 next if duplicate.closed?
895 # Same user and notes
895 # Same user and notes
896 if @current_journal
896 if @current_journal
897 duplicate.init_journal(@current_journal.user, @current_journal.notes)
897 duplicate.init_journal(@current_journal.user, @current_journal.notes)
898 end
898 end
899 duplicate.update_attribute :status, self.status
899 duplicate.update_attribute :status, self.status
900 end
900 end
901 end
901 end
902 end
902 end
903
903
904 # Saves the changes in a Journal
904 # Saves the changes in a Journal
905 # Called after_save
905 # Called after_save
906 def create_journal
906 def create_journal
907 if @current_journal
907 if @current_journal
908 # attributes changes
908 # attributes changes
909 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
909 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
910 before = @issue_before_change.send(c)
910 before = @issue_before_change.send(c)
911 after = send(c)
911 after = send(c)
912 next if before == after || (before.blank? && after.blank?)
912 next if before == after || (before.blank? && after.blank?)
913 @current_journal.details << JournalDetail.new(:property => 'attr',
913 @current_journal.details << JournalDetail.new(:property => 'attr',
914 :prop_key => c,
914 :prop_key => c,
915 :old_value => @issue_before_change.send(c),
915 :old_value => @issue_before_change.send(c),
916 :value => send(c))
916 :value => send(c))
917 }
917 }
918 # custom fields changes
918 # custom fields changes
919 custom_values.each {|c|
919 custom_values.each {|c|
920 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
920 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
921 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
921 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
922 @current_journal.details << JournalDetail.new(:property => 'cf',
922 @current_journal.details << JournalDetail.new(:property => 'cf',
923 :prop_key => c.custom_field_id,
923 :prop_key => c.custom_field_id,
924 :old_value => @custom_values_before_change[c.custom_field_id],
924 :old_value => @custom_values_before_change[c.custom_field_id],
925 :value => c.value)
925 :value => c.value)
926 }
926 }
927 @current_journal.save
927 @current_journal.save
928 # reset current journal
928 # reset current journal
929 init_journal @current_journal.user, @current_journal.notes
929 init_journal @current_journal.user, @current_journal.notes
930 end
930 end
931 end
931 end
932
932
933 # Query generator for selecting groups of issue counts for a project
933 # Query generator for selecting groups of issue counts for a project
934 # based on specific criteria
934 # based on specific criteria
935 #
935 #
936 # Options
936 # Options
937 # * project - Project to search in.
937 # * project - Project to search in.
938 # * field - String. Issue field to key off of in the grouping.
938 # * field - String. Issue field to key off of in the grouping.
939 # * joins - String. The table name to join against.
939 # * joins - String. The table name to join against.
940 def self.count_and_group_by(options)
940 def self.count_and_group_by(options)
941 project = options.delete(:project)
941 project = options.delete(:project)
942 select_field = options.delete(:field)
942 select_field = options.delete(:field)
943 joins = options.delete(:joins)
943 joins = options.delete(:joins)
944
944
945 where = "#{Issue.table_name}.#{select_field}=j.id"
945 where = "#{Issue.table_name}.#{select_field}=j.id"
946
946
947 ActiveRecord::Base.connection.select_all("select s.id as status_id,
947 ActiveRecord::Base.connection.select_all("select s.id as status_id,
948 s.is_closed as closed,
948 s.is_closed as closed,
949 j.id as #{select_field},
949 j.id as #{select_field},
950 count(#{Issue.table_name}.id) as total
950 count(#{Issue.table_name}.id) as total
951 from
951 from
952 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
952 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
953 where
953 where
954 #{Issue.table_name}.status_id=s.id
954 #{Issue.table_name}.status_id=s.id
955 and #{where}
955 and #{where}
956 and #{Issue.table_name}.project_id=#{Project.table_name}.id
956 and #{Issue.table_name}.project_id=#{Project.table_name}.id
957 and #{visible_condition(User.current, :project => project)}
957 and #{visible_condition(User.current, :project => project)}
958 group by s.id, s.is_closed, j.id")
958 group by s.id, s.is_closed, j.id")
959 end
959 end
960 end
960 end
General Comments 0
You need to be logged in to leave comments. Login now