app/models/rb_story.rb
class RbStory < Issue
unloadable
RELEASE_RELATIONSHIP = %w(auto initial continued added)
private
def self.__find_options_normalize_option(option)
option = [option] if option && !option.is_a?(Array)
option = option.collect{|s| s.is_a?(Integer) ? s : s.id} if option
end
def self.__find_options_add_permissions(options)
permission = options.delete(:permission)
permission = false if permission.nil?
options[:conditions] ||= []
if permission
if Issue.respond_to? :visible_condition
visible = Issue.visible_condition(User.current, :project => project || Project.find(project_id))
else
visible = Project.allowed_to_condition(User.current, :view_issues)
end
Backlogs::ActiveRecord.add_condition(options, visible)
end
end
def self.__find_options_sprint_condition(project_id, sprint_ids)
if Backlogs.settings[:sharing_enabled]
["
tracker_id in (?)
and fixed_version_id IN (?)", self.trackers, sprint_ids]
else
["
project_id = ?
and tracker_id in (?)
and fixed_version_id IN (?)", project_id, self.trackers, sprint_ids]
end
end
def self.__find_options_release_condition(project_id, release_ids)
["
project_id in (#{Project.find(project_id).projects_in_shared_product_backlog.map{|p| p.id}.join(',')})
and tracker_id in (?)
and fixed_version_id is NULL
and release_id in (?)", self.trackers, release_ids]
end
def self.__find_options_pbl_condition(project_id)
["
project_id in (#{Project.find(project_id).projects_in_shared_product_backlog.map{|p| p.id}.join(',')})
and tracker_id in (?)
and release_id is NULL
and fixed_version_id is NULL
and is_closed = ?", self.trackers, false]
end
public
def self.find_options(options)
options = options.dup
project = options.delete(:project)
if project.nil?
project_id = nil
elsif project.is_a?(Integer)
project_id = project
project = nil
else
project_id = project.id
end
self.__find_options_add_permissions(options)
sprint_ids = self.__find_options_normalize_option(options.delete(:sprint))
release_ids = self.__find_options_normalize_option(options.delete(:release))
if sprint_ids
Backlogs::ActiveRecord.add_condition(options, self.__find_options_sprint_condition(project_id, sprint_ids))
elsif release_ids
Backlogs::ActiveRecord.add_condition(options, self.__find_options_release_condition(project_id, release_ids))
else #product backlog
Backlogs::ActiveRecord.add_condition(options, self.__find_options_pbl_condition(project_id))
options[:joins] ||= []
options[:joins] [options[:joins]] unless options[:joins].is_a?(Array)
options[:joins] << :status
options[:joins] << :project
end
options
end
scope :backlog_scope, lambda{|opts| RbStory.find_options(opts) }
def self.inject_lower_higher
prev = nil
i = 1
all.map {|story|
#optimization: set virtual attributes to avoid hundreds of sql queries
# this requires that the scope is clean - meaning exactly ONE backlog is queried here.
prev.higher_item = story if prev
story.lower_item = prev
prev = story
}
end
def self.backlog(project_id, sprint_id, release_id, options={})
self.visible.order("#{self.table_name}.position").
backlog_scope(
options.merge({
:project => project_id,
:sprint => sprint_id,
:release => release_id
}))
end
def self.product_backlog(project, limit=nil)
return RbStory.backlog(project.id, nil, nil, :limit => limit)
end
def self.sprint_backlog(sprint, options={})
return RbStory.backlog(sprint.project.id, sprint.id, nil, options)
end
def self.release_backlog(release, options={})
return RbStory.backlog(release.project.id, nil, release.id, options)
end
def self.backlogs_by_sprint(project, sprints, options={})
#make separate queries for each sprint to get higher/lower item right
return [] unless sprints
sprints.map do |s|
{ :sprint => s,
:stories => RbStory.backlog(project.id, s.id, nil, options)
}
end
end
def self.backlogs_by_release(project, releases, options={})
#make separate queries for each release to get higher/lower item right
return [] unless releases
releases.map do |r|
{ :release => r,
:stories => RbStory.backlog(project.id, nil, r.id, options)
}
end
end
def self.create_and_position(params)
params['prev'] = params.delete('prev_id') if params.include?('prev_id')
params['next'] = params.delete('next_id') if params.include?('next_id')
params['prev'] = nil if (['next', 'prev'] - params.keys).size == 2
# lft and rgt fields are handled by acts_as_nested_set
attribs = params.select{|k,v| !['prev', 'next', 'id', 'lft', 'rgt'].include?(k) && RbStory.column_names.include?(k) }
attribs = Hash[*attribs.flatten]
s = RbStory.new(attribs)
s.save!
s.position!(params)
return s
end
scope :updated_since, lambda {|since|
where(["#{self.table_name}.updated_on > ?", Time.parse(since)]).
order("#{self.table_name}.updated_on ASC")
}
def self.find_all_updated_since(since, project_id)
#look in backlog, sprint and releases. look in shared sprints and shared releases
project = Project.select("id,lft,rgt").find_by_id(project_id)
sprints = project.open_shared_sprints.map{|s|s.id}
releases = project.open_releases_by_date.map{|s|s.id}
#following will execute 3 queries and join it as array
self.backlog_scope( {:project => project_id, :sprint => nil, :release => nil } ).
updated_since(since) |
self.backlog_scope( {:project => project_id, :sprint => sprints, :release => nil } ).
updated_since(since) |
self.backlog_scope( {:project => project_id, :sprint => nil, :release => releases } ).
updated_since(since)
end
def self.trackers(options = {})
# legacy
options = {:type => options} if options.is_a?(Symbol)
# somewhere early in the initialization process during first-time migration this gets called when the table doesn't yet exist
trackers = []
if has_settings_table
trackers = Backlogs.setting[:story_trackers]
trackers = [] if trackers.blank?
end
trackers = Tracker.find_all_by_id(trackers)
trackers = trackers & options[:project].trackers if options[:project]
trackers = trackers.sort_by { |t| [t.position] }
case options[:type]
when :trackers then return trackers
when :array, nil then return trackers.collect{|t| t.id}
when :string then return trackers.collect{|t| t.id.to_s}.join(',')
else raise "Unexpected return type #{options[:type].inspect}"
end
end
def self.has_settings_table
ActiveRecord::Base.connection.tables.include?('settings')
end
def tasks
return self.children
end
def set_points(p)
return self.journalized_update_attribute(:story_points, nil) if p.blank? || p == '-'
return self.journalized_update_attribute(:story_points, 0) if p.downcase == 's'
return self.journalized_update_attribute(:story_points, Float(p)) if Float(p) >= 0
end
def points_display(notsized='-')
# For reasons I have yet to uncover, activerecord will
# sometimes return numbers as Fixnums that lack the nil?
# method. Comparing to nil should be safe.
return notsized if story_points == nil || story_points.blank?
return 'S' if story_points == 0
return story_points.to_s
end
def update_and_position!(params)
params['prev'] = params.delete('prev_id') if params.include?('prev_id')
params['next'] = params.delete('next_id') if params.include?('next_id')
self.position!(params)
# lft and rgt fields are handled by acts_as_nested_set
attribs = params.select{|k,v| !['prev', 'id', 'project_id', 'lft', 'rgt'].include?(k) && RbStory.column_names.include?(k) }
attribs = Hash[*attribs.flatten]
return self.journalized_update_attributes attribs
end
def position!(params)
if params.include?('prev')
if params['prev'].blank?
self.move_to_top # move after 'prev'. Meaning no prev, we go at top
else
self.move_after(RbStory.find(params['prev']))
end
elsif params.include?('next')
if params['next'].blank?
self.move_to_bottom
else
self.move_before(RbStory.find(params['next']))
end
end
end
def update_release_burnchart_data(days,release_burndown_id)
#Idea: is it feasible to only recalculate missing days?
calculate_release_burndown_data(days,release_burndown_id)
end
def save_release_burnchart_data(series,release_burndown_id)
RbReleaseBurnchartDayCache.delete_all(
["issue_id = ? AND release_id = ? AND day IN (?)",
self.id,
release_burndown_id,
series.series(:day)])
series.each{|s|
RbReleaseBurnchartDayCache.create(:issue_id => self.id,
:release_id => release_burndown_id,
:day => s.day,
:total_points => s.total_points.nil? ? 0 : s.total_points,
:added_points => s.added_points.nil? ? 0 : s.added_points,
:closed_points => s.closed_points.nil? ? 0 : s.closed_points)
}
end
#private
# Calculates total, added and closed points for each day of interest
# in a release. The result is stored as RbReleaseBurnchartDayCache-objects
# per day. Stored data include:
# :total_points is all points in release including closed+added at given day
# :added_points is points from stories added after release start
# :closed_points is accumulated number of closed points
# @param days of interest in the release
# @param release_burndown_id release_id of burnchart under calculation
def calculate_release_burndown_data(days, release_burndown_id)
baseline = [0] * days.size
series = Backlogs::MergedArray.new
series.merge(:total_points => baseline.dup)
series.merge(:closed_points => baseline.dup)
series.merge(:added_points => baseline.dup)
# Collect data
bd = {:points => [], :open => [], :accepted => [], :in_release => [], :rejected => [] }
self.history.filter_release(days).each{|d|
if d.nil? || d[:tracker] != :story
[:points, :open, :accepted, :in_release, :rejected].each{|k| bd[k] << nil }
else
bd[:points] << d[:story_points]
bd[:open] << d[:status_open]
bd[:accepted] << d[:status_success]
bd[:in_release] << (d[:release] == release_burndown_id)
bd[:rejected] << (d[:status_open] == false && d[:status_success] == false)
end
}
series.merge(:accepted => bd[:accepted])
series.merge(:points => bd[:points])
series.merge(:open => bd[:open])
series.merge(:in_release => bd[:in_release])
series.merge(:rejected => bd[:rejected])
series.merge(:day => days)
in_release_first = (bd[:in_release][0] == true)
index_first = bd[:points].find_index{|i| i}
story_points_first = index_first ? bd[:points][index_first] : 0
# Extract total, closed and added points during release
series.each{|p|
if release_relationship == 'auto'
p.total_points = calc_total_auto(p,days,in_release_first)
p.closed_points = calc_closed_auto(p,days,in_release_first)
p.added_points = calc_added_auto(p,days,in_release_first)
else
p.total_points = calc_total_manual(p,days,release_burndown_id)
p.closed_points = calc_closed_manual(p,days,release_burndown_id)
p.added_points = calc_added_manual(p,days,release_burndown_id)
end
}
rl = {}
rl[:total_points] = series.series(:total_points)
rl[:added_points] = series.series(:added_points)
rl[:closed_points] = series.series(:closed_points)
self.save_release_burnchart_data(series,release_burndown_id)
end
#optimization for RbRelease.stories_all_time to eager load all the required stuff
def self.release_burndown_includes
#return a scope for release burndown chart rendering
includes(:relations_from, :relations_to)
end
# Definition of a continued story:
# * "Copied to" relation with another story
# * The other story is in same release
# * The other story is rejected
def continued_story?
self.relations.each{|r|
if r.relation_type == IssueRelation::TYPE_COPIED_TO
from_story = RbStory.find(r.issue_from_id)
if from_story.status.backlog_is?(:failure)
#FIXME check from_story is in the same release as this story at the
# point in time being examined.
return true
end
end
}
return false
end
def burndown(sprint = nil, status=nil)
return nil unless self.is_story?
sprint ||= self.fixed_version.becomes(RbSprint) if self.fixed_version
return nil if sprint.nil? || !sprint.has_burndown?
bd = {:points_committed => [], :points_accepted => [], :points_resolved => [], :hours_remaining => []}
self.history.filter(sprint, status).each{|d|
if d.nil? || d[:sprint] != sprint.id || d[:tracker] != :story
[:points_committed, :points_accepted, :points_resolved, :hours_remaining].each{|k| bd[k] << nil}
else
bd[:points_committed] << d[:story_points]
bd[:points_accepted] << (d[:status_success] ? d[:story_points] : 0)
bd[:points_resolved] << (d[:status_success] || d[:hours].to_f == 0.0 ? d[:story_points] : 0)
bd[:hours_remaining] << (d[:status_closed] ? 0 : d[:hours])
end
}
return bd
end
def list_with_gaps_scope_condition(options={})
return options if self.new_record?
self.class.find_options(options.dup.merge({
:project => self.project_id,
:sprint => self.fixed_version_id,
:release => self.release_id
}))
end
def story_follow_task_state
return if Setting.plugin_redmine_backlogs[:story_follow_task_status] != 'close' && Setting.plugin_redmine_backlogs[:story_follow_task_status] != 'loose'
return if self.status.is_closed? #bail out if we are closed
self.reload #we might be stale at this point
case Setting.plugin_redmine_backlogs[:story_follow_task_status]
when 'close'
set_closed_status_if_following_to_close
when 'loose'
avg_ratio = tasks.map{|task| task.status.default_done_ratio.to_f }.sum / tasks.length # #837 coerce to float, nil counts for 0.0
#find status near avg_ratio
#find the status allowed, order by position, with nearest default_done_ratio not higher then avg_ratio
new_st = nil
self.new_statuses_allowed_to.each{|status|
new_st = status if status.default_done_ratio.to_f <= avg_ratio # #837 use to_f for comparison of number OR nil
break if status.default_done_ratio.to_f > avg_ratio
}
#set status and good.
self.journalized_update_attributes :status_id => new_st.id if new_st
set_closed_status_if_following_to_close
#calculate done_ratio weighted from tasks
recalculate_attributes_for(self.id) unless Issue.use_status_for_done_ratio?
else
end
end
def set_closed_status_if_following_to_close
status_id = Setting.plugin_redmine_backlogs[:story_close_status_id]
unless status_id.nil? || status_id.to_i == 0
# bail out if something is other than closed.
tasks.each{|task|
return unless task.status.is_closed?
}
self.journalized_update_attributes :status_id => status_id.to_i #update, but no need to position
end
end
private
def calc_total_auto(p,days,in_release_first)
return p.points if (p.in_release == true) && (p.rejected == false) &&
( continued_story? == false || continued_story? == true && created_on.to_date <= p.day)
# last part above (continued... || continu....) takes care of an edge case because
# RbIssueHistory adds an entry for all issues the day before created_on.
# Without this the continued story's points might show up a sprint too early.
0
end
def calc_total_manual(p,days,release_burndown_id)
return p.points if p.rejected == false &&
(release_id == release_burndown_id || p.in_release) &&
( continued_story? == false || continued_story? == true && created_on.to_date <= p.day)
# See description for calc_total_auto
0
end
def calc_closed_auto(p,days,in_release_first)
return p.points if p.in_release == true && p.accepted == true
0
end
def calc_closed_manual(p,days,release_burndown_id)
return p.points if p.accepted == true && release_id == release_burndown_id
0
end
def calc_added_auto(p,day,in_release_first)
return p.points if p.in_release == true &&
p.open == true &&
continued_story? == false &&
in_release_first == false
0
end
def calc_added_manual(p,days,release_burndown_id)
return p.points if release_id == release_burndown_id &&
release_relationship == 'added' &&
p.open == true
0
end
end