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

Last change on this file since 14744 was 14744, checked in by osimons, 9 years ago

XmlRpcPlugin: Formatting of 'owner' in ticket action controllers has changed.

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