timingandestimationpluginを導入することで、簡易な工数管理が行える。
しかし、ガントチャートでは工数等が見えない為、WBS的な利用には今一歩といったところ。
そこで、無理矢理ガントチャートへ要りそうな拡張を行う。
とりあえず、欲しい機能は...
- 各チケット毎に実績と見積を表示する
- チケットの実績と見積のそれぞれの合計を表示する
- 実績が見積を超過した場合は、分かりやすく表示する
- 進捗率をプログレスバー的な表示以外に、数字として表示する
に絞る。
完成画面:
という訳で、実装する。
当然、編集対象プラグインはganttcalendarになる。
プラグイン構成は、
- ganntcalendar
- ticketcalendar.py
- ticketgannt.py <- 今回編集する
- templates
- gantt.html <- 今回編集する
- calendar.html
以下、実装後ファイル
[ticketgantt.py]
# -*- coding: utf-8 -*-
import re, calendar, time, sys
from datetime import datetime, date, timedelta
from genshi.builder import tag
from trac.core import *
from trac.web import IRequestHandler
from trac.web.chrome import INavigationContributor, ITemplateProvider
from trac.util.datefmt import to_datetime, format_date, parse_date
class TicketGanttChartPlugin(Component):
implements(INavigationContributor, IRequestHandler, ITemplateProvider)
# INavigationContributor methods
def get_active_navigation_item(self, req):
return 'ticketgantt'
def get_navigation_items(self, req):
if req.perm.has_permission('TICKET_VIEW'):
yield ('mainnav', 'ticketgantt',tag.a(u'ガントチャート', href=req.href.ticketgantt()))
# IRequestHandler methods
def match_request(self, req):
return re.match(r'/ticketgantt(?:_trac)?(?:/.*)?$', req.path_info)
def adjust( self, x_start, x_end, term):
if x_start > term or x_end < 0:
x_start= done_end= None
else:
if x_start < 0:
x_start= 0
if x_end > term:
x_end= term
return x_start, x_end
def process_request(self, req):
req.perm.assert_permission('TICKET_VIEW')
self.log.debug("process_request " + str(globals().get('__file__')))
ymonth = req.args.get('month')
yyear = req.args.get('year')
baseday = req.args.get('baseday')
selected_milestone = req.args.get('selected_milestone')
selected_component = req.args.get('selected_component')
show_my_ticket = req.args.get('show_my_ticket')
show_closed_ticket = req.args.get('show_closed_ticket')
sorted_field = req.args.get('sorted_field')
if sorted_field == None:
sorted_field = 'component'
if baseday != None:
baseday = parse_date( baseday).date()
else:
baseday = date.today()
cday = date.today()
if not (not ymonth or not yyear):
cday = date(int(yyear),int(ymonth),1)
# cal next month
nmonth = cday.replace(day=1).__add__(timedelta(days=32)).replace(day=1)
# cal previous month
pmonth = cday.replace(day=1).__add__(timedelta(days=-1)).replace(day=1)
first_date= cday.replace(day=1)
days_term= (first_date.__add__(timedelta(100)).replace(day=1)-first_date).days
# process ticket
db = self.env.get_db_cnx()
cursor = db.cursor();
sql = ""
condition=""
if show_my_ticket == 'on':
if condition != "":
condition += " AND "
condition += "owner ='" + req.authname + "'"
if show_closed_ticket != 'on':
if condition != "":
condition += " AND "
condition += "status <> 'closed'"
if selected_milestone != None and selected_milestone !="":
if condition != "":
condition += " AND "
condition += "milestone ='" + selected_milestone +"'"
if selected_component != None and selected_component !="":
if condition != "":
condition += " AND "
condition += "component ='" + selected_component +"'"
if condition != "":
condition = "WHERE " + condition + " "
sql = ("SELECT id, type, summary, owner, t.description, status, a.value, c.value, cmp.value, milestone, component, etime.value, ttime.value "
"FROM ticket t "
"JOIN ticket_custom a ON a.ticket = t.id AND a.name = 'due_assign' "
"JOIN ticket_custom c ON c.ticket = t.id AND c.name = 'due_close' "
"JOIN ticket_custom cmp ON cmp.ticket = t.id AND cmp.name = 'complete' "
"JOIN ticket_custom etime ON etime.ticket = t.id AND etime.name = 'estimatedhours' "
"JOIN ticket_custom ttime ON ttime.ticket = t.id AND ttime.name = 'totalhours' "
"%sORDER by %s , a.value ") % (condition, sorted_field)
self.log.debug(sql)
cursor.execute(sql)
tickets=[]
estimatedhour_sum = 0.0
totalhour_sum = 0.0
for id, type, summary, owner, description, status, due_assign, due_close, complete, milestone, component, estimatedhours, totalhours in cursor:
due_assign_date = None
due_close_date = None
try:
due_assign_date = parse_date(due_assign).date()
except ( TracError, ValueError, TypeError):
continue
try:
due_close_date = parse_date(due_close).date()
except ( TracError, ValueError, TypeError):
continue
if complete != None and len(complete)>1 and complete[len(complete)-1]=='%':
complete = complete[0:len(complete)-1]
try:
if int(complete) >100:
complete = "100"
except:
complete = "0"
complete = int(complete)
if due_assign_date > due_close_date:
continue
if milestone == None or milestone == "":
milestone = "*"
if component == None or component == "":
component = "*"
if estimatedhours == None or estimatedhours < 0:
estimatedhours = 0
else:
estimatedhours = float(estimatedhours)
if totalhours == None or totalhours < 0:
totalhours = 0
else:
totalhours = float(totalhours)
estimatedhour_sum += estimatedhours
totalhour_sum += totalhours
ticket = {'id':id, 'type':type, 'summary':summary, 'owner':owner, 'description': description, 'status':status,
'due_assign':due_assign_date, 'due_close':due_close_date, 'complete': complete,
'milestone': milestone,'component': component,
'estimatedhours':estimatedhours,
'totalhours':totalhours}
#calc chart
base = (baseday -first_date).days + 1
done_start= done_end= None
late_start= late_end= None
todo_start= todo_end= None
all_start=(due_assign_date-first_date).days
all_end=(due_close_date-first_date).days + 1
done_start= all_start
done_end= done_start + (all_end - all_start)*int(complete)/100.0
if all_end <= base < days_term:
late_start= done_end
late_end= all_end
elif done_end <= base < all_end:
late_start= done_end
late_end= todo_start= base
todo_end= all_end
else:
todo_start= done_end
todo_end= all_end
#
done_start, done_end= self.adjust(done_start,done_end,days_term)
late_start, late_end= self.adjust(late_start,late_end,days_term)
todo_start, todo_end= self.adjust(todo_start,todo_end,days_term)
all_start, all_end= self.adjust(all_start,all_end,days_term)
if done_start != None:
ticket.update({'done_start':done_start,'done_end':done_end})
if late_start != None:
ticket.update({'late_start':late_start,'late_end':late_end})
if todo_start != None:
ticket.update({'todo_start':todo_start,'todo_end':todo_end})
if all_start != None:
ticket.update({'all_start':all_start})
self.log.debug(ticket)
tickets.append(ticket)
# milestones
milestones = {'':None}
sql = ("SELECT name, due, completed, description FROM milestone")
self.log.debug(sql)
cursor.execute(sql)
for name, due, completed, description in cursor:
due_date = to_datetime(due, req.tz).date()
item = { 'due':due_date, 'completed':completed != 0,'description':description}
if due==0:
del item['due']
milestones.update({name:item})
# componet
components = [{}]
sql = ("SELECT name FROM component")
self.log.debug(sql)
cursor.execute(sql)
for name, in cursor:
components.append({'name':name})
holidays = {}
sql = "SELECT date,description from holiday"
try:
cursor.execute(sql)
for hol_date,hol_desc in cursor:
holidays[format_date(parse_date(hol_date, tzinfo=req.tz))]= hol_desc
except:
pass
data = {'baseday': baseday, 'current':cday, 'prev':pmonth, 'next':nmonth}
data.update({'show_my_ticket': show_my_ticket, 'show_closed_ticket': show_closed_ticket, 'sorted_field': sorted_field})
data.update({'selected_milestone':selected_milestone,'selected_component': selected_component})
data.update({'tickets':tickets,'milestones':milestones,'components':components})
data.update({'holidays':holidays,'first_date':first_date,'days_term':days_term})
data.update({'parse_date':parse_date,'format_date':format_date,'calendar':calendar})
data.update({'estimatedhour_sum':estimatedhour_sum,'totalhour_sum':totalhour_sum})
return 'gantt.html', data, None
def get_templates_dirs(self):
from pkg_resources import resource_filename
return [resource_filename(__name__, 'templates')]
def get_htdocs_dirs(self):
from pkg_resources import resource_filename
return [('tc', resource_filename(__name__, 'htdocs'))]
[gantt.html]
<が<な所に注意!
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:py="http://genshi.edgewall.org/"
xmlns:xi="http://www.w3.org/2001/XInclude"
py:with="px_ti=25;px_hd=46;px_dw=12;px_ch=10;maxtic=len(tickets);">
<xi:include href="layout.html" />
<xi:include href="macros.html" />
<head>
<script type="text/javascript" src="${chrome.htdocs_location}js/folding.js"></script>
<script type="text/javascript">
jQuery(document).ready(function($) {
$("fieldset legend.foldable").enableFolding(false);
/* Hide the filters for saved queries. */
$("#options").toggleClass("collapsed");
});
</script>
<style type="text/css">
form fieldset.collapsed {
border-width: 0px;
margin-bottom: 0px;
padding: 0px .5em;
}
fieldset legend.foldable :link,
fieldset legend.foldable :visited {
background: url(${chrome.htdocs_location}expanded.png) 0 50% no-repeat;
border: none;
color: #666;
font-size: 110%;
padding-left: 16px;
}
fieldset legend.foldable :link:hover, fieldset legend.foldable :visited:hover {
background-color: transparent;
}
fieldset.collapsed legend.foldable :link, fieldset.collapsed legend.foldable :visited {
background-image: url(${chrome.htdocs_location}collapsed.png);
}
fieldset.collapsed table, fieldset.collapsed div { display: none }
table.list {width:100%;border-collapse: collapse;margin-bottom: 6px;}
table.list td {padding:2px;}
.border_line {background-color: gray;}
.hdr {position: absolute;background-color: #eee;}
.hdr_title {display:block;position: absolute; width:100%;text-align:center;font-size:10px;}
.bdy {position: absolute;background-color: #fff;text-align: left;top:${px_hd}px;height:${maxtic*px_ti}px;}
.bdy_elem {position: absolute;font-size: 9px;left:1px;height:${px_ti-2}px;}
.tip {position: static;}
.tip span.popup{position: absolute;visibility: hidden;background-color: #ffe;color: black;border: 1px solid #555;left: 20px;top: 30px;width: 400px; padding: 3px;}
.tip:hover span.popup
{visibility: visible; z-index: 100;}
.tic_done {position: absolute; overflow: hidden; background:lightgreen;}
.tic_late {position: absolute; overflow: hidden; background:pink;}
.tic_todo {position: absolute; overflow: hidden; background:lightgrey;}
.tic_done_bl {position: absolute; overflow: hidden; background:green;}
.tic_late_bl {position: absolute; overflow: hidden; background:red;}
.tic_todo_bl {position: absolute; overflow: hidden; background:gray;}
.baseline {position: absolute; overflow: hidden; border-left: 1px dashed red;}
.milestone {position: absolute; overflow: hidden; background-color: red;}
</style>
</head>
<body py:with="weekdays = ['月', '火', '水' ,'木', '金', '土', '日']">
<form>
<fieldset id="options">
<legend class="foldable">設定</legend>
<table class="list">
<tr>
<td>
基準日<input type="text" id="field-baseday" name="baseday" value="${format_date(parse_date(baseday.isoformat()))}" length="10"/>
</td>
</tr>
<tr>
<td>
ソートするフィールド
<select name="sorted_field">
<option value="component" selected="${sorted_field=='component' or None}">component</option>
<option value="milestone" selected="${sorted_field=='milestone' or None}">milestone</option>
</select><br/>
</td>
</tr>
<tr>
<td>
絞込みをします
マイルストーン =
<select name="selected_milestone">
<py:for each="i in milestones.keys()">
<option selected="${selected_milestone==i or None}" value="$i">$i</option>
</py:for>
</select>
AND
コンポーネント =
<select name="selected_component">
<py:for each="i in components">
<option selected="${selected_component==i.name or None}" value="${i.name}">${i.name}</option>
</py:for>
</select>
</td>
</tr>
<tr>
<td>
<input type="checkbox" name="show_my_ticket" checked="$show_my_ticket" />自分のチケットのみ表示
<input type="checkbox" name="show_closed_ticket" checked="$show_closed_ticket" />closeしたチケットを表示<br/>
</td>
<td align="right" valign="bottom">
<input type="submit" value="更新" />
</td>
</tr>
</table>
</fieldset>
<table class="list">
<tr>
<td>
<input type="button" value="<<${prev.month}月" onclick="form.year.value = ${prev.year}; form.month.value = ${prev.month}; form.submit();"/>
</td>
<td align="center">
<select name="year">
<option py:for="y in range(current.year-3,current.year+4)"
value="$y"
selected="${y==current.year or None}">$y</option>
</select>
年
<select name="month">
<option py:for="m in [1,2,3,4,5,6,7,8,9,10,11,12]"
value="$m" selected="${m==current.month or None}">$m</option>
</select>
月
<input type="submit" value="更新" />
</td>
<td align="right">
<input type="button" value="${next.month}月>>" onclick="form.year.value = ${next.year}; form.month.value = ${next.month}; form.submit();"/>
</td>
</tr>
</table>
</form>
<!-- gantt -->
<div style="position:relative;left:1px;top:1px;width:100%;height:${maxtic*px_ti+px_hd+1+40}px;">
<!-- right side -->
<div style="overflow:auto;margin-left:403px;margin-right:4px;position:relative;left:0px;top:1px;height:${maxtic*px_ti+px_hd+1+30}px;">
<div class="border_line" style="left:0px;top:1px;width:${px_dw*days_term+1}px;height:${maxtic*px_ti+px_hd+1}px;">
<!-- head and sun,sta,holiday -->
<div class="bdy" style="position:relative;left:1px;top:${px_hd}px;width:${px_dw*days_term-1}px;height:${maxtic*px_ti}px;"/>
<py:for each="cnt in reversed(range(days_term))" py:with="cur=first_date+timedelta(cnt);wk=cur.weekday()">
<div py:if="cur.day == 1" py:with="days_thismonth=calendar.monthrange(cur.year,cur.month)[1];" class="hdr hdr_title" style="left:${px_dw*cnt+1}px;top:${(px_hd-4)/3*0+1}px;width: ${days_thismonth*px_dw-1}px;height:${(px_hd-4)/3}px;">${cur.year}/${cur.month}</div>
<div py:if="wk==6 and(cur-first_date).days+7<days_term" class="hdr hdr_title" style="left:${px_dw*cnt+1}px;top:${(px_hd-4)/3*1+2}px;width: ${px_dw*7-1}px;height:${(px_hd-4)/3}px;">${cur.month}/${cur.day}</div>
<div py:if="wk==6 and(cur-first_date).days+7>days_term" class="hdr hdr_title" style="left:${px_dw*cnt+1}px;top:${(px_hd-4)/3*1+2}px;width: ${px_dw*(days_term-(cur-first_date).days)-1}px;height:${(px_hd-4)/3}px;"/>
<div class="hdr hdr_title" style="left:${px_dw*cnt+1}px;top:${(px_hd-4)/3*2+3}px;width: ${px_dw-1}px;height:${(px_hd-4)/3}px;">${weekdays[wk]}</div>
<py:with vars="holiday_desc = holidays.get(format_date(parse_date(cur.isoformat())));">
<py:if test="cur.weekday()>4or holiday_desc">
<div class="border_line" style="position:absolute;top:${px_hd}px; left: ${px_dw*cnt}px; width: ${px_dw+1}px; height: ${maxtic*px_ti+1}px;">
<div class="hdr" py:attrs="{'title':holiday_desc}" style="top:0px; left:1px; width: ${px_dw-1}px; height: ${maxtic*px_ti}px;"/>
</div>
</py:if>
</py:with>
</py:for>
<div py:if="first_date.weekday()!=6" class="hdr" style="left:1px;top:${(px_hd-4)/3*1+2}px;width: ${px_dw*(6-first_date.weekday())-1}px;height:${(px_hd-4)/3}px;"/>
<!-- chart -->
<py:def function="print_chart(kind)">
<py:with vars="s=tickets[cnt].get('all_start');e=tickets[cnt].get(kind +'_end');">
<py:if test="e!=None and e-s!= 0">
<div class="${'tic_'+kind+'_bl'}" style="left:${int(s*px_dw+1)}px;top:${px_ti*cnt+px_hd+((px_ti-px_ch)/2)}px;width: ${int((e-s)*px_dw)}px;height:${px_ch}px;"/>
<div class="${'tic_'+kind}" style="left:${int(s*px_dw+2)}px;top:${px_ti*cnt+px_hd+((px_ti-px_ch)/2+1)}px;width: ${int((e-s)*px_dw)-2}px;height:${px_ch-2}px;"/>
</py:if>
</py:with>
</py:def>
<py:for each="cnt in reversed(range(maxtic))">
${print_chart('todo')}
${print_chart('late')}
${print_chart('done')}
<py:if test="selected_milestone != '' and selected_milestone != None">
<py:if test="tickets[cnt].get('milestone')!= None and tickets[cnt].get('milestone') in milestones" py:with="d= milestones[tickets[cnt]['milestone']].get('due')">
<py:if test="d!=None and 0 <= (d-first_date).days+1 < days_term" py:with="d=(d-first_date).days+1">
<div class="milestone" style="left: ${d*px_dw}px; top: ${cnt*px_ti+px_hd}px; width: 2px; height: ${px_ti}px;"></div>
</py:if>
</py:if>
</py:if>
</py:for>
<py:with vars="base = (baseday-first_date).days+1">
<!-- baseline -->
<div py:if="base+1 < days_term" class="baseline" style="left:${base*px_dw}px;top:${px_hd}px; height:${maxtic*px_ti}px; width: 0px;"/>
</py:with>
</div>
</div>
<!-- left side -->
<div style="position:absolute;background-color:gray;left:1px;top:1px;width:402px;height:${maxtic*px_ti+px_hd+1}px;">
<div class="hdr" style="left:1px;top:1px;width: 89px;height:${px_hd-2}px;"><span class="hdr_title" style="top:${(px_hd-2-16)/2}px;">${_(sorted_field)}</span></div>
<div class="hdr" style="left:91px;top:1px;width:104px;height:${px_hd-2}px;"><span class="hdr_title" style="top:${(px_hd-2-16)/2}px;">チケット</span></div>
<div class="hdr" style="left:196px;top:1px;width:107px;height:${px_hd-2}px;"><span class="hdr_title" style="top:${(px_hd-2-16)/2}px;">担当者</span></div>
<div class="hdr" style="left:304px;top:1px;width:35px;height:${px_hd-2}px;"><span class="hdr_title" style="top:${(px_hd-2-16)/2}px;">実績</span></div>
<div class="hdr" style="left:340px;top:1px;width:35px;height:${px_hd-2}px;"><span class="hdr_title" style="top:${(px_hd-2-16)/2}px;">見積</span></div>
<div class="hdr" style="left:376px;top:1px;width:25px;height:${px_hd-2}px;"><span class="hdr_title" style="top:${(px_hd-2-16)/2}px;">達成</span></div>
<py:def function="print_field(px_x,px_w,ticket_col,dupchk=False)">
<div class="bdy" style="left:${px_x}px;width:${px_w}px;">
<py:for each="cnt in range(maxtic)" py:with="t=tickets[cnt]">
<py:choose>
<py:when test="ticket_col=='ticket'" py:with="cnt=maxtic-cnt-1;t=tickets[cnt];">
<div class="bdy_elem" style="top: ${cnt*px_ti}px;width: ${px_w-2}px;">
<a class="tip" href="${req.href.ticket()}/${t['id']}">#${t['id']}:${t['summary'][0:14]}
<span class="popup">
<pre><span class="type">${t['type']}</span>#${t['id']}: ${t['summary']}</pre>
<strong>担当者</strong>: ${format_author(t['owner'])}<br/>
<strong>開始日</strong>: ${format_date(parse_date(t['due_assign'].isoformat()))}<br/>
<strong>終了日</strong>: ${format_date(parse_date(t['due_close'].isoformat()))}<br/>
<strong>達成率</strong>: ${t['complete']}%<br/>
<strong>作業実績</strong>: ${t['totalhours']}<br/>
<strong>予定工数</strong>: ${t['estimatedhours']}<br/>
<strong>詳細</strong>: <pre>${t['description']}</pre>
</span>
</a>
</div>
</py:when>
<py:otherwise>
<py:choose>
<py:when test="dupchk">
<div py:if="not cnt or t[ticket_col]!=tickets[cnt-1][ticket_col]" class="bdy_elem" style="top:${cnt*px_ti}px;width: ${px_w-2}px;">${t[ticket_col]}</div>
</py:when>
<py:otherwise>
<div class="bdy_elem" style="top: ${cnt*px_ti}px;width: ${px_w-2}px;">${ticket_col in ('owner','reporter') and format_author(t[ticket_col]) or t[ticket_col]}</div>
</py:otherwise>
</py:choose>
</py:otherwise>
</py:choose>
</py:for>
</div>
</py:def>
<py:def function="print_time_fields(ttime_x,etime_x,col_width)">
<py:def function="print_time_value(val,cnt,color='')">
<div class="bdy_elem" style="top: ${cnt*px_ti}px;width: ${col_width-2}px;${color}">$val</div>
</py:def>
<py:def function="print_totaltime_value(t,cnt,color='')">
${print_time_value('totalhours' in ('owner','reporter') and format_author(t['totalhours']) or t['totalhours'],cnt,color)}
</py:def>
<div class="bdy" style="left:${ttime_x}px;width:${col_width}px;">
<py:for each="cnt in range(maxtic)" py:with="t=tickets[cnt]">
<py:choose>
<py:when test="t['totalhours'] &<= t['estimatedhours']">
${print_totaltime_value(t,cnt)}
</py:when>
<py:otherwise>
${print_totaltime_value(t,cnt,'color:red;')}
</py:otherwise>
</py:choose>
</py:for>
<div class="bdy_elem" style="top: ${maxtic*px_ti}px;width: ${col_width-2}px;">$totalhour_sum</div>
</div>
<div class="bdy" style="left:${etime_x}px;width:${col_width}px;">
<py:for each="cnt in range(maxtic)" py:with="t=tickets[cnt]">
${print_time_value('estimatedhours' in ('owner','reporter') and format_author(t['estimatedhours']) or t['estimatedhours'],cnt)}
</py:for>
<div class="bdy_elem" style="top: ${maxtic*px_ti}px;width: ${col_width-2}px;">$estimatedhour_sum</div>
</div>
</py:def>
${print_field( 376,25,'complete')}
${print_time_fields(304,340,35)}
${print_field( 196,107,'owner')}
${print_field( 91,104,'ticket')}
${print_field( 1, 89,sorted_field,dupchk=True)}
</div>
</div>
<!-- gantt -->
</body>
</html>