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