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