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