backlogs/redmine_backlogs

View on GitHub
app/models/rb_release.rb

Summary

Maintainability
C
7 hrs
Test Coverage
require 'date'
require 'linear_regression'

class ReleaseBurndown
  def initialize(release)
    @days = release.days
    @planned_velocity = release.planned_velocity
    @data = {}

    calculate_burndown(release)
    calculate_trend
    calculate_planned
  end

  def [](i)
    i = i.intern if i.is_a?(String)
    return nil unless @data[i] # be graceful
    #raise "No burn#{@direction} data series '#{i}', available: #{@data.keys.inspect}" unless @data[i]
    return @data[i]
  end

  def series(select = :active)
    return @data.keys.collect{ |k| k.to_s }
#    return @available_series.values.select{|s| (select == :all) }.sort{|x,y| "#{x.name}" <=> "#{y.name}"}
  end

  attr_reader :planned_estimate_end_date
  attr_reader :trend_estimate_end_date
  attr_reader :lr_closed # linear regression closed
  attr_reader :lr_scope # linear regression scope

  private

  def calculate_burndown(release)
    @data[:offset_points] = []
    @data[:added_points] = []
    @data[:backlog_points] = []
    @data[:closed_points] = []

    baseline = [0] * @days.size

    series = Backlogs::MergedArray.new
    series.merge(:total_points => baseline.dup)
    series.merge(:added_points => baseline.dup)
    series.merge(:closed_points => baseline.dup)

    # Fetch stories of all time an find out which ones has missing cache entries
    # for any of the given days.
    release.stories_all_time.where(
            "( select count(c.day) from rb_release_burnchart_day_caches c
            where issues.id = c.issue_id AND
            c.release_id = ? AND c.day IN (?)) != ?",
            release.id, @days, @days.size).each{|s|
              s.update_release_burnchart_data(@days,release.id)
            }

    # Calculate total, added and closed points
    total = RbReleaseBurnchartDayCache.where("release_id = ? AND day IN (?)",release.id,@days)
              .order(:day).group(:day).sum(:total_points)
    added = RbReleaseBurnchartDayCache.where("release_id = ? AND day IN (?)",release.id,@days)
              .order(:day).group(:day).sum(:added_points)
    closed = RbReleaseBurnchartDayCache.where("release_id = ? AND day IN (?)",release.id,@days)
               .order(:day).group(:day).sum(:closed_points)

    # Series collected, now format data for jqplot
    # Slightly hacky formatting to get the correct view. Might change when this jqplot issue is 
    # sorted out:
    # See https://bitbucket.org/cleonello/jqplot/issue/181/nagative-values-in-stacked-bar-chart
    @data[:closed_points] = closed.values
    @data[:backlog_points] = total.values.each_with_index.map{|n,i|
      n - closed.values[i] - added.values[i] }
    @data[:added_points] = added.values
    @data[:total_points] = total.values

    # Keyfigures for later calculations
    @index_last_active = calc_index_before(release.last_active_sprint_date)
    @index_estimate_last = calc_index_before
    @last_total_points = @data[:total_points][@index_last_active] # notice total points utilize latest active sprint
    @last_closed_points = @data[:closed_points][@index_estimate_last] # whereas closed points does not look at ongoing sprints
    @last_points_left = @last_total_points - @last_closed_points
    @last_date = release.days[@index_estimate_last]
  end


  def calculate_trend
    @data[:trend_scope] = []
    @data[:trend_closed] = []

    return unless @last_points_left > 0
    return unless @index_estimate_last > 0
    avg_count = @index_estimate_last >= 3 ? 3 : @index_estimate_last
    index_estimate_first = @index_estimate_last - avg_count
    index_last_active_first = @index_last_active - avg_count

    @lr_closed = Backlogs::LinearRegression.new(@days[index_estimate_first..@index_estimate_last],
                                                @data[:closed_points][index_estimate_first..@index_estimate_last])

    @lr_scope = Backlogs::LinearRegression.new(@days[index_last_active_first..@index_last_active],
                                               @data[:total_points][index_last_active_first..@index_last_active])

    #Calculate trend end date (crossing trend_closed and trend_added)
    trend_cross_date = @lr_closed.crossing_date(@lr_scope)

    # Use active sprint closed points data to recalculate closed trendline if crossing is before current date
    if trend_cross_date.nil? or trend_cross_date < Time.now.to_date
      @lr_closed = Backlogs::LinearRegression.new(@days[index_estimate_first..@index_last_active],
                                                  @data[:closed_points][index_estimate_first..@index_last_active])
    end

    # Recalculate crossing date
    trend_cross_date = @lr_closed.crossing_date(@lr_scope)

    return if trend_cross_date.nil? # Still no crossing date in the future, nothing to show...


    trend_cross_days = (trend_cross_date - @last_date).to_i

    # Value for display in sidebar
    @trend_estimate_end_date = trend_cross_date

    estimate_days = 800
    # Add beginning and end dataset [sprint,points] for trendlines
    trendline_end_date = trend_cross_days.between?(1,730) ? trend_cross_date + 30 : @last_date + estimate_days

    @data[:trend_closed] = lr_closed.predict_line(trendline_end_date)
    @data[:trend_scope] = lr_scope.predict_line(trendline_end_date)
  end

  def calculate_planned
    @data[:planned] = []

    return unless @last_points_left > 0
    return unless @planned_velocity.is_a? Float
    return unless @planned_velocity > 0.0

    @data[:planned] << [@last_date, @last_closed_points]
    #FIXME add possibility to choose velocity per week, fortnight, month?
    @planned_estimate_end_date = @last_date + (@last_points_left / @planned_velocity * 30)
    @data[:planned] << [@planned_estimate_end_date, @data[:total_points][@index_estimate_last]]

  end

  # Calculate index in days array before last_date
  def calc_index_before(last_date = nil)
    date = last_date.nil? ? Time.now.to_date : last_date
    result = []
    result << 0
    @days.each_with_index{|d,i|
      result << i if d <= date
    }
    return result[-1]
  end

end

class RbRelease < ActiveRecord::Base
  self.table_name = 'releases'

  RELEASE_STATUSES = %w(open closed)
  RELEASE_SHARINGS = %w(none descendants hierarchy tree system)

  unloadable

  belongs_to :project, :inverse_of => :releases
  has_many :issues, :class_name => 'RbStory', :foreign_key => 'release_id', :dependent => :nullify
  has_many :rb_release_burnchart_day_cache, :dependent => :delete_all, :foreign_key => 'release_id'

  validates_presence_of :project_id, :name, :release_start_date, :release_end_date
  validates_inclusion_of :status, :in => RELEASE_STATUSES
  validates_inclusion_of :sharing, :in => RELEASE_SHARINGS
  validates_length_of :name, :maximum => 64
  validate :dates_valid?

  scope :open, :conditions => {:status => 'open'}
  scope :closed, :conditions => {:status => 'closed'}
  scope :visible, lambda {|*args| { :include => :project,
                                    :conditions => Project.allowed_to_condition(args.first || User.current, :view_releases) } }


  include Backlogs::ActiveRecord::Attributes

  def to_s; name end

  def closed?
    status == 'closed'
  end

  def dates_valid?
    errors.add(:base, "release_end_after_start") if self.release_start_date >= self.release_end_date if self.release_start_date and self.release_end_date
  end

  def stories #compat
    issues
  end

  # Returns current stories + stories previously scheduled for this release
  def stories_all_time
    RbStory.includes(:journals => :details).where(
            "(release_id = ?) OR (
            journal_details.property ='attr' and
            journal_details.prop_key = 'release_id' and
            (journal_details.old_value = ? or journal_details.value = ?))",
            self.id,self.id.to_s,self.id.to_s).release_burndown_includes
  end

  #Return sprints that contain issues within this release
  def sprints
    RbSprint.where('id in (select distinct(fixed_version_id) from issues where release_id=?)', id).order('versions.effective_date')
  end

  # Return sprints closed within this release
  def closed_sprints
    sprints.where("versions.status = ?", "closed")
  end

  def stories_by_sprint
    order = Backlogs.setting[:sprint_sort_order] == 'desc' ? 'DESC' : 'ASC'
#return issues sorted into sprints. Obviously does not return issues which are not in a sprint
#unfortunately, group_by returns unsorted results.
    issues.where(:tracker_id => RbStory.trackers).joins(:fixed_version).includes(:fixed_version).order("versions.effective_date #{order}").group_by(&:fixed_version_id)
  end

  # The dates are:
  # start: first day of release
  # 1..n: a day after the nth sprint
  def days
    current_date = Time.now.to_date
    days_of_interest = Array.new
    days_of_interest << self.release_start_date.to_date
    self.sprints.each{|sprint|
      days_of_interest << sprint.effective_date.to_date unless sprint.effective_date.nil?
    }
    # Add current day if we are past last sprint end date and has open stories
    days_of_interest << Time.now.to_date if self.has_open_stories? and Time.now.to_date > days_of_interest[-1]
    return days_of_interest.uniq.sort
  end

  def last_closed_sprint_date
    RbSprint.where('id in (select distinct(fixed_version_id) from issues where release_id=?) and versions.status = ?', id, "closed").order("versions.effective_date DESC").first.effective_date.to_date unless closed_sprints.size == 0
  end

  def last_active_sprint_date
    last_active_sprint = RbSprint.where('id in (select distinct(fixed_version_id) from issues where release_id=? and ? between sprint_start_date and effective_date)', id,Time.now.beginning_of_day).order("versions.effective_date DESC")
    return last_active_sprint.first.effective_date.to_date unless last_active_sprint.size == 0
  end

  def has_open_stories?
    stories.open.size > 0
  end

  def has_burndown?
    return self.stories.size > 0
  end

  def burndown
    # @cached_burndown is not the release_burndown_cache. This is just an instance variable to avoid
    # calculating the object more than once _per request_.
    # Nevertheless, @cached_burndown will be calculated at least once per request.
    #
    # On the other hand, rebuild of individual release_burndown_cache entries is triggered inside
    # each story when a story changes. Release_burndown_cache is not one atomic piece of data but
    # a set of fragments per story and therefore it does not need a logic similar to the sprint burndown cache.
    # If anything (dates, sprints, sprint status etc.) changes, the release burndown will ask just more
    # stories and thus the cache needs no touching.
    return nil if not self.has_burndown?
    @cached_burndown ||= ReleaseBurndown.new(self)
    return @cached_burndown
  end

  def today
    ReleaseBurndownDay.find(:first, :conditions => { :release_id => self, :day => Date.today })
  end

  def remaining_story_points #FIXME merge bohansen_release_chart removed this
    res = 0
    stories.open.each {|s| res += s.story_points if s.story_points}
    res
  end

  def allowed_sharings(user = User.current)
    RELEASE_SHARINGS.select do |s|
      if sharing == s
        true
      else
        case s
        when 'system'
          # Only admin users can set a systemwide sharing
          user.admin?
        when 'hierarchy', 'tree'
          # Only users allowed to manage versions of the root project can
          # set sharing to hierarchy or tree
          project.nil? || user.allowed_to?(:manage_versions, project.root)
        else
          true
        end
      end
    end
  end

  def shared_to_projects(scope_project)
    @shared_projects ||=
      begin
        # Project used when fetching tree sharing
        r = self.project.root? ? self.project : self.project.root
        # Project used for other sharings
        p = self.project
        Project.visible.scoped(:include => :releases,
          :conditions => ["#{RbRelease.table_name}.id = #{id}" +
          " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
          " 'system' = ? " +
          " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND ? = 'tree')" +
          " OR (#{Project.table_name}.lft > #{p.lft} AND #{Project.table_name}.rgt < #{p.rgt} AND ? IN ('hierarchy', 'descendants'))" +
          " OR (#{Project.table_name}.lft < #{p.lft} AND #{Project.table_name}.rgt > #{p.rgt} AND ? = 'hierarchy')" +
          "))",sharing,sharing,sharing,sharing]).order('lft')
      end
    @shared_projects
  end

  #FIXME Code should be moved to migration task and not require the real model-objects.
  #See "Using model in your migrations." on http://guides.rubyonrails.org/migrations.html.
  #migrate old date-based releases to relation-based
  def self.integrate_implicit_stories
    unless RbStory.trackers
      puts "Redmine not configured, skipping release migratinos"
      return
    end
    #each release from newest to oldest
    RbRelease.order('release_end_date desc').each do |release|
      if release.project.nil?
        # Release comes from deleted project before dependency was added.
        release.delete
      else
        release.project.versions.select{ |v| v.due_date && (v.due_date>=release.release_start_date && v.due_date<=release.release_end_date)
        }.each do |version|
          #each sprint that lies within the release
          version.fixed_issues.where('tracker_id in (?)', RbStory.trackers).each { |issue|
            #each issue in that version which is a story and does not belong to a release, yet
            if issue.release_id.nil?
              # Only update column, hence no callback functions executed which
              # might touch non-existing columns during migration.
              issue.update_column(:release_id,release.id)
            end
          }
        end #sprints
      end #releases
    end #if project.nil?
  end

end