Rewrite the Gantt chart. #6276
This version of the Gantt chart supports nested charts. So Projects, Versions, and Issues will be nested underneath their parents correctly. Additional features: * Move all Gantt code to Redmine::Helpers::Gantt class instead of having it in the Gantt class, controller, and view * Recursive and nest sub-projects * Recursive and nest versions * Recursive and nest issues * Draw a line showing when a Project is active and it's progress * Draw a line showing when a Version is active and it's progress * Show a version's % complete * Change the color of Projects, Versions, and Issues if they are late or behind schedule * Added Project#start_date and #due_date * Added Project#completed_percent * Use a mini-gravatar on the Gantt chart * Added tests for the Gantt rendering git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@4072 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
@@ -4,6 +4,7 @@ class GanttsController < ApplicationController
|
||||
|
||||
rescue_from Query::StatementInvalid, :with => :query_statement_invalid
|
||||
|
||||
helper :gantt
|
||||
helper :issues
|
||||
helper :projects
|
||||
helper :queries
|
||||
@@ -14,32 +15,17 @@ class GanttsController < ApplicationController
|
||||
|
||||
def show
|
||||
@gantt = Redmine::Helpers::Gantt.new(params)
|
||||
@gantt.project = @project
|
||||
retrieve_query
|
||||
@query.group_by = nil
|
||||
if @query.valid?
|
||||
events = []
|
||||
# Issues that have start and due dates
|
||||
events += @query.issues(:include => [:tracker, :assigned_to, :priority],
|
||||
:order => "start_date, due_date",
|
||||
:conditions => ["(((start_date>=? and start_date<=?) or (due_date>=? and due_date<=?) or (start_date<? and due_date>?)) and start_date is not null and due_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
|
||||
)
|
||||
# Issues that don't have a due date but that are assigned to a version with a date
|
||||
events += @query.issues(:include => [:tracker, :assigned_to, :priority, :fixed_version],
|
||||
:order => "start_date, effective_date",
|
||||
:conditions => ["(((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date<? and effective_date>?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
|
||||
)
|
||||
# Versions
|
||||
events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
|
||||
|
||||
@gantt.events = events
|
||||
end
|
||||
@gantt.query = @query if @query.valid?
|
||||
|
||||
basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render :action => "show", :layout => !request.xhr? }
|
||||
format.png { send_data(@gantt.to_image(@project), :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
|
||||
format.pdf { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
|
||||
format.png { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
|
||||
format.pdf { send_data(@gantt.to_pdf, :type => 'application/pdf', :filename => "#{basename}.pdf") }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class IssuesController < ApplicationController
|
||||
include SortHelper
|
||||
include IssuesHelper
|
||||
helper :timelog
|
||||
helper :gantt
|
||||
include Redmine::Export::PDF
|
||||
|
||||
verify :method => [:post, :delete],
|
||||
|
||||
@@ -121,6 +121,11 @@ module ApplicationHelper
|
||||
|
||||
link_to(text, {:controller => 'repositories', :action => 'revision', :id => project, :rev => revision}, :title => l(:label_revision_id, revision))
|
||||
end
|
||||
|
||||
def link_to_project(project, options={})
|
||||
options[:class] ||= 'project'
|
||||
link_to(h(project), {:controller => 'projects', :action => 'show', :id => project}, :class => options[:class])
|
||||
end
|
||||
|
||||
# Generates a link to a project if active
|
||||
# Examples:
|
||||
@@ -832,6 +837,8 @@ module ApplicationHelper
|
||||
email = $1
|
||||
end
|
||||
return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
|
||||
else
|
||||
''
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# redMine - project management software
|
||||
# Copyright (C) 2006 Jean-Philippe Lang
|
||||
#
|
||||
# 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.
|
||||
|
||||
module GanttHelper
|
||||
def number_of_issues_on_versions(gantt)
|
||||
versions = gantt.events.collect {|event| (event.is_a? Version) ? event : nil}.compact
|
||||
|
||||
versions.sum {|v| v.fixed_issues.for_gantt.with_query(@query).count}
|
||||
end
|
||||
end
|
||||
@@ -35,8 +35,10 @@ module IssuesHelper
|
||||
@cached_label_due_date ||= l(:field_due_date)
|
||||
@cached_label_assigned_to ||= l(:field_assigned_to)
|
||||
@cached_label_priority ||= l(:field_priority)
|
||||
|
||||
@cached_label_project ||= l(:field_project)
|
||||
|
||||
link_to_issue(issue) + "<br /><br />" +
|
||||
"<strong>#{@cached_label_project}</strong>: #{link_to_project(issue.project)}<br />" +
|
||||
"<strong>#{@cached_label_status}</strong>: #{issue.status.name}<br />" +
|
||||
"<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />" +
|
||||
"<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />" +
|
||||
|
||||
+26
-1
@@ -62,10 +62,28 @@ class Issue < ActiveRecord::Base
|
||||
|
||||
named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
|
||||
|
||||
named_scope :recently_updated, :order => "#{self.table_name}.updated_on DESC"
|
||||
named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
|
||||
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}"]
|
||||
named_scope :for_gantt, lambda {
|
||||
{
|
||||
:include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
|
||||
:order => "#{Issue.table_name}.due_date ASC, #{Issue.table_name}.start_date ASC, #{Issue.table_name}.id ASC"
|
||||
}
|
||||
}
|
||||
|
||||
named_scope :without_version, lambda {
|
||||
{
|
||||
:conditions => { :fixed_version_id => nil}
|
||||
}
|
||||
}
|
||||
|
||||
named_scope :with_query, lambda {|query|
|
||||
{
|
||||
:conditions => Query.merge_conditions(query.statement)
|
||||
}
|
||||
}
|
||||
|
||||
before_create :default_assign
|
||||
before_save :reschedule_following_issues, :close_duplicates, :update_done_ratio_from_issue_status
|
||||
@@ -357,6 +375,13 @@ class Issue < ActiveRecord::Base
|
||||
def overdue?
|
||||
!due_date.nil? && (due_date < Date.today) && !status.is_closed?
|
||||
end
|
||||
|
||||
# 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
|
||||
|
||||
# Users the issue can be assigned to
|
||||
def assignable_users
|
||||
|
||||
@@ -412,6 +412,50 @@ class Project < ActiveRecord::Base
|
||||
def short_description(length = 255)
|
||||
description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
|
||||
end
|
||||
|
||||
# The earliest start date of a project, based on it's issues and versions
|
||||
def start_date
|
||||
if module_enabled?(:issue_tracking)
|
||||
[
|
||||
issues.minimum('start_date'),
|
||||
shared_versions.collect(&:effective_date),
|
||||
shared_versions.collect {|v| v.fixed_issues.minimum('start_date')}
|
||||
].flatten.compact.min
|
||||
end
|
||||
end
|
||||
|
||||
# The latest due date of an issue or version
|
||||
def due_date
|
||||
if module_enabled?(:issue_tracking)
|
||||
[
|
||||
issues.maximum('due_date'),
|
||||
shared_versions.collect(&:effective_date),
|
||||
shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
|
||||
].flatten.compact.max
|
||||
end
|
||||
end
|
||||
|
||||
def overdue?
|
||||
active? && !due_date.nil? && (due_date < Date.today)
|
||||
end
|
||||
|
||||
# Returns the percent completed for this project, based on the
|
||||
# progress on it's versions.
|
||||
def completed_percent(options={:include_subprojects => false})
|
||||
if options.delete(:include_subprojects)
|
||||
total = self_and_descendants.collect(&:completed_percent).sum
|
||||
|
||||
total / self_and_descendants.count
|
||||
else
|
||||
if versions.count > 0
|
||||
total = versions.collect(&:completed_pourcent).sum
|
||||
|
||||
total / versions.count
|
||||
else
|
||||
100
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Return true if this project is allowed to do the specified action.
|
||||
# action can be:
|
||||
|
||||
@@ -73,6 +73,18 @@ class Version < ActiveRecord::Base
|
||||
def completed?
|
||||
effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
|
||||
end
|
||||
|
||||
def behind_schedule?
|
||||
if completed_pourcent == 100
|
||||
return false
|
||||
elsif due_date && fixed_issues.present? && fixed_issues.minimum('start_date') # TODO: should use #start_date but that method is wrong...
|
||||
start_date = fixed_issues.minimum('start_date')
|
||||
done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
|
||||
return done_date <= Date.today
|
||||
else
|
||||
false # No issues so it's not late
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the completion percentage of this version based on the amount of open/closed issues
|
||||
# and the time spent on the open issues.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<% @gantt.view = self %>
|
||||
<h2><%= l(:label_gantt) %></h2>
|
||||
|
||||
<% form_tag(gantt_path(:month => params[:month], :year => params[:year], :months => params[:months]), :method => :put, :id => 'query_form') do %>
|
||||
@@ -55,11 +56,12 @@ if @gantt.zoom >1
|
||||
end
|
||||
end
|
||||
|
||||
# Width of the entire chart
|
||||
g_width = (@gantt.date_to - @gantt.date_from + 1)*zoom
|
||||
g_height = [(20 * @gantt.events.length + 6)+150, 206].max
|
||||
# Collect the number of issues on Versions
|
||||
g_height = [(20 * (@gantt.number_of_rows + 6))+150, 206].max
|
||||
t_height = g_height + headers_height
|
||||
%>
|
||||
|
||||
<table width="100%" style="border:0; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="width:<%= subject_width %>px; padding:0px;">
|
||||
@@ -67,26 +69,10 @@ t_height = g_height + headers_height
|
||||
<div style="position:relative;height:<%= t_height + 24 %>px;width:<%= subject_width + 1 %>px;">
|
||||
<div style="right:-2px;width:<%= subject_width %>px;height:<%= headers_height %>px;background: #eee;" class="gantt_hdr"></div>
|
||||
<div style="right:-2px;width:<%= subject_width %>px;height:<%= t_height %>px;border-left: 1px solid #c0c0c0;overflow:hidden;" class="gantt_hdr"></div>
|
||||
<%
|
||||
#
|
||||
# Tasks subjects
|
||||
#
|
||||
top = headers_height + 8
|
||||
@gantt.events.each do |i|
|
||||
left = 4 + (i.is_a?(Issue) ? i.level * 16 : 0)
|
||||
%>
|
||||
<div style="position: absolute;line-height:1.2em;height:16px;top:<%= top %>px;left:<%= left %>px;overflow:hidden;"><small>
|
||||
<% if i.is_a? Issue %>
|
||||
<%= h("#{i.project} -") unless @project && @project == i.project %>
|
||||
<%= link_to_issue i %>
|
||||
<% else %>
|
||||
<span class="icon icon-package">
|
||||
<%= link_to_version i %>
|
||||
</span>
|
||||
<% end %>
|
||||
</small></div>
|
||||
<% top = top + 20
|
||||
end %>
|
||||
<% top = headers_height + 8 %>
|
||||
|
||||
<%= @gantt.subjects(:headers_height => headers_height, :top => top, :g_width => g_width) %>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -164,53 +150,9 @@ if show_days
|
||||
end
|
||||
end %>
|
||||
|
||||
<%
|
||||
#
|
||||
# Tasks
|
||||
#
|
||||
top = headers_height + 10
|
||||
@gantt.events.each do |i|
|
||||
if i.is_a? Issue
|
||||
i_start_date = (i.start_date >= @gantt.date_from ? i.start_date : @gantt.date_from )
|
||||
i_end_date = (i.due_before <= @gantt.date_to ? i.due_before : @gantt.date_to )
|
||||
|
||||
i_done_date = i.start_date + ((i.due_before - i.start_date+1)*i.done_ratio/100).floor
|
||||
i_done_date = (i_done_date <= @gantt.date_from ? @gantt.date_from : i_done_date )
|
||||
i_done_date = (i_done_date >= @gantt.date_to ? @gantt.date_to : i_done_date )
|
||||
|
||||
i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
|
||||
|
||||
i_left = ((i_start_date - @gantt.date_from)*zoom).floor
|
||||
i_width = ((i_end_date - i_start_date + 1)*zoom).floor - 2 # total width of the issue (- 2 for left and right borders)
|
||||
d_width = ((i_done_date - i_start_date)*zoom).floor - 2 # done width
|
||||
l_width = i_late_date ? ((i_late_date - i_start_date+1)*zoom).floor - 2 : 0 # delay width
|
||||
css = "task " + (i.leaf? ? 'leaf' : 'parent')
|
||||
%>
|
||||
<div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= i_width %>px;" class="<%= css %> task_todo"><div class="left"></div> <div class="right"></div></div>
|
||||
<% if l_width > 0 %>
|
||||
<div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= l_width %>px;" class="<%= css %> task_late"> </div>
|
||||
<% end %>
|
||||
<% if d_width > 0 %>
|
||||
<div style="top:<%= top %>px;left:<%= i_left %>px;width:<%= d_width %>px;" class="<%= css %> task_done"> </div>
|
||||
<% end %>
|
||||
<div style="top:<%= top %>px;left:<%= i_left + i_width + 8 %>px;background:#fff;" class="<%= css %>">
|
||||
<%= i.status.name %>
|
||||
<%= (i.done_ratio).to_i %>%
|
||||
</div>
|
||||
<div class="tooltip" style="position: absolute;top:<%= top %>px;left:<%= i_left %>px;width:<%= i_width %>px;height:12px;">
|
||||
<span class="tip">
|
||||
<%= render_issue_tooltip i %>
|
||||
</span></div>
|
||||
<% else
|
||||
i_left = ((i.start_date - @gantt.date_from)*zoom).floor
|
||||
%>
|
||||
<div style="top:<%= top %>px;left:<%= i_left %>px;width:15px;" class="task milestone"> </div>
|
||||
<div style="top:<%= top %>px;left:<%= i_left + 12 %>px;background:#fff;" class="task">
|
||||
<strong><%= format_version_name i %></strong>
|
||||
</div>
|
||||
<% end %>
|
||||
<% top = top + 20
|
||||
end %>
|
||||
<% top = headers_height + 10 %>
|
||||
|
||||
<%= @gantt.lines(:top => top, :zoom => zoom, :g_width => g_width ) %>
|
||||
|
||||
<%
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user