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