Edgewall Software

root/trunk/trac/ticket/api.py

Revision 8170, 17.8 KB (checked in by cboos, 2 months ago)

0.12dev: ported r8153 and r8167 from 0.11-stable. r8161 has issues related to  #G300, so it's not merged for now.

  • Property svn:eol-style set to native
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2009 Edgewall Software
4# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
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. The terms
9# are also available at http://trac.edgewall.org/wiki/TracLicense.
10#
11# This software consists of voluntary contributions made by many
12# individuals. For the exact contribution history, see the revision
13# history and logs, available at http://trac.edgewall.org/log/.
14#
15# Author: Jonas Borgström <jonas@edgewall.com>
16
17import re
18from datetime import datetime
19
20from genshi.builder import tag
21
22from trac.cache import cached, cached_value
23from trac.config import *
24from trac.core import *
25from trac.perm import IPermissionRequestor, PermissionCache, PermissionSystem
26from trac.resource import IResourceManager
27from trac.util import Ranges
28from trac.util.datefmt import utc
29from trac.util.text import shorten_line, obfuscate_email_address
30from trac.util.translation import _
31from trac.wiki import IWikiSyntaxProvider, WikiParser
32
33
34class ITicketActionController(Interface):
35    """Extension point interface for components willing to participate
36    in the ticket workflow.
37
38    This is mainly about controlling the changes to the ticket ''status'',
39    though not restricted to it.
40    """
41
42    def get_ticket_actions(req, ticket):
43        """Return an iterable of `(weight, action)` tuples corresponding to
44        the actions that are contributed by this component.
45        That list may vary given the current state of the ticket and the
46        actual request parameter.
47
48        `action` is a key used to identify that particular action.
49        (note that 'history' and 'diff' are reserved and should not be used
50        by plugins)
51       
52        The actions will be presented on the page in descending order of the
53        integer weight. The first action in the list is used as the default
54        action.
55
56        When in doubt, use a weight of 0."""
57
58    def get_all_status():
59        """Returns an iterable of all the possible values for the ''status''
60        field this action controller knows about.
61
62        This will be used to populate the query options and the like.
63        It is assumed that the initial status of a ticket is 'new' and
64        the terminal status of a ticket is 'closed'.
65        """
66
67    def render_ticket_action_control(req, ticket, action):
68        """Return a tuple in the form of `(label, control, hint)`
69
70        `label` is a short text that will be used when listing the action,
71        `control` is the markup for the action control and `hint` should
72        explain what will happen if this action is taken.
73       
74        This method will only be called if the controller claimed to handle
75        the given `action` in the call to `get_ticket_actions`.
76
77        Note that the radio button for the action has an `id` of
78        `"action_%s" % action`.  Any `id`s used in `control` need to be made
79        unique.  The method used in the default ITicketActionController is to
80        use `"action_%s_something" % action`.
81        """
82
83    def get_ticket_changes(req, ticket, action):
84        """Return a dictionary of ticket field changes.
85
86        This method must not have any side-effects because it will also
87        be called in preview mode (`req.args['preview']` will be set, then).
88        See `apply_action_side_effects` for that. If the latter indeed triggers
89        some side-effects, it is advised to emit a warning
90        (`trac.web.chrome.add_warning(req, reason)`) when this method is called
91        in preview mode.
92
93        This method will only be called if the controller claimed to handle
94        the given `action` in the call to `get_ticket_actions`.
95        """
96
97    def apply_action_side_effects(req, ticket, action):
98        """Perform side effects once all changes have been made to the ticket.
99
100        Multiple controllers might be involved, so the apply side-effects
101        offers a chance to trigger a side-effect based on the given `action`
102        after the new state of the ticket has been saved.
103
104        This method will only be called if the controller claimed to handle
105        the given `action` in the call to `get_ticket_actions`.
106        """
107
108
109class ITicketChangeListener(Interface):
110    """Extension point interface for components that require notification
111    when tickets are created, modified, or deleted."""
112
113    def ticket_created(ticket):
114        """Called when a ticket is created."""
115
116    def ticket_changed(ticket, comment, author, old_values):
117        """Called when a ticket is modified.
118       
119        `old_values` is a dictionary containing the previous values of the
120        fields that have changed.
121        """
122
123    def ticket_deleted(ticket):
124        """Called when a ticket is deleted."""
125
126
127class ITicketManipulator(Interface):
128    """Miscellaneous manipulation of ticket workflow features."""
129
130    def prepare_ticket(req, ticket, fields, actions):
131        """Not currently called, but should be provided for future
132        compatibility."""
133
134    def validate_ticket(req, ticket):
135        """Validate a ticket after it's been populated from user input.
136       
137        Must return a list of `(field, message)` tuples, one for each problem
138        detected. `field` can be `None` to indicate an overall problem with the
139        ticket. Therefore, a return value of `[]` means everything is OK."""
140
141
142class TicketSystem(Component):
143    implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager)
144
145    change_listeners = ExtensionPoint(ITicketChangeListener)
146    action_controllers = OrderedExtensionsOption('ticket', 'workflow',
147        ITicketActionController, default='ConfigurableTicketWorkflow',
148        include_missing=False,
149        doc="""Ordered list of workflow controllers to use for ticket actions
150            (''since 0.11'').""")
151
152    restrict_owner = BoolOption('ticket', 'restrict_owner', 'false',
153        """Make the owner field of tickets use a drop-down menu. See
154        [TracTickets#Assign-toasDrop-DownList Assign-to as Drop-Down List]
155        (''since 0.9'').""")
156
157    def __init__(self):
158        self.log.debug('action controllers for ticket workflow: %r' % 
159                [c.__class__.__name__ for c in self.action_controllers])
160
161    # Public API
162
163    def get_available_actions(self, req, ticket):
164        """Returns a sorted list of available actions"""
165        # The list should not have duplicates.
166        actions = {}
167        for controller in self.action_controllers:
168            weighted_actions = controller.get_ticket_actions(req, ticket)
169            for weight, action in weighted_actions:
170                if action in actions:
171                    actions[action] = max(actions[action], weight)
172                else:
173                    actions[action] = weight
174        all_weighted_actions = [(weight, action) for action, weight in
175                                actions.items()]
176        return [x[1] for x in sorted(all_weighted_actions, reverse=True)]
177
178    def get_all_status(self):
179        """Returns a sorted list of all the states all of the action
180        controllers know about."""
181        valid_states = set()
182        for controller in self.action_controllers:
183            valid_states.update(controller.get_all_status())
184        return sorted(valid_states)
185
186    def get_ticket_fields(self):
187        """Returns the list of fields available for tickets."""
188        return [f.copy() for f in self.fields.get()]
189
190    def reset_ticket_fields(self, db=None):
191        """Invalidate ticket field cache."""
192        self.fields.invalidate(db)
193
194    @cached
195    def fields(self, db):
196        """Return the list of fields available for tickets."""
197        from trac.ticket import model
198
199        fields = []
200
201        # Basic text fields
202        for name in ('summary', 'reporter'):
203            field = {'name': name, 'type': 'text', 'label': name.title()}
204            fields.append(field)
205
206        # Owner field, by default text but can be changed dynamically
207        # into a drop-down depending on configuration (restrict_owner=true)
208        field = {'name': 'owner', 'label': 'Owner'}
209        field['type'] = 'text'
210        fields.append(field)
211
212        # Description
213        fields.append({'name': 'description', 'type': 'textarea',
214                       'label': _('Description')})
215
216        # Default select and radio fields
217        selects = [('type', model.Type),
218                   ('status', model.Status),
219                   ('priority', model.Priority),
220                   ('milestone', model.Milestone),
221                   ('component', model.Component),
222                   ('version', model.Version),
223                   ('severity', model.Severity),
224                   ('resolution', model.Resolution)]
225        for name, cls in selects:
226            options = [val.name for val in cls.select(self.env, db=db)]
227            if not options:
228                # Fields without possible values are treated as if they didn't
229                # exist
230                continue
231            field = {'name': name, 'type': 'select', 'label': name.title(),
232                     'value': self.config.get('ticket', 'default_' + name),
233                     'options': options}
234            if name in ('status', 'resolution'):
235                field['type'] = 'radio'
236                field['optional'] = True
237            elif name in ('milestone', 'version'):
238                field['optional'] = True
239            fields.append(field)
240
241        # Advanced text fields
242        for name in ('keywords', 'cc', ):
243            field = {'name': name, 'type': 'text', 'label': name.title()}
244            fields.append(field)
245
246        # Date/time fields
247        fields.append({'name': 'time', 'type': 'time',
248                       'label': _('Created')})
249        fields.append({'name': 'changetime', 'type': 'time',
250                       'label': _('Modified')})
251
252        for field in self.get_custom_fields():
253            if field['name'] in [f['name'] for f in fields]:
254                self.log.warning('Duplicate field name "%s" (ignoring)',
255                                 field['name'])
256                continue
257            if field['name'] in self.reserved_field_names:
258                self.log.warning('Field name "%s" is a reserved name '
259                                 '(ignoring)', field['name'])
260                continue
261            if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', field['name']):
262                self.log.warning('Invalid name for custom field: "%s" '
263                                 '(ignoring)', field['name'])
264                continue
265            field['custom'] = True
266            fields.append(field)
267
268        return fields
269
270    reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc',
271                            'col', 'row', 'format', 'max', 'page', 'verbose',
272                            'comment']
273
274    def get_custom_fields(self):
275        return [f.copy() for f in self.custom_fields]
276
277    @cached_value
278    def custom_fields(self, db):
279        """Return the list of custom ticket fields available for tickets."""
280        fields = []
281        config = self.config['ticket-custom']
282        for name in [option for option, value in config.options()
283                     if '.' not in option]:
284            field = {
285                'name': name,
286                'type': config.get(name),
287                'order': config.getint(name + '.order', 0),
288                'label': config.get(name + '.label') or name.capitalize(),
289                'value': config.get(name + '.value', '')
290            }
291            if field['type'] == 'select' or field['type'] == 'radio':
292                field['options'] = config.getlist(name + '.options', sep='|')
293                if '' in field['options']:
294                    field['optional'] = True
295                    field['options'].remove('')
296            elif field['type'] == 'text':
297                field['format'] = config.get(name + '.format', 'plain')
298            elif field['type'] == 'textarea':
299                field['format'] = config.get(name + '.format', 'plain')
300                field['width'] = config.getint(name + '.cols')
301                field['height'] = config.getint(name + '.rows')
302            fields.append(field)
303
304        fields.sort(lambda x, y: cmp(x['order'], y['order']))
305        return fields
306
307    def get_field_synonyms(self):
308        """Return a mapping from field name synonyms to field names.
309        The synonyms are supposed to be more intuitive for custom queries."""
310        return {'created': 'time', 'modified': 'changetime'}
311
312    def eventually_restrict_owner(self, field, ticket=None):
313        """Restrict given owner field to be a list of users having
314        the TICKET_MODIFY permission (for the given ticket)
315        """
316        if self.restrict_owner:
317            field['type'] = 'select'
318            possible_owners = []
319            for user in PermissionSystem(self.env) \
320                    .get_users_with_permission('TICKET_MODIFY'):
321                if not ticket or \
322                        'TICKET_MODIFY' in PermissionCache(self.env, user,
323                                                           ticket.resource):
324                    possible_owners.append(user)
325            possible_owners.sort()
326            field['options'] = possible_owners
327            field['optional'] = True
328
329    # IPermissionRequestor methods
330
331    def get_permission_actions(self):
332        return ['TICKET_APPEND', 'TICKET_CREATE', 'TICKET_CHGPROP',
333                'TICKET_VIEW', 'TICKET_EDIT_CC', 'TICKET_EDIT_DESCRIPTION',
334                ('TICKET_MODIFY', ['TICKET_APPEND', 'TICKET_CHGPROP']),
335                ('TICKET_ADMIN', ['TICKET_CREATE', 'TICKET_MODIFY',
336                                  'TICKET_VIEW', 'TICKET_EDIT_CC',
337                                  'TICKET_EDIT_DESCRIPTION'])]
338
339    # IWikiSyntaxProvider methods
340
341    def get_link_resolvers(self):
342        return [('bug', self._format_link),
343                ('ticket', self._format_link),
344                ('comment', self._format_comment_link)]
345
346    def get_wiki_syntax(self):
347        yield (
348            # matches #... but not &#... (HTML entity)
349            r"!?(?<!&)#"
350            # optional intertrac shorthand #T... + digits
351            r"(?P<it_ticket>%s)%s" % (WikiParser.INTERTRAC_SCHEME,
352                                      Ranges.RE_STR),
353            lambda x, y, z: self._format_link(x, 'ticket', y[1:], y, z))
354
355    def _format_link(self, formatter, ns, target, label, fullmatch=None):
356        intertrac = formatter.shorthand_intertrac_helper(ns, target, label,
357                                                         fullmatch)
358        if intertrac:
359            return intertrac
360        try:
361            link, params, fragment = formatter.split_link(target)
362            r = Ranges(link)
363            if len(r) == 1:
364                num = r.a
365                ticket = formatter.resource('ticket', num)
366                from trac.ticket.model import Ticket
367                if Ticket.id_is_valid(num) and \
368                        'TICKET_VIEW' in formatter.perm(ticket):
369                    # TODO: watch #6436 and when done, attempt to retrieve
370                    #       ticket directly (try: Ticket(self.env, num) ...)
371                    cursor = formatter.db.cursor() 
372                    cursor.execute("SELECT type,summary,status,resolution "
373                                   "FROM ticket WHERE id=%s", (str(num),)) 
374                    for type, summary, status, resolution in cursor:
375                        title = self.format_summary(summary, status,
376                                                    resolution, type)
377                        href = formatter.href.ticket(num) + params + fragment
378                        return tag.a(label, class_='%s ticket' % status, 
379                                     title=title, href=href)
380            else:
381                ranges = str(r)
382                if params:
383                    params = '&' + params[1:]
384                return tag.a(label, title='Tickets '+ranges,
385                             href=formatter.href.query(id=ranges) + params)
386        except ValueError:
387            pass
388        return tag.a(label, class_='missing ticket')
389
390    def _format_comment_link(self, formatter, ns, target, label):
391        resource = None
392        if ':' in target:
393            elts = target.split(':')
394            if len(elts) == 3:
395                cnum, realm, id = elts
396                if cnum != 'description' and cnum and not cnum[0].isdigit():
397                    realm, id, cnum = elts # support old comment: style
398                resource = formatter.resource(realm, id)
399        else:
400            resource = formatter.resource
401            cnum = target
402
403        if resource:
404            href = "%s#comment:%s" % (formatter.href.ticket(resource.id), cnum)
405            title = _("Comment %(cnum)s for Ticket #%(id)s", cnum=cnum,
406                      id=resource.id)
407            return tag.a(label, href=href, title=title)
408        else:
409            return label
410 
411    # IResourceManager methods
412
413    def get_resource_realms(self):
414        yield 'ticket'
415
416    def get_resource_description(self, resource, format=None, context=None,
417                                 **kwargs):
418        if format == 'compact':
419            return '#%s' % resource.id
420        elif format == 'summary':
421            from trac.ticket.model import Ticket
422            ticket = Ticket(self.env, resource.id)
423            args = [ticket[f] for f in ('summary', 'status', 'resolution',
424                                        'type')]
425            return self.format_summary(*args)
426        return _("Ticket #%(shortname)s", shortname=resource.id)
427
428    def format_summary(self, summary, status=None, resolution=None, type=None):
429        summary = shorten_line(summary)
430        if type:
431            summary = type + ': ' + summary
432        if status:
433            if status == 'closed' and resolution:
434                status += ': ' + resolution
435            return "%s (%s)" % (summary, status)
436        else:
437            return summary
Note: See TracBrowser for help on using the browser.