Mercurial > genshi > mirror
diff examples/trac/trac/ticket/roadmap.py @ 39:93b4dcbafd7b trunk
Copy Trac to main branch.
author | cmlenz |
---|---|
date | Mon, 03 Jul 2006 18:53:27 +0000 |
parents | |
children |
line wrap: on
line diff
new file mode 100644 --- /dev/null +++ b/examples/trac/trac/ticket/roadmap.py @@ -0,0 +1,522 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2004-2006 Edgewall Software +# Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de> +# Copyright (C) 2006 Christian Boos <cboos@neuf.fr> +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz <cmlenz@gmx.de> + +import re +from time import localtime, strftime, time + +from trac import __version__ +from trac.core import * +from trac.perm import IPermissionRequestor +from trac.util.datefmt import format_date, format_datetime, parse_date, \ + pretty_timedelta +from trac.util.text import shorten_line, CRLF, to_unicode +from trac.util.markup import html, unescape, Markup +from trac.ticket import Milestone, Ticket, TicketSystem +from trac.Timeline import ITimelineEventProvider +from trac.web import IRequestHandler +from trac.web.chrome import add_link, add_stylesheet, INavigationContributor +from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider + + +def get_tickets_for_milestone(env, db, milestone, field='component'): + cursor = db.cursor() + fields = TicketSystem(env).get_ticket_fields() + if field in [f['name'] for f in fields if not f.get('custom')]: + cursor.execute("SELECT id,status,%s FROM ticket WHERE milestone=%%s " + "ORDER BY %s" % (field, field), (milestone,)) + else: + cursor.execute("SELECT id,status,value FROM ticket LEFT OUTER " + "JOIN ticket_custom ON (id=ticket AND name=%s) " + "WHERE milestone=%s ORDER BY value", (field, milestone)) + tickets = [] + for tkt_id, status, fieldval in cursor: + tickets.append({'id': tkt_id, 'status': status, field: fieldval}) + return tickets + +def get_query_links(req, milestone, grouped_by='component', group=None): + q = {} + if not group: + q['all_tickets'] = req.href.query(milestone=milestone) + q['active_tickets'] = req.href.query( + milestone=milestone, status=('new', 'assigned', 'reopened')) + q['closed_tickets'] = req.href.query( + milestone=milestone, status='closed') + else: + q['all_tickets'] = req.href.query( + {grouped_by: group}, milestone=milestone) + q['active_tickets'] = req.href.query( + {grouped_by: group}, milestone=milestone, + status=('new', 'assigned', 'reopened')) + q['closed_tickets'] = req.href.query( + {grouped_by: group}, milestone=milestone, status='closed') + return q + +def calc_ticket_stats(tickets): + total_cnt = len(tickets) + active = [ticket for ticket in tickets if ticket['status'] != 'closed'] + active_cnt = len(active) + closed_cnt = total_cnt - active_cnt + + percent_active, percent_closed = 0, 0 + if total_cnt > 0: + percent_active = round(float(active_cnt) / float(total_cnt) * 100) + percent_closed = round(float(closed_cnt) / float(total_cnt) * 100) + if percent_active + percent_closed > 100: + percent_closed -= 1 + + return { + 'total_tickets': total_cnt, + 'active_tickets': active_cnt, + 'percent_active': percent_active, + 'closed_tickets': closed_cnt, + 'percent_closed': percent_closed + } + +def milestone_to_hdf(env, db, req, milestone): + safe_name = None + if milestone.exists: + safe_name = milestone.name.replace('/', '%2F') + hdf = {'name': milestone.name, + 'href': req.href.milestone(safe_name)} + if milestone.description: + hdf['description_source'] = milestone.description + hdf['description'] = wiki_to_html(milestone.description, env, req, db) + if milestone.due: + hdf['due'] = milestone.due + hdf['due_date'] = format_date(milestone.due) + hdf['due_delta'] = pretty_timedelta(milestone.due + 86400) + hdf['late'] = milestone.is_late + if milestone.completed: + hdf['completed'] = milestone.completed + hdf['completed_date'] = format_datetime(milestone.completed) + hdf['completed_delta'] = pretty_timedelta(milestone.completed) + return hdf + +def _get_groups(env, db, by='component'): + for field in TicketSystem(env).get_ticket_fields(): + if field['name'] == by: + if field.has_key('options'): + return field['options'] + else: + cursor = db.cursor() + cursor.execute("SELECT DISTINCT %s FROM ticket ORDER BY %s" + % (by, by)) + return [row[0] for row in cursor] + return [] + + +class RoadmapModule(Component): + + implements(INavigationContributor, IPermissionRequestor, IRequestHandler) + + # INavigationContributor methods + + def get_active_navigation_item(self, req): + return 'roadmap' + + def get_navigation_items(self, req): + if not req.perm.has_permission('ROADMAP_VIEW'): + return + yield ('mainnav', 'roadmap', + html.a('Roadmap', href=req.href.roadmap(), accesskey=3)) + + # IPermissionRequestor methods + + def get_permission_actions(self): + return ['ROADMAP_VIEW'] + + # IRequestHandler methods + + def match_request(self, req): + return re.match(r'/roadmap/?', req.path_info) is not None + + def process_request(self, req): + req.perm.assert_permission('ROADMAP_VIEW') + req.hdf['title'] = 'Roadmap' + + showall = req.args.get('show') == 'all' + req.hdf['roadmap.showall'] = showall + + db = self.env.get_db_cnx() + milestones = [milestone_to_hdf(self.env, db, req, m) + for m in Milestone.select(self.env, showall, db)] + req.hdf['roadmap.milestones'] = milestones + + for idx, milestone in enumerate(milestones): + milestone_name = unescape(milestone['name']) # Kludge + prefix = 'roadmap.milestones.%d.' % idx + tickets = get_tickets_for_milestone(self.env, db, milestone_name, + 'owner') + req.hdf[prefix + 'stats'] = calc_ticket_stats(tickets) + for k, v in get_query_links(req, milestone_name).items(): + req.hdf[prefix + 'queries.' + k] = v + milestone['tickets'] = tickets # for the iCalendar view + + if req.args.get('format') == 'ics': + self.render_ics(req, db, milestones) + return + + add_stylesheet(req, 'common/css/roadmap.css') + + # FIXME should use the 'webcal:' scheme, probably + username = None + if req.authname and req.authname != 'anonymous': + username = req.authname + icshref = req.href.roadmap(show=req.args.get('show'), + user=username, format='ics') + add_link(req, 'alternate', icshref, 'iCalendar', 'text/calendar', 'ics') + + return 'roadmap.cs', None + + # Internal methods + + def render_ics(self, req, db, milestones): + req.send_response(200) + req.send_header('Content-Type', 'text/calendar;charset=utf-8') + req.end_headers() + + from trac.ticket import Priority + priorities = {} + for priority in Priority.select(self.env): + priorities[priority.name] = float(priority.value) + def get_priority(ticket): + value = priorities.get(ticket['priority']) + if value: + return int(value * 9 / len(priorities)) + + def get_status(ticket): + status = ticket['status'] + if status == 'new' or status == 'reopened' and not ticket['owner']: + return 'NEEDS-ACTION' + elif status == 'assigned' or status == 'reopened': + return 'IN-PROCESS' + elif status == 'closed': + if ticket['resolution'] == 'fixed': return 'COMPLETED' + else: return 'CANCELLED' + else: return '' + + def write_prop(name, value, params={}): + text = ';'.join([name] + [k + '=' + v for k, v in params.items()]) \ + + ':' + '\\n'.join(re.split(r'[\r\n]+', value)) + firstline = 1 + while text: + if not firstline: text = ' ' + text + else: firstline = 0 + req.write(text[:75] + CRLF) + text = text[75:] + + def write_date(name, value, params={}): + params['VALUE'] = 'DATE' + write_prop(name, strftime('%Y%m%d', value), params) + + def write_utctime(name, value, params={}): + write_prop(name, strftime('%Y%m%dT%H%M%SZ', value), params) + + host = req.base_url[req.base_url.find('://') + 3:] + user = req.args.get('user', 'anonymous') + + write_prop('BEGIN', 'VCALENDAR') + write_prop('VERSION', '2.0') + write_prop('PRODID', '-//Edgewall Software//NONSGML Trac %s//EN' + % __version__) + write_prop('METHOD', 'PUBLISH') + write_prop('X-WR-CALNAME', + self.config.get('project', 'name') + ' - Roadmap') + for milestone in milestones: + uid = '<%s/milestone/%s@%s>' % (req.base_path, milestone['name'], + host) + if milestone.has_key('due'): + write_prop('BEGIN', 'VEVENT') + write_prop('UID', uid) + write_date('DTSTAMP', localtime(milestone['due'])) + write_date('DTSTART', localtime(milestone['due'])) + write_prop('SUMMARY', 'Milestone %s' % milestone['name']) + write_prop('URL', req.base_url + '/milestone/' + + milestone['name']) + if milestone.has_key('description_source'): + write_prop('DESCRIPTION', milestone['description_source']) + write_prop('END', 'VEVENT') + for tkt_id in [ticket['id'] for ticket in milestone['tickets'] + if ticket['owner'] == user]: + ticket = Ticket(self.env, tkt_id) + write_prop('BEGIN', 'VTODO') + if milestone.has_key('due'): + write_prop('RELATED-TO', uid) + write_date('DUE', localtime(milestone['due'])) + write_prop('SUMMARY', 'Ticket #%i: %s' % (ticket.id, + ticket['summary'])) + write_prop('URL', req.abs_href.ticket(ticket.id)) + write_prop('DESCRIPTION', ticket['description']) + priority = get_priority(ticket) + if priority: + write_prop('PRIORITY', unicode(priority)) + write_prop('STATUS', get_status(ticket)) + if ticket['status'] == 'closed': + cursor = db.cursor() + cursor.execute("SELECT time FROM ticket_change " + "WHERE ticket=%s AND field='status' " + "ORDER BY time desc LIMIT 1", + (ticket.id,)) + row = cursor.fetchone() + if row: + write_utctime('COMPLETED', localtime(row[0])) + write_prop('END', 'VTODO') + write_prop('END', 'VCALENDAR') + + +class MilestoneModule(Component): + + implements(INavigationContributor, IPermissionRequestor, IRequestHandler, + ITimelineEventProvider, IWikiSyntaxProvider) + + # INavigationContributor methods + + def get_active_navigation_item(self, req): + return 'roadmap' + + def get_navigation_items(self, req): + return [] + + # IPermissionRequestor methods + + def get_permission_actions(self): + actions = ['MILESTONE_CREATE', 'MILESTONE_DELETE', 'MILESTONE_MODIFY', + 'MILESTONE_VIEW'] + return actions + [('MILESTONE_ADMIN', actions), + ('ROADMAP_ADMIN', actions)] + + # ITimelineEventProvider methods + + def get_timeline_filters(self, req): + if req.perm.has_permission('MILESTONE_VIEW'): + yield ('milestone', 'Milestones') + + def get_timeline_events(self, req, start, stop, filters): + if 'milestone' in filters: + format = req.args.get('format') + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT completed,name,description FROM milestone " + "WHERE completed>=%s AND completed<=%s", + (start, stop,)) + for completed, name, description in cursor: + title = Markup('Milestone <em>%s</em> completed', name) + if format == 'rss': + href = req.abs_href.milestone(name) + message = wiki_to_html(description, self.env, req, db, + absurls=True) + else: + href = req.href.milestone(name) + message = wiki_to_oneliner(description, self.env, db, + shorten=True) + yield 'milestone', href, title, completed, None, message or '--' + + # IRequestHandler methods + + def match_request(self, req): + import re, urllib + match = re.match(r'/milestone(?:/(.+))?', req.path_info) + if match: + if match.group(1): + req.args['id'] = match.group(1) + return True + + def process_request(self, req): + req.perm.assert_permission('MILESTONE_VIEW') + + add_link(req, 'up', req.href.roadmap(), 'Roadmap') + + db = self.env.get_db_cnx() + milestone = Milestone(self.env, req.args.get('id'), db) + action = req.args.get('action', 'view') + + if req.method == 'POST': + if req.args.has_key('cancel'): + if milestone.exists: + safe_name = milestone.name.replace('/', '%2F') + req.redirect(req.href.milestone(safe_name)) + else: + req.redirect(req.href.roadmap()) + elif action == 'edit': + self._do_save(req, db, milestone) + elif action == 'delete': + self._do_delete(req, db, milestone) + elif action in ('new', 'edit'): + self._render_editor(req, db, milestone) + elif action == 'delete': + self._render_confirm(req, db, milestone) + else: + self._render_view(req, db, milestone) + + add_stylesheet(req, 'common/css/roadmap.css') + return 'milestone.cs', None + + # Internal methods + + def _do_delete(self, req, db, milestone): + req.perm.assert_permission('MILESTONE_DELETE') + + retarget_to = None + if req.args.has_key('retarget'): + retarget_to = req.args.get('target') + milestone.delete(retarget_to, req.authname) + db.commit() + req.redirect(req.href.roadmap()) + + def _do_save(self, req, db, milestone): + if milestone.exists: + req.perm.assert_permission('MILESTONE_MODIFY') + else: + req.perm.assert_permission('MILESTONE_CREATE') + + if not req.args.has_key('name'): + raise TracError('You must provide a name for the milestone.', + 'Required Field Missing') + milestone.name = req.args.get('name') + + due = req.args.get('duedate', '') + try: + milestone.due = due and parse_date(due) or 0 + except ValueError, e: + raise TracError(to_unicode(e), 'Invalid Date Format') + if req.args.has_key('completed'): + completed = req.args.get('completeddate', '') + try: + milestone.completed = completed and parse_date(completed) or 0 + except ValueError, e: + raise TracError(to_unicode(e), 'Invalid Date Format') + if milestone.completed > time(): + raise TracError('Completion date may not be in the future', + 'Invalid Completion Date') + retarget_to = req.args.get('target') + if req.args.has_key('retarget'): + cursor = db.cursor() + cursor.execute("UPDATE ticket SET milestone=%s WHERE " + "milestone=%s and status != 'closed'", + (retarget_to, milestone.name)) + self.env.log.info('Tickets associated with milestone %s ' + 'retargeted to %s' % + (milestone.name, retarget_to)) + else: + milestone.completed = 0 + + milestone.description = req.args.get('description', '') + + if milestone.exists: + milestone.update() + else: + milestone.insert() + db.commit() + + safe_name = milestone.name.replace('/', '%2F') + req.redirect(req.href.milestone(safe_name)) + + def _render_confirm(self, req, db, milestone): + req.perm.assert_permission('MILESTONE_DELETE') + + req.hdf['title'] = 'Milestone %s' % milestone.name + req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone) + req.hdf['milestone.mode'] = 'delete' + + for idx,other in enumerate(Milestone.select(self.env, False, db)): + if other.name == milestone.name: + continue + req.hdf['milestones.%d' % idx] = other.name + + def _render_editor(self, req, db, milestone): + if milestone.exists: + req.perm.assert_permission('MILESTONE_MODIFY') + req.hdf['title'] = 'Milestone %s' % milestone.name + req.hdf['milestone.mode'] = 'edit' + req.hdf['milestones'] = [m.name for m in + Milestone.select(self.env) + if m.name != milestone.name] + else: + req.perm.assert_permission('MILESTONE_CREATE') + req.hdf['title'] = 'New Milestone' + req.hdf['milestone.mode'] = 'new' + + from trac.util.datefmt import get_date_format_hint, \ + get_datetime_format_hint + req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone) + req.hdf['milestone.date_hint'] = get_date_format_hint() + req.hdf['milestone.datetime_hint'] = get_datetime_format_hint() + req.hdf['milestone.datetime_now'] = format_datetime() + + def _render_view(self, req, db, milestone): + req.hdf['title'] = 'Milestone %s' % milestone.name + req.hdf['milestone.mode'] = 'view' + + req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone) + + available_groups = [] + component_group_available = False + for field in TicketSystem(self.env).get_ticket_fields(): + if field['type'] == 'select' and field['name'] != 'milestone' \ + or field['name'] == 'owner': + available_groups.append({'name': field['name'], + 'label': field['label']}) + if field['name'] == 'component': + component_group_available = True + req.hdf['milestone.stats.available_groups'] = available_groups + + if component_group_available: + by = req.args.get('by', 'component') + else: + by = req.args.get('by', available_groups[0]['name']) + req.hdf['milestone.stats.grouped_by'] = by + + tickets = get_tickets_for_milestone(self.env, db, milestone.name, by) + stats = calc_ticket_stats(tickets) + req.hdf['milestone.stats'] = stats + for key, value in get_query_links(req, milestone.name).items(): + req.hdf['milestone.queries.' + key] = value + + groups = _get_groups(self.env, db, by) + group_no = 0 + max_percent_total = 0 + for group in groups: + group_tickets = [t for t in tickets if t[by] == group] + if not group_tickets: + continue + prefix = 'milestone.stats.groups.%s' % group_no + req.hdf['%s.name' % prefix] = group + percent_total = 0 + if len(tickets) > 0: + percent_total = float(len(group_tickets)) / float(len(tickets)) + if percent_total > max_percent_total: + max_percent_total = percent_total + req.hdf['%s.percent_total' % prefix] = percent_total * 100 + stats = calc_ticket_stats(group_tickets) + req.hdf[prefix] = stats + for key, value in \ + get_query_links(req, milestone.name, by, group).items(): + req.hdf['%s.queries.%s' % (prefix, key)] = value + group_no += 1 + req.hdf['milestone.stats.max_percent_total'] = max_percent_total * 100 + + # IWikiSyntaxProvider methods + + def get_wiki_syntax(self): + return [] + + def get_link_resolvers(self): + yield ('milestone', self._format_link) + + def _format_link(self, formatter, ns, name, label): + return html.A(label, href=formatter.href.milestone(name), + class_='milestone')