Ticket #2314: mstone_stat_ext_r4008.patch
| File mstone_stat_ext_r4008.patch, 25.4 KB (added by trac@…, 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 4 conversion, roadmap 5 5 6 6 def suite(): 7 7 suite = unittest.TestSuite() 8 8 suite.addTest(api.suite()) 9 9 suite.addTest(model.suite()) 10 10 suite.addTest(query.suite()) 11 suite.addTest(roadmap.suite()) 11 12 suite.addTest(wikisyntax.suite()) 12 13 suite.addTest(notification.suite()) 13 14 suite.addTest(conversion.suite()) -
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:executable + *
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 … … 467 567 by = req.args.get('by', available_groups[0]['name']) 468 568 469 569 tickets = get_tickets_for_milestone(self.env, db, milestone.name, by) 470 data['stats'] = calc_ticket_stats(tickets)471 data['stats']['available_groups'] = available_groups472 data['stats']['grouped_by'] = by473 data['queries'] = get_query_links(req, milestone.name)474 570 571 stat = get_ticket_stats(self.stats_provider, tickets) 572 data['stats'] = {'available_groups': available_groups, 573 'grouped_by': by} 574 data['stats'].update(milestone_stat_to_hdf(self.env, stat, 575 milestone.name)) 576 475 577 data['stats']['groups'] = [] 476 578 groups = _get_groups(self.env, db, by) 477 max_percent_total = 0 579 max_count = 0 580 group_stats = [] 581 478 582 for group in groups: 479 583 group_tickets = [t for t in tickets if t[by] == group] 480 584 if not group_tickets: 481 585 continue 482 data['stats']['groups'].append({'name': group})483 percent_total = 0484 if len(tickets) > 0:485 percent_total = float(len(group_tickets)) / float(len(tickets))486 if percent_total > max_percent_total:487 max_percent_total = percent_total488 data['stats']['groups'][-1]['percent_total'] = percent_total * 100489 data['stats']['groups'][-1]['stats'] = calc_ticket_stats(group_tickets)490 data['stats']['groups'][-1]['queries'] = get_query_links(req, milestone.name, by, group)491 data['stats']['max_percent_total'] = max_percent_total * 100492 586 587 gstat = get_ticket_stats(self.stats_provider, group_tickets) 588 589 gs_dict = {'name': group} 590 gs_dict['stats'] = milestone_stat_to_hdf(self.env, gstat, 591 milestone.name, by, group) 592 593 if gstat.count > max_count: 594 max_count = gstat.count 595 596 group_stats.append(gstat) 597 data['stats']['groups'].append(gs_dict) 598 599 grp_no = 0 600 for gstat in group_stats: 601 d = data['stats']['groups'][grp_no] 602 d['percent_of_max_total'] = (float(gstat.count) / 603 float(max_count) * 100) 604 grp_no +=1 605 493 606 return 'milestone_view.html', data, None 494 607 495 608 # IWikiSyntaxProvider methods -
templates/milestone_view.html
32 32 No date set 33 33 </p> 34 34 </py:choose> 35 <py:if test="stats. total_tickets">35 <py:if test="stats.count"> 36 36 <table class="progress"> 37 <td class="closed" style="width: ${stats.percent_closed}%"> 38 <a href="$queries.closed_tickets" 39 title="${stats.active_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} closed"></a> 37 <py:for each="interval in stats.intervals"> 38 <td class="$interval.css_class" style="width: ${interval.percent}%"> 39 <a href="$interval.href" 40 title="${interval.count} of ${stats.count} ${stats.unit}${stats.count != 1 and 's' or ''} ${interval.title}"></a> 40 41 </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> 42 </py:for> 45 43 </table> 46 <p class="percent">${stats. percent_closed}%</p>44 <p class="percent">${stats.done_percent}%</p> 47 45 <dl> 48 <dt>Closed tickets:</dt>49 <d d><a href="$queries.closed_tickets">${stats.closed_tickets}</a></dd>50 <d t>Active tickets:</dt>51 <dd><a href="$queries.active_tickets">${stats.active_tickets}</a></dd>46 <py:for each="interval in stats.intervals"> 47 <dt>${interval.caps_title} ${stats.unit}s:</dt> 48 <dd><a href="$interval.href">${interval.count}</a></dd> 49 </py:for> 52 50 </dl> 53 51 </py:if> 54 52 </div> … … 56 54 <form py:if="stats.available_groups" id="stats" action=""> 57 55 <fieldset> 58 56 <legend> 59 <label for="by"> Ticket statusby</label>57 <label for="by">${stats.caps_title} by</label> 60 58 <select id="by" name="by" onchange="this.form.submit()"> 61 59 <option py:for="group in stats.available_groups" 62 60 value="${group.name}" py:content="group.label" … … 67 65 <table summary="Shows the milestone completion status grouped by ${stats.grouped_by}"> 68 66 <tr py:for="group in stats.groups"> 69 67 <th scope="row"> 70 <a href="${group. queries.all_tickets}">${group.name}</a>68 <a href="${group.stats.href}">${group.name}</a> 71 69 </th> 72 70 <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="$queries.closed_tickets" 76 title="${group.stats.active_tickets} of ${group.stats.total_tickets} ticket${group.stats.total_tickets != 1 and 's' or ''} closed"></a> 71 <table class="progress" style="width: ${group.percent_of_max_total * 0.8}%"> 72 <py:for each="interval in group.stats.intervals"> 73 <td class="$interval.css_class" style="width: ${interval.percent}%"> 74 <a href="$interval.href" 75 title="${interval.count} of ${group.stats.count} ${group.stats.unit}${group.stats.count != 1 and 's' or ''} ${interval.title}"></a> 77 76 </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> 77 </py:for> 82 78 </table> 83 <p class="percent">${group.stats. closed_tickets} / ${group.stats.total_tickets}</p>79 <p class="percent">${group.stats.done_count} / ${group.stats.count}</p> 84 80 </td> 85 81 </tr> 86 82 </table> -
templates/roadmap.html
12 12 </head> 13 13 14 14 <body> 15 15 16 <div id="ctxtnav" class="nav"></div> 16 17 17 18 <div id="content" class="roadmap"> 18 19 <h1>Roadmap</h1> 19 20 20 21 <form id="prefs" method="get" action=""> 21 22 <div> 22 23 <input type="checkbox" id="showall" name="show" value="all" … … 40 41 class="date">Due in ${pretty_timedelta(milestone.due + timedelta(days=1))}</p> 41 42 <p py:otherwise="" class="date">No date set</p> 42 43 </py:choose> 43 <py:if test="milestone.stats. total_tickets"44 <py:if test="milestone.stats.count" 44 45 py:with="stats = milestone.stats; queries = milestone.queries"> 45 46 <table class="progress"> 46 <td class="closed" style="width: ${stats.percent_closed}%"> 47 <a href="$queries.closed_tickets" 48 title="${stats.active_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} closed"></a> 47 <py:for each="interval in stats.intervals"> 48 <td class="${interval.css_class}" style="width: ${interval.percent}%"> 49 <a href="$interval.href" 50 title="${interval.count} of ${stats.count} ${stats.unit}${stats.count != 1 and 's' or ''} ${interval.title}"></a> 49 51 </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> 52 </py:for> 54 53 </table> 55 <p class="percent">${stats. percent_closed}%</p>54 <p class="percent">${stats.done_percent}%</p> 56 55 <dl> 57 <dt>Closed tickets:</dt>58 <d d><a href="$queries.closed_tickets">${stats.closed_tickets}</a></dd>59 <d t>Active tickets:</dt>60 <dd><a href="$queries.active_tickets">${stats.active_tickets}</a></dd>56 <py:for each="interval in stats.intervals"> 57 <dt>${interval.caps_title} ${stats.unit}s:</dt> 58 <dd><a href="$interval.href">${interval.count}</a></dd> 59 </py:for> 61 60 </dl> 62 61 </py:if> 63 62 </div>
