source: tagsplugin/trunk/tractags/ticket.py @ 13461

Last change on this file since 13461 was 13461, checked in by Steffen Hoffmann, 10 years ago

TagsPlugin: Make tag providers conform to interface definition, refs #11434.

File size: 12.1 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006 Alec Thomas <alec@swapoff.org>
4# Copyright (C) 2011-2013 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
10import re
11
12from trac.config import BoolOption, ListOption
13from trac.core import Component, implements
14from trac.perm import PermissionError
15from trac.resource import Resource
16from trac.test import Mock, MockPerm
17from trac.ticket.api import ITicketChangeListener, TicketSystem
18from trac.ticket.model import Ticket
19from trac.util import get_reporter_id
20from trac.util.compat import all, any, groupby
21from trac.util.text import to_unicode
22
23from tractags.api import DefaultTagProvider, ITagProvider, _
24from tractags.model import delete_tags
25from tractags.util import get_db_exc, split_into_tags
26
27
28class TicketTagProvider(DefaultTagProvider):
29    """A 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        db = self.env.get_db_cnx()
55        try:
56            self._fetch_tkt_tags(db)
57            db.commit()
58        except get_db_exc(self.env).IntegrityError, e:
59            self.log.warn('tags for ticket already exist: %s', to_unicode(e))
60            db.rollback()
61        except:
62            db.rollback()
63            raise
64        cfg = self.config
65        cfg_key = 'permission_policies'
66        default_policies = cfg.defaults().get('trac', {}).get(cfg_key)
67        self.fast_permcheck = all(p in default_policies for
68                                  p in cfg.get('trac', cfg_key))
69
70    def _check_permission(self, req, resource, action):
71        """Optionally coarse-grained permission check."""
72        if self.fast_permcheck or not (resource and resource.id):
73            perm = req.perm('ticket')
74        else:
75            perm = req.perm(resource)
76        return self.check_permission(perm, action) and \
77               self.map[action] in perm
78
79    def get_tagged_resources(self, req, tags=None):
80        if not self._check_permission(req, None, 'view'):
81            return
82
83        if not tags:
84            # Cache 'all tagged resources' for better performance.
85            for resource, tags in self._tagged_resources:
86                if self.fast_permcheck or \
87                        self._check_permission(req, resource, 'view'):
88                    yield resource, tags
89        else:
90            db = self.env.get_db_cnx()
91            cursor = db.cursor()
92            sql = """
93                SELECT name, tag
94                  FROM tags
95                 WHERE tagspace=%%s
96                   AND tags.tag IN (%s)
97                 ORDER by name
98            """ % ', '.join(['%s' for t in tags])
99            args = [self.realm] + list(tags)
100            cursor.execute(sql, args)
101            for name, tags in groupby(cursor, lambda row: row[0]):
102                resource = Resource(self.realm, name)
103                if self.fast_permcheck or \
104                        self._check_permission(req, resource, 'view'):
105                    yield resource, set([tag[1] for tag in tags])
106
107    def get_resource_tags(self, req, resource):
108        assert resource.realm == self.realm
109        ticket = Ticket(self.env, resource.id)
110        if not self._check_permission(req, ticket.resource, 'view'):
111            return
112        return self._ticket_tags(ticket)
113
114    def set_resource_tags(self, req, ticket_or_resource, tags, comment=u''):
115        try:
116            resource = ticket_or_resource.resource
117        except AttributeError:
118            resource = ticket_or_resource
119            assert resource.realm == self.realm
120            if not self._check_permission(req, resource, 'modify'):
121                raise PermissionError(resource=resource, env=self.env)
122            tag_set = set(tags)
123            # Processing a call from TracTags, try to alter the ticket.
124            tkt = Ticket(self.env, resource.id)
125            all = self._ticket_tags(tkt)
126            # Avoid unnecessary ticket changes, considering comments below.
127            if tag_set != all:
128                # Will only alter tags in 'keywords' ticket field.
129                keywords = split_into_tags(tkt['keywords'])
130                # Assume, that duplication is depreciated and consolitation
131                # wanted to primarily affect 'keywords' ticket field.
132                # Consequently updating ticket tags and reducing (tag)
133                # 'ticket_fields' afterwards may result in undesired tag loss.
134                tag_set.difference_update(all.difference(keywords))
135                tkt['keywords'] = u' '.join(sorted(map(to_unicode, tag_set)))
136                tkt.save_changes(get_reporter_id(req), comment)
137        else:
138            # Processing a change listener event.
139            tags = self._ticket_tags(ticket_or_resource)
140            super(TicketTagProvider,
141                  self).set_resource_tags(req, resource, tags)
142
143    def remove_resource_tags(self, req, ticket_or_resource, comment=u''):
144        try:
145            resource = ticket_or_resource.resource
146        except AttributeError:
147            resource = ticket_or_resource
148            assert resource.realm == self.realm
149            if not self._check_permission(req, resource, 'modify'):
150                raise PermissionError(resource=resource, env=self.env)
151            # Processing a call from TracTags, try to alter the ticket.
152            ticket = Ticket(self.env, resource.id)
153            # Can only alter tags in 'keywords' ticket field.
154            # DEVEL: Time to differentiate managed and sticky/unmanaged tags?
155            ticket['keywords'] = u''
156            ticket.save_changes(get_reporter_id(req), comment)
157        else:
158            # Processing a change listener event.
159            super(TicketTagProvider, self).remove_resource_tags(req, resource)
160
161    def describe_tagged_resource(self, req, resource):
162        if not self.check_permission(req.perm, 'view'):
163            return ''
164        ticket = Ticket(self.env, resource.id)
165        if ticket.exists:
166            # Use the corresponding IResourceManager.
167            ticket_system = TicketSystem(self.env)
168            return ticket_system.get_resource_description(ticket.resource,
169                                                          format='summary')
170        else:
171            return ''
172
173    # ITicketChangeListener methods
174
175    def ticket_created(self, ticket):
176        """Called when a ticket is created."""
177        # Add any tags unconditionally.
178        self.set_resource_tags(Mock(authname=ticket['reporter'],
179                                    perm=MockPerm()), ticket, None)
180        if self.use_cache:
181            # Invalidate resource cache.
182            del self._tagged_resources
183
184    def ticket_changed(self, ticket, comment, author, old_values):
185        """Called when a ticket is modified."""
186        # Sync only on change of ticket fields, that are exposed as tags.
187        if any(f in self.fields for f in old_values.keys()):
188            self.set_resource_tags(Mock(authname=author, perm=MockPerm()),
189                                   ticket, None)
190            if self.use_cache:
191                # Invalidate resource cache.
192                del self._tagged_resources
193
194    def ticket_deleted(self, ticket):
195        """Called when a ticket is deleted."""
196        # Ticket gone, so remove all records on it.
197        delete_tags(self.env, ticket.resource)
198        if self.use_cache:
199            # Invalidate resource cache.
200            del self._tagged_resources
201
202    # Private methods
203
204    def _fetch_tkt_tags(self, db):
205        """Transfer all relevant ticket attributes to tags db table."""
206        # Initial sync is done by forced, stupid one-way mirroring.
207        # Data aquisition for this utilizes the known ticket tags query.
208        fields = ["COALESCE(%s, '')" % f for f in self.fields]
209        ignore = ''
210        if self.ignore_closed_tickets:
211            ignore = " AND status != 'closed'"
212        sql = """
213            SELECT *
214              FROM (SELECT id, %s, %s AS std_fields
215                      FROM ticket AS tkt
216                     WHERE NOT EXISTS (SELECT * FROM tags
217                                       WHERE tagspace=%%s AND name=%s)
218                     %s) AS s
219             WHERE std_fields != ''
220             ORDER BY id
221            """ % (','.join(self.fields), db.concat(*fields),
222                   db.cast('tkt.id', 'text'), ignore)
223        self.env.log.debug(sql)
224        # Obtain cursors for reading tickets and altering tags db table.
225        # DEVEL: Use appropriate cursor typs from Trac 1.0 db API.
226        ro_cursor = db.cursor()
227        rw_cursor = db.cursor()
228        # Delete tags for non-existent ticket
229        rw_cursor.execute("""
230            DELETE FROM tags
231             WHERE tagspace=%%s
232               AND NOT EXISTS (SELECT * FROM ticket AS tkt
233                               WHERE tkt.id=%s%s)
234            """ % (db.cast('tags.name', 'int'), ignore),
235            (self.realm,))
236
237        self.log.debug('ENTER_TAG_DB_CHECKOUT')
238        ro_cursor.execute(sql, (self.realm,))
239        self.log.debug('EXIT_TAG_DB_CHECKOUT')
240        self.log.debug('ENTER_TAG_SYNC')
241
242        for row in ro_cursor:
243            tkt_id, ttags = row[0], ' '.join([f for f in row[1:-1] if f])
244            ticket_tags = split_into_tags(ttags)
245            rw_cursor.executemany("""
246                INSERT INTO tags
247                       (tagspace, name, tag)
248                VALUES (%s, %s, %s)
249                """, [(self.realm, str(tkt_id), tag) for tag in ticket_tags])
250        self.log.debug('EXIT_TAG_SYNC')
251
252    try:
253        from trac.cache import cached
254        use_cache = True
255
256        @cached
257        def _tagged_resources(self, db=None):
258            """Cached version."""
259            db = self.env.get_db_cnx()
260            cursor = db.cursor()
261            sql = """
262                SELECT name, tag
263                  FROM tags
264                 WHERE tagspace=%s
265                 ORDER by name
266            """
267            self.log.debug('ENTER_TAG_DB_CHECKOUT')
268            cursor.execute(sql, (self.realm,))
269            self.log.debug('EXIT_TAG_DB_CHECKOUT')
270
271            resources = []
272            self.log.debug('ENTER_TAG_GRID_MAKER')
273            counter = 0
274            for name, tags in groupby(cursor, lambda row: row[0]):
275                resource = Resource(self.realm, name)
276                resources.append((resource, set([tag[1] for tag in tags])))
277                counter += 1
278            self.log.debug('TAG_GRID_COUNTER: ' + str(counter))
279            self.log.debug('EXIT_TAG_GRID_MAKER')
280            return resources
281
282    except ImportError:
283        @property
284        def _tagged_resources(self, db=None):
285            """The old, uncached method."""
286            db = self.env.get_db_cnx()
287            cursor = db.cursor()
288            sql = """
289                SELECT name, tag
290                  FROM tags
291                 WHERE tagspace=%s
292                 ORDER by name
293            """
294            self.log.debug('ENTER_PER_REQ_TAG_DB_CHECKOUT')
295            cursor.execute(sql, (self.realm,))
296            self.log.debug('EXIT_PER_REQ_TAG_DB_CHECKOUT')
297
298            self.log.debug('ENTER_TAG_GRID_MAKER_UNCACHED')
299            for name, tags in groupby(cursor, lambda row: row[0]):
300                resource = Resource(self.realm, name)
301                yield resource, set([tag[1] for tag in tags])
302            self.log.debug('EXIT_TAG_GRID_MAKER_UNCACHED')
303
304    def _ticket_tags(self, ticket):
305        return split_into_tags(
306            ' '.join(filter(None, [ticket[f] for f in self.fields])))
Note: See TracBrowser for help on using the repository browser.