GitLab steht wegen Wartungsarbeiten am Montag, den 10. Mai, zwischen 17:00 und 19:00 Uhr nicht zur Verfügung.

issue.rb 47 KB
Newer Older
1
# Redmine - project management software
jplang's avatar
jplang committed
2
# Copyright (C) 2006-2012  Jean-Philippe Lang
3 4 5 6 7
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
8
#
9 10 11 12
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
13
#
14 15 16 17 18
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

class Issue < ActiveRecord::Base
19
  include Redmine::SafeAttributes
20

21 22 23 24
  belongs_to :project
  belongs_to :tracker
  belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
  belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25
  belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26
  belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27
  belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 29 30
  belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'

  has_many :journals, :as => :journalized, :dependent => :destroy
jplang's avatar
jplang committed
31 32 33 34 35 36 37 38
  has_many :visible_journals,
    :class_name => 'Journal',
    :as => :journalized,
    :conditions => Proc.new { 
      ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
    },
    :readonly => true

39
  has_many :time_entries, :dependent => :delete_all
40
  has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
41

42 43
  has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
  has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
44

45
  acts_as_nested_set :scope => 'root_id', :dependent => :destroy
46
  acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47
  acts_as_customizable
48
  acts_as_watchable
49
  acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
jplang's avatar
jplang committed
50
                     :include => [:project, :visible_journals],
51 52
                     # sort by id so that limited eager loading doesn't break with postgresql
                     :order_column => "#{table_name}.id"
53
  acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
54 55
                :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
                :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
56

57 58
  acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
                            :author_key => :author_id
59 60

  DONE_RATIO_OPTIONS = %w(issue_field issue_status)
61 62

  attr_reader :current_journal
jplang's avatar
jplang committed
63
  delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
64

65
  validates_presence_of :subject, :priority, :project, :tracker, :author, :status
66

jplang's avatar
jplang committed
67
  validates_length_of :subject, :maximum => 255
68
  validates_inclusion_of :done_ratio, :in => 0..100
69
  validates_numericality_of :estimated_hours, :allow_nil => true
70
  validate :validate_issue, :validate_required_fields
71

72 73 74
  scope :visible,
        lambda {|*args| { :include => :project,
                          :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
75

76
  scope :open, lambda {|*args|
77 78 79
    is_closed = args.size > 0 ? !args.first : false
    {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
  }
80

81 82 83
  scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
  scope :on_active_project, :include => [:status, :project, :tracker],
                            :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
edavis10's avatar
edavis10 committed
84

85
  before_create :default_assign
86
  before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change
87
  after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?} 
88
  after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
89 90
  # Should be after_create but would be called before previous after_save callbacks
  after_save :after_create_from_copy
jplang's avatar
jplang committed
91
  after_destroy :update_parent_attributes
92

93 94
  # Returns a SQL conditions string used to find all issues visible by the specified user
  def self.visible_condition(user, options={})
95
    Project.allowed_to_condition(user, :view_issues, options) do |role, user|
96 97 98 99 100
      if user.logged?
        case role.issues_visibility
        when 'all'
          nil
        when 'default'
101 102
          user_ids = [user.id] + user.groups.map(&:id)
          "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
103
        when 'own'
104 105 106 107 108
          user_ids = [user.id] + user.groups.map(&:id)
          "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
        else
          '1=0'
        end
109
      else
110
        "(#{table_name}.is_private = #{connection.quoted_false})"
111 112
      end
    end
113 114
  end

115 116
  # Returns true if usr or current user is allowed to view the issue
  def visible?(usr=nil)
117
    (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
118 119 120 121 122 123 124 125 126 127 128
      if user.logged?
        case role.issues_visibility
        when 'all'
          true
        when 'default'
          !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
        when 'own'
          self.author == user || user.is_or_belongs_to?(assigned_to)
        else
          false
        end
129
      else
130
        !self.is_private?
131 132
      end
    end
133
  end
134

jplang's avatar
jplang committed
135 136
  def initialize(attributes=nil, *args)
    super
jplang's avatar
jplang committed
137 138 139
    if new_record?
      # set default values for new records only
      self.status ||= IssueStatus.default
140
      self.priority ||= IssuePriority.default
141
      self.watcher_user_ids = []
jplang's avatar
jplang committed
142 143
    end
  end
144

145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166
  # AR#Persistence#destroy would raise and RecordNotFound exception
  # if the issue was already deleted or updated (non matching lock_version).
  # This is a problem when bulk deleting issues or deleting a project
  # (because an issue may already be deleted if its parent was deleted
  # first).
  # The issue is reloaded by the nested_set before being deleted so
  # the lock_version condition should not be an issue but we handle it.
  def destroy
    super
  rescue ActiveRecord::RecordNotFound
    # Stale or already deleted
    begin
      reload
    rescue ActiveRecord::RecordNotFound
      # The issue was actually already deleted
      @destroyed = true
      return freeze
    end
    # The issue was stale, retry to destroy
    super
  end

167 168
  def reload(*args)
    @workflow_rule_by_attribute = nil
169
    @assignable_versions = nil
170 171 172
    super
  end

173 174
  # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
  def available_custom_fields
jplang's avatar
jplang committed
175
    (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
176
  end
177

178
  # Copies attributes from another issue, arg can be an id or an Issue
179
  def copy_from(arg, options={})
edavis10's avatar
edavis10 committed
180
    issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
jplang's avatar
jplang committed
181 182
    self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
    self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
183
    self.status = issue.status
jplang's avatar
jplang committed
184
    self.author = User.current
185 186 187 188
    unless options[:attachments] == false
      self.attachments = issue.attachments.map do |attachement| 
        attachement.copy(:container => self)
      end
189
    end
190
    @copied_from = issue
191
    @copy_options = options
jplang's avatar
jplang committed
192 193
    self
  end
194

jplang's avatar
jplang committed
195
  # Returns an unsaved copy of the issue
196 197
  def copy(attributes=nil, copy_options={})
    copy = self.class.new.copy_from(self, copy_options)
jplang's avatar
jplang committed
198 199 200 201
    copy.attributes = attributes if attributes
    copy
  end

202 203 204 205 206
  # Returns true if the issue is a copy
  def copy?
    @copied_from.present?
  end

207 208
  # Moves/copies an issue to a new project and tracker
  # Returns the moved/copied issue on success, false on failure
jplang's avatar
jplang committed
209
  def move_to_project(new_project, new_tracker=nil, options={})
jplang's avatar
jplang committed
210 211
    ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."

emassip's avatar
emassip committed
212
    if options[:copy]
jplang's avatar
jplang committed
213
      issue = self.copy
emassip's avatar
emassip committed
214 215 216
    else
      issue = self
    end
217

jplang's avatar
jplang committed
218 219
    issue.init_journal(User.current, options[:notes])

220 221 222
    # Preserve previous behaviour
    # #move_to_project doesn't change tracker automatically
    issue.send :project=, new_project, true
jplang's avatar
jplang committed
223 224 225 226 227 228 229
    if new_tracker
      issue.tracker = new_tracker
    end
    # Allow bulk setting of attributes on the issue
    if options[:attributes]
      issue.attributes = options[:attributes]
    end
jplang's avatar
jplang committed
230

jplang's avatar
jplang committed
231
    issue.save ? issue : false
232
  end
233 234 235

  def status_id=(sid)
    self.status = nil
236 237 238
    result = write_attribute(:status_id, sid)
    @workflow_rule_by_attribute = nil
    result
239
  end
240

jplang's avatar
jplang committed
241 242 243
  def priority_id=(pid)
    self.priority = nil
    write_attribute(:priority_id, pid)
244
  end
245

246 247 248 249 250 251 252 253 254 255
  def category_id=(cid)
    self.category = nil
    write_attribute(:category_id, cid)
  end

  def fixed_version_id=(vid)
    self.fixed_version = nil
    write_attribute(:fixed_version_id, vid)
  end

256 257
  def tracker_id=(tid)
    self.tracker = nil
258 259
    result = write_attribute(:tracker_id, tid)
    @custom_field_values = nil
260
    @workflow_rule_by_attribute = nil
261
    result
262
  end
263

264 265 266 267 268 269
  def project_id=(project_id)
    if project_id.to_s != self.project_id.to_s
      self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
    end
  end

270
  def project=(project, keep_tracker=false)
271 272 273 274
    project_was = self.project
    write_attribute(:project_id, project ? project.id : nil)
    association_instance_set('project', project)
    if project_was && project && project_was != project
275 276
      @assignable_versions = nil

277 278 279
      unless keep_tracker || project.trackers.include?(tracker)
        self.tracker = project.trackers.first
      end
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294
      # Reassign to the category with same name if any
      if category
        self.category = project.issue_categories.find_by_name(category.name)
      end
      # Keep the fixed_version if it's still valid in the new_project
      if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
        self.fixed_version = nil
      end
      if parent && parent.project_id != project_id
        self.parent_issue_id = nil
      end
      @custom_field_values = nil
    end
  end

295 296 297 298 299 300
  def description=(arg)
    if arg.is_a?(String)
      arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
    end
    write_attribute(:description, arg)
  end
301

jplang's avatar
jplang committed
302 303
  # Overrides assign_attributes so that project and tracker get assigned first
  def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
304
    return if new_attributes.nil?
305 306 307 308 309 310 311
    attrs = new_attributes.dup
    attrs.stringify_keys!

    %w(project project_id tracker tracker_id).each do |attr|
      if attrs.has_key?(attr)
        send "#{attr}=", attrs.delete(attr)
      end
312
    end
jplang's avatar
jplang committed
313
    send :assign_attributes_without_project_and_tracker_first, attrs, *args
314
  end
315
  # Do not redefine alias chain on reload (see #4838)
jplang's avatar
jplang committed
316
  alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
317

318 319 320
  def estimated_hours=(h)
    write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
  end
321

322 323
  safe_attributes 'project_id',
    :if => lambda {|issue, user|
324 325 326
      if issue.new_record?
        issue.copy?
      elsif user.allowed_to?(:move_issues, issue.project)
327 328 329
        projects = Issue.allowed_target_projects_on_move(user)
        projects.include?(issue.project) && projects.size > 1
      end
330 331
    }

332 333 334 335 336 337 338 339 340 341 342 343 344 345 346
  safe_attributes 'tracker_id',
    'status_id',
    'category_id',
    'assigned_to_id',
    'priority_id',
    'fixed_version_id',
    'subject',
    'description',
    'start_date',
    'due_date',
    'done_ratio',
    'estimated_hours',
    'custom_field_values',
    'custom_fields',
    'lock_version',
jplang's avatar
jplang committed
347
    'notes',
348
    :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
349

350 351 352 353
  safe_attributes 'status_id',
    'assigned_to_id',
    'fixed_version_id',
    'done_ratio',
354
    'lock_version',
jplang's avatar
jplang committed
355
    'notes',
356
    :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
357

jplang's avatar
jplang committed
358 359 360 361 362 363
  safe_attributes 'notes',
    :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}

  safe_attributes 'private_notes',
    :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)} 

364 365 366
  safe_attributes 'watcher_user_ids',
    :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)} 

jplang's avatar
jplang committed
367 368 369 370 371
  safe_attributes 'is_private',
    :if => lambda {|issue, user|
      user.allowed_to?(:set_issues_private, issue.project) ||
        (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
    }
372

jplang's avatar
jplang committed
373 374 375 376
  safe_attributes 'parent_issue_id',
    :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
      user.allowed_to?(:manage_subtasks, issue.project)}

377 378
  def safe_attribute_names(user=nil)
    names = super
379
    names -= disabled_core_fields
380
    names -= read_only_attribute_names(user)
381 382 383
    names
  end

384 385 386 387 388
  # Safely sets attributes
  # Should be called from controllers instead of #attributes=
  # attr_accessible is too rough because we still want things like
  # Issue.new(:project => foo) to work
  def safe_attributes=(attrs, user=User.current)
389
    return unless attrs.is_a?(Hash)
390

391
    attrs = attrs.dup
392

393
    # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
394
    if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
395 396 397
      if allowed_target_projects(user).collect(&:id).include?(p.to_i)
        self.project_id = p
      end
398
    end
399

400
    if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
401 402
      self.tracker_id = t
    end
403

404 405 406
    if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
      if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
        self.status_id = s
jplang's avatar
jplang committed
407 408
      end
    end
409

410 411 412
    attrs = delete_unsafe_attributes(attrs, user)
    return if attrs.empty?

jplang's avatar
jplang committed
413 414 415
    unless leaf?
      attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
    end
416

jplang's avatar
jplang committed
417 418
    if attrs['parent_issue_id'].present?
      attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
jplang's avatar
jplang committed
419
    end
420

421 422 423 424 425 426 427 428
    if attrs['custom_field_values'].present?
      attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
    end

    if attrs['custom_fields'].present?
      attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
    end

429
    # mass-assignment security bypass
jplang's avatar
jplang committed
430
    assign_attributes attrs, :without_protection => true
431
  end
432

433 434 435 436
  def disabled_core_fields
    tracker ? tracker.disabled_core_fields : []
  end

437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452
  # Returns the custom_field_values that can be edited by the given user
  def editable_custom_field_values(user=nil)
    custom_field_values.reject do |value|
      read_only_attribute_names(user).include?(value.custom_field_id.to_s)
    end
  end

  # Returns the names of attributes that are read-only for user or the current user
  # For users with multiple roles, the read-only fields are the intersection of
  # read-only fields of each role
  # The result is an array of strings where sustom fields are represented with their ids
  #
  # Examples:
  #   issue.read_only_attribute_names # => ['due_date', '2']
  #   issue.read_only_attribute_names(user) # => []
  def read_only_attribute_names(user=nil)
jplang's avatar
jplang committed
453
    workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
454 455 456 457 458 459 460 461 462 463 464
  end

  # Returns the names of required attributes for user or the current user
  # For users with multiple roles, the required fields are the intersection of
  # required fields of each role
  # The result is an array of strings where sustom fields are represented with their ids
  #
  # Examples:
  #   issue.required_attribute_names # => ['due_date', '2']
  #   issue.required_attribute_names(user) # => []
  def required_attribute_names(user=nil)
jplang's avatar
jplang committed
465
    workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506
  end

  # Returns true if the attribute is required for user
  def required_attribute?(name, user=nil)
    required_attribute_names(user).include?(name.to_s)
  end

  # Returns a hash of the workflow rule by attribute for the given user
  #
  # Examples:
  #   issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
  def workflow_rule_by_attribute(user=nil)
    return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?

    user_real = user || User.current
    roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
    return {} if roles.empty?

    result = {}
    workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
    if workflow_permissions.any?
      workflow_rules = workflow_permissions.inject({}) do |h, wp|
        h[wp.field_name] ||= []
        h[wp.field_name] << wp.rule
        h
      end
      workflow_rules.each do |attr, rules|
        next if rules.size < roles.size
        uniq_rules = rules.uniq
        if uniq_rules.size == 1
          result[attr] = uniq_rules.first
        else
          result[attr] = 'required'
        end
      end
    end
    @workflow_rule_by_attribute = result if user.nil?
    result
  end
  private :workflow_rule_by_attribute

507
  def done_ratio
508
    if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
509
      status.default_done_ratio
510 511 512 513 514 515 516 517 518 519 520 521
    else
      read_attribute(:done_ratio)
    end
  end

  def self.use_status_for_done_ratio?
    Setting.issue_done_ratio == 'issue_status'
  end

  def self.use_field_for_done_ratio?
    Setting.issue_done_ratio == 'issue_field'
  end
522

523
  def validate_issue
524
    if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
525
      errors.add :due_date, :not_a_date
526
    end
527

528
    if self.due_date and self.start_date and self.due_date < self.start_date
529
      errors.add :due_date, :greater_than_start_date
530
    end
531

532
    if start_date && soonest_start && start_date < soonest_start
533
      errors.add :start_date, :invalid
534
    end
535

536 537 538 539
    if fixed_version
      if !assignable_versions.include?(fixed_version)
        errors.add :fixed_version_id, :inclusion
      elsif reopened? && fixed_version.closed?
540
        errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
541 542
      end
    end
543

544 545 546 547 548 549
    # Checks that the issue can not be added/moved to a disabled tracker
    if project && (tracker_id_changed? || project_id_changed?)
      unless project.trackers.include?(tracker)
        errors.add :tracker_id, :inclusion
      end
    end
550

jplang's avatar
jplang committed
551 552 553 554 555 556 557 558 559 560 561 562 563 564 565
    # Checks parent issue assignment
    if @parent_issue
      if @parent_issue.project_id != project_id
        errors.add :parent_issue_id, :not_same_project
      elsif !new_record?
        # moving an existing issue
        if @parent_issue.root_id != root_id
          # we can always move to another tree
        elsif move_possible?(@parent_issue)
          # move accepted inside tree
        else
          errors.add :parent_issue_id, :not_a_valid_parent
        end
      end
    end
566
  end
567

568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586
  # Validates the issue against additional workflow requirements
  def validate_required_fields
    user = new_record? ? author : current_journal.try(:user)

    required_attribute_names(user).each do |attribute|
      if attribute =~ /^\d+$/
        attribute = attribute.to_i
        v = custom_field_values.detect {|v| v.custom_field_id == attribute }
        if v && v.value.blank?
          errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
        end
      else
        if respond_to?(attribute) && send(attribute).blank?
          errors.add attribute, :blank
        end
      end
    end
  end

587 588 589
  # Set the done_ratio using the status if that setting is set.  This will keep the done_ratios
  # even if the user turns off the setting later
  def update_done_ratio_from_issue_status
590
    if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
591
      self.done_ratio = status.default_done_ratio
592 593
    end
  end
594

595 596
  def init_journal(user, notes = "")
    @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
jplang's avatar
jplang committed
597 598 599 600 601
    if new_record?
      @current_journal.notify = false
    else
      @attributes_before_change = attributes.dup
      @custom_values_before_change = {}
602
      self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
jplang's avatar
jplang committed
603
    end
604 605
    @current_journal
  end
606

607 608 609 610 611
  # Returns the id of the last journal or nil
  def last_journal_id
    if new_record?
      nil
    else
jplang's avatar
jplang committed
612
      journals.maximum(:id)
613 614 615
    end
  end

jplang's avatar
jplang committed
616 617 618 619 620 621 622 623 624
  # Returns a scope for journals that have an id greater than journal_id
  def journals_after(journal_id)
    scope = journals.reorder("#{Journal.table_name}.id ASC")
    if journal_id.present?
      scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
    end
    scope
  end

625 626 627 628
  # Return true if the issue is closed, otherwise false
  def closed?
    self.status.is_closed?
  end
629

630 631 632 633 634 635 636 637 638 639 640
  # Return true if the issue is being reopened
  def reopened?
    if !new_record? && status_id_changed?
      status_was = IssueStatus.find_by_id(status_id_was)
      status_new = IssueStatus.find_by_id(status_id)
      if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
        return true
      end
    end
    false
  end
641 642 643 644 645 646 647 648 649 650 651 652

  # Return true if the issue is being closed
  def closing?
    if !new_record? && status_id_changed?
      status_was = IssueStatus.find_by_id(status_id_was)
      status_new = IssueStatus.find_by_id(status_id)
      if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
        return true
      end
    end
    false
  end
653

654 655
  # Returns true if the issue is overdue
  def overdue?
656
    !due_date.nil? && (due_date < Date.today) && !status.is_closed?
657
  end
edavis10's avatar
edavis10 committed
658 659 660 661 662 663 664

  # Is the amount of work done less than it should for the due date
  def behind_schedule?
    return false if start_date.nil? || due_date.nil?
    done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
    return done_date <= Date.today
  end
665 666 667 668 669

  # Does this issue have children?
  def children?
    !leaf?
  end
670

671 672
  # Users the issue can be assigned to
  def assignable_users
673 674
    users = project.assignable_users
    users << author if author
675
    users << assigned_to if assigned_to
676
    users.uniq.sort
677
  end
678

679 680
  # Versions that the issue can be assigned to
  def assignable_versions
681 682 683 684 685 686 687 688 689 690 691 692 693 694 695
    return @assignable_versions if @assignable_versions

    versions = project.shared_versions.open.all
    if fixed_version
      if fixed_version_id_changed?
        # nothing to do
      elsif project_id_changed?
        if project.shared_versions.include?(fixed_version)
          versions << fixed_version
        end
      else
        versions << fixed_version
      end
    end
    @assignable_versions = versions.uniq.sort
696
  end
697

698 699 700 701
  # Returns true if this issue is blocked by another issue that is still open
  def blocked?
    !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
  end
702

703
  # Returns an array of statuses that user is able to apply
jplang's avatar
jplang committed
704
  def new_statuses_allowed_to(user=User.current, include_default=false)
705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726
    if new_record? && @copied_from
      [IssueStatus.default, @copied_from.status].compact.uniq.sort
    else
      initial_status = nil
      if new_record?
        initial_status = IssueStatus.default
      elsif status_id_was
        initial_status = IssueStatus.find_by_id(status_id_was)
      end
      initial_status ||= status
  
      statuses = initial_status.find_new_statuses_allowed_to(
        user.admin ? Role.all : user.roles_for_project(project),
        tracker,
        author == user,
        assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
        )
      statuses << initial_status unless statuses.empty?
      statuses << IssueStatus.default if include_default
      statuses = statuses.compact.uniq.sort
      blocked? ? statuses.reject {|s| s.is_closed?} : statuses
    end
727
  end
728

729 730 731 732 733 734
  def assigned_to_was
    if assigned_to_id_changed? && assigned_to_id_was.present?
      @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
    end
  end

jplang's avatar
jplang committed
735 736
  # Returns the users that should be notified
  def notified_users
737
    notified = []
738 739
    # Author and assignee are always notified unless they have been
    # locked or don't want to be notified
740
    notified << author if author
741
    if assigned_to
742 743 744 745
      notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
    end
    if assigned_to_was
      notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
746
    end
747 748 749
    notified = notified.select {|u| u.active? && u.notify_about?(self)}

    notified += project.notified_users
750 751 752
    notified.uniq!
    # Remove users that can not view the issue
    notified.reject! {|user| !visible?(user)}
jplang's avatar
jplang committed
753 754 755 756 757 758
    notified
  end

  # Returns the email addresses that should be notified
  def recipients
    notified_users.collect(&:mail)
759
  end
760

761 762 763 764 765
  # Returns the number of hours spent on this issue
  def spent_hours
    @spent_hours ||= time_entries.sum(:hours) || 0
  end

jplang's avatar
jplang committed
766
  # Returns the total number of hours spent on this issue and its descendants
767 768
  #
  # Example:
jplang's avatar
jplang committed
769 770
  #   spent_hours => 0.0
  #   spent_hours => 50.2
771
  def total_spent_hours
jplang's avatar
jplang committed
772 773
    @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
      :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
774
  end
775

776
  def relations
777
    @relations ||= IssueRelations.new(self, (relations_from + relations_to).sort)
778
  end
779

780 781 782 783 784 785 786 787
  # Preloads relations for a collection of issues
  def self.load_relations(issues)
    if issues.any?
      relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
      issues.each do |issue|
        issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
      end
    end
788
  end
789

790 791 792 793 794 795 796 797 798 799
  # Preloads visible spent time for a collection of issues
  def self.load_visible_spent_hours(issues, user=User.current)
    if issues.any?
      hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
      issues.each do |issue|
        issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
      end
    end
  end

800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818
  # Preloads visible relations for a collection of issues
  def self.load_visible_relations(issues, user=User.current)
    if issues.any?
      issue_ids = issues.map(&:id)
      # Relations with issue_from in given issues and visible issue_to
      relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
      # Relations with issue_to in given issues and visible issue_from
      relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all

      issues.each do |issue|
        relations =
          relations_from.select {|relation| relation.issue_from_id == issue.id} +
          relations_to.select {|relation| relation.issue_to_id == issue.id}

        issue.instance_variable_set "@relations", IssueRelations.new(issue, relations.sort)
      end
    end
  end

819 820 821 822
  # Finds an issue relation given its id.
  def find_relation(relation_id)
    IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
  end
823

824 825
  def all_dependent_issues(except=[])
    except << self
826 827
    dependencies = []
    relations_from.each do |relation|
828
      if relation.issue_to && !except.include?(relation.issue_to)
829 830 831
        dependencies << relation.issue_to
        dependencies += relation.issue_to.all_dependent_issues(except)
      end
832 833 834
    end
    dependencies
  end
835

836
  # Returns an array of issues that duplicate this one
837
  def duplicates
838
    relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
839
  end
840

841 842 843 844 845
  # Returns the due date or the target due date if any
  # Used on gantt chart
  def due_before
    due_date || (fixed_version ? fixed_version.effective_date : nil)
  end
846

847
  # Returns the time scheduled for this issue.
848
  #
849 850 851
  # Example:
  #   Start Date: 2/26/09, End Date: 3/04/09
  #   duration => 6
852 853 854
  def duration
    (start_date && due_date) ? due_date - start_date : 0
  end
855

856
  def soonest_start
857 858 859 860 861
    @soonest_start ||= (
        relations_to.collect{|relation| relation.successor_soonest_start} +
        ancestors.collect(&:soonest_start)
      ).compact.max
  end
862

863 864 865 866 867
  def reschedule_after(date)
    return if date.nil?
    if leaf?
      if start_date.nil? || start_date < date
        self.start_date, self.due_date = date, date + duration
868 869 870 871 872 873 874
        begin
          save
        rescue ActiveRecord::StaleObjectError
          reload
          self.start_date, self.due_date = date, date + duration
          save
        end
875 876 877 878 879 880
      end
    else
      leaves.each do |leaf|
        leaf.reschedule_after(date)
      end
    end
881
  end
882

jplang's avatar
jplang committed
883 884 885 886 887 888 889 890 891
  def <=>(issue)
    if issue.nil?
      -1
    elsif root_id != issue.root_id
      (root_id || 0) <=> (issue.root_id || 0)
    else
      (lft || 0) <=> (issue.lft || 0)
    end
  end
892

893 894 895
  def to_s
    "#{tracker} ##{id}: #{subject}"
  end
896

897 898
  # Returns a string of css classes that apply to the issue
  def css_classes
899
    s = "issue status-#{status_id} priority-#{priority_id}"
900 901
    s << ' closed' if closed?
    s << ' overdue' if overdue?
902 903
    s << ' child' if child?
    s << ' parent' unless leaf?
jplang's avatar
jplang committed
904
    s << ' private' if is_private?
905 906 907 908
    s << ' created-by-me' if User.current.logged? && author_id == User.current.id
    s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
    s
  end
909

910
  # Saves an issue and a time_entry from the parameters
911
  def save_issue_with_child_records(params, existing_time_entry=nil)
912
    Issue.transaction do
913
      if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
914 915 916 917
        @time_entry = existing_time_entry || TimeEntry.new
        @time_entry.project = project
        @time_entry.issue = self
        @time_entry.user = User.current