16
16
# Author: Christopher Lenz <cmlenz@gmx.de>
18
from datetime import datetime
19
20
from time import localtime, strftime, time
22
from genshi.builder import tag
21
24
from trac import __version__
25
from trac.attachment import AttachmentModule
26
from trac.config import ExtensionOption
22
27
from trac.core import *
28
from trac.mimeview import Context
23
29
from trac.perm import IPermissionRequestor
24
from trac.util.datefmt import format_date, format_datetime, parse_date, \
26
from trac.util.html import html, unescape, Markup
30
from trac.resource import *
31
from trac.util.compat import set, sorted
32
from trac.util.datefmt import parse_date, utc, to_timestamp, to_datetime, \
33
get_date_format_hint, get_datetime_format_hint, \
34
format_date, format_datetime
27
35
from trac.util.text import shorten_line, CRLF, to_unicode
36
from trac.util.translation import _
28
37
from trac.ticket import Milestone, Ticket, TicketSystem
29
from trac.Timeline import ITimelineEventProvider
38
from trac.ticket.query import Query
39
from trac.timeline.api import ITimelineEventProvider
30
40
from trac.web import IRequestHandler
31
from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
32
from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider
41
from trac.web.chrome import add_link, add_stylesheet, add_warning, \
42
INavigationContributor
43
from trac.wiki.api import IWikiSyntaxProvider
44
from trac.wiki.formatter import format_to_html
46
class ITicketGroupStatsProvider(Interface):
47
def get_ticket_group_stats(ticket_ids):
48
""" Gather statistics on a group of tickets.
50
This method returns a valid TicketGroupStats object.
53
class TicketGroupStats(object):
54
"""Encapsulates statistics on a group of tickets."""
56
def __init__(self, title, unit):
57
"""Creates a new TicketGroupStats object.
59
`title` is the display name of this group of stats (e.g.
61
`unit` is the display name of the units for these stats (e.g. 'hour').
71
def add_interval(self, title, count, qry_args, css_class,
72
overall_completion=None, countsToProg=0):
73
"""Adds a division to this stats' group's progress bar.
75
`title` is the display name (eg 'closed', 'spent effort') of this
76
interval that will be displayed in front of the unit name.
77
`count` is the number of units in the interval.
78
`qry_args` is a dict of extra params that will yield the subset of
79
tickets in this interval on a query.
80
`css_class` is the css class that will be used to display the division.
81
`overall_completion` can be set to true to make this interval count
82
towards overall completion of this group of tickets.
84
(Warning: `countsToProg` argument will be removed in 0.12, use
85
`overall_completion` instead)
87
if overall_completion is None:
88
overall_completion = countsToProg
89
self.intervals.append({
93
'css_class': css_class,
95
'countsToProg': overall_completion,
96
'overall_completion': overall_completion,
98
self.count = self.count + count
100
def refresh_calcs(self):
104
self.done_percent = 0
106
for interval in self.intervals:
107
interval['percent'] = round(float(interval['count'] /
108
float(self.count) * 100))
109
total_percent = total_percent + interval['percent']
110
if interval['overall_completion']:
111
self.done_percent += interval['percent']
112
self.done_count += interval['count']
114
# We want the percentages to add up to 100%. To do that, we fudge the
115
# first interval that counts as "completed". That interval is adjusted
116
# by enough to make the intervals sum to 100%.
117
if self.done_count and total_percent != 100:
118
fudge_int = [i for i in self.intervals
119
if i['overall_completion']][0]
120
fudge_amt = 100 - total_percent
121
fudge_int['percent'] += fudge_amt
122
self.done_percent += fudge_amt
125
class DefaultTicketGroupStatsProvider(Component):
126
"""Configurable ticket group statistics provider.
128
Example configuration (which is also the default):
132
# Definition of a 'closed' group:
136
# The definition consists in a comma-separated list of accepted status.
137
# Also, '*' means any status and could be used to associate all remaining
138
# states to one catch-all group.
140
# Qualifiers for the above group (the group must have been defined first):
142
closed.order = 0 # sequence number in the progress bar
143
closed.query_args = group=resolution # optional extra param for the query
144
closed.overall_completion = true # count for overall completion
146
# Definition of an 'active' group:
148
active = * # one catch-all group is allowed
150
active.css_class = open # CSS class for this interval
151
active.label = in progress # Displayed name for the group,
152
# needed for non-ascii group names
154
# The CSS class can be one of: new (yellow), open (no color) or
155
# closed (green). New styles can easily be added using the following
156
# selector: `table.progress td.<class>`
160
implements(ITicketGroupStatsProvider)
162
default_milestone_groups = [
163
{'name': 'closed', 'status': 'closed',
164
'query_args': 'group=resolution', 'overall_completion': 'true'},
165
{'name': 'active', 'status': '*', 'css_class': 'open'}
168
def _get_ticket_groups(self):
169
"""Returns a list of dict describing the ticket groups
170
in the expected order of appearance in the milestone progress bars.
172
if 'milestone-groups' in self.config:
175
for groupname, value in self.config.options('milestone-groups'):
178
groupname, qualifier = groupname.split('.', 1)
179
group = groups.setdefault(groupname, {'name': groupname,
181
group[qualifier] = value
182
order = max(order, int(group['order'])) + 1
183
return [group for group in sorted(groups.values(),
184
key=lambda g: int(g['order']))]
186
return self.default_milestone_groups
188
def get_ticket_group_stats(self, ticket_ids):
189
total_cnt = len(ticket_ids)
190
all_statuses = set(TicketSystem(self.env).get_all_status())
192
for s in all_statuses:
195
cursor = self.env.get_db_cnx().cursor()
196
str_ids = [str(x) for x in sorted(ticket_ids)]
197
cursor.execute("SELECT status, count(status) FROM ticket "
198
"WHERE id IN (%s) GROUP BY status" %
200
for s, cnt in cursor:
203
stat = TicketGroupStats('ticket status', 'ticket')
204
remaining_statuses = set(all_statuses)
205
groups = self._get_ticket_groups()
206
catch_all_group = None
207
# we need to go through the groups twice, so that the catch up group
208
# doesn't need to be the last one in the sequence
210
status_str = group['status'].strip()
211
if status_str == '*':
214
"'%(group1)s' and '%(group2)s' milestone groups "
215
"both are declared to be \"catch-all\" groups. "
216
"Please check your configuration.",
217
group1=group['name'], group2=catch_all_group['name']))
218
catch_all_group = group
220
group_statuses = set([s.strip()
221
for s in status_str.split(',')]) \
223
if group_statuses - remaining_statuses:
225
"'%(groupname)s' milestone group reused status "
226
"'%(status)s' already taken by other groups. "
227
"Please check your configuration.",
228
groupname=group['name'],
229
status=', '.join(group_statuses - remaining_statuses)))
231
remaining_statuses -= group_statuses
232
group['statuses'] = group_statuses
234
catch_all_group['statuses'] = remaining_statuses
238
for s, cnt in status_cnt.iteritems():
239
if s in group['statuses']:
241
query_args.setdefault('status', []).append(s)
242
for arg in [kv for kv in group.get('query_args', '').split(',')
244
k, v = [a.strip() for a in arg.split('=', 1)]
246
stat.add_interval(group.get('label', group['name']),
247
group_cnt, query_args,
248
group.get('css_class', group['name']),
249
bool(group.get('overall_completion')))
254
def get_ticket_stats(provider, tickets):
255
return provider.get_ticket_group_stats([t['id'] for t in tickets])
35
257
def get_tickets_for_milestone(env, db, milestone, field='component'):
36
258
cursor = db.cursor()
47
269
tickets.append({'id': tkt_id, 'status': status, field: fieldval})
50
def get_query_links(req, milestone, grouped_by='component', group=None):
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')
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')
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
74
percent_active, percent_closed = 0, 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:
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
89
def milestone_to_hdf(env, db, req, milestone):
90
hdf = {'name': milestone.name,
91
'href': req.href.milestone(milestone.name)}
92
if milestone.description:
93
hdf['description_source'] = milestone.description
94
hdf['description'] = wiki_to_html(milestone.description, env, req, db)
96
hdf['due'] = milestone.due
97
hdf['due_date'] = format_date(milestone.due)
98
hdf['due_delta'] = pretty_timedelta(milestone.due + 86400)
99
hdf['late'] = milestone.is_late
100
if milestone.completed:
101
hdf['completed'] = milestone.completed
102
hdf['completed_date'] = format_datetime(milestone.completed)
103
hdf['completed_delta'] = pretty_timedelta(milestone.completed)
106
def _get_groups(env, db, by='component'):
107
for field in TicketSystem(env).get_ticket_fields():
108
if field['name'] == by:
109
if field.has_key('options'):
110
return field['options']
113
cursor.execute("SELECT DISTINCT %s FROM ticket ORDER BY %s"
115
return [row[0] for row in cursor]
272
def apply_ticket_permissions(env, req, tickets):
273
"""Apply permissions to a set of milestone tickets as returned by
274
get_tickets_for_milestone()."""
275
return [t for t in tickets
276
if 'TICKET_VIEW' in req.perm('ticket', t['id'])]
278
def milestone_stats_data(req, stat, name, grouped_by='component', group=None):
279
def query_href(extra_args):
280
args = {'milestone': name, grouped_by: group, 'group': 'status'}
281
args.update(extra_args)
282
return req.href.query(args)
283
return {'stats': stat,
284
'stats_href': query_href(stat.qry_args),
285
'interval_hrefs': [query_href(interval['qry_args'])
286
for interval in stat.intervals]}
119
290
class RoadmapModule(Component):
121
292
implements(INavigationContributor, IPermissionRequestor, IRequestHandler)
293
stats_provider = ExtensionOption('roadmap', 'stats_provider',
294
ITicketGroupStatsProvider,
295
'DefaultTicketGroupStatsProvider',
296
"""Name of the component implementing `ITicketGroupStatsProvider`,
297
which is used to collect statistics on groups of tickets for display
298
in the roadmap views.""")
123
300
# INavigationContributor methods
142
318
return re.match(r'/roadmap/?', req.path_info) is not None
144
320
def process_request(self, req):
145
req.perm.assert_permission('ROADMAP_VIEW')
146
req.hdf['title'] = 'Roadmap'
321
milestone_realm = Resource('milestone')
322
req.perm.require('MILESTONE_VIEW')
148
324
showall = req.args.get('show') == 'all'
149
req.hdf['roadmap.showall'] = showall
151
326
db = self.env.get_db_cnx()
152
milestones = [milestone_to_hdf(self.env, db, req, m)
153
for m in Milestone.select(self.env, showall, db)]
154
req.hdf['roadmap.milestones'] = milestones
327
milestones = [m for m in Milestone.select(self.env, showall, db)
328
if 'MILESTONE_VIEW' in req.perm(m.resource)]
156
for idx, milestone in enumerate(milestones):
157
milestone_name = unescape(milestone['name']) # Kludge
158
prefix = 'roadmap.milestones.%d.' % idx
159
tickets = get_tickets_for_milestone(self.env, db, milestone_name,
332
for milestone in milestones:
333
tickets = get_tickets_for_milestone(self.env, db, milestone.name,
161
req.hdf[prefix + 'stats'] = calc_ticket_stats(tickets)
162
for k, v in get_query_links(req, milestone_name).items():
163
req.hdf[prefix + 'queries.' + k] = v
164
milestone['tickets'] = tickets # for the iCalendar view
335
tickets = apply_ticket_permissions(self.env, req, tickets)
336
stat = get_ticket_stats(self.stats_provider, tickets)
337
stats.append(milestone_stats_data(req, stat, milestone.name))
338
#milestone['tickets'] = tickets # for the iCalendar view
166
340
if req.args.get('format') == 'ics':
167
341
self.render_ics(req, db, milestones)
170
add_stylesheet(req, 'common/css/roadmap.css')
172
344
# FIXME should use the 'webcal:' scheme, probably
174
346
if req.authname and req.authname != 'anonymous':
175
347
username = req.authname
176
icshref = req.href.roadmap(show=req.args.get('show'),
177
user=username, format='ics')
178
add_link(req, 'alternate', icshref, 'iCalendar', 'text/calendar', 'ics')
348
icshref = req.href.roadmap(show=req.args.get('show'), user=username,
350
add_link(req, 'alternate', icshref, _('iCalendar'), 'text/calendar',
180
return 'roadmap.cs', None
354
'milestones': milestones,
355
'milestone_stats': stats,
359
return 'roadmap.html', data, None
182
361
# Internal methods
237
417
write_prop('METHOD', 'PUBLISH')
238
418
write_prop('X-WR-CALNAME',
239
self.config.get('project', 'name') + ' - Roadmap')
419
self.config.get('project', 'name') + ' - ' + _('Roadmap'))
240
420
for milestone in milestones:
241
uid = '<%s/milestone/%s@%s>' % (req.base_path, milestone['name'],
421
uid = '<%s/milestone/%s@%s>' % (req.base_path, milestone.name,
243
if milestone.has_key('due'):
244
424
write_prop('BEGIN', 'VEVENT')
245
425
write_prop('UID', uid)
246
write_utctime('DTSTAMP', localtime(milestone['due']))
247
write_date('DTSTART', localtime(milestone['due']))
248
write_prop('SUMMARY', 'Milestone %s' % milestone['name'])
426
write_utctime('DTSTAMP', milestone.due)
427
write_date('DTSTART', milestone.due)
428
write_prop('SUMMARY', _('Milestone %(name)s') % {
429
'name': milestone.name
249
431
write_prop('URL', req.base_url + '/milestone/' +
251
if milestone.has_key('description_source'):
252
write_prop('DESCRIPTION', milestone['description_source'])
433
if milestone.description:
434
write_prop('DESCRIPTION', milestone.description)
253
435
write_prop('END', 'VEVENT')
254
for tkt_id in [ticket['id'] for ticket in milestone['tickets']
436
tickets = get_tickets_for_milestone(self.env, db, milestone.name,
438
tickets = apply_ticket_permissions(self.env, req, tickets)
439
for tkt_id in [ticket['id'] for ticket in tickets
255
440
if ticket['owner'] == user]:
256
441
ticket = Ticket(self.env, tkt_id)
257
442
write_prop('BEGIN', 'VTODO')
258
443
write_prop('UID', '<%s/ticket/%s@%s>' % (req.base_path,
260
if milestone.has_key('due'):
261
446
write_prop('RELATED-TO', uid)
262
write_date('DUE', localtime(milestone['due']))
263
write_prop('SUMMARY', 'Ticket #%i: %s' % (ticket.id,
447
write_date('DUE', milestone.due)
448
write_prop('SUMMARY', _('Ticket #%(num)s: %(summary)s') % {
449
'num': ticket.id, 'summary': ticket['summary']
265
451
write_prop('URL', req.abs_href.ticket(ticket.id))
266
452
write_prop('DESCRIPTION', ticket['description'])
267
453
priority = get_priority(ticket)
305
500
# ITimelineEventProvider methods
307
502
def get_timeline_filters(self, req):
308
if req.perm.has_permission('MILESTONE_VIEW'):
309
yield ('milestone', 'Milestones')
503
if 'MILESTONE_VIEW' in req.perm:
504
yield ('milestone', _('Milestones'))
311
506
def get_timeline_events(self, req, start, stop, filters):
312
507
if 'milestone' in filters:
313
format = req.args.get('format')
508
milestone_realm = Resource('milestone')
314
509
db = self.env.get_db_cnx()
315
510
cursor = db.cursor()
511
# TODO: creation and (later) modifications should also be reported
316
512
cursor.execute("SELECT completed,name,description FROM milestone "
317
513
"WHERE completed>=%s AND completed<=%s",
514
(to_timestamp(start), to_timestamp(stop)))
319
515
for completed, name, description in cursor:
320
title = Markup('Milestone <em>%s</em> completed', name)
322
href = req.abs_href.milestone(name)
323
message = wiki_to_html(description, self.env, req, db,
326
href = req.href.milestone(name)
327
message = wiki_to_oneliner(description, self.env, db,
329
yield 'milestone', href, title, completed, None, message or '--'
516
milestone = milestone_realm(id=name)
517
if 'MILESTONE_VIEW' in req.perm(milestone):
518
yield('milestone', datetime.fromtimestamp(completed, utc),
519
'', (milestone, description)) # FIXME: author?
522
for event in AttachmentModule(self.env).get_timeline_events(
523
req, milestone_realm, start, stop):
526
def render_timeline_event(self, context, field, event):
527
milestone, description = event[3]
529
return context.href.milestone(milestone.id)
530
elif field == 'title':
531
return tag('Milestone ', tag.em(milestone.id), ' completed')
532
elif field == 'description':
533
return format_to_html(self.env, context(resource=milestone),
534
shorten_line(description))
331
536
# IRequestHandler methods
387
588
def _do_save(self, req, db, milestone):
388
589
if milestone.exists:
389
req.perm.assert_permission('MILESTONE_MODIFY')
590
req.perm(milestone.resource).require('MILESTONE_MODIFY')
391
req.perm.assert_permission('MILESTONE_CREATE')
592
req.perm(milestone.resource).require('MILESTONE_CREATE')
393
if not req.args.has_key('name'):
394
raise TracError('You must provide a name for the milestone.',
395
'Required Field Missing')
594
old_name = milestone.name
595
new_name = req.args.get('name')
597
milestone.name = new_name
598
milestone.description = req.args.get('description', '')
397
600
due = req.args.get('duedate', '')
399
milestone.due = due and parse_date(due) or 0
400
except ValueError, e:
401
raise TracError(to_unicode(e), 'Invalid Date Format')
402
if req.args.has_key('completed'):
403
completed = req.args.get('completeddate', '')
601
milestone.due = due and parse_date(due, tzinfo=req.tz) or 0
603
completed = req.args.get('completeddate', '')
604
retarget_to = req.args.get('target')
606
# Instead of raising one single error, check all the constraints and
607
# let the user fix them by going back to edit mode showing the warnings
610
add_warning(req, msg)
615
# check that the milestone doesn't already exists
616
# FIXME: the whole .exists business needs to be clarified (#4130)
617
# and should behave like a WikiPage does in this respect.
405
milestone.completed = completed and parse_date(completed) or 0
406
except ValueError, e:
407
raise TracError(to_unicode(e), 'Invalid Date Format')
408
if milestone.completed > time():
409
raise TracError('Completion date may not be in the future',
410
'Invalid Completion Date')
411
retarget_to = req.args.get('target')
412
if req.args.has_key('retarget'):
619
test_milestone = Milestone(self.env, new_name, db)
620
if not test_milestone.exists:
621
# then an exception should have been raised
622
warn(_('Milestone "%(name)s" already exists, please '
623
'choose another name', name=new_name))
627
warn(_('You must provide a name for the milestone.'))
629
# -- check completed date
630
if 'completed' in req.args:
631
completed = completed and parse_date(completed, req.tz) or None
632
if completed and completed > datetime.now(utc):
633
warn(_('Completion date may not be in the future'))
636
milestone.completed = completed
639
return self._render_editor(req, db, milestone)
641
# -- actually save changes
644
# eventually retarget opened tickets associated with the milestone
645
if 'retarget' in req.args:
413
646
cursor = db.cursor()
414
647
cursor.execute("UPDATE ticket SET milestone=%s WHERE "
415
648
"milestone=%s and status != 'closed'",
416
(retarget_to, milestone.name))
649
(retarget_to, old_name))
417
650
self.env.log.info('Tickets associated with milestone %s '
419
(milestone.name, retarget_to))
421
milestone.completed = 0
423
# don't update the milestone name until after retargetting open tickets
424
milestone.name = req.args.get('name')
425
milestone.description = req.args.get('description', '')
651
'retargeted to %s' % (old_name, retarget_to))
430
653
milestone.insert()
433
656
req.redirect(req.href.milestone(milestone.name))
435
658
def _render_confirm(self, req, db, milestone):
436
req.perm.assert_permission('MILESTONE_DELETE')
438
req.hdf['title'] = 'Milestone %s' % milestone.name
439
req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
440
req.hdf['milestone.mode'] = 'delete'
442
for idx,other in enumerate(Milestone.select(self.env, False, db)):
443
if other.name == milestone.name:
445
req.hdf['milestones.%d' % idx] = other.name
659
req.perm(milestone.resource).require('MILESTONE_DELETE')
662
'milestone': milestone,
663
'milestones': Milestone.select(self.env, False, db)
665
return 'milestone_delete.html', data, None
447
667
def _render_editor(self, req, db, milestone):
669
'milestone': milestone,
670
'date_hint': get_date_format_hint(),
671
'datetime_hint': get_datetime_format_hint(),
448
675
if milestone.exists:
449
req.perm.assert_permission('MILESTONE_MODIFY')
450
req.hdf['title'] = 'Milestone %s' % milestone.name
451
req.hdf['milestone.mode'] = 'edit'
452
req.hdf['milestones'] = [m.name for m in
453
Milestone.select(self.env)
454
if m.name != milestone.name]
676
req.perm(milestone.resource).require('MILESTONE_MODIFY')
677
data['milestones'] = [m for m in
678
Milestone.select(self.env, False, db)
679
if m.name != milestone.name]
456
req.perm.assert_permission('MILESTONE_CREATE')
457
req.hdf['title'] = 'New Milestone'
458
req.hdf['milestone.mode'] = 'new'
681
req.perm(milestone.resource).require('MILESTONE_CREATE')
460
from trac.util.datefmt import get_date_format_hint, \
461
get_datetime_format_hint
462
req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
463
req.hdf['milestone.date_hint'] = get_date_format_hint()
464
req.hdf['milestone.datetime_hint'] = get_datetime_format_hint()
465
req.hdf['milestone.datetime_now'] = format_datetime()
683
return 'milestone_edit.html', data, None
467
685
def _render_view(self, req, db, milestone):
468
req.hdf['title'] = 'Milestone %s' % milestone.name
469
req.hdf['milestone.mode'] = 'view'
471
req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
686
milestone_groups = []
473
687
available_groups = []
474
688
component_group_available = False
475
for field in TicketSystem(self.env).get_ticket_fields():
689
ticket_fields = TicketSystem(self.env).get_ticket_fields()
691
# collect fields that can be used for grouping
692
for field in ticket_fields:
476
693
if field['type'] == 'select' and field['name'] != 'milestone' \
477
or field['name'] == 'owner':
694
or field['name'] in ('owner', 'reporter'):
478
695
available_groups.append({'name': field['name'],
479
696
'label': field['label']})
480
697
if field['name'] == 'component':
481
698
component_group_available = True
482
req.hdf['milestone.stats.available_groups'] = available_groups
700
# determine the field currently used for grouping
484
702
if component_group_available:
485
by = req.args.get('by', 'component')
487
by = req.args.get('by', available_groups[0]['name'])
488
req.hdf['milestone.stats.grouped_by'] = by
704
elif available_groups:
705
by = available_groups[0]['name']
706
by = req.args.get('by', by)
490
708
tickets = get_tickets_for_milestone(self.env, db, milestone.name, by)
491
stats = calc_ticket_stats(tickets)
492
req.hdf['milestone.stats'] = stats
493
for key, value in get_query_links(req, milestone.name).items():
494
req.hdf['milestone.queries.' + key] = value
496
groups = _get_groups(self.env, db, by)
498
max_percent_total = 0
500
group_tickets = [t for t in tickets if t[by] == group]
501
if not group_tickets:
503
prefix = 'milestone.stats.groups.%s' % group_no
504
req.hdf['%s.name' % prefix] = group
507
percent_total = float(len(group_tickets)) / float(len(tickets))
508
if percent_total > max_percent_total:
509
max_percent_total = percent_total
510
req.hdf['%s.percent_total' % prefix] = percent_total * 100
511
stats = calc_ticket_stats(group_tickets)
512
req.hdf[prefix] = stats
514
get_query_links(req, milestone.name, by, group).items():
515
req.hdf['%s.queries.%s' % (prefix, key)] = value
517
req.hdf['milestone.stats.max_percent_total'] = max_percent_total * 100
709
tickets = apply_ticket_permissions(self.env, req, tickets)
710
stat = get_ticket_stats(self.stats_provider, tickets)
712
context = Context.from_request(req, milestone.resource)
715
'milestone': milestone,
716
'attachments': AttachmentModule(self.env).attachment_data(context),
717
'available_groups': available_groups,
719
'groups': milestone_groups
721
data.update(milestone_stats_data(req, stat, milestone.name))
725
for field in ticket_fields:
726
if field['name'] == by:
727
if field.has_key('options'):
728
groups = field['options']
731
cursor.execute("SELECT DISTINCT %s FROM ticket "
732
"ORDER BY %s" % (by, by))
733
groups = [row[0] for row in cursor]
739
group_tickets = [t for t in tickets if t[by] == group]
740
if not group_tickets:
743
gstat = get_ticket_stats(self.stats_provider, group_tickets)
744
if gstat.count > max_count:
745
max_count = gstat.count
747
group_stats.append(gstat)
749
gs_dict = {'name': group}
750
gs_dict.update(milestone_stats_data(req, gstat, milestone.name,
752
milestone_groups.append(gs_dict)
754
for idx, gstat in enumerate(group_stats):
755
gs_dict = milestone_groups[idx]
758
percent = float(gstat.count) / float(max_count) * 100
759
gs_dict['percent_of_max_total'] = percent
761
return 'milestone_view.html', data, None
519
763
# IWikiSyntaxProvider methods
525
769
yield ('milestone', self._format_link)
527
771
def _format_link(self, formatter, ns, name, label):
528
return html.A(label, href=formatter.href.milestone(name),
772
name, query, fragment = formatter.split_link(name)
773
return self._render_link(formatter.context, name, label,
776
def _render_link(self, context, name, label, extra=''):
778
milestone = Milestone(self.env, name)
781
# Note: the above should really not be needed, `Milestone.exists`
782
# should simply be false if the milestone doesn't exist in the db
784
href = context.href.milestone(name)
785
if milestone and milestone.exists and \
786
'MILESTONE_VIEW' in context.perm(milestone.resource):
787
closed = milestone.is_completed and 'closed ' or ''
788
return tag.a(label, class_='%smilestone' % closed, href=href+extra)
790
return tag.a(label, class_='missing milestone', href=href+extra,
793
# IResourceManager methods
795
def get_resource_realms(self):
798
def get_resource_description(self, resource, format=None, context=None,
801
if format != 'compact':
802
desc = _('Milestone %(name)s', name=resource.id)
804
return self._render_link(context, resource.id, desc)