##// END OF EJS Templates
Removes a call to alias_method_chain....
Jean-Philippe Lang -
r15275:106f6dcacefc
parent child
Show More
@@ -1,1723 +1,1721
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 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 19 include Redmine::SafeAttributes
20 20 include Redmine::Utils::DateCalculation
21 21 include Redmine::I18n
22 22 before_save :set_parent_id
23 23 include Redmine::NestedSet::IssueNestedSet
24 24
25 25 belongs_to :project
26 26 belongs_to :tracker
27 27 belongs_to :status, :class_name => 'IssueStatus'
28 28 belongs_to :author, :class_name => 'User'
29 29 belongs_to :assigned_to, :class_name => 'Principal'
30 30 belongs_to :fixed_version, :class_name => 'Version'
31 31 belongs_to :priority, :class_name => 'IssuePriority'
32 32 belongs_to :category, :class_name => 'IssueCategory'
33 33
34 34 has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
35 35 has_many :visible_journals,
36 36 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
37 37 :class_name => 'Journal',
38 38 :as => :journalized
39 39
40 40 has_many :time_entries, :dependent => :destroy
41 41 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
42 42
43 43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 45
46 46 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47 47 acts_as_customizable
48 48 acts_as_watchable
49 49 acts_as_searchable :columns => ['subject', "#{table_name}.description"],
50 50 :preload => [:project, :status, :tracker],
51 51 :scope => lambda {|options| options[:open_issues] ? self.open : self.all}
52 52
53 53 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
54 54 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
55 55 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
56 56
57 57 acts_as_activity_provider :scope => preload(:project, :author, :tracker, :status),
58 58 :author_key => :author_id
59 59
60 60 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
61 61
62 62 attr_accessor :deleted_attachment_ids
63 63 attr_reader :current_journal
64 64 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
65 65
66 66 validates_presence_of :subject, :project, :tracker
67 67 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
68 68 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
69 69 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
70 70
71 71 validates_length_of :subject, :maximum => 255
72 72 validates_inclusion_of :done_ratio, :in => 0..100
73 73 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
74 74 validates :start_date, :date => true
75 75 validates :due_date, :date => true
76 76 validate :validate_issue, :validate_required_fields
77 77 attr_protected :id
78 78
79 79 scope :visible, lambda {|*args|
80 80 joins(:project).
81 81 where(Issue.visible_condition(args.shift || User.current, *args))
82 82 }
83 83
84 84 scope :open, lambda {|*args|
85 85 is_closed = args.size > 0 ? !args.first : false
86 86 joins(:status).
87 87 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
88 88 }
89 89
90 90 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
91 91 scope :on_active_project, lambda {
92 92 joins(:project).
93 93 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
94 94 }
95 95 scope :fixed_version, lambda {|versions|
96 96 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
97 97 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
98 98 }
99 99 scope :assigned_to, lambda {|arg|
100 100 arg = Array(arg).uniq
101 101 ids = arg.map {|p| p.is_a?(Principal) ? p.id : p}
102 102 ids += arg.select {|p| p.is_a?(User)}.map(&:group_ids).flatten.uniq
103 103 ids.compact!
104 104 ids.any? ? where(:assigned_to_id => ids) : none
105 105 }
106 106
107 107 before_validation :clear_disabled_fields
108 108 before_create :default_assign
109 109 before_save :close_duplicates, :update_done_ratio_from_issue_status,
110 110 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
111 111 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
112 112 after_save :reschedule_following_issues, :update_nested_set_attributes,
113 113 :update_parent_attributes, :delete_selected_attachments, :create_journal
114 114 # Should be after_create but would be called before previous after_save callbacks
115 115 after_save :after_create_from_copy
116 116 after_destroy :update_parent_attributes
117 117 after_create :send_notification
118 118 # Keep it at the end of after_save callbacks
119 119 after_save :clear_assigned_to_was
120 120
121 121 # Returns a SQL conditions string used to find all issues visible by the specified user
122 122 def self.visible_condition(user, options={})
123 123 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
124 124 sql = if user.id && user.logged?
125 125 case role.issues_visibility
126 126 when 'all'
127 127 '1=1'
128 128 when 'default'
129 129 user_ids = [user.id] + user.groups.map(&:id).compact
130 130 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
131 131 when 'own'
132 132 user_ids = [user.id] + user.groups.map(&:id).compact
133 133 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
134 134 else
135 135 '1=0'
136 136 end
137 137 else
138 138 "(#{table_name}.is_private = #{connection.quoted_false})"
139 139 end
140 140 unless role.permissions_all_trackers?(:view_issues)
141 141 tracker_ids = role.permissions_tracker_ids(:view_issues)
142 142 if tracker_ids.any?
143 143 sql = "(#{sql} AND #{table_name}.tracker_id IN (#{tracker_ids.join(',')}))"
144 144 else
145 145 sql = '1=0'
146 146 end
147 147 end
148 148 sql
149 149 end
150 150 end
151 151
152 152 # Returns true if usr or current user is allowed to view the issue
153 153 def visible?(usr=nil)
154 154 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
155 155 visible = if user.logged?
156 156 case role.issues_visibility
157 157 when 'all'
158 158 true
159 159 when 'default'
160 160 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
161 161 when 'own'
162 162 self.author == user || user.is_or_belongs_to?(assigned_to)
163 163 else
164 164 false
165 165 end
166 166 else
167 167 !self.is_private?
168 168 end
169 169 unless role.permissions_all_trackers?(:view_issues)
170 170 visible &&= role.permissions_tracker_ids?(:view_issues, tracker_id)
171 171 end
172 172 visible
173 173 end
174 174 end
175 175
176 176 # Returns true if user or current user is allowed to edit or add notes to the issue
177 177 def editable?(user=User.current)
178 178 attributes_editable?(user) || notes_addable?(user)
179 179 end
180 180
181 181 # Returns true if user or current user is allowed to edit the issue
182 182 def attributes_editable?(user=User.current)
183 183 user_tracker_permission?(user, :edit_issues)
184 184 end
185 185
186 186 # Overrides Redmine::Acts::Attachable::InstanceMethods#attachments_editable?
187 187 def attachments_editable?(user=User.current)
188 188 attributes_editable?(user)
189 189 end
190 190
191 191 # Returns true if user or current user is allowed to add notes to the issue
192 192 def notes_addable?(user=User.current)
193 193 user_tracker_permission?(user, :add_issue_notes)
194 194 end
195 195
196 196 # Returns true if user or current user is allowed to delete the issue
197 197 def deletable?(user=User.current)
198 198 user_tracker_permission?(user, :delete_issues)
199 199 end
200 200
201 201 def initialize(attributes=nil, *args)
202 202 super
203 203 if new_record?
204 204 # set default values for new records only
205 205 self.priority ||= IssuePriority.default
206 206 self.watcher_user_ids = []
207 207 end
208 208 end
209 209
210 210 def create_or_update
211 211 super
212 212 ensure
213 213 @status_was = nil
214 214 end
215 215 private :create_or_update
216 216
217 217 # AR#Persistence#destroy would raise and RecordNotFound exception
218 218 # if the issue was already deleted or updated (non matching lock_version).
219 219 # This is a problem when bulk deleting issues or deleting a project
220 220 # (because an issue may already be deleted if its parent was deleted
221 221 # first).
222 222 # The issue is reloaded by the nested_set before being deleted so
223 223 # the lock_version condition should not be an issue but we handle it.
224 224 def destroy
225 225 super
226 226 rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotFound
227 227 # Stale or already deleted
228 228 begin
229 229 reload
230 230 rescue ActiveRecord::RecordNotFound
231 231 # The issue was actually already deleted
232 232 @destroyed = true
233 233 return freeze
234 234 end
235 235 # The issue was stale, retry to destroy
236 236 super
237 237 end
238 238
239 239 alias :base_reload :reload
240 240 def reload(*args)
241 241 @workflow_rule_by_attribute = nil
242 242 @assignable_versions = nil
243 243 @relations = nil
244 244 @spent_hours = nil
245 245 @total_spent_hours = nil
246 246 @total_estimated_hours = nil
247 247 base_reload(*args)
248 248 end
249 249
250 250 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
251 251 def available_custom_fields
252 252 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
253 253 end
254 254
255 255 def visible_custom_field_values(user=nil)
256 256 user_real = user || User.current
257 257 custom_field_values.select do |value|
258 258 value.custom_field.visible_by?(project, user_real)
259 259 end
260 260 end
261 261
262 262 # Copies attributes from another issue, arg can be an id or an Issue
263 263 def copy_from(arg, options={})
264 264 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
265 265 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
266 266 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
267 267 self.status = issue.status
268 268 self.author = User.current
269 269 unless options[:attachments] == false
270 270 self.attachments = issue.attachments.map do |attachement|
271 271 attachement.copy(:container => self)
272 272 end
273 273 end
274 274 @copied_from = issue
275 275 @copy_options = options
276 276 self
277 277 end
278 278
279 279 # Returns an unsaved copy of the issue
280 280 def copy(attributes=nil, copy_options={})
281 281 copy = self.class.new.copy_from(self, copy_options)
282 282 copy.attributes = attributes if attributes
283 283 copy
284 284 end
285 285
286 286 # Returns true if the issue is a copy
287 287 def copy?
288 288 @copied_from.present?
289 289 end
290 290
291 291 def status_id=(status_id)
292 292 if status_id.to_s != self.status_id.to_s
293 293 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
294 294 end
295 295 self.status_id
296 296 end
297 297
298 298 # Sets the status.
299 299 def status=(status)
300 300 if status != self.status
301 301 @workflow_rule_by_attribute = nil
302 302 end
303 303 association(:status).writer(status)
304 304 end
305 305
306 306 def priority_id=(pid)
307 307 self.priority = nil
308 308 write_attribute(:priority_id, pid)
309 309 end
310 310
311 311 def category_id=(cid)
312 312 self.category = nil
313 313 write_attribute(:category_id, cid)
314 314 end
315 315
316 316 def fixed_version_id=(vid)
317 317 self.fixed_version = nil
318 318 write_attribute(:fixed_version_id, vid)
319 319 end
320 320
321 321 def tracker_id=(tracker_id)
322 322 if tracker_id.to_s != self.tracker_id.to_s
323 323 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
324 324 end
325 325 self.tracker_id
326 326 end
327 327
328 328 # Sets the tracker.
329 329 # This will set the status to the default status of the new tracker if:
330 330 # * the status was the default for the previous tracker
331 331 # * or if the status was not part of the new tracker statuses
332 332 # * or the status was nil
333 333 def tracker=(tracker)
334 334 tracker_was = self.tracker
335 335 association(:tracker).writer(tracker)
336 336 if tracker != tracker_was
337 337 if status == tracker_was.try(:default_status)
338 338 self.status = nil
339 339 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
340 340 self.status = nil
341 341 end
342 342 reassign_custom_field_values
343 343 @workflow_rule_by_attribute = nil
344 344 end
345 345 self.status ||= default_status
346 346 self.tracker
347 347 end
348 348
349 349 def project_id=(project_id)
350 350 if project_id.to_s != self.project_id.to_s
351 351 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
352 352 end
353 353 self.project_id
354 354 end
355 355
356 356 # Sets the project.
357 357 # Unless keep_tracker argument is set to true, this will change the tracker
358 358 # to the first tracker of the new project if the previous tracker is not part
359 359 # of the new project trackers.
360 360 # This will:
361 361 # * clear the fixed_version is it's no longer valid for the new project.
362 362 # * clear the parent issue if it's no longer valid for the new project.
363 363 # * set the category to the category with the same name in the new
364 364 # project if it exists, or clear it if it doesn't.
365 365 # * for new issue, set the fixed_version to the project default version
366 366 # if it's a valid fixed_version.
367 367 def project=(project, keep_tracker=false)
368 368 project_was = self.project
369 369 association(:project).writer(project)
370 370 if project_was && project && project_was != project
371 371 @assignable_versions = nil
372 372
373 373 unless keep_tracker || project.trackers.include?(tracker)
374 374 self.tracker = project.trackers.first
375 375 end
376 376 # Reassign to the category with same name if any
377 377 if category
378 378 self.category = project.issue_categories.find_by_name(category.name)
379 379 end
380 380 # Keep the fixed_version if it's still valid in the new_project
381 381 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
382 382 self.fixed_version = nil
383 383 end
384 384 # Clear the parent task if it's no longer valid
385 385 unless valid_parent_project?
386 386 self.parent_issue_id = nil
387 387 end
388 388 reassign_custom_field_values
389 389 @workflow_rule_by_attribute = nil
390 390 end
391 391 # Set fixed_version to the project default version if it's valid
392 392 if new_record? && fixed_version.nil? && project && project.default_version_id?
393 393 if project.shared_versions.open.exists?(project.default_version_id)
394 394 self.fixed_version_id = project.default_version_id
395 395 end
396 396 end
397 397 self.project
398 398 end
399 399
400 400 def description=(arg)
401 401 if arg.is_a?(String)
402 402 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
403 403 end
404 404 write_attribute(:description, arg)
405 405 end
406 406
407 407 def deleted_attachment_ids
408 408 Array(@deleted_attachment_ids).map(&:to_i)
409 409 end
410 410
411 411 # Overrides assign_attributes so that project and tracker get assigned first
412 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
412 def assign_attributes(new_attributes, *args)
413 413 return if new_attributes.nil?
414 414 attrs = new_attributes.dup
415 415 attrs.stringify_keys!
416 416
417 417 %w(project project_id tracker tracker_id).each do |attr|
418 418 if attrs.has_key?(attr)
419 419 send "#{attr}=", attrs.delete(attr)
420 420 end
421 421 end
422 send :assign_attributes_without_project_and_tracker_first, attrs, *args
422 super attrs, *args
423 423 end
424 # Do not redefine alias chain on reload (see #4838)
425 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
426 424
427 425 def attributes=(new_attributes)
428 426 assign_attributes new_attributes
429 427 end
430 428
431 429 def estimated_hours=(h)
432 430 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
433 431 end
434 432
435 433 safe_attributes 'project_id',
436 434 'tracker_id',
437 435 'status_id',
438 436 'category_id',
439 437 'assigned_to_id',
440 438 'priority_id',
441 439 'fixed_version_id',
442 440 'subject',
443 441 'description',
444 442 'start_date',
445 443 'due_date',
446 444 'done_ratio',
447 445 'estimated_hours',
448 446 'custom_field_values',
449 447 'custom_fields',
450 448 'lock_version',
451 449 'notes',
452 450 :if => lambda {|issue, user| issue.new_record? || issue.attributes_editable?(user) }
453 451
454 452 safe_attributes 'notes',
455 453 :if => lambda {|issue, user| issue.notes_addable?(user)}
456 454
457 455 safe_attributes 'private_notes',
458 456 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
459 457
460 458 safe_attributes 'watcher_user_ids',
461 459 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
462 460
463 461 safe_attributes 'is_private',
464 462 :if => lambda {|issue, user|
465 463 user.allowed_to?(:set_issues_private, issue.project) ||
466 464 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
467 465 }
468 466
469 467 safe_attributes 'parent_issue_id',
470 468 :if => lambda {|issue, user| (issue.new_record? || issue.attributes_editable?(user)) &&
471 469 user.allowed_to?(:manage_subtasks, issue.project)}
472 470
473 471 safe_attributes 'deleted_attachment_ids',
474 472 :if => lambda {|issue, user| issue.attachments_deletable?(user)}
475 473
476 474 def safe_attribute_names(user=nil)
477 475 names = super
478 476 names -= disabled_core_fields
479 477 names -= read_only_attribute_names(user)
480 478 if new_record?
481 479 # Make sure that project_id can always be set for new issues
482 480 names |= %w(project_id)
483 481 end
484 482 if dates_derived?
485 483 names -= %w(start_date due_date)
486 484 end
487 485 if priority_derived?
488 486 names -= %w(priority_id)
489 487 end
490 488 if done_ratio_derived?
491 489 names -= %w(done_ratio)
492 490 end
493 491 names
494 492 end
495 493
496 494 # Safely sets attributes
497 495 # Should be called from controllers instead of #attributes=
498 496 # attr_accessible is too rough because we still want things like
499 497 # Issue.new(:project => foo) to work
500 498 def safe_attributes=(attrs, user=User.current)
501 499 return unless attrs.is_a?(Hash)
502 500
503 501 attrs = attrs.deep_dup
504 502
505 503 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
506 504 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
507 505 if allowed_target_projects(user).where(:id => p.to_i).exists?
508 506 self.project_id = p
509 507 end
510 508
511 509 if project_id_changed? && attrs['category_id'].to_s == category_id_was.to_s
512 510 # Discard submitted category on previous project
513 511 attrs.delete('category_id')
514 512 end
515 513 end
516 514
517 515 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
518 516 if allowed_target_trackers(user).where(:id => t.to_i).exists?
519 517 self.tracker_id = t
520 518 end
521 519 end
522 520 if project
523 521 # Set a default tracker to accept custom field values
524 522 # even if tracker is not specified
525 523 self.tracker ||= allowed_target_trackers(user).first
526 524 end
527 525
528 526 statuses_allowed = new_statuses_allowed_to(user)
529 527 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
530 528 if statuses_allowed.collect(&:id).include?(s.to_i)
531 529 self.status_id = s
532 530 end
533 531 end
534 532 if new_record? && !statuses_allowed.include?(status)
535 533 self.status = statuses_allowed.first || default_status
536 534 end
537 535 if (u = attrs.delete('assigned_to_id')) && safe_attribute?('assigned_to_id')
538 536 if u.blank?
539 537 self.assigned_to_id = nil
540 538 else
541 539 u = u.to_i
542 540 if assignable_users.any?{|assignable_user| assignable_user.id == u}
543 541 self.assigned_to_id = u
544 542 end
545 543 end
546 544 end
547 545
548 546
549 547 attrs = delete_unsafe_attributes(attrs, user)
550 548 return if attrs.empty?
551 549
552 550 if attrs['parent_issue_id'].present?
553 551 s = attrs['parent_issue_id'].to_s
554 552 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
555 553 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
556 554 end
557 555 end
558 556
559 557 if attrs['custom_field_values'].present?
560 558 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
561 559 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
562 560 end
563 561
564 562 if attrs['custom_fields'].present?
565 563 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
566 564 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
567 565 end
568 566
569 567 # mass-assignment security bypass
570 568 assign_attributes attrs, :without_protection => true
571 569 end
572 570
573 571 def disabled_core_fields
574 572 tracker ? tracker.disabled_core_fields : []
575 573 end
576 574
577 575 # Returns the custom_field_values that can be edited by the given user
578 576 def editable_custom_field_values(user=nil)
579 577 visible_custom_field_values(user).reject do |value|
580 578 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
581 579 end
582 580 end
583 581
584 582 # Returns the custom fields that can be edited by the given user
585 583 def editable_custom_fields(user=nil)
586 584 editable_custom_field_values(user).map(&:custom_field).uniq
587 585 end
588 586
589 587 # Returns the names of attributes that are read-only for user or the current user
590 588 # For users with multiple roles, the read-only fields are the intersection of
591 589 # read-only fields of each role
592 590 # The result is an array of strings where sustom fields are represented with their ids
593 591 #
594 592 # Examples:
595 593 # issue.read_only_attribute_names # => ['due_date', '2']
596 594 # issue.read_only_attribute_names(user) # => []
597 595 def read_only_attribute_names(user=nil)
598 596 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
599 597 end
600 598
601 599 # Returns the names of required attributes for user or the current user
602 600 # For users with multiple roles, the required fields are the intersection of
603 601 # required fields of each role
604 602 # The result is an array of strings where sustom fields are represented with their ids
605 603 #
606 604 # Examples:
607 605 # issue.required_attribute_names # => ['due_date', '2']
608 606 # issue.required_attribute_names(user) # => []
609 607 def required_attribute_names(user=nil)
610 608 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
611 609 end
612 610
613 611 # Returns true if the attribute is required for user
614 612 def required_attribute?(name, user=nil)
615 613 required_attribute_names(user).include?(name.to_s)
616 614 end
617 615
618 616 # Returns a hash of the workflow rule by attribute for the given user
619 617 #
620 618 # Examples:
621 619 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
622 620 def workflow_rule_by_attribute(user=nil)
623 621 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
624 622
625 623 user_real = user || User.current
626 624 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
627 625 roles = roles.select(&:consider_workflow?)
628 626 return {} if roles.empty?
629 627
630 628 result = {}
631 629 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
632 630 if workflow_permissions.any?
633 631 workflow_rules = workflow_permissions.inject({}) do |h, wp|
634 632 h[wp.field_name] ||= {}
635 633 h[wp.field_name][wp.role_id] = wp.rule
636 634 h
637 635 end
638 636 fields_with_roles = {}
639 637 IssueCustomField.where(:visible => false).joins(:roles).pluck(:id, "role_id").each do |field_id, role_id|
640 638 fields_with_roles[field_id] ||= []
641 639 fields_with_roles[field_id] << role_id
642 640 end
643 641 roles.each do |role|
644 642 fields_with_roles.each do |field_id, role_ids|
645 643 unless role_ids.include?(role.id)
646 644 field_name = field_id.to_s
647 645 workflow_rules[field_name] ||= {}
648 646 workflow_rules[field_name][role.id] = 'readonly'
649 647 end
650 648 end
651 649 end
652 650 workflow_rules.each do |attr, rules|
653 651 next if rules.size < roles.size
654 652 uniq_rules = rules.values.uniq
655 653 if uniq_rules.size == 1
656 654 result[attr] = uniq_rules.first
657 655 else
658 656 result[attr] = 'required'
659 657 end
660 658 end
661 659 end
662 660 @workflow_rule_by_attribute = result if user.nil?
663 661 result
664 662 end
665 663 private :workflow_rule_by_attribute
666 664
667 665 def done_ratio
668 666 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
669 667 status.default_done_ratio
670 668 else
671 669 read_attribute(:done_ratio)
672 670 end
673 671 end
674 672
675 673 def self.use_status_for_done_ratio?
676 674 Setting.issue_done_ratio == 'issue_status'
677 675 end
678 676
679 677 def self.use_field_for_done_ratio?
680 678 Setting.issue_done_ratio == 'issue_field'
681 679 end
682 680
683 681 def validate_issue
684 682 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
685 683 errors.add :due_date, :greater_than_start_date
686 684 end
687 685
688 686 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
689 687 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
690 688 end
691 689
692 690 if fixed_version
693 691 if !assignable_versions.include?(fixed_version)
694 692 errors.add :fixed_version_id, :inclusion
695 693 elsif reopening? && fixed_version.closed?
696 694 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
697 695 end
698 696 end
699 697
700 698 # Checks that the issue can not be added/moved to a disabled tracker
701 699 if project && (tracker_id_changed? || project_id_changed?)
702 700 if tracker && !project.trackers.include?(tracker)
703 701 errors.add :tracker_id, :inclusion
704 702 end
705 703 end
706 704
707 705 # Checks parent issue assignment
708 706 if @invalid_parent_issue_id.present?
709 707 errors.add :parent_issue_id, :invalid
710 708 elsif @parent_issue
711 709 if !valid_parent_project?(@parent_issue)
712 710 errors.add :parent_issue_id, :invalid
713 711 elsif (@parent_issue != parent) && (
714 712 self.would_reschedule?(@parent_issue) ||
715 713 @parent_issue.self_and_ancestors.any? {|a| a.relations_from.any? {|r| r.relation_type == IssueRelation::TYPE_PRECEDES && r.issue_to.would_reschedule?(self)}}
716 714 )
717 715 errors.add :parent_issue_id, :invalid
718 716 elsif !new_record?
719 717 # moving an existing issue
720 718 if move_possible?(@parent_issue)
721 719 # move accepted
722 720 else
723 721 errors.add :parent_issue_id, :invalid
724 722 end
725 723 end
726 724 end
727 725 end
728 726
729 727 # Validates the issue against additional workflow requirements
730 728 def validate_required_fields
731 729 user = new_record? ? author : current_journal.try(:user)
732 730
733 731 required_attribute_names(user).each do |attribute|
734 732 if attribute =~ /^\d+$/
735 733 attribute = attribute.to_i
736 734 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
737 735 if v && Array(v.value).detect(&:present?).nil?
738 736 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
739 737 end
740 738 else
741 739 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
742 740 next if attribute == 'category_id' && project.try(:issue_categories).blank?
743 741 next if attribute == 'fixed_version_id' && assignable_versions.blank?
744 742 errors.add attribute, :blank
745 743 end
746 744 end
747 745 end
748 746 end
749 747
750 748 # Overrides Redmine::Acts::Customizable::InstanceMethods#validate_custom_field_values
751 749 # so that custom values that are not editable are not validated (eg. a custom field that
752 750 # is marked as required should not trigger a validation error if the user is not allowed
753 751 # to edit this field).
754 752 def validate_custom_field_values
755 753 user = new_record? ? author : current_journal.try(:user)
756 754 if new_record? || custom_field_values_changed?
757 755 editable_custom_field_values(user).each(&:validate_value)
758 756 end
759 757 end
760 758
761 759 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
762 760 # even if the user turns off the setting later
763 761 def update_done_ratio_from_issue_status
764 762 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
765 763 self.done_ratio = status.default_done_ratio
766 764 end
767 765 end
768 766
769 767 def init_journal(user, notes = "")
770 768 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
771 769 end
772 770
773 771 # Returns the current journal or nil if it's not initialized
774 772 def current_journal
775 773 @current_journal
776 774 end
777 775
778 776 # Returns the names of attributes that are journalized when updating the issue
779 777 def journalized_attribute_names
780 778 names = Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
781 779 if tracker
782 780 names -= tracker.disabled_core_fields
783 781 end
784 782 names
785 783 end
786 784
787 785 # Returns the id of the last journal or nil
788 786 def last_journal_id
789 787 if new_record?
790 788 nil
791 789 else
792 790 journals.maximum(:id)
793 791 end
794 792 end
795 793
796 794 # Returns a scope for journals that have an id greater than journal_id
797 795 def journals_after(journal_id)
798 796 scope = journals.reorder("#{Journal.table_name}.id ASC")
799 797 if journal_id.present?
800 798 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
801 799 end
802 800 scope
803 801 end
804 802
805 803 # Returns the initial status of the issue
806 804 # Returns nil for a new issue
807 805 def status_was
808 806 if status_id_changed?
809 807 if status_id_was.to_i > 0
810 808 @status_was ||= IssueStatus.find_by_id(status_id_was)
811 809 end
812 810 else
813 811 @status_was ||= status
814 812 end
815 813 end
816 814
817 815 # Return true if the issue is closed, otherwise false
818 816 def closed?
819 817 status.present? && status.is_closed?
820 818 end
821 819
822 820 # Returns true if the issue was closed when loaded
823 821 def was_closed?
824 822 status_was.present? && status_was.is_closed?
825 823 end
826 824
827 825 # Return true if the issue is being reopened
828 826 def reopening?
829 827 if new_record?
830 828 false
831 829 else
832 830 status_id_changed? && !closed? && was_closed?
833 831 end
834 832 end
835 833 alias :reopened? :reopening?
836 834
837 835 # Return true if the issue is being closed
838 836 def closing?
839 837 if new_record?
840 838 closed?
841 839 else
842 840 status_id_changed? && closed? && !was_closed?
843 841 end
844 842 end
845 843
846 844 # Returns true if the issue is overdue
847 845 def overdue?
848 846 due_date.present? && (due_date < User.current.today) && !closed?
849 847 end
850 848
851 849 # Is the amount of work done less than it should for the due date
852 850 def behind_schedule?
853 851 return false if start_date.nil? || due_date.nil?
854 852 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
855 853 return done_date <= User.current.today
856 854 end
857 855
858 856 # Does this issue have children?
859 857 def children?
860 858 !leaf?
861 859 end
862 860
863 861 # Users the issue can be assigned to
864 862 def assignable_users
865 863 users = project.assignable_users(tracker).to_a
866 864 users << author if author && author.active?
867 865 users << assigned_to if assigned_to
868 866 users.uniq.sort
869 867 end
870 868
871 869 # Versions that the issue can be assigned to
872 870 def assignable_versions
873 871 return @assignable_versions if @assignable_versions
874 872
875 873 versions = project.shared_versions.open.to_a
876 874 if fixed_version
877 875 if fixed_version_id_changed?
878 876 # nothing to do
879 877 elsif project_id_changed?
880 878 if project.shared_versions.include?(fixed_version)
881 879 versions << fixed_version
882 880 end
883 881 else
884 882 versions << fixed_version
885 883 end
886 884 end
887 885 @assignable_versions = versions.uniq.sort
888 886 end
889 887
890 888 # Returns true if this issue is blocked by another issue that is still open
891 889 def blocked?
892 890 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
893 891 end
894 892
895 893 # Returns the default status of the issue based on its tracker
896 894 # Returns nil if tracker is nil
897 895 def default_status
898 896 tracker.try(:default_status)
899 897 end
900 898
901 899 # Returns an array of statuses that user is able to apply
902 900 def new_statuses_allowed_to(user=User.current, include_default=false)
903 901 if new_record? && @copied_from
904 902 [default_status, @copied_from.status].compact.uniq.sort
905 903 else
906 904 initial_status = nil
907 905 if new_record?
908 906 # nop
909 907 elsif tracker_id_changed?
910 908 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
911 909 initial_status = default_status
912 910 elsif tracker.issue_status_ids.include?(status_id_was)
913 911 initial_status = IssueStatus.find_by_id(status_id_was)
914 912 else
915 913 initial_status = default_status
916 914 end
917 915 else
918 916 initial_status = status_was
919 917 end
920 918
921 919 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
922 920 assignee_transitions_allowed = initial_assigned_to_id.present? &&
923 921 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
924 922
925 923 statuses = []
926 924 statuses += IssueStatus.new_statuses_allowed(
927 925 initial_status,
928 926 user.admin ? Role.all.to_a : user.roles_for_project(project),
929 927 tracker,
930 928 author == user,
931 929 assignee_transitions_allowed
932 930 )
933 931 statuses << initial_status unless statuses.empty?
934 932 statuses << default_status if include_default || (new_record? && statuses.empty?)
935 933 statuses = statuses.compact.uniq.sort
936 934 if blocked?
937 935 statuses.reject!(&:is_closed?)
938 936 end
939 937 statuses
940 938 end
941 939 end
942 940
943 941 # Returns the previous assignee (user or group) if changed
944 942 def assigned_to_was
945 943 # assigned_to_id_was is reset before after_save callbacks
946 944 user_id = @previous_assigned_to_id || assigned_to_id_was
947 945 if user_id && user_id != assigned_to_id
948 946 @assigned_to_was ||= Principal.find_by_id(user_id)
949 947 end
950 948 end
951 949
952 950 # Returns the original tracker
953 951 def tracker_was
954 952 Tracker.find_by_id(tracker_id_was)
955 953 end
956 954
957 955 # Returns the users that should be notified
958 956 def notified_users
959 957 notified = []
960 958 # Author and assignee are always notified unless they have been
961 959 # locked or don't want to be notified
962 960 notified << author if author
963 961 if assigned_to
964 962 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
965 963 end
966 964 if assigned_to_was
967 965 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
968 966 end
969 967 notified = notified.select {|u| u.active? && u.notify_about?(self)}
970 968
971 969 notified += project.notified_users
972 970 notified.uniq!
973 971 # Remove users that can not view the issue
974 972 notified.reject! {|user| !visible?(user)}
975 973 notified
976 974 end
977 975
978 976 # Returns the email addresses that should be notified
979 977 def recipients
980 978 notified_users.collect(&:mail)
981 979 end
982 980
983 981 def each_notification(users, &block)
984 982 if users.any?
985 983 if custom_field_values.detect {|value| !value.custom_field.visible?}
986 984 users_by_custom_field_visibility = users.group_by do |user|
987 985 visible_custom_field_values(user).map(&:custom_field_id).sort
988 986 end
989 987 users_by_custom_field_visibility.values.each do |users|
990 988 yield(users)
991 989 end
992 990 else
993 991 yield(users)
994 992 end
995 993 end
996 994 end
997 995
998 996 def notify?
999 997 @notify != false
1000 998 end
1001 999
1002 1000 def notify=(arg)
1003 1001 @notify = arg
1004 1002 end
1005 1003
1006 1004 # Returns the number of hours spent on this issue
1007 1005 def spent_hours
1008 1006 @spent_hours ||= time_entries.sum(:hours) || 0
1009 1007 end
1010 1008
1011 1009 # Returns the total number of hours spent on this issue and its descendants
1012 1010 def total_spent_hours
1013 1011 @total_spent_hours ||= if leaf?
1014 1012 spent_hours
1015 1013 else
1016 1014 self_and_descendants.joins(:time_entries).sum("#{TimeEntry.table_name}.hours").to_f || 0.0
1017 1015 end
1018 1016 end
1019 1017
1020 1018 def total_estimated_hours
1021 1019 if leaf?
1022 1020 estimated_hours
1023 1021 else
1024 1022 @total_estimated_hours ||= self_and_descendants.sum(:estimated_hours)
1025 1023 end
1026 1024 end
1027 1025
1028 1026 def relations
1029 1027 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
1030 1028 end
1031 1029
1032 1030 # Preloads relations for a collection of issues
1033 1031 def self.load_relations(issues)
1034 1032 if issues.any?
1035 1033 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
1036 1034 issues.each do |issue|
1037 1035 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
1038 1036 end
1039 1037 end
1040 1038 end
1041 1039
1042 1040 # Preloads visible spent time for a collection of issues
1043 1041 def self.load_visible_spent_hours(issues, user=User.current)
1044 1042 if issues.any?
1045 1043 hours_by_issue_id = TimeEntry.visible(user).where(:issue_id => issues.map(&:id)).group(:issue_id).sum(:hours)
1046 1044 issues.each do |issue|
1047 1045 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
1048 1046 end
1049 1047 end
1050 1048 end
1051 1049
1052 1050 # Preloads visible total spent time for a collection of issues
1053 1051 def self.load_visible_total_spent_hours(issues, user=User.current)
1054 1052 if issues.any?
1055 1053 hours_by_issue_id = TimeEntry.visible(user).joins(:issue).
1056 1054 joins("JOIN #{Issue.table_name} parent ON parent.root_id = #{Issue.table_name}.root_id" +
1057 1055 " AND parent.lft <= #{Issue.table_name}.lft AND parent.rgt >= #{Issue.table_name}.rgt").
1058 1056 where("parent.id IN (?)", issues.map(&:id)).group("parent.id").sum(:hours)
1059 1057 issues.each do |issue|
1060 1058 issue.instance_variable_set "@total_spent_hours", (hours_by_issue_id[issue.id] || 0)
1061 1059 end
1062 1060 end
1063 1061 end
1064 1062
1065 1063 # Preloads visible relations for a collection of issues
1066 1064 def self.load_visible_relations(issues, user=User.current)
1067 1065 if issues.any?
1068 1066 issue_ids = issues.map(&:id)
1069 1067 # Relations with issue_from in given issues and visible issue_to
1070 1068 relations_from = IssueRelation.joins(:issue_to => :project).
1071 1069 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
1072 1070 # Relations with issue_to in given issues and visible issue_from
1073 1071 relations_to = IssueRelation.joins(:issue_from => :project).
1074 1072 where(visible_condition(user)).
1075 1073 where(:issue_to_id => issue_ids).to_a
1076 1074 issues.each do |issue|
1077 1075 relations =
1078 1076 relations_from.select {|relation| relation.issue_from_id == issue.id} +
1079 1077 relations_to.select {|relation| relation.issue_to_id == issue.id}
1080 1078
1081 1079 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
1082 1080 end
1083 1081 end
1084 1082 end
1085 1083
1086 1084 # Finds an issue relation given its id.
1087 1085 def find_relation(relation_id)
1088 1086 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
1089 1087 end
1090 1088
1091 1089 # Returns true if this issue blocks the other issue, otherwise returns false
1092 1090 def blocks?(other)
1093 1091 all = [self]
1094 1092 last = [self]
1095 1093 while last.any?
1096 1094 current = last.map {|i| i.relations_from.where(:relation_type => IssueRelation::TYPE_BLOCKS).map(&:issue_to)}.flatten.uniq
1097 1095 current -= last
1098 1096 current -= all
1099 1097 return true if current.include?(other)
1100 1098 last = current
1101 1099 all += last
1102 1100 end
1103 1101 false
1104 1102 end
1105 1103
1106 1104 # Returns true if the other issue might be rescheduled if the start/due dates of this issue change
1107 1105 def would_reschedule?(other)
1108 1106 all = [self]
1109 1107 last = [self]
1110 1108 while last.any?
1111 1109 current = last.map {|i|
1112 1110 i.relations_from.where(:relation_type => IssueRelation::TYPE_PRECEDES).map(&:issue_to) +
1113 1111 i.leaves.to_a +
1114 1112 i.ancestors.map {|a| a.relations_from.where(:relation_type => IssueRelation::TYPE_PRECEDES).map(&:issue_to)}
1115 1113 }.flatten.uniq
1116 1114 current -= last
1117 1115 current -= all
1118 1116 return true if current.include?(other)
1119 1117 last = current
1120 1118 all += last
1121 1119 end
1122 1120 false
1123 1121 end
1124 1122
1125 1123 # Returns an array of issues that duplicate this one
1126 1124 def duplicates
1127 1125 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1128 1126 end
1129 1127
1130 1128 # Returns the due date or the target due date if any
1131 1129 # Used on gantt chart
1132 1130 def due_before
1133 1131 due_date || (fixed_version ? fixed_version.effective_date : nil)
1134 1132 end
1135 1133
1136 1134 # Returns the time scheduled for this issue.
1137 1135 #
1138 1136 # Example:
1139 1137 # Start Date: 2/26/09, End Date: 3/04/09
1140 1138 # duration => 6
1141 1139 def duration
1142 1140 (start_date && due_date) ? due_date - start_date : 0
1143 1141 end
1144 1142
1145 1143 # Returns the duration in working days
1146 1144 def working_duration
1147 1145 (start_date && due_date) ? working_days(start_date, due_date) : 0
1148 1146 end
1149 1147
1150 1148 def soonest_start(reload=false)
1151 1149 if @soonest_start.nil? || reload
1152 1150 dates = relations_to(reload).collect{|relation| relation.successor_soonest_start}
1153 1151 p = @parent_issue || parent
1154 1152 if p && Setting.parent_issue_dates == 'derived'
1155 1153 dates << p.soonest_start
1156 1154 end
1157 1155 @soonest_start = dates.compact.max
1158 1156 end
1159 1157 @soonest_start
1160 1158 end
1161 1159
1162 1160 # Sets start_date on the given date or the next working day
1163 1161 # and changes due_date to keep the same working duration.
1164 1162 def reschedule_on(date)
1165 1163 wd = working_duration
1166 1164 date = next_working_date(date)
1167 1165 self.start_date = date
1168 1166 self.due_date = add_working_days(date, wd)
1169 1167 end
1170 1168
1171 1169 # Reschedules the issue on the given date or the next working day and saves the record.
1172 1170 # If the issue is a parent task, this is done by rescheduling its subtasks.
1173 1171 def reschedule_on!(date)
1174 1172 return if date.nil?
1175 1173 if leaf? || !dates_derived?
1176 1174 if start_date.nil? || start_date != date
1177 1175 if start_date && start_date > date
1178 1176 # Issue can not be moved earlier than its soonest start date
1179 1177 date = [soonest_start(true), date].compact.max
1180 1178 end
1181 1179 reschedule_on(date)
1182 1180 begin
1183 1181 save
1184 1182 rescue ActiveRecord::StaleObjectError
1185 1183 reload
1186 1184 reschedule_on(date)
1187 1185 save
1188 1186 end
1189 1187 end
1190 1188 else
1191 1189 leaves.each do |leaf|
1192 1190 if leaf.start_date
1193 1191 # Only move subtask if it starts at the same date as the parent
1194 1192 # or if it starts before the given date
1195 1193 if start_date == leaf.start_date || date > leaf.start_date
1196 1194 leaf.reschedule_on!(date)
1197 1195 end
1198 1196 else
1199 1197 leaf.reschedule_on!(date)
1200 1198 end
1201 1199 end
1202 1200 end
1203 1201 end
1204 1202
1205 1203 def dates_derived?
1206 1204 !leaf? && Setting.parent_issue_dates == 'derived'
1207 1205 end
1208 1206
1209 1207 def priority_derived?
1210 1208 !leaf? && Setting.parent_issue_priority == 'derived'
1211 1209 end
1212 1210
1213 1211 def done_ratio_derived?
1214 1212 !leaf? && Setting.parent_issue_done_ratio == 'derived'
1215 1213 end
1216 1214
1217 1215 def <=>(issue)
1218 1216 if issue.nil?
1219 1217 -1
1220 1218 elsif root_id != issue.root_id
1221 1219 (root_id || 0) <=> (issue.root_id || 0)
1222 1220 else
1223 1221 (lft || 0) <=> (issue.lft || 0)
1224 1222 end
1225 1223 end
1226 1224
1227 1225 def to_s
1228 1226 "#{tracker} ##{id}: #{subject}"
1229 1227 end
1230 1228
1231 1229 # Returns a string of css classes that apply to the issue
1232 1230 def css_classes(user=User.current)
1233 1231 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1234 1232 s << ' closed' if closed?
1235 1233 s << ' overdue' if overdue?
1236 1234 s << ' child' if child?
1237 1235 s << ' parent' unless leaf?
1238 1236 s << ' private' if is_private?
1239 1237 if user.logged?
1240 1238 s << ' created-by-me' if author_id == user.id
1241 1239 s << ' assigned-to-me' if assigned_to_id == user.id
1242 1240 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1243 1241 end
1244 1242 s
1245 1243 end
1246 1244
1247 1245 # Unassigns issues from +version+ if it's no longer shared with issue's project
1248 1246 def self.update_versions_from_sharing_change(version)
1249 1247 # Update issues assigned to the version
1250 1248 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1251 1249 end
1252 1250
1253 1251 # Unassigns issues from versions that are no longer shared
1254 1252 # after +project+ was moved
1255 1253 def self.update_versions_from_hierarchy_change(project)
1256 1254 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1257 1255 # Update issues of the moved projects and issues assigned to a version of a moved project
1258 1256 Issue.update_versions(
1259 1257 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1260 1258 moved_project_ids, moved_project_ids]
1261 1259 )
1262 1260 end
1263 1261
1264 1262 def parent_issue_id=(arg)
1265 1263 s = arg.to_s.strip.presence
1266 1264 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1267 1265 @invalid_parent_issue_id = nil
1268 1266 elsif s.blank?
1269 1267 @parent_issue = nil
1270 1268 @invalid_parent_issue_id = nil
1271 1269 else
1272 1270 @parent_issue = nil
1273 1271 @invalid_parent_issue_id = arg
1274 1272 end
1275 1273 end
1276 1274
1277 1275 def parent_issue_id
1278 1276 if @invalid_parent_issue_id
1279 1277 @invalid_parent_issue_id
1280 1278 elsif instance_variable_defined? :@parent_issue
1281 1279 @parent_issue.nil? ? nil : @parent_issue.id
1282 1280 else
1283 1281 parent_id
1284 1282 end
1285 1283 end
1286 1284
1287 1285 def set_parent_id
1288 1286 self.parent_id = parent_issue_id
1289 1287 end
1290 1288
1291 1289 # Returns true if issue's project is a valid
1292 1290 # parent issue project
1293 1291 def valid_parent_project?(issue=parent)
1294 1292 return true if issue.nil? || issue.project_id == project_id
1295 1293
1296 1294 case Setting.cross_project_subtasks
1297 1295 when 'system'
1298 1296 true
1299 1297 when 'tree'
1300 1298 issue.project.root == project.root
1301 1299 when 'hierarchy'
1302 1300 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1303 1301 when 'descendants'
1304 1302 issue.project.is_or_is_ancestor_of?(project)
1305 1303 else
1306 1304 false
1307 1305 end
1308 1306 end
1309 1307
1310 1308 # Returns an issue scope based on project and scope
1311 1309 def self.cross_project_scope(project, scope=nil)
1312 1310 if project.nil?
1313 1311 return Issue
1314 1312 end
1315 1313 case scope
1316 1314 when 'all', 'system'
1317 1315 Issue
1318 1316 when 'tree'
1319 1317 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1320 1318 :lft => project.root.lft, :rgt => project.root.rgt)
1321 1319 when 'hierarchy'
1322 1320 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1323 1321 :lft => project.lft, :rgt => project.rgt)
1324 1322 when 'descendants'
1325 1323 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1326 1324 :lft => project.lft, :rgt => project.rgt)
1327 1325 else
1328 1326 Issue.where(:project_id => project.id)
1329 1327 end
1330 1328 end
1331 1329
1332 1330 def self.by_tracker(project)
1333 1331 count_and_group_by(:project => project, :association => :tracker)
1334 1332 end
1335 1333
1336 1334 def self.by_version(project)
1337 1335 count_and_group_by(:project => project, :association => :fixed_version)
1338 1336 end
1339 1337
1340 1338 def self.by_priority(project)
1341 1339 count_and_group_by(:project => project, :association => :priority)
1342 1340 end
1343 1341
1344 1342 def self.by_category(project)
1345 1343 count_and_group_by(:project => project, :association => :category)
1346 1344 end
1347 1345
1348 1346 def self.by_assigned_to(project)
1349 1347 count_and_group_by(:project => project, :association => :assigned_to)
1350 1348 end
1351 1349
1352 1350 def self.by_author(project)
1353 1351 count_and_group_by(:project => project, :association => :author)
1354 1352 end
1355 1353
1356 1354 def self.by_subproject(project)
1357 1355 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1358 1356 r.reject {|r| r["project_id"] == project.id.to_s}
1359 1357 end
1360 1358
1361 1359 # Query generator for selecting groups of issue counts for a project
1362 1360 # based on specific criteria
1363 1361 #
1364 1362 # Options
1365 1363 # * project - Project to search in.
1366 1364 # * with_subprojects - Includes subprojects issues if set to true.
1367 1365 # * association - Symbol. Association for grouping.
1368 1366 def self.count_and_group_by(options)
1369 1367 assoc = reflect_on_association(options[:association])
1370 1368 select_field = assoc.foreign_key
1371 1369
1372 1370 Issue.
1373 1371 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1374 1372 joins(:status, assoc.name).
1375 1373 group(:status_id, :is_closed, select_field).
1376 1374 count.
1377 1375 map do |columns, total|
1378 1376 status_id, is_closed, field_value = columns
1379 1377 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1380 1378 {
1381 1379 "status_id" => status_id.to_s,
1382 1380 "closed" => is_closed,
1383 1381 select_field => field_value.to_s,
1384 1382 "total" => total.to_s
1385 1383 }
1386 1384 end
1387 1385 end
1388 1386
1389 1387 # Returns a scope of projects that user can assign the issue to
1390 1388 def allowed_target_projects(user=User.current)
1391 1389 current_project = new_record? ? nil : project
1392 1390 self.class.allowed_target_projects(user, current_project)
1393 1391 end
1394 1392
1395 1393 # Returns a scope of projects that user can assign issues to
1396 1394 # If current_project is given, it will be included in the scope
1397 1395 def self.allowed_target_projects(user=User.current, current_project=nil)
1398 1396 condition = Project.allowed_to_condition(user, :add_issues)
1399 1397 if current_project
1400 1398 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1401 1399 end
1402 1400 Project.where(condition).having_trackers
1403 1401 end
1404 1402
1405 1403 # Returns a scope of trackers that user can assign the issue to
1406 1404 def allowed_target_trackers(user=User.current)
1407 1405 self.class.allowed_target_trackers(project, user, tracker_id_was)
1408 1406 end
1409 1407
1410 1408 # Returns a scope of trackers that user can assign project issues to
1411 1409 def self.allowed_target_trackers(project, user=User.current, current_tracker=nil)
1412 1410 if project
1413 1411 scope = project.trackers.sorted
1414 1412 unless user.admin?
1415 1413 roles = user.roles_for_project(project).select {|r| r.has_permission?(:add_issues)}
1416 1414 unless roles.any? {|r| r.permissions_all_trackers?(:add_issues)}
1417 1415 tracker_ids = roles.map {|r| r.permissions_tracker_ids(:add_issues)}.flatten.uniq
1418 1416 if current_tracker
1419 1417 tracker_ids << current_tracker
1420 1418 end
1421 1419 scope = scope.where(:id => tracker_ids)
1422 1420 end
1423 1421 end
1424 1422 scope
1425 1423 else
1426 1424 Tracker.none
1427 1425 end
1428 1426 end
1429 1427
1430 1428 private
1431 1429
1432 1430 def user_tracker_permission?(user, permission)
1433 1431 if user.admin?
1434 1432 true
1435 1433 else
1436 1434 roles = user.roles_for_project(project).select {|r| r.has_permission?(permission)}
1437 1435 roles.any? {|r| r.permissions_all_trackers?(permission) || r.permissions_tracker_ids?(permission, tracker_id)}
1438 1436 end
1439 1437 end
1440 1438
1441 1439 def after_project_change
1442 1440 # Update project_id on related time entries
1443 1441 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1444 1442
1445 1443 # Delete issue relations
1446 1444 unless Setting.cross_project_issue_relations?
1447 1445 relations_from.clear
1448 1446 relations_to.clear
1449 1447 end
1450 1448
1451 1449 # Move subtasks that were in the same project
1452 1450 children.each do |child|
1453 1451 next unless child.project_id == project_id_was
1454 1452 # Change project and keep project
1455 1453 child.send :project=, project, true
1456 1454 unless child.save
1457 1455 raise ActiveRecord::Rollback
1458 1456 end
1459 1457 end
1460 1458 end
1461 1459
1462 1460 # Callback for after the creation of an issue by copy
1463 1461 # * adds a "copied to" relation with the copied issue
1464 1462 # * copies subtasks from the copied issue
1465 1463 def after_create_from_copy
1466 1464 return unless copy? && !@after_create_from_copy_handled
1467 1465
1468 1466 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1469 1467 if @current_journal
1470 1468 @copied_from.init_journal(@current_journal.user)
1471 1469 end
1472 1470 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1473 1471 unless relation.save
1474 1472 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1475 1473 end
1476 1474 end
1477 1475
1478 1476 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1479 1477 copy_options = (@copy_options || {}).merge(:subtasks => false)
1480 1478 copied_issue_ids = {@copied_from.id => self.id}
1481 1479 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1482 1480 # Do not copy self when copying an issue as a descendant of the copied issue
1483 1481 next if child == self
1484 1482 # Do not copy subtasks of issues that were not copied
1485 1483 next unless copied_issue_ids[child.parent_id]
1486 1484 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1487 1485 unless child.visible?
1488 1486 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1489 1487 next
1490 1488 end
1491 1489 copy = Issue.new.copy_from(child, copy_options)
1492 1490 if @current_journal
1493 1491 copy.init_journal(@current_journal.user)
1494 1492 end
1495 1493 copy.author = author
1496 1494 copy.project = project
1497 1495 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1498 1496 unless copy.save
1499 1497 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1500 1498 next
1501 1499 end
1502 1500 copied_issue_ids[child.id] = copy.id
1503 1501 end
1504 1502 end
1505 1503 @after_create_from_copy_handled = true
1506 1504 end
1507 1505
1508 1506 def update_nested_set_attributes
1509 1507 if parent_id_changed?
1510 1508 update_nested_set_attributes_on_parent_change
1511 1509 end
1512 1510 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1513 1511 end
1514 1512
1515 1513 # Updates the nested set for when an existing issue is moved
1516 1514 def update_nested_set_attributes_on_parent_change
1517 1515 former_parent_id = parent_id_was
1518 1516 # delete invalid relations of all descendants
1519 1517 self_and_descendants.each do |issue|
1520 1518 issue.relations.each do |relation|
1521 1519 relation.destroy unless relation.valid?
1522 1520 end
1523 1521 end
1524 1522 # update former parent
1525 1523 recalculate_attributes_for(former_parent_id) if former_parent_id
1526 1524 end
1527 1525
1528 1526 def update_parent_attributes
1529 1527 if parent_id
1530 1528 recalculate_attributes_for(parent_id)
1531 1529 association(:parent).reset
1532 1530 end
1533 1531 end
1534 1532
1535 1533 def recalculate_attributes_for(issue_id)
1536 1534 if issue_id && p = Issue.find_by_id(issue_id)
1537 1535 if p.priority_derived?
1538 1536 # priority = highest priority of open children
1539 1537 if priority_position = p.children.open.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1540 1538 p.priority = IssuePriority.find_by_position(priority_position)
1541 1539 else
1542 1540 p.priority = IssuePriority.default
1543 1541 end
1544 1542 end
1545 1543
1546 1544 if p.dates_derived?
1547 1545 # start/due dates = lowest/highest dates of children
1548 1546 p.start_date = p.children.minimum(:start_date)
1549 1547 p.due_date = p.children.maximum(:due_date)
1550 1548 if p.start_date && p.due_date && p.due_date < p.start_date
1551 1549 p.start_date, p.due_date = p.due_date, p.start_date
1552 1550 end
1553 1551 end
1554 1552
1555 1553 if p.done_ratio_derived?
1556 1554 # done ratio = weighted average ratio of leaves
1557 1555 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1558 1556 child_count = p.children.count
1559 1557 if child_count > 0
1560 1558 average = p.children.where("estimated_hours > 0").average(:estimated_hours).to_f
1561 1559 if average == 0
1562 1560 average = 1
1563 1561 end
1564 1562 done = p.children.joins(:status).
1565 1563 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1566 1564 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1567 1565 progress = done / (average * child_count)
1568 1566 p.done_ratio = progress.round
1569 1567 end
1570 1568 end
1571 1569 end
1572 1570
1573 1571 # ancestors will be recursively updated
1574 1572 p.save(:validate => false)
1575 1573 end
1576 1574 end
1577 1575
1578 1576 # Update issues so their versions are not pointing to a
1579 1577 # fixed_version that is not shared with the issue's project
1580 1578 def self.update_versions(conditions=nil)
1581 1579 # Only need to update issues with a fixed_version from
1582 1580 # a different project and that is not systemwide shared
1583 1581 Issue.joins(:project, :fixed_version).
1584 1582 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1585 1583 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1586 1584 " AND #{Version.table_name}.sharing <> 'system'").
1587 1585 where(conditions).each do |issue|
1588 1586 next if issue.project.nil? || issue.fixed_version.nil?
1589 1587 unless issue.project.shared_versions.include?(issue.fixed_version)
1590 1588 issue.init_journal(User.current)
1591 1589 issue.fixed_version = nil
1592 1590 issue.save
1593 1591 end
1594 1592 end
1595 1593 end
1596 1594
1597 1595 def delete_selected_attachments
1598 1596 if deleted_attachment_ids.present?
1599 1597 objects = attachments.where(:id => deleted_attachment_ids.map(&:to_i))
1600 1598 attachments.delete(objects)
1601 1599 end
1602 1600 end
1603 1601
1604 1602 # Callback on file attachment
1605 1603 def attachment_added(attachment)
1606 1604 if current_journal && !attachment.new_record?
1607 1605 current_journal.journalize_attachment(attachment, :added)
1608 1606 end
1609 1607 end
1610 1608
1611 1609 # Callback on attachment deletion
1612 1610 def attachment_removed(attachment)
1613 1611 if current_journal && !attachment.new_record?
1614 1612 current_journal.journalize_attachment(attachment, :removed)
1615 1613 current_journal.save
1616 1614 end
1617 1615 end
1618 1616
1619 1617 # Called after a relation is added
1620 1618 def relation_added(relation)
1621 1619 if current_journal
1622 1620 current_journal.journalize_relation(relation, :added)
1623 1621 current_journal.save
1624 1622 end
1625 1623 end
1626 1624
1627 1625 # Called after a relation is removed
1628 1626 def relation_removed(relation)
1629 1627 if current_journal
1630 1628 current_journal.journalize_relation(relation, :removed)
1631 1629 current_journal.save
1632 1630 end
1633 1631 end
1634 1632
1635 1633 # Default assignment based on category
1636 1634 def default_assign
1637 1635 if assigned_to.nil? && category && category.assigned_to
1638 1636 self.assigned_to = category.assigned_to
1639 1637 end
1640 1638 end
1641 1639
1642 1640 # Updates start/due dates of following issues
1643 1641 def reschedule_following_issues
1644 1642 if start_date_changed? || due_date_changed?
1645 1643 relations_from.each do |relation|
1646 1644 relation.set_issue_to_dates
1647 1645 end
1648 1646 end
1649 1647 end
1650 1648
1651 1649 # Closes duplicates if the issue is being closed
1652 1650 def close_duplicates
1653 1651 if closing?
1654 1652 duplicates.each do |duplicate|
1655 1653 # Reload is needed in case the duplicate was updated by a previous duplicate
1656 1654 duplicate.reload
1657 1655 # Don't re-close it if it's already closed
1658 1656 next if duplicate.closed?
1659 1657 # Same user and notes
1660 1658 if @current_journal
1661 1659 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1662 1660 duplicate.private_notes = @current_journal.private_notes
1663 1661 end
1664 1662 duplicate.update_attribute :status, self.status
1665 1663 end
1666 1664 end
1667 1665 end
1668 1666
1669 1667 # Make sure updated_on is updated when adding a note and set updated_on now
1670 1668 # so we can set closed_on with the same value on closing
1671 1669 def force_updated_on_change
1672 1670 if @current_journal || changed?
1673 1671 self.updated_on = current_time_from_proper_timezone
1674 1672 if new_record?
1675 1673 self.created_on = updated_on
1676 1674 end
1677 1675 end
1678 1676 end
1679 1677
1680 1678 # Callback for setting closed_on when the issue is closed.
1681 1679 # The closed_on attribute stores the time of the last closing
1682 1680 # and is preserved when the issue is reopened.
1683 1681 def update_closed_on
1684 1682 if closing?
1685 1683 self.closed_on = updated_on
1686 1684 end
1687 1685 end
1688 1686
1689 1687 # Saves the changes in a Journal
1690 1688 # Called after_save
1691 1689 def create_journal
1692 1690 if current_journal
1693 1691 current_journal.save
1694 1692 end
1695 1693 end
1696 1694
1697 1695 def send_notification
1698 1696 if notify? && Setting.notified_events.include?('issue_added')
1699 1697 Mailer.deliver_issue_add(self)
1700 1698 end
1701 1699 end
1702 1700
1703 1701 # Stores the previous assignee so we can still have access
1704 1702 # to it during after_save callbacks (assigned_to_id_was is reset)
1705 1703 def set_assigned_to_was
1706 1704 @previous_assigned_to_id = assigned_to_id_was
1707 1705 end
1708 1706
1709 1707 # Clears the previous assignee at the end of after_save callbacks
1710 1708 def clear_assigned_to_was
1711 1709 @assigned_to_was = nil
1712 1710 @previous_assigned_to_id = nil
1713 1711 end
1714 1712
1715 1713 def clear_disabled_fields
1716 1714 if tracker
1717 1715 tracker.disabled_core_fields.each do |attribute|
1718 1716 send "#{attribute}=", nil
1719 1717 end
1720 1718 self.done_ratio ||= 0
1721 1719 end
1722 1720 end
1723 1721 end
General Comments 0
You need to be logged in to leave comments. Login now