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