1 | # -*- coding: utf-8 -*-
|
---|
2 | #
|
---|
3 | # Copyright (C) 2004-2009 Edgewall Software
|
---|
4 | # Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de>
|
---|
5 | # Copyright (C) 2006-2007 Christian Boos <cboos@neuf.fr>
|
---|
6 | # All rights reserved.
|
---|
7 | #
|
---|
8 | # This software is licensed as described in the file COPYING, which
|
---|
9 | # you should have received as part of this distribution. The terms
|
---|
10 | # are also available at http://trac.edgewall.org/wiki/TracLicense.
|
---|
11 | #
|
---|
12 | # This software consists of voluntary contributions made by many
|
---|
13 | # individuals. For the exact contribution history, see the revision
|
---|
14 | # history and logs, available at http://trac.edgewall.org/log/.
|
---|
15 | #
|
---|
16 | # Author: Christopher Lenz <cmlenz@gmx.de>
|
---|
17 |
|
---|
18 | from StringIO import StringIO
|
---|
19 | from datetime import datetime
|
---|
20 | import re
|
---|
21 |
|
---|
22 | from genshi.builder import tag
|
---|
23 |
|
---|
24 | from trac import __version__
|
---|
25 | from trac.attachment import AttachmentModule
|
---|
26 | from trac.config import ExtensionOption
|
---|
27 | from trac.core import *
|
---|
28 | from trac.mimeview import Context
|
---|
29 | from trac.perm import IPermissionRequestor
|
---|
30 | from trac.resource import *
|
---|
31 | from trac.search import ISearchSource, search_to_sql, shorten_result
|
---|
32 | from trac.util.datefmt import parse_date, utc, to_utimestamp, \
|
---|
33 | get_date_format_hint, get_datetime_format_hint, \
|
---|
34 | format_date, format_datetime, from_utimestamp
|
---|
35 | from trac.util.text import CRLF
|
---|
36 | from trac.util.translation import _, tag_
|
---|
37 | from trac.ticket import Milestone, Ticket, TicketSystem, group_milestones
|
---|
38 | from trac.ticket.query import QueryModule
|
---|
39 | from trac.timeline.api import ITimelineEventProvider
|
---|
40 | from trac.web import IRequestHandler, RequestDone
|
---|
41 | from trac.web.chrome import add_link, add_notice, add_script, add_stylesheet, \
|
---|
42 | add_warning, Chrome, INavigationContributor
|
---|
43 | from trac.wiki.api import IWikiSyntaxProvider
|
---|
44 | from trac.wiki.formatter import format_to
|
---|
45 |
|
---|
46 |
|
---|
47 | class ITicketGroupStatsProvider(Interface):
|
---|
48 | def get_ticket_group_stats(ticket_ids):
|
---|
49 | """ Gather statistics on a group of tickets.
|
---|
50 |
|
---|
51 | This method returns a valid TicketGroupStats object.
|
---|
52 | """
|
---|
53 |
|
---|
54 | class TicketGroupStats(object):
|
---|
55 | """Encapsulates statistics on a group of tickets."""
|
---|
56 |
|
---|
57 | def __init__(self, title, unit):
|
---|
58 | """Creates a new TicketGroupStats object.
|
---|
59 |
|
---|
60 | `title` is the display name of this group of stats (e.g.
|
---|
61 | 'ticket status').
|
---|
62 | `unit` is the units for these stats in plural form, e.g. _('hours')
|
---|
63 | """
|
---|
64 | self.title = title
|
---|
65 | self.unit = unit
|
---|
66 | self.count = 0
|
---|
67 | self.qry_args = {}
|
---|
68 | self.intervals = []
|
---|
69 | self.done_percent = 0
|
---|
70 | self.done_count = 0
|
---|
71 |
|
---|
72 | def add_interval(self, title, count, qry_args, css_class,
|
---|
73 | overall_completion=None, countsToProg=0):
|
---|
74 | """Adds a division to this stats' group's progress bar.
|
---|
75 |
|
---|
76 | `title` is the display name (eg 'closed', 'spent effort') of this
|
---|
77 | interval that will be displayed in front of the unit name.
|
---|
78 | `count` is the number of units in the interval.
|
---|
79 | `qry_args` is a dict of extra params that will yield the subset of
|
---|
80 | tickets in this interval on a query.
|
---|
81 | `css_class` is the css class that will be used to display the division.
|
---|
82 | `overall_completion` can be set to true to make this interval count
|
---|
83 | towards overall completion of this group of tickets.
|
---|
84 |
|
---|
85 | (Warning: `countsToProg` argument will be removed in 0.12, use
|
---|
86 | `overall_completion` instead)
|
---|
87 | """
|
---|
88 | if overall_completion is None:
|
---|
89 | overall_completion = countsToProg
|
---|
90 | self.intervals.append({
|
---|
91 | 'title': title,
|
---|
92 | 'count': count,
|
---|
93 | 'qry_args': qry_args,
|
---|
94 | 'css_class': css_class,
|
---|
95 | 'percent': None,
|
---|
96 | 'countsToProg': overall_completion,
|
---|
97 | 'overall_completion': overall_completion,
|
---|
98 | })
|
---|
99 | self.count = self.count + count
|
---|
100 |
|
---|
101 | def refresh_calcs(self):
|
---|
102 | if self.count < 1:
|
---|
103 | return
|
---|
104 | total_percent = 0
|
---|
105 | self.done_percent = 0
|
---|
106 | self.done_count = 0
|
---|
107 | for interval in self.intervals:
|
---|
108 | interval['percent'] = round(float(interval['count'] /
|
---|
109 | float(self.count) * 100))
|
---|
110 | total_percent = total_percent + interval['percent']
|
---|
111 | if interval['overall_completion']:
|
---|
112 | self.done_percent += interval['percent']
|
---|
113 | self.done_count += interval['count']
|
---|
114 |
|
---|
115 | # We want the percentages to add up to 100%. To do that, we fudge the
|
---|
116 | # first interval that counts as "completed". That interval is adjusted
|
---|
117 | # by enough to make the intervals sum to 100%.
|
---|
118 | if self.done_count and total_percent != 100:
|
---|
119 | fudge_int = [i for i in self.intervals
|
---|
120 | if i['overall_completion']][0]
|
---|
121 | fudge_amt = 100 - total_percent
|
---|
122 | fudge_int['percent'] += fudge_amt
|
---|
123 | self.done_percent += fudge_amt
|
---|
124 |
|
---|
125 |
|
---|
126 | class DefaultTicketGroupStatsProvider(Component):
|
---|
127 | """Configurable ticket group statistics provider.
|
---|
128 |
|
---|
129 | Example configuration (which is also the default):
|
---|
130 | {{{
|
---|
131 | [milestone-groups]
|
---|
132 |
|
---|
133 | # Definition of a 'closed' group:
|
---|
134 |
|
---|
135 | closed = closed
|
---|
136 |
|
---|
137 | # The definition consists in a comma-separated list of accepted status.
|
---|
138 | # Also, '*' means any status and could be used to associate all remaining
|
---|
139 | # states to one catch-all group.
|
---|
140 |
|
---|
141 | # Qualifiers for the above group (the group must have been defined first):
|
---|
142 |
|
---|
143 | closed.order = 0 # sequence number in the progress bar
|
---|
144 | closed.query_args = group=resolution # optional extra param for the query
|
---|
145 | closed.overall_completion = true # count for overall completion
|
---|
146 |
|
---|
147 | # Definition of an 'active' group:
|
---|
148 |
|
---|
149 | active = * # one catch-all group is allowed
|
---|
150 | active.order = 1
|
---|
151 | active.css_class = open # CSS class for this interval
|
---|
152 | active.label = in progress # Displayed name for the group,
|
---|
153 | # needed for non-ascii group names
|
---|
154 |
|
---|
155 | # The CSS class can be one of: new (yellow), open (no color) or
|
---|
156 | # closed (green). New styles can easily be added using the following
|
---|
157 | # selector: `table.progress td.<class>`
|
---|
158 | }}}
|
---|
159 | """
|
---|
160 |
|
---|
161 | implements(ITicketGroupStatsProvider)
|
---|
162 |
|
---|
163 | default_milestone_groups = [
|
---|
164 | {'name': 'closed', 'status': 'closed',
|
---|
165 | 'query_args': 'group=resolution', 'overall_completion': 'true'},
|
---|
166 | {'name': 'active', 'status': '*', 'css_class': 'open'}
|
---|
167 | ]
|
---|
168 |
|
---|
169 | def _get_ticket_groups(self):
|
---|
170 | """Returns a list of dict describing the ticket groups
|
---|
171 | in the expected order of appearance in the milestone progress bars.
|
---|
172 | """
|
---|
173 | if 'milestone-groups' in self.config:
|
---|
174 | groups = {}
|
---|
175 | order = 0
|
---|
176 | for groupname, value in self.config.options('milestone-groups'):
|
---|
177 | qualifier = 'status'
|
---|
178 | if '.' in groupname:
|
---|
179 | groupname, qualifier = groupname.split('.', 1)
|
---|
180 | group = groups.setdefault(groupname, {'name': groupname,
|
---|
181 | 'order': order})
|
---|
182 | group[qualifier] = value
|
---|
183 | order = max(order, int(group['order'])) + 1
|
---|
184 | return [group for group in sorted(groups.values(),
|
---|
185 | key=lambda g: int(g['order']))]
|
---|
186 | else:
|
---|
187 | return self.default_milestone_groups
|
---|
188 |
|
---|
189 | def get_ticket_group_stats(self, ticket_ids):
|
---|
190 | total_cnt = len(ticket_ids)
|
---|
191 | all_statuses = set(TicketSystem(self.env).get_all_status())
|
---|
192 | status_cnt = {}
|
---|
193 | for s in all_statuses:
|
---|
194 | status_cnt[s] = 0
|
---|
195 | if total_cnt:
|
---|
196 | db = self.env.get_db_cnx()
|
---|
197 | cursor = db.cursor()
|
---|
198 | str_ids = [str(x) for x in sorted(ticket_ids)]
|
---|
199 | cursor.execute("SELECT status, count(status) FROM ticket "
|
---|
200 | "WHERE id IN (%s) GROUP BY status" %
|
---|
201 | ",".join(str_ids))
|
---|
202 | for s, cnt in cursor:
|
---|
203 | status_cnt[s] = cnt
|
---|
204 |
|
---|
205 | stat = TicketGroupStats(_('ticket status'), _('tickets'))
|
---|
206 | remaining_statuses = set(all_statuses)
|
---|
207 | groups = self._get_ticket_groups()
|
---|
208 | catch_all_group = None
|
---|
209 | # we need to go through the groups twice, so that the catch up group
|
---|
210 | # doesn't need to be the last one in the sequence
|
---|
211 | for group in groups:
|
---|
212 | status_str = group['status'].strip()
|
---|
213 | if status_str == '*':
|
---|
214 | if catch_all_group:
|
---|
215 | raise TracError(_(
|
---|
216 | "'%(group1)s' and '%(group2)s' milestone groups "
|
---|
217 | "both are declared to be \"catch-all\" groups. "
|
---|
218 | "Please check your configuration.",
|
---|
219 | group1=group['name'], group2=catch_all_group['name']))
|
---|
220 | catch_all_group = group
|
---|
221 | else:
|
---|
222 | group_statuses = set([s.strip()
|
---|
223 | for s in status_str.split(',')]) \
|
---|
224 | & all_statuses
|
---|
225 | if group_statuses - remaining_statuses:
|
---|
226 | raise TracError(_(
|
---|
227 | "'%(groupname)s' milestone group reused status "
|
---|
228 | "'%(status)s' already taken by other groups. "
|
---|
229 | "Please check your configuration.",
|
---|
230 | groupname=group['name'],
|
---|
231 | status=', '.join(group_statuses - remaining_statuses)))
|
---|
232 | else:
|
---|
233 | remaining_statuses -= group_statuses
|
---|
234 | group['statuses'] = group_statuses
|
---|
235 | if catch_all_group:
|
---|
236 | catch_all_group['statuses'] = remaining_statuses
|
---|
237 | for group in groups:
|
---|
238 | group_cnt = 0
|
---|
239 | query_args = {}
|
---|
240 | for s, cnt in status_cnt.iteritems():
|
---|
241 | if s in group['statuses']:
|
---|
242 | group_cnt += cnt
|
---|
243 | query_args.setdefault('status', []).append(s)
|
---|
244 | for arg in [kv for kv in group.get('query_args', '').split(',')
|
---|
245 | if '=' in kv]:
|
---|
246 | k, v = [a.strip() for a in arg.split('=', 1)]
|
---|
247 | query_args.setdefault(k, []).append(v)
|
---|
248 | stat.add_interval(group.get('label', group['name']),
|
---|
249 | group_cnt, query_args,
|
---|
250 | group.get('css_class', group['name']),
|
---|
251 | bool(group.get('overall_completion')))
|
---|
252 | stat.refresh_calcs()
|
---|
253 | return stat
|
---|
254 |
|
---|
255 |
|
---|
256 | def get_ticket_stats(provider, tickets):
|
---|
257 | return provider.get_ticket_group_stats([t['id'] for t in tickets])
|
---|
258 |
|
---|
259 | def get_tickets_for_milestone(env, db, milestone, field='component'):
|
---|
260 | cursor = db.cursor()
|
---|
261 | fields = TicketSystem(env).get_ticket_fields()
|
---|
262 | if field in [f['name'] for f in fields if not f.get('custom')]:
|
---|
263 | cursor.execute("SELECT id,status,%s FROM ticket WHERE milestone=%%s "
|
---|
264 | "ORDER BY %s" % (field, field), (milestone,))
|
---|
265 | else:
|
---|
266 | cursor.execute("SELECT id,status,value FROM ticket LEFT OUTER "
|
---|
267 | "JOIN ticket_custom ON (id=ticket AND name=%s) "
|
---|
268 | "WHERE milestone=%s ORDER BY value", (field, milestone))
|
---|
269 | tickets = []
|
---|
270 | for tkt_id, status, fieldval in cursor:
|
---|
271 | tickets.append({'id': tkt_id, 'status': status, field: fieldval})
|
---|
272 | return tickets
|
---|
273 |
|
---|
274 | def apply_ticket_permissions(env, req, tickets):
|
---|
275 | """Apply permissions to a set of milestone tickets as returned by
|
---|
276 | get_tickets_for_milestone()."""
|
---|
277 | return [t for t in tickets
|
---|
278 | if 'TICKET_VIEW' in req.perm('ticket', t['id'])]
|
---|
279 |
|
---|
280 | def milestone_stats_data(env, req, stat, name, grouped_by='component',
|
---|
281 | group=None):
|
---|
282 | has_query = env[QueryModule] is not None
|
---|
283 | def query_href(extra_args):
|
---|
284 | if not has_query:
|
---|
285 | return None
|
---|
286 | args = {'milestone': name, grouped_by: group, 'group': 'status'}
|
---|
287 | args.update(extra_args)
|
---|
288 | return req.href.query(args)
|
---|
289 | return {'stats': stat,
|
---|
290 | 'stats_href': query_href(stat.qry_args),
|
---|
291 | 'interval_hrefs': [query_href(interval['qry_args'])
|
---|
292 | for interval in stat.intervals]}
|
---|
293 |
|
---|
294 |
|
---|
295 |
|
---|
296 | class RoadmapModule(Component):
|
---|
297 |
|
---|
298 | implements(INavigationContributor, IPermissionRequestor, IRequestHandler)
|
---|
299 | stats_provider = ExtensionOption('roadmap', 'stats_provider',
|
---|
300 | ITicketGroupStatsProvider,
|
---|
301 | 'DefaultTicketGroupStatsProvider',
|
---|
302 | """Name of the component implementing `ITicketGroupStatsProvider`,
|
---|
303 | which is used to collect statistics on groups of tickets for display
|
---|
304 | in the roadmap views.""")
|
---|
305 |
|
---|
306 | # INavigationContributor methods
|
---|
307 |
|
---|
308 | def get_active_navigation_item(self, req):
|
---|
309 | return 'roadmap'
|
---|
310 |
|
---|
311 | def get_navigation_items(self, req):
|
---|
312 | if 'ROADMAP_VIEW' in req.perm:
|
---|
313 | yield ('mainnav', 'roadmap',
|
---|
314 | tag.a(_('Roadmap'), href=req.href.roadmap(), accesskey=3))
|
---|
315 |
|
---|
316 | # IPermissionRequestor methods
|
---|
317 |
|
---|
318 | def get_permission_actions(self):
|
---|
319 | actions = ['MILESTONE_CREATE', 'MILESTONE_DELETE', 'MILESTONE_MODIFY',
|
---|
320 | 'MILESTONE_VIEW', 'ROADMAP_VIEW']
|
---|
321 | return ['ROADMAP_VIEW'] + [('ROADMAP_ADMIN', actions)]
|
---|
322 |
|
---|
323 | # IRequestHandler methods
|
---|
324 |
|
---|
325 | def match_request(self, req):
|
---|
326 | return req.path_info == '/roadmap'
|
---|
327 |
|
---|
328 | def process_request(self, req):
|
---|
329 | req.perm.require('MILESTONE_VIEW')
|
---|
330 |
|
---|
331 | show = req.args.getlist('show')
|
---|
332 | if 'all' in show:
|
---|
333 | show = ['completed']
|
---|
334 |
|
---|
335 | db = self.env.get_db_cnx()
|
---|
336 | milestones = Milestone.select(self.env, 'completed' in show, db)
|
---|
337 | if 'noduedate' in show:
|
---|
338 | milestones = [m for m in milestones
|
---|
339 | if m.due is not None or m.completed]
|
---|
340 | milestones = [m for m in milestones
|
---|
341 | if 'MILESTONE_VIEW' in req.perm(m.resource)]
|
---|
342 |
|
---|
343 | stats = []
|
---|
344 | queries = []
|
---|
345 |
|
---|
346 | for milestone in milestones:
|
---|
347 | tickets = get_tickets_for_milestone(self.env, db, milestone.name,
|
---|
348 | 'owner')
|
---|
349 | tickets = apply_ticket_permissions(self.env, req, tickets)
|
---|
350 | stat = get_ticket_stats(self.stats_provider, tickets)
|
---|
351 | stats.append(milestone_stats_data(self.env, req, stat,
|
---|
352 | milestone.name))
|
---|
353 | #milestone['tickets'] = tickets # for the iCalendar view
|
---|
354 |
|
---|
355 | if req.args.get('format') == 'ics':
|
---|
356 | self.render_ics(req, db, milestones)
|
---|
357 | return
|
---|
358 |
|
---|
359 | # FIXME should use the 'webcal:' scheme, probably
|
---|
360 | username = None
|
---|
361 | if req.authname and req.authname != 'anonymous':
|
---|
362 | username = req.authname
|
---|
363 | icshref = req.href.roadmap(show=show, user=username, format='ics')
|
---|
364 | add_link(req, 'alternate', icshref, _('iCalendar'), 'text/calendar',
|
---|
365 | 'ics')
|
---|
366 |
|
---|
367 | data = {
|
---|
368 | 'milestones': milestones,
|
---|
369 | 'milestone_stats': stats,
|
---|
370 | 'queries': queries,
|
---|
371 | 'show': show,
|
---|
372 | }
|
---|
373 | add_stylesheet(req, 'common/css/roadmap.css')
|
---|
374 | return 'roadmap.html', data, None
|
---|
375 |
|
---|
376 | # Internal methods
|
---|
377 |
|
---|
378 | def render_ics(self, req, db, milestones):
|
---|
379 | req.send_response(200)
|
---|
380 | req.send_header('Content-Type', 'text/calendar;charset=utf-8')
|
---|
381 | buf = StringIO()
|
---|
382 |
|
---|
383 | from trac.ticket import Priority
|
---|
384 | priorities = {}
|
---|
385 | for priority in Priority.select(self.env):
|
---|
386 | priorities[priority.name] = float(priority.value)
|
---|
387 | def get_priority(ticket):
|
---|
388 | value = priorities.get(ticket['priority'])
|
---|
389 | if value:
|
---|
390 | return int(value * 9 / len(priorities))
|
---|
391 |
|
---|
392 | def get_status(ticket):
|
---|
393 | status = ticket['status']
|
---|
394 | if status == 'new' or status == 'reopened' and not ticket['owner']:
|
---|
395 | return 'NEEDS-ACTION'
|
---|
396 | elif status == 'assigned' or status == 'reopened':
|
---|
397 | return 'IN-PROCESS'
|
---|
398 | elif status == 'closed':
|
---|
399 | if ticket['resolution'] == 'fixed':
|
---|
400 | return 'COMPLETED'
|
---|
401 | else: return 'CANCELLED'
|
---|
402 | else: return ''
|
---|
403 |
|
---|
404 | def escape_value(text):
|
---|
405 | s = ''.join(map(lambda c: (c in ';,\\') and '\\' + c or c, text))
|
---|
406 | return '\\n'.join(re.split(r'[\r\n]+', s))
|
---|
407 |
|
---|
408 | def write_prop(name, value, params={}):
|
---|
409 | text = ';'.join([name] + [k + '=' + v for k, v in params.items()]) \
|
---|
410 | + ':' + escape_value(value)
|
---|
411 | firstline = 1
|
---|
412 | while text:
|
---|
413 | if not firstline:
|
---|
414 | text = ' ' + text
|
---|
415 | else: firstline = 0
|
---|
416 | buf.write(text[:75] + CRLF)
|
---|
417 | text = text[75:]
|
---|
418 |
|
---|
419 | def write_date(name, value, params={}):
|
---|
420 | params['VALUE'] = 'DATE'
|
---|
421 | write_prop(name, format_date(value, '%Y%m%d', req.tz), params)
|
---|
422 |
|
---|
423 | def write_utctime(name, value, params={}):
|
---|
424 | write_prop(name, format_datetime(value, '%Y%m%dT%H%M%SZ', utc),
|
---|
425 | params)
|
---|
426 |
|
---|
427 | host = req.base_url[req.base_url.find('://') + 3:]
|
---|
428 | user = req.args.get('user', 'anonymous')
|
---|
429 |
|
---|
430 | write_prop('BEGIN', 'VCALENDAR')
|
---|
431 | write_prop('VERSION', '2.0')
|
---|
432 | write_prop('PRODID', '-//Edgewall Software//NONSGML Trac %s//EN'
|
---|
433 | % __version__)
|
---|
434 | write_prop('METHOD', 'PUBLISH')
|
---|
435 | write_prop('X-WR-CALNAME',
|
---|
436 | self.env.project_name + ' - ' + _('Roadmap'))
|
---|
437 | for milestone in milestones:
|
---|
438 | uid = '<%s/milestone/%s@%s>' % (req.base_path, milestone.name,
|
---|
439 | host)
|
---|
440 | if milestone.due:
|
---|
441 | write_prop('BEGIN', 'VEVENT')
|
---|
442 | write_prop('UID', uid)
|
---|
443 | write_utctime('DTSTAMP', milestone.due)
|
---|
444 | write_date('DTSTART', milestone.due)
|
---|
445 | write_prop('SUMMARY', _('Milestone %(name)s',
|
---|
446 | name=milestone.name))
|
---|
447 | write_prop('URL', req.base_url + '/milestone/' +
|
---|
448 | milestone.name)
|
---|
449 | if milestone.description:
|
---|
450 | write_prop('DESCRIPTION', milestone.description)
|
---|
451 | write_prop('END', 'VEVENT')
|
---|
452 | tickets = get_tickets_for_milestone(self.env, db, milestone.name,
|
---|
453 | field='owner')
|
---|
454 | tickets = apply_ticket_permissions(self.env, req, tickets)
|
---|
455 | for tkt_id in [ticket['id'] for ticket in tickets
|
---|
456 | if ticket['owner'] == user]:
|
---|
457 | ticket = Ticket(self.env, tkt_id)
|
---|
458 | write_prop('BEGIN', 'VTODO')
|
---|
459 | write_prop('UID', '<%s/ticket/%s@%s>' % (req.base_path,
|
---|
460 | tkt_id, host))
|
---|
461 | if milestone.due:
|
---|
462 | write_prop('RELATED-TO', uid)
|
---|
463 | write_date('DUE', milestone.due)
|
---|
464 | write_prop('SUMMARY', _('Ticket #%(num)s: %(summary)s',
|
---|
465 | num=ticket.id,
|
---|
466 | summary=ticket['summary']))
|
---|
467 | write_prop('URL', req.abs_href.ticket(ticket.id))
|
---|
468 | write_prop('DESCRIPTION', ticket['description'])
|
---|
469 | priority = get_priority(ticket)
|
---|
470 | if priority:
|
---|
471 | write_prop('PRIORITY', unicode(priority))
|
---|
472 | write_prop('STATUS', get_status(ticket))
|
---|
473 | if ticket['status'] == 'closed':
|
---|
474 | cursor = db.cursor()
|
---|
475 | cursor.execute("SELECT time FROM ticket_change "
|
---|
476 | "WHERE ticket=%s AND field='status' "
|
---|
477 | "ORDER BY time desc LIMIT 1",
|
---|
478 | (ticket.id,))
|
---|
479 | row = cursor.fetchone()
|
---|
480 | if row:
|
---|
481 | write_utctime('COMPLETED', from_utimestamp(row[0]))
|
---|
482 | write_prop('END', 'VTODO')
|
---|
483 | write_prop('END', 'VCALENDAR')
|
---|
484 |
|
---|
485 | ics_str = buf.getvalue().encode('utf-8')
|
---|
486 | req.send_header('Content-Length', len(ics_str))
|
---|
487 | req.end_headers()
|
---|
488 | req.write(ics_str)
|
---|
489 | raise RequestDone
|
---|
490 |
|
---|
491 |
|
---|
492 | class MilestoneModule(Component):
|
---|
493 |
|
---|
494 | implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
|
---|
495 | ITimelineEventProvider, IWikiSyntaxProvider, IResourceManager,
|
---|
496 | ISearchSource)
|
---|
497 |
|
---|
498 | stats_provider = ExtensionOption('milestone', 'stats_provider',
|
---|
499 | ITicketGroupStatsProvider,
|
---|
500 | 'DefaultTicketGroupStatsProvider',
|
---|
501 | """Name of the component implementing `ITicketGroupStatsProvider`,
|
---|
502 | which is used to collect statistics on groups of tickets for display
|
---|
503 | in the milestone views.""")
|
---|
504 |
|
---|
505 |
|
---|
506 | # INavigationContributor methods
|
---|
507 |
|
---|
508 | def get_active_navigation_item(self, req):
|
---|
509 | return 'roadmap'
|
---|
510 |
|
---|
511 | def get_navigation_items(self, req):
|
---|
512 | return []
|
---|
513 |
|
---|
514 | # IPermissionRequestor methods
|
---|
515 |
|
---|
516 | def get_permission_actions(self):
|
---|
517 | actions = ['MILESTONE_CREATE', 'MILESTONE_DELETE', 'MILESTONE_MODIFY',
|
---|
518 | 'MILESTONE_VIEW']
|
---|
519 | return actions + [('MILESTONE_ADMIN', actions)]
|
---|
520 |
|
---|
521 | # ITimelineEventProvider methods
|
---|
522 |
|
---|
523 | def get_timeline_filters(self, req):
|
---|
524 | if 'MILESTONE_VIEW' in req.perm:
|
---|
525 | yield ('milestone', _('Milestones reached'))
|
---|
526 |
|
---|
527 | def get_timeline_events(self, req, start, stop, filters):
|
---|
528 | if 'milestone' in filters:
|
---|
529 | milestone_realm = Resource('milestone')
|
---|
530 | db = self.env.get_db_cnx()
|
---|
531 | cursor = db.cursor()
|
---|
532 | # TODO: creation and (later) modifications should also be reported
|
---|
533 | cursor.execute("SELECT completed,name,description FROM milestone "
|
---|
534 | "WHERE completed>=%s AND completed<=%s",
|
---|
535 | (to_utimestamp(start), to_utimestamp(stop)))
|
---|
536 | for completed, name, description in cursor:
|
---|
537 | milestone = milestone_realm(id=name)
|
---|
538 | if 'MILESTONE_VIEW' in req.perm(milestone):
|
---|
539 | yield('milestone', from_utimestamp(completed),
|
---|
540 | '', (milestone, description)) # FIXME: author?
|
---|
541 |
|
---|
542 | # Attachments
|
---|
543 | for event in AttachmentModule(self.env).get_timeline_events(
|
---|
544 | req, milestone_realm, start, stop):
|
---|
545 | yield event
|
---|
546 |
|
---|
547 | def render_timeline_event(self, context, field, event):
|
---|
548 | milestone, description = event[3]
|
---|
549 | if field == 'url':
|
---|
550 | return context.href.milestone(milestone.id)
|
---|
551 | elif field == 'title':
|
---|
552 | return tag_('Milestone %(name)s completed',
|
---|
553 | name=tag.em(milestone.id))
|
---|
554 | elif field == 'description':
|
---|
555 | return format_to(self.env, None, context(resource=milestone),
|
---|
556 | description)
|
---|
557 |
|
---|
558 | # IRequestHandler methods
|
---|
559 |
|
---|
560 | def match_request(self, req):
|
---|
561 | match = re.match(r'/milestone(?:/(.+))?$', req.path_info)
|
---|
562 | if match:
|
---|
563 | if match.group(1):
|
---|
564 | req.args['id'] = match.group(1)
|
---|
565 | return True
|
---|
566 |
|
---|
567 | def process_request(self, req):
|
---|
568 | milestone_id = req.args.get('id')
|
---|
569 | req.perm('milestone', milestone_id).require('MILESTONE_VIEW')
|
---|
570 |
|
---|
571 | add_link(req, 'up', req.href.roadmap(), _('Roadmap'))
|
---|
572 |
|
---|
573 | db = self.env.get_db_cnx() # TODO: db can be removed
|
---|
574 | action = req.args.get('action', 'view')
|
---|
575 | try:
|
---|
576 | milestone = Milestone(self.env, milestone_id, db)
|
---|
577 | except ResourceNotFound:
|
---|
578 | if 'MILESTONE_CREATE' not in req.perm('milestone', milestone_id):
|
---|
579 | raise
|
---|
580 | milestone = Milestone(self.env, None, db)
|
---|
581 | milestone.name = milestone_id
|
---|
582 | action = 'edit' # rather than 'new' so that it works for POST/save
|
---|
583 |
|
---|
584 | if req.method == 'POST':
|
---|
585 | if req.args.has_key('cancel'):
|
---|
586 | if milestone.exists:
|
---|
587 | req.redirect(req.href.milestone(milestone.name))
|
---|
588 | else:
|
---|
589 | req.redirect(req.href.roadmap())
|
---|
590 | elif action == 'edit':
|
---|
591 | return self._do_save(req, db, milestone)
|
---|
592 | elif action == 'delete':
|
---|
593 | self._do_delete(req, milestone)
|
---|
594 | elif action in ('new', 'edit'):
|
---|
595 | return self._render_editor(req, db, milestone)
|
---|
596 | elif action == 'delete':
|
---|
597 | return self._render_confirm(req, db, milestone)
|
---|
598 |
|
---|
599 | if not milestone.name:
|
---|
600 | req.redirect(req.href.roadmap())
|
---|
601 |
|
---|
602 | return self._render_view(req, db, milestone)
|
---|
603 |
|
---|
604 | # Internal methods
|
---|
605 |
|
---|
606 | def _do_delete(self, req, milestone):
|
---|
607 | req.perm(milestone.resource).require('MILESTONE_DELETE')
|
---|
608 |
|
---|
609 | retarget_to = None
|
---|
610 | if req.args.has_key('retarget'):
|
---|
611 | retarget_to = req.args.get('target') or None
|
---|
612 | milestone.delete(retarget_to, req.authname)
|
---|
613 | add_notice(req, _('The milestone "%(name)s" has been deleted.',
|
---|
614 | name=milestone.name))
|
---|
615 | req.redirect(req.href.roadmap())
|
---|
616 |
|
---|
617 | def _do_save(self, req, db, milestone):
|
---|
618 | if milestone.exists:
|
---|
619 | req.perm(milestone.resource).require('MILESTONE_MODIFY')
|
---|
620 | else:
|
---|
621 | req.perm(milestone.resource).require('MILESTONE_CREATE')
|
---|
622 |
|
---|
623 | old_name = milestone.name
|
---|
624 | new_name = req.args.get('name')
|
---|
625 |
|
---|
626 | milestone.description = req.args.get('description', '')
|
---|
627 |
|
---|
628 | due = req.args.get('duedate', '')
|
---|
629 | milestone.due = due and parse_date(due, tzinfo=req.tz) or None
|
---|
630 |
|
---|
631 | completed = req.args.get('completeddate', '')
|
---|
632 | retarget_to = req.args.get('target')
|
---|
633 |
|
---|
634 | # Instead of raising one single error, check all the constraints and
|
---|
635 | # let the user fix them by going back to edit mode showing the warnings
|
---|
636 | warnings = []
|
---|
637 | def warn(msg):
|
---|
638 | add_warning(req, msg)
|
---|
639 | warnings.append(msg)
|
---|
640 |
|
---|
641 | # -- check the name
|
---|
642 | # If the name has changed, check that the milestone doesn't already
|
---|
643 | # exist
|
---|
644 | # FIXME: the whole .exists business needs to be clarified
|
---|
645 | # (#4130) and should behave like a WikiPage does in
|
---|
646 | # this respect.
|
---|
647 | try:
|
---|
648 | new_milestone = Milestone(self.env, new_name, db)
|
---|
649 | if new_milestone.name == old_name:
|
---|
650 | pass # Creation or no name change
|
---|
651 | elif new_milestone.name:
|
---|
652 | warn(_('Milestone "%(name)s" already exists, please '
|
---|
653 | 'choose another name.', name=new_milestone.name))
|
---|
654 | else:
|
---|
655 | warn(_('You must provide a name for the milestone.'))
|
---|
656 | except ResourceNotFound:
|
---|
657 | milestone.name = new_name
|
---|
658 |
|
---|
659 | # -- check completed date
|
---|
660 | if 'completed' in req.args:
|
---|
661 | completed = completed and parse_date(completed, req.tz) or None
|
---|
662 | if completed and completed > datetime.now(utc):
|
---|
663 | warn(_('Completion date may not be in the future'))
|
---|
664 | else:
|
---|
665 | completed = None
|
---|
666 | milestone.completed = completed
|
---|
667 |
|
---|
668 | if warnings:
|
---|
669 | return self._render_editor(req, db, milestone)
|
---|
670 |
|
---|
671 | # -- actually save changes
|
---|
672 | if milestone.exists:
|
---|
673 | milestone.update()
|
---|
674 | # eventually retarget opened tickets associated with the milestone
|
---|
675 | if 'retarget' in req.args and completed:
|
---|
676 | @self.env.with_transaction()
|
---|
677 | def retarget(db):
|
---|
678 | cursor = db.cursor()
|
---|
679 | cursor.execute("UPDATE ticket SET milestone=%s WHERE "
|
---|
680 | "milestone=%s and status != 'closed'",
|
---|
681 | (retarget_to, old_name))
|
---|
682 | self.env.log.info('Tickets associated with milestone %s '
|
---|
683 | 'retargeted to %s' % (old_name, retarget_to))
|
---|
684 | else:
|
---|
685 | milestone.insert()
|
---|
686 |
|
---|
687 | add_notice(req, _('Your changes have been saved.'))
|
---|
688 | req.redirect(req.href.milestone(milestone.name))
|
---|
689 |
|
---|
690 | def _render_confirm(self, req, db, milestone):
|
---|
691 | req.perm(milestone.resource).require('MILESTONE_DELETE')
|
---|
692 |
|
---|
693 | milestones = [m for m in Milestone.select(self.env, db=db)
|
---|
694 | if m.name != milestone.name
|
---|
695 | and 'MILESTONE_VIEW' in req.perm(m.resource)]
|
---|
696 | data = {
|
---|
697 | 'milestone': milestone,
|
---|
698 | 'milestone_groups': group_milestones(milestones,
|
---|
699 | 'TICKET_ADMIN' in req.perm)
|
---|
700 | }
|
---|
701 | return 'milestone_delete.html', data, None
|
---|
702 |
|
---|
703 | def _render_editor(self, req, db, milestone):
|
---|
704 | data = {
|
---|
705 | 'milestone': milestone,
|
---|
706 | 'date_hint': get_date_format_hint(),
|
---|
707 | 'datetime_hint': get_datetime_format_hint(),
|
---|
708 | 'milestone_groups': [],
|
---|
709 | }
|
---|
710 |
|
---|
711 | if milestone.exists:
|
---|
712 | req.perm(milestone.resource).require('MILESTONE_MODIFY')
|
---|
713 | milestones = [m for m in Milestone.select(self.env, db=db)
|
---|
714 | if m.name != milestone.name
|
---|
715 | and 'MILESTONE_VIEW' in req.perm(m.resource)]
|
---|
716 | data['milestone_groups'] = group_milestones(milestones,
|
---|
717 | 'TICKET_ADMIN' in req.perm)
|
---|
718 | else:
|
---|
719 | req.perm(milestone.resource).require('MILESTONE_CREATE')
|
---|
720 |
|
---|
721 | Chrome(self.env).add_wiki_toolbars(req)
|
---|
722 | return 'milestone_edit.html', data, None
|
---|
723 |
|
---|
724 | def _render_view(self, req, db, milestone):
|
---|
725 | milestone_groups = []
|
---|
726 | available_groups = []
|
---|
727 | component_group_available = False
|
---|
728 | ticket_fields = TicketSystem(self.env).get_ticket_fields()
|
---|
729 |
|
---|
730 | # collect fields that can be used for grouping
|
---|
731 | for field in ticket_fields:
|
---|
732 | if field['type'] == 'select' and field['name'] != 'milestone' \
|
---|
733 | or field['name'] in ('owner', 'reporter'):
|
---|
734 | available_groups.append({'name': field['name'],
|
---|
735 | 'label': field['label']})
|
---|
736 | if field['name'] == 'component':
|
---|
737 | component_group_available = True
|
---|
738 |
|
---|
739 | # determine the field currently used for grouping
|
---|
740 | by = None
|
---|
741 | if component_group_available:
|
---|
742 | by = 'component'
|
---|
743 | elif available_groups:
|
---|
744 | by = available_groups[0]['name']
|
---|
745 | by = req.args.get('by', by)
|
---|
746 |
|
---|
747 | tickets = get_tickets_for_milestone(self.env, db, milestone.name, by)
|
---|
748 | tickets = apply_ticket_permissions(self.env, req, tickets)
|
---|
749 | stat = get_ticket_stats(self.stats_provider, tickets)
|
---|
750 |
|
---|
751 | context = Context.from_request(req, milestone.resource)
|
---|
752 | data = {
|
---|
753 | 'context': context,
|
---|
754 | 'milestone': milestone,
|
---|
755 | 'attachments': AttachmentModule(self.env).attachment_data(context),
|
---|
756 | 'available_groups': available_groups,
|
---|
757 | 'grouped_by': by,
|
---|
758 | 'groups': milestone_groups
|
---|
759 | }
|
---|
760 | data.update(milestone_stats_data(self.env, req, stat, milestone.name))
|
---|
761 |
|
---|
762 | if by:
|
---|
763 | groups = []
|
---|
764 | for field in ticket_fields:
|
---|
765 | if field['name'] == by:
|
---|
766 | if field.has_key('options'):
|
---|
767 | groups = field['options']
|
---|
768 | else:
|
---|
769 | cursor = db.cursor()
|
---|
770 | cursor.execute("SELECT DISTINCT %s FROM ticket "
|
---|
771 | "ORDER BY %s" % (by, by))
|
---|
772 | groups = [row[0] for row in cursor]
|
---|
773 |
|
---|
774 | max_count = 0
|
---|
775 | group_stats = []
|
---|
776 |
|
---|
777 | for group in groups:
|
---|
778 | group_tickets = [t for t in tickets if t[by] == group]
|
---|
779 | if not group_tickets:
|
---|
780 | continue
|
---|
781 |
|
---|
782 | gstat = get_ticket_stats(self.stats_provider, group_tickets)
|
---|
783 | if gstat.count > max_count:
|
---|
784 | max_count = gstat.count
|
---|
785 |
|
---|
786 | group_stats.append(gstat)
|
---|
787 |
|
---|
788 | gs_dict = {'name': group}
|
---|
789 | gs_dict.update(milestone_stats_data(self.env, req, gstat,
|
---|
790 | milestone.name, by, group))
|
---|
791 | milestone_groups.append(gs_dict)
|
---|
792 |
|
---|
793 | for idx, gstat in enumerate(group_stats):
|
---|
794 | gs_dict = milestone_groups[idx]
|
---|
795 | percent = 1.0
|
---|
796 | if max_count:
|
---|
797 | percent = float(gstat.count) / float(max_count) * 100
|
---|
798 | gs_dict['percent_of_max_total'] = percent
|
---|
799 |
|
---|
800 | add_stylesheet(req, 'common/css/roadmap.css')
|
---|
801 | add_script(req, 'common/js/folding.js')
|
---|
802 | return 'milestone_view.html', data, None
|
---|
803 |
|
---|
804 | # IWikiSyntaxProvider methods
|
---|
805 |
|
---|
806 | def get_wiki_syntax(self):
|
---|
807 | return []
|
---|
808 |
|
---|
809 | def get_link_resolvers(self):
|
---|
810 | yield ('milestone', self._format_link)
|
---|
811 |
|
---|
812 | def _format_link(self, formatter, ns, name, label):
|
---|
813 | name, query, fragment = formatter.split_link(name)
|
---|
814 | return self._render_link(formatter.context, name, label,
|
---|
815 | query + fragment)
|
---|
816 |
|
---|
817 | def _render_link(self, context, name, label, extra=''):
|
---|
818 | try:
|
---|
819 | milestone = Milestone(self.env, name)
|
---|
820 | except TracError:
|
---|
821 | milestone = None
|
---|
822 | # Note: the above should really not be needed, `Milestone.exists`
|
---|
823 | # should simply be false if the milestone doesn't exist in the db
|
---|
824 | # (related to #4130)
|
---|
825 | href = context.href.milestone(name)
|
---|
826 | if milestone and milestone.exists:
|
---|
827 | if 'MILESTONE_VIEW' in context.perm(milestone.resource):
|
---|
828 | closed = milestone.is_completed and 'closed ' or ''
|
---|
829 | return tag.a(label, class_='%smilestone' % closed,
|
---|
830 | href=href + extra)
|
---|
831 | elif 'MILESTONE_CREATE' in context.perm('milestone', name):
|
---|
832 | return tag.a(label, class_='missing milestone', href=href + extra,
|
---|
833 | rel='nofollow')
|
---|
834 | return tag.a(label, class_='missing milestone')
|
---|
835 |
|
---|
836 | # IResourceManager methods
|
---|
837 |
|
---|
838 | def get_resource_realms(self):
|
---|
839 | yield 'milestone'
|
---|
840 |
|
---|
841 | def get_resource_description(self, resource, format=None, context=None,
|
---|
842 | **kwargs):
|
---|
843 | desc = resource.id
|
---|
844 | if format != 'compact':
|
---|
845 | desc = _('Milestone %(name)s', name=resource.id)
|
---|
846 | if context:
|
---|
847 | return self._render_link(context, resource.id, desc)
|
---|
848 | else:
|
---|
849 | return desc
|
---|
850 |
|
---|
851 | def resource_exists(self, resource):
|
---|
852 | """
|
---|
853 | >>> from trac.test import EnvironmentStub
|
---|
854 | >>> env = EnvironmentStub()
|
---|
855 |
|
---|
856 | >>> m1 = Milestone(env)
|
---|
857 | >>> m1.name = 'M1'
|
---|
858 | >>> m1.insert()
|
---|
859 |
|
---|
860 | >>> MilestoneModule(env).resource_exists(Resource('milestone', 'M1'))
|
---|
861 | True
|
---|
862 | >>> MilestoneModule(env).resource_exists(Resource('milestone', 'M2'))
|
---|
863 | False
|
---|
864 | """
|
---|
865 | db = self.env.get_read_db()
|
---|
866 | cursor = db.cursor()
|
---|
867 | cursor.execute("SELECT name FROM milestone WHERE name=%s",
|
---|
868 | (resource.id,))
|
---|
869 | return bool(cursor.fetchall())
|
---|
870 |
|
---|
871 | # ISearchSource methods
|
---|
872 |
|
---|
873 | def get_search_filters(self, req):
|
---|
874 | if 'MILESTONE_VIEW' in req.perm:
|
---|
875 | yield ('milestone', _('Milestones'))
|
---|
876 |
|
---|
877 | def get_search_results(self, req, terms, filters):
|
---|
878 | if not 'milestone' in filters:
|
---|
879 | return
|
---|
880 | db = self.env.get_db_cnx()
|
---|
881 | sql_query, args = search_to_sql(db, ['name', 'description'], terms)
|
---|
882 | cursor = db.cursor()
|
---|
883 | cursor.execute("SELECT name,due,completed,description "
|
---|
884 | "FROM milestone "
|
---|
885 | "WHERE " + sql_query, args)
|
---|
886 |
|
---|
887 | milestone_realm = Resource('milestone')
|
---|
888 | for name, due, completed, description in cursor:
|
---|
889 | milestone = milestone_realm(id=name)
|
---|
890 | if 'MILESTONE_VIEW' in req.perm(milestone):
|
---|
891 | dt = (completed and from_utimestamp(completed) or
|
---|
892 | due and from_utimestamp(due) or datetime.now(utc))
|
---|
893 | yield (get_resource_url(self.env, milestone, req.href),
|
---|
894 | get_resource_name(self.env, milestone), dt,
|
---|
895 | '', shorten_result(description, terms))
|
---|
896 |
|
---|
897 | # Attachments
|
---|
898 | for result in AttachmentModule(self.env).get_search_results(
|
---|
899 | req, milestone_realm, terms):
|
---|
900 | yield result
|
---|