source: tagsplugin/trunk/tractags/ticket.py

Last change on this file was 18143, checked in by Cinc-th, 2 years ago

TagsPlugin: replace unicode and dict.iteritems() statements which are no longer available in Python 3. Fix exception handling now requiring Exception as e. Fixed imports.

Refs #13994

File size: 10.5 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.cache import cached
13from trac.config import BoolOption, ListOption
14from trac.core import implements
15from trac.perm import PermissionError
16from trac.resource import Resource
17from trac.ticket.api import ITicketChangeListener, TicketSystem
18from trac.ticket.model import Ticket
19from trac.util import get_reporter_id
20from trac.util.text import to_unicode
21
22from tractags.api import DefaultTagProvider, _
23from tractags.model import delete_tags
24from tractags.util import MockReq, 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
51    def __init__(self):
52        try:
53            self._fetch_tkt_tags()
54        except self.env.db_exc.IntegrityError as 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        # Invalidate resource cache.
170        del self._tagged_resources
171
172    def ticket_changed(self, ticket, comment, author, old_values):
173        """Called when a ticket is modified."""
174        req = MockReq(authname=author)
175        # Sync only on change of ticket fields, that are exposed as tags.
176        if any(f in self.fields for f in old_values.keys()):
177            self.set_resource_tags(req, ticket, None, ticket['changetime'])
178            # Invalidate resource cache.
179            del self._tagged_resources
180
181    def ticket_deleted(self, ticket):
182        """Called when a ticket is deleted."""
183        # Ticket gone, so remove all records on it.
184        delete_tags(self.env, ticket.resource, purge=True)
185        # Invalidate resource cache.
186        del self._tagged_resources
187
188    # Private methods
189
190    def _fetch_tkt_tags(self):
191        """Transfer all relevant ticket attributes to tags db table."""
192        # Initial sync is done by forced, stupid one-way mirroring.
193        # Data acquisition for this utilizes the known ticket tags query.
194        fields = ["COALESCE(%s, '')" % f for f in self.fields]
195        ignore = ''
196        if self.ignore_closed_tickets:
197            ignore = " AND status != 'closed'"
198        with self.env.db_transaction as db:
199            sql = """
200                  SELECT *
201                  FROM (SELECT id, %s, %s AS std_fields
202                        FROM ticket AS tkt
203                        WHERE NOT EXISTS (SELECT * FROM tags
204                                          WHERE tagspace=%%s AND name=%s)
205                        %s) AS s
206                  WHERE std_fields != ''
207                  ORDER BY id
208                  """ % (','.join(self.fields), db.concat(*fields),
209                         db.cast('tkt.id', 'text'), ignore)
210            # Obtain cursors for reading tickets and altering tags db table.
211            # DEVEL: Use appropriate cursor types from Trac 1.0 db API.
212            ro_cursor = db.cursor()
213            rw_cursor = db.cursor()
214            # Delete tags for non-existent ticket
215            rw_cursor.execute("""
216                DELETE FROM tags
217                 WHERE tagspace=%%s
218                   AND NOT EXISTS (SELECT * FROM ticket AS tkt
219                                   WHERE tkt.id=%s%s)
220                """ % (db.cast('tags.name', 'int'), ignore),
221                (self.realm,))
222
223            ro_cursor.execute(sql, (self.realm,))
224
225            for row in ro_cursor:
226                tkt_id, ttags = row[0], ' '.join([f for f in row[1:-1] if f])
227                ticket_tags = split_into_tags(ttags)
228                rw_cursor.executemany("""
229                    INSERT INTO tags (tagspace, name, tag)
230                    VALUES (%s, %s, %s)
231                    """, [(self.realm, str(tkt_id), tag) for tag in ticket_tags])
232    @cached
233    def _tagged_resources(self):
234        """Cached version."""
235        resources = []
236        counter = 0
237        for name, tags in groupby(self.env.db_query("""
238                SELECT name, tag FROM tags
239                WHERE tagspace=%s ORDER by name
240                """, (self.realm,)), lambda row: row[0]):
241            resource = Resource(self.realm, name)
242            resources.append((resource, set([tag[1] for tag in tags])))
243            counter += 1
244        return resources
245
246    def _ticket_tags(self, ticket):
247        return split_into_tags(
248            ' '.join(filter(None, [ticket[f] for f in self.fields])))
Note: See TracBrowser for help on using the repository browser.