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

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

TagsPlugin: Interim changes with DEBUG logging to catch flawed ITagProvider, refs #11658.

Nevertheless the signature for ITicketTagProvider.get_tagged_resources()
was incomplete - fixed.

File size: 12.4 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
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, filter=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 ts.name, ts.tag
94                  FROM tags
95                  LEFT JOIN tags ts ON (
96                       tags.tagspace=ts.tagspace AND tags.name=ts.name)
97                 WHERE tags.tagspace=%%s AND tags.tag IN (%s)
98                 ORDER by ts.name
99            """ % ', '.join(['%s' for t in tags])
100            args = [self.realm] + list(tags)
101            cursor.execute(sql, args)
102            for name, tags in groupby(cursor, lambda row: row[0]):
103                resource = Resource(self.realm, name)
104                if self.fast_permcheck or \
105                        self._check_permission(req, resource, 'view'):
106                    yield resource, set([tag[1] for tag in tags])
107
108    def get_resource_tags(self, req, resource):
109        assert resource.realm == self.realm
110        ticket = Ticket(self.env, resource.id)
111        if not self._check_permission(req, ticket.resource, 'view'):
112            return
113        return self._ticket_tags(ticket)
114
115    def set_resource_tags(self, req, ticket_or_resource, tags, comment=u'',
116                          when=None):
117        try:
118            resource = ticket_or_resource.resource
119        except AttributeError:
120            resource = ticket_or_resource
121            assert resource.realm == self.realm
122            if not self._check_permission(req, resource, 'modify'):
123                raise PermissionError(resource=resource, env=self.env)
124            tag_set = set(tags)
125            # Processing a call from TracTags, try to alter the ticket.
126            tkt = Ticket(self.env, resource.id)
127            all = self._ticket_tags(tkt)
128            # Avoid unnecessary ticket changes, considering comments below.
129            if tag_set != all:
130                # Will only alter tags in 'keywords' ticket field.
131                keywords = split_into_tags(tkt['keywords'])
132                # Assume, that duplication is depreciated and consolitation
133                # wanted to primarily affect 'keywords' ticket field.
134                # Consequently updating ticket tags and reducing (tag)
135                # 'ticket_fields' afterwards may result in undesired tag loss.
136                tag_set.difference_update(all.difference(keywords))
137                tkt['keywords'] = u' '.join(sorted(map(to_unicode, tag_set)))
138                tkt.save_changes(get_reporter_id(req), comment)
139        else:
140            # Processing a change listener event.
141            tags = self._ticket_tags(ticket_or_resource)
142            super(TicketTagProvider,
143                  self).set_resource_tags(req, resource, tags)
144
145    def remove_resource_tags(self, req, ticket_or_resource, comment=u''):
146        try:
147            resource = ticket_or_resource.resource
148        except AttributeError:
149            resource = ticket_or_resource
150            assert resource.realm == self.realm
151            if not self._check_permission(req, resource, 'modify'):
152                raise PermissionError(resource=resource, env=self.env)
153            # Processing a call from TracTags, try to alter the ticket.
154            ticket = Ticket(self.env, resource.id)
155            # Can only alter tags in 'keywords' ticket field.
156            # DEVEL: Time to differentiate managed and sticky/unmanaged tags?
157            ticket['keywords'] = u''
158            ticket.save_changes(get_reporter_id(req), comment)
159        else:
160            # Processing a change listener event.
161            super(TicketTagProvider, self).remove_resource_tags(req, resource)
162
163    def describe_tagged_resource(self, req, resource):
164        if not self.check_permission(req.perm, 'view'):
165            return ''
166        ticket = Ticket(self.env, resource.id)
167        if ticket.exists:
168            # Use the corresponding IResourceManager.
169            ticket_system = TicketSystem(self.env)
170            return ticket_system.get_resource_description(ticket.resource,
171                                                          format='summary')
172        else:
173            return ''
174
175    # ITicketChangeListener methods
176
177    def ticket_created(self, ticket):
178        """Called when a ticket is created."""
179        # Add any tags unconditionally.
180        self.set_resource_tags(Mock(authname=ticket['reporter'],
181                                    perm=MockPerm()),
182                               ticket, None, ticket['time'])
183        if self.use_cache:
184            # Invalidate resource cache.
185            del self._tagged_resources
186
187    def ticket_changed(self, ticket, comment, author, old_values):
188        """Called when a ticket is modified."""
189        # Sync only on change of ticket fields, that are exposed as tags.
190        if any(f in self.fields for f in old_values.keys()):
191            self.set_resource_tags(Mock(authname=author, perm=MockPerm()),
192                                   ticket, None, ticket['changetime'])
193            if self.use_cache:
194                # Invalidate resource cache.
195                del self._tagged_resources
196
197    def ticket_deleted(self, ticket):
198        """Called when a ticket is deleted."""
199        # Ticket gone, so remove all records on it.
200        delete_tags(self.env, ticket.resource)
201        if self.use_cache:
202            # Invalidate resource cache.
203            del self._tagged_resources
204
205    # Private methods
206
207    def _fetch_tkt_tags(self, db):
208        """Transfer all relevant ticket attributes to tags db table."""
209        # Initial sync is done by forced, stupid one-way mirroring.
210        # Data aquisition for this utilizes the known ticket tags query.
211        fields = ["COALESCE(%s, '')" % f for f in self.fields]
212        ignore = ''
213        if self.ignore_closed_tickets:
214            ignore = " AND status != 'closed'"
215        sql = """
216            SELECT *
217              FROM (SELECT id, %s, %s AS std_fields
218                      FROM ticket AS tkt
219                     WHERE NOT EXISTS (SELECT * FROM tags
220                                       WHERE tagspace=%%s AND name=%s)
221                     %s) AS s
222             WHERE std_fields != ''
223             ORDER BY id
224            """ % (','.join(self.fields), db.concat(*fields),
225                   db.cast('tkt.id', 'text'), ignore)
226        self.env.log.debug(sql)
227        # Obtain cursors for reading tickets and altering tags db table.
228        # DEVEL: Use appropriate cursor typs from Trac 1.0 db API.
229        ro_cursor = db.cursor()
230        rw_cursor = db.cursor()
231        # Delete tags for non-existent ticket
232        rw_cursor.execute("""
233            DELETE FROM tags
234             WHERE tagspace=%%s
235               AND NOT EXISTS (SELECT * FROM ticket AS tkt
236                               WHERE tkt.id=%s%s)
237            """ % (db.cast('tags.name', 'int'), ignore),
238            (self.realm,))
239
240        self.log.debug('ENTER_TAG_DB_CHECKOUT')
241        ro_cursor.execute(sql, (self.realm,))
242        self.log.debug('EXIT_TAG_DB_CHECKOUT')
243        self.log.debug('ENTER_TAG_SYNC')
244
245        for row in ro_cursor:
246            tkt_id, ttags = row[0], ' '.join([f for f in row[1:-1] if f])
247            ticket_tags = split_into_tags(ttags)
248            rw_cursor.executemany("""
249                INSERT INTO tags
250                       (tagspace, name, tag)
251                VALUES (%s, %s, %s)
252                """, [(self.realm, str(tkt_id), tag) for tag in ticket_tags])
253        self.log.debug('EXIT_TAG_SYNC')
254
255    try:
256        from trac.cache import cached
257        use_cache = True
258
259        @cached
260        def _tagged_resources(self, db=None):
261            """Cached version."""
262            db = self.env.get_db_cnx()
263            cursor = db.cursor()
264            sql = """
265                SELECT name, tag
266                  FROM tags
267                 WHERE tagspace=%s
268                 ORDER by name
269            """
270            self.log.debug('ENTER_TAG_DB_CHECKOUT')
271            cursor.execute(sql, (self.realm,))
272            self.log.debug('EXIT_TAG_DB_CHECKOUT')
273
274            resources = []
275            self.log.debug('ENTER_TAG_GRID_MAKER')
276            counter = 0
277            for name, tags in groupby(cursor, lambda row: row[0]):
278                resource = Resource(self.realm, name)
279                resources.append((resource, set([tag[1] for tag in tags])))
280                counter += 1
281            self.log.debug('TAG_GRID_COUNTER: ' + str(counter))
282            self.log.debug('EXIT_TAG_GRID_MAKER')
283            return resources
284
285    except ImportError:
286        @property
287        def _tagged_resources(self, db=None):
288            """The old, uncached method."""
289            db = self.env.get_db_cnx()
290            cursor = db.cursor()
291            sql = """
292                SELECT name, tag
293                  FROM tags
294                 WHERE tagspace=%s
295                 ORDER by name
296            """
297            self.log.debug('ENTER_PER_REQ_TAG_DB_CHECKOUT')
298            cursor.execute(sql, (self.realm,))
299            self.log.debug('EXIT_PER_REQ_TAG_DB_CHECKOUT')
300
301            self.log.debug('ENTER_TAG_GRID_MAKER_UNCACHED')
302            for name, tags in groupby(cursor, lambda row: row[0]):
303                resource = Resource(self.realm, name)
304                yield resource, set([tag[1] for tag in tags])
305            self.log.debug('EXIT_TAG_GRID_MAKER_UNCACHED')
306
307    def _ticket_tags(self, ticket):
308        return split_into_tags(
309            ' '.join(filter(None, [ticket[f] for f in self.fields])))
Note: See TracBrowser for help on using the repository browser.