Edgewall Software

root/trunk/trac/ticket/api.py

Revision 7644, 18.0 KB (checked in by cboos, 3 weeks ago)

0.12dev: merge of 0.11-stable (corresponds to [7638-7643])

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