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