2009/10/25

Tracで簡易WBS

timingandestimationpluginを導入することで、簡易な工数管理が行える。
しかし、ガントチャートでは工数等が見えない為、WBS的な利用には今一歩といったところ。
そこで、無理矢理ガントチャートへ要りそうな拡張を行う。
とりあえず、欲しい機能は...
  1. 各チケット毎に実績と見積を表示する
  2. チケットの実績と見積のそれぞれの合計を表示する
  3. 実績が見積を超過した場合は、分かりやすく表示する
  4. 進捗率をプログレスバー的な表示以外に、数字として表示する
に絞る。
完成画面:

という訳で、実装する。
当然、編集対象プラグインは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]
<が&lt;な所に注意!
<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 件のコメント: