source: multipleworkflowplugin/branches/multipleworkflow-1.3/multipleworkflow/workflow.py

Last change on this file was 16593, checked in by Ryan J Ollos, 6 years ago

MultipleWorkflowPlugin 1.3.3: Fix regression in r15137

get_workflow_config_default was removed without
replacing all uses.

Tidy up codebase and require Trac >= 1.0.

Refs #12570, Fixes #12665.

File size: 16.8 KB
Line 
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
9from trac.core import TracError, implements
10from trac.perm import PermissionSystem
11from trac.ticket import model
12from trac.ticket.default_workflow import ConfigurableTicketWorkflow, \
13                                         parse_workflow_config
14from trac.ticket.api import ITicketActionController, TicketSystem
15from trac.ticket.model import Resolution
16from trac.util.html import html as tag
17from trac.util.text import obfuscate_email_address
18from trac.util.translation import _, tag_
19from trac.web.chrome import Chrome
20
21
22def 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
37def 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
51class 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
Note: See TracBrowser for help on using the repository browser.