| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2009-2015 |
|---|
| 4 | # All rights reserved. |
|---|
| 5 | # |
|---|
| 6 | # This software is licensed as described in the file COPYING, which |
|---|
| 7 | # you should have received as part of this distribution. |
|---|
| 8 | |
|---|
| 9 | from trac.core import TracError, implements |
|---|
| 10 | from trac.perm import PermissionSystem |
|---|
| 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, TicketSystem |
|---|
| 15 | from trac.ticket.model import Resolution |
|---|
| 16 | from trac.util.html import html as tag |
|---|
| 17 | from trac.util.text import obfuscate_email_address |
|---|
| 18 | from trac.util.translation import _, tag_ |
|---|
| 19 | from trac.web.chrome import Chrome |
|---|
| 20 | |
|---|
| 21 | |
|---|
| 22 | def get_workflow_config_by_type(config, tipo_ticket): |
|---|
| 23 | """return the [ticket-workflow-type] session""" |
|---|
| 24 | raw_actions = list(config.options('ticket-workflow-%s' % tipo_ticket)) |
|---|
| 25 | actions = parse_workflow_config(raw_actions) |
|---|
| 26 | if actions and '_reset' not in actions: |
|---|
| 27 | actions['_reset'] = { |
|---|
| 28 | 'default': 0, |
|---|
| 29 | 'name': 'reset', |
|---|
| 30 | 'newstate': 'new', |
|---|
| 31 | 'oldstates': [], # Will not be invoked unless needed |
|---|
| 32 | 'operations': ['reset_workflow'], |
|---|
| 33 | 'permissions': []} |
|---|
| 34 | return actions |
|---|
| 35 | |
|---|
| 36 | |
|---|
| 37 | def calc_status(actions): |
|---|
| 38 | """Calculate all states from the given list of actions. |
|---|
| 39 | |
|---|
| 40 | :return a list of states like 'new', 'closed' etc. |
|---|
| 41 | """ |
|---|
| 42 | all_status = set() |
|---|
| 43 | for action_name, action_info in actions.items(): |
|---|
| 44 | all_status.update(action_info['oldstates']) |
|---|
| 45 | all_status.add(action_info['newstate']) |
|---|
| 46 | all_status.discard('*') |
|---|
| 47 | all_status.discard('') |
|---|
| 48 | return all_status |
|---|
| 49 | |
|---|
| 50 | |
|---|
| 51 | class MultipleWorkflowPlugin(ConfigurableTicketWorkflow): |
|---|
| 52 | """Ticket action controller providing actions according to the ticket type. |
|---|
| 53 | |
|---|
| 54 | == == |
|---|
| 55 | The [http://trac-hacks.org/wiki/MultipleWorkflowPlugin MultipleWorkflowPlugin] |
|---|
| 56 | replaces the [TracWorkflow ConfigurableTicketWorkflow] used by Trac to |
|---|
| 57 | control what actions can be performed on a ticket. The actions are |
|---|
| 58 | specified in the {{{[ticket-workflow]}}} section of the TracIni file. |
|---|
| 59 | |
|---|
| 60 | With [http://trac-hacks.org/wiki/MultipleWorkflowPlugin MultipleWorkflowPlugin] |
|---|
| 61 | Trac can read the workflow based on the type of a ticket. If a section for |
|---|
| 62 | that ticket type doesn't exist, then it uses the default workflow. |
|---|
| 63 | |
|---|
| 64 | == Installation |
|---|
| 65 | |
|---|
| 66 | Enable the plugin by adding the following to your trac.ini file: |
|---|
| 67 | |
|---|
| 68 | {{{#!ini |
|---|
| 69 | [components] |
|---|
| 70 | multipleworkflow.* = enabled |
|---|
| 71 | }}} |
|---|
| 72 | Add the controller to the workflow controller list: |
|---|
| 73 | |
|---|
| 74 | {{{#!ini |
|---|
| 75 | [ticket] |
|---|
| 76 | workflow = MultipleWorkflowPlugin |
|---|
| 77 | }}} |
|---|
| 78 | |
|---|
| 79 | == Example |
|---|
| 80 | To define a different workflow for a ticket with type {{{Requirement}}} |
|---|
| 81 | create a section in ''trac.ini'' called |
|---|
| 82 | {{{[ticket-workflow-Requirement]}}} and add your workflow items: |
|---|
| 83 | {{{#!ini |
|---|
| 84 | [ticket-workflow-Requirement] |
|---|
| 85 | leave = * -> * |
|---|
| 86 | leave.default = 1 |
|---|
| 87 | leave.operations = leave_status |
|---|
| 88 | |
|---|
| 89 | approve = new, reopened -> approved |
|---|
| 90 | approve.operations = del_resolution |
|---|
| 91 | approve.permissions = TICKET_MODIFY |
|---|
| 92 | |
|---|
| 93 | reopen_verified = closed -> reopened |
|---|
| 94 | reopen_verified.name= Reopen |
|---|
| 95 | reopen_verified.operations = set_resolution |
|---|
| 96 | reopen_verified.set_resolution=from verified |
|---|
| 97 | reopen_verified.permissions = TICKET_MODIFY |
|---|
| 98 | |
|---|
| 99 | reopen_approved = approved -> reopened |
|---|
| 100 | reopen_approved.name = Reopen |
|---|
| 101 | reopen_approved.operations = set_resolution |
|---|
| 102 | reopen_approved.set_resolution=from approved |
|---|
| 103 | reopen_approved.permissions = TICKET_CREATE |
|---|
| 104 | |
|---|
| 105 | remove = new, reopened, approved, closed -> removed |
|---|
| 106 | remove.name=Remove this Requirement permanently |
|---|
| 107 | remove.operations = set_resolution |
|---|
| 108 | remove.set_resolution= removed |
|---|
| 109 | remove.permissions = TICKET_MODIFY |
|---|
| 110 | |
|---|
| 111 | verify = approved -> closed |
|---|
| 112 | verify.name=Verifiy the Requirement and mark |
|---|
| 113 | verify.operations = set_resolution |
|---|
| 114 | verify.set_resolution=verified |
|---|
| 115 | verify.permissions = TICKET_MODIFY |
|---|
| 116 | }}} |
|---|
| 117 | """ |
|---|
| 118 | implements(ITicketActionController) |
|---|
| 119 | |
|---|
| 120 | def __init__(self): |
|---|
| 121 | # This call creates self.actions |
|---|
| 122 | super(MultipleWorkflowPlugin, self).__init__() |
|---|
| 123 | self.type_actions = {} |
|---|
| 124 | # for all ticket types do |
|---|
| 125 | for t in [enum.name for enum in model.Type.select(self.env)]: |
|---|
| 126 | actions = get_workflow_config_by_type(self.config, t) |
|---|
| 127 | if actions: |
|---|
| 128 | self.type_actions[t] = actions |
|---|
| 129 | |
|---|
| 130 | def get_workflow_actions_by_type(self, tkt_type): |
|---|
| 131 | """Return the ticket actions defined by the workflow for the given |
|---|
| 132 | ticket type or {}. |
|---|
| 133 | """ |
|---|
| 134 | try: |
|---|
| 135 | actions = self.type_actions[tkt_type] |
|---|
| 136 | except KeyError: |
|---|
| 137 | actions = {} |
|---|
| 138 | return actions |
|---|
| 139 | |
|---|
| 140 | # ITicketActionController methods |
|---|
| 141 | |
|---|
| 142 | def get_ticket_actions(self, req, ticket): |
|---|
| 143 | """Returns a list of (weight, action) tuples that are valid for this |
|---|
| 144 | request and this ticket.""" |
|---|
| 145 | # Get the list of actions that can be performed |
|---|
| 146 | |
|---|
| 147 | # Determine the current status of this ticket. If this ticket is in |
|---|
| 148 | # the process of being modified, we need to base our information on the |
|---|
| 149 | # pre-modified state so that we don't try to do two (or more!) steps at |
|---|
| 150 | # once and get really confused. |
|---|
| 151 | status = ticket._old.get('status', ticket['status']) or 'new' |
|---|
| 152 | |
|---|
| 153 | # Calculate actions for ticket type. If no wtype workflow exists use |
|---|
| 154 | # the default workflow. |
|---|
| 155 | tipo_ticket = ticket._old.get('type', ticket['type']) |
|---|
| 156 | actions = self.get_workflow_actions_by_type(tipo_ticket) |
|---|
| 157 | if not actions: |
|---|
| 158 | actions = self.actions |
|---|
| 159 | |
|---|
| 160 | ticket_perm = req.perm(ticket.resource) |
|---|
| 161 | allowed_actions = [] |
|---|
| 162 | for action_name, action_info in actions.items(): |
|---|
| 163 | oldstates = action_info['oldstates'] |
|---|
| 164 | if oldstates == ['*'] or status in oldstates: |
|---|
| 165 | # This action is valid in this state. Check permissions. |
|---|
| 166 | required_perms = action_info['permissions'] |
|---|
| 167 | if self._is_action_allowed(ticket_perm, required_perms): |
|---|
| 168 | allowed_actions.append((action_info['default'], |
|---|
| 169 | action_name)) |
|---|
| 170 | if not (status in ['new', 'closed'] or |
|---|
| 171 | status in TicketSystem(self.env).get_all_status()) \ |
|---|
| 172 | and 'TICKET_ADMIN' in ticket_perm: |
|---|
| 173 | # State no longer exists - add a 'reset' action if admin. |
|---|
| 174 | allowed_actions.append((0, '_reset')) |
|---|
| 175 | |
|---|
| 176 | # Check if the state is valid for the current ticket type. |
|---|
| 177 | # If not offer the action to reset it. |
|---|
| 178 | type_status = self.get_all_status_for_type(tipo_ticket) |
|---|
| 179 | if not type_status: |
|---|
| 180 | type_status = calc_status(self.actions) |
|---|
| 181 | if status not in type_status and (0, '_reset') not in allowed_actions: |
|---|
| 182 | allowed_actions.append((0, '_reset')) |
|---|
| 183 | return allowed_actions |
|---|
| 184 | |
|---|
| 185 | def get_all_status_for_type(self, t_type): |
|---|
| 186 | actions = self.get_workflow_actions_by_type(t_type) |
|---|
| 187 | return calc_status(actions) |
|---|
| 188 | |
|---|
| 189 | def get_all_status(self): |
|---|
| 190 | """Return a list of all states described by the configuration. |
|---|
| 191 | """ |
|---|
| 192 | # Default workflow |
|---|
| 193 | all_status = calc_status(self.actions) |
|---|
| 194 | |
|---|
| 195 | # for all ticket types do |
|---|
| 196 | for t in [enum.name for enum in model.Type.select(self.env)]: |
|---|
| 197 | all_status.update(self.get_all_status_for_type(t)) |
|---|
| 198 | return all_status |
|---|
| 199 | |
|---|
| 200 | def render_ticket_action_control(self, req, ticket, action): |
|---|
| 201 | |
|---|
| 202 | self.log.debug('render_ticket_action_control: action "%s"' % action) |
|---|
| 203 | |
|---|
| 204 | tipo_ticket = ticket._old.get('type', ticket['type']) |
|---|
| 205 | actions = self.get_workflow_actions_by_type(tipo_ticket) |
|---|
| 206 | if not actions: |
|---|
| 207 | actions = self.actions |
|---|
| 208 | |
|---|
| 209 | this_action = actions[action] |
|---|
| 210 | status = this_action['newstate'] |
|---|
| 211 | operations = this_action['operations'] |
|---|
| 212 | current_owner = ticket._old.get('owner', ticket['owner'] or '(none)') |
|---|
| 213 | if not (Chrome(self.env).show_email_addresses |
|---|
| 214 | or 'EMAIL_VIEW' in req.perm(ticket.resource)): |
|---|
| 215 | format_user = obfuscate_email_address |
|---|
| 216 | else: |
|---|
| 217 | format_user = lambda address: address |
|---|
| 218 | current_owner = format_user(current_owner) |
|---|
| 219 | |
|---|
| 220 | control = [] # default to nothing |
|---|
| 221 | hints = [] |
|---|
| 222 | if 'reset_workflow' in operations: |
|---|
| 223 | control.append(tag("from invalid state ")) |
|---|
| 224 | hints.append(_("Current state no longer exists")) |
|---|
| 225 | if 'del_owner' in operations: |
|---|
| 226 | hints.append(_("The ticket will be disowned")) |
|---|
| 227 | if 'set_owner' in operations: |
|---|
| 228 | id = 'action_%s_reassign_owner' % action |
|---|
| 229 | selected_owner = req.args.get(id, req.authname) |
|---|
| 230 | |
|---|
| 231 | if 'set_owner' in this_action: |
|---|
| 232 | owners = [x.strip() for x in |
|---|
| 233 | this_action['set_owner'].split(',')] |
|---|
| 234 | elif self.config.getbool('ticket', 'restrict_owner'): |
|---|
| 235 | perm = PermissionSystem(self.env) |
|---|
| 236 | owners = perm.get_users_with_permission('TICKET_MODIFY') |
|---|
| 237 | owners.sort() |
|---|
| 238 | else: |
|---|
| 239 | owners = None |
|---|
| 240 | |
|---|
| 241 | if owners is None: |
|---|
| 242 | owner = req.args.get(id, req.authname) |
|---|
| 243 | control.append(tag_('to %(owner)s', |
|---|
| 244 | owner=tag.input(type='text', id=id, |
|---|
| 245 | name=id, value=owner))) |
|---|
| 246 | hints.append(_("The owner will be changed from " |
|---|
| 247 | "%(current_owner)s", |
|---|
| 248 | current_owner=current_owner)) |
|---|
| 249 | elif len(owners) == 1: |
|---|
| 250 | owner = tag.input(type='hidden', id=id, name=id, |
|---|
| 251 | value=owners[0]) |
|---|
| 252 | formatted_owner = format_user(owners[0]) |
|---|
| 253 | control.append(tag_('to %(owner)s ', |
|---|
| 254 | owner=tag(formatted_owner, owner))) |
|---|
| 255 | if ticket['owner'] != owners[0]: |
|---|
| 256 | hints.append(_("The owner will be changed from " |
|---|
| 257 | "%(current_owner)s to %(selected_owner)s", |
|---|
| 258 | current_owner=current_owner, |
|---|
| 259 | selected_owner=formatted_owner)) |
|---|
| 260 | else: |
|---|
| 261 | control.append(tag_('to %(owner)s', owner=tag.select( |
|---|
| 262 | [tag.option(x, value=x, |
|---|
| 263 | selected=(x == selected_owner or None)) |
|---|
| 264 | for x in owners], |
|---|
| 265 | id=id, name=id))) |
|---|
| 266 | hints.append(_("The owner will be changed from " |
|---|
| 267 | "%(current_owner)s", |
|---|
| 268 | current_owner=current_owner)) |
|---|
| 269 | if 'set_owner_to_self' in operations and \ |
|---|
| 270 | ticket._old.get('owner', |
|---|
| 271 | ticket['owner']) != req.authname: |
|---|
| 272 | hints.append(_("The owner will be changed from %(current_owner)s " |
|---|
| 273 | "to %(authname)s", current_owner=current_owner, |
|---|
| 274 | authname=req.authname)) |
|---|
| 275 | if 'set_resolution' in operations: |
|---|
| 276 | if 'set_resolution' in this_action: |
|---|
| 277 | resolutions = [x.strip() for x in |
|---|
| 278 | this_action['set_resolution'].split(',')] |
|---|
| 279 | else: |
|---|
| 280 | resolutions = [val.name for val in Resolution.select(self.env)] |
|---|
| 281 | if not resolutions: |
|---|
| 282 | raise TracError(_("Your workflow attempts to set a resolution " |
|---|
| 283 | "but none is defined (configuration issue, " |
|---|
| 284 | "please contact your Trac admin).")) |
|---|
| 285 | id_ = 'action_%s_resolve_resolution' % action |
|---|
| 286 | if len(resolutions) == 1: |
|---|
| 287 | resolution = tag.input(type='hidden', id=id_, name=id_, |
|---|
| 288 | value=resolutions[0]) |
|---|
| 289 | control.append(tag_('as %(resolution)s', |
|---|
| 290 | resolution=tag(resolutions[0], |
|---|
| 291 | resolution))) |
|---|
| 292 | hints.append(_("The resolution will be set to %(name)s", |
|---|
| 293 | name=resolutions[0])) |
|---|
| 294 | else: |
|---|
| 295 | selected_option = req.args.get(id_, TicketSystem( |
|---|
| 296 | self.env).default_resolution) |
|---|
| 297 | control.append(tag_('as %(resolution)s', |
|---|
| 298 | resolution=tag.select( |
|---|
| 299 | [tag.option(x, value=x, selected=( |
|---|
| 300 | x == selected_option or None)) |
|---|
| 301 | for x in resolutions], |
|---|
| 302 | id=id_, name=id_))) |
|---|
| 303 | hints.append(_("The resolution will be set")) |
|---|
| 304 | if 'del_resolution' in operations: |
|---|
| 305 | hints.append(_("The resolution will be deleted")) |
|---|
| 306 | if 'leave_status' in operations: |
|---|
| 307 | control.append(_('as %(status)s ', |
|---|
| 308 | status=ticket._old.get('status', |
|---|
| 309 | ticket['status']))) |
|---|
| 310 | else: |
|---|
| 311 | if status != '*': |
|---|
| 312 | hints.append(_("Next status will be '%(name)s'", name=status)) |
|---|
| 313 | return this_action['name'], tag(*control), '. '.join(hints) |
|---|
| 314 | |
|---|
| 315 | def get_ticket_changes(self, req, ticket, action): |
|---|
| 316 | tipo_ticket = ticket._old.get('type', ticket['type']) |
|---|
| 317 | actions = self.get_workflow_actions_by_type(tipo_ticket) |
|---|
| 318 | if not actions: |
|---|
| 319 | actions = self.actions |
|---|
| 320 | this_action = actions[action] |
|---|
| 321 | |
|---|
| 322 | # Enforce permissions |
|---|
| 323 | if not self._has_perms_for_action(req, this_action, ticket.resource): |
|---|
| 324 | # The user does not have any of the listed permissions, so we won't |
|---|
| 325 | # do anything. |
|---|
| 326 | return {} |
|---|
| 327 | |
|---|
| 328 | updated = {} |
|---|
| 329 | # Status changes |
|---|
| 330 | status = this_action['newstate'] |
|---|
| 331 | if status != '*': |
|---|
| 332 | updated['status'] = status |
|---|
| 333 | |
|---|
| 334 | for operation in this_action['operations']: |
|---|
| 335 | if operation == 'reset_workflow': |
|---|
| 336 | updated['status'] = 'new' |
|---|
| 337 | elif operation == 'del_owner': |
|---|
| 338 | updated['owner'] = '' |
|---|
| 339 | elif operation == 'set_owner': |
|---|
| 340 | newowner = req.args.get('action_%s_reassign_owner' % action, |
|---|
| 341 | this_action.get('set_owner', |
|---|
| 342 | '').strip()) |
|---|
| 343 | # If there was already an owner, we get a list, [new, old], |
|---|
| 344 | # but if there wasn't we just get new. |
|---|
| 345 | if type(newowner) == list: |
|---|
| 346 | newowner = newowner[0] |
|---|
| 347 | updated['owner'] = newowner |
|---|
| 348 | elif operation == 'set_owner_to_self': |
|---|
| 349 | updated['owner'] = req.authname |
|---|
| 350 | elif operation == 'del_resolution': |
|---|
| 351 | updated['resolution'] = '' |
|---|
| 352 | elif operation == 'set_resolution': |
|---|
| 353 | newresolution = req.args.get('action_%s_resolve_resolution' % |
|---|
| 354 | action, |
|---|
| 355 | this_action.get('set_resolution', |
|---|
| 356 | '').strip()) |
|---|
| 357 | updated['resolution'] = newresolution |
|---|
| 358 | |
|---|
| 359 | # leave_status is just a no-op here, so we don't look for it. |
|---|
| 360 | return updated |
|---|
| 361 | |
|---|
| 362 | # Public methods (for other ITicketActionControllers that want to use |
|---|
| 363 | # our config file and provide an operation for an action) |
|---|
| 364 | |
|---|
| 365 | def get_actions_by_operation(self, operation): |
|---|
| 366 | """Return a list of all actions with a given operation |
|---|
| 367 | (for use in the controller's get_all_status()) |
|---|
| 368 | """ |
|---|
| 369 | all_actions = {} |
|---|
| 370 | # Default workflow |
|---|
| 371 | all_actions.update(self.actions) |
|---|
| 372 | # for all ticket types do |
|---|
| 373 | for t in [enum.name for enum in model.Type.select(self.env)]: |
|---|
| 374 | all_actions.update(self.get_workflow_actions_by_type(t)) |
|---|
| 375 | |
|---|
| 376 | actions = [(info['default'], action) for action, info |
|---|
| 377 | in all_actions.items() |
|---|
| 378 | if operation in info['operations']] |
|---|
| 379 | return actions |
|---|
| 380 | |
|---|
| 381 | def get_actions_by_operation_for_req(self, req, ticket, operation): |
|---|
| 382 | """Return list of all actions with a given operation that are valid |
|---|
| 383 | in the given state for the controller's get_ticket_actions(). |
|---|
| 384 | |
|---|
| 385 | If state='*' (the default), all actions with the given operation are |
|---|
| 386 | returned. |
|---|
| 387 | """ |
|---|
| 388 | tipo_ticket = ticket._old.get('type', ticket['type']) |
|---|
| 389 | actions = self.get_workflow_actions_by_type(tipo_ticket) |
|---|
| 390 | if not actions: |
|---|
| 391 | actions = self.actions |
|---|
| 392 | |
|---|
| 393 | # Be sure to look at the original status. |
|---|
| 394 | status = ticket._old.get('status', ticket['status']) |
|---|
| 395 | actions = [(info['default'], action) for action, info in actions.items() |
|---|
| 396 | if operation in info['operations'] and |
|---|
| 397 | ('*' in info['oldstates'] or status in info['oldstates']) and |
|---|
| 398 | self._has_perms_for_action(req, info, ticket.resource)] |
|---|
| 399 | return actions |
|---|