source: tagsplugin/branches/0.8-stable/tractags/ticket.py

Last change on this file was 14157, checked in by Steffen Hoffmann, 9 years ago

TagsPlugin: Add a versatile Trac request mockup, refs #11945.

This is partially a rework of [14146] using partial built-in for better
maintainability.

Thanks to Ryan J Ollos for designing and proposing the new utility class.

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