source: tagsplugin/tags/0.9/tractags/web_ui.py

Last change on this file was 15565, checked in by Ryan J Ollos, 7 years ago

0.9dev: Fix AttributeError filtering timeline containing changesets

The code that filters the timeline assumed that event['data']
contained a Resource object at index 0, but the
ChangesetModule returns a tuple with the Resource object
at index 1.

Refs #12718.

File size: 21.3 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006 Alec Thomas <alec@swapoff.org>
4# Copyright (C) 2008 Dmitry Dianov
5# Copyright (C) 2011 Itamar Ostricher <itamarost@gmail.com>
6# Copyright (C) 2011-2012 Ryan J Ollos <ryan.j.ollos@gmail.com>
7# Copyright (C) 2011-2014 Steffen Hoffmann <hoff.st@web.de>
8#
9# This software is licensed as described in the file COPYING, which
10# you should have received as part of this distribution.
11#
12
13import re
14
15from genshi.builder import tag as builder
16from genshi.core import Markup
17from genshi.filters.transform import Transformer
18
19from trac import __version__ as trac_version
20from trac.config import BoolOption, ListOption, Option
21from trac.core import implements
22from trac.resource import Resource, ResourceSystem, get_resource_name
23from trac.resource import get_resource_url
24from trac.timeline.api import ITimelineEventProvider
25from trac.util import to_unicode
26from trac.util.text import javascript_quote, unicode_quote_plus
27from trac.web import IRequestFilter
28from trac.web.api import IRequestHandler, ITemplateStreamFilter
29from trac.web.chrome import Chrome, INavigationContributor
30from trac.web.chrome import add_ctxtnav, add_script, add_stylesheet
31from trac.web.chrome import add_warning, web_context
32from trac.wiki.formatter import Formatter
33from trac.wiki.model import WikiPage
34
35from tractags.api import REALM_RE, TagSystem, _, tag_, tagn_
36from tractags.macros import TagTemplateProvider, TagWikiMacros, as_int
37from tractags.macros import query_realms
38from tractags.model import tag_changes
39from tractags.query import InvalidQuery, Query
40from tractags.util import split_into_tags
41
42
43class TagInputAutoComplete(TagTemplateProvider):
44    """[opt] Provides auto-complete functionality for tag input fields.
45
46    This module is based on KeywordSuggestModule from KeywordSuggestPlugin
47    0.5dev.
48    """
49
50    implements(IRequestFilter, ITemplateStreamFilter)
51
52    field_opt = Option('tags', 'complete_field', 'keywords',
53        "Ticket field to which a drop-down tag list should be attached.")
54
55    help_opt = Option('tags', 'ticket_help', None,
56        "If specified, 'keywords' label on ticket view will be turned into a "
57        "link to this URL.")
58
59    helpnewwindow_opt = BoolOption('tags', 'ticket_help_newwindow', False,
60        "If true and keywords_help specified, wiki page will open in a new "
61        "window. Default is false.")
62
63    # Needs to be reimplemented, refs th:#8141.
64    #mustmatch = BoolOption('tags', 'complete_mustmatch', False,
65    #    "If true, input fields accept values from the word list only.")
66
67    matchcontains_opt = BoolOption('tags', 'complete_matchcontains', True,
68        "Include partial matches in suggestion list. Default is true.")
69
70    separator_opt = Option('tags', 'separator', ' ',
71        "Character(s) to use as separators between tags. Default is a "
72        "single whitespace.")
73
74    sticky_tags_opt = ListOption('tags', 'complete_sticky_tags', '', ',',
75        doc="A list of comma separated values available for input.")
76
77    def __init__(self):
78        self.tags_enabled = self.env.is_enabled(TagSystem)
79
80    @property
81    def separator(self):
82        return self.separator_opt.strip('\'') or ' '
83
84    # IRequestFilter methods
85
86    def pre_process_request(self, req, handler):
87        return handler
88
89    def post_process_request(self, req, template, data, content_type):
90        if template is not None and \
91                (req.path_info.startswith('/ticket/') or
92                 req.path_info.startswith('/newticket') or
93                 (self.tags_enabled and req.path_info.startswith('/wiki/'))):
94            # In Trac 1.0 and later, jQuery-UI is included from the core.
95            if trac_version >= '1.0':
96                Chrome(self.env).add_jquery_ui(req)
97            else:
98                add_script(req, 'tags/js/jquery-ui-1.8.16.custom.min.js')
99                add_stylesheet(req, 'tags/css/jquery-ui-1.8.16.custom.css')
100        return template, data, content_type
101
102    # ITemplateStreamFilter method
103    def filter_stream(self, req, method, filename, stream, data):
104
105        if not (filename == 'ticket.html' or
106                (self.tags_enabled and filename == 'wiki_edit.html')):
107            return stream
108
109        keywords = self._get_keywords_string(req)
110        if not keywords:
111            self.log.debug(
112                "No keywords found. TagInputAutoComplete is disabled.")
113            return stream
114
115        matchfromstart = '"^" +'
116        if self.matchcontains_opt:
117            matchfromstart = ''
118
119        js = """
120            jQuery(document).ready(function($) {
121                var keywords = [ %(keywords)s ]
122                var sep = '%(separator)s'.trim() + ' '
123                function split( val ) {
124                    return val.split( /%(separator)s\s*|\s+/ );
125                }
126                function extractLast( term ) {
127                    return split( term ).pop();
128                }
129                $('%(field)s')
130                    // don't navigate away from field on tab when selecting
131                    // an item
132                    .bind( "keydown", function( event ) {
133                        if ( event.keyCode === $.ui.keyCode.TAB &&
134                             $( this ).data( "autocomplete" ).menu.active ) {
135                            event.preventDefault();
136                        }
137                    })
138                    .autocomplete({
139                        delay: 0,
140                        minLength: 0,
141                        source: function( request, response ) {
142                            // delegate back to autocomplete, but extract
143                            // the last term
144                            response( $.ui.autocomplete.filter(
145                                keywords, extractLast( request.term ) ) );
146                        },
147                        focus: function() {
148                            // prevent value inserted on focus
149                            return false;
150                        },
151                        select: function( event, ui ) {
152                            var terms = split( this.value );
153                            // remove the current input
154                            terms.pop();
155                            // add the selected item
156                            terms.push( ui.item.value );
157                            // add placeholder to get the comma-and-space at
158                            // the end
159                            terms.push( "" );
160                            this.value = terms.join( sep );
161                            return false;
162                        }
163                    });
164            });"""
165
166        # Inject transient part of JavaScript into ticket.html template.
167        if req.path_info.startswith('/ticket/') or \
168           req.path_info.startswith('/newticket'):
169            js_ticket = js % {'field': '#field-' + self.field_opt,
170                              'keywords': keywords,
171                              'matchfromstart': matchfromstart,
172                              'separator': self.separator}
173            stream = stream | Transformer('.//head')\
174                              .append(builder.script(Markup(js_ticket),
175                                      type='text/javascript'))
176
177            # Turn keywords field label into link to an arbitrary resource.
178            if self.help_opt:
179                link = self._get_help_link(req)
180                if self.helpnewwindow_opt:
181                    link = builder.a(href=link, target='blank')
182                else:
183                    link = builder.a(href=link)
184                xpath = '//label[@for="field-keywords"]/text()'
185                stream = stream | Transformer(xpath).wrap(link)
186
187        # Inject transient part of JavaScript into wiki.html template.
188        elif self.tags_enabled and req.path_info.startswith('/wiki/'):
189            js_wiki = js % {'field': '#tags',
190                            'keywords': keywords,
191                            'matchfromstart': matchfromstart,
192                            'separator': self.separator}
193            stream = stream | Transformer('.//head')\
194                              .append(builder.script(Markup(js_wiki),
195                                                     type='text/javascript'))
196        return stream
197
198    # Private methods
199
200    def _get_keywords_string(self, req):
201        keywords = set(self.sticky_tags_opt)  # prevent duplicates
202        if self.tags_enabled:
203            # Use TagsPlugin >= 0.7 performance-enhanced API.
204            tags = TagSystem(self.env).get_all_tags(req)
205            keywords.update(tags.keys())
206
207        if keywords:
208            keywords = sorted(keywords)
209            keywords = ','.join(("'%s'" % javascript_quote(_keyword)
210                                 for _keyword in keywords))
211        else:
212            keywords = ''
213
214        return keywords
215
216    def _get_help_link(self, req):
217        link = resource_id = None
218        if self.help_opt.startswith('/'):
219            # Assume valid URL to arbitrary resource inside
220            #   of the current Trac environment.
221            link = req.href(self.help_opt)
222        if not link and ':' in self.help_opt:
223            realm, resource_id = self.help_opt.split(':', 1)
224            # Validate realm-like prefix against resource realm list,
225            #   but exclude 'wiki' to allow deferred page creation.
226            rsys = ResourceSystem(self.env)
227            if realm in set(rsys.get_known_realms()) - set('wiki'):
228                mgr = rsys.get_resource_manager(realm)
229                # Handle optional IResourceManager method gracefully.
230                try:
231                    if mgr.resource_exists(Resource(realm, resource_id)):
232                        link = mgr.get_resource_url(resource_id, req.href)
233                except AttributeError:
234                    # Assume generic resource URL build rule.
235                    link = req.href(realm, resource_id)
236        if not link:
237            if not resource_id:
238                # Assume wiki page name for backwards-compatibility.
239                resource_id = self.help_opt
240            # Preserve anchor without 'path_safe' arg (since Trac 0.12.2dev).
241            if '#' in resource_id:
242                path, anchor = resource_id.split('#', 1)
243            else:
244                anchor = None
245                path = resource_id
246            if hasattr(unicode_quote_plus, "safe"):
247                # Use method for query string quoting (since Trac 0.13dev).
248                anchor = unicode_quote_plus(anchor, safe="?!~*'()")
249            else:
250                anchor = unicode_quote_plus(anchor)
251            link = '#'.join([req.href.wiki(path), anchor])
252        return link
253
254
255class TagRequestHandler(TagTemplateProvider):
256    """[main] Implements the /tags handler."""
257
258    implements(INavigationContributor, IRequestHandler)
259
260    cloud_mincount = Option('tags', 'cloud_mincount', 1,
261        doc="""Integer threshold to hide tags with smaller count.""")
262    default_cols = Option('tags', 'default_table_cols', 'id|description|tags',
263        doc="""Select columns and order for table format using a "|"-separated
264            list of column names.
265
266            Supported columns: realm, id, description, tags
267            """)
268    default_format = Option('tags', 'default_format', 'oldlist',
269        doc="""Set the default format for the handler of the `/tags` domain.
270
271            || `oldlist` (default value) || The original format with a
272            bulleted-list of "linked-id description (tags)" ||
273            || `compact` || bulleted-list of "linked-description" ||
274            || `table` || table... (see corresponding column option) ||
275            """)
276    exclude_realms = ListOption('tags', 'exclude_realms', [],
277        doc="""Comma-separated list of realms to exclude from tags queries
278            by default, unless specifically included using "realm:realm-name"
279            in a query.""")
280
281    # INavigationContributor methods
282    def get_active_navigation_item(self, req):
283        if 'TAGS_VIEW' in req.perm:
284            return 'tags'
285
286    def get_navigation_items(self, req):
287        if 'TAGS_VIEW' in req.perm:
288            label = tag_("Tags")
289            yield ('mainnav', 'tags',
290                   builder.a(label, href=req.href.tags(), accesskey='T'))
291
292    # IRequestHandler methods
293    def match_request(self, req):
294        return req.path_info.startswith('/tags')
295
296    def process_request(self, req):
297        req.perm.require('TAGS_VIEW')
298
299        match = re.match(r'/tags/?(.*)', req.path_info)
300        tag_id = match.group(1) and match.group(1) or None
301        query = req.args.get('q', '')
302
303        # Consider only providers, that are permitted for display.
304        tag_system = TagSystem(self.env)
305        all_realms = tag_system.get_taggable_realms(req.perm)
306        if not (tag_id or query) or [r for r in all_realms
307                                     if r in req.args] == []:
308            for realm in all_realms:
309                if realm not in self.exclude_realms:
310                    req.args[realm] = 'on'
311        checked_realms = [r for r in all_realms if r in req.args]
312        if query:
313            # Add permitted realms from query expression.
314            checked_realms.extend(query_realms(query, all_realms))
315        realm_args = dict(zip([r for r in checked_realms],
316                              ['on' for r in checked_realms]))
317        # Switch between single tag and tag query expression mode.
318        if tag_id and not re.match(r"""(['"]?)(\S+)\1$""", tag_id, re.UNICODE):
319            # Convert complex, invalid tag ID's --> query expression.
320            req.redirect(req.href.tags(realm_args, q=tag_id))
321        elif query:
322            single_page = re.match(r"""(['"]?)(\S+)\1$""", query, re.UNICODE)
323            if single_page:
324                # Convert simple query --> single tag.
325                req.redirect(req.href.tags(single_page.group(2), realm_args))
326
327        data = dict(page_title=_("Tags"), checked_realms=checked_realms)
328        # Populate the TagsQuery form field.
329        data['tag_query'] = tag_id and tag_id or query
330        data['tag_realms'] = list(dict(name=realm,
331                                       checked=realm in checked_realms)
332                                  for realm in all_realms)
333        if tag_id:
334            data['tag_page'] = WikiPage(self.env,
335                                        tag_system.wiki_page_prefix + tag_id)
336        if query or tag_id:
337            macro = 'ListTagged'
338            # TRANSLATOR: The meta-nav link label.
339            add_ctxtnav(req, _("Back to Cloud"), req.href.tags())
340            args = "%s,format=%s,cols=%s" % \
341                   (tag_id and tag_id or query, self.default_format,
342                    self.default_cols)
343            data['mincount'] = None
344        else:
345            macro = 'TagCloud'
346            mincount = as_int(req.args.get('mincount', None),
347                              self.cloud_mincount)
348            args = mincount and "mincount=%s" % mincount or None
349            data['mincount'] = mincount
350        formatter = Formatter(self.env, web_context(req, Resource('tag')))
351        self.env.log.debug("%s macro arguments: %s", macro,
352                           args and args or '(none)')
353        macros = TagWikiMacros(self.env)
354        try:
355            # Query string without realm throws 'NotImplementedError'.
356            data['tag_body'] = checked_realms and \
357                               macros.expand_macro(formatter, macro, args,
358                                                   realms=checked_realms) \
359                               or ''
360        except InvalidQuery, e:
361            data['tag_query_error'] = to_unicode(e)
362            data['tag_body'] = macros.expand_macro(formatter, 'TagCloud', '')
363        add_stylesheet(req, 'tags/css/tractags.css')
364        return 'tag_view.html', data, None
365
366
367class TagTimelineEventFilter(TagTemplateProvider):
368    """[opt] Filters timeline events by tags associated with listed resources
369    mentioned in the event.
370    """
371
372    implements(IRequestFilter, ITemplateStreamFilter)
373
374    key = 'tag_query'
375
376    # ITemplateStreamFilter method
377    def filter_stream(self, req, method, filename, stream, data):
378        if req.path_info == '/timeline':
379            insert = builder(Markup('<br />'), tag_("matching tags "),
380                             builder.input(type='text', name=self.key,
381                                           value=data.get(self.key)))
382            xpath = '//form[@id="prefs"]/div[1]'
383            stream = stream | Transformer(xpath).append(insert)
384        return stream
385
386    # IRequestFilter methods
387
388    def pre_process_request(self, req, handler):
389        return handler
390
391    def post_process_request(self, req, template, data, content_type):
392        if data and req.path_info == '/timeline' and \
393                'TAGS_VIEW' in req.perm(Resource('tags')):
394
395            def realm_handler(_, node, context):
396                return query.match(node, [context.realm])
397
398            query_str = req.args.getfirst(self.key)
399            if query_str is None and req.args.get('format') != 'rss':
400                query_str = req.session.get('timeline.%s' % self.key)
401            else:
402                query_str = (query_str or '').strip()
403                # Record tag query expression between visits.
404                req.session['timeline.%s' % self.key] = query_str
405
406            if data.get('events') and query_str:
407                tag_system = TagSystem(self.env)
408                try:
409                    query = Query(query_str,
410                                  attribute_handlers={'realm': realm_handler})
411                except InvalidQuery, e:
412                    add_warning(req, _("Tag query syntax error: %s" % e))
413                else:
414                    all_realms = tag_system.get_taggable_realms(req.perm)
415                    query_realms = set()
416                    for m in REALM_RE.finditer(query.as_string()):
417                        query_realms.add(m.group(1))
418                    # Don't care about resources from non-taggable realms.
419                    realms = not query_realms and all_realms or \
420                             query_realms.intersection(all_realms)
421                    events = []
422                    self.log.debug("Filtering timeline events by tags '%s'",
423                                   query_str)
424                    for event in data['events']:
425                        resource = resource_from_event(event)
426                        if resource and resource.realm in realms:
427                            # Shortcut view permission checks here.
428                            tags = tag_system.get_tags(None, resource)
429                            if query(tags, context=resource):
430                                events.append(event)
431                    # Overwrite with filtered list.
432                    data['events'] = events
433            if query_str:
434                # Add current value for next form rendering.
435                data[self.key] = query_str
436            elif self.key in req.session:
437                del req.session[self.key]
438        return template, data, content_type
439
440
441def resource_from_event(event):
442    resource = None
443    event_data = event['data']
444    if not isinstance(event_data, (tuple, list)):
445        event_data = [event_data]
446    for entry in event_data:
447        try:
448            entry.realm
449        except AttributeError:
450            pass
451        else:
452            resource = entry
453            break
454    return resource
455
456
457class TagTimelineEventProvider(TagTemplateProvider):
458    """[opt] Delivers recorded tag change events to timeline view."""
459
460    implements(ITimelineEventProvider)
461
462    # ITimelineEventProvider methods
463
464    def get_timeline_filters(self, req):
465        if 'TAGS_VIEW' in req.perm('tags'):
466            yield ('tags', _("Tag changes"))
467
468    def get_timeline_events(self, req, start, stop, filters):
469        if 'tags' in filters:
470            tags_realm = Resource('tags')
471            if 'TAGS_VIEW' not in req.perm(tags_realm):
472                return
473            add_stylesheet(req, 'tags/css/tractags.css')
474            for time, author, tagspace, name, old_tags, new_tags in \
475                    tag_changes(self.env, None, start, stop):
476                tagged_resource = Resource(tagspace, name)
477                if 'TAGS_VIEW' in req.perm(tagged_resource):
478                    yield ('tags', time, author,
479                           (tagged_resource, old_tags, new_tags), self)
480
481    def render_timeline_event(self, context, field, event):
482        resource = event[3][0]
483        if field == 'url':
484            return get_resource_url(self.env, resource, context.href)
485        elif field == 'title':
486            name = builder.em(get_resource_name(self.env, resource))
487            return tag_("Tag change on %(resource)s", resource=name)
488        elif field == 'description':
489            return render_tag_changes(event[3][1], event[3][2])
490
491
492def render_tag_changes(old_tags, new_tags):
493        old_tags = split_into_tags(old_tags or '')
494        new_tags = split_into_tags(new_tags or '')
495        added = sorted(new_tags - old_tags)
496        added = added and \
497                tagn_("%(tags)s added", "%(tags)s added",
498                      len(added), tags=builder.em(', '.join(added)))
499        removed = sorted(old_tags - new_tags)
500        removed = removed and \
501                  tagn_("%(tags)s removed", "%(tags)s removed",
502                        len(removed), tags=builder.em(', '.join(removed)))
503        # TRANSLATOR: How to delimit added and removed tags.
504        delim = added and removed and _("; ")
505        return builder(builder.strong(_("Tags")), ' ', added,
506                       delim, removed)
Note: See TracBrowser for help on using the repository browser.