source: xmlrpcplugin/trunk/tracrpc/ticket.py @ 9912

Last change on this file since 9912 was 9912, checked in by osimons, 13 years ago

XmlRpcPlugin: Added a ticket '_ts' token to detect mid-air collisions for ticket.update() calls.

Big thanks to stp for raising this issue and providing the patch + lots of new ticket tests to verify main ticket functionality.

Closes #5402

File size: 20.5 KB
Line 
1# -*- coding: utf-8 -*-
2"""
3License: BSD
4
5(c) 2005-2008 ::: Alec Thomas (alec@swapoff.org)
6(c) 2009      ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no)
7"""
8
9import inspect
10from datetime import datetime
11
12import genshi
13
14from trac.attachment import Attachment
15from trac.core import *
16from trac.perm import PermissionError
17from trac.resource import Resource, ResourceNotFound
18import trac.ticket.model as model
19import trac.ticket.query as query
20from trac.ticket.api import TicketSystem
21from trac.ticket.notification import TicketNotifyEmail
22from trac.ticket.web_ui import TicketModule
23from trac.web.chrome import add_warning
24from trac.util.datefmt import to_datetime, utc
25
26from tracrpc.api import IXMLRPCHandler, expose_rpc, Binary
27from tracrpc.util import StringIO, to_utimestamp
28
29__all__ = ['TicketRPC']
30
31class TicketRPC(Component):
32    """ An interface to Trac's ticketing system. """
33
34    implements(IXMLRPCHandler)
35
36    # IXMLRPCHandler methods
37    def xmlrpc_namespace(self):
38        return 'ticket'
39
40    def xmlrpc_methods(self):
41        yield (None, ((list,), (list, str)), self.query)
42        yield (None, ((list, datetime),), self.getRecentChanges)
43        yield (None, ((list, int),), self.getAvailableActions)
44        yield (None, ((list, int),), self.getActions)
45        yield (None, ((list, int),), self.get)
46        yield ('TICKET_CREATE', ((int, str, str),
47                                 (int, str, str, dict),
48                                 (int, str, str, dict, bool),
49                                 (int, str, str, dict, bool, datetime)),
50                      self.create)
51        yield (None, ((list, int, str),
52                      (list, int, str, dict),
53                      (list, int, str, dict, bool),
54                      (list, int, str, dict, bool, str),
55                      (list, int, str, dict, bool, str, datetime)),
56                      self.update)
57        yield (None, ((None, int),), self.delete)
58        yield (None, ((dict, int), (dict, int, int)), self.changeLog)
59        yield (None, ((list, int),), self.listAttachments)
60        yield (None, ((Binary, int, str),), self.getAttachment)
61        yield (None,
62               ((str, int, str, str, Binary, bool),
63                (str, int, str, str, Binary)),
64               self.putAttachment)
65        yield (None, ((bool, int, str),), self.deleteAttachment)
66        yield ('TICKET_VIEW', ((list,),), self.getTicketFields)
67
68    # Exported methods
69    def query(self, req, qstr='status!=closed'):
70        """
71        Perform a ticket query, returning a list of ticket ID's.
72        All queries will use stored settings for maximum number of results per
73        page and paging options. Use `max=n` to define number of results to
74        receive, and use `page=n` to page through larger result sets. Using
75        `max=0` will turn off paging and return all results.
76        """
77        q = query.Query.from_string(self.env, qstr)
78        ticket_realm = Resource('ticket')
79        out = []
80        for t in q.execute(req):
81            tid = t['id']
82            if 'TICKET_VIEW' in req.perm(ticket_realm(id=tid)):
83                out.append(tid)
84        return out
85
86    def getRecentChanges(self, req, since):
87        """Returns a list of IDs of tickets that have changed since timestamp."""
88        since = to_utimestamp(since)
89        db = self.env.get_db_cnx()
90        cursor = db.cursor()
91        cursor.execute('SELECT id FROM ticket'
92                       ' WHERE changetime >= %s', (since,))
93        result = []
94        ticket_realm = Resource('ticket')
95        for row in cursor:
96            tid = int(row[0])
97            if 'TICKET_VIEW' in req.perm(ticket_realm(id=tid)):
98                result.append(tid)
99        return result
100
101    def getAvailableActions(self, req, id):
102        """ Deprecated - will be removed. Replaced by `getActions()`. """
103        self.log.warning("Rpc ticket.getAvailableActions is deprecated")
104        return [action[0] for action in self.getActions(req, id)]
105
106    def getActions(self, req, id):
107        """Returns the actions that can be performed on the ticket as a list of
108        `[action, label, hints, [input_fields]]` elements, where `input_fields` is
109        a list of `[name, value, [options]]` for any required action inputs."""
110        ts = TicketSystem(self.env)
111        t = model.Ticket(self.env, id)
112        actions = []
113        for action in ts.get_available_actions(req, t):
114            fragment = genshi.builder.Fragment()
115            hints = []
116            first_label = None
117            for controller in ts.action_controllers:
118                if action in [c_action for c_weight, c_action \
119                                in controller.get_ticket_actions(req, t)]:
120                    label, widget, hint = \
121                        controller.render_ticket_action_control(req, t, action)
122                    fragment += widget
123                    hints.append(hint)
124                    first_label = first_label == None and label or first_label
125            controls = []
126            for elem in fragment.children:
127                if not isinstance(elem, genshi.builder.Element):
128                    continue
129                if elem.tag == 'input':
130                    controls.append((elem.attrib.get('name'),
131                                    elem.attrib.get('value'), []))
132                elif elem.tag == 'select':
133                    value = ''
134                    options = []
135                    for opt in elem.children:
136                        if not (opt.tag == 'option' and opt.children):
137                            continue
138                        option = opt.children[0]
139                        options.append(option)
140                        if opt.attrib.get('selected'):
141                            value = option
142                    controls.append((elem.attrib.get('name'),
143                                    value, options))
144            actions.append((action, first_label, ". ".join(hints) + '.', controls))
145        return actions
146
147    def get(self, req, id):
148        """ Fetch a ticket. Returns [id, time_created, time_changed, attributes]. """
149        t = model.Ticket(self.env, id)
150        req.perm(t.resource).require('TICKET_VIEW')
151        t['_ts'] = str(t.time_changed)
152        return (t.id, t.time_created, t.time_changed, t.values)
153
154    def create(self, req, summary, description, attributes={}, notify=False, when=None):
155        """ Create a new ticket, returning the ticket ID.
156        Overriding 'when' requires admin permission. """
157        t = model.Ticket(self.env)
158        t['summary'] = summary
159        t['description'] = description
160        t['reporter'] = req.authname
161        for k, v in attributes.iteritems():
162            t[k] = v
163        t['status'] = 'new'
164        t['resolution'] = ''
165        # custom create timestamp?
166        if when and not 'TICKET_ADMIN' in req.perm:
167            self.log.warn("RPC ticket.create: %r not allowed to create with "
168                    "non-current timestamp (%r)", req.authname, when)
169            when = None
170        t.insert(when=when)
171        # Call ticket change listeners
172        ts = TicketSystem(self.env)
173        for listener in ts.change_listeners:
174            listener.ticket_created(t)
175        if notify:
176            try:
177                tn = TicketNotifyEmail(self.env)
178                tn.notify(t, newticket=True)
179            except Exception, e:
180                self.log.exception("Failure sending notification on creation "
181                                   "of ticket #%s: %s" % (t.id, e))
182        return t.id
183
184    def update(self, req, id, comment, attributes={}, notify=False, author='', when=None):
185        """ Update a ticket, returning the new ticket in the same form as
186        get(). 'New-style' call requires two additional items in attributes:
187        (1) 'action' for workflow support (including any supporting fields
188        as retrieved by getActions()),
189        (2) '_ts' changetime token for detecting update collisions (as received
190        from get() or update() calls).
191        ''Calling update without 'action' and '_ts' changetime token is
192        deprecated, and will raise errors in a future version.'' """
193        t = model.Ticket(self.env, id)
194        # custom author?
195        if author and not (req.authname == 'anonymous' \
196                            or 'TICKET_ADMIN' in req.perm(t.resource)):
197            # only allow custom author if anonymous is permitted or user is admin
198            self.log.warn("RPC ticket.update: %r not allowed to change author "
199                    "to %r for comment on #%d", req.authname, author, id)
200            author = ''
201        author = author or req.authname
202        # custom change timestamp?
203        if when and not 'TICKET_ADMIN' in req.perm(t.resource):
204            self.log.warn("RPC ticket.update: %r not allowed to update #%d with "
205                    "non-current timestamp (%r)", author, id, when)
206            when = None
207        when = when or to_datetime(None, utc)
208        # and action...
209        if not 'action' in attributes:
210            # FIXME: Old, non-restricted update - remove soon!
211            self.log.warning("Rpc ticket.update for ticket %d by user %s " \
212                    "has no workflow 'action'." % (id, req.authname))
213            req.perm(t.resource).require('TICKET_MODIFY')
214            time_changed = attributes.pop('_ts', None)
215            if time_changed and str(time_changed) != str(t.time_changed):
216                raise TracError("Ticket has been updated since last get().")
217            for k, v in attributes.iteritems():
218                t[k] = v
219            t.save_changes(author, comment, when=when)
220        else:
221            ts = TicketSystem(self.env)
222            tm = TicketModule(self.env)
223            # TODO: Deprecate update without time_changed timestamp
224            time_changed = str(attributes.pop('_ts', t.time_changed))
225            action = attributes.get('action')
226            avail_actions = ts.get_available_actions(req, t)
227            if not action in avail_actions:
228                raise TracError("Rpc: Ticket %d by %s " \
229                        "invalid action '%s'" % (id, req.authname, action))
230            controllers = list(tm._get_action_controllers(req, t, action))
231            all_fields = [field['name'] for field in ts.get_ticket_fields()]
232            for k, v in attributes.iteritems():
233                if k in all_fields and k != 'status':
234                    t[k] = v
235            # TicketModule reads req.args - need to move things there...
236            req.args.update(attributes)
237            req.args['comment'] = comment
238            req.args['ts'] = time_changed
239            changes, problems = tm.get_ticket_changes(req, t, action)
240            for warning in problems:
241                add_warning(req, "Rpc ticket.update: %s" % warning)
242            valid = problems and False or tm._validate_ticket(req, t)
243            if not valid:
244                raise TracError(
245                    " ".join([warning for warning in req.chrome['warnings']]))
246            else:
247                tm._apply_ticket_changes(t, changes)
248                self.log.debug("Rpc ticket.update save: %s" % repr(t.values))
249                t.save_changes(author, comment, when=when)
250                # Apply workflow side-effects
251                for controller in controllers:
252                    controller.apply_action_side_effects(req, t, action)
253                # Call ticket change listeners
254                for listener in ts.change_listeners:
255                    listener.ticket_changed(t, comment, author, t._old)
256        if notify:
257            try:
258                tn = TicketNotifyEmail(self.env)
259                tn.notify(t, newticket=False, modtime=when)
260            except Exception, e:
261                self.log.exception("Failure sending notification on change of "
262                                   "ticket #%s: %s" % (t.id, e))
263        return self.get(req, t.id)
264
265    def delete(self, req, id):
266        """ Delete ticket with the given id. """
267        t = model.Ticket(self.env, id)
268        req.perm(t.resource).require('TICKET_ADMIN')
269        t.delete()
270        ts = TicketSystem(self.env)
271        # Call ticket change listeners
272        for listener in ts.change_listeners:
273            listener.ticket_deleted(t)
274
275    def changeLog(self, req, id, when=0):
276        t = model.Ticket(self.env, id)
277        req.perm(t.resource).require('TICKET_VIEW')
278        for date, author, field, old, new, permanent in t.get_changelog(when):
279            yield (date, author, field, old, new, permanent)
280    # Use existing documentation from Ticket model
281    changeLog.__doc__ = inspect.getdoc(model.Ticket.get_changelog)
282
283    def listAttachments(self, req, ticket):
284        """ Lists attachments for a given ticket. Returns (filename,
285        description, size, time, author) for each attachment."""
286        attachments = []
287        for a in Attachment.select(self.env, 'ticket', ticket):
288            if 'ATTACHMENT_VIEW' in req.perm(a.resource):
289                yield (a.filename, a.description, a.size, a.date, a.author)
290
291    def getAttachment(self, req, ticket, filename):
292        """ returns the content of an attachment. """
293        attachment = Attachment(self.env, 'ticket', ticket, filename)
294        req.perm(attachment.resource).require('ATTACHMENT_VIEW')
295        return Binary(attachment.open().read())
296
297    def putAttachment(self, req, ticket, filename, description, data, replace=True):
298        """ Add an attachment, optionally (and defaulting to) overwriting an
299        existing one. Returns filename."""
300        if not model.Ticket(self.env, ticket).exists:
301            raise ResourceNotFound('Ticket "%s" does not exist' % ticket)
302        if replace:
303            try:
304                attachment = Attachment(self.env, 'ticket', ticket, filename)
305                req.perm(attachment.resource).require('ATTACHMENT_DELETE')
306                attachment.delete()
307            except TracError:
308                pass
309        attachment = Attachment(self.env, 'ticket', ticket)
310        req.perm(attachment.resource).require('ATTACHMENT_CREATE')
311        attachment.author = req.authname
312        attachment.description = description
313        attachment.insert(filename, StringIO(data.data), len(data.data))
314        return attachment.filename
315
316    def deleteAttachment(self, req, ticket, filename):
317        """ Delete an attachment. """
318        if not model.Ticket(self.env, ticket).exists:
319            raise ResourceNotFound('Ticket "%s" does not exists' % ticket)
320        attachment = Attachment(self.env, 'ticket', ticket, filename)
321        req.perm(attachment.resource).require('ATTACHMENT_DELETE')
322        attachment.delete()
323        return True
324
325    def getTicketFields(self, req):
326        """ Return a list of all ticket fields fields. """
327        return TicketSystem(self.env).get_ticket_fields()
328
329class StatusRPC(Component):
330    """ An interface to Trac ticket status objects.
331    Note: Status is defined by workflow, and all methods except `getAll()`
332    are deprecated no-op methods - these will be removed later. """
333
334    implements(IXMLRPCHandler)
335
336    # IXMLRPCHandler methods
337    def xmlrpc_namespace(self):
338        return 'ticket.status'
339
340    def xmlrpc_methods(self):
341        yield ('TICKET_VIEW', ((list,),), self.getAll)
342        yield ('TICKET_VIEW', ((dict, str),), self.get)
343        yield ('TICKET_ADMIN', ((None, str,),), self.delete)
344        yield ('TICKET_ADMIN', ((None, str, dict),), self.create)
345        yield ('TICKET_ADMIN', ((None, str, dict),), self.update)
346
347    def getAll(self, req):
348        """ Returns all ticket states described by active workflow. """
349        return TicketSystem(self.env).get_all_status()
350   
351    def get(self, req, name):
352        """ Deprecated no-op method. Do not use. """
353        # FIXME: Remove
354        return '0'
355
356    def delete(self, req, name):
357        """ Deprecated no-op method. Do not use. """
358        # FIXME: Remove
359        return 0
360
361    def create(self, req, name, attributes):
362        """ Deprecated no-op method. Do not use. """
363        # FIXME: Remove
364        return 0
365
366    def update(self, req, name, attributes):
367        """ Deprecated no-op method. Do not use. """
368        # FIXME: Remove
369        return 0
370
371def ticketModelFactory(cls, cls_attributes):
372    """ Return a class which exports an interface to trac.ticket.model.<cls>. """
373    class TicketModelImpl(Component):
374        implements(IXMLRPCHandler)
375
376        def xmlrpc_namespace(self):
377            return 'ticket.' + cls.__name__.lower()
378
379        def xmlrpc_methods(self):
380            yield ('TICKET_VIEW', ((list,),), self.getAll)
381            yield ('TICKET_VIEW', ((dict, str),), self.get)
382            yield ('TICKET_ADMIN', ((None, str,),), self.delete)
383            yield ('TICKET_ADMIN', ((None, str, dict),), self.create)
384            yield ('TICKET_ADMIN', ((None, str, dict),), self.update)
385
386        def getAll(self, req):
387            for i in cls.select(self.env):
388                yield i.name
389        getAll.__doc__ = """ Get a list of all ticket %s names. """ % cls.__name__.lower()
390
391        def get(self, req, name):
392            i = cls(self.env, name)
393            attributes= {}
394            for k, default in cls_attributes.iteritems():
395                v = getattr(i, k)
396                if v is None:
397                    v = default
398                attributes[k] = v
399            return attributes
400        get.__doc__ = """ Get a ticket %s. """ % cls.__name__.lower()
401
402        def delete(self, req, name):
403            cls(self.env, name).delete()
404        delete.__doc__ = """ Delete a ticket %s """ % cls.__name__.lower()
405
406        def create(self, req, name, attributes):
407            i = cls(self.env)
408            i.name = name
409            for k, v in attributes.iteritems():
410                setattr(i, k, v)
411            i.insert();
412        create.__doc__ = """ Create a new ticket %s with the given attributes. """ % cls.__name__.lower()
413
414        def update(self, req, name, attributes):
415            self._updateHelper(name, attributes).update()
416        update.__doc__ = """ Update ticket %s with the given attributes. """ % cls.__name__.lower()
417
418        def _updateHelper(self, name, attributes):
419            i = cls(self.env, name)
420            for k, v in attributes.iteritems():
421                setattr(i, k, v)
422            return i
423    TicketModelImpl.__doc__ = """ Interface to ticket %s objects. """ % cls.__name__.lower()
424    TicketModelImpl.__name__ = '%sRPC' % cls.__name__
425    return TicketModelImpl
426
427def ticketEnumFactory(cls):
428    """ Return a class which exports an interface to one of the Trac ticket abstract enum types. """
429    class AbstractEnumImpl(Component):
430        implements(IXMLRPCHandler)
431
432        def xmlrpc_namespace(self):
433            return 'ticket.' + cls.__name__.lower()
434
435        def xmlrpc_methods(self):
436            yield ('TICKET_VIEW', ((list,),), self.getAll)
437            yield ('TICKET_VIEW', ((str, str),), self.get)
438            yield ('TICKET_ADMIN', ((None, str,),), self.delete)
439            yield ('TICKET_ADMIN', ((None, str, str),), self.create)
440            yield ('TICKET_ADMIN', ((None, str, str),), self.update)
441
442        def getAll(self, req):
443            for i in cls.select(self.env):
444                yield i.name
445        getAll.__doc__ = """ Get a list of all ticket %s names. """ % cls.__name__.lower()
446
447        def get(self, req, name):
448            if (cls.__name__ == 'Status'):
449               i = cls(self.env)
450               x = name
451            else: 
452               i = cls(self.env, name)
453               x = i.value
454            return x
455        get.__doc__ = """ Get a ticket %s. """ % cls.__name__.lower()
456
457        def delete(self, req, name):
458            cls(self.env, name).delete()
459        delete.__doc__ = """ Delete a ticket %s """ % cls.__name__.lower()
460
461        def create(self, req, name, value):
462            i = cls(self.env)
463            i.name = name
464            i.value = value
465            i.insert()
466        create.__doc__ = """ Create a new ticket %s with the given value. """ % cls.__name__.lower()
467
468        def update(self, req, name, value):
469            self._updateHelper(name, value).update()
470        update.__doc__ = """ Update ticket %s with the given value. """ % cls.__name__.lower()
471
472        def _updateHelper(self, name, value):
473            i = cls(self.env, name)
474            i.value = value
475            return i
476
477    AbstractEnumImpl.__doc__ = """ Interface to ticket %s. """ % cls.__name__.lower()
478    AbstractEnumImpl.__name__ = '%sRPC' % cls.__name__
479    return AbstractEnumImpl
480
481ticketModelFactory(model.Component, {'name': '', 'owner': '', 'description': ''})
482ticketModelFactory(model.Version, {'name': '', 'time': 0, 'description': ''})
483ticketModelFactory(model.Milestone, {'name': '', 'due': 0, 'completed': 0, 'description': ''})
484
485ticketEnumFactory(model.Type)
486ticketEnumFactory(model.Resolution)
487ticketEnumFactory(model.Priority)
488ticketEnumFactory(model.Severity)
Note: See TracBrowser for help on using the repository browser.