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

Last change on this file was 16943, checked in by Ryan J Ollos, 6 years ago

TracTags 0.10dev: Make compatible with Trac 1.3.3dev

Fixes #13316.

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