# Copyright © Emilio González Montaña # Licence: Attribution & no derivatives # * Attribution to the plugin web page URL should be done if you want to use it. # https://redmine.ociotec.com/projects/redmine-plugin-scrum # * No derivatives of this plugin (or partial) are allowed. # Take a look to licence.txt file at plugin root folder for further details. class SprintsController < ApplicationController menu_item :sprint model_object Sprint before_action :find_model_object, :only => [:show, :edit, :update, :destroy, :edit_effort, :update_effort, :burndown, :stats, :sort] before_action :find_project_from_association, :only => [:show, :edit, :update, :destroy, :edit_effort, :update_effort, :burndown, :stats, :sort] before_action :find_project_by_project_id, :only => [:index, :new, :create, :change_issue_status, :burndown_index, :stats_index] before_action :find_pbis, :only => [:sort] before_action :find_subprojects, :only => [:burndown] before_action :filter_by_project, :only => [:burndown] before_action :calculate_stats, :only => [:show, :burndown, :stats] before_action :authorize accept_api_auth :index, :show helper :custom_fields helper :scrum helper :timelog include Redmine::Utils::DateCalculation def index respond_to do |format| format.html { if (current_sprint = @project.current_sprint) redirect_to sprint_path(current_sprint) else render_error l(:error_no_sprints) end } format.api end rescue render_404 end def show redirect_to project_product_backlog_index_path(@project) if @sprint.is_product_backlog? respond_to do |format| format.html format.api end end def new @sprint = Sprint.new(:project => @project, :is_product_backlog => params[:create_product_backlog]) if @sprint.is_product_backlog @sprint.name = l(:label_product_backlog) @sprint.sprint_start_date = @sprint.sprint_end_date = Date.today elsif @project.sprints.empty? @sprint.name = Scrum::Setting.default_sprint_name @sprint.sprint_start_date = Date.today @sprint.sprint_end_date = add_working_days(@sprint.sprint_start_date, Scrum::Setting.default_sprint_days - 1) @sprint.shared = Scrum::Setting.default_sprint_shared else last_sprint = @project.sprints.last result = last_sprint.name.match(/^(.*)(\d+)(.*)$/) @sprint.name = result.nil? ? Scrum::Setting.default_sprint_name : (result[1] + (result[2].to_i + 1).to_s + result[3]) @sprint.description = last_sprint.description @sprint.sprint_start_date = next_working_date(last_sprint.sprint_end_date + 1) last_sprint_duration = last_sprint.sprint_end_date - last_sprint.sprint_start_date @sprint.sprint_end_date = next_working_date(@sprint.sprint_start_date + last_sprint_duration) @sprint.shared = last_sprint.shared end end def create is_product_backlog = !(params[:create_product_backlog].nil?) @sprint = Sprint.new(:user => User.current, :project => @project, :is_product_backlog => is_product_backlog) @sprint.safe_attributes = params[:sprint] if request.post? and @sprint.save if is_product_backlog @project.product_backlogs << @sprint raise 'Fail to update project with product backlog' unless @project.save! end flash[:notice] = l(:notice_successful_create) redirect_back_or_default settings_project_path(@project, :tab => is_product_backlog ? 'product_backlogs' : 'sprints') else render :action => :new end rescue ActiveRecord::RecordNotFound render_404 end def edit @product_backlog = @sprint if @sprint.is_product_backlog end def update @sprint.safe_attributes = params[:sprint] if @sprint.save flash[:notice] = l(:notice_successful_update) redirect_back_or_default settings_project_path(@project, :tab => 'sprints') else render :action => :edit end end def destroy if @sprint.issues.any? flash[:error] = l(:notice_sprint_has_issues) else @sprint.destroy end rescue flash[:error] = l(:notice_unable_delete_sprint) ensure redirect_to settings_project_path(@project, :tab => 'sprints') end def change_issue_status result = params[:task].match(/^(task|pbi)_(\d+)$/) issue_id = result[2].to_i @issue = Issue.find(issue_id) @old_status = @issue.status # Do not change issue status if not necessary new_status = IssueStatus.find(params[:status].to_i) # Manage case where new status is allowed if new_status && @issue.new_statuses_allowed_to.include?(new_status) @issue.init_journal(User.current) @issue.status = new_status @issue.save! else # Exception replaced by an instance variable # Create error message if new status not allowed @error_message = l(:error_new_status_no_allowed, :status_from => @old_status, :status_to => new_status) end respond_to do |format| format.js { render 'scrum/update_issue' } end end def edit_effort end def update_effort params[:user].each_pair do |user_id, days| user_id = user_id.to_i days.each_pair do |day, effort| day = day.to_i date = @sprint.sprint_start_date + day.to_i sprint_effort = SprintEffort.where(:sprint_id => @sprint.id, :user_id => user_id, :date => date).first if sprint_effort.nil? unless effort.blank? sprint_effort = SprintEffort.new(:sprint_id => @sprint.id, :user_id => user_id, :date => @sprint.sprint_start_date + day, :effort => effort) end elsif effort.blank? sprint_effort.destroy sprint_effort = nil else sprint_effort.effort = effort end sprint_effort.save! unless sprint_effort.nil? end end flash[:notice] = l(:notice_successful_update) redirect_back_or_default settings_project_path(@project, :tab => 'sprints') end def burndown_index if @project.last_sprint redirect_to burndown_sprint_path(@project.last_sprint, :type => params[:type]) else render_error l(:error_no_sprints) end rescue Exception => exception render_404 end MAX_SERIES = 10 def burndown if @sprint.is_product_backlog redirect_to(burndown_product_backlog_path(@sprint)) else if @pbi_filter and @pbi_filter[:filter_by_project] == 'without-total' @pbi_filter.delete(:filter_by_project) without_total = true else without_total = false end @only_one = @project.children.visible.empty? @x_axis_labels = [] serie_label = @only_one ? l(:field_pending_effort) : "#{l(:field_pending_effort)} (#{l(:label_all)})" all_projects_serie = burndown_for_project(@sprint, @project, serie_label, @pbi_filter, @x_axis_labels) @series = [] @series << all_projects_serie unless without_total unless @only_one if @pbi_filter.empty? and @subprojects.count > 2 sub_series = recursive_burndown(@sprint, @project) @series += sub_series end @series.sort! { |serie_1, serie_2| closed = ((serie_1[:project].respond_to?('closed?') and serie_1[:project].closed?) ? 1 : 0) - ((serie_2[:project].respond_to?('closed?') and serie_2[:project].closed?) ? 1 : 0) if 0 != closed closed else serie_2[:max_value] <=> serie_1[:max_value] end } end if params[:type] == 'effort' @series = [estimated_effort_serie(@sprint)] + @series end if @series.count > MAX_SERIES @warning = l(:label_limited_to_n_series, :n => MAX_SERIES) @series = @series.first(MAX_SERIES) end end end def stats_index if @project.last_sprint redirect_to stats_sprint_path(@project.last_sprint) else render_error l(:error_no_sprints) end rescue render_404 end def stats @days = [] @members_efforts = {} @estimated_efforts_totals = {:days => {}, :total => 0.0} @done_efforts_totals = {:days => {}, :total => 0.0} ((@sprint.sprint_start_date)..(@sprint.sprint_end_date)).each do |date| if @sprint.efforts.where(['date = ?', date]).count > 0 @days << {:date => date, :label => "#{I18n.l(date, :format => :scrum_day)} #{date.day}"} if User.current.allowed_to?(:view_sprint_stats_by_member, @project) estimated_effort_conditions = ['date = ?', date] done_effort_conditions = ['spent_on = ?', date] else estimated_effort_conditions = ['date = ? AND user_id = ?', date, User.current.id] done_effort_conditions = ['spent_on = ? AND user_id = ?', date, User.current.id] end @sprint.efforts.where(estimated_effort_conditions).each do |sprint_effort| if sprint_effort.effort init_members_efforts(@members_efforts, sprint_effort.user) member_estimated_efforts_days = init_member_efforts_days(@members_efforts, @sprint, sprint_effort.user, date, true) member_estimated_efforts_days[date] += sprint_effort.effort @members_efforts[sprint_effort.user.id][:estimated_efforts][:total] += sprint_effort.effort @estimated_efforts_totals[:days][date] = 0.0 unless @estimated_efforts_totals[:days].include?(date) @estimated_efforts_totals[:days][date] += sprint_effort.effort @estimated_efforts_totals[:total] += sprint_effort.effort end end project_efforts_for_stats(@project, @sprint, date, done_effort_conditions, @members_efforts, @done_efforts_totals) end end @members_efforts = @members_efforts.values.sort{|a, b| a[:member] <=> b[:member]} @sps_by_pbi_category, @sps_by_pbi_category_total = @sprint.sps_by_pbi_category @sps_by_pbi_type, @sps_by_pbi_type_total = @sprint.sps_by_pbi_type @sps_by_pbi_creation_date, @sps_by_pbi_creation_date_total = @sprint.sps_by_pbi_creation_date @effort_by_activity, @effort_by_activity_total = @sprint.time_entries_by_activity if User.current.allowed_to?(:view_sprint_stats_by_member, @project) @efforts_by_member_and_activity = @sprint.efforts_by_member_and_activity @efforts_by_member_and_activity_chart = {:id => 'stats_efforts_by_member_and_activity', :height => 400} end end def sort new_pbis_order = [] params.keys.each do |param| id = param.scan(/pbi\_(\d+)/) new_pbis_order << id[0][0].to_i if id and id[0] and id[0][0] end @pbis.each do |pbi| if (index = new_pbis_order.index(pbi.id)) pbi.position = index + 1 pbi.save! end end render :body => nil end private def init_members_efforts(members_efforts, member) unless members_efforts.include?(member.id) members_efforts[member.id] = { :member => member, :estimated_efforts => { :days => {}, :total => 0.0 }, :done_efforts => { :days => {}, :total => 0.0 } } end end def init_member_efforts_days(members_efforts, sprint, member, date, estimated) member_efforts_days = members_efforts[member.id][estimated ? :estimated_efforts : :done_efforts][:days] unless member_efforts_days.include?(date) member_efforts_days[date] = 0.0 end return member_efforts_days end def project_efforts_for_stats(project, sprint, date, done_effort_conditions, members_efforts, done_efforts_totals) project.time_entries.where(done_effort_conditions).each do |time_entry| if time_entry.hours init_members_efforts(members_efforts, time_entry.user) member_done_efforts_days = init_member_efforts_days(members_efforts, sprint, time_entry.user, date, false) member_done_efforts_days[date] += time_entry.hours members_efforts[time_entry.user.id][:done_efforts][:total] += time_entry.hours done_efforts_totals[:days][date] = 0.0 unless done_efforts_totals[:days].include?(date) done_efforts_totals[:days][date] += time_entry.hours done_efforts_totals[:total] += time_entry.hours end end if sprint.shared project.children.visible.each do |sub_project| project_efforts_for_stats(sub_project, sprint, date, done_effort_conditions, members_efforts, done_efforts_totals) end end end def find_pbis @pbis = @sprint.pbis rescue render_404 end def calculate_stats if Scrum::Setting.show_project_totals_on_sprint total_pbis_count = @sprint.pbis().count closed_pbis_count = @sprint.closed_pbis().count total_sps_count = @sprint.story_points() closed_sps_count = @sprint.closed_story_points() closed_total_percentage = (total_sps_count == 0.0) ? 0.0 : ((closed_sps_count * 100.0) / total_sps_count) @stats = {:total_pbis_count => total_pbis_count, :closed_pbis_count => closed_pbis_count, :total_sps_count => total_sps_count, :closed_sps_count => closed_sps_count, :closed_total_percentage => closed_total_percentage} end end def find_subprojects if @project and @sprint @subprojects = [[l(:label_all), calculate_path(@sprint)]] @subprojects << [l(:label_all_but_total), calculate_path(@sprint, 'without-total')] if action_name == 'burndown' @subprojects += find_recursive_subprojects(@project, @sprint) end end def find_recursive_subprojects(project, sprint, tabs = '') options = [[tabs + project.name, calculate_path(sprint, project)]] project.children.visible.to_a.each do |child| options += find_recursive_subprojects(child, sprint, tabs + '» ') end return options end def filter_by_project @pbi_filter = {} unless params[:filter_by_project].blank? @pbi_filter = {:filter_by_project => params[:filter_by_project]} end end def calculate_path(sprint, project = nil) options = {} path_method = :burndown_sprint_path if ['burndown'].include?(action_name) options[:type] = params[:type] unless params[:type].blank? end if project.nil? project_id = nil elsif project == 'without-total' options[:filter_by_project] = 'without-total' project_id = 'without-total' else options[:filter_by_project] = project.id project_id = project.id.to_s end result = send(path_method, sprint, options) if (project.nil? and params[:filter_by_project].blank?) or (project_id == params[:filter_by_project]) @selected_subproject = result end return result end def burndown_for_project(sprint, project, label, pbi_filter = {}, x_axis_labels = nil) serie = {:data => [], :label => label, :project => pbi_filter.include?(:filter_by_project) ? Project.find(pbi_filter[:filter_by_project]) : project, :max_value => 0.0} if params[:type] == 'sps' last_sps = sprint.completed_sps_at_day(sprint.sprint_start_date - 1, pbi_filter) last_day = nil last_label = l(:label_begin) if Scrum::Setting.sprint_burndown_day_zero? sprint.completed_sps_by_day(pbi_filter).each do |date, sps| date_label = "#{I18n.l(date, :format => :scrum_day)} #{date.day}" last_label = date_label unless Scrum::Setting.sprint_burndown_day_zero? x_axis_labels << last_label unless x_axis_labels.nil? serie[:max_value] = last_sps if last_sps and last_sps > serie[:max_value] serie[:data] << {:day => date, :pending_sps => last_sps, :pending_sps_tooltip => l(:label_pending_sps_tooltip, :date => last_label, :sps => last_sps)} last_sps = sps last_day = date.day last_label = date_label if Scrum::Setting.sprint_burndown_day_zero? end if serie[:data].any? unless x_axis_labels.nil? if Scrum::Setting.sprint_burndown_day_zero? x_axis_labels << last_label else x_axis_labels[x_axis_labels.length - 1] = l(:label_end) end end serie[:max_value] = last_sps if last_sps and last_sps > serie[:max_value] serie[:data].last[:pending_sps_tooltip] = l(:label_pending_sps_tooltip, :date => last_label, :sps => last_sps) end @type = :sps else sprint_tasks = sprint.tasks(pbi_filter) last_pending_effort = pending_effort_at_day(sprint_tasks, sprint.sprint_start_date - 1) last_day = nil last_label = l(:label_begin) if Scrum::Setting.sprint_burndown_day_zero? ((sprint.sprint_start_date)..(sprint.sprint_end_date)).each do |date| sprint_efforts = sprint.efforts.where(['date >= ?', date]) if sprint_efforts.any? if date <= Date.today pending_effort = pending_effort_at_day(sprint_tasks, date) end date_label = "#{I18n.l(date, :format => :scrum_day)} #{date.day}" last_label = date_label unless Scrum::Setting.sprint_burndown_day_zero? x_axis_labels << last_label unless x_axis_labels.nil? serie[:max_value] = last_pending_effort if last_pending_effort and last_pending_effort > serie[:max_value] serie[:data] << {:day => date, :effort => last_pending_effort, :tooltip => l(:label_pending_effort_tooltip, :date => last_label, :hours => last_pending_effort)} last_pending_effort = pending_effort last_day = date.day last_label = date_label if Scrum::Setting.sprint_burndown_day_zero? end end last_label = l(:label_end) unless Scrum::Setting.sprint_burndown_day_zero? x_axis_labels << last_label unless x_axis_labels.nil? serie[:max_value] = last_pending_effort if last_pending_effort and last_pending_effort > serie[:max_value] serie[:data] << {:day => last_day, :effort => last_pending_effort, :tooltip => l(:label_pending_effort_tooltip, :date => last_label, :hours => last_pending_effort)} @type = :effort end return serie end def estimated_effort_serie(sprint) serie = {:data => [], :label => l(:label_estimated_effort)} last_day = nil last_label = l(:label_begin) if Scrum::Setting.sprint_burndown_day_zero ((sprint.sprint_start_date)..(sprint.sprint_end_date)).each do |date| sprint_efforts = sprint.efforts.where(['date >= ?', date]) if sprint_efforts.any? estimated_effort = sprint_efforts.collect{|effort| effort.effort}.compact.sum date_label = "#{I18n.l(date, :format => :scrum_day)} #{date.day}" last_label = date_label unless Scrum::Setting.sprint_burndown_day_zero serie[:data] << {:day => date, :effort => estimated_effort, :tooltip => l(:label_estimated_effort_tooltip, :date => last_label, :hours => estimated_effort)} last_day = date.day last_label = date_label if Scrum::Setting.sprint_burndown_day_zero end end last_label = l(:label_end) unless Scrum::Setting.sprint_burndown_day_zero serie[:data] << {:day => last_day, :effort => 0, :tooltip => l(:label_estimated_effort_tooltip, :date => last_label, :hours => 0)} return serie end def recursive_burndown(sprint, project) serie_name = "#{l(:field_pending_effort)} (#{project.name})" series = [burndown_for_project(@sprint, @project, serie_name, {:filter_by_project => project.id})] project.children.visible.to_a.each do |child| series += recursive_burndown(sprint, child) end return series end def pending_effort_at_day(tasks, date) efforts = [] tasks.each do |task| if task.use_in_burndown? task_efforts = task.pending_efforts.where(['date <= ?', date]) efforts << (task_efforts.any? ? task_efforts.last.effort : task.estimated_hours) end end return efforts.compact.sum end end