traclightning-plugins / tracsteinschartplugin / tracsteinschart / chart / web_ui.py

# -*- coding: utf-8 -*-
# Steins Chart plugin for Trac.
# Author: Takashi Okamoto <toraneko at gmail.com>
# Some files are originally from scrumburndownchart plugin.
# TODO:
#  * 終了予定日を過ぎた場合、
#     * 完了日が設定されていない場合今日
#   のチャートを記述
#  オプションで稼動集計を消せるように

import time
import datetime
import sys
from pkg_resources import resource_filename
from time import strptime, mktime
from datetime import datetime, date, timedelta
from trac import __version__ as tracversion_runtime
from trac.core import *
from trac.config import BoolOption
from trac.env import IEnvironmentSetupParticipant
from trac.perm import IPermissionRequestor
from trac.web.chrome import INavigationContributor, ITemplateProvider, add_stylesheet, add_script, add_warning
from trac.web.main import IRequestHandler
from trac.util import escape, Markup, format_date
from trac.ticket import ITicketChangeListener
from trac.ticket import model
from trac.util.datefmt import to_datetime
from tracsteinschart.api import _, tag_, N_, add_domain
from tracsteinschart import dbhelper

class SteinsChartComponent(Component):
    implements(INavigationContributor,
               IRequestHandler, 
               ITemplateProvider,
               IPermissionRequestor)

    tracversion=tracversion_runtime[:4]
    draw_worktime = BoolOption('steinschart', 'draw_worktime', 'true', """Draw worktime chart and table. (default: false)""")
    
    def __init__(self):
        locale_dir = resource_filename('tracsteinschart', 'locale')
        add_domain(self.env.path, locale_dir)

    #---------------------------------------------------------------------------
    # INavigationContributor methods
    #---------------------------------------------------------------------------
    def get_active_navigation_item(self, req):
        return "burndown"

    def get_navigation_items(self, req):
        if req.perm.has_permission("BURNDOWN_VIEW"):
            if self.tracversion=="0.10":
                yield 'mainnav', 'burndown', Markup('<a href="%s">Burndown</a>') % req.href.burndown()
            else:
                yield 'mainnav', 'burndown', Markup('<a href="%s">Burndown</a>' % req.href.burndown())

    #---------------------------------------------------------------------------
    # IPermissionRequestor methods
    #---------------------------------------------------------------------------
    def get_permission_actions(self):
        return ["BURNDOWN_VIEW", "BURNDOWN_ADMIN"]

    #---------------------------------------------------------------------------
    # IRequestHandler methods
    #---------------------------------------------------------------------------
    def match_request(self, req):
        return req.path_info == '/burndown'
    
    def process_request(self, req):
        req.perm.assert_permission('BURNDOWN_VIEW')

        draw_worktime = self.config.getbool('steinschart','draw_worktime')
        db = self.env.get_db_cnx()
        cursor = db.cursor()

        # マイルストーンとコンポーネントを取得
        milestone = req.args.get('milestone', self.config.get('ticket','default_milestone'))
        component = req.args.get('component', '-')

        if req.args.has_key('start'):
            self.start_milestone(db, milestone)


        milestones = dbhelper.get_milestones(db)
        components = dbhelper.get_components(db)

        for m in milestones:
           if m['name'] == milestone:
               milestone = m
               self.log.info(milestone)
           if m.has_key('started') and m['started'] != 0 and m['started'] != None:
               m['started_date'] = to_datetime(m['started'],req.tz)
           if m.has_key('completed') and m['completed'] != 0 and m['completed'] != None: 
               m['completed_date'] = to_datetime(m['completed'],req.tz)
           if m.has_key('due') and m['due'] != 0 and m['due'] != None: 
               m['due_date'] = to_datetime(m['due'],req.tz)


        if not milestone.has_key('started_date') or not milestone.has_key('due_date'):
            if not milestone.has_key('started_date'):
                add_warning(req, 'マイルストーン「%s」は開始されていません。チャートを表示するには、マイルストーンを開始してください。' % milestone['name'])
            if not milestone.has_key('due_date'):
                add_warning(req, 'マイスルトーン「%s」に期日が設定されていません。期日を設定してください。' % milestone['name'])
            data = {'actual_chart': None,
                    'estimate_chart': None,
                    'worktime': None,
                    'milestone':milestone,
                    'milestones': milestones,
                    'components': components,
                    'component': component,
                    '_': _
            }
            return 'chart.html', data, None

        actual_chart = self.generate_burndown(db, milestone['name'], component)

        if not actual_chart:
             add_warning(req, '実績が見つかりません。現在実施中のマイルストーンとコンポーネントを指定してください。')

        # 安全領域の計算
        if milestone.has_key('started_date') and milestone.has_key('due_date') and actual_chart:
            # 終了日が設定されていれば、安全領域を記述g
            estimate_chart = self.generate_estimate(milestone['started_date'], milestone['due_date'], actual_chart[0][1])
        else:
            add_warning(req, 'マイルストーン %s に期日が設定されてないか、実績が存在しません。マイルストーンの設定を確認してください' % milestone['name'])
            estimate_chart = None

        # 作業時間取得
        if draw_worktime and milestone.has_key('started_date') and actual_chart:
            # チャートの最終日を取得
            to_date = actual_chart[len(actual_chart)-1][0]
            t = strptime(to_date,'%Y/%m/%d')
            to_date = datetime(t[0] ,t[1] ,t[2],tzinfo=req.tz)
            worktime = self.generate_worktime(db, milestone['name'], component, milestone['started_date'], milestone['due_date'] if milestone['due_date'] > to_date else to_date)
        else:
            worktime = None
        if milestone.has_key('due_date'):
            milestone['remain_days'] = (milestone['due_date'].date()-datetime.today().date()).days

        # チームのオーバレイチャート作成
        if component=='-':
            overlay_chart = self.generate_overlay_burndown(db, milestone['name'], components)
        else:
            overlay_chart = None
        # 描画
        data = {'estimate_chart':estimate_chart,
                'actual_chart':actual_chart,
                'timeline':req.href.timeline()+'?from=',
                'milestone':milestone,
                'milestones': milestones,
                'components': components,
                'worktime': worktime,
                'overlay_chart': overlay_chart,
                'component': component,
                '_': _
        }

        add_stylesheet(req, 'common/css/report.css')
        add_stylesheet(req, 'steinschart/css/jquery.jqplot.min.css')
        add_script(req, 'steinschart/js/jquery.jqplot.min.js')
        add_script(req, 'steinschart/js/plugins/jqplot.highlighter.min.js')
        add_script(req, 'steinschart/js/plugins/jqplot.enhancedLegendRenderer.min.js')
        add_script(req, 'steinschart/js/plugins/jqplot.canvasTextRenderer.min.js')
        add_script(req, 'steinschart/js/plugins/jqplot.canvasAxisLabelRenderer.min.js')
        add_script(req, 'steinschart/js/plugins/jqplot.canvasAxisTickRenderer.min.js')
        add_script(req, 'steinschart/js/plugins/jqplot.dateAxisRenderer.min.js')

        return 'chart.html', data, None

    ###############################################
    # バーンダウンデータの作成
    ###############################################
    def generate_burndown(self, db, milestone, component):
        cursor = db.cursor()
        if component=='-':
             # 全てのコンポーネントのバーンダウンのデータを取得
            cursor.execute("""
                SELECT id, sum(hours_remaining) ,date FROM burndown
                WHERE milestone_name = %s
                GROUP BY date ORDER BY id""",(milestone,))
        else:
            # コンポーネントを指定してバーンダウンのデータを取得
            cursor.execute("""
                SELECT id, hours_remaining ,date FROM burndown
                WHERE milestone_name = %s AND component_name = %s 
                ORDER BY id""" , (milestone, component))

        rows = cursor.fetchall()
        actual_chart = []
        if rows:
             for id, hours_remaining, date in rows:
	         actual_chart.append([str(date), hours_remaining])
             return actual_chart
        else:
             return None

    ###############################################
    # 全てのチームを重ね合わせたバーンダウンデータの作成
    ###############################################
    def generate_overlay_burndown(self, db, milestone, components):
        cursor = db.cursor()
        overlay_chart = {}
        for component in components:
            # 全てのコンポーネントのバーンダウンのデータを取得
            self.log.info("components: %s" % components)
            self.log.info("milestone: %s" % milestone)
            cursor.execute("""
                SELECT id, hours_remaining ,date FROM burndown
                WHERE milestone_name = %s AND component_name = %s
                ORDER BY id""" , (milestone, component['name']))
            rows = cursor.fetchall()

            actual_chart = []
            if rows:
                 for id, hours_remaining, date in rows:
                     actual_chart.append([str(date), hours_remaining])
                 overlay_chart[component['name']] = actual_chart
        return overlay_chart

    ###############################################
    # 安全領域を作成
    # start: 開始日(datetime)
    # end:   開始日(datetime)
    # initial_hours: 当初計画の見積もり時間(float)
    ###############################################
    def generate_estimate(self, start_date, due_date, initial_hours):
        estimate_chart = []
        term = (due_date - start_date).days+1

        # マイルストーン内の休日を計算する
        offdays = 0
        for i in range (0,term):
            day = start_date + timedelta(days=i)
            if day.weekday() >= 5:
                offdays = offdays + 1
        c = 0

        # マイルストーン内の休日の作業時間を0とした安全領域を計算
        for i in range (0,term):
            day = start_date + timedelta(days=i)
            if day.weekday() < 5:
                c = c + 1
            hours = initial_hours*(1-float(c)/((term-offdays)))
            estimate_chart.append([day.strftime('%Y/%m/%d'),hours])
        return estimate_chart

    # 作業時間の集計
    def generate_worktime(self, db, milestone, component, start_day, due_day):
        cursor = db.cursor()

        # 稼動時間の計算 
        if component=='-':
            # 全てのコンポーネント
            cursor.execute("""
                SELECT c.author, c.time, c.newvalue FROM ticket t 
                   JOIN ticket_change c ON t.id=c.ticket AND c.field='hours' 
                   JOIN ticket_custom b ON t.id=b.ticket AND b.name='billable'  AND b.value='1' 
               WHERE t.milestone=%s ORDER BY c.time""", (milestone,))
            rows = cursor.fetchall()
        else:
            # 選択されたコンポーネント
            cursor.execute("""
                SELECT c.author, c.time, c.newvalue FROM ticket t 
                    JOIN ticket_change c ON t.id=c.ticket AND c.field='hours' 
                    JOIN ticket_custom b ON t.id=b.ticket AND b.name='billable'  AND b.value='1' 
                WHERE t.milestone=%s AND t.component=%s ORDER BY c.time""", (milestone, component))
            rows = cursor.fetchall()
            
        # 開始日をunixtimeに変換
        starttime = basetime = mktime(start_day.timetuple())*1e+6+start_day.microsecond
        fmtdate = to_datetime(starttime).strftime('%Y/%#m/%d')

        worktime = {}

        for author,time,hours in rows:
            auhtor = str(author)
            hours = float(hours)
            self.log.info('basetime: %s (%s,%s,%s)' % (basetime,author,time,hours))
            if time < basetime:
                self.log.info("time is too old.")
                continue

            if time > (basetime + 24*2600*1e+6):
                basetime = basetime + 24*3600*1e+6
                fmtdate = to_datetime(basetime).strftime('%Y/%#m/%d')

            if worktime.has_key(author):
                wt = worktime[author]
                lastwt = wt[len(wt)-1]
                if lastwt[0] == fmtdate:
                    lastwt[1] = lastwt[1] + hours
                else:
                    wt.append([str(fmtdate), hours])
            else:
                worktime[author] = [[str(fmtdate),hours]]

        # starttimeからendtimeまでの日付のリストを作成
        def genDateList(starttime, endtime):
            list = []
            t = starttime
            while t<=endtime:
                list.append(str(to_datetime(t).strftime('%Y/%#m/%d')))
                t = t + 24*3600*1e+6
            return list

        endtime = mktime(due_day.timetuple())*1e+6+due_day.microsecond
        list = genDateList(starttime,endtime)

        # 日付が入っていない日を稼動時間0時間で埋める
        for author in worktime:
            wt = worktime[author]
            cnt = 0
            for d in list:
                if cnt==len(wt):
                    wt.append([d,0.0])
                elif str(wt[cnt][0])!=d:
                    wt.insert(cnt,[d,0.0])
                cnt=cnt+1

        return worktime

    # マイルストーン開始
    def start_milestone(self, db, milestone):
        startdate = dbhelper.get_startdate_for_milestone(db, milestone)

        if startdate != None:
            raise TracError("Milestone %s was already started." % milestone)

        dbhelper.set_startdate_for_milestone(db, milestone, int(time.time()))

    #---------------------------------------------------------------------------
    # ITemplateProvider methods
    #---------------------------------------------------------------------------
    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 [('steinschart', resource_filename('tracsteinschart', 'htdocs'))]
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.