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