Ticket #2314: mstone_stat_ext_r4086.patch
| File mstone_stat_ext_r4086.patch, 26.1 KB (added by cboos, 2 years ago) |
|---|
-
trac/ticket/tests/__init__.py
1 1 import unittest 2 2 3 3 from trac.ticket.tests import api, model, query, wikisyntax, notification, \ 4 conversion, report 4 conversion, report, roadmap 5 5 6 6 def suite(): 7 7 suite = unittest.TestSuite() … … 12 12 suite.addTest(notification.suite()) 13 13 suite.addTest(conversion.suite()) 14 14 suite.addTest(report.suite()) 15 suite.addTest(roadmap.suite()) 15 16 return suite 16 17 17 18 if __name__ == '__main__': -
trac/ticket/tests/roadmap.py
1 from trac.config import Configuration 2 from trac.test import EnvironmentStub 3 from trac.ticket.roadmap import * 4 from trac.core import ComponentManager 5 6 import unittest 7 8 class 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 58 class 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 118 def in_tlist(ticket, list): 119 return len([t for t in list if t['id'] == ticket.id]) > 0 120 121 def suite(): 122 suite = unittest.TestSuite() 123 suite.addTest(unittest.makeSuite(TicketGroupStatsTestCase, 'test')) 124 suite.addTest(unittest.makeSuite(DefaultTicketGroupStatsProviderTestCase, 125 'test')) 126 return suite 127 128 if __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
26 26 from trac.util.html import html, unescape, Markup 27 27 from trac.util.text import shorten_line, CRLF, to_unicode 28 28 from trac.ticket import Milestone, Ticket, TicketSystem 29 from trac.ticket.query import Query 29 30 from trac.Timeline import ITimelineEventProvider 30 31 from trac.web import IRequestHandler 31 32 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor 32 33 from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider 34 from trac.config import ExtensionOption 33 35 36 class ITicketGroupStatsProvider(Interface): 37 def get_ticket_group_stats(self, ticket_ids): 38 """ Gather statistics on a group of tickets. 34 39 40 This method returns a valid TicketGroupStats object. 41 """ 42 43 class 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 102 class 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 128 def get_ticket_stats(provider, tickets): 129 return provider.get_ticket_group_stats([t['id'] for t in tickets]) 130 35 131 def get_tickets_for_milestone(env, db, milestone, field='component'): 36 132 cursor = db.cursor() 37 133 fields = TicketSystem(env).get_ticket_fields() … … 47 143 tickets.append({'id': tkt_id, 'status': status, field: fieldval}) 48 144 return tickets 49 145 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 q67 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_cnt73 74 percent_active, percent_closed = 0, 075 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 -= 180 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_closed87 }88 89 146 def milestone_to_hdf(env, db, req, milestone): 90 147 safe_name = None 91 148 if milestone.exists: … … 101 158 hdf['late'] = milestone.is_late 102 159 return hdf 103 160 161 def 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 104 192 def _get_groups(env, db, by='component'): 105 193 for field in TicketSystem(env).get_ticket_fields(): 106 194 if field['name'] == by: … … 117 205 class RoadmapModule(Component): 118 206 119 207 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.""") 120 213 121 214 # INavigationContributor methods 122 215 … … 155 248 milestone_name = unescape(milestone['name']) # Kludge 156 249 tickets = get_tickets_for_milestone(self.env, db, milestone_name, 157 250 '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) 160 253 milestone['tickets'] = tickets # for the iCalendar view 161 254 162 255 if req.args.get('format') == 'ics': … … 279 372 280 373 implements(INavigationContributor, IPermissionRequestor, IRequestHandler, 281 374 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 282 382 283 383 # INavigationContributor methods 284 384 … … 471 571 by = req.args.get('by', available_groups[0]['name']) 472 572 473 573 tickets = get_tickets_for_milestone(self.env, db, milestone.name, by) 474 data['stats'] = calc_ticket_stats(tickets)475 data['stats']['available_groups'] = available_groups476 data['stats']['grouped_by'] = by477 data['queries'] = get_query_links(req, milestone.name)478 574 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 479 581 data['stats']['groups'] = [] 480 582 groups = _get_groups(self.env, db, by) 481 max_percent_total = 0 583 max_count = 0 584 group_stats = [] 585 482 586 for group in groups: 483 587 group_tickets = [t for t in tickets if t[by] == group] 484 588 if not group_tickets: 485 589 continue 486 data['stats']['groups'].append({'name': group})487 percent_total = 0488 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_total492 data['stats']['groups'][-1]['percent_total'] = percent_total * 100493 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 * 100496 590 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 497 610 return 'milestone_view.html', data, None 498 611 499 612 # IWikiSyntaxProvider methods -
templates/macros.html
180 180 </py:choose> 181 181 </py:def> 182 182 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 183 216 </div> -
templates/milestone_view.html
5 5 xmlns:py="http://genshi.edgewall.org/" 6 6 xmlns:xi="http://www.w3.org/2001/XInclude"> 7 7 <xi:include href="layout.html" /> 8 <xi:include href="macros.html" /> 8 9 <head> 9 10 <title>Milestone ${milestone.name}</title> 10 11 <link rel="stylesheet" type="text/css" … … 32 33 No date set 33 34 </p> 34 35 </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> 54 37 </div> 55 38 56 39 <form py:if="stats.available_groups" id="stats" action=""> 57 40 <fieldset> 58 41 <legend> 59 <label for="by"> Ticket statusby</label>42 <label for="by">${stats.caps_title} by</label> 60 43 <select id="by" name="by" onchange="this.form.submit()"> 61 44 <option py:for="group in stats.available_groups" 62 45 value="${group.name}" py:content="group.label" … … 67 50 <table summary="Shows the milestone completion status grouped by ${stats.grouped_by}"> 68 51 <tr py:for="group in stats.groups"> 69 52 <th scope="row"> 70 <a href="${group. queries.all_tickets}">${group.name}</a>53 <a href="${group.stats.href}">${group.name}</a> 71 54 </th> 72 55 <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))} 84 58 </td> 85 59 </tr> 86 60 </table> -
templates/roadmap.html
5 5 xmlns:py="http://genshi.edgewall.org/" 6 6 xmlns:xi="http://www.w3.org/2001/XInclude"> 7 7 <xi:include href="layout.html" /> 8 <xi:include href="macros.html" /> 8 9 <head> 9 10 <title>Roadmap</title> 10 11 <link rel="stylesheet" type="text/css" … … 12 13 </head> 13 14 14 15 <body> 16 15 17 <div id="ctxtnav" class="nav"></div> 16 18 17 19 <div id="content" class="roadmap"> … … 40 42 class="date">Due in ${pretty_timedelta(milestone.due + timedelta(days=1))}</p> 41 43 <p py:otherwise="" class="date">No date set</p> 42 44 </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> 63 46 </div> 64 47 65 48 <div class="description">${milestone.description}</div>
