# -*- coding: utf-8 -*- # # Copyright (C) 2003-2008 Edgewall Software # Copyright (C) 2003-2005 Jonas Borgström # 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.org/wiki/TracLicense. # # This software consists of voluntary contributions made by many # individuals. For the exact contribution history, see the revision # history and logs, available at http://trac.edgewall.org/log/. # # Author: Jonas Borgström import re import itertools from datetime import date, datetime, timedelta from genshi.builder import tag from genshi.template.text import TextTemplate from trac.config import * from trac.core import * from trac.perm import IPermissionRequestor, PermissionSystem, PermissionError from trac.resource import IResourceManager from trac.util import Ranges from trac.util.compat import set, sorted from trac.util.datefmt import utc, to_timestamp, parse_date from trac.util.text import shorten_line, obfuscate_email_address from trac.util.translation import _ from trac.wiki import IWikiSyntaxProvider, WikiParser class ITicketActionController(Interface): """Extension point interface for components willing to participate in the ticket workflow. This is mainly about controlling the changes to the ticket ''status'', though not restricted to it. """ def get_ticket_actions(req, ticket): """Return an iterable of `(weight, action)` tuples corresponding to the actions that are contributed by this component. That list may vary given the current state of the ticket and the actual request parameter. `action` is a key used to identify that particular action. (note that 'history' and 'diff' are reserved and should not be used by plugins) The actions will be presented on the page in descending order of the integer weight. The first action in the list is used as the default action. When in doubt, use a weight of 0.""" def get_all_status(): """Returns an iterable of all the possible values for the ''status'' field this action controller knows about. This will be used to populate the query options and the like. It is assumed that the initial status of a ticket is 'new' and the terminal status of a ticket is 'closed'. """ def render_ticket_action_control(req, ticket, action): """Return a tuple in the form of `(label, control, hint)` `label` is a short text that will be used when listing the action, `control` is the markup for the action control and `hint` should explain what will happen if this action is taken. This method will only be called if the controller claimed to handle the given `action` in the call to `get_ticket_actions`. Note that the radio button for the action has an `id` of `"action_%s" % action`. Any `id`s used in `control` need to be made unique. The method used in the default ITicketActionController is to use `"action_%s_something" % action`. """ def get_ticket_changes(req, ticket, action): """Return a dictionary of ticket field changes. This method must not have any side-effects because it will also be called in preview mode (`req.args['preview']` will be set, then). See `apply_action_side_effects` for that. If the latter indeed triggers some side-effects, it is advised to emit a warning (`trac.web.chrome.add_warning(req, reason)`) when this method is called in preview mode. This method will only be called if the controller claimed to handle the given `action` in the call to `get_ticket_actions`. """ def apply_action_side_effects(req, ticket, action): """Perform side effects once all changes have been made to the ticket. Multiple controllers might be involved, so the apply side-effects offers a chance to trigger a side-effect based on the given `action` after the new state of the ticket has been saved. This method will only be called if the controller claimed to handle the given `action` in the call to `get_ticket_actions`. """ class ITicketChangeListener(Interface): """Extension point interface for components that require notification when tickets are created, modified, or deleted.""" def ticket_created(ticket): """Called when a ticket is created.""" def ticket_changed(ticket, comment, author, old_values): """Called when a ticket is modified. `old_values` is a dictionary containing the previous values of the fields that have changed. """ def ticket_deleted(ticket): """Called when a ticket is deleted.""" class ITicketManipulator(Interface): """Miscellaneous manipulation of ticket workflow features.""" def prepare_ticket(req, ticket, fields, actions): """Not currently called, but should be provided for future compatibility.""" def validate_ticket(req, ticket): """Validate a ticket after it's been populated from user input. Must return a list of `(field, message)` tuples, one for each problem detected. `field` can be `None` to indicate an overall problem with the ticket. Therefore, a return value of `[]` means everything is OK.""" class TicketSystem(Component): implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager) change_listeners = ExtensionPoint(ITicketChangeListener) action_controllers = OrderedExtensionsOption('ticket', 'workflow', ITicketActionController, default='ConfigurableTicketWorkflow', include_missing=False, doc="""Ordered list of workflow controllers to use for ticket actions (''since 0.11'').""") restrict_owner = BoolOption('ticket', 'restrict_owner', 'false', """Make the owner field of tickets use a drop-down menu. See [TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List] (''since 0.9'').""") def __init__(self): self.log.debug('action controllers for ticket workflow: %r' % [c.__class__.__name__ for c in self.action_controllers]) # Public API def get_available_actions(self, req, ticket): """Returns a sorted list of available actions""" # The list should not have duplicates. actions = {} for controller in self.action_controllers: weighted_actions = controller.get_ticket_actions(req, ticket) for weight, action in weighted_actions: if action in actions: actions[action] = max(actions[action], weight) else: actions[action] = weight all_weighted_actions = [(weight, action) for action, weight in actions.items()] return [x[1] for x in sorted(all_weighted_actions, reverse=True)] def get_all_status(self): """Returns a sorted list of all the states all of the action controllers know about.""" valid_states = set() for controller in self.action_controllers: valid_states.update(controller.get_all_status()) return sorted(valid_states) def get_ticket_fields(self): """Returns the list of fields available for tickets.""" from trac.ticket import model db = self.env.get_db_cnx() fields = [] # Basic text fields for name in ('summary', 'reporter'): field = {'name': name, 'type': 'text', 'label': name.title()} field['standard'] = True fields.append(field) # Owner field, can be text or drop-down depending on configuration field = {'name': 'owner', 'label': 'Owner'} if self.restrict_owner: field['type'] = 'select' perm = PermissionSystem(self.env) field['options'] = perm.get_users_with_permission('TICKET_MODIFY') field['options'].sort() field['optional'] = True else: field['type'] = 'text' field['standard'] = True fields.append(field) # Description field = {'name': 'description', 'type': 'textarea', 'label': 'Description'} field['standard'] = True fields.append(field) # Default select and radio fields selects = [('type', model.Type), ('status', model.Status), ('priority', model.Priority), ('milestone', model.Milestone), ('component', model.Component), ('version', model.Version), ('severity', model.Severity), ('resolution', model.Resolution)] for name, cls in selects: options = [val.name for val in cls.select(self.env, db=db)] if not options: # Fields without possible values are treated as if they didn't # exist continue field = {'name': name, 'type': 'select', 'label': name.title(), 'value': self.config.get('ticket', 'default_' + name), 'options': options} if name in ('status', 'resolution'): field['type'] = 'radio' field['optional'] = True elif name in ('milestone', 'version'): field['optional'] = True field['standard'] = True fields.append(field) # Advanced text fields for name in ('keywords', 'cc', ): field = {'name': name, 'type': 'text', 'label': name.title()} field['standard'] = True fields.append(field) for field in self.get_completion_fields(): if field['name'] in [f['name'] for f in fields]: self.log.warning('Duplicate field name "%s" (ignoring)', field['name']) continue if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']): self.log.warning('Invalid name for completion field: "%s" ' '(ignoring)', field['name']) continue field['completion'] = True fields.append(field) for field in self.get_custom_fields(): if field['name'] in [f['name'] for f in fields]: self.log.warning('Duplicate field name "%s" (ignoring)', field['name']) continue if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']): self.log.warning('Invalid name for custom field: "%s" ' '(ignoring)', field['name']) continue field['custom'] = True fields.append(field) return fields def get_completion_stages(self): #from trac.ticket import model config = self.config['ticket-completion'] stages = [] for stage in [option for option, value in config.options() if '.' not in option]: stage = { 'name': stage, 'label': (config.get(stage + '.label') or stage.capitalize()), 'short_label': (config.get(stage + '.short_label') or config.get(stage + '.label') or stage.capitalize()), 'order': config.getint(stage + '.order', 0), } stages.append(stage) stages.sort(lambda x, y: cmp(x['order'], y['order'])) return stages def get_completion_fields(self): from trac.ticket import model db = self.env.get_db_cnx() fields = [] config = self.config['ticket-completion'] size_options = [val.name for val in model.RelativeSize.select(self.env, db=db)] for stage in [option for option, value in config.options() if '.' not in option]: field = { 'name': (stage + '_size'), 'stage': stage, 'field': 'size', 'type': 'select', 'label': ((config.get(stage + '.label') or stage.capitalize()) + ' Size'), 'order': config.getint(stage + '.order', 0), 'options': size_options, 'value': self.config.get('ticket', 'default_relative_size') } field['completion'] = True fields.append(field) field = { 'name': (stage + '_date'), 'stage': stage, 'field': 'date', 'type': 'date', 'label': ((config.get(stage + '.label') or stage.capitalize()) + ' Date'), 'order': config.getint(stage + '.order', 0), 'value': '' } field['completion'] = True fields.append(field) fields.sort(lambda x, y: cmp(x['order'], y['order'])) return fields def get_ticket_completion_stages(self, ticket): #from trac.ticket import model size_fields = [cf for cf in ticket.fields if cf.get('completion') and cf.get('field') == 'size'] date_fields = [cf for cf in ticket.fields if cf.get('completion') and cf.get('field') == 'date'] stages = [] for s, d in itertools.izip(size_fields, date_fields): stages.append( { 'size': s, 'date': d, 'stage':s['stage'] } ) return stages def update_sizing_stats_for_milestone_tickets(self, milestone_tickets, sizing_stats, stages, default_size): for milestone_ticket in milestone_tickets.itervalues(): for stage in stages: stage_name = stage['name'] if not milestone_ticket.has_key( stage_name ): if milestone_ticket['closed']: milestone_ticket[stage_name] = 0 else: milestone_ticket[stage_name] = default_size size = milestone_ticket[stage_name] integer_size = 0 size_defined = True try: integer_size = int(size) except: size_defined = False integer_size = 0 if size_defined: sizing_stats['total_defined_size'] += integer_size sizing_stats['count_defined_size'] += 1 if sizing_stats['min_size'] == sizing_stats['max_size'] == 0: sizing_stats['min_size'] = sizing_stats['max_size'] = integer_size else: if integer_size < sizing_stats['min_size']: sizing_stats['min_size'] = integer_size if integer_size > sizing_stats['max_size']: sizing_stats['max_size'] = integer_size if sizing_stats[stage_name]['min_size'] == sizing_stats[stage_name]['max_size'] == 0: sizing_stats[stage_name]['min_size'] = sizing_stats[stage_name]['max_size'] = integer_size else: if integer_size < sizing_stats[stage_name]['min_size']: sizing_stats[stage_name]['min_size'] = integer_size if integer_size > sizing_stats[stage_name]['max_size']: sizing_stats[stage_name]['max_size'] = integer_size sizing_stats[stage_name]['total_defined_size'] += integer_size sizing_stats[stage_name]['count_defined_size'] += 1 return sizing_stats def get_sizing_statistics_for_milestones(self, milestones_names): from trac.ticket import Milestone remaining_milestones = [m.name for m in Milestone.select(self.env, False) if m.name not in milestones_names] cursor = self.env.get_db_cnx().cursor() sizing_stats = {} sizing_stats['total_defined_size'] = 0 sizing_stats['count_defined_size'] = 0 sizing_stats['min_size'] = 0 sizing_stats['max_size'] = 0 stages = self.get_completion_stages() for stage in stages: stage_name = stage['name'] sizing_stats.setdefault( stage_name, {} ) sizing_stats[stage_name]['total_defined_size'] = 0 sizing_stats[stage_name]['count_defined_size'] = 0 sizing_stats[stage_name]['min_size'] = 0 sizing_stats[stage_name]['max_size'] = 0 milestone_tickets = {} if len(milestones_names): cursor.execute("SELECT tc.ticket,tc.stage,tc.size FROM ticket_completion tc, ticket t WHERE tc.ticket = t.id AND t.milestone IN (\'%s\')" % "\',\'".join(milestones_names)) for ticket, stage, size in cursor: milestone_tickets.setdefault( str(ticket), {} )[stage] = size cursor.execute("SELECT id,status FROM ticket WHERE ticket.milestone IN (\'%s\')" % "\',\'".join(milestones_names)) for id, status in cursor: milestone_tickets.setdefault( str(id), {} )['closed'] = status == 'closed' default_size = self.env.config.get('ticket', 'default_relative_size') self.update_sizing_stats_for_milestone_tickets( milestone_tickets, sizing_stats, stages, default_size) if sizing_stats['count_defined_size'] > 0: sizing_stats['average_defined_size'] = int( (sizing_stats['total_defined_size']/sizing_stats['count_defined_size']) + 0.5 ) else: sizing_stats['average_defined_size'] = 0 for stage in stages: stage_name = stage['name'] if sizing_stats[stage_name]['count_defined_size'] > 0: sizing_stats[stage_name]['average_defined_size'] = int( (sizing_stats[stage_name]['total_defined_size']/sizing_stats[stage_name]['count_defined_size']) + 0.5 ) else: sizing_stats[stage_name]['average_defined_size'] = sizing_stats['average_defined_size'] if sizing_stats['average_defined_size'] == 0: remaining_milestone_tickets = {} if len(remaining_milestones): cursor.execute("SELECT tc.ticket,tc.stage,tc.size FROM ticket_completion tc, ticket t WHERE tc.ticket = t.id AND t.milestone IN (\'%s\')" % "\',\'".join(remaining_milestones)) for ticket, stage, size in cursor: remaining_milestone_tickets.setdefault( str(ticket), {} )[stage] = size cursor.execute("SELECT id,status FROM ticket WHERE ticket.milestone IN (\'%s\')" % "\',\'".join(remaining_milestones)) for id, status in cursor: remaining_milestone_tickets.setdefault( str(id), {} )['closed'] = status == 'closed' self.update_sizing_stats_for_milestone_tickets(remaining_milestone_tickets,sizing_stats,stages,default_size) if sizing_stats['count_defined_size'] > 0: sizing_stats['average_defined_size'] = int( (sizing_stats['total_defined_size']/sizing_stats['count_defined_size']) + 0.5 ) else: sizing_stats['average_defined_size'] = 0 for stage in stages: stage_name = stage['name'] if sizing_stats[stage_name]['count_defined_size'] > 0: sizing_stats[stage_name]['average_defined_size'] = int( (sizing_stats[stage_name]['total_defined_size']/sizing_stats[stage_name]['count_defined_size']) + 0.5 ) else: sizing_stats[stage_name]['average_defined_size'] = sizing_stats['average_defined_size'] return sizing_stats def get_completion_stage_details_for_ticket_group(self, ticket_ids, milestone_sizing_stats, end_date): cursor = self.env.get_db_cnx().cursor() if not end_date: end_date = date.today() stages = [s['name'] for s in self.get_completion_stages()] ticket_completion_stage = {} if len(ticket_ids): cursor.execute("SELECT ticket,stage,size,date FROM ticket_completion WHERE ticket IN (\'%s\')" % "','".join(ticket_ids)) for ticket, stage, size, date_in_seconds in cursor: if stage in stages: ticket_completion_stage.setdefault( str(ticket), { 'stages':{} } )['stages'][stage] = { 'size': size, 'date_in_seconds': date_in_seconds } cursor.execute("SELECT id,status FROM ticket WHERE id IN (\'%s\')" % "','".join(ticket_ids)) for id, status in cursor: ticket_completion_stage.setdefault( str(id), { 'stages':{} } )['closed'] = status == 'closed' completion_stages = self.get_completion_stages() default_size = self.env.config.get('ticket', 'default_relative_size') for ticket in ticket_ids: ticket_completion_stage.setdefault( ticket, { 'stages':{} } ) for stage in completion_stages: stage_name = stage['name'] if not ticket_completion_stage[ticket]['stages'].has_key(stage_name): if ticket_completion_stage[ticket].has_key('closed') and ticket_completion_stage[ticket]['closed']: ticket_completion_stage[ticket]['stages'][stage_name] = { 'size': '0', 'date_in_seconds': None } else: ticket_completion_stage[ticket]['stages'][stage_name] = { 'size': default_size, 'date_in_seconds': None } ticket_stages = {} for ticket, details in ticket_completion_stage.iteritems(): for stage, values in details['stages'].iteritems(): size = values['size'] date_in_seconds = values['date_in_seconds'] # Update 'complete' based on a defined size and the date compared to today completion_date = date_in_seconds and datetime.fromtimestamp(int(date_in_seconds), utc) or None integer_size = 0 size_defined = True is_complete = False try: if size: integer_size = int(size) else: raise ValueError, "size is NoneType" if integer_size == 0: is_complete = True else: is_complete = completion_date and completion_date.date() <= end_date or False except ValueError: is_complete = False size_defined = False if milestone_sizing_stats: if milestone_sizing_stats.has_key(stage): integer_size = milestone_sizing_stats[stage]['average_defined_size'] else: integer_size = milestone_sizing_stats['average_defined_size'] else: integer_size = 0 ticket_stages.setdefault( ticket, {} )[stage] = { 'size': integer_size, 'size_defined': size_defined, 'date': completion_date, 'complete': is_complete } if size_defined: ticket_stages[ticket].setdefault( 'total_defined_size', 0 ) ticket_stages[ticket]['total_defined_size'] = integer_size + ticket_stages[ticket]['total_defined_size'] # Update the total estimated size of the ticket ticket_stages[ticket].setdefault( 'total_estimated_size', 0 ) ticket_stages[ticket]['total_estimated_size'] = integer_size + ticket_stages[ticket]['total_estimated_size'] # Update the 'done' status of the ticket (in some way this should be the same as 'closed') ticket_stages[ticket].setdefault( 'done', True ) ticket_stages[ticket]['done'] = is_complete and ticket_stages[ticket]['done'] ticket_stages[ticket].setdefault( 'last_modified', None ) if completion_date: if not ticket_stages[ticket]['last_modified'] \ or ( ticket_stages[ticket]['last_modified'] \ and completion_date > ticket_stages[ticket]['last_modified'] ): ticket_stages[ticket]['last_modified'] = completion_date ticket_stages[ticket].setdefault( 'size_defined', True ) ticket_stages[ticket]['size_defined'] = size_defined and ticket_stages[ticket]['size_defined'] return ticket_stages def get_ticket_info(self, tickets, milestone_sizing_stats, end_date): tickets_with_stages = {} ticket_ids = [t['id'] for t in tickets] stages = self.get_completion_stage_details_for_ticket_group(ticket_ids, milestone_sizing_stats, end_date) total_tickets = 0 total_relative_size = 0 done_tickets = set() done_size = 0 done_count = 0 last_modified = None for ticket in tickets: ordered_stages = self.get_ticket_completion_stages(ticket) component = ticket[ 'component' ] and ticket[ 'component' ] or '' tickets_with_stages.setdefault( component, {} )[ticket.id] = { 'ticket':ticket, 'ordered_stages':ordered_stages } total_tickets += 1 id = ticket.id total_relative_size += stages[id]['total_estimated_size'] if stages[id]['done'] and stages[id]['total_defined_size']: done_size += stages[id]['total_defined_size'] done_count += 1 done_tickets.add(id) if last_modified and stages[id]['last_modified']: if stages[id]['last_modified'] > last_modified: last_modified = stages[id]['last_modified'] if not last_modified and stages[id]['last_modified']: last_modified = stages[id]['last_modified'] ticket_info = { 'tickets_with_stages':tickets_with_stages, 'stages':stages, 'total_relative_size':total_relative_size, 'total_tickets':total_tickets, 'done_tickets':done_tickets, 'done_count':done_count, 'done_size':done_size, 'last_modified':last_modified } return ticket_info def get_iteration_overview(self, iteration): template = self.config.get('agile-trac','iteration_overview_template','') template = TextTemplate(template.encode('utf8')) data = { 'iteration': iteration, 'env': self.env, } return template.generate(**data).render('text', encoding=None).strip() def get_iteration_info(self, iteration, req, stats_provider): db = self.env.get_db_cnx() tickets = self.get_tickets_for_iteration(db, iteration.tickets) tickets = self.apply_ticket_permissions(req, tickets) changed_tickets = self.get_changed_tickets_for_iteration(db, iteration) milestones = self.get_milestones_for_tickets(tickets) milestone_sizing_stats = self.get_sizing_statistics_for_milestones(milestones) end_date = date.today() if iteration.end_date: end_date = iteration.end_date.date() ticket_info = self.get_ticket_info( tickets, milestone_sizing_stats, end_date ) stat = stats_provider.get_ticket_group_stats([t['id'] for t in tickets], milestone_sizing_stats, end_date) iteration_stats = {} if iteration.id: iteration_stats = self.iteration_stats_data(req, stat, iteration.id) return {'ticket_info':ticket_info, 'iteration_stats':iteration_stats, 'changed_tickets':changed_tickets, 'iteration_overview':self.get_iteration_overview(iteration) } def get_tickets_for_iteration(self, db, iteration_tickets): from trac.ticket import Ticket tickets = [] for ticket_id in iteration_tickets.split(): ticket = Ticket(self.env, ticket_id, db) if ticket.exists: tickets.append(ticket) return tickets def get_changed_tickets_for_iteration(self, db, iteration): from trac.ticket import Ticket start_date = to_timestamp(iteration.start_date) end_date = to_timestamp(iteration.end_date) changed_tickets = {} cursor = db.cursor() cursor.execute("SELECT ticket,time,author,field,oldvalue,newvalue " "FROM ticket_change WHERE time > %s AND time < %s " "ORDER BY ticket", (start_date, end_date)) for ticket, time, author, field, oldvalue, newvalue in cursor: if not changed_tickets.has_key( ticket ): ticket_object = Ticket(self.env, ticket, db) changed_tickets[ ticket ] = { 'ticket': ticket_object, 'changes': [] } change_time = time and datetime.fromtimestamp(int(time), utc) or None change = { 'time': change_time, 'author': author, 'field': field, 'oldvalue': oldvalue, 'newvalue': newvalue } changed_tickets[ ticket ]['changes'].append( change ) return changed_tickets def get_points_per_period(self, history_length): from trac.ticket import Iteration db = self.env.get_db_cnx() all_iterations = [i for i in Iteration.select(self.env, True, db)] # Sort by end date so the most recent iteration is displayed first all_iterations.sort(lambda x, y: cmp(y.end_date, x.end_date)) points = 0 period = timedelta(0) ignore_iterations_ending_before = self.env.config.get('agile-trac', 'ignore_iterations_ending_before', None) ignore_before_date = ignore_iterations_ending_before and parse_date(ignore_iterations_ending_before) or None date_today = date.today() iterations = [i for i in all_iterations if ((i.start_date.date() < date_today) and ( (ignore_before_date == None) or i.end_date.date() > ignore_before_date.date())) ] history_length = (history_length < len(iterations)) and history_length or len(iterations) if history_length: total_done_size = 0 total_period = timedelta(0) for idx, iteration in enumerate(iterations): if idx == history_length: break tickets = self.get_tickets_for_iteration(db, iteration.tickets) ticket_info = self.get_ticket_info( tickets, None, iteration.end_date.date() ) total_done_size += ticket_info['done_size'] end_date = iteration.end_date.date() < date_today and iteration.end_date.date() or date_today total_period += end_date - iteration.start_date.date() points = total_done_size / history_length period = total_period / history_length return { 'points': points, 'period': period } def get_milestones_for_tickets(self, tickets): milestones = set() for ticket in tickets: if ticket['milestone']: milestones.add(ticket['milestone']) else: milestones.add('') return milestones def iteration_stats_data(self, req, stat, id, grouped_by='type', group=None): def query_href(extra_args): args = {'iteration': id, grouped_by: group, 'group': 'status'} args.update(extra_args) return req.href.query(args) return {'stats': stat, 'stats_href': query_href(stat.qry_args), 'interval_hrefs': [query_href(interval['qry_args']) for interval in stat.intervals]} def apply_ticket_permissions(self, req, tickets): """Apply permissions to a set of milestone tickets as returned by get_tickets_for_milestone().""" return [t for t in tickets if 'TICKET_VIEW' in req.perm('ticket', t['id'])] def get_custom_fields(self): fields = [] config = self.config['ticket-custom'] for name in [option for option, value in config.options() if '.' not in option]: field = { 'name': name, 'type': config.get(name), 'order': config.getint(name + '.order', 0), 'label': config.get(name + '.label') or name.capitalize(), 'value': config.get(name + '.value', '') } if field['type'] == 'select' or field['type'] == 'radio': field['options'] = config.getlist(name + '.options', sep='|') if '' in field['options']: field['optional'] = True field['options'].remove('') elif field['type'] == 'textarea': field['width'] = config.getint(name + '.cols') field['height'] = config.getint(name + '.rows') fields.append(field) fields.sort(lambda x, y: cmp(x['order'], y['order'])) return fields # IPermissionRequestor methods def get_permission_actions(self): return ['TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP', 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION', ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']), ('TICKET_ADMIN', ['TICKET_CREATE', 'TICKET_MODIFY', 'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION'])] # IWikiSyntaxProvider methods def get_link_resolvers(self): return [('bug', self._format_link), ('ticket', self._format_link), ('comment', self._format_comment_link)] def get_wiki_syntax(self): yield ( # matches #... but not &#... (HTML entity) r"!?(?%s)%s" % (WikiParser.INTERTRAC_SCHEME, Ranges.RE_STR), lambda x, y, z: self._format_link(x, 'ticket', y[1:], y, z)) def _format_link(self, formatter, ns, target, label, fullmatch=None): intertrac = formatter.shorthand_intertrac_helper(ns, target, label, fullmatch) if intertrac: return intertrac try: link, params, fragment = formatter.split_link(target) r = Ranges(link) if len(r) == 1: num = r.a ticket = formatter.resource('ticket', num) from trac.ticket.model import Ticket if Ticket.id_is_valid(num): # TODO: watch #6436 and when done, attempt to retrieve # ticket directly (try: Ticket(self.env, num) ...) cursor = formatter.db.cursor() cursor.execute("SELECT type,summary,status,resolution " "FROM ticket WHERE id=%s", (str(num),)) for type, summary, status, resolution in cursor: title = self.format_summary(summary, status, resolution, type) href = formatter.href.ticket(num) + params + fragment return tag.a(label, class_='%s ticket' % status, title=title, href=href) else: href = formatter.href.ticket(num) return tag.a(label, class_='missing ticket', href=href, rel="nofollow") else: ranges = str(r) if params: params = '&' + params[1:] return tag.a(label, title='Tickets '+ranges, href=formatter.href.query(id=ranges) + params) except ValueError: pass return tag.a(label, class_='missing ticket') def _format_comment_link(self, formatter, ns, target, label): resource = None if ':' in target: elts = target.split(':') if len(elts) == 3: cnum, realm, id = elts if cnum != 'description' and cnum and not cnum[0].isdigit(): realm, id, cnum = elts # support old comment: style resource = formatter.resource(realm, id) else: resource = formatter.resource cnum = target if resource: href = "%s#comment:%s" % (formatter.href.ticket(resource.id), cnum) title = _("Comment %(cnum)s for Ticket #%(id)s", cnum=cnum, id=resource.id) return tag.a(label, href=href, title=title) else: return label # IResourceManager methods def get_resource_realms(self): yield 'ticket' def get_resource_description(self, resource, format=None, context=None, **kwargs): if format == 'compact': return '#%s' % resource.id elif format == 'summary': from trac.ticket.model import Ticket ticket = Ticket(self.env, resource.id) args = [ticket[f] for f in ('summary', 'status', 'resolution', 'type')] return self.format_summary(*args) return _("Ticket #%(shortname)s", shortname=resource.id) def format_summary(self, summary, status=None, resolution=None, type=None): summary = shorten_line(summary) if type: summary = type + ': ' + summary if status: if status == 'closed' and resolution: status += ': ' + resolution return "%s (%s)" % (summary, status) else: return summary