| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2009-2015 ermal |
|---|
| 4 | # Copyright (C) 2015-2020 Cinc |
|---|
| 5 | # All rights reserved. |
|---|
| 6 | # |
|---|
| 7 | # This software is licensed as described in the file COPYING, which |
|---|
| 8 | # you should have received as part of this distribution. |
|---|
| 9 | |
|---|
| 10 | from trac.core import implements |
|---|
| 11 | from trac.ticket import model |
|---|
| 12 | from trac.ticket.default_workflow import ConfigurableTicketWorkflow, \ |
|---|
| 13 | parse_workflow_config |
|---|
| 14 | from trac.ticket.api import ITicketActionController |
|---|
| 15 | from trac.util import lazy, sub_val |
|---|
| 16 | from trac.web.api import IRequestFilter |
|---|
| 17 | from trac.web.chrome import add_script |
|---|
| 18 | |
|---|
| 19 | |
|---|
| 20 | try: |
|---|
| 21 | dict.iteritems |
|---|
| 22 | except AttributeError: |
|---|
| 23 | # Python 3 |
|---|
| 24 | def iteritems(d): |
|---|
| 25 | return iter(d.items()) |
|---|
| 26 | else: |
|---|
| 27 | # Python 2 |
|---|
| 28 | def iteritems(d): |
|---|
| 29 | return d.iteritems() |
|---|
| 30 | |
|---|
| 31 | |
|---|
| 32 | def get_workflow_config_by_type(config, ticket_type): |
|---|
| 33 | """return the [ticket-workflow-type] session""" |
|---|
| 34 | if ticket_type == 'default': |
|---|
| 35 | raw_actions = list(config.options('ticket-workflow')) |
|---|
| 36 | else: |
|---|
| 37 | raw_actions = list(config.options('ticket-workflow-%s' % ticket_type)) |
|---|
| 38 | return parse_workflow_config(raw_actions) |
|---|
| 39 | |
|---|
| 40 | |
|---|
| 41 | def get_all_status(actions): |
|---|
| 42 | """Calculate all states from the given list of actions. |
|---|
| 43 | |
|---|
| 44 | :return a list of states like 'new', 'closed' etc. |
|---|
| 45 | """ |
|---|
| 46 | all_status = set() |
|---|
| 47 | for attributes in actions.values(): |
|---|
| 48 | all_status.update(attributes['oldstates']) |
|---|
| 49 | all_status.add(attributes['newstate']) |
|---|
| 50 | all_status.discard('*') |
|---|
| 51 | all_status.discard('') |
|---|
| 52 | all_status.discard(None) |
|---|
| 53 | return all_status |
|---|
| 54 | |
|---|
| 55 | |
|---|
| 56 | class MultipleWorkflowPlugin(ConfigurableTicketWorkflow): |
|---|
| 57 | """Ticket action controller providing actions according to the ticket type. |
|---|
| 58 | |
|---|
| 59 | The [http://trac-hacks.org/wiki/MultipleWorkflowPlugin MultipleWorkflowPlugin] |
|---|
| 60 | replaces the [TracWorkflow ConfigurableTicketWorkflow] used by Trac to |
|---|
| 61 | control what actions can be performed on a ticket. The actions are |
|---|
| 62 | specified in the {{{[ticket-workflow]}}} section of the TracIni file. |
|---|
| 63 | |
|---|
| 64 | With [http://trac-hacks.org/wiki/MultipleWorkflowPlugin MultipleWorkflowPlugin] |
|---|
| 65 | Trac can read the workflow based on the type of a ticket. If a section for |
|---|
| 66 | that ticket type doesn't exist, then it uses the default workflow. |
|---|
| 67 | |
|---|
| 68 | == Installation |
|---|
| 69 | |
|---|
| 70 | Enable the plugin by adding the following to your trac.ini file: |
|---|
| 71 | |
|---|
| 72 | {{{#!ini |
|---|
| 73 | [components] |
|---|
| 74 | multipleworkflow.* = enabled |
|---|
| 75 | }}} |
|---|
| 76 | Add the controller to the workflow controller list: |
|---|
| 77 | |
|---|
| 78 | {{{#!ini |
|---|
| 79 | [ticket] |
|---|
| 80 | workflow = MultipleWorkflowPlugin |
|---|
| 81 | }}} |
|---|
| 82 | |
|---|
| 83 | == Example |
|---|
| 84 | To define a different workflow for a ticket with type {{{Requirement}}} |
|---|
| 85 | create a section in ''trac.ini'' called |
|---|
| 86 | {{{[ticket-workflow-Requirement]}}} and add your workflow items: |
|---|
| 87 | {{{#!ini |
|---|
| 88 | [ticket-workflow-Requirement] |
|---|
| 89 | leave = * -> * |
|---|
| 90 | leave.default = 1 |
|---|
| 91 | leave.operations = leave_status |
|---|
| 92 | |
|---|
| 93 | approve = new, reopened -> approved |
|---|
| 94 | approve.operations = del_resolution |
|---|
| 95 | approve.permissions = TICKET_MODIFY |
|---|
| 96 | |
|---|
| 97 | reopen_verified = closed -> reopened |
|---|
| 98 | reopen_verified.name= Reopen |
|---|
| 99 | reopen_verified.operations = set_resolution |
|---|
| 100 | reopen_verified.set_resolution=from verified |
|---|
| 101 | reopen_verified.permissions = TICKET_MODIFY |
|---|
| 102 | |
|---|
| 103 | reopen_approved = approved -> reopened |
|---|
| 104 | reopen_approved.name = Reopen |
|---|
| 105 | reopen_approved.operations = set_resolution |
|---|
| 106 | reopen_approved.set_resolution=from approved |
|---|
| 107 | reopen_approved.permissions = TICKET_CREATE |
|---|
| 108 | |
|---|
| 109 | remove = new, reopened, approved, closed -> removed |
|---|
| 110 | remove.name=Remove this Requirement permanently |
|---|
| 111 | remove.operations = set_resolution |
|---|
| 112 | remove.set_resolution= removed |
|---|
| 113 | remove.permissions = TICKET_MODIFY |
|---|
| 114 | |
|---|
| 115 | verify = approved -> closed |
|---|
| 116 | verify.name=Verifiy the Requirement and mark |
|---|
| 117 | verify.operations = set_resolution |
|---|
| 118 | verify.set_resolution=verified |
|---|
| 119 | verify.permissions = TICKET_MODIFY |
|---|
| 120 | }}} |
|---|
| 121 | """ |
|---|
| 122 | implements(ITicketActionController, IRequestFilter) |
|---|
| 123 | |
|---|
| 124 | @lazy |
|---|
| 125 | def type_actions(self): |
|---|
| 126 | type_actions = {} |
|---|
| 127 | for t in self._ticket_types + ['default']: |
|---|
| 128 | actions = self.get_all_actions_for_type(t) |
|---|
| 129 | if actions: |
|---|
| 130 | type_actions[t] = actions |
|---|
| 131 | self.log.debug('Workflow actions at initialization: %s\n', |
|---|
| 132 | type_actions) |
|---|
| 133 | return type_actions |
|---|
| 134 | |
|---|
| 135 | @property |
|---|
| 136 | def _ticket_types(self): |
|---|
| 137 | return [enum.name for enum in model.Type.select(self.env)] |
|---|
| 138 | |
|---|
| 139 | def get_actions_by_type(self, ticket_type): |
|---|
| 140 | """Return the ticket actions defined by the workflow for the given |
|---|
| 141 | ticket type or {}. |
|---|
| 142 | """ |
|---|
| 143 | try: |
|---|
| 144 | return self.type_actions[ticket_type] |
|---|
| 145 | except KeyError: |
|---|
| 146 | return self.type_actions['default'] |
|---|
| 147 | |
|---|
| 148 | # IRequestFilter methods |
|---|
| 149 | |
|---|
| 150 | def pre_process_request(self, req, handler): |
|---|
| 151 | return handler |
|---|
| 152 | |
|---|
| 153 | def post_process_request(self, req, template, data, content_type): |
|---|
| 154 | """Implements the special behaviour for requests with 'mw_refresh' |
|---|
| 155 | argument should provide the proper list of available actions. |
|---|
| 156 | """ |
|---|
| 157 | mine = ('/newticket', '/ticket', '/simpleticket') |
|---|
| 158 | |
|---|
| 159 | match = False |
|---|
| 160 | for target in mine: |
|---|
| 161 | if req.path_info.startswith(target): |
|---|
| 162 | match = True |
|---|
| 163 | break |
|---|
| 164 | |
|---|
| 165 | if match: |
|---|
| 166 | if 'mw_refresh' in req.args: |
|---|
| 167 | # This is our outosubmit handler for the type field requesting an update for the |
|---|
| 168 | # ticket actions |
|---|
| 169 | template = 'ticket_workflow.html' |
|---|
| 170 | else: |
|---|
| 171 | add_script(req, 'multipleworkflow/js/refresh_actions.js') |
|---|
| 172 | return template, data, content_type |
|---|
| 173 | |
|---|
| 174 | # ITicketActionController methods |
|---|
| 175 | |
|---|
| 176 | def get_ticket_actions(self, req, ticket): |
|---|
| 177 | ticket_type = req.args.get('field_type') or ticket['type'] |
|---|
| 178 | self.actions = self.get_actions_by_type(ticket_type) |
|---|
| 179 | return super(MultipleWorkflowPlugin, self).\ |
|---|
| 180 | get_ticket_actions(req, ticket) |
|---|
| 181 | |
|---|
| 182 | def get_all_status_for_type(self, ticket_type): |
|---|
| 183 | actions = self.get_actions_by_type(ticket_type) |
|---|
| 184 | return get_all_status(actions) |
|---|
| 185 | |
|---|
| 186 | def get_all_status(self): |
|---|
| 187 | """Return a list of all states described by the configuration. |
|---|
| 188 | """ |
|---|
| 189 | # Default workflow |
|---|
| 190 | all_status = self.get_all_status_for_type('default') |
|---|
| 191 | |
|---|
| 192 | # for all ticket types do |
|---|
| 193 | for t in self._ticket_types: |
|---|
| 194 | all_status.update(self.get_all_status_for_type(t)) |
|---|
| 195 | return all_status |
|---|
| 196 | |
|---|
| 197 | def render_ticket_action_control(self, req, ticket, action): |
|---|
| 198 | self.actions = self.get_actions_by_type(ticket['type']) |
|---|
| 199 | return super(MultipleWorkflowPlugin, self).\ |
|---|
| 200 | render_ticket_action_control(req, ticket, action) |
|---|
| 201 | |
|---|
| 202 | def get_ticket_changes(self, req, ticket, action): |
|---|
| 203 | self.actions = self.get_actions_by_type(ticket['type']) |
|---|
| 204 | return super(MultipleWorkflowPlugin, self). \ |
|---|
| 205 | get_ticket_changes(req, ticket, action) |
|---|
| 206 | |
|---|
| 207 | # Public methods (for other ITicketActionControllers that want to use |
|---|
| 208 | # our config file and provide an operation for an action) |
|---|
| 209 | |
|---|
| 210 | def get_all_actions_for_type(self, ticket_type): |
|---|
| 211 | actions = get_workflow_config_by_type(self.config, ticket_type) |
|---|
| 212 | if not actions: |
|---|
| 213 | return actions |
|---|
| 214 | |
|---|
| 215 | # Special action that gets enabled if the current status no longer |
|---|
| 216 | # exists, as no other action can then change its state. (#5307/#11850) |
|---|
| 217 | reset = { |
|---|
| 218 | 'default': 0, |
|---|
| 219 | 'label': 'reset', |
|---|
| 220 | 'newstate': 'new', |
|---|
| 221 | 'oldstates': [], |
|---|
| 222 | 'operations': ['reset_workflow'], |
|---|
| 223 | 'permissions': ['TICKET_ADMIN'] |
|---|
| 224 | } |
|---|
| 225 | for key, val in reset.items(): |
|---|
| 226 | actions['_reset'].setdefault(key, val) |
|---|
| 227 | |
|---|
| 228 | for name, info in iteritems(actions): |
|---|
| 229 | for val in ('<none>', '< none >'): |
|---|
| 230 | sub_val(actions[name]['oldstates'], val, None) |
|---|
| 231 | if not info['newstate']: |
|---|
| 232 | self.log.warning("Ticket workflow action '%s' doesn't define " |
|---|
| 233 | "any transitions", name) |
|---|
| 234 | return actions |
|---|
| 235 | |
|---|
| 236 | def get_actions_by_operation(self, operation): |
|---|
| 237 | """Return a list of all actions with a given operation |
|---|
| 238 | (for use in the controller's get_all_status()) |
|---|
| 239 | """ |
|---|
| 240 | all_actions = {} |
|---|
| 241 | all_actions.update(self.get_actions_by_type('default')) |
|---|
| 242 | for t in self._ticket_types: |
|---|
| 243 | all_actions.update(self.get_actions_by_type(t)) |
|---|
| 244 | self.actions = all_actions |
|---|
| 245 | |
|---|
| 246 | return super(MultipleWorkflowPlugin, self).\ |
|---|
| 247 | get_actions_by_operation(operation) |
|---|
| 248 | |
|---|
| 249 | def get_actions_by_operation_for_req(self, req, ticket, operation): |
|---|
| 250 | """Return list of all actions with a given operation that are valid |
|---|
| 251 | in the given state for the controller's get_ticket_actions(). |
|---|
| 252 | |
|---|
| 253 | If state='*' (the default), all actions with the given operation are |
|---|
| 254 | returned. |
|---|
| 255 | """ |
|---|
| 256 | ticket_type = ticket._old.get('type', ticket['type']) |
|---|
| 257 | self.actions = self.get_actions_by_type(ticket_type) |
|---|
| 258 | |
|---|
| 259 | return super(MultipleWorkflowPlugin, self).\ |
|---|
| 260 | get_actions_by_operation_for_req(req, ticket, operation) |
|---|