##// END OF EJS Templates
Dup attributes instead of issue object....
Jean-Philippe Lang -
r8224:8f2304385170
parent child
Show More
@@ -1,980 +1,979
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 @issue_before_change = self.clone
400 @attributes_before_change = attributes.dup
401 @issue_before_change.status = self.status
402 @custom_values_before_change = {}
401 @custom_values_before_change = {}
403 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 }
404 # Make sure updated_on is updated when adding a note.
403 # Make sure updated_on is updated when adding a note.
405 updated_on_will_change!
404 updated_on_will_change!
406 @current_journal
405 @current_journal
407 end
406 end
408
407
409 # Return true if the issue is closed, otherwise false
408 # Return true if the issue is closed, otherwise false
410 def closed?
409 def closed?
411 self.status.is_closed?
410 self.status.is_closed?
412 end
411 end
413
412
414 # Return true if the issue is being reopened
413 # Return true if the issue is being reopened
415 def reopened?
414 def reopened?
416 if !new_record? && status_id_changed?
415 if !new_record? && status_id_changed?
417 status_was = IssueStatus.find_by_id(status_id_was)
416 status_was = IssueStatus.find_by_id(status_id_was)
418 status_new = IssueStatus.find_by_id(status_id)
417 status_new = IssueStatus.find_by_id(status_id)
419 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?
420 return true
419 return true
421 end
420 end
422 end
421 end
423 false
422 false
424 end
423 end
425
424
426 # Return true if the issue is being closed
425 # Return true if the issue is being closed
427 def closing?
426 def closing?
428 if !new_record? && status_id_changed?
427 if !new_record? && status_id_changed?
429 status_was = IssueStatus.find_by_id(status_id_was)
428 status_was = IssueStatus.find_by_id(status_id_was)
430 status_new = IssueStatus.find_by_id(status_id)
429 status_new = IssueStatus.find_by_id(status_id)
431 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?
432 return true
431 return true
433 end
432 end
434 end
433 end
435 false
434 false
436 end
435 end
437
436
438 # Returns true if the issue is overdue
437 # Returns true if the issue is overdue
439 def overdue?
438 def overdue?
440 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
439 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
441 end
440 end
442
441
443 # 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
444 def behind_schedule?
443 def behind_schedule?
445 return false if start_date.nil? || due_date.nil?
444 return false if start_date.nil? || due_date.nil?
446 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
447 return done_date <= Date.today
446 return done_date <= Date.today
448 end
447 end
449
448
450 # Does this issue have children?
449 # Does this issue have children?
451 def children?
450 def children?
452 !leaf?
451 !leaf?
453 end
452 end
454
453
455 # Users the issue can be assigned to
454 # Users the issue can be assigned to
456 def assignable_users
455 def assignable_users
457 users = project.assignable_users
456 users = project.assignable_users
458 users << author if author
457 users << author if author
459 users << assigned_to if assigned_to
458 users << assigned_to if assigned_to
460 users.uniq.sort
459 users.uniq.sort
461 end
460 end
462
461
463 # Versions that the issue can be assigned to
462 # Versions that the issue can be assigned to
464 def assignable_versions
463 def assignable_versions
465 @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
466 end
465 end
467
466
468 # 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
469 def blocked?
468 def blocked?
470 !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?
471 end
470 end
472
471
473 # Returns an array of status that user is able to apply
472 # Returns an array of status that user is able to apply
474 def new_statuses_allowed_to(user, include_default=false)
473 def new_statuses_allowed_to(user, include_default=false)
475 statuses = status.find_new_statuses_allowed_to(
474 statuses = status.find_new_statuses_allowed_to(
476 user.roles_for_project(project),
475 user.roles_for_project(project),
477 tracker,
476 tracker,
478 author == user,
477 author == user,
479 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
480 )
479 )
481 statuses << status unless statuses.empty?
480 statuses << status unless statuses.empty?
482 statuses << IssueStatus.default if include_default
481 statuses << IssueStatus.default if include_default
483 statuses = statuses.uniq.sort
482 statuses = statuses.uniq.sort
484 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
483 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
485 end
484 end
486
485
487 # Returns the mail adresses of users that should be notified
486 # Returns the mail adresses of users that should be notified
488 def recipients
487 def recipients
489 notified = project.notified_users
488 notified = project.notified_users
490 # Author and assignee are always notified unless they have been
489 # Author and assignee are always notified unless they have been
491 # locked or don't want to be notified
490 # locked or don't want to be notified
492 notified << author if author && author.active? && author.notify_about?(self)
491 notified << author if author && author.active? && author.notify_about?(self)
493 if assigned_to
492 if assigned_to
494 if assigned_to.is_a?(Group)
493 if assigned_to.is_a?(Group)
495 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)}
496 else
495 else
497 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)
498 end
497 end
499 end
498 end
500 notified.uniq!
499 notified.uniq!
501 # Remove users that can not view the issue
500 # Remove users that can not view the issue
502 notified.reject! {|user| !visible?(user)}
501 notified.reject! {|user| !visible?(user)}
503 notified.collect(&:mail)
502 notified.collect(&:mail)
504 end
503 end
505
504
506 # Returns the number of hours spent on this issue
505 # Returns the number of hours spent on this issue
507 def spent_hours
506 def spent_hours
508 @spent_hours ||= time_entries.sum(:hours) || 0
507 @spent_hours ||= time_entries.sum(:hours) || 0
509 end
508 end
510
509
511 # 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
512 #
511 #
513 # Example:
512 # Example:
514 # spent_hours => 0.0
513 # spent_hours => 0.0
515 # spent_hours => 50.2
514 # spent_hours => 50.2
516 def total_spent_hours
515 def total_spent_hours
517 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
516 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
518 :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
519 end
518 end
520
519
521 def relations
520 def relations
522 @relations ||= (relations_from + relations_to).sort
521 @relations ||= (relations_from + relations_to).sort
523 end
522 end
524
523
525 # Preloads relations for a collection of issues
524 # Preloads relations for a collection of issues
526 def self.load_relations(issues)
525 def self.load_relations(issues)
527 if issues.any?
526 if issues.any?
528 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)}])
529 issues.each do |issue|
528 issues.each do |issue|
530 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}
531 end
530 end
532 end
531 end
533 end
532 end
534
533
535 # Preloads visible spent time for a collection of issues
534 # Preloads visible spent time for a collection of issues
536 def self.load_visible_spent_hours(issues, user=User.current)
535 def self.load_visible_spent_hours(issues, user=User.current)
537 if issues.any?
536 if issues.any?
538 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)
539 issues.each do |issue|
538 issues.each do |issue|
540 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)
541 end
540 end
542 end
541 end
543 end
542 end
544
543
545 # Finds an issue relation given its id.
544 # Finds an issue relation given its id.
546 def find_relation(relation_id)
545 def find_relation(relation_id)
547 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])
548 end
547 end
549
548
550 def all_dependent_issues(except=[])
549 def all_dependent_issues(except=[])
551 except << self
550 except << self
552 dependencies = []
551 dependencies = []
553 relations_from.each do |relation|
552 relations_from.each do |relation|
554 if relation.issue_to && !except.include?(relation.issue_to)
553 if relation.issue_to && !except.include?(relation.issue_to)
555 dependencies << relation.issue_to
554 dependencies << relation.issue_to
556 dependencies += relation.issue_to.all_dependent_issues(except)
555 dependencies += relation.issue_to.all_dependent_issues(except)
557 end
556 end
558 end
557 end
559 dependencies
558 dependencies
560 end
559 end
561
560
562 # Returns an array of issues that duplicate this one
561 # Returns an array of issues that duplicate this one
563 def duplicates
562 def duplicates
564 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}
565 end
564 end
566
565
567 # Returns the due date or the target due date if any
566 # Returns the due date or the target due date if any
568 # Used on gantt chart
567 # Used on gantt chart
569 def due_before
568 def due_before
570 due_date || (fixed_version ? fixed_version.effective_date : nil)
569 due_date || (fixed_version ? fixed_version.effective_date : nil)
571 end
570 end
572
571
573 # Returns the time scheduled for this issue.
572 # Returns the time scheduled for this issue.
574 #
573 #
575 # Example:
574 # Example:
576 # Start Date: 2/26/09, End Date: 3/04/09
575 # Start Date: 2/26/09, End Date: 3/04/09
577 # duration => 6
576 # duration => 6
578 def duration
577 def duration
579 (start_date && due_date) ? due_date - start_date : 0
578 (start_date && due_date) ? due_date - start_date : 0
580 end
579 end
581
580
582 def soonest_start
581 def soonest_start
583 @soonest_start ||= (
582 @soonest_start ||= (
584 relations_to.collect{|relation| relation.successor_soonest_start} +
583 relations_to.collect{|relation| relation.successor_soonest_start} +
585 ancestors.collect(&:soonest_start)
584 ancestors.collect(&:soonest_start)
586 ).compact.max
585 ).compact.max
587 end
586 end
588
587
589 def reschedule_after(date)
588 def reschedule_after(date)
590 return if date.nil?
589 return if date.nil?
591 if leaf?
590 if leaf?
592 if start_date.nil? || start_date < date
591 if start_date.nil? || start_date < date
593 self.start_date, self.due_date = date, date + duration
592 self.start_date, self.due_date = date, date + duration
594 save
593 save
595 end
594 end
596 else
595 else
597 leaves.each do |leaf|
596 leaves.each do |leaf|
598 leaf.reschedule_after(date)
597 leaf.reschedule_after(date)
599 end
598 end
600 end
599 end
601 end
600 end
602
601
603 def <=>(issue)
602 def <=>(issue)
604 if issue.nil?
603 if issue.nil?
605 -1
604 -1
606 elsif root_id != issue.root_id
605 elsif root_id != issue.root_id
607 (root_id || 0) <=> (issue.root_id || 0)
606 (root_id || 0) <=> (issue.root_id || 0)
608 else
607 else
609 (lft || 0) <=> (issue.lft || 0)
608 (lft || 0) <=> (issue.lft || 0)
610 end
609 end
611 end
610 end
612
611
613 def to_s
612 def to_s
614 "#{tracker} ##{id}: #{subject}"
613 "#{tracker} ##{id}: #{subject}"
615 end
614 end
616
615
617 # Returns a string of css classes that apply to the issue
616 # Returns a string of css classes that apply to the issue
618 def css_classes
617 def css_classes
619 s = "issue status-#{status.position} priority-#{priority.position}"
618 s = "issue status-#{status.position} priority-#{priority.position}"
620 s << ' closed' if closed?
619 s << ' closed' if closed?
621 s << ' overdue' if overdue?
620 s << ' overdue' if overdue?
622 s << ' child' if child?
621 s << ' child' if child?
623 s << ' parent' unless leaf?
622 s << ' parent' unless leaf?
624 s << ' private' if is_private?
623 s << ' private' if is_private?
625 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
626 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
627 s
626 s
628 end
627 end
629
628
630 # 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
631 # Returns false if save fails
630 # Returns false if save fails
632 def save_issue_with_child_records(params, existing_time_entry=nil)
631 def save_issue_with_child_records(params, existing_time_entry=nil)
633 Issue.transaction do
632 Issue.transaction do
634 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)
635 @time_entry = existing_time_entry || TimeEntry.new
634 @time_entry = existing_time_entry || TimeEntry.new
636 @time_entry.project = project
635 @time_entry.project = project
637 @time_entry.issue = self
636 @time_entry.issue = self
638 @time_entry.user = User.current
637 @time_entry.user = User.current
639 @time_entry.spent_on = User.current.today
638 @time_entry.spent_on = User.current.today
640 @time_entry.attributes = params[:time_entry]
639 @time_entry.attributes = params[:time_entry]
641 self.time_entries << @time_entry
640 self.time_entries << @time_entry
642 end
641 end
643
642
644 if valid?
643 if valid?
645 attachments = Attachment.attach_files(self, params[:attachments])
644 attachments = Attachment.attach_files(self, params[:attachments])
646 # TODO: Rename hook
645 # TODO: Rename hook
647 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})
648 begin
647 begin
649 if save
648 if save
650 # TODO: Rename hook
649 # TODO: Rename hook
651 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})
652 else
651 else
653 raise ActiveRecord::Rollback
652 raise ActiveRecord::Rollback
654 end
653 end
655 rescue ActiveRecord::StaleObjectError
654 rescue ActiveRecord::StaleObjectError
656 attachments[:files].each(&:destroy)
655 attachments[:files].each(&:destroy)
657 errors.add :base, l(:notice_locking_conflict)
656 errors.add :base, l(:notice_locking_conflict)
658 raise ActiveRecord::Rollback
657 raise ActiveRecord::Rollback
659 end
658 end
660 end
659 end
661 end
660 end
662 end
661 end
663
662
664 # 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
665 def self.update_versions_from_sharing_change(version)
664 def self.update_versions_from_sharing_change(version)
666 # Update issues assigned to the version
665 # Update issues assigned to the version
667 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
666 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
668 end
667 end
669
668
670 # Unassigns issues from versions that are no longer shared
669 # Unassigns issues from versions that are no longer shared
671 # after +project+ was moved
670 # after +project+ was moved
672 def self.update_versions_from_hierarchy_change(project)
671 def self.update_versions_from_hierarchy_change(project)
673 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
672 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
674 # 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
675 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])
676 end
675 end
677
676
678 def parent_issue_id=(arg)
677 def parent_issue_id=(arg)
679 parent_issue_id = arg.blank? ? nil : arg.to_i
678 parent_issue_id = arg.blank? ? nil : arg.to_i
680 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)
681 @parent_issue.id
680 @parent_issue.id
682 else
681 else
683 @parent_issue = nil
682 @parent_issue = nil
684 nil
683 nil
685 end
684 end
686 end
685 end
687
686
688 def parent_issue_id
687 def parent_issue_id
689 if instance_variable_defined? :@parent_issue
688 if instance_variable_defined? :@parent_issue
690 @parent_issue.nil? ? nil : @parent_issue.id
689 @parent_issue.nil? ? nil : @parent_issue.id
691 else
690 else
692 parent_id
691 parent_id
693 end
692 end
694 end
693 end
695
694
696 # Extracted from the ReportsController.
695 # Extracted from the ReportsController.
697 def self.by_tracker(project)
696 def self.by_tracker(project)
698 count_and_group_by(:project => project,
697 count_and_group_by(:project => project,
699 :field => 'tracker_id',
698 :field => 'tracker_id',
700 :joins => Tracker.table_name)
699 :joins => Tracker.table_name)
701 end
700 end
702
701
703 def self.by_version(project)
702 def self.by_version(project)
704 count_and_group_by(:project => project,
703 count_and_group_by(:project => project,
705 :field => 'fixed_version_id',
704 :field => 'fixed_version_id',
706 :joins => Version.table_name)
705 :joins => Version.table_name)
707 end
706 end
708
707
709 def self.by_priority(project)
708 def self.by_priority(project)
710 count_and_group_by(:project => project,
709 count_and_group_by(:project => project,
711 :field => 'priority_id',
710 :field => 'priority_id',
712 :joins => IssuePriority.table_name)
711 :joins => IssuePriority.table_name)
713 end
712 end
714
713
715 def self.by_category(project)
714 def self.by_category(project)
716 count_and_group_by(:project => project,
715 count_and_group_by(:project => project,
717 :field => 'category_id',
716 :field => 'category_id',
718 :joins => IssueCategory.table_name)
717 :joins => IssueCategory.table_name)
719 end
718 end
720
719
721 def self.by_assigned_to(project)
720 def self.by_assigned_to(project)
722 count_and_group_by(:project => project,
721 count_and_group_by(:project => project,
723 :field => 'assigned_to_id',
722 :field => 'assigned_to_id',
724 :joins => User.table_name)
723 :joins => User.table_name)
725 end
724 end
726
725
727 def self.by_author(project)
726 def self.by_author(project)
728 count_and_group_by(:project => project,
727 count_and_group_by(:project => project,
729 :field => 'author_id',
728 :field => 'author_id',
730 :joins => User.table_name)
729 :joins => User.table_name)
731 end
730 end
732
731
733 def self.by_subproject(project)
732 def self.by_subproject(project)
734 ActiveRecord::Base.connection.select_all("select s.id as status_id,
733 ActiveRecord::Base.connection.select_all("select s.id as status_id,
735 s.is_closed as closed,
734 s.is_closed as closed,
736 #{Issue.table_name}.project_id as project_id,
735 #{Issue.table_name}.project_id as project_id,
737 count(#{Issue.table_name}.id) as total
736 count(#{Issue.table_name}.id) as total
738 from
737 from
739 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
738 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
740 where
739 where
741 #{Issue.table_name}.status_id=s.id
740 #{Issue.table_name}.status_id=s.id
742 and #{Issue.table_name}.project_id = #{Project.table_name}.id
741 and #{Issue.table_name}.project_id = #{Project.table_name}.id
743 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
742 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
744 and #{Issue.table_name}.project_id <> #{project.id}
743 and #{Issue.table_name}.project_id <> #{project.id}
745 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?
746 end
745 end
747 # End ReportsController extraction
746 # End ReportsController extraction
748
747
749 # 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
750 def self.allowed_target_projects_on_move
749 def self.allowed_target_projects_on_move
751 projects = []
750 projects = []
752 if User.current.admin?
751 if User.current.admin?
753 # admin is allowed to move issues to any active (visible) project
752 # admin is allowed to move issues to any active (visible) project
754 projects = Project.visible.all
753 projects = Project.visible.all
755 elsif User.current.logged?
754 elsif User.current.logged?
756 if Role.non_member.allowed_to?(:move_issues)
755 if Role.non_member.allowed_to?(:move_issues)
757 projects = Project.visible.all
756 projects = Project.visible.all
758 else
757 else
759 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)}}
760 end
759 end
761 end
760 end
762 projects
761 projects
763 end
762 end
764
763
765 private
764 private
766
765
767 def update_nested_set_attributes
766 def update_nested_set_attributes
768 if root_id.nil?
767 if root_id.nil?
769 # issue was just created
768 # issue was just created
770 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
769 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
771 set_default_left_and_right
770 set_default_left_and_right
772 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])
773 if @parent_issue
772 if @parent_issue
774 move_to_child_of(@parent_issue)
773 move_to_child_of(@parent_issue)
775 end
774 end
776 reload
775 reload
777 elsif parent_issue_id != parent_id
776 elsif parent_issue_id != parent_id
778 former_parent_id = parent_id
777 former_parent_id = parent_id
779 # moving an existing issue
778 # moving an existing issue
780 if @parent_issue && @parent_issue.root_id == root_id
779 if @parent_issue && @parent_issue.root_id == root_id
781 # inside the same tree
780 # inside the same tree
782 move_to_child_of(@parent_issue)
781 move_to_child_of(@parent_issue)
783 else
782 else
784 # to another tree
783 # to another tree
785 unless root?
784 unless root?
786 move_to_right_of(root)
785 move_to_right_of(root)
787 reload
786 reload
788 end
787 end
789 old_root_id = root_id
788 old_root_id = root_id
790 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
789 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
791 target_maxright = nested_set_scope.maximum(right_column_name) || 0
790 target_maxright = nested_set_scope.maximum(right_column_name) || 0
792 offset = target_maxright + 1 - lft
791 offset = target_maxright + 1 - lft
793 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}",
794 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
793 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
795 self[left_column_name] = lft + offset
794 self[left_column_name] = lft + offset
796 self[right_column_name] = rgt + offset
795 self[right_column_name] = rgt + offset
797 if @parent_issue
796 if @parent_issue
798 move_to_child_of(@parent_issue)
797 move_to_child_of(@parent_issue)
799 end
798 end
800 end
799 end
801 reload
800 reload
802 # delete invalid relations of all descendants
801 # delete invalid relations of all descendants
803 self_and_descendants.each do |issue|
802 self_and_descendants.each do |issue|
804 issue.relations.each do |relation|
803 issue.relations.each do |relation|
805 relation.destroy unless relation.valid?
804 relation.destroy unless relation.valid?
806 end
805 end
807 end
806 end
808 # update former parent
807 # update former parent
809 recalculate_attributes_for(former_parent_id) if former_parent_id
808 recalculate_attributes_for(former_parent_id) if former_parent_id
810 end
809 end
811 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
810 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
812 end
811 end
813
812
814 def update_parent_attributes
813 def update_parent_attributes
815 recalculate_attributes_for(parent_id) if parent_id
814 recalculate_attributes_for(parent_id) if parent_id
816 end
815 end
817
816
818 def recalculate_attributes_for(issue_id)
817 def recalculate_attributes_for(issue_id)
819 if issue_id && p = Issue.find_by_id(issue_id)
818 if issue_id && p = Issue.find_by_id(issue_id)
820 # priority = highest priority of children
819 # priority = highest priority of children
821 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)
822 p.priority = IssuePriority.find_by_position(priority_position)
821 p.priority = IssuePriority.find_by_position(priority_position)
823 end
822 end
824
823
825 # start/due dates = lowest/highest dates of children
824 # start/due dates = lowest/highest dates of children
826 p.start_date = p.children.minimum(:start_date)
825 p.start_date = p.children.minimum(:start_date)
827 p.due_date = p.children.maximum(:due_date)
826 p.due_date = p.children.maximum(:due_date)
828 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
829 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
830 end
829 end
831
830
832 # done ratio = weighted average ratio of leaves
831 # done ratio = weighted average ratio of leaves
833 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
834 leaves_count = p.leaves.count
833 leaves_count = p.leaves.count
835 if leaves_count > 0
834 if leaves_count > 0
836 average = p.leaves.average(:estimated_hours).to_f
835 average = p.leaves.average(:estimated_hours).to_f
837 if average == 0
836 if average == 0
838 average = 1
837 average = 1
839 end
838 end
840 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
841 progress = done / (average * leaves_count)
840 progress = done / (average * leaves_count)
842 p.done_ratio = progress.round
841 p.done_ratio = progress.round
843 end
842 end
844 end
843 end
845
844
846 # estimate = sum of leaves estimates
845 # estimate = sum of leaves estimates
847 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
846 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
848 p.estimated_hours = nil if p.estimated_hours == 0.0
847 p.estimated_hours = nil if p.estimated_hours == 0.0
849
848
850 # ancestors will be recursively updated
849 # ancestors will be recursively updated
851 p.save(false)
850 p.save(false)
852 end
851 end
853 end
852 end
854
853
855 # Update issues so their versions are not pointing to a
854 # Update issues so their versions are not pointing to a
856 # fixed_version that is not shared with the issue's project
855 # fixed_version that is not shared with the issue's project
857 def self.update_versions(conditions=nil)
856 def self.update_versions(conditions=nil)
858 # Only need to update issues with a fixed_version from
857 # Only need to update issues with a fixed_version from
859 # a different project and that is not systemwide shared
858 # a different project and that is not systemwide shared
860 Issue.scoped(:conditions => conditions).all(
859 Issue.scoped(:conditions => conditions).all(
861 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
860 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
862 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
861 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
863 " AND #{Version.table_name}.sharing <> 'system'",
862 " AND #{Version.table_name}.sharing <> 'system'",
864 :include => [:project, :fixed_version]
863 :include => [:project, :fixed_version]
865 ).each do |issue|
864 ).each do |issue|
866 next if issue.project.nil? || issue.fixed_version.nil?
865 next if issue.project.nil? || issue.fixed_version.nil?
867 unless issue.project.shared_versions.include?(issue.fixed_version)
866 unless issue.project.shared_versions.include?(issue.fixed_version)
868 issue.init_journal(User.current)
867 issue.init_journal(User.current)
869 issue.fixed_version = nil
868 issue.fixed_version = nil
870 issue.save
869 issue.save
871 end
870 end
872 end
871 end
873 end
872 end
874
873
875 # Callback on attachment deletion
874 # Callback on attachment deletion
876 def attachment_added(obj)
875 def attachment_added(obj)
877 if @current_journal && !obj.new_record?
876 if @current_journal && !obj.new_record?
878 @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)
879 end
878 end
880 end
879 end
881
880
882 # Callback on attachment deletion
881 # Callback on attachment deletion
883 def attachment_removed(obj)
882 def attachment_removed(obj)
884 journal = init_journal(User.current)
883 journal = init_journal(User.current)
885 journal.details << JournalDetail.new(:property => 'attachment',
884 journal.details << JournalDetail.new(:property => 'attachment',
886 :prop_key => obj.id,
885 :prop_key => obj.id,
887 :old_value => obj.filename)
886 :old_value => obj.filename)
888 journal.save
887 journal.save
889 end
888 end
890
889
891 # Default assignment based on category
890 # Default assignment based on category
892 def default_assign
891 def default_assign
893 if assigned_to.nil? && category && category.assigned_to
892 if assigned_to.nil? && category && category.assigned_to
894 self.assigned_to = category.assigned_to
893 self.assigned_to = category.assigned_to
895 end
894 end
896 end
895 end
897
896
898 # Updates start/due dates of following issues
897 # Updates start/due dates of following issues
899 def reschedule_following_issues
898 def reschedule_following_issues
900 if start_date_changed? || due_date_changed?
899 if start_date_changed? || due_date_changed?
901 relations_from.each do |relation|
900 relations_from.each do |relation|
902 relation.set_issue_to_dates
901 relation.set_issue_to_dates
903 end
902 end
904 end
903 end
905 end
904 end
906
905
907 # Closes duplicates if the issue is being closed
906 # Closes duplicates if the issue is being closed
908 def close_duplicates
907 def close_duplicates
909 if closing?
908 if closing?
910 duplicates.each do |duplicate|
909 duplicates.each do |duplicate|
911 # 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
912 duplicate.reload
911 duplicate.reload
913 # Don't re-close it if it's already closed
912 # Don't re-close it if it's already closed
914 next if duplicate.closed?
913 next if duplicate.closed?
915 # Same user and notes
914 # Same user and notes
916 if @current_journal
915 if @current_journal
917 duplicate.init_journal(@current_journal.user, @current_journal.notes)
916 duplicate.init_journal(@current_journal.user, @current_journal.notes)
918 end
917 end
919 duplicate.update_attribute :status, self.status
918 duplicate.update_attribute :status, self.status
920 end
919 end
921 end
920 end
922 end
921 end
923
922
924 # Saves the changes in a Journal
923 # Saves the changes in a Journal
925 # Called after_save
924 # Called after_save
926 def create_journal
925 def create_journal
927 if @current_journal
926 if @current_journal
928 # attributes changes
927 # attributes changes
929 (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|
930 before = @issue_before_change.send(c)
929 before = @attributes_before_change[c]
931 after = send(c)
930 after = send(c)
932 next if before == after || (before.blank? && after.blank?)
931 next if before == after || (before.blank? && after.blank?)
933 @current_journal.details << JournalDetail.new(:property => 'attr',
932 @current_journal.details << JournalDetail.new(:property => 'attr',
934 :prop_key => c,
933 :prop_key => c,
935 :old_value => @issue_before_change.send(c),
934 :old_value => before,
936 :value => send(c))
935 :value => after)
937 }
936 }
938 # custom fields changes
937 # custom fields changes
939 custom_values.each {|c|
938 custom_values.each {|c|
940 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
939 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
941 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
940 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
942 @current_journal.details << JournalDetail.new(:property => 'cf',
941 @current_journal.details << JournalDetail.new(:property => 'cf',
943 :prop_key => c.custom_field_id,
942 :prop_key => c.custom_field_id,
944 :old_value => @custom_values_before_change[c.custom_field_id],
943 :old_value => @custom_values_before_change[c.custom_field_id],
945 :value => c.value)
944 :value => c.value)
946 }
945 }
947 @current_journal.save
946 @current_journal.save
948 # reset current journal
947 # reset current journal
949 init_journal @current_journal.user, @current_journal.notes
948 init_journal @current_journal.user, @current_journal.notes
950 end
949 end
951 end
950 end
952
951
953 # Query generator for selecting groups of issue counts for a project
952 # Query generator for selecting groups of issue counts for a project
954 # based on specific criteria
953 # based on specific criteria
955 #
954 #
956 # Options
955 # Options
957 # * project - Project to search in.
956 # * project - Project to search in.
958 # * field - String. Issue field to key off of in the grouping.
957 # * field - String. Issue field to key off of in the grouping.
959 # * joins - String. The table name to join against.
958 # * joins - String. The table name to join against.
960 def self.count_and_group_by(options)
959 def self.count_and_group_by(options)
961 project = options.delete(:project)
960 project = options.delete(:project)
962 select_field = options.delete(:field)
961 select_field = options.delete(:field)
963 joins = options.delete(:joins)
962 joins = options.delete(:joins)
964
963
965 where = "#{Issue.table_name}.#{select_field}=j.id"
964 where = "#{Issue.table_name}.#{select_field}=j.id"
966
965
967 ActiveRecord::Base.connection.select_all("select s.id as status_id,
966 ActiveRecord::Base.connection.select_all("select s.id as status_id,
968 s.is_closed as closed,
967 s.is_closed as closed,
969 j.id as #{select_field},
968 j.id as #{select_field},
970 count(#{Issue.table_name}.id) as total
969 count(#{Issue.table_name}.id) as total
971 from
970 from
972 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
971 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
973 where
972 where
974 #{Issue.table_name}.status_id=s.id
973 #{Issue.table_name}.status_id=s.id
975 and #{where}
974 and #{where}
976 and #{Issue.table_name}.project_id=#{Project.table_name}.id
975 and #{Issue.table_name}.project_id=#{Project.table_name}.id
977 and #{visible_condition(User.current, :project => project)}
976 and #{visible_condition(User.current, :project => project)}
978 group by s.id, s.is_closed, j.id")
977 group by s.id, s.is_closed, j.id")
979 end
978 end
980 end
979 end
General Comments 0
You need to be logged in to leave comments. Login now