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