| 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 | |
|---|
| 10 | from itertools import groupby |
|---|
| 11 | |
|---|
| 12 | from trac.config import BoolOption, ListOption |
|---|
| 13 | from trac.core import implements |
|---|
| 14 | from trac.perm import PermissionError |
|---|
| 15 | from trac.resource import Resource |
|---|
| 16 | from trac.ticket.api import ITicketChangeListener, TicketSystem |
|---|
| 17 | from trac.ticket.model import Ticket |
|---|
| 18 | from trac.util import get_reporter_id |
|---|
| 19 | from trac.util.text import to_unicode |
|---|
| 20 | |
|---|
| 21 | from tractags.api import DefaultTagProvider, _ |
|---|
| 22 | from tractags.model import delete_tags |
|---|
| 23 | from tractags.util import MockReq, split_into_tags |
|---|
| 24 | |
|---|
| 25 | |
|---|
| 26 | class 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]))) |
|---|