Edgewall Software

Ticket #2314: mstone_stat_ext_r4086.patch

File mstone_stat_ext_r4086.patch, 26.1 KB (added by cboos, 2 years ago)

Updated patch after r4085 + progress bar as a macro.

  • trac/ticket/tests/__init__.py

     
    11import unittest 
    22 
    33from trac.ticket.tests import api, model, query, wikisyntax, notification, \ 
    4                               conversion, report 
     4                              conversion, report, roadmap 
    55 
    66def suite(): 
    77    suite = unittest.TestSuite() 
     
    1212    suite.addTest(notification.suite()) 
    1313    suite.addTest(conversion.suite()) 
    1414    suite.addTest(report.suite()) 
     15    suite.addTest(roadmap.suite()) 
    1516    return suite 
    1617 
    1718if __name__ == '__main__': 
  • trac/ticket/tests/roadmap.py

     
     1from trac.config import Configuration 
     2from trac.test import EnvironmentStub 
     3from trac.ticket.roadmap import * 
     4from trac.core import ComponentManager 
     5 
     6import unittest 
     7 
     8class TicketGroupStatsTestCase(unittest.TestCase): 
     9 
     10    def setUp(self): 
     11        self.stats = TicketGroupStats('title', 'unit') 
     12 
     13    def test_init(self): 
     14        self.assertEquals('title', self.stats.title, 'title incorrect') 
     15        self.assertEquals('unit', self.stats.unit, 'unit incorrect') 
     16        self.assertEquals(0, self.stats.count, 'count not zero') 
     17        self.assertEquals(0, len(self.stats.intervals), 'intervals not empty') 
     18 
     19    def test_add_iterval(self): 
     20        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     21        self.assertEquals(3, self.stats.count, 'count not incremented') 
     22        int = self.stats.intervals[0] 
     23        self.assertEquals('intTitle', int['title'], 'title incorrect') 
     24        self.assertEquals(3, int['count'], 'count incorrect') 
     25        self.assertEquals({'k1': 'v1'}, int['qry_args'], 'query args incorrect') 
     26        self.assertEquals('css', int['css_class'], 'css class incorrect') 
     27        self.assertEquals(100, int['percent'], 'percent incorrect') 
     28        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     29        self.assertEquals(50, int['percent'], 'percent not being updated') 
     30 
     31    def test_add_interval_no_prog(self): 
     32        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     33        self.stats.add_interval('intTitle', 5, {'k1': 'v1'}, 'css', 0) 
     34        int = self.stats.intervals[1] 
     35        self.assertEquals(0, self.stats.done_count, 'count added for no prog') 
     36        self.assertEquals(0, self.stats.done_percent, 'percent incremented') 
     37 
     38    def test_add_interval_prog(self): 
     39        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     40        self.stats.add_interval('intTitle', 1, {'k1': 'v1'}, 'css', 1) 
     41        self.assertEquals(4, self.stats.count, 'count not incremented') 
     42        self.assertEquals(1, self.stats.done_count, 'count not added to prog') 
     43        self.assertEquals(25, self.stats.done_percent, 'done percent not incr') 
     44 
     45    def test_add_interval_fudging(self): 
     46        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     47        self.stats.add_interval('intTitle', 5, {'k1': 'v1'}, 'css', 1) 
     48        self.assertEquals(8, self.stats.count, 'count not incremented') 
     49        self.assertEquals(5, self.stats.done_count, 'count not added to prog') 
     50        self.assertEquals(62, self.stats.done_percent, 
     51                          'done percnt not fudged downward') 
     52        self.assertEquals(62, self.stats.intervals[1]['percent'], 
     53                          'interval percent not fudged downward') 
     54        self.assertEquals(38, self.stats.intervals[0]['percent'], 
     55                          'interval percent not fudged upward') 
     56 
     57 
     58class DefaultTicketGroupStatsProviderTestCase(unittest.TestCase): 
     59 
     60    def setUp(self): 
     61        self.env = EnvironmentStub(default_data=True) 
     62 
     63        self.milestone1 = Milestone(self.env) 
     64        self.milestone1.name = 'Test' 
     65        self.milestone1.insert() 
     66        self.milestone2 = Milestone(self.env) 
     67        self.milestone2.name = 'Test2' 
     68        self.milestone2.insert() 
     69 
     70        tkt1 = Ticket(self.env) 
     71        tkt1.populate({'summary': 'Foo', 'milestone': 'Test', 'owner': 'foman', 
     72                        'status': 'open'}) 
     73        tkt1.insert() 
     74        tkt2 = Ticket(self.env) 
     75        tkt2.populate({'summary': 'Bar', 'milestone': 'Test', 
     76                        'status': 'closed', 'owner': 'barman'}) 
     77        tkt2.insert() 
     78        tkt3 = Ticket(self.env) 
     79        tkt3.populate({'summary': 'Sum', 'milestone': 'Test', 'owner': 'suman', 
     80                        'status': 'reopened'}) 
     81        tkt3.insert() 
     82        self.tkt1 = tkt1 
     83        self.tkt2 = tkt2 
     84        self.tkt3 = tkt3 
     85         
     86        prov = DefaultTicketGroupStatsProvider(ComponentManager()) 
     87        prov.env = self.env 
     88        self.stats = prov.get_ticket_group_stats([tkt1.id, tkt2.id, tkt3.id]) 
     89 
     90    def test_stats(self): 
     91        self.assertEquals(self.stats.title, 'ticket status', 'title incorrect') 
     92        self.assertEquals(self.stats.unit, 'ticket', 'unit incorrect') 
     93        self.assertEquals(2, len(self.stats.intervals), 'more than 2 intervals') 
     94 
     95    def test_closed_interval(self): 
     96        closed = self.stats.intervals[0] 
     97        self.assertEquals('closed', closed['title'], 'closed title incorrect') 
     98        self.assertEquals('closed', closed['css_class'], 'closed class incorrect') 
     99        self.assertEquals(True, closed['countsToProg'], 
     100                          'closed not count to prog') 
     101        self.assertEquals({'status': 'closed'}, closed['qry_args'], 
     102                          'qry_args incorrect') 
     103        self.assertEquals(1, closed['count'], 'closed count incorrect') 
     104        self.assertEquals(33, closed['percent'], 'closed percent incorrect') 
     105 
     106    def test_open_interval(self): 
     107        open = self.stats.intervals[1] 
     108        self.assertEquals('active', open['title'], 'open title incorrect') 
     109        self.assertEquals('open', open['css_class'], 'open class incorrect') 
     110        self.assertEquals(False, open['countsToProg'], 
     111                          'open not count to prog') 
     112        self.assertEquals({'status': ['new', 'assigned', 'reopened']}, 
     113                          open['qry_args'], 'qry_args incorrect') 
     114        self.assertEquals(2, open['count'], 'open count incorrect') 
     115        self.assertEquals(67, open['percent'], 'open percent incorrect') 
     116 
     117 
     118def in_tlist(ticket, list): 
     119    return len([t for t in list if t['id'] == ticket.id]) > 0 
     120 
     121def suite(): 
     122    suite = unittest.TestSuite() 
     123    suite.addTest(unittest.makeSuite(TicketGroupStatsTestCase, 'test')) 
     124    suite.addTest(unittest.makeSuite(DefaultTicketGroupStatsProviderTestCase, 
     125                                      'test')) 
     126    return suite 
     127 
     128if __name__ == '__main__': 
     129    unittest.main(defaultTest='suite') 
     130 No newline at end of file 
  • trac/ticket/roadmap.py

    Property changes on: trac\ticket\tests\roadmap.py
    ___________________________________________________________________
    Name: svn:eol-style
       + native
    
     
    2626from trac.util.html import html, unescape, Markup 
    2727from trac.util.text import shorten_line, CRLF, to_unicode 
    2828from trac.ticket import Milestone, Ticket, TicketSystem 
     29from trac.ticket.query import Query 
    2930from trac.Timeline import ITimelineEventProvider 
    3031from trac.web import IRequestHandler 
    3132from trac.web.chrome import add_link, add_stylesheet, INavigationContributor 
    3233from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider 
     34from trac.config import ExtensionOption 
    3335 
     36class ITicketGroupStatsProvider(Interface): 
     37    def get_ticket_group_stats(self, ticket_ids): 
     38        """ Gather statistics on a group of tickets. 
    3439 
     40        This method returns a valid TicketGroupStats object. 
     41        """ 
     42 
     43class TicketGroupStats(object): 
     44    """Encapsulates statistics on a group of tickets.""" 
     45 
     46    def __init__(self, title, unit): 
     47        """Creates a new TicketGroupStats object. 
     48        title is the display name of this group of stats (eg 'ticket status'). 
     49        unit is the display name of the units for these stats (eg 'hour'). 
     50        """ 
     51        self.title = title 
     52        self.unit = unit 
     53        self.count = 0 
     54        self.qry_args = {} 
     55        self.intervals = [] 
     56        self.done_percent = 0 
     57        self.done_count = 0 
     58 
     59    def add_interval(self, title, count, qry_args, css_class, countsToProg=0): 
     60        """Adds a division to this stats' group's progress bar. 
     61 
     62        title is the display name (eg 'closed', 'spent effort') of this interval 
     63          that will be displayed in front of the unit name. 
     64        count is the number of units in the interval. 
     65        qry_args is a dict of extra params that will yield the subset of 
     66          tickets in this interval on a query. 
     67        css_class is the css class that will be used to display the division. 
     68        countsToProg can be set to true to make this interval count towards 
     69          overall completion of this group of tickets. 
     70        """ 
     71        self.intervals.append({ 
     72            'title': title, 
     73            'count': count, 
     74            'qry_args': qry_args, 
     75            'css_class': css_class, 
     76            'percent': None, 
     77            'countsToProg': countsToProg 
     78        }) 
     79        self.count = self.count + count 
     80        self._refresh_calcs() 
     81 
     82    def _refresh_calcs(self): 
     83        if self.count < 1: 
     84            return 
     85        total_percent = 0 
     86        self.done_percent = 0 
     87        self.done_count = 0 
     88        for interval in self.intervals: 
     89            interval['percent'] = round(float(interval['count'] /  
     90                                        float(self.count) * 100)) 
     91            total_percent = total_percent + interval['percent'] 
     92            if interval['countsToProg']: 
     93                self.done_percent += interval['percent'] 
     94                self.done_count += interval['count'] 
     95 
     96        if self.done_count and total_percent != 100: 
     97            fudge_int = [i for i in self.intervals if i['countsToProg']][0] 
     98            fudge_amt = 100 - total_percent 
     99            fudge_int['percent'] += fudge_amt 
     100            self.done_percent += fudge_amt 
     101 
     102class DefaultTicketGroupStatsProvider(Component): 
     103    implements(ITicketGroupStatsProvider) 
     104 
     105    def get_ticket_group_stats(self, ticket_ids): 
     106        total_cnt = len(ticket_ids) 
     107        if total_cnt: 
     108            cursor = self.env.get_db_cnx().cursor() 
     109            str_ids = [str(x) for x in ticket_ids] 
     110            active_cnt = cursor.execute("SELECT count(1) FROM ticket " 
     111                                    "WHERE status <> 'closed' AND id IN " 
     112                                    "(%s)" % ",".join(str_ids)) 
     113            active_cnt = 0 
     114            for cnt, in cursor: 
     115                active_cnt = cnt 
     116        else: 
     117            active_cnt = 0 
     118 
     119        closed_cnt = total_cnt - active_cnt 
     120 
     121        stat = TicketGroupStats('ticket status', 'ticket') 
     122        stat.add_interval('closed', closed_cnt, {'status': 'closed'}, 
     123                          'closed', True) 
     124        stat.add_interval('active', active_cnt, 
     125                     {'status': ['new', 'assigned', 'reopened']}, 'open', False) 
     126        return stat 
     127 
     128def get_ticket_stats(provider, tickets): 
     129    return provider.get_ticket_group_stats([t['id'] for t in tickets]) 
     130 
    35131def get_tickets_for_milestone(env, db, milestone, field='component'): 
    36132    cursor = db.cursor() 
    37133    fields = TicketSystem(env).get_ticket_fields() 
     
    47143        tickets.append({'id': tkt_id, 'status': status, field: fieldval}) 
    48144    return tickets 
    49145 
    50 def get_query_links(req, milestone, grouped_by='component', group=None): 
    51     q = {} 
    52     if not group: 
    53         q['all_tickets'] = req.href.query(milestone=milestone) 
    54         q['active_tickets'] = req.href.query( 
    55             milestone=milestone, status=('new', 'assigned', 'reopened')) 
    56         q['closed_tickets'] = req.href.query( 
    57             milestone=milestone, status='closed') 
    58     else: 
    59         q['all_tickets'] = req.href.query( 
    60             {grouped_by: group}, milestone=milestone) 
    61         q['active_tickets'] = req.href.query( 
    62             {grouped_by: group}, milestone=milestone, 
    63             status=('new', 'assigned', 'reopened')) 
    64         q['closed_tickets'] = req.href.query( 
    65             {grouped_by: group}, milestone=milestone, status='closed') 
    66     return q 
    67  
    68 def calc_ticket_stats(tickets): 
    69     total_cnt = len(tickets) 
    70     active = [ticket for ticket in tickets if ticket['status'] != 'closed'] 
    71     active_cnt = len(active) 
    72     closed_cnt = total_cnt - active_cnt 
    73  
    74     percent_active, percent_closed = 0, 0 
    75     if total_cnt > 0: 
    76         percent_active = round(float(active_cnt) / float(total_cnt) * 100) 
    77         percent_closed = round(float(closed_cnt) / float(total_cnt) * 100) 
    78         if percent_active + percent_closed > 100: 
    79             percent_closed -= 1 
    80  
    81     return { 
    82         'total_tickets': total_cnt, 
    83         'active_tickets': active_cnt, 
    84         'percent_active': percent_active, 
    85         'closed_tickets': closed_cnt, 
    86         'percent_closed': percent_closed 
    87     } 
    88  
    89146def milestone_to_hdf(env, db, req, milestone): 
    90147    safe_name = None 
    91148    if milestone.exists: 
     
    101158        hdf['late'] = milestone.is_late 
    102159    return hdf 
    103160 
     161def milestone_stat_to_hdf(env, stat, name, grouped_by='component', group=None): 
     162    def merge_cp(dict1, dict2): 
     163        cp = dict1.copy() 
     164        cp.update(dict2) 
     165        return cp 
     166 
     167    hdf = {} 
     168    base_args = {'milestone': name, grouped_by: group} 
     169    hdf['title'] = stat.title 
     170    hdf['caps_title'] = stat.title.capitalize() 
     171    hdf['count'] = stat.count 
     172    hdf['unit']= stat.unit 
     173    hdf['href'] = env.href.query(merge_cp(base_args, stat.qry_args)) 
     174    hdf['done_percent'] = stat.done_percent 
     175    hdf['done_count'] = stat.done_count 
     176    hdf['intervals'] = [] 
     177    i = 0 
     178    for i in range(len(stat.intervals)): 
     179        interval = stat.intervals[i] 
     180        int_hdf = {} 
     181        for (key,val) in interval.items(): 
     182            if key != 'qry_args': 
     183                int_hdf[key] = val 
     184        int_hdf['href'] = env.href.query( 
     185                                    merge_cp(base_args, interval['qry_args'])) 
     186        int_hdf['caps_title'] = interval['title'].capitalize() 
     187        int_hdf['last'] = i == len(stat.intervals) - 1 
     188        hdf['intervals'].append(int_hdf) 
     189        i += 1 
     190    return hdf 
     191 
    104192def _get_groups(env, db, by='component'): 
    105193    for field in TicketSystem(env).get_ticket_fields(): 
    106194        if field['name'] == by: 
     
    117205class RoadmapModule(Component): 
    118206 
    119207    implements(INavigationContributor, IPermissionRequestor, IRequestHandler) 
     208    stats_provider = ExtensionOption('trac', 'roadmap_stats', ITicketGroupStatsProvider, 
     209                            'DefaultTicketGroupStatsProvider', 
     210        """Name of the component implementing `ITicketGroupStatsProvider`,  
     211        which is used to collect statistics on groups of tickets for display 
     212        in the roadmap views.""") 
    120213 
    121214    # INavigationContributor methods 
    122215 
     
    155248            milestone_name = unescape(milestone['name']) # Kludge 
    156249            tickets = get_tickets_for_milestone(self.env, db, milestone_name, 
    157250                                                'owner') 
    158             milestone['stats'] = calc_ticket_stats(tickets) 
    159             milestone['queries'] = get_query_links(req, milestone_name) 
     251            stat = get_ticket_stats(self.stats_provider, tickets) 
     252            milestone['stats'] = milestone_stat_to_hdf(self.env, stat, milestone_name) 
    160253            milestone['tickets'] = tickets # for the iCalendar view 
    161254 
    162255        if req.args.get('format') == 'ics': 
     
    279372 
    280373    implements(INavigationContributor, IPermissionRequestor, IRequestHandler, 
    281374               ITimelineEventProvider, IWikiSyntaxProvider) 
     375  
     376    stats_provider = ExtensionOption('trac', 'milestone_stats', ITicketGroupStatsProvider, 
     377                            'DefaultTicketGroupStatsProvider', 
     378        """Name of the component implementing `ITicketGroupStatsProvider`,  
     379        which is used to collect statistics on groups of tickets for display 
     380        in the milestone views.""") 
     381     
    282382 
    283383    # INavigationContributor methods 
    284384 
     
    471571            by = req.args.get('by', available_groups[0]['name']) 
    472572 
    473573        tickets = get_tickets_for_milestone(self.env, db, milestone.name, by) 
    474         data['stats'] = calc_ticket_stats(tickets) 
    475         data['stats']['available_groups'] = available_groups 
    476         data['stats']['grouped_by'] = by 
    477         data['queries'] = get_query_links(req, milestone.name) 
    478574 
     575        stat = get_ticket_stats(self.stats_provider, tickets) 
     576        data['stats'] = {'available_groups': available_groups,  
     577                         'grouped_by': by} 
     578        data['stats'].update(milestone_stat_to_hdf(self.env, stat, 
     579                                                  milestone.name)) 
     580 
    479581        data['stats']['groups'] = [] 
    480582        groups = _get_groups(self.env, db, by) 
    481         max_percent_total = 0 
     583        max_count = 0 
     584        group_stats = [] 
     585 
    482586        for group in groups: 
    483587            group_tickets = [t for t in tickets if t[by] == group] 
    484588            if not group_tickets: 
    485589                continue 
    486             data['stats']['groups'].append({'name': group}) 
    487             percent_total = 0 
    488             if len(tickets) > 0: 
    489                 percent_total = float(len(group_tickets)) / float(len(tickets)) 
    490                 if percent_total > max_percent_total: 
    491                     max_percent_total = percent_total 
    492             data['stats']['groups'][-1]['percent_total'] = percent_total * 100 
    493             data['stats']['groups'][-1]['stats'] = calc_ticket_stats(group_tickets) 
    494             data['stats']['groups'][-1]['queries'] = get_query_links(req, milestone.name, by, group) 
    495         data['stats']['max_percent_total'] = max_percent_total * 100 
    496590 
     591            gstat = get_ticket_stats(self.stats_provider, group_tickets) 
     592             
     593            gs_dict = {'name': group}  
     594            gs_dict['stats'] = milestone_stat_to_hdf(self.env, gstat, 
     595                                                 milestone.name, by, group) 
     596                 
     597            if gstat.count > max_count: 
     598                max_count = gstat.count 
     599              
     600            group_stats.append(gstat)                 
     601            data['stats']['groups'].append(gs_dict) 
     602         
     603        grp_no = 0 
     604        for gstat in group_stats: 
     605            d = data['stats']['groups'][grp_no] 
     606            d['percent_of_max_total'] = (float(gstat.count) / 
     607                                        float(max_count) * 100) 
     608            grp_no +=1 
     609 
    497610        return 'milestone_view.html', data, None 
    498611 
    499612    # IWikiSyntaxProvider methods 
  • templates/macros.html

     
    180180    </py:choose> 
    181181  </py:def> 
    182182 
     183  <!--!  Display a generic "progress bar", for use in roadmap and milestone. 
     184  - 
     185  -      Expected properties of the `stats` argument: 
     186  -       .intervals    list of intervals, each with: 
     187  -         .title        the common identifier for this group 
     188  -         .caps_title   same as above but capitalized 
     189  -         .css_class    the CSS class used to customize the look of the interval 
     190  -         .percent      the actual width in % of the interval 
     191  -         .count        the actual number of units taken by this interval 
     192  -      .unit         the name of the unit used 
     193  -      .done_percent the percent considered "done" 
     194  -      .count        the total number of units 
     195  --> 
     196  <py:def function="progress_bar(stats, percent=None, legend=True, style=None)"> 
     197    <table class="progress" style="$style"> 
     198      <tr> 
     199        <td py:for="interval in stats.intervals" 
     200            class="$interval.css_class" style="width: ${interval.percent}%"> 
     201          <a href="$interval.href" 
     202            title="${interval.count} of ${stats.count} ${stats.unit}${ 
     203            stats.count != 1 and 's' or ''} ${interval.title}"></a> 
     204        </td> 
     205      </tr> 
     206    </table> 
     207    <p class="percent">${percent is None and '%d%%' % stats.done_percent or percent}</p> 
     208    <dl py:if="legend"> 
     209      <py:for each="interval in stats.intervals"> 
     210        <dt>${interval.caps_title} ${stats.unit}s:</dt> 
     211        <dd><a href="$interval.href">${interval.count}</a></dd> 
     212      </py:for> 
     213    </dl> 
     214  </py:def> 
     215 
    183216</div> 
  • templates/milestone_view.html

     
    55      xmlns:py="http://genshi.edgewall.org/" 
    66      xmlns:xi="http://www.w3.org/2001/XInclude"> 
    77  <xi:include href="layout.html" /> 
     8  <xi:include href="macros.html" /> 
    89  <head> 
    910    <title>Milestone ${milestone.name}</title> 
    1011    <link rel="stylesheet" type="text/css" 
     
    3233            No date set 
    3334          </p> 
    3435        </py:choose> 
    35         <py:if test="stats.total_tickets"> 
    36           <table class="progress"> 
    37             <td class="closed" style="width: ${stats.percent_closed}%"> 
    38               <a href="$queries.closed_tickets" 
    39                  title="${stats.closed_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} closed"></a> 
    40             </td> 
    41             <td class="open" style="width: ${stats.percent_active}%"> 
    42               <a href="$queries.active_tickets" 
    43                  title="${stats.active_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} active"></a> 
    44             </td> 
    45           </table> 
    46           <p class="percent">${stats.percent_closed}%</p> 
    47           <dl> 
    48             <dt>Closed tickets:</dt> 
    49             <dd><a href="$queries.closed_tickets">${stats.closed_tickets}</a></dd> 
    50             <dt>Active tickets:</dt> 
    51             <dd><a href="$queries.active_tickets">${stats.active_tickets}</a></dd> 
    52           </dl> 
    53         </py:if> 
     36        <py:if test="stats.count">${progress_bar(stats)}</py:if> 
    5437      </div> 
    5538 
    5639      <form py:if="stats.available_groups" id="stats" action=""> 
    5740        <fieldset> 
    5841          <legend> 
    59             <label for="by">Ticket status by</label> 
     42            <label for="by">${stats.caps_title} by</label> 
    6043            <select id="by" name="by" onchange="this.form.submit()"> 
    6144              <option py:for="group in stats.available_groups" 
    6245                      value="${group.name}" py:content="group.label" 
     
    6750          <table summary="Shows the milestone completion status grouped by ${stats.grouped_by}"> 
    6851            <tr py:for="group in stats.groups"> 
    6952              <th scope="row"> 
    70                 <a href="${group.queries.all_tickets}">${group.name}</a> 
     53                <a href="${group.stats.href}">${group.name}</a> 
    7154              </th> 
    7255              <td style="white-space: nowrap"> 
    73                 <table class="progress" style="width: ${group.percent_total * 80 / stats.max_percent_total}%"> 
    74                   <td class="closed" style="width: ${group.stats.percent_closed}%"> 
    75                     <a href="$group.queries.closed_tickets" 
    76                        title="${group.stats.closed_tickets} of ${group.stats.total_tickets} ticket${group.stats.total_tickets != 1 and 's' or ''} closed"></a> 
    77                   </td> 
    78                   <td class="open" style="width: ${group.stats.percent_active}%"> 
    79                     <a href="$group.queries.active_tickets" 
    80                        title="${group.stats.active_tickets} of ${group.stats.total_tickets} ticket${group.stats.total_tickets != 1 and 's' or ''} active"></a> 
    81                   </td> 
    82                 </table> 
    83                 <p class="percent">${group.stats.closed_tickets} / ${group.stats.total_tickets}</p> 
     56                ${progress_bar(group.stats,'%d / %d' % (group.stats.done_count, group.stats.count), 
     57                               legend=False, style="width: %d%%" % (group.percent_of_max_total * 0.8))} 
    8458              </td> 
    8559            </tr> 
    8660          </table> 
  • templates/roadmap.html

     
    55      xmlns:py="http://genshi.edgewall.org/" 
    66      xmlns:xi="http://www.w3.org/2001/XInclude"> 
    77  <xi:include href="layout.html" /> 
     8  <xi:include href="macros.html" /> 
    89  <head> 
    910    <title>Roadmap</title> 
    1011    <link rel="stylesheet" type="text/css" 
     
    1213  </head> 
    1314 
    1415  <body> 
     16 
    1517    <div id="ctxtnav" class="nav"></div> 
    1618 
    1719    <div id="content" class="roadmap"> 
     
    4042                 class="date">Due in ${pretty_timedelta(milestone.due + timedelta(days=1))}</p> 
    4143              <p py:otherwise="" class="date">No date set</p> 
    4244            </py:choose> 
    43             <py:if test="milestone.stats.total_tickets" 
    44                    py:with="stats = milestone.stats; queries = milestone.queries"> 
    45               <table class="progress"> 
    46                 <td class="closed" style="width: ${stats.percent_closed}%"> 
    47                   <a href="$queries.closed_tickets" 
    48                      title="${stats.closed_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} closed"></a> 
    49                 </td> 
    50                 <td class="open" style="width: ${stats.percent_active}%"> 
    51                   <a href="$queries.active_tickets" 
    52                      title="${stats.active_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} active"></a> 
    53                 </td> 
    54               </table> 
    55               <p class="percent">${stats.percent_closed}%</p> 
    56               <dl> 
    57                 <dt>Closed tickets:</dt> 
    58                 <dd><a href="$queries.closed_tickets">${stats.closed_tickets}</a></dd> 
    59                 <dt>Active tickets:</dt> 
    60                 <dd><a href="$queries.active_tickets">${stats.active_tickets}</a></dd> 
    61               </dl> 
    62             </py:if> 
     45            <py:if test="milestone.stats.count">${progress_bar(milestone.stats)}</py:if> 
    6346          </div> 
    6447 
    6548          <div class="description">${milestone.description}</div>