"Fossies" - the Fresh Open Source Software Archive

Member "PURELIB/trac/ticket/default_workflow.py" (27 Aug 2019, 31322 Bytes) of package /windows/misc/Trac-1.4.win-amd64.exe:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. See also the last Fossies "Diffs" side-by-side code changes report for "default_workflow.py": 1.3.5_vs_1.3.6.

    1 # -*- coding: utf-8 -*-
    2 #
    3 # Copyright (C) 2006-2019 Edgewall Software
    4 # Copyright (C) 2006 Alec Thomas
    5 # Copyright (C) 2007 Eli Carter
    6 # Copyright (C) 2007 Christian Boos <cboos@edgewall.org>
    7 # All rights reserved.
    8 #
    9 # This software is licensed as described in the file COPYING, which
   10 # you should have received as part of this distribution. The terms
   11 # are also available at https://trac.edgewall.org/wiki/TracLicense.
   12 #
   13 # This software consists of voluntary contributions made by many
   14 # individuals. For the exact contribution history, see the revision
   15 # history and logs, available at https://trac.edgewall.org/log/.
   16 #
   17 # Author: Eli Carter
   18 
   19 import io
   20 from ConfigParser import ParsingError, RawConfigParser
   21 from collections import defaultdict
   22 from functools import partial
   23 from pkg_resources import resource_filename
   24 
   25 from trac.api import IEnvironmentSetupParticipant
   26 from trac.config import ConfigSection, Configuration, ConfigurationError
   27 from trac.core import *
   28 from trac.perm import PermissionCache, PermissionSystem
   29 from trac.resource import ResourceNotFound
   30 from trac.ticket.api import ITicketActionController, TicketSystem
   31 from trac.ticket.model import Component as TicketComponent, Resolution
   32 from trac.util import exception_to_unicode, get_reporter_id, sub_val, to_list
   33 from trac.util.html import tag
   34 from trac.util.presentation import separated
   35 from trac.util.translation import _, tag_, cleandoc_
   36 from trac.versioncontrol.api import RepositoryManager
   37 from trac.web.chrome import Chrome, add_script, add_script_data
   38 from trac.wiki.formatter import MacroError, ProcessorError
   39 from trac.wiki.macros import WikiMacroBase, parse_args
   40 
   41 
   42 # -- Utilities for the ConfigurableTicketWorkflow
   43 
   44 def parse_workflow_config(rawactions):
   45     """Given a list of options from [ticket-workflow]"""
   46 
   47     required_attrs = {
   48         'oldstates': [],
   49         'newstate': '',
   50         'name': '',
   51         'label': '',
   52         'default': 0,
   53         'operations': [],
   54         'permissions': [],
   55     }
   56     optional_attrs = {
   57         'set_owner': [],
   58         'set_resolution': [],
   59     }
   60     known_attrs = required_attrs.copy()
   61     known_attrs.update(optional_attrs)
   62 
   63     actions = defaultdict(dict)
   64     for option, value in rawactions:
   65         parts = option.split('.')
   66         name = parts[0]
   67         if len(parts) == 1:
   68             try:
   69                 # Base name, of the syntax: old,states,here -> newstate
   70                 oldstates, newstate = [x.strip() for x in value.split('->')]
   71             except ValueError:
   72                 continue  # Syntax error, a warning will be logged later
   73             actions[name]['oldstates'] = to_list(oldstates)
   74             actions[name]['newstate'] = newstate
   75         else:
   76             attribute = parts[1]
   77             if attribute not in known_attrs or \
   78                     isinstance(known_attrs[attribute], str):
   79                 actions[name][attribute] = value
   80             elif isinstance(known_attrs[attribute], int):
   81                 actions[name][attribute] = int(value)
   82             elif isinstance(known_attrs[attribute], list):
   83                 actions[name][attribute] = to_list(value)
   84 
   85     for action, attributes in actions.items():
   86         if 'label' not in attributes:
   87             if 'name' in attributes:  # backwards-compatibility, #11828
   88                 attributes['label'] = attributes['name']
   89             else:
   90                 attributes['label'] = action.replace("_", " ").strip()
   91         for key, val in required_attrs.items():
   92             attributes.setdefault(key, val)
   93 
   94     return actions
   95 
   96 
   97 def get_workflow_config(config):
   98     """Usually passed self.config, this will return the parsed ticket-workflow
   99     section.
  100     """
  101     raw_actions = list(config.options('ticket-workflow'))
  102     actions = parse_workflow_config(raw_actions)
  103     return actions
  104 
  105 
  106 def load_workflow_config_snippet(config, filename):
  107     """Loads the ticket-workflow section from the given file (expected to be in
  108     the 'workflows' tree) into the provided config.
  109     """
  110     filename = resource_filename('trac.ticket', 'workflows/%s' % filename)
  111     new_config = Configuration(filename)
  112     for name, value in new_config.options('ticket-workflow'):
  113         config.set('ticket-workflow', name, value)
  114 
  115 
  116 class ConfigurableTicketWorkflow(Component):
  117     """Ticket action controller which provides actions according to a
  118     workflow defined in trac.ini.
  119 
  120     The workflow is defined in the `[ticket-workflow]` section of the
  121     [wiki:TracIni#ticket-workflow-section trac.ini] configuration file.
  122     """
  123 
  124     implements(IEnvironmentSetupParticipant, ITicketActionController)
  125 
  126     ticket_workflow_section = ConfigSection('ticket-workflow',
  127         """The workflow for tickets is controlled by plugins. By default,
  128         there's only a `ConfigurableTicketWorkflow` component in charge.
  129         That component allows the workflow to be configured via this section
  130         in the `trac.ini` file. See TracWorkflow for more details.
  131         """)
  132 
  133     operations = ('del_owner', 'set_owner', 'set_owner_to_self',
  134                   'may_set_owner', 'set_resolution', 'del_resolution',
  135                   'leave_status', 'reset_workflow')
  136 
  137     def __init__(self):
  138         self.actions = self.get_all_actions()
  139         self.log.debug('Workflow actions at initialization: %s\n',
  140                        self.actions)
  141 
  142     # IEnvironmentSetupParticipant methods
  143 
  144     def environment_created(self):
  145         """When an environment is created, we provide the basic-workflow,
  146         unless a ticket-workflow section already exists.
  147         """
  148         if 'ticket-workflow' not in self.config.sections():
  149             load_workflow_config_snippet(self.config, 'basic-workflow.ini')
  150             self.config.save()
  151             self.actions = self.get_all_actions()
  152 
  153     def environment_needs_upgrade(self):
  154         pass
  155 
  156     def upgrade_environment(self):
  157         pass
  158 
  159     # ITicketActionController methods
  160 
  161     def get_ticket_actions(self, req, ticket):
  162         """Returns a list of (weight, action) tuples that are valid for this
  163         request and this ticket."""
  164         # Get the list of actions that can be performed
  165 
  166         # Determine the current status of this ticket.  If this ticket is in
  167         # the process of being modified, we need to base our information on the
  168         # pre-modified state so that we don't try to do two (or more!) steps at
  169         # once and get really confused.
  170         ticket_status = ticket._old.get('status', ticket['status'])
  171         exists = ticket_status is not None
  172         ticket_owner = ticket._old.get('owner', ticket['owner'])
  173         author = get_reporter_id(req, 'author')
  174 
  175         resource = ticket.resource
  176         allowed_actions = []
  177         for action_name, action_info in self.actions.items():
  178             operations = action_info['operations']
  179             newstate = action_info['newstate']
  180             # Exclude action that is effectively a No-op.
  181             if len(operations) == 1 and \
  182                     operations[0] == 'set_owner_to_self' and \
  183                     ticket_owner == author and ticket_status == newstate:
  184                 continue
  185             oldstates = action_info['oldstates']
  186             if exists and oldstates == ['*'] or ticket_status in oldstates:
  187                 # This action is valid in this state.  Check permissions.
  188                 if self._is_action_allowed(req, action_info, resource):
  189                     allowed_actions.append((action_info['default'],
  190                                             action_name))
  191         # Append special `_reset` action if status is invalid.
  192         if exists and '_reset' in self.actions and \
  193                 ticket_status not in TicketSystem(self.env).get_all_status():
  194             reset = self.actions['_reset']
  195             if self._is_action_allowed(req, reset, resource):
  196                 allowed_actions.append((reset['default'], '_reset'))
  197         return allowed_actions
  198 
  199     def _is_action_allowed(self, req, action, resource):
  200         """Returns `True` if the workflow action is allowed for the `resource`.
  201         """
  202         perm_cache = req.perm(resource)
  203         required_perms = action['permissions']
  204         if required_perms:
  205             for permission in required_perms:
  206                 if permission in perm_cache:
  207                     break
  208             else:
  209                 return False
  210         return True
  211 
  212     def get_all_status(self):
  213         """Return a list of all states described by the configuration.
  214 
  215         """
  216         all_status = set()
  217         for attributes in self.actions.values():
  218             all_status.update(attributes['oldstates'])
  219             all_status.add(attributes['newstate'])
  220         all_status.discard('*')
  221         all_status.discard('')
  222         all_status.discard(None)
  223         return all_status
  224 
  225     def render_ticket_action_control(self, req, ticket, action):
  226 
  227         self.log.debug('render_ticket_action_control: action "%s"', action)
  228 
  229         this_action = self.actions[action]
  230         status = this_action['newstate']
  231         label = this_action['label']
  232         operations = this_action['operations']
  233         ticket_owner = ticket._old.get('owner', ticket['owner'])
  234         ticket_status = ticket._old.get('status', ticket['status'])
  235         author = get_reporter_id(req, 'author')
  236         author_info = partial(Chrome(self.env).authorinfo, req,
  237                               resource=ticket.resource)
  238         format_author = partial(Chrome(self.env).format_author, req,
  239                                 resource=ticket.resource)
  240         formatted_current_owner = author_info(ticket_owner)
  241         exists = ticket_status is not None
  242 
  243         ticket_system = TicketSystem(self.env)
  244         control = []  # default to nothing
  245         hints = []
  246         if 'reset_workflow' in operations:
  247             control.append(_("from invalid state"))
  248             hints.append(_("Current state no longer exists"))
  249         if 'del_owner' in operations:
  250             hints.append(_("The ticket will be disowned"))
  251         if 'set_owner' in operations or 'may_set_owner' in operations:
  252             owners = self.get_allowed_owners(req, ticket, this_action)
  253 
  254             if 'set_owner' in operations:
  255                 default_owner = author
  256             elif 'may_set_owner' in operations:
  257                 if not exists:
  258                     default_owner = ticket_system.default_owner
  259                 else:
  260                     default_owner = ticket_owner or None
  261                 if owners is not None and default_owner not in owners:
  262                     owners.insert(0, default_owner)
  263             else:
  264                 # Protect against future modification for case that another
  265                 # operation is added to the outer conditional
  266                 raise AssertionError(operations)
  267 
  268             id = 'action_%s_reassign_owner' % action
  269 
  270             if not owners:
  271                 owner = req.args.get(id, default_owner)
  272                 control.append(
  273                     tag_("to %(owner)s",
  274                          owner=tag.input(type='text', id=id, name=id,
  275                                          value=owner)))
  276                 if not exists or ticket_owner is None:
  277                     hints.append(_("The owner will be the specified user"))
  278                 else:
  279                     hints.append(tag_("The owner will be changed from "
  280                                       "%(current_owner)s to the specified "
  281                                       "user",
  282                                       current_owner=formatted_current_owner))
  283             elif len(owners) == 1:
  284                 owner = tag.input(type='hidden', id=id, name=id,
  285                                   value=owners[0])
  286                 formatted_new_owner = author_info(owners[0])
  287                 control.append(tag_("to %(owner)s",
  288                                     owner=tag(formatted_new_owner, owner)))
  289                 if not exists or ticket_owner is None:
  290                     hints.append(tag_("The owner will be %(new_owner)s",
  291                                       new_owner=formatted_new_owner))
  292                 elif ticket['owner'] != owners[0]:
  293                     hints.append(tag_("The owner will be changed from "
  294                                       "%(current_owner)s to %(new_owner)s",
  295                                       current_owner=formatted_current_owner,
  296                                       new_owner=formatted_new_owner))
  297             else:
  298                 selected_owner = req.args.get(id, default_owner)
  299                 control.append(tag_("to %(owner)s", owner=tag.select(
  300                     [tag.option(text, value=value if value is not None else '',
  301                                 selected=(value == selected_owner or None))
  302                      for text, value in sorted((format_author(owner), owner)
  303                                                 for owner in owners)],
  304                     id=id, name=id)))
  305                 if not exists or ticket_owner is None:
  306                     hints.append(_("The owner will be the selected user"))
  307                 else:
  308                     hints.append(tag_("The owner will be changed from "
  309                                       "%(current_owner)s to the selected user",
  310                                       current_owner=formatted_current_owner))
  311         elif 'set_owner_to_self' in operations:
  312             formatted_author = author_info(author)
  313             if not exists or ticket_owner is None:
  314                 hints.append(tag_("The owner will be %(new_owner)s",
  315                                   new_owner=formatted_author))
  316             elif ticket_owner != author:
  317                 hints.append(tag_("The owner will be changed from "
  318                                   "%(current_owner)s to %(new_owner)s",
  319                                   current_owner=formatted_current_owner,
  320                                   new_owner=formatted_author))
  321             elif ticket_status != status:
  322                 hints.append(tag_("The owner will remain %(current_owner)s",
  323                                   current_owner=formatted_current_owner))
  324         if 'set_resolution' in operations:
  325             resolutions = [r.name for r in Resolution.select(self.env)]
  326             if 'set_resolution' in this_action:
  327                 valid_resolutions = set(resolutions)
  328                 resolutions = this_action['set_resolution']
  329                 if any(x not in valid_resolutions for x in resolutions):
  330                     raise ConfigurationError(_(
  331                         "Your workflow attempts to set a resolution but uses "
  332                         "undefined resolutions (configuration issue, please "
  333                         "contact your Trac admin)."))
  334             if not resolutions:
  335                 raise ConfigurationError(_(
  336                     "Your workflow attempts to set a resolution but none is "
  337                     "defined (configuration issue, please contact your Trac "
  338                     "admin)."))
  339             id = 'action_%s_resolve_resolution' % action
  340             if len(resolutions) == 1:
  341                 resolution = tag.input(type='hidden', id=id, name=id,
  342                                        value=resolutions[0])
  343                 control.append(tag_("as %(resolution)s",
  344                                     resolution=tag(resolutions[0],
  345                                                    resolution)))
  346                 hints.append(tag_("The resolution will be set to %(name)s",
  347                                   name=resolutions[0]))
  348             else:
  349                 selected_option = req.args.get(id,
  350                                                ticket_system.default_resolution)
  351                 control.append(tag_("as %(resolution)s",
  352                                     resolution=tag.select(
  353                     [tag.option(x, value=x,
  354                                 selected=(x == selected_option or None))
  355                      for x in resolutions],
  356                     id=id, name=id)))
  357                 hints.append(_("The resolution will be set"))
  358         if 'del_resolution' in operations:
  359             hints.append(_("The resolution will be deleted"))
  360         if 'leave_status' in operations:
  361             control.append(tag_("as %(status)s", status=ticket_status))
  362             if len(operations) == 1:
  363                 hints.append(tag_("The owner will remain %(current_owner)s",
  364                                   current_owner=formatted_current_owner)
  365                              if ticket_owner else
  366                              _("The ticket will remain with no owner"))
  367         else:
  368             if status == '*':
  369                 label = None  # Control won't be created
  370             elif ticket['status'] is None:  # New ticket
  371                 hints.append(tag_("The status will be '%(name)s'",
  372                                   name=status))
  373             else:
  374                 hints.append(tag_("Next status will be '%(name)s'",
  375                                   name=status))
  376         return (label, tag(separated(control, ' ')),
  377                 tag(separated(hints, '. ', '.') if hints else ''))
  378 
  379     def get_ticket_changes(self, req, ticket, action):
  380         this_action = self.actions[action]
  381 
  382         # Enforce permissions
  383         if not self._is_action_allowed(req, this_action, ticket.resource):
  384             # The user does not have any of the listed permissions, so we won't
  385             # do anything.
  386             return {}
  387 
  388         updated = {}
  389         # Status changes
  390         status = this_action['newstate']
  391         if status != '*':
  392             updated['status'] = status
  393 
  394         for operation in this_action['operations']:
  395             if operation == 'del_owner':
  396                 updated['owner'] = ''
  397             elif operation in ('set_owner', 'may_set_owner'):
  398                 set_owner = this_action.get('set_owner')
  399                 newowner = req.args.get('action_%s_reassign_owner' % action,
  400                                         set_owner[0] if set_owner else '')
  401                 # If there was already an owner, we get a list, [new, old],
  402                 # but if there wasn't we just get new.
  403                 if type(newowner) == list:
  404                     newowner = newowner[0]
  405                 updated['owner'] = self._sub_owner_keyword(newowner, ticket)
  406             elif operation == 'set_owner_to_self':
  407                 updated['owner'] = get_reporter_id(req, 'author')
  408             elif operation == 'del_resolution':
  409                 updated['resolution'] = ''
  410             elif operation == 'set_resolution':
  411                 set_resolution = this_action.get('set_resolution')
  412                 newresolution = req.args.get('action_%s_resolve_resolution'
  413                                              % action,
  414                                              set_resolution[0]
  415                                              if set_resolution else '')
  416                 updated['resolution'] = newresolution
  417 
  418             # reset_workflow is just a no-op here, so we don't look for it.
  419             # leave_status is just a no-op here, so we don't look for it.
  420 
  421         # Set owner to component owner for 'new' ticket if:
  422         #  - ticket doesn't exist and owner is < default >
  423         #  - component is changed
  424         #  - owner isn't explicitly changed
  425         #  - ticket owner is equal to owner of previous component
  426         #  - new component has an owner
  427         if not ticket.exists and 'owner' not in updated:
  428             updated['owner'] = self._sub_owner_keyword(ticket['owner'], ticket)
  429         elif ticket['status'] == 'new' and \
  430                 'component' in ticket.values and \
  431                 'component' in ticket._old and \
  432                 'owner' not in updated:
  433             try:
  434                 old_comp = TicketComponent(self.env, ticket._old['component'])
  435             except ResourceNotFound:
  436                 # If the old component has been removed from the database
  437                 # we just leave the owner as is.
  438                 pass
  439             else:
  440                 old_owner = old_comp.owner or ''
  441                 current_owner = ticket['owner'] or ''
  442                 if old_owner == current_owner:
  443                     new_comp = TicketComponent(self.env, ticket['component'])
  444                     if new_comp.owner:
  445                         updated['owner'] = new_comp.owner
  446 
  447         return updated
  448 
  449     def apply_action_side_effects(self, req, ticket, action):
  450         pass
  451 
  452     # Public methods (for other ITicketActionControllers that want to use
  453     #                 our config file and provide an operation for an action)
  454 
  455     def get_all_actions(self):
  456         actions = parse_workflow_config(self.ticket_workflow_section.options())
  457 
  458         has_new_state = any('new' in [a['newstate']] + a['oldstates']
  459                             for a in actions.itervalues())
  460         if has_new_state:
  461             # Special action that gets enabled if the current status no
  462             # longer exists, as no other action can then change its state.
  463             # (#5307/#11850)
  464             reset = {
  465                 'default': 0,
  466                 'label': 'Reset',
  467                 'newstate': 'new',
  468                 'oldstates': [],
  469                 'operations': ['reset_workflow'],
  470                 'permissions': ['TICKET_ADMIN']
  471             }
  472             for key, val in reset.items():
  473                 actions['_reset'].setdefault(key, val)
  474 
  475         for name, info in actions.iteritems():
  476             for val in ('<none>', '< none >'):
  477                 sub_val(actions[name]['oldstates'], val, None)
  478             if not info['newstate']:
  479                 self.log.warning("Ticket workflow action '%s' doesn't define "
  480                                  "any transitions", name)
  481         return actions
  482 
  483     def get_actions_by_operation(self, operation):
  484         """Return a list of all actions with a given operation
  485         (for use in the controller's get_all_status())
  486         """
  487         actions = [(info['default'], action) for action, info
  488                    in self.actions.items()
  489                    if operation in info['operations']]
  490         return actions
  491 
  492     def get_actions_by_operation_for_req(self, req, ticket, operation):
  493         """Return list of all actions with a given operation that are valid
  494         in the given state for the controller's get_ticket_actions().
  495 
  496         If state='*' (the default), all actions with the given operation are
  497         returned.
  498         """
  499         # Be sure to look at the original status.
  500         status = ticket._old.get('status', ticket['status'])
  501         actions = [(info['default'], action)
  502                    for action, info in self.actions.items()
  503                    if operation in info['operations'] and
  504                       ('*' in info['oldstates'] or
  505                        status in info['oldstates']) and
  506                       self._is_action_allowed(req, info, ticket.resource)]
  507         return actions
  508 
  509     # Public methods
  510 
  511     def get_allowed_owners(self, req, ticket, action):
  512         """Returns users listed in the `set_owner` field of the action or
  513         possessing the `TICKET_MODIFY` permission if `set_owner` is not
  514         specified.
  515 
  516         This method can be overridden in a subclasses in order to
  517         customize the list of users that populate the assign-to select
  518         box.
  519 
  520         :since: 1.3.2
  521         """
  522         if 'set_owner' in action:
  523             return self._to_users(action['set_owner'], ticket)
  524         elif TicketSystem(self.env).restrict_owner:
  525             users = PermissionSystem(self.env)\
  526                     .get_users_with_permission('TICKET_MODIFY')
  527             cache = partial(PermissionCache, self.env, resource=ticket.resource)
  528             return sorted(u for u in users
  529                             if 'TICKET_MODIFY' in cache(username=u))
  530 
  531     # Internal methods
  532 
  533     def _sub_owner_keyword(self, owner, ticket):
  534         """Substitute keywords from the default_owner field.
  535 
  536         < default > -> component owner
  537         """
  538         if owner in ('< default >', '<default>'):
  539             default_owner = ''
  540             if ticket['component']:
  541                 try:
  542                     component = TicketComponent(self.env, ticket['component'])
  543                 except ResourceNotFound:
  544                     pass  # No such component exists
  545                 else:
  546                     default_owner = component.owner  # May be empty
  547             return default_owner
  548         return owner
  549 
  550     def _to_users(self, users_perms_and_groups, ticket):
  551         """Finds all users contained in the list of `users_perms_and_groups`
  552         by recursive lookup of users when a `group` is encountered.
  553         """
  554         ps = PermissionSystem(self.env)
  555         groups = ps.get_groups_dict()
  556 
  557         def append_owners(users_perms_and_groups):
  558             for user_perm_or_group in users_perms_and_groups:
  559                 if user_perm_or_group == 'authenticated':
  560                     owners.update({u[0] for u in self.env.get_known_users()})
  561                 elif user_perm_or_group.isupper():
  562                     perm = user_perm_or_group
  563                     for user in ps.get_users_with_permission(perm):
  564                         if perm in PermissionCache(self.env, user,
  565                                                    ticket.resource):
  566                             owners.add(user)
  567                 elif user_perm_or_group not in groups:
  568                     owners.add(user_perm_or_group)
  569                 else:
  570                     append_owners(groups[user_perm_or_group])
  571 
  572         owners = set()
  573         append_owners(users_perms_and_groups)
  574 
  575         return sorted(owners)
  576 
  577 
  578 class WorkflowMacro(WikiMacroBase):
  579     _domain = 'messages'
  580     _description = cleandoc_(
  581     """Render a workflow graph.
  582 
  583     This macro accepts a TracWorkflow configuration and renders the states
  584     and transitions as a directed graph. If no parameters are given, the
  585     current ticket workflow is rendered.
  586 
  587     In [WikiProcessors WikiProcessor] mode the `width` and `height`
  588     arguments can be specified (Defaults: `width = 800` and `height = 600`).
  589 
  590     The repository-scoped path of a workflow file can be specified as either
  591     a macro or !WikiProcessor `file` argument. The file revision can be
  592     specified by appending `@<rev>` to the path. The `file` argument value
  593     must be enclosed in single or double quotes. //(Since 1.3.2)//.
  594 
  595     Examples:
  596     {{{
  597     [[Workflow()]]
  598 
  599     [[Workflow(go = here -> there; return = there -> here)]]
  600 
  601     [[Workflow(file=/contrib/workflow/enterprise-workflow.ini@1)]]
  602 
  603     {{{#!Workflow file="/contrib/workflow/enterprise-workflow.ini"
  604     }}}
  605 
  606     {{{#!Workflow width=700 height=700
  607     leave = * -> *
  608     leave.operations = leave_status
  609     leave.default = 1
  610 
  611     create = <none> -> new
  612     create.default = 1
  613 
  614     create_and_assign = <none> -> assigned
  615     create_and_assign.label = assign
  616     create_and_assign.permissions = TICKET_MODIFY
  617     create_and_assign.operations = may_set_owner
  618 
  619     accept = new,assigned,accepted,reopened -> accepted
  620     accept.permissions = TICKET_MODIFY
  621     accept.operations = set_owner_to_self
  622 
  623     resolve = new,assigned,accepted,reopened -> closed
  624     resolve.permissions = TICKET_MODIFY
  625     resolve.operations = set_resolution
  626 
  627     reassign = new,assigned,accepted,reopened -> assigned
  628     reassign.permissions = TICKET_MODIFY
  629     reassign.operations = set_owner
  630 
  631     reopen = closed -> reopened
  632     reopen.permissions = TICKET_CREATE
  633     reopen.operations = del_resolution
  634     }}}
  635     }}}
  636     """)
  637 
  638     def expand_macro(self, formatter, name, content, args=None):
  639         if content is not None:
  640             content = content.strip()
  641         if not args and not content:
  642             raw_actions = self.config.options('ticket-workflow')
  643         else:
  644             is_macro = args is None
  645             if is_macro:
  646                 kwargs = parse_args(content)[1]
  647                 file = kwargs.get('file')
  648             else:
  649                 file = args.get('file')
  650                 if not file and not content:
  651                     raise ProcessorError("Invalid argument(s).")
  652 
  653             if file:
  654                 print(file)
  655                 text = RepositoryManager(self.env).read_file_by_path(file)
  656                 if text is None:
  657                     raise ProcessorError(
  658                         tag_("The file %(file)s does not exist.",
  659                              file=tag.code(file)))
  660             elif is_macro:
  661                 text = '\n'.join(line.lstrip() for line in content.split(';'))
  662             else:
  663                 text = content
  664 
  665             if '[ticket-workflow]' not in text:
  666                 text = '[ticket-workflow]\n' + text
  667             parser = RawConfigParser()
  668             try:
  669                 parser.readfp(io.StringIO(text))
  670             except ParsingError as e:
  671                 if is_macro:
  672                     raise MacroError(exception_to_unicode(e))
  673                 else:
  674                     raise ProcessorError(exception_to_unicode(e))
  675             raw_actions = list(parser.items('ticket-workflow'))
  676         actions = parse_workflow_config(raw_actions)
  677         states = list(
  678             {state for action in actions.itervalues()
  679                    for state in action['oldstates']} |
  680             {action['newstate'] for action in actions.itervalues()})
  681         action_labels = [attrs['label'] for attrs in actions.values()]
  682         action_names = list(actions)
  683         edges = []
  684         for name, action in actions.items():
  685             new_index = states.index(action['newstate'])
  686             name_index = action_names.index(name)
  687             for old_state in action['oldstates']:
  688                 old_index = states.index(old_state)
  689                 edges.append((old_index, new_index, name_index))
  690 
  691         args = args or {}
  692         width = args.get('width', 800)
  693         height = args.get('height', 600)
  694         graph = {'nodes': states, 'actions': action_labels, 'edges': edges,
  695                  'width': width, 'height': height}
  696         graph_id = '%012x' % id(graph)
  697         req = formatter.req
  698         add_script(req, 'common/js/excanvas.js', ie_if='IE')
  699         add_script(req, 'common/js/workflow_graph.js')
  700         add_script_data(req, {'graph_%s' % graph_id: graph})
  701         return tag(
  702             tag.div('', class_='trac-workflow-graph trac-noscript',
  703                     id='trac-workflow-graph-%s' % graph_id,
  704                     style="display:inline-block;width:%spx;height:%spx" %
  705                           (width, height)),
  706             tag.noscript(
  707                 tag.div(_("Enable JavaScript to display the workflow graph."),
  708                         class_='system-message')))