Edgewall Software

source: tags/trac-0.12/trac/ticket/roadmap.py

Last change on this file was 9723, checked in by Christian Boos, 13 years ago

Implement MilestoneModule.resource_exists, with doctests.

  • Property svn:eol-style set to native
File size: 35.8 KB
Line 
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
18from StringIO import StringIO
19from datetime import datetime
20import re
21
22from genshi.builder import tag
23
24from trac import __version__
25from trac.attachment import AttachmentModule
26from trac.config import ExtensionOption
27from trac.core import *
28from trac.mimeview import Context
29from trac.perm import IPermissionRequestor
30from trac.resource import *
31from trac.search import ISearchSource, search_to_sql, shorten_result
32from 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
35from trac.util.text import CRLF
36from trac.util.translation import _, tag_
37from trac.ticket import Milestone, Ticket, TicketSystem, group_milestones
38from trac.ticket.query import QueryModule
39from trac.timeline.api import ITimelineEventProvider
40from trac.web import IRequestHandler, RequestDone
41from trac.web.chrome import add_link, add_notice, add_script, add_stylesheet, \
42 add_warning, Chrome, INavigationContributor
43from trac.wiki.api import IWikiSyntaxProvider
44from trac.wiki.formatter import format_to
45
46
47class 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
54class 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
126class 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
256def get_ticket_stats(provider, tickets):
257 return provider.get_ticket_group_stats([t['id'] for t in tickets])
258
259def 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
274def 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
280def 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
296class 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
492class 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
Note: See TracBrowser for help on using the repository browser.