source: tagsplugin/tags/0.9/tractags/ticket.py

Last change on this file was 16054, checked in by Jun Omae, 7 years ago

0.9dev: fix unit tests with Trac 1.0 on Python 2.5

File size: 11.2 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006 Alec Thomas <alec@swapoff.org>
4# Copyright (C) 2011-2014 Steffen Hoffmann <hoff.st@web.de>
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution.
8#
9
10from __future__ import with_statement
11
12from itertools import groupby
13
14from trac.config import BoolOption, ListOption
15from trac.core import implements
16from trac.perm import PermissionError
17from trac.resource import Resource
18from trac.ticket.api import ITicketChangeListener, TicketSystem
19from trac.ticket.model import Ticket
20from trac.util import get_reporter_id
21from trac.util.text import to_unicode
22
23from tractags.api import DefaultTagProvider, _
24from tractags.model import delete_tags
25from tractags.util import MockReq, split_into_tags
26
27
28class TicketTagProvider(DefaultTagProvider):
29    """[main] Tag provider using ticket fields as sources of tags.
30
31    Relevant ticket data is initially copied to plugin's own tag db store for
32    more efficient regular access, that matters especially when working with
33    large ticket quantities, kept current using ticket change listener events.
34
35    Currently does NOT support custom fields.
36    """
37
38    implements(ITicketChangeListener)
39
40#    custom_fields = ListOption('tags', 'custom_ticket_fields',
41#        doc=_("List of custom ticket fields to expose as tags."))
42
43    fields = ListOption('tags', 'ticket_fields', 'keywords',
44        doc=_("List of ticket fields to expose as tags."))
45
46    ignore_closed_tickets = BoolOption('tags', 'ignore_closed_tickets', True,
47        _("Do not collect tags from closed tickets."))
48
49    map = {'view': 'TICKET_VIEW', 'modify': 'TICKET_CHGPROP'}
50    realm = 'ticket'
51    use_cache = False
52
53    def __init__(self):
54        try:
55            self._fetch_tkt_tags()
56        except self.env.db_exc.IntegrityError, e:
57            self.log.warn('tags for ticket already exist: %s', to_unicode(e))
58        cfg = self.config
59        cfg_key = 'permission_policies'
60        default_policies = cfg.defaults().get('trac', {}).get(cfg_key)
61        self.fast_permcheck = all(p in default_policies for
62                                  p in cfg.get('trac', cfg_key))
63
64    def _check_permission(self, req, resource, action):
65        """Optionally coarse-grained permission check."""
66        if self.fast_permcheck or not (resource and resource.id):
67            perm = req.perm('ticket')
68        else:
69            perm = req.perm(resource)
70        return self.check_permission(perm, action) and \
71               self.map[action] in perm
72
73    def get_tagged_resources(self, req, tags=None, filter=None):
74        if not self._check_permission(req, None, 'view'):
75            return
76
77        if not tags:
78            # Cache 'all tagged resources' for better performance.
79            for resource, tags in self._tagged_resources:
80                if self.fast_permcheck or \
81                        self._check_permission(req, resource, 'view'):
82                    yield resource, tags
83        else:
84            for name, tags in groupby(self.env.db_query("""
85                    SELECT ts.name, ts.tag FROM tags
86                     LEFT JOIN tags ts ON (tags.tagspace=ts.tagspace
87                                           AND tags.name=ts.name)
88                    WHERE tags.tagspace=%%s AND tags.tag IN (%s)
89                    ORDER by ts.name
90                    """ % ', '.join(['%s'] * len(tags)),
91                    [self.realm] + list(tags)), lambda row: row[0]):
92                resource = Resource(self.realm, name)
93                if self.fast_permcheck or \
94                        self._check_permission(req, resource, 'view'):
95                    yield resource, set([tag[1] for tag in tags])
96
97    def get_resource_tags(self, req, resource):
98        assert resource.realm == self.realm
99        ticket = Ticket(self.env, resource.id)
100        if not self._check_permission(req, ticket.resource, 'view'):
101            return
102        return self._ticket_tags(ticket)
103
104    def set_resource_tags(self, req, ticket_or_resource, tags, comment=u'',
105                          when=None):
106        try:
107            resource = ticket_or_resource.resource
108        except AttributeError:
109            resource = ticket_or_resource
110            assert resource.realm == self.realm
111            if not self._check_permission(req, resource, 'modify'):
112                raise PermissionError(resource=resource, env=self.env)
113            tag_set = set(tags)
114            # Processing a call from TracTags, try to alter the ticket.
115            tkt = Ticket(self.env, resource.id)
116            all = self._ticket_tags(tkt)
117            # Avoid unnecessary ticket changes, considering comments below.
118            if tag_set != all:
119                # Will only alter tags in 'keywords' ticket field.
120                keywords = split_into_tags(tkt['keywords'])
121                # Assume, that duplication is depreciated and consolitation
122                # wanted to primarily affect 'keywords' ticket field.
123                # Consequently updating ticket tags and reducing (tag)
124                # 'ticket_fields' afterwards may result in undesired tag loss.
125                tag_set.difference_update(all.difference(keywords))
126                tkt['keywords'] = u' '.join(sorted(map(to_unicode, tag_set)))
127                tkt.save_changes(get_reporter_id(req), comment)
128        else:
129            # Processing a change listener event.
130            tags = self._ticket_tags(ticket_or_resource)
131            super(TicketTagProvider,
132                  self).set_resource_tags(req, resource, tags)
133
134    def remove_resource_tags(self, req, ticket_or_resource, comment=u''):
135        try:
136            resource = ticket_or_resource.resource
137        except AttributeError:
138            resource = ticket_or_resource
139            assert resource.realm == self.realm
140            if not self._check_permission(req, resource, 'modify'):
141                raise PermissionError(resource=resource, env=self.env)
142            # Processing a call from TracTags, try to alter the ticket.
143            ticket = Ticket(self.env, resource.id)
144            # Can only alter tags in 'keywords' ticket field.
145            # DEVEL: Time to differentiate managed and sticky/unmanaged tags?
146            ticket['keywords'] = u''
147            ticket.save_changes(get_reporter_id(req), comment)
148        else:
149            # Processing a change listener event.
150            super(TicketTagProvider, self).remove_resource_tags(req, resource)
151
152    def describe_tagged_resource(self, req, resource):
153        if not self.check_permission(req.perm, 'view'):
154            return ''
155        ticket = Ticket(self.env, resource.id)
156        if ticket.exists:
157            # Use the corresponding IResourceManager.
158            ticket_system = TicketSystem(self.env)
159            return ticket_system.get_resource_description(ticket.resource,
160                                                          format='summary')
161        else:
162            return ''
163
164    # ITicketChangeListener methods
165
166    def ticket_created(self, ticket):
167        """Called when a ticket is created."""
168        req = MockReq(authname=ticket['reporter'])
169        # Add any tags unconditionally.
170        self.set_resource_tags(req, ticket, None, ticket['time'])
171        if self.use_cache:
172            # Invalidate resource cache.
173            del self._tagged_resources
174
175    def ticket_changed(self, ticket, comment, author, old_values):
176        """Called when a ticket is modified."""
177        req = MockReq(authname=author)
178        # Sync only on change of ticket fields, that are exposed as tags.
179        if any(f in self.fields for f in old_values.keys()):
180            self.set_resource_tags(req, ticket, None, ticket['changetime'])
181            if self.use_cache:
182                # Invalidate resource cache.
183                del self._tagged_resources
184
185    def ticket_deleted(self, ticket):
186        """Called when a ticket is deleted."""
187        # Ticket gone, so remove all records on it.
188        delete_tags(self.env, ticket.resource, purge=True)
189        if self.use_cache:
190            # Invalidate resource cache.
191            del self._tagged_resources
192
193    # Private methods
194
195    def _fetch_tkt_tags(self):
196        """Transfer all relevant ticket attributes to tags db table."""
197        # Initial sync is done by forced, stupid one-way mirroring.
198        # Data acquisition for this utilizes the known ticket tags query.
199        fields = ["COALESCE(%s, '')" % f for f in self.fields]
200        ignore = ''
201        if self.ignore_closed_tickets:
202            ignore = " AND status != 'closed'"
203        with self.env.db_query as db:
204            sql = """
205                  SELECT *
206                  FROM (SELECT id, %s, %s AS std_fields
207                        FROM ticket AS tkt
208                        WHERE NOT EXISTS (SELECT * FROM tags
209                                          WHERE tagspace=%%s AND name=%s)
210                        %s) AS s
211                  WHERE std_fields != ''
212                  ORDER BY id
213                  """ % (','.join(self.fields), db.concat(*fields),
214                         db.cast('tkt.id', 'text'), ignore)
215            # Obtain cursors for reading tickets and altering tags db table.
216            # DEVEL: Use appropriate cursor typs from Trac 1.0 db API.
217            ro_cursor = db.cursor()
218            rw_cursor = db.cursor()
219            # Delete tags for non-existent ticket
220            rw_cursor.execute("""
221                DELETE FROM tags
222                 WHERE tagspace=%%s
223                   AND NOT EXISTS (SELECT * FROM ticket AS tkt
224                                   WHERE tkt.id=%s%s)
225                """ % (db.cast('tags.name', 'int'), ignore),
226                (self.realm,))
227
228            ro_cursor.execute(sql, (self.realm,))
229
230            for row in ro_cursor:
231                tkt_id, ttags = row[0], ' '.join([f for f in row[1:-1] if f])
232                ticket_tags = split_into_tags(ttags)
233                rw_cursor.executemany("""
234                    INSERT INTO tags (tagspace, name, tag)
235                    VALUES (%s, %s, %s)
236                    """, [(self.realm, str(tkt_id), tag) for tag in ticket_tags])
237
238    try:
239        from trac.cache import cached
240        use_cache = True
241
242        @cached
243        def _tagged_resources(self):
244            """Cached version."""
245            resources = []
246            counter = 0
247            for name, tags in groupby(self.env.db_query("""
248                    SELECT name, tag FROM tags
249                    WHERE tagspace=%s ORDER by name
250                    """, (self.realm,)), lambda row: row[0]):
251                resource = Resource(self.realm, name)
252                resources.append((resource, set([tag[1] for tag in tags])))
253                counter += 1
254            return resources
255
256    except ImportError:
257        @property
258        def _tagged_resources(self):
259            """The old, uncached method."""
260            for name, tags in groupby(self.env.db_query("""
261                    SELECT name, tag FROM tags
262                    WHERE tagspace=%s ORDER by name
263                    """, (self.realm,)), lambda row: row[0]):
264                resource = Resource(self.realm, name)
265                yield resource, set([tag[1] for tag in tags])
266
267    def _ticket_tags(self, ticket):
268        return split_into_tags(
269            ' '.join(filter(None, [ticket[f] for f in self.fields])))
Note: See TracBrowser for help on using the repository browser.