source: advancedticketworkflowplugin/0.12/advancedworkflow/controller.py @ 9962

Last change on this file since 9962 was 9962, checked in by Eli Carter, 13 years ago

AdvancedTicketWorkflowPlugin: Ticket #6988: use the current owner if the owner has never changed instead of deleting the owner

File size: 18.8 KB
Line 
1"""Trac plugin that provides a number of advanced operations for customizable
2workflows.
3"""
4
5import os
6import time
7from datetime import datetime
8from subprocess import call
9from genshi.builder import tag
10
11from trac.core import implements, Component
12from trac.ticket import model
13from trac.ticket.api import ITicketActionController, TicketSystem
14from trac.ticket.default_workflow import ConfigurableTicketWorkflow
15from trac.ticket.model import Milestone
16from trac.ticket.notification import TicketNotifyEmail
17from trac.resource import ResourceNotFound
18from trac.util.datefmt import utc
19from trac.web.chrome import add_warning
20
21
22class TicketWorkflowOpBase(Component):
23    """Abstract base class for 'simple' ticket workflow operations."""
24
25    implements(ITicketActionController)
26    abstract = True
27
28    _op_name = None # Must be specified.
29
30    def get_configurable_workflow(self):
31        controllers = TicketSystem(self.env).action_controllers
32        for controller in controllers:
33            if isinstance(controller, ConfigurableTicketWorkflow):
34                return controller
35        return ConfigurableTicketWorkflow(self.env)
36
37    # ITicketActionController methods
38
39    def get_ticket_actions(self, req, ticket):
40        """Finds the actions that use this operation"""
41        controller = self.get_configurable_workflow()
42        return controller.get_actions_by_operation_for_req(req, ticket,
43                                                           self._op_name)
44
45    def get_all_status(self):
46        """Provide any additional status values"""
47        # We don't have anything special here; the statuses will be recognized
48        # by the default controller.
49        return []
50
51    # This should most likely be overridden to be more functional
52    def render_ticket_action_control(self, req, ticket, action):
53        """Returns the action control"""
54        actions = self.get_configurable_workflow().actions
55        label = actions[action]['name']
56        return (label, tag(''), '')
57
58    def get_ticket_changes(self, req, ticket, action):
59        """Must be implemented in subclasses"""
60        raise NotImplementedError
61
62    def apply_action_side_effects(self, req, ticket, action):
63        """No side effects"""
64        pass
65
66
67class TicketWorkflowOpOwnerReporter(TicketWorkflowOpBase):
68    """Sets the owner to the reporter of the ticket.
69
70    needinfo = * -> needinfo
71    needinfo.name = Need info
72    needinfo.operations = set_owner_to_reporter
73
74
75    Don't forget to add the `TicketWorkflowOpOwnerReporter` to the workflow
76    option in [ticket].
77    If there is no workflow option, the line will look like this:
78
79    workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerReporter
80    """
81
82    _op_name = 'set_owner_to_reporter'
83
84    # ITicketActionController methods
85
86    def render_ticket_action_control(self, req, ticket, action):
87        """Returns the action control"""
88        actions = self.get_configurable_workflow().actions
89        label = actions[action]['name']
90        hint = 'The owner will change to %s' % ticket['reporter']
91        control = tag('')
92        return (label, control, hint)
93
94    def get_ticket_changes(self, req, ticket, action):
95        """Returns the change of owner."""
96        return {'owner': ticket['reporter']}
97
98
99class TicketWorkflowOpOwnerComponent(TicketWorkflowOpBase):
100    """Sets the owner to the default owner for the component.
101
102    <someaction>.operations = set_owner_to_component_owner
103
104    Don't forget to add the `TicketWorkflowOpOwnerComponent` to the workflow
105    option in [ticket].
106    If there is no workflow option, the line will look like this:
107
108    workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerComponent
109    """
110
111    _op_name = 'set_owner_to_component_owner'
112
113    # ITicketActionController methods
114
115    def render_ticket_action_control(self, req, ticket, action):
116        """Returns the action control"""
117        actions = self.get_configurable_workflow().actions
118        label = actions[action]['name']
119        hint = 'The owner will change to %s' % self._new_owner(ticket)
120        control = tag('')
121        return (label, control, hint)
122
123    def get_ticket_changes(self, req, ticket, action):
124        """Returns the change of owner."""
125        return {'owner': self._new_owner(ticket)}
126
127    def _new_owner(self, ticket):
128        """Determines the new owner"""
129        component = model.Component(self.env, name=ticket['component'])
130        self.env.log.debug("component %s, owner %s" % (component, component.owner))
131        return component.owner
132
133
134class TicketWorkflowOpOwnerField(TicketWorkflowOpBase):
135    """Sets the owner to the value of a ticket field
136
137    <someaction>.operations = set_owner_to_field
138    <someaction>.set_owner_to_field = myfield
139
140    Don't forget to add the `TicketWorkflowOpOwnerField` to the workflow
141    option in [ticket].
142    If there is no workflow option, the line will look like this:
143
144    workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerField
145    """
146
147    _op_name = 'set_owner_to_field'
148
149    # ITicketActionController methods
150
151    def render_ticket_action_control(self, req, ticket, action):
152        """Returns the action control"""
153        actions = self.get_configurable_workflow().actions
154        label = actions[action]['name']
155        hint = 'The owner will change to %s' % self._new_owner(action, ticket)
156        control = tag('')
157        return (label, control, hint)
158
159    def get_ticket_changes(self, req, ticket, action):
160        """Returns the change of owner."""
161        return {'owner': self._new_owner(action, ticket)}
162
163    def _new_owner(self, action, ticket):
164        """Determines the new owner"""
165        # Should probably do some sanity checking...
166        field = self.config.get('ticket-workflow',
167                                action + '.' + self._op_name).strip()
168        return ticket[field]
169
170
171class TicketWorkflowOpOwnerPrevious(TicketWorkflowOpBase):
172    """Sets the owner to the previous owner
173
174    Don't forget to add the `TicketWorkflowOpOwnerPrevious` to the workflow
175    option in [ticket].
176    If there is no workflow option, the line will look like this:
177
178    workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerPrevious
179    """
180
181    _op_name = 'set_owner_to_previous'
182
183    # ITicketActionController methods
184
185    def render_ticket_action_control(self, req, ticket, action):
186        """Returns the action control"""
187        actions = self.get_configurable_workflow().actions
188        label = actions[action]['name']
189        new_owner = self._new_owner(ticket)
190        if new_owner:
191            hint = 'The owner will change to %s' % new_owner
192        else:
193            hint = 'The owner will be deleted.'
194        control = tag('')
195        return (label, control, hint)
196
197    def get_ticket_changes(self, req, ticket, action):
198        """Returns the change of owner."""
199        return {'owner': self._new_owner(ticket)}
200
201    def _new_owner(self, ticket):
202        """Determines the new owner"""
203        db = self.env.get_db_cnx()
204        cursor = db.cursor()
205        cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \
206                       "AND field='owner' ORDER BY -time", (ticket.id, ))
207        row = cursor.fetchone()
208        if row:
209            owner = row[0]
210        else: # The owner has never changed.
211            owner = ticket['owner']
212        return owner
213
214
215class TicketWorkflowOpStatusPrevious(TicketWorkflowOpBase):
216    """Sets the status to the previous status
217
218    Don't forget to add the `TicketWorkflowOpStatusPrevious` to the workflow
219    option in [ticket].
220    If there is no workflow option, the line will look like this:
221
222    workflow = ConfigurableTicketWorkflow,TicketWorkflowOpStatusPrevious
223    """
224
225    _op_name = 'set_status_to_previous'
226
227    # ITicketActionController methods
228
229    def render_ticket_action_control(self, req, ticket, action):
230        """Returns the action control"""
231        actions = self.get_configurable_workflow().actions
232        label = actions[action]['name']
233        new_status = self._new_status(ticket)
234        if new_status != self._old_status(ticket):
235            hint = 'The status will change to %s' % new_status
236        else:
237            hint = ''
238        control = tag('')
239        return (label, control, hint)
240
241    def get_ticket_changes(self, req, ticket, action):
242        """Returns the change of status."""
243        return {'status': self._new_status(ticket)}
244
245    def _old_status(self, ticket):
246        """Determines what the ticket state was (is)"""
247        return ticket._old.get('status', ticket['status'])
248
249    def _new_status(self, ticket):
250        """Determines the new status"""
251        db = self.env.get_db_cnx()
252        cursor = db.cursor()
253        cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \
254                       "AND field='status' ORDER BY -time", (ticket.id, ))
255        row = cursor.fetchone()
256        if row:
257            status = row[0]
258        else: # The status has never changed.
259            status = 'new'
260        return status
261
262
263class TicketWorkflowOpRunExternal(TicketWorkflowOpBase):
264    """Action to allow running an external command as a side-effect.
265
266    If it is a lengthy task, it should daemonize so the webserver can get back
267    to doing its thing.  If the script exits with a non-zero return code, an
268    error will be logged to the Trac log.
269    The plugin will look for a script named <tracenv>/hooks/<someaction>, and
270    will pass it 2 parameters: the ticket number, and the user.
271
272    <someaction>.operations = run_external
273    <someaction>.run_external = Hint for the user
274
275    Don't forget to add the `TicketWorkflowOpRunExternal` to the workflow
276    option in [ticket].
277    If there is no workflow option, the line will look like this:
278
279    workflow = ConfigurableTicketWorkflow,TicketWorkflowOpRunExternal
280    """
281
282    implements(ITicketActionController)
283
284    # ITicketActionController methods
285
286    def get_ticket_actions(self, req, ticket):
287        """Finds the actions that use this operation"""
288        controller = self.get_configurable_workflow()
289        return controller.get_actions_by_operation_for_req(req, ticket,
290                                                           'run_external')
291
292    def get_all_status(self):
293        """Provide any additional status values"""
294        # We don't have anything special here; the statuses will be recognized
295        # by the default controller.
296        return []
297
298    def render_ticket_action_control(self, req, ticket, action):
299        """Returns the action control"""
300        actions = self.get_configurable_workflow().actions
301        label = actions[action]['name']
302        hint = self.config.get('ticket-workflow',
303                               action + '.run_external').strip()
304        if hint is None:
305            hint = "Will run external script."
306        return (label, tag(''), hint)
307
308    def get_ticket_changes(self, req, ticket, action):
309        """No changes to the ticket"""
310        return {}
311
312    def apply_action_side_effects(self, req, ticket, action):
313        """Run the external script"""
314        print "running external script for %s" % action
315        script = os.path.join(self.env.path, 'hooks', action)
316        for extension in ('', '.exe', '.cmd', '.bat'):
317            if os.path.exists(script + extension):
318                script += extension
319                break
320        else:
321            self.env.log.error("Error in ticket workflow config; could not find external command to run for %s in %s" % (action, os.path.join(self.env.path, 'hooks')))
322            return
323        retval = call([script, str(ticket.id), req.authname])
324        if retval:
325            self.env.log.error("External script %r exited with return code %s." % (script, retval))
326
327
328class TicketWorkflowOpTriage(TicketWorkflowOpBase):
329    """Action to split a workflow based on a field
330
331    <someaction> = somestatus -> *
332    <someaction>.operations = triage
333    <someaction>.triage_field = type
334    <someaction>.traige_split = defect -> new_defect, task -> new_task, enhancement -> new_enhancement
335
336    Don't forget to add the `TicketWorkflowOpTriage` to the workflow option in
337    [ticket].
338    If there is no workflow option, the line will look like this:
339
340    workflow = ConfigurableTicketWorkflow,TicketWorkflowOpTriage
341    """
342
343    _op_name = 'triage'
344
345    # ITicketActionController methods
346
347    def render_ticket_action_control(self, req, ticket, action):
348        """Returns the action control"""
349        actions = self.get_configurable_workflow().actions
350        label = actions[action]['name']
351        new_status = self._new_status(ticket, action)
352        if new_status != ticket['status']:
353            hint = 'The status will change to %s.' % new_status
354        else:
355            hint = ''
356        control = tag('')
357        return (label, control, hint)
358
359    def get_ticket_changes(self, req, ticket, action):
360        """Returns the change of status."""
361        return {'status': self._new_status(ticket, action)}
362
363    def _new_status(self, ticket, action):
364        """Determines the new status"""
365        field = self.config.get('ticket-workflow',
366                                action + '.triage_field').strip()
367        transitions = self.config.get('ticket-workflow',
368                                      action + '.triage_split').strip()
369        for transition in [x.strip() for x in transitions.split(',')]:
370            value, status = [y.strip() for y in transition.split('->')]
371            if value == ticket[field].strip():
372                break
373        else:
374            self.env.log.error("Bad configuration for 'triage' operation in action '%s'" % action)
375            status = 'new'
376        return status
377
378
379class TicketWorkflowOpXRef(TicketWorkflowOpBase):
380    """Adds a cross reference to another ticket
381
382    <someaction>.operations = xref
383    <someaction>.xref = "Ticket %s is related to this ticket"
384    <someaction>.xref_local = "Ticket %s was marked as related to this ticket"
385    <someaction>.xref_hint = "The specified ticket will be cross-referenced with this ticket"
386
387    The example values shown are the default values.
388    Don't forget to add the `TicketWorkflowOpXRef` to the workflow
389    option in [ticket].
390    If there is no workflow option, the line will look like this:
391
392    workflow = ConfigurableTicketWorkflow,TicketWorkflowOpXRef
393    """
394
395    _op_name = 'xref'
396
397    # ITicketActionController methods
398
399    def render_ticket_action_control(self, req, ticket, action):
400        """Returns the action control"""
401        id = 'action_%s_xref' % action
402        ticketnum = req.args.get(id, '')
403        actions = self.get_configurable_workflow().actions
404        label = actions[action]['name']
405        hint = actions[action].get('xref_hint',
406            'The specified ticket will be cross-referenced with this ticket')
407        control = tag.input(type='text', id=id, name=id, value=ticketnum)
408        return (label, control, hint)
409
410    def get_ticket_changes(self, req, ticket, action):
411        # WARNING: Directly modifying the ticket in this method breaks the
412        # intent of this method.  But it does accomplish the desired goal.
413        if not 'preview' in req.args:
414            id = 'action_%s_xref' % action
415            ticketnum = req.args.get(id).strip('#')
416
417            try:
418                xticket = model.Ticket(self.env, ticketnum)
419            except ValueError:
420                req.args['preview'] = True
421                add_warning(req, 'The cross-referenced ticket number "%s" was not a number' % ticketnum)
422                return {}
423            except ResourceNotFound, e:
424                #put in preview mode to prevent ticket being saved
425                req.args['preview'] = True
426                add_warning(req, "Unable to cross-reference Ticket #%s (%s)." % (ticketnum, e.message))
427                return {}
428
429            oldcomment = req.args.get('comment')
430            actions = self.get_configurable_workflow().actions
431            format_string = actions[action].get('xref_local',
432                'Ticket %s was marked as related to this ticket')
433            # Add a comment to this ticket to indicate that the "remote" ticket is
434            # related to it.  (But only if <action>.xref_local was set in the
435            # config.)
436            if format_string:
437                comment = format_string % ('#%s' % ticketnum)
438                req.args['comment'] = "%s%s%s" % \
439                    (comment, oldcomment and "[[BR]]" or "", oldcomment or "")
440
441        """Returns no changes."""
442        return {}
443
444    def apply_action_side_effects(self, req, ticket, action):
445        """Add a cross-reference comment to the other ticket"""
446        # TODO: This needs a lot more error checking.
447        id = 'action_%s_xref' % action
448        ticketnum = req.args.get(id).strip('#')
449        actions = self.get_configurable_workflow().actions
450        author = req.authname
451
452        # Add a comment to the "remote" ticket to indicate this ticket is
453        # related to it.
454        format_string = actions[action].get('xref',
455                                        'Ticket %s is related to this ticket')
456        comment = format_string % ('#%s' % ticket.id)
457        # FIXME: we need a cnum to avoid messing up
458        xticket = model.Ticket(self.env, ticketnum)
459        # FIXME: We _assume_ we have sufficient permissions to comment on the
460        # other ticket.
461        now = datetime.now(utc)
462        xticket.save_changes(author, comment, now)
463
464        #Send notification on the other ticket
465        try:
466            tn = TicketNotifyEmail(self.env)
467            tn.notify(xticket, newticket=False, modtime=now)
468        except Exception, e:
469            self.log.exception("Failure sending notification on change to "
470                               "ticket #%s: %s" % (ticketnum, e))
471
472
473class TicketWorkflowOpResetMilestone(TicketWorkflowOpBase):
474    """Resets the ticket milestone if it is assigned to a completed milestone.
475    This is useful for reopen operations.
476
477    reopened = closed -> reopened
478    reopened.name = Reopened
479    reopened.operations = reset_milestone
480
481
482    Don't forget to add the `TicketWorkflowOpResetMilestone` to the  workflow
483    option in [ticket].
484    If there is no workflow option, the line will look like this:
485
486    workflow =  ConfigurableTicketWorkflow,TicketWorkflowOpResetMilestone
487    """
488
489    _op_name = 'reset_milestone'
490
491    # ITicketActionController methods
492
493    def render_ticket_action_control(self, req, ticket, action):
494        """Returns the action control"""
495        actions = self.get_configurable_workflow().actions
496        label = actions[action]['name']
497        # check if the assigned milestone has been completed
498        milestone = Milestone(self.env,ticket['milestone'])
499        if milestone.is_completed:
500            hint = 'The milestone will be reset'
501        else:
502            hint = ''
503        control = tag('')
504        return (label, control, hint)
505
506    def get_ticket_changes(self, req, ticket, action):
507        """Returns the change of milestone, if needed."""
508        milestone = Milestone(self.env,ticket['milestone'])
509        if milestone.is_completed:
510            return {'milestone': ''}
511        return {}
Note: See TracBrowser for help on using the repository browser.