issue.rb 31.6 KB
Newer Older
1 2
# Redmine - project management software
# Copyright (C) 2006-2011  Jean-Philippe Lang
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
#
# 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.
# 
# 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.
# 
# 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 20
  include Redmine::SafeAttributes
  
21 22 23 24 25 26
  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'
  belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
  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
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 35 36
  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
  
37
  acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38
  acts_as_attachable :after_remove => :attachment_removed
39
  acts_as_customizable
40
  acts_as_watchable
41 42 43 44
  acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
                     :include => [:project, :journals],
                     # sort by id so that limited eager loading doesn't break with postgresql
                     :order_column => "#{table_name}.id"
45
  acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 47
                :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
                :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48
  
49 50
  acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
                            :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

62
  named_scope :visible, lambda {|*args| { :include => :project,
63
                                          :conditions => Issue.visible_condition(args.first || User.current) } }
64
  
65
  named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
66

67
  named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
68 69 70
  named_scope :with_limit, lambda { |limit| { :limit => limit} }
  named_scope :on_active_project, :include => [:status, :project, :tracker],
                                  :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
71 72 73 74 75 76 77 78 79 80 81 82

  named_scope :without_version, lambda {
    {
      :conditions => { :fixed_version_id => nil}
    }
  }

  named_scope :with_query, lambda {|query|
    {
      :conditions => Query.merge_conditions(query.statement)
    }
  }
83

84
  before_create :default_assign
85 86
  before_save :close_duplicates, :update_done_ratio_from_issue_status
  after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
87
  after_destroy :update_parent_attributes
88
  
89 90 91 92 93
  # Returns a SQL conditions string used to find all issues visible by the specified user
  def self.visible_condition(user, options={})
    Project.allowed_to_condition(user, :view_issues, options)
  end

94 95 96 97 98
  # Returns true if usr or current user is allowed to view the issue
  def visible?(usr=nil)
    (usr || User.current).allowed_to?(:view_issues, self.project)
  end
  
99 100 101 102
  def after_initialize
    if new_record?
      # set default values for new records only
      self.status ||= IssueStatus.default
103
      self.priority ||= IssuePriority.default
104 105 106
    end
  end
  
107 108 109 110 111
  # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
  def available_custom_fields
    (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
  end
  
112
  def copy_from(arg)
Eric Davis's avatar
Eric Davis committed
113
    issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
114 115
    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}
116
    self.status = issue.status
117 118 119
    self
  end
  
120 121
  # Moves/copies an issue to a new project and tracker
  # Returns the moved/copied issue on success, false on failure
122
  def move_to_project(*args)
123
    ret = Issue.transaction do
124 125 126 127 128 129 130 131 132 133 134 135 136
      move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
    end || false
  end
  
  def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
    options ||= {}
    issue = options[:copy] ? self.class.new.copy_from(self) : self
    
    if new_project && issue.project_id != new_project.id
      # delete issue relations
      unless Setting.cross_project_issue_relations?
        issue.relations_from.clear
        issue.relations_to.clear
137
      end
138 139 140 141 142 143 144
      # issue is moved to another project
      # reassign to the category with same name if any
      new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
      issue.category = new_category
      # Keep the fixed_version if it's still valid in the new_project
      unless new_project.shared_versions.include?(issue.fixed_version)
        issue.fixed_version = nil
145
      end
146 147 148
      issue.project = new_project
      if issue.parent && issue.parent.project_id != issue.project_id
        issue.parent_issue_id = nil
149
      end
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
    end
    if new_tracker
      issue.tracker = new_tracker
      issue.reset_custom_values!
    end
    if options[:copy]
      issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
      issue.status = if options[:attributes] && options[:attributes][:status_id]
                       IssueStatus.find_by_id(options[:attributes][:status_id])
                     else
                       self.status
                     end
    end
    # Allow bulk setting of attributes on the issue
    if options[:attributes]
      issue.attributes = options[:attributes]
    end
    if issue.save
      unless options[:copy]
        # Manually update project_id on related time entries
        TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
        
        issue.children.each do |child|
          unless child.move_to_project_without_transaction(new_project)
            # Move failed and transaction was rollback'd
            return false
          end
177
        end
178
      end
179 180
    else
      return false
181
    end
182
    issue
183
  end
184 185 186 187 188

  def status_id=(sid)
    self.status = nil
    write_attribute(:status_id, sid)
  end
189
  
190 191 192
  def priority_id=(pid)
    self.priority = nil
    write_attribute(:priority_id, pid)
193
  end
194 195 196

  def tracker_id=(tid)
    self.tracker = nil
197 198 199
    result = write_attribute(:tracker_id, tid)
    @custom_field_values = nil
    result
200
  end
201
  
202 203 204 205 206 207 208
  # Overrides attributes= so that tracker_id gets assigned first
  def attributes_with_tracker_first=(new_attributes, *args)
    return if new_attributes.nil?
    new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
    if new_tracker_id
      self.tracker_id = new_tracker_id
    end
209
    send :attributes_without_tracker_first=, new_attributes, *args
210
  end
211 212
  # Do not redefine alias chain on reload (see #4838)
  alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
213
  
214 215 216 217
  def estimated_hours=(h)
    write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
  end
  
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234
  safe_attributes 'tracker_id',
    'status_id',
    'parent_issue_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',
    :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
235
  
236 237 238 239 240
  safe_attributes 'status_id',
    'assigned_to_id',
    'fixed_version_id',
    'done_ratio',
    :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
241

242 243 244 245 246 247
  # 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
  # TODO: move workflow/permission checks from controllers to here
  def safe_attributes=(attrs, user=User.current)
248 249 250
    return unless attrs.is_a?(Hash)
    
    # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
251 252
    attrs = delete_unsafe_attributes(attrs, user)
    return if attrs.empty? 
253
    
254 255 256 257 258
    # Tracker must be set before since new_statuses_allowed_to depends on it.
    if t = attrs.delete('tracker_id')
      self.tracker_id = t
    end
    
259
    if attrs['status_id']
260
      unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
261 262 263
        attrs.delete('status_id')
      end
    end
264 265 266 267 268 269 270 271 272
    
    unless leaf?
      attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
    end
    
    if attrs.has_key?('parent_issue_id')
      if !user.allowed_to?(:manage_subtasks, project)
        attrs.delete('parent_issue_id')
      elsif !attrs['parent_issue_id'].blank?
273
        attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
274 275 276
      end
    end
    
277
    self.attributes = attrs
278 279
  end
  
280
  def done_ratio
281
    if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
282
      status.default_done_ratio
283 284 285 286 287 288 289 290 291 292 293 294 295
    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
  
296 297
  def validate
    if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
298
      errors.add :due_date, :not_a_date
299 300 301
    end
    
    if self.due_date and self.start_date and self.due_date < self.start_date
302
      errors.add :due_date, :greater_than_start_date
303
    end
304 305
    
    if start_date && soonest_start && start_date < soonest_start
306
      errors.add :start_date, :invalid
307
    end
308 309 310 311 312 313 314 315
    
    if fixed_version
      if !assignable_versions.include?(fixed_version)
        errors.add :fixed_version_id, :inclusion
      elsif reopened? && fixed_version.closed?
        errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
      end
    end
316 317 318 319 320 321 322
    
    # 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
323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338
    
    # 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
339 340
  end
  
341 342 343
  # 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
344
    if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
345
      self.done_ratio = status.default_done_ratio
346 347 348
    end
  end
  
349 350 351
  def init_journal(user, notes = "")
    @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
    @issue_before_change = self.clone
352
    @issue_before_change.status = self.status
353 354
    @custom_values_before_change = {}
    self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
355 356
    # Make sure updated_on is updated when adding a note.
    updated_on_will_change!
357 358
    @current_journal
  end
359
  
360 361 362 363 364
  # Return true if the issue is closed, otherwise false
  def closed?
    self.status.is_closed?
  end
  
365 366 367 368 369 370 371 372 373 374 375
  # 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
376 377 378 379 380 381 382 383 384 385 386 387

  # 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
388
  
389 390
  # Returns true if the issue is overdue
  def overdue?
391
    !due_date.nil? && (due_date < Date.today) && !status.is_closed?
392
  end
393 394 395 396 397 398 399

  # 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
400 401 402 403 404

  # Does this issue have children?
  def children?
    !leaf?
  end
405
  
406 407
  # Users the issue can be assigned to
  def assignable_users
408 409
    users = project.assignable_users
    users << author if author
410
    users.uniq.sort
411 412
  end
  
413 414
  # Versions that the issue can be assigned to
  def assignable_versions
415
    @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
416 417
  end
  
418 419 420 421 422
  # 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
  
423
  # Returns an array of status that user is able to apply
424
  def new_statuses_allowed_to(user, include_default=false)
425 426 427 428 429 430
    statuses = status.find_new_statuses_allowed_to(
      user.roles_for_project(project),
      tracker,
      author == user,
      assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
      )
431
    statuses << status unless statuses.empty?
432
    statuses << IssueStatus.default if include_default
433 434
    statuses = statuses.uniq.sort
    blocked? ? statuses.reject {|s| s.is_closed?} : statuses
435 436
  end
  
437
  # Returns the mail adresses of users that should be notified
438
  def recipients
439
    notified = project.notified_users
440 441 442 443
    # Author and assignee are always notified unless they have been
    # locked or don't want to be notified
    notified << author if author && author.active? && author.notify_about?(self)
    notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
444 445 446 447 448 449
    notified.uniq!
    # Remove users that can not view the issue
    notified.reject! {|user| !visible?(user)}
    notified.collect(&:mail)
  end
  
450
  # Returns the total number of hours spent on this issue and its descendants
451 452
  #
  # Example:
453 454
  #   spent_hours => 0.0
  #   spent_hours => 50.2
455
  def spent_hours
456
    @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
457
  end
458 459 460 461 462
  
  def relations
    (relations_from + relations_to).sort
  end
  
463 464
  def all_dependent_issues(except=[])
    except << self
465 466
    dependencies = []
    relations_from.each do |relation|
467
      if relation.issue_to && !except.include?(relation.issue_to) 
468 469 470
        dependencies << relation.issue_to
        dependencies += relation.issue_to.all_dependent_issues(except)
      end
471 472 473 474
    end
    dependencies
  end
  
475
  # Returns an array of issues that duplicate this one
476
  def duplicates
477
    relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
478 479
  end
  
480 481 482 483 484 485
  # 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
  
486 487 488 489 490
  # Returns the time scheduled for this issue.
  # 
  # Example:
  #   Start Date: 2/26/09, End Date: 3/04/09
  #   duration => 6
491 492 493 494 495
  def duration
    (start_date && due_date) ? due_date - start_date : 0
  end
  
  def soonest_start
496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513
    @soonest_start ||= (
        relations_to.collect{|relation| relation.successor_soonest_start} +
        ancestors.collect(&:soonest_start)
      ).compact.max
  end
  
  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
        save
      end
    else
      leaves.each do |leaf|
        leaf.reschedule_after(date)
      end
    end
514
  end
515
  
516 517 518 519 520 521 522 523 524 525
  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
  
526 527 528
  def to_s
    "#{tracker} ##{id}: #{subject}"
  end
529
  
530 531 532 533 534 535 536 537 538
  # Returns a string of css classes that apply to the issue
  def css_classes
    s = "issue status-#{status.position} priority-#{priority.position}"
    s << ' closed' if closed?
    s << ' overdue' if overdue?
    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
539

540
  # Saves an issue, time_entry, attachments, and a journal from the parameters
541
  # Returns false if save fails
542
  def save_issue_with_child_records(params, existing_time_entry=nil)
543
    Issue.transaction do
544
      if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570
        @time_entry = existing_time_entry || TimeEntry.new
        @time_entry.project = project
        @time_entry.issue = self
        @time_entry.user = User.current
        @time_entry.spent_on = Date.today
        @time_entry.attributes = params[:time_entry]
        self.time_entries << @time_entry
      end
  
      if valid?
        attachments = Attachment.attach_files(self, params[:attachments])
  
        attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
        # TODO: Rename hook
        Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
        begin
          if save
            # TODO: Rename hook
            Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
          else
            raise ActiveRecord::Rollback
          end
        rescue ActiveRecord::StaleObjectError
          attachments[:files].each(&:destroy)
          errors.add_to_base l(:notice_locking_conflict)
          raise ActiveRecord::Rollback
571
        end
572 573 574 575
      end
    end
  end

576 577 578 579 580 581 582 583 584 585 586 587 588 589
  # Unassigns issues from +version+ if it's no longer shared with issue's project
  def self.update_versions_from_sharing_change(version)
    # Update issues assigned to the version
    update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
  end
  
  # Unassigns issues from versions that are no longer shared
  # after +project+ was moved
  def self.update_versions_from_hierarchy_change(project)
    moved_project_ids = project.self_and_descendants.reload.collect(&:id)
    # Update issues of the moved projects and issues assigned to a version of a moved project
    Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
  end

590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607
  def parent_issue_id=(arg)
    parent_issue_id = arg.blank? ? nil : arg.to_i
    if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
      @parent_issue.id
    else
      @parent_issue = nil
      nil
    end
  end
  
  def parent_issue_id
    if instance_variable_defined? :@parent_issue
      @parent_issue.nil? ? nil : @parent_issue.id
    else
      parent_id
    end
  end

608 609
  # Extracted from the ReportsController.
  def self.by_tracker(project)
610 611 612
    count_and_group_by(:project => project,
                       :field => 'tracker_id',
                       :joins => Tracker.table_name)
613 614 615
  end

  def self.by_version(project)
616 617 618
    count_and_group_by(:project => project,
                       :field => 'fixed_version_id',
                       :joins => Version.table_name)
619 620 621
  end

  def self.by_priority(project)
622 623 624
    count_and_group_by(:project => project,
                       :field => 'priority_id',
                       :joins => IssuePriority.table_name)
625 626 627
  end

  def self.by_category(project)
628 629 630
    count_and_group_by(:project => project,
                       :field => 'category_id',
                       :joins => IssueCategory.table_name)
631 632 633
  end

  def self.by_assigned_to(project)
634 635 636
    count_and_group_by(:project => project,
                       :field => 'assigned_to_id',
                       :joins => User.table_name)
637 638 639
  end

  def self.by_author(project)
640 641 642
    count_and_group_by(:project => project,
                       :field => 'author_id',
                       :joins => User.table_name)
643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658
  end

  def self.by_subproject(project)
    ActiveRecord::Base.connection.select_all("select    s.id as status_id, 
                                                s.is_closed as closed, 
                                                i.project_id as project_id,
                                                count(i.id) as total 
                                              from 
                                                #{Issue.table_name} i, #{IssueStatus.table_name} s
                                              where 
                                                i.status_id=s.id 
                                                and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
                                              group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
  end
  # End ReportsController extraction
  
659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674
  # Returns an array of projects that current user can move issues to
  def self.allowed_target_projects_on_move
    projects = []
    if User.current.admin?
      # admin is allowed to move issues to any active (visible) project
      projects = Project.visible.all
    elsif User.current.logged?
      if Role.non_member.allowed_to?(:move_issues)
        projects = Project.visible.all
      else
        User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
      end
    end
    projects
  end
   
675 676
  private
  
677 678 679 680 681 682 683 684 685 686 687
  def update_nested_set_attributes
    if root_id.nil?
      # issue was just created
      self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
      set_default_left_and_right
      Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
      if @parent_issue
        move_to_child_of(@parent_issue)
      end
      reload
    elsif parent_issue_id != parent_id
688
      former_parent_id = parent_id
689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717
      # moving an existing issue
      if @parent_issue && @parent_issue.root_id == root_id
        # inside the same tree
        move_to_child_of(@parent_issue)
      else
        # to another tree
        unless root?
          move_to_right_of(root)
          reload
        end
        old_root_id = root_id
        self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
        target_maxright = nested_set_scope.maximum(right_column_name) || 0
        offset = target_maxright + 1 - lft
        Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
                          ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
        self[left_column_name] = lft + offset
        self[right_column_name] = rgt + offset
        if @parent_issue
          move_to_child_of(@parent_issue)
        end
      end
      reload
      # delete invalid relations of all descendants
      self_and_descendants.each do |issue|
        issue.relations.each do |relation|
          relation.destroy unless relation.valid?
        end
      end
718 719
      # update former parent
      recalculate_attributes_for(former_parent_id) if former_parent_id
720 721 722 723 724
    end
    remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
  end
  
  def update_parent_attributes
725 726 727 728 729
    recalculate_attributes_for(parent_id) if parent_id
  end

  def recalculate_attributes_for(issue_id)
    if issue_id && p = Issue.find_by_id(issue_id)
730 731 732 733 734 735 736 737 738 739 740 741 742
      # priority = highest priority of children
      if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
        p.priority = IssuePriority.find_by_position(priority_position)
      end
      
      # start/due dates = lowest/highest dates of children
      p.start_date = p.children.minimum(:start_date)
      p.due_date = p.children.maximum(:due_date)
      if p.start_date && p.due_date && p.due_date < p.start_date
        p.start_date, p.due_date = p.due_date, p.start_date
      end
      
      # done ratio = weighted average ratio of leaves
743
      unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764
        leaves_count = p.leaves.count
        if leaves_count > 0
          average = p.leaves.average(:estimated_hours).to_f
          if average == 0
            average = 1
          end
          done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
          progress = done / (average * leaves_count)
          p.done_ratio = progress.round
        end
      end
      
      # estimate = sum of leaves estimates
      p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
      p.estimated_hours = nil if p.estimated_hours == 0.0
      
      # ancestors will be recursively updated
      p.save(false)
    end
  end
  
765 766 767 768 769 770 771 772 773
  # Update issues so their versions are not pointing to a
  # fixed_version that is not shared with the issue's project
  def self.update_versions(conditions=nil)
    # Only need to update issues with a fixed_version from
    # a different project and that is not systemwide shared
    Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
                                                " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
                                                " AND #{Version.table_name}.sharing <> 'system'",
                                                conditions),
774 775 776 777 778 779 780 781 782 783
              :include => [:project, :fixed_version]
              ).each do |issue|
      next if issue.project.nil? || issue.fixed_version.nil?
      unless issue.project.shared_versions.include?(issue.fixed_version)
        issue.init_journal(User.current)
        issue.fixed_version = nil
        issue.save
      end
    end
  end
784
  
785 786 787 788 789 790 791 792
  # Callback on attachment deletion
  def attachment_removed(obj)
    journal = init_journal(User.current)
    journal.details << JournalDetail.new(:property => 'attachment',
                                         :prop_key => obj.id,
                                         :old_value => obj.filename)
    journal.save
  end
793
  
794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826
  # Default assignment based on category
  def default_assign
    if assigned_to.nil? && category && category.assigned_to
      self.assigned_to = category.assigned_to
    end
  end

  # Updates start/due dates of following issues
  def reschedule_following_issues
    if start_date_changed? || due_date_changed?
      relations_from.each do |relation|
        relation.set_issue_to_dates
      end
    end
  end

  # Closes duplicates if the issue is being closed
  def close_duplicates
    if closing?
      duplicates.each do |duplicate|
        # Reload is need in case the duplicate was updated by a previous duplicate
        duplicate.reload
        # Don't re-close it if it's already closed
        next if duplicate.closed?
        # Same user and notes
        if @current_journal
          duplicate.init_journal(@current_journal.user, @current_journal.notes)
        end
        duplicate.update_attribute :status, self.status
      end
    end
  end
  
827 828 829 830 831
  # Saves the changes in a Journal
  # Called after_save
  def create_journal
    if @current_journal
      # attributes changes
832
      (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
833 834 835 836 837 838 839 840 841 842 843 844 845 846 847
        @current_journal.details << JournalDetail.new(:property => 'attr',
                                                      :prop_key => c,
                                                      :old_value => @issue_before_change.send(c),
                                                      :value => send(c)) unless send(c)==@issue_before_change.send(c)
      }
      # custom fields changes
      custom_values.each {|c|
        next if (@custom_values_before_change[c.custom_field_id]==c.value ||
                  (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
        @current_journal.details << JournalDetail.new(:property => 'cf', 
                                                      :prop_key => c.custom_field_id,
                                                      :old_value => @custom_values_before_change[c.custom_field_id],
                                                      :value => c.value)
      }      
      @current_journal.save
848 849
      # reset current journal
      init_journal @current_journal.user, @current_journal.notes
850 851
    end
  end
852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871

  # Query generator for selecting groups of issue counts for a project
  # based on specific criteria
  #
  # Options
  # * project - Project to search in.
  # * field - String. Issue field to key off of in the grouping.
  # * joins - String. The table name to join against.
  def self.count_and_group_by(options)
    project = options.delete(:project)
    select_field = options.delete(:field)
    joins = options.delete(:joins)

    where = "i.#{select_field}=j.id"
    
    ActiveRecord::Base.connection.select_all("select    s.id as status_id, 
                                                s.is_closed as closed, 
                                                j.id as #{select_field},
                                                count(i.id) as total 
                                              from 
872
                                                  #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
873 874 875 876 877 878 879 880
                                              where 
                                                i.status_id=s.id 
                                                and #{where}
                                                and i.project_id=#{project.id}
                                              group by s.id, s.is_closed, j.id")
  end
  

Jean-Philippe Lang's avatar
Jean-Philippe Lang committed
881
end