File: app/models/issue.rb

Overview
Module Structure
Class Hierarchy
Code

Overview

Module Structure

  module: <Toplevel Module>
  class: Issue#18
includes
  SafeAttributes ( Redmine )
inherits from
  Base ( ActiveRecord )
has properties
constant: DONE_RATIO_OPTIONS #52
attribute: current_journal [R] #54
class method: visible_condition / 2 #83
method: visible? / 1 #101
method: initialize / 2 #116
method: available_custom_fields #127
method: copy_from / 2 #132
method: copy / 2 #148
method: copy? #155
method: move_to_project / 3 #161
method: status_id= / 1 #186
method: priority_id= / 1 #191
method: category_id= / 1 #196
method: fixed_version_id= / 1 #201
method: tracker_id= / 1 #206
method: project_id= / 1 #213
method: project= / 2 #219
method: description= / 1 #242
method: attributes_with_project_and_tracker_first= / 2 #250
method: estimated_hours= / 1 #265
method: safe_attributes= / 2 #320
method: done_ratio #356
class method: use_status_for_done_ratio? #364
class method: use_field_for_done_ratio? #368
method: validate_issue #372
method: update_done_ratio_from_issue_status #419
method: init_journal / 2 #425
method: last_journal_id #440
method: closed? #449
method: reopened? #454
method: closing? #466
method: overdue? #478
method: behind_schedule? #483
method: children? #490
method: assignable_users #495
method: assignable_versions #503
method: blocked? #508
method: new_statuses_allowed_to / 2 #513
method: assigned_to_was #538
method: recipients #545
method: spent_hours #566
method: total_spent_hours #575
method: relations #580
class method: load_relations / 1 #585
class method: load_visible_spent_hours / 2 #595
method: find_relation / 1 #605
method: all_dependent_issues / 1 #609
method: duplicates #622
method: due_before #628
method: duration #637
method: soonest_start #641
method: reschedule_after / 1 #648
method: <=> / 1 #668
method: to_s #678
method: css_classes #683
method: save_issue_with_child_records / 2 #696
class method: update_versions_from_sharing_change / 1 #720
class method: update_versions_from_hierarchy_change / 1 #727
method: parent_issue_id= / 1 #733
method: parent_issue_id #743
class method: by_tracker / 1 #752
class method: by_version / 1 #758
class method: by_priority / 1 #764
class method: by_category / 1 #770
class method: by_assigned_to / 1 #776
class method: by_author / 1 #782
class method: by_subproject / 1 #788
method: allowed_target_projects / 1 #805
class method: allowed_target_projects_on_move / 1 #814
method: after_project_change #820
method: update_nested_set_attributes #840
method: update_parent_attributes #887
method: recalculate_attributes_for / 1 #891
class method: update_versions / 1 #930
method: attachment_added / 1 #949
method: attachment_removed / 1 #956
method: default_assign #964
method: reschedule_following_issues #971
method: close_duplicates #980
method: create_journal #998
class method: count_and_group_by / 1 #1058

Class Hierarchy

Object ( Builtin-Module )
Base ( ActiveRecord )
  Issue    #18

Code

   1  # Redmine - project management software
   2  # Copyright (C) 2006-2011  Jean-Philippe Lang
   3  #
   4  # This program is free software; you can redistribute it and/or
   5  # modify it under the terms of the GNU General Public License
   6  # as published by the Free Software Foundation; either version 2
   7  # of the License, or (at your option) any later version.
   8  #
   9  # This program is distributed in the hope that it will be useful,
  10  # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  # GNU General Public License for more details.
  13  #
  14  # You should have received a copy of the GNU General Public License
  15  # along with this program; if not, write to the Free Software
  16  # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  17 
  18  class Issue < ActiveRecord::Base
  19    include Redmine::SafeAttributes
  20 
  21    belongs_to :project
  22    belongs_to :tracker
  23    belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
  24    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    belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
  29 
  30    has_many :journals, :as => :journalized, :dependent => :destroy
  31    has_many :time_entries, :dependent => :delete_all
  32    has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
  33 
  34    has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
  35    has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
  36 
  37    acts_as_nested_set :scope => 'root_id', :dependent => :destroy
  38    acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
  39    acts_as_customizable
  40    acts_as_watchable
  41    acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
  42                       :include => [:project, :journals],
  43                       # sort by id so that limited eager loading doesn't break with postgresql
  44                       :order_column => "#{table_name}.id"
  45    acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
  46                  :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
  47                  :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
  48 
  49    acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
  50                              :author_key => :author_id
  51 
  52    DONE_RATIO_OPTIONS = %w(issue_field issue_status)
  53 
  54    attr_reader :current_journal
  55 
  56    validates_presence_of :subject, :priority, :project, :tracker, :author, :status
  57 
  58    validates_length_of :subject, :maximum => 255
  59    validates_inclusion_of :done_ratio, :in => 0..100
  60    validates_numericality_of :estimated_hours, :allow_nil => true
  61    validate :validate_issue
  62 
  63    named_scope :visible, lambda {|*args| { :include => :project,
  64                                            :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
  65 
  66    named_scope :open, lambda {|*args|
  67      is_closed = args.size > 0 ? !args.first : false
  68      {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
  69    }
  70 
  71    named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
  72    named_scope :with_limit, lambda { |limit| { :limit => limit} }
  73    named_scope :on_active_project, :include => [:status, :project, :tracker],
  74                                    :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
  75 
  76    before_create :default_assign
  77    before_save :close_duplicates, :update_done_ratio_from_issue_status
  78    after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?} 
  79    after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
  80    after_destroy :update_parent_attributes
  81 
  82    # Returns a SQL conditions string used to find all issues visible by the specified user
  83    def self.visible_condition(user, options={})
  84      Project.allowed_to_condition(user, :view_issues, options) do |role, user|
  85        case role.issues_visibility
  86        when 'all'
  87          nil
  88        when 'default'
  89          user_ids = [user.id] + user.groups.map(&:id)
  90          "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
  91        when 'own'
  92          user_ids = [user.id] + user.groups.map(&:id)
  93          "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
  94        else
  95          '1=0'
  96        end
  97      end
  98    end
  99 
 100    # Returns true if usr or current user is allowed to view the issue
 101    def visible?(usr=nil)
 102      (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
 103        case role.issues_visibility
 104        when 'all'
 105          true
 106        when 'default'
 107          !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
 108        when 'own'
 109          self.author == user || user.is_or_belongs_to?(assigned_to)
 110        else
 111          false
 112        end
 113      end
 114    end
 115 
 116    def initialize(attributes=nil, *args)
 117      super
 118      if new_record?
 119        # set default values for new records only
 120        self.status ||= IssueStatus.default
 121        self.priority ||= IssuePriority.default
 122        self.watcher_user_ids = []
 123      end
 124    end
 125 
 126    # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
 127    def available_custom_fields
 128      (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
 129    end
 130 
 131    # Copies attributes from another issue, arg can be an id or an Issue
 132    def copy_from(arg, options={})
 133      issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
 134      self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
 135      self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
 136      self.status = issue.status
 137      self.author = User.current
 138      unless options[:attachments] == false
 139        self.attachments = issue.attachments.map do |attachement| 
 140          attachement.copy(:container => self)
 141        end
 142      end
 143      @copied_from = issue
 144      self
 145    end
 146 
 147    # Returns an unsaved copy of the issue
 148    def copy(attributes=nil, copy_options={})
 149      copy = self.class.new.copy_from(self, copy_options)
 150      copy.attributes = attributes if attributes
 151      copy
 152    end
 153 
 154    # Returns true if the issue is a copy
 155    def copy?
 156      @copied_from.present?
 157    end
 158 
 159    # Moves/copies an issue to a new project and tracker
 160    # Returns the moved/copied issue on success, false on failure
 161    def move_to_project(new_project, new_tracker=nil, options={})
 162      ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
 163 
 164      if options[:copy]
 165        issue = self.copy
 166      else
 167        issue = self
 168      end
 169 
 170      issue.init_journal(User.current, options[:notes])
 171 
 172      # Preserve previous behaviour
 173      # #move_to_project doesn't change tracker automatically
 174      issue.send :project=, new_project, true
 175      if new_tracker
 176        issue.tracker = new_tracker
 177      end
 178      # Allow bulk setting of attributes on the issue
 179      if options[:attributes]
 180        issue.attributes = options[:attributes]
 181      end
 182 
 183      issue.save ? issue : false
 184    end
 185 
 186    def status_id=(sid)
 187      self.status = nil
 188      write_attribute(:status_id, sid)
 189    end
 190 
 191    def priority_id=(pid)
 192      self.priority = nil
 193      write_attribute(:priority_id, pid)
 194    end
 195 
 196    def category_id=(cid)
 197      self.category = nil
 198      write_attribute(:category_id, cid)
 199    end
 200 
 201    def fixed_version_id=(vid)
 202      self.fixed_version = nil
 203      write_attribute(:fixed_version_id, vid)
 204    end
 205 
 206    def tracker_id=(tid)
 207      self.tracker = nil
 208      result = write_attribute(:tracker_id, tid)
 209      @custom_field_values = nil
 210      result
 211    end
 212 
 213    def project_id=(project_id)
 214      if project_id.to_s != self.project_id.to_s
 215        self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
 216      end
 217    end
 218 
 219    def project=(project, keep_tracker=false)
 220      project_was = self.project
 221      write_attribute(:project_id, project ? project.id : nil)
 222      association_instance_set('project', project)
 223      if project_was && project && project_was != project
 224        unless keep_tracker || project.trackers.include?(tracker)
 225          self.tracker = project.trackers.first
 226        end
 227        # Reassign to the category with same name if any
 228        if category
 229          self.category = project.issue_categories.find_by_name(category.name)
 230        end
 231        # Keep the fixed_version if it's still valid in the new_project
 232        if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
 233          self.fixed_version = nil
 234        end
 235        if parent && parent.project_id != project_id
 236          self.parent_issue_id = nil
 237        end
 238        @custom_field_values = nil
 239      end
 240    end
 241 
 242    def description=(arg)
 243      if arg.is_a?(String)
 244        arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
 245      end
 246      write_attribute(:description, arg)
 247    end
 248 
 249    # Overrides attributes= so that project and tracker get assigned first
 250    def attributes_with_project_and_tracker_first=(new_attributes, *args)
 251      return if new_attributes.nil?
 252      attrs = new_attributes.dup
 253      attrs.stringify_keys!
 254 
 255      %w(project project_id tracker tracker_id).each do |attr|
 256        if attrs.has_key?(attr)
 257          send "#{attr}=", attrs.delete(attr)
 258        end
 259      end
 260      send :attributes_without_project_and_tracker_first=, attrs, *args
 261    end
 262    # Do not redefine alias chain on reload (see #4838)
 263    alias_method_chain(:attributes=, :project_and_tracker_first) unless method_defined?(:attributes_without_project_and_tracker_first=)
 264 
 265    def estimated_hours=(h)
 266      write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
 267    end
 268 
 269    safe_attributes 'project_id',
 270      :if => lambda {|issue, user|
 271        if issue.new_record?
 272          issue.copy?
 273        elsif user.allowed_to?(:move_issues, issue.project)
 274          projects = Issue.allowed_target_projects_on_move(user)
 275          projects.include?(issue.project) && projects.size > 1
 276        end
 277      }
 278 
 279    safe_attributes 'tracker_id',
 280      'status_id',
 281      'category_id',
 282      'assigned_to_id',
 283      'priority_id',
 284      'fixed_version_id',
 285      'subject',
 286      'description',
 287      'start_date',
 288      'due_date',
 289      'done_ratio',
 290      'estimated_hours',
 291      'custom_field_values',
 292      'custom_fields',
 293      'lock_version',
 294      :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
 295 
 296    safe_attributes 'status_id',
 297      'assigned_to_id',
 298      'fixed_version_id',
 299      'done_ratio',
 300      'lock_version',
 301      :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
 302 
 303    safe_attributes 'watcher_user_ids',
 304      :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)} 
 305 
 306    safe_attributes 'is_private',
 307      :if => lambda {|issue, user|
 308        user.allowed_to?(:set_issues_private, issue.project) ||
 309          (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
 310      }
 311 
 312    safe_attributes 'parent_issue_id',
 313      :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
 314        user.allowed_to?(:manage_subtasks, issue.project)}
 315 
 316    # Safely sets attributes
 317    # Should be called from controllers instead of #attributes=
 318    # attr_accessible is too rough because we still want things like
 319    # Issue.new(:project => foo) to work
 320    def safe_attributes=(attrs, user=User.current)
 321      return unless attrs.is_a?(Hash)
 322 
 323      # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
 324      attrs = delete_unsafe_attributes(attrs, user)
 325      return if attrs.empty?
 326 
 327      # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
 328      if p = attrs.delete('project_id')
 329        if allowed_target_projects(user).collect(&:id).include?(p.to_i)
 330          self.project_id = p
 331        end
 332      end
 333 
 334      if t = attrs.delete('tracker_id')
 335        self.tracker_id = t
 336      end
 337 
 338      if attrs['status_id']
 339        unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
 340          attrs.delete('status_id')
 341        end
 342      end
 343 
 344      unless leaf?
 345        attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
 346      end
 347 
 348      if attrs['parent_issue_id'].present?
 349        attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
 350      end
 351 
 352      # mass-assignment security bypass
 353      self.send :attributes=, attrs, false
 354    end
 355 
 356    def done_ratio
 357      if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
 358        status.default_done_ratio
 359      else
 360        read_attribute(:done_ratio)
 361      end
 362    end
 363 
 364    def self.use_status_for_done_ratio?
 365      Setting.issue_done_ratio == 'issue_status'
 366    end
 367 
 368    def self.use_field_for_done_ratio?
 369      Setting.issue_done_ratio == 'issue_field'
 370    end
 371 
 372    def validate_issue
 373      if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
 374        errors.add :due_date, :not_a_date
 375      end
 376 
 377      if self.due_date and self.start_date and self.due_date < self.start_date
 378        errors.add :due_date, :greater_than_start_date
 379      end
 380 
 381      if start_date && soonest_start && start_date < soonest_start
 382        errors.add :start_date, :invalid
 383      end
 384 
 385      if fixed_version
 386        if !assignable_versions.include?(fixed_version)
 387          errors.add :fixed_version_id, :inclusion
 388        elsif reopened? && fixed_version.closed?
 389          errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
 390        end
 391      end
 392 
 393      # Checks that the issue can not be added/moved to a disabled tracker
 394      if project && (tracker_id_changed? || project_id_changed?)
 395        unless project.trackers.include?(tracker)
 396          errors.add :tracker_id, :inclusion
 397        end
 398      end
 399 
 400      # Checks parent issue assignment
 401      if @parent_issue
 402        if @parent_issue.project_id != project_id
 403          errors.add :parent_issue_id, :not_same_project
 404        elsif !new_record?
 405          # moving an existing issue
 406          if @parent_issue.root_id != root_id
 407            # we can always move to another tree
 408          elsif move_possible?(@parent_issue)
 409            # move accepted inside tree
 410          else
 411            errors.add :parent_issue_id, :not_a_valid_parent
 412          end
 413        end
 414      end
 415    end
 416 
 417    # Set the done_ratio using the status if that setting is set.  This will keep the done_ratios
 418    # even if the user turns off the setting later
 419    def update_done_ratio_from_issue_status
 420      if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
 421        self.done_ratio = status.default_done_ratio
 422      end
 423    end
 424 
 425    def init_journal(user, notes = "")
 426      @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
 427      if new_record?
 428        @current_journal.notify = false
 429      else
 430        @attributes_before_change = attributes.dup
 431        @custom_values_before_change = {}
 432        self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
 433      end
 434      # Make sure updated_on is updated when adding a note.
 435      updated_on_will_change!
 436      @current_journal
 437    end
 438 
 439    # Returns the id of the last journal or nil
 440    def last_journal_id
 441      if new_record?
 442        nil
 443      else
 444        journals.first(:order => "#{Journal.table_name}.id DESC").try(:id)
 445      end
 446    end
 447 
 448    # Return true if the issue is closed, otherwise false
 449    def closed?
 450      self.status.is_closed?
 451    end
 452 
 453    # Return true if the issue is being reopened
 454    def reopened?
 455      if !new_record? && status_id_changed?
 456        status_was = IssueStatus.find_by_id(status_id_was)
 457        status_new = IssueStatus.find_by_id(status_id)
 458        if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
 459          return true
 460        end
 461      end
 462      false
 463    end
 464 
 465    # Return true if the issue is being closed
 466    def closing?
 467      if !new_record? && status_id_changed?
 468        status_was = IssueStatus.find_by_id(status_id_was)
 469        status_new = IssueStatus.find_by_id(status_id)
 470        if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
 471          return true
 472        end
 473      end
 474      false
 475    end
 476 
 477    # Returns true if the issue is overdue
 478    def overdue?
 479      !due_date.nil? && (due_date < Date.today) && !status.is_closed?
 480    end
 481 
 482    # Is the amount of work done less than it should for the due date
 483    def behind_schedule?
 484      return false if start_date.nil? || due_date.nil?
 485      done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
 486      return done_date <= Date.today
 487    end
 488 
 489    # Does this issue have children?
 490    def children?
 491      !leaf?
 492    end
 493 
 494    # Users the issue can be assigned to
 495    def assignable_users
 496      users = project.assignable_users
 497      users << author if author
 498      users << assigned_to if assigned_to
 499      users.uniq.sort
 500    end
 501 
 502    # Versions that the issue can be assigned to
 503    def assignable_versions
 504      @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
 505    end
 506 
 507    # Returns true if this issue is blocked by another issue that is still open
 508    def blocked?
 509      !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
 510    end
 511 
 512    # Returns an array of statuses that user is able to apply
 513    def new_statuses_allowed_to(user=User.current, include_default=false)
 514      if new_record? && @copied_from
 515        [IssueStatus.default, @copied_from.status].compact.uniq.sort
 516      else
 517        initial_status = nil
 518        if new_record?
 519          initial_status = IssueStatus.default
 520        elsif status_id_was
 521          initial_status = IssueStatus.find_by_id(status_id_was)
 522        end
 523        initial_status ||= status
 524    
 525        statuses = initial_status.find_new_statuses_allowed_to(
 526          user.admin ? Role.all : user.roles_for_project(project),
 527          tracker,
 528          author == user,
 529          assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
 530          )
 531        statuses << initial_status unless statuses.empty?
 532        statuses << IssueStatus.default if include_default
 533        statuses = statuses.compact.uniq.sort
 534        blocked? ? statuses.reject {|s| s.is_closed?} : statuses
 535      end
 536    end
 537 
 538    def assigned_to_was
 539      if assigned_to_id_changed? && assigned_to_id_was.present?
 540        @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
 541      end
 542    end
 543 
 544    # Returns the mail adresses of users that should be notified
 545    def recipients
 546      notified = []
 547      # Author and assignee are always notified unless they have been
 548      # locked or don't want to be notified
 549      notified << author if author
 550      if assigned_to
 551        notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
 552      end
 553      if assigned_to_was
 554        notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
 555      end
 556      notified = notified.select {|u| u.active? && u.notify_about?(self)}
 557 
 558      notified += project.notified_users
 559      notified.uniq!
 560      # Remove users that can not view the issue
 561      notified.reject! {|user| !visible?(user)}
 562      notified.collect(&:mail)
 563    end
 564 
 565    # Returns the number of hours spent on this issue
 566    def spent_hours
 567      @spent_hours ||= time_entries.sum(:hours) || 0
 568    end
 569 
 570    # Returns the total number of hours spent on this issue and its descendants
 571    #
 572    # Example:
 573    #   spent_hours => 0.0
 574    #   spent_hours => 50.2
 575    def total_spent_hours
 576      @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
 577        :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
 578    end
 579 
 580    def relations
 581      @relations ||= (relations_from + relations_to).sort
 582    end
 583 
 584    # Preloads relations for a collection of issues
 585    def self.load_relations(issues)
 586      if issues.any?
 587        relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
 588        issues.each do |issue|
 589          issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
 590        end
 591      end
 592    end
 593 
 594    # Preloads visible spent time for a collection of issues
 595    def self.load_visible_spent_hours(issues, user=User.current)
 596      if issues.any?
 597        hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
 598        issues.each do |issue|
 599          issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
 600        end
 601      end
 602    end
 603 
 604    # Finds an issue relation given its id.
 605    def find_relation(relation_id)
 606      IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
 607    end
 608 
 609    def all_dependent_issues(except=[])
 610      except << self
 611      dependencies = []
 612      relations_from.each do |relation|
 613        if relation.issue_to && !except.include?(relation.issue_to)
 614          dependencies << relation.issue_to
 615          dependencies += relation.issue_to.all_dependent_issues(except)
 616        end
 617      end
 618      dependencies
 619    end
 620 
 621    # Returns an array of issues that duplicate this one
 622    def duplicates
 623      relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
 624    end
 625 
 626    # Returns the due date or the target due date if any
 627    # Used on gantt chart
 628    def due_before
 629      due_date || (fixed_version ? fixed_version.effective_date : nil)
 630    end
 631 
 632    # Returns the time scheduled for this issue.
 633    #
 634    # Example:
 635    #   Start Date: 2/26/09, End Date: 3/04/09
 636    #   duration => 6
 637    def duration
 638      (start_date && due_date) ? due_date - start_date : 0
 639    end
 640 
 641    def soonest_start
 642      @soonest_start ||= (
 643          relations_to.collect{|relation| relation.successor_soonest_start} +
 644          ancestors.collect(&:soonest_start)
 645        ).compact.max
 646    end
 647 
 648    def reschedule_after(date)
 649      return if date.nil?
 650      if leaf?
 651        if start_date.nil? || start_date < date
 652          self.start_date, self.due_date = date, date + duration
 653          begin
 654            save
 655          rescue ActiveRecord::StaleObjectError
 656            reload
 657            self.start_date, self.due_date = date, date + duration
 658            save
 659          end
 660        end
 661      else
 662        leaves.each do |leaf|
 663          leaf.reschedule_after(date)
 664        end
 665      end
 666    end
 667 
 668    def <=>(issue)
 669      if issue.nil?
 670        -1
 671      elsif root_id != issue.root_id
 672        (root_id || 0) <=> (issue.root_id || 0)
 673      else
 674        (lft || 0) <=> (issue.lft || 0)
 675      end
 676    end
 677 
 678    def to_s
 679      "#{tracker} ##{id}: #{subject}"
 680    end
 681 
 682    # Returns a string of css classes that apply to the issue
 683    def css_classes
 684      s = "issue status-#{status.position} priority-#{priority.position}"
 685      s << ' closed' if closed?
 686      s << ' overdue' if overdue?
 687      s << ' child' if child?
 688      s << ' parent' unless leaf?
 689      s << ' private' if is_private?
 690      s << ' created-by-me' if User.current.logged? && author_id == User.current.id
 691      s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
 692      s
 693    end
 694 
 695    # Saves an issue and a time_entry from the parameters
 696    def save_issue_with_child_records(params, existing_time_entry=nil)
 697      Issue.transaction do
 698        if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
 699          @time_entry = existing_time_entry || TimeEntry.new
 700          @time_entry.project = project
 701          @time_entry.issue = self
 702          @time_entry.user = User.current
 703          @time_entry.spent_on = User.current.today
 704          @time_entry.attributes = params[:time_entry]
 705          self.time_entries << @time_entry
 706        end
 707 
 708        # TODO: Rename hook
 709        Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
 710        if save
 711          # TODO: Rename hook
 712          Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
 713        else
 714          raise ActiveRecord::Rollback
 715        end
 716      end
 717    end
 718 
 719    # Unassigns issues from +version+ if it's no longer shared with issue's project
 720    def self.update_versions_from_sharing_change(version)
 721      # Update issues assigned to the version
 722      update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
 723    end
 724 
 725    # Unassigns issues from versions that are no longer shared
 726    # after +project+ was moved
 727    def self.update_versions_from_hierarchy_change(project)
 728      moved_project_ids = project.self_and_descendants.reload.collect(&:id)
 729      # Update issues of the moved projects and issues assigned to a version of a moved project
 730      Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
 731    end
 732 
 733    def parent_issue_id=(arg)
 734      parent_issue_id = arg.blank? ? nil : arg.to_i
 735      if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
 736        @parent_issue.id
 737      else
 738        @parent_issue = nil
 739        nil
 740      end
 741    end
 742 
 743    def parent_issue_id
 744      if instance_variable_defined? :@parent_issue
 745        @parent_issue.nil? ? nil : @parent_issue.id
 746      else
 747        parent_id
 748      end
 749    end
 750 
 751    # Extracted from the ReportsController.
 752    def self.by_tracker(project)
 753      count_and_group_by(:project => project,
 754                         :field => 'tracker_id',
 755                         :joins => Tracker.table_name)
 756    end
 757 
 758    def self.by_version(project)
 759      count_and_group_by(:project => project,
 760                         :field => 'fixed_version_id',
 761                         :joins => Version.table_name)
 762    end
 763 
 764    def self.by_priority(project)
 765      count_and_group_by(:project => project,
 766                         :field => 'priority_id',
 767                         :joins => IssuePriority.table_name)
 768    end
 769 
 770    def self.by_category(project)
 771      count_and_group_by(:project => project,
 772                         :field => 'category_id',
 773                         :joins => IssueCategory.table_name)
 774    end
 775 
 776    def self.by_assigned_to(project)
 777      count_and_group_by(:project => project,
 778                         :field => 'assigned_to_id',
 779                         :joins => User.table_name)
 780    end
 781 
 782    def self.by_author(project)
 783      count_and_group_by(:project => project,
 784                         :field => 'author_id',
 785                         :joins => User.table_name)
 786    end
 787 
 788    def self.by_subproject(project)
 789      ActiveRecord::Base.connection.select_all("select    s.id as status_id, 
 790                                                  s.is_closed as closed, 
 791                                                  #{Issue.table_name}.project_id as project_id,
 792                                                  count(#{Issue.table_name}.id) as total 
 793                                                from 
 794                                                  #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
 795                                                where 
 796                                                  #{Issue.table_name}.status_id=s.id
 797                                                  and #{Issue.table_name}.project_id = #{Project.table_name}.id
 798                                                  and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
 799                                                  and #{Issue.table_name}.project_id <> #{project.id}
 800                                                group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
 801    end
 802    # End ReportsController extraction
 803 
 804    # Returns an array of projects that user can assign the issue to
 805    def allowed_target_projects(user=User.current)
 806      if new_record?
 807        Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
 808      else
 809        self.class.allowed_target_projects_on_move(user)
 810      end
 811    end
 812 
 813    # Returns an array of projects that user can move issues to
 814    def self.allowed_target_projects_on_move(user=User.current)
 815      Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
 816    end
 817 
 818    private
 819 
 820    def after_project_change
 821      # Update project_id on related time entries
 822      TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
 823 
 824      # Delete issue relations
 825      unless Setting.cross_project_issue_relations?
 826        relations_from.clear
 827        relations_to.clear
 828      end
 829 
 830      # Move subtasks
 831      children.each do |child|
 832        # Change project and keep project
 833        child.send :project=, project, true
 834        unless child.save
 835          raise ActiveRecord::Rollback
 836        end
 837      end
 838    end
 839 
 840    def update_nested_set_attributes
 841      if root_id.nil?
 842        # issue was just created
 843        self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
 844        set_default_left_and_right
 845        Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
 846        if @parent_issue
 847          move_to_child_of(@parent_issue)
 848        end
 849        reload
 850      elsif parent_issue_id != parent_id
 851        former_parent_id = parent_id
 852        # moving an existing issue
 853        if @parent_issue && @parent_issue.root_id == root_id
 854          # inside the same tree
 855          move_to_child_of(@parent_issue)
 856        else
 857          # to another tree
 858          unless root?
 859            move_to_right_of(root)
 860            reload
 861          end
 862          old_root_id = root_id
 863          self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
 864          target_maxright = nested_set_scope.maximum(right_column_name) || 0
 865          offset = target_maxright + 1 - lft
 866          Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
 867                            ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
 868          self[left_column_name] = lft + offset
 869          self[right_column_name] = rgt + offset
 870          if @parent_issue
 871            move_to_child_of(@parent_issue)
 872          end
 873        end
 874        reload
 875        # delete invalid relations of all descendants
 876        self_and_descendants.each do |issue|
 877          issue.relations.each do |relation|
 878            relation.destroy unless relation.valid?
 879          end
 880        end
 881        # update former parent
 882        recalculate_attributes_for(former_parent_id) if former_parent_id
 883      end
 884      remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
 885    end
 886 
 887    def update_parent_attributes
 888      recalculate_attributes_for(parent_id) if parent_id
 889    end
 890 
 891    def recalculate_attributes_for(issue_id)
 892      if issue_id && p = Issue.find_by_id(issue_id)
 893        # priority = highest priority of children
 894        if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
 895          p.priority = IssuePriority.find_by_position(priority_position)
 896        end
 897 
 898        # start/due dates = lowest/highest dates of children
 899        p.start_date = p.children.minimum(:start_date)
 900        p.due_date = p.children.maximum(:due_date)
 901        if p.start_date && p.due_date && p.due_date < p.start_date
 902          p.start_date, p.due_date = p.due_date, p.start_date
 903        end
 904 
 905        # done ratio = weighted average ratio of leaves
 906        unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
 907          leaves_count = p.leaves.count
 908          if leaves_count > 0
 909            average = p.leaves.average(:estimated_hours).to_f
 910            if average == 0
 911              average = 1
 912            end
 913            done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
 914            progress = done / (average * leaves_count)
 915            p.done_ratio = progress.round
 916          end
 917        end
 918 
 919        # estimate = sum of leaves estimates
 920        p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
 921        p.estimated_hours = nil if p.estimated_hours == 0.0
 922 
 923        # ancestors will be recursively updated
 924        p.save(false)
 925      end
 926    end
 927 
 928    # Update issues so their versions are not pointing to a
 929    # fixed_version that is not shared with the issue's project
 930    def self.update_versions(conditions=nil)
 931      # Only need to update issues with a fixed_version from
 932      # a different project and that is not systemwide shared
 933      Issue.scoped(:conditions => conditions).all(
 934        :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
 935          " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
 936          " AND #{Version.table_name}.sharing <> 'system'",
 937        :include => [:project, :fixed_version]
 938      ).each do |issue|
 939        next if issue.project.nil? || issue.fixed_version.nil?
 940        unless issue.project.shared_versions.include?(issue.fixed_version)
 941          issue.init_journal(User.current)
 942          issue.fixed_version = nil
 943          issue.save
 944        end
 945      end
 946    end
 947 
 948    # Callback on attachment deletion
 949    def attachment_added(obj)
 950      if @current_journal && !obj.new_record?
 951        @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
 952      end
 953    end
 954 
 955    # Callback on attachment deletion
 956    def attachment_removed(obj)
 957      if @current_journal && !obj.new_record?
 958        @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
 959        @current_journal.save
 960      end
 961    end
 962 
 963    # Default assignment based on category
 964    def default_assign
 965      if assigned_to.nil? && category && category.assigned_to
 966        self.assigned_to = category.assigned_to
 967      end
 968    end
 969 
 970    # Updates start/due dates of following issues
 971    def reschedule_following_issues
 972      if start_date_changed? || due_date_changed?
 973        relations_from.each do |relation|
 974          relation.set_issue_to_dates
 975        end
 976      end
 977    end
 978 
 979    # Closes duplicates if the issue is being closed
 980    def close_duplicates
 981      if closing?
 982        duplicates.each do |duplicate|
 983          # Reload is need in case the duplicate was updated by a previous duplicate
 984          duplicate.reload
 985          # Don't re-close it if it's already closed
 986          next if duplicate.closed?
 987          # Same user and notes
 988          if @current_journal
 989            duplicate.init_journal(@current_journal.user, @current_journal.notes)
 990          end
 991          duplicate.update_attribute :status, self.status
 992        end
 993      end
 994    end
 995 
 996    # Saves the changes in a Journal
 997    # Called after_save
 998    def create_journal
 999      if @current_journal
1000        # attributes changes
1001        if @attributes_before_change
1002          (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1003            before = @attributes_before_change[c]
1004            after = send(c)
1005            next if before == after || (before.blank? && after.blank?)
1006            @current_journal.details << JournalDetail.new(:property => 'attr',
1007                                                          :prop_key => c,
1008                                                          :old_value => before,
1009                                                          :value => after)
1010          }
1011        end
1012        if @custom_values_before_change
1013          # custom fields changes
1014          custom_field_values.each {|c|
1015            before = @custom_values_before_change[c.custom_field_id]
1016            after = c.value
1017            next if before == after || (before.blank? && after.blank?)
1018            
1019            if before.is_a?(Array) || after.is_a?(Array)
1020              before = [before] unless before.is_a?(Array)
1021              after = [after] unless after.is_a?(Array)
1022              
1023              # values removed
1024              (before - after).reject(&:blank?).each do |value|
1025                @current_journal.details << JournalDetail.new(:property => 'cf',
1026                                                              :prop_key => c.custom_field_id,
1027                                                              :old_value => value,
1028                                                              :value => nil)
1029              end
1030              # values added
1031              (after - before).reject(&:blank?).each do |value|
1032                @current_journal.details << JournalDetail.new(:property => 'cf',
1033                                                              :prop_key => c.custom_field_id,
1034                                                              :old_value => nil,
1035                                                              :value => value)
1036              end
1037            else
1038              @current_journal.details << JournalDetail.new(:property => 'cf',
1039                                                            :prop_key => c.custom_field_id,
1040                                                            :old_value => before,
1041                                                            :value => after)
1042            end
1043          }
1044        end
1045        @current_journal.save
1046        # reset current journal
1047        init_journal @current_journal.user, @current_journal.notes
1048      end
1049    end
1050 
1051    # Query generator for selecting groups of issue counts for a project
1052    # based on specific criteria
1053    #
1054    # Options
1055    # * project - Project to search in.
1056    # * field - String. Issue field to key off of in the grouping.
1057    # * joins - String. The table name to join against.
1058    def self.count_and_group_by(options)
1059      project = options.delete(:project)
1060      select_field = options.delete(:field)
1061      joins = options.delete(:joins)
1062 
1063      where = "#{Issue.table_name}.#{select_field}=j.id"
1064 
1065      ActiveRecord::Base.connection.select_all("select    s.id as status_id, 
1066                                                  s.is_closed as closed, 
1067                                                  j.id as #{select_field},
1068                                                  count(#{Issue.table_name}.id) as total 
1069                                                from 
1070                                                    #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1071                                                where 
1072                                                  #{Issue.table_name}.status_id=s.id 
1073                                                  and #{where}
1074                                                  and #{Issue.table_name}.project_id=#{Project.table_name}.id
1075                                                  and #{visible_condition(User.current, :project => project)}
1076                                                group by s.id, s.is_closed, j.id")
1077    end
1078  end