しかし、ガントチャートでは工数等が見えない為、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>
0 件のコメント:
コメントを投稿