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