# -*- coding: utf-8 -*- import pkg_resources from datetime import date, datetime from genshi.builder import tag from trac.core import * from trac.perm import PermissionSystem from trac.env import IEnvironmentSetupParticipant from trac.config import Configuration from trac.ticket.api import ITicketActionController, TicketSystem from trac.ticket.model import group_iterations, Iteration from trac.util import get_reporter_id from trac.util.compat import set from trac.util.datefmt import to_timestamp, utc, get_date_format_hint, format_date, parse_date from trac.util.translation import _ # -- Utilities for the AgileTicketWorkflow def parse_workflow_config(rawactions): """Given a list of options from [ticket-workflow]""" actions = {} for option, value in rawactions: parts = option.split('.') action = parts[0] if action not in actions: actions[action] = {} if len(parts) == 1: # Base name, of the syntax: old,states,here -> newstate try: oldstates, newstate = [x.strip() for x in value.split('->')] except ValueError: raise Exception('Bad option "%s"' % (option, )) # 500, no _ actions[action]['newstate'] = newstate actions[action]['oldstates'] = oldstates else: action, attribute = option.split('.') actions[action][attribute] = value # Fill in the defaults for every action, and normalize them to the desired # types for action, attributes in actions.items(): # Default the 'name' attribute to the name used in the ini file if 'name' not in attributes: attributes['name'] = action # If not specified, an action is not the default. if 'default' not in attributes: attributes['default'] = 0 else: attributes['default'] = int(attributes['default']) # If operations are not specified, that means no operations if 'operations' not in attributes: attributes['operations'] = [] else: attributes['operations'] = [a.strip() for a in attributes['operations'].split(',')] # If no permissions are specified, then no permissions are needed if 'permissions' not in attributes: attributes['permissions'] = [] else: attributes['permissions'] = [a.strip() for a in attributes['permissions'].split(',')] # Normalize the oldstates attributes['oldstates'] = [x.strip() for x in attributes['oldstates'].split(',')] return actions def get_workflow_config(config): """Usually passed self.config, this will return the parsed ticket-workflow section. """ raw_actions = list(config.options('ticket-workflow')) actions = parse_workflow_config(raw_actions) return actions def load_config_snippet(config, section, filename): filename = pkg_resources.resource_filename('agiletrac', 'conf/%s' % filename) new_config = Configuration(filename) for name, value in new_config.options(section): config.set(section, name, value) def load_workflow_config_snippet(config, filename): """Loads the ticket-workflow section from the given file (expected to be in the 'workflows' tree) into the provided config. """ filename = pkg_resources.resource_filename('agiletrac', 'conf/%s' % filename) new_config = Configuration(filename) for name, value in new_config.options('ticket-workflow'): config.set('ticket-workflow', name, value) for name, value in new_config.options('milestone-groups'): config.set('milestone-groups', name, value) def reset_resolution_and_sizes_to_default(stages, default_size, ticket, updated): for stage in stages: name = stage['size'].get('name') updated[name] = default_size updated['resolution'] = '' updated['status'] = 'reopened' def update_ticket_status(req, ticket, stages, action, updated): updated['status'] = 'new' date_today = date.today() done = True started = False fully_sized = True partially_sized = False for stage in stages: date_name = stage['date'].get('name') date_arg_name = action + '_' + date_name # We want to make sure we don't silently overwrite a date field because it was already # completed and there is therefore no element of that name on the web page. Therefore # first assign the current value and overwrite that if the element was present new_date = ticket[date_name] if new_date and isinstance(new_date, basestring): new_date = parse_date(new_date) formatted_date = new_date and format_date(new_date) if req.args.has_key(date_arg_name): new_formatted_date = req.args.get(date_arg_name) new_date = new_formatted_date and parse_date(new_formatted_date, tzinfo=req.tz) or 0 if new_date and new_date.date() > date_today: new_formatted_date = format_date(datetime.now(req.tz)) if new_formatted_date != formatted_date: updated[date_name] = new_formatted_date size_name = stage['size'].get('name') size_arg_name = action + '_' + size_name # We want to make sure we don't silently overwrite a size field because it was already # completed and there is therefore no element of that name on the web page. Therefore # first assign the current value and overwrite that if the element was present new_size = ticket[ size_name ] if req.args.has_key(size_arg_name): new_size = req.args.get(size_arg_name) updated[size_name] = new_size try: #size = ticket[ stage['size'].get('name') ] integer_size = int(new_size) partially_sized = True if integer_size > 0: if new_date: started = True else: done = False except ValueError: done = False fully_sized = False if done: updated['status'] = 'closed' updated['resolution'] = 'DONE' elif fully_sized: updated['status'] = 'sized' elif partially_sized: updated['status'] = 'partially_sized' elif ticket['owner']: updated['status'] = 'assigned' else: updated['status'] = 'new' def ticket_never_progressed(stages, ticket): never_progressed = True for stage in stages: date_name = stage['date'].get('name') date = ticket[ date_name ] and ticket[ date_name ] or None if date: never_progressed = False return never_progressed class AgileTicketWorkflow(Component): """Ticket action controller which provides actions according to a workflow defined in the TracIni configuration file, inside the [ticket-workflow] section. """ def __init__(self, *args, **kwargs): Component.__init__(self, *args, **kwargs) self.actions = get_workflow_config(self.config) if not '_reset' in self.actions: # Special action that gets enabled if the current status no longer # exists, as no other action can then change its state. (#5307) self.actions['_reset'] = { 'default': 0, 'name': 'reset', 'newstate': 'new', 'oldstates': [], # Will not be invoked unless needed 'operations': ['reset_workflow'], 'permissions': []} self.log.debug('Workflow actions at initialization: %s\n' % str(self.actions)) implements(ITicketActionController, IEnvironmentSetupParticipant) # IEnvironmentSetupParticipant methods def environment_created(self): pass def __workflow_needs_updated_for_iterations( self ): return self.config.get( 'ticket-workflow', 'add_to_iteration', None ) == None def __workflow_is_not_agile_workflow( self ): return self.config.get('ticket', 'workflow') != 'AgileTicketWorkflow' def environment_needs_upgrade(self, db): """The environment needs an upgrade if the workflow is not the agile workflow. """ return self.__workflow_is_not_agile_workflow() or self.__workflow_needs_updated_for_iterations() def upgrade_environment(self, db): if self.__workflow_needs_updated_for_iterations(): if 'ticket-workflow' in self.config.sections(): for name, value in self.config.options('ticket-workflow'): self.config.remove('ticket-workflow', name) load_config_snippet( self.config, 'ticket-workflow', 'agile_conf.ini') self.config.save() self.actions = get_workflow_config(self.config) info_message = """ ==== Upgrade Notice ==== The ticket-workflow has been updated to include adding to and removing from iterations """ else: """Insert a [ticket-workflow] section using the agile_workflow""" if 'ticket-workflow' in self.config.sections(): for name, value in self.config.options('ticket-workflow'): self.config.remove('ticket-workflow', name) if 'milestone-groups' in self.config.sections(): for name, value in self.config.options('milestone-groups'): self.config.remove('milestone-groups', name) load_config_snippet( self.config, 'ticket-workflow', 'agile_conf.ini') load_config_snippet( self.config, 'milestone-groups', 'agile_conf.ini') self.config.set('ticket', 'workflow', 'AgileTicketWorkflow') self.config.save() self.actions = get_workflow_config(self.config) info_message = """ ==== Upgrade Notice ==== The ticket-workflow and milestone-groups have been replaced with ones suitable for the agile-trac plugin """ self.log.info(info_message.replace('\n', ' ').replace('==', '')) print info_message # ITicketActionController methods def get_ticket_actions(self, req, ticket): """Returns a list of (weight, action) tuples that are valid for this request and this ticket.""" # Get the list of actions that can be performed # Determine the current status of this ticket. If this ticket is in # the process of being modified, we need to base our information on the # pre-modified state so that we don't try to do two (or more!) steps at # once and get really confused. if 'status' in ticket._old: status = ticket._old['status'] else: status = ticket['status'] status = status or 'new' in_iterations = len( TicketSystem(self.env).get_ticket_iterations( ticket.id ) ) > 0 allowed_actions = [] for action_name, action_info in self.actions.items(): oldstates = action_info['oldstates'] if oldstates == ['*'] or status in oldstates: # This action is valid in this state. Check permissions. allowed = 0 required_perms = action_info['permissions'] if required_perms: for permission in required_perms: if permission in req.perm(ticket.resource): allowed = 1 break else: allowed = 1 if allowed: if action_name == 'remove_from_iteration': if in_iterations: allowed_actions.append((action_info['default'], action_name)) else: allowed_actions.append((action_info['default'], action_name)) if not (status in ['new', 'closed'] or \ status in TicketSystem(self.env).get_all_status()) \ and 'TICKET_ADMIN' in req.perm(ticket.resource): # State no longer exists - add a 'reset' action if admin. allowed_actions.append((0, '_reset')) return allowed_actions def get_all_status(self): """Return a list of all states described by the configuration. """ all_status = set() for action_name, action_info in self.actions.items(): all_status.update(action_info['oldstates']) all_status.add(action_info['newstate']) all_status.discard('*') return all_status def render_ticket_action_control(self, req, ticket, action): from trac.ticket import model self.log.debug('render_ticket_action_control: action "%s"' % action) this_action = self.actions[action] status = this_action['newstate'] operations = this_action['operations'] control_label = this_action['name'] control = [] # default to nothing hints = [] if 'reset_workflow' in operations: control.append(tag("from invalid state ")) hints.append(_("Current state no longer exists")) if 'del_owner' in operations: hints.append(_("The ticket will be disowned")) if 'set_owner' in operations: id = 'action_%s_reassign_owner' % action selected_owner = req.args.get(id, req.authname) if this_action.has_key('set_owner'): owners = [x.strip() for x in this_action['set_owner'].split(',')] elif self.config.getbool('ticket', 'restrict_owner'): perm = PermissionSystem(self.env) owners = perm.get_users_with_permission('TICKET_MODIFY') owners.sort() else: owners = None if owners == None: owner = req.args.get(id, req.authname) control.append(tag(['to ', tag.input(type='text', id=id, name=id, value=owner)])) hints.append(_("The owner will change")) elif len(owners) == 1: control.append(tag('to %s ' % owners[0])) if ticket['owner'] != owners[0]: hints.append(_("The owner will change to %s") % owners[0]) else: control.append(tag([_("to "), tag.select( [tag.option(x, selected=(x == selected_owner or None)) for x in owners], id=id, name=id)])) hints.append(_("The owner will change")) if 'set_owner_to_self' in operations and \ ticket['owner'] != req.authname: hints.append(_("The owner will change to %s") % req.authname) if 'del_resolution' in operations: if ticket['resolution'] != 'DONE': hints.append("Next status will be reopened") else: stages = TicketSystem(self.env).get_ticket_completion_stages(ticket) outer_control = tag.ul() append_controls = False for stage in stages: date_name = stage['date'].get('name') date = ticket[ date_name ] and ticket[ date_name ] or None if date: if isinstance(date, basestring): date = parse_date(date) formatted_date = format_date(date) date_hint = 'Previously completed on ' + formatted_date label = 'Reopen ' + stage['date'].get('stage') id = action + '_' + date_name outer_control( tag.li([ tag.input( type='checkbox', size=len(get_date_format_hint()), id=id, name=id ), tag.label( label, for_=id ), tag.span( date_hint, class_='hint' ) ]) ) append_controls = True if append_controls: control.append( outer_control ) if 'set_resolution' in operations: if this_action.has_key('set_resolution'): resolutions = [x.strip() for x in this_action['set_resolution'].split(',')] else: resolutions = [val.name for val in model.Resolution.select(self.env)] assert(resolutions) if len(resolutions) == 1: control.append(tag('as %s' % resolutions[0])) hints.append(_("The resolution will be set to %s") % resolutions[0]) else: id = 'action_%s_resolve_resolution' % action selected_option = req.args.get(id, self.config.get('ticket', 'default_resolution')) control.append(tag(['as ', tag.select( [tag.option(x, selected=(x == selected_option or None)) for x in resolutions], id=id, name=id)])) hints.append(_("The resolution will be set")) if 'set_completion' in operations: # Consider adding a 'started' status for stories that have one or more stages # complete, but are not yet DONE stages = TicketSystem(self.env).get_ticket_completion_stages(ticket) date_picker_img = req.chrome['htdocs_location'] + 'datepick.png' outer_control = tag.ul() append_controls = False for stage in stages: size = ticket[ stage['size'].get('name') ] try: if size: integer_size = int(size) if integer_size > 0: label = stage['date'].get('label') + ' ' name = stage['date'].get('name') date = ticket[ name ] and ticket[ name ] or None if isinstance(date, basestring): date = parse_date(date) id = action + '_' + name date_hint = 'Format: ' + get_date_format_hint() date_chooser_id = 'dp_' + name outer_control( tag.li([ label, tag.input( type='text', size=len(get_date_format_hint()), id=id, name=id, value=(date and format_date(date))), tag.a( tag.img( src=date_picker_img, name=id, alt='Choose Date' ), href='#', class_='dp-choose-date', id=date_chooser_id, name=id ) ], tag.span(date_hint, class_='hint') ) ) append_controls = True except ValueError: continue if append_controls: control.append( outer_control ) if 'set_size' in operations: stages = TicketSystem(self.env).get_ticket_completion_stages(ticket) sizes = [val.name for val in model.RelativeSize.select(self.env)] outer_control = tag.ul() append_controls = False for stage in stages: date_name = stage['date'].get('name') date = ticket[ date_name ] and ticket[ date_name ] or None if not date: size_field = stage['size'] label = size_field.get('label') + ' ' name = size_field.get('name') initial_value = ticket[name] id = action + '_' + name outer_control(tag.li([label, tag.select( [tag.option(x, selected=(x == initial_value or None )) for x in sizes], id=id, name=id)])) append_controls = True if append_controls: control.append( outer_control ) if 'add_to_iteration' in operations: control_label = 'add to iteration' current_iterations = TicketSystem(self.env).get_ticket_iterations( ticket.id ) iterations = [ i for i in model.Iteration.select(self.env, True) if ( str(i.id) not in current_iterations and 'ITERATION_VIEW' in req.perm(i.resource) ) ] iterations.sort(lambda x, y: cmp(y.end_date, x.end_date)) groups = group_iterations( iterations ) def iteration_name( iteration ): return str(iteration.id) + ': ' + iteration.summary id = 'action_%s_add_to_iteration' % action selected_option = req.args.get( id, '' ) control.append( tag( [ tag.select( [ tag.optgroup( [ tag.option( iteration_name(i), selected = (iteration_name(i) == selected_option or None) ) for i in iteration_group ], label = group_label, ) for ( group_label, iteration_group ) in groups ], id = id, name = id ) ] ) ) hints.append(_("The ticket will be added to the specified iteration")) if 'remove_from_iteration' in operations: control_label = 'remove from iteration' current_iterations = TicketSystem(self.env).get_ticket_iterations( ticket.id ) iterations = [ model.Iteration(self.env, str(i)) for i in current_iterations ] iterations.sort(lambda x, y: cmp(y.end_date, x.end_date)) groups = group_iterations( iterations ) def iteration_name( iteration ): return str(iteration.id) + ': ' + iteration.summary id = 'action_%s_remove_from_iteration' % action selected_option = req.args.get( id, '' ) control.append( tag( [ tag.select( [ tag.optgroup( [ tag.option( iteration_name(i), selected = (iteration_name(i) == selected_option or None) ) for i in iteration_group ], label = group_label, ) for ( group_label, iteration_group ) in groups ], id = id, name = id ) ] ) ) hints.append(_("The ticket will be removed from the specified iteration")) if 'leave_status' in operations: control.append('as %s ' % ticket['status']) else: if( status != '*' and 'set_size' not in operations and 'set_completion' not in operations and 'del_resolution' not in operations and 'set_owner_to_self' not in operations and 'set_owner' not in operations and 'del_owner' not in operations ): hints.append(_("Next status will be '%s'") % status) return (control_label, tag(*control), '. '.join(hints)) def get_ticket_changes(self, req, ticket, action): this_action = self.actions[action] # Enforce permissions if not self._has_perms_for_action(req, this_action, ticket.resource): # The user does not have any of the listed permissions, so we won't # do anything. return {} updated = {} # Status changes status = this_action['newstate'] if status != '*': updated['status'] = status stages = TicketSystem(self.env).get_ticket_completion_stages(ticket) for operation in this_action['operations']: if operation == 'reset_workflow': updated['status'] = 'new' if operation == 'del_owner': updated['owner'] = '' update_ticket_status(req, ticket, stages, action, updated) elif operation == 'set_owner': newowner = req.args.get('action_%s_reassign_owner' % action, this_action.get('set_owner', '').strip()) # If there was already an owner, we get a list, [new, old], # but if there wasn't we just get new. if type(newowner) == list: newowner = newowner[0] updated['owner'] = newowner update_ticket_status(req, ticket, stages, action, updated) elif operation == 'set_owner_to_self': updated['owner'] = req.authname update_ticket_status(req, ticket, stages, action, updated) if operation == 'del_resolution': default_size =self. config.get('ticket', 'default_relative_size') # If sized only re-open if at least one completed stage is 'cleared', otherwise # we will not re-open # If NOT sized then simply delete the resolution as before and set the size values # to defaults if ticket['resolution'] != 'DONE' or ticket_never_progressed(stages, ticket): reset_resolution_and_sizes_to_default(stages, default_size, ticket, updated) else: some_dates_cleared = False for stage in stages: name = stage['date'].get('name') arg_name = action + '_' + name clear_date = req.args.get(arg_name, '') if clear_date: updated[name] = '' some_dates_cleared = True if some_dates_cleared: updated['resolution'] = '' updated['status'] = 'sized' else: updated['status'] = 'closed' elif operation == 'set_resolution': newresolution = req.args.get('action_%s_resolve_resolution' % \ action, this_action.get('set_resolution', '').strip()) updated['resolution'] = newresolution for stage in stages: updated[stage['size'].get('name')] = '0' updated[stage['date'].get('name')] = '' if operation == 'set_completion': update_ticket_status(req, ticket, stages, action, updated) updated['owner'] = '' if operation == 'set_size': update_ticket_status(req, ticket, stages, action, updated) if operation == 'add_to_iteration': new_in_ieration = req.args.get('action_%s_add_to_iteration' % \ action, this_action.get('add_to_iteration', '').strip()) new_iteration_id = new_in_ieration.split(':')[0] iterations = TicketSystem(self.env).get_ticket_iterations( ticket.id ) or [] iterations.append(new_iteration_id) iterations.sort() updated['iterations'] = ' '.join( iterations ) if operation == 'remove_from_iteration': new_in_ieration = req.args.get('action_%s_remove_from_iteration' % \ action, this_action.get('remove_from_iteration', '').strip()) new_iteration_id = new_in_ieration.split(':')[0] iterations = TicketSystem(self.env).get_ticket_iterations( ticket.id ) or [] iterations.remove(new_iteration_id) updated['iterations'] = ' '.join( iterations ) # leave_status and hidden are just no-ops here, so we don't look # for them. return updated def __add_to_iteration( self, req, ticket, action ): this_action = self.actions[action] iteration = req.args.get( 'action_%s_add_to_iteration' % action, this_action.get('add_to_iteration', '').strip() ) iteration_id = iteration.split(':')[0] db = self.env.get_db_cnx() Iteration.add_ticket( self.env, iteration_id, ticket.id, req, db ) db.commit() def __remove_from_iteration( self, req, ticket, action ): this_action = self.actions[action] iteration = req.args.get( 'action_%s_remove_from_iteration' % action, this_action.get('remove_from_iteration', '').strip() ) iteration_id = iteration.split(':')[0] db = self.env.get_db_cnx() Iteration.remove_ticket( self.env, iteration_id, ticket.id, req, db ) db.commit() def apply_action_side_effects(self, req, ticket, action): if action == 'add_to_iteration': self.__add_to_iteration( req, ticket, action ) elif action == 'remove_from_iteration': self.__remove_from_iteration( req, ticket, action ) def _has_perms_for_action(self, req, action, resource): required_perms = action['permissions'] if required_perms: for permission in required_perms: if permission in req.perm(resource): break else: # The user does not have any of the listed permissions return False return True # Public methods (for other ITicketActionControllers that want to use # our config file and provide an operation for an action) def get_actions_by_operation(self, operation): """Return a list of all actions with a given operation (for use in the controller's get_all_status()) """ actions = [(info['default'], action) for action, info in self.actions.items() if operation in info['operations']] return actions def get_actions_by_operation_for_req(self, req, ticket, operation): """Return list of all actions with a given operation that are valid in the given state for the controller's get_ticket_actions(). If state='*' (the default), all actions with the given operation are returned. """ # Be sure to look at the original status. status = ticket._old.get('status', ticket['status']) actions = [(info['default'], action) for action, info in self.actions.items() if operation in info['operations'] and ('*' in info['oldstates'] or status in info['oldstates']) and self._has_perms_for_action(req, info, ticket.resource)] return actions