root/BRANCH/TRUNK/source/trac0.11b1/trac/ticket/query.py

Revision 24, 39.2 KB (checked in by ja11sop@…, 4 years ago)

Add 'relative_size' to the enum columns for the ticket. For ticket:2

  • Property svn:executable set to *
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2004-2007 Edgewall Software
4# Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de>
5# Copyright (C) 2005-2007 Christian Boos <cboos@neuf.fr>
6# All rights reserved.
7#
8# This software is licensed as described in the file COPYING, which
9# you should have received as part of this distribution. The terms
10# are also available at http://trac.edgewall.org/wiki/TracLicense.
11#
12# This software consists of voluntary contributions made by many
13# individuals. For the exact contribution history, see the revision
14# history and logs, available at http://trac.edgewall.org/log/.
15#
16# Author: Christopher Lenz <cmlenz@gmx.de>
17
18import csv
19from datetime import datetime, timedelta
20import re
21from StringIO import StringIO
22
23from genshi.builder import tag
24
25from trac.core import *
26from trac.db import get_column_names
27from trac.mimeview.api import Mimeview, IContentConverter, Context
28from trac.perm import IPermissionRequestor
29from trac.resource import Resource
30from trac.ticket.api import TicketSystem
31from trac.ticket.model import Ticket
32from trac.util import Ranges
33from trac.util.compat import groupby
34from trac.util.datefmt import to_timestamp, utc
35from trac.util.html import escape, unescape
36from trac.util.text import shorten_line, CRLF
37from trac.util.translation import _
38from trac.web import IRequestHandler
39from trac.web.href import Href
40from trac.web.chrome import add_ctxtnav, add_link, add_script, add_stylesheet, \
41                            INavigationContributor, Chrome
42from trac.wiki.api import IWikiSyntaxProvider, parse_args
43from trac.wiki.macros import WikiMacroBase # TODO: should be moved in .api
44from trac.config import Option
45
46class QuerySyntaxError(Exception):
47    """Exception raised when a ticket query cannot be parsed from a string."""
48
49
50class Query(object):
51
52    def __init__(self, env, report=None, constraints=None, cols=None,
53                 order=None, desc=0, group=None, groupdesc=0, verbose=0,
54                 rows=None, limit=None):
55        self.env = env
56        self.id = report # if not None, it's the corresponding saved query
57        self.constraints = constraints or {}
58        self.order = order
59        self.desc = desc
60        self.group = group
61        self.groupdesc = groupdesc
62        self.limit = limit
63        if rows == None:
64            rows = []
65        if verbose and 'description' not in rows: # 0.10 compatibility
66            rows.append('description')
67        self.fields = TicketSystem(self.env).get_ticket_fields()
68        field_names = [f['name'] for f in self.fields]
69        self.cols = [c for c in cols or [] if c in field_names or c == 'id']
70        self.rows = [c for c in rows if c in field_names]
71
72        if self.order != 'id' and self.order not in field_names:
73            # TODO: fix after adding time/changetime to the api.py
74            if order == 'created':
75                order = 'time'
76            elif order == 'modified':
77                order = 'changetime'
78            if order in ('time', 'changetime'):
79                self.order = order
80            else:
81                self.order = 'priority'
82
83        if self.group not in field_names:
84            self.group = None
85
86    def from_string(cls, env, string, **kw):
87        filters = string.split('&')
88        kw_strs = ['order', 'group', 'limit']
89        kw_arys = ['rows']
90        kw_bools = ['desc', 'groupdesc', 'verbose']
91        constraints = {}
92        cols = []
93        for filter_ in filters:
94            filter_ = filter_.split('=')
95            if len(filter_) != 2:
96                raise QuerySyntaxError('Query filter requires field and ' 
97                                       'constraints separated by a "="')
98            field,values = filter_
99            if not field:
100                raise QuerySyntaxError('Query filter requires field name')
101            # from last char of `field`, get the mode of comparison
102            mode, neg = '', ''
103            if field[-1] in ('~', '^', '$'):
104                mode = field[-1]
105                field = field[:-1]
106            if field[-1] == '!':
107                neg = '!'
108                field = field[:-1]
109            processed_values = []
110            for val in values.split('|'):
111                val = neg + mode + val # add mode of comparison
112                processed_values.append(val)
113            try:
114                field = str(field)
115                if field in kw_strs:
116                    kw[field] = processed_values[0]
117                elif field in kw_arys:
118                    kw[field] = processed_values
119                elif field in kw_bools:
120                    kw[field] = True
121                elif field == 'col':
122                    cols.extend(processed_values)
123                else:
124                    constraints[field] = processed_values
125            except UnicodeError:
126                pass # field must be a str, see `get_href()`
127        report = constraints.pop('report', None)
128        report = kw.pop('report', report)
129        return cls(env, report, constraints=constraints, cols=cols, **kw)
130    from_string = classmethod(from_string)
131
132    def get_columns(self):
133        if not self.cols:
134            self.cols = self.get_default_columns()
135        return self.cols
136
137    def get_all_textareas(self):
138        return [f['name'] for f in self.fields if f['type'] == 'textarea']
139
140    def get_all_columns(self):
141        # Prepare the default list of columns
142        cols = ['id']
143        cols += [f['name'] for f in self.fields if f['type'] != 'textarea']
144        for col in ('reporter', 'keywords', 'cc'):
145            if col in cols:
146                cols.remove(col)
147                cols.append(col)
148
149        # Semi-intelligently remove columns that are restricted to a single
150        # value by a query constraint.
151        for col in [k for k in self.constraints.keys()
152                    if k != 'id' and k in cols]:
153            constraint = self.constraints[col]
154            if len(constraint) == 1 and constraint[0] \
155                    and not constraint[0][0] in ('!', '~', '^', '$'):
156                if col in cols:
157                    cols.remove(col)
158            if col == 'status' and not 'closed' in constraint \
159                    and 'resolution' in cols:
160                cols.remove('resolution')
161        if self.group in cols:
162            cols.remove(self.group)
163
164        def sort_columns(col1, col2):
165            constrained_fields = self.constraints.keys()
166            if 'id' in (col1, col2):
167                # Ticket ID is always the first column
168                return col1 == 'id' and -1 or 1
169            elif 'summary' in (col1, col2):
170                # Ticket summary is always the second column
171                return col1 == 'summary' and -1 or 1
172            elif col1 in constrained_fields or col2 in constrained_fields:
173                # Constrained columns appear before other columns
174                return col1 in constrained_fields and -1 or 1
175            return 0
176        cols.sort(sort_columns)
177        return cols
178
179    def get_default_columns(self):
180        all_cols = self.get_all_columns()
181        # Only display the first seven columns by default
182        cols = all_cols[:7]
183        # Make sure the column we order by is visible, if it isn't also
184        # the column we group by
185        if not self.order in cols and not self.order == self.group:
186            cols[-1] = self.order
187        return cols
188
189    def execute(self, req, db=None):
190        if not self.cols:
191            self.get_columns()
192
193        sql, args = self.get_sql(req)
194        self.env.log.debug("Query SQL: " + sql % tuple([repr(a) for a in args]))
195
196        if not db:
197            db = self.env.get_db_cnx()
198        cursor = db.cursor()
199        cursor.execute(sql, args)
200        columns = get_column_names(cursor)
201        fields = []
202        for column in columns:
203            fields += [f for f in self.fields if f['name'] == column] or [None]
204        results = []
205
206        for row in cursor:
207            id = int(row[0])
208            result = {'id': id, 'href': req.href.ticket(id)}
209            for i in range(1, len(columns)):
210                name, field, val = columns[i], fields[i], row[i]
211                if name == self.group:
212                    val = val or 'None'
213                elif name == 'reporter':
214                    val = val or 'anonymous'
215                elif val is None:
216                    val = '--'
217                elif name in ('changetime', 'time'):
218                    val = datetime.fromtimestamp(int(val), utc)
219                elif field and field['type'] == 'checkbox':
220                    try:
221                        val = bool(int(val))
222                    except TypeError, ValueError:
223                        val = False
224                result[name] = val
225            results.append(result)
226        cursor.close()
227        return results
228
229    def get_href(self, href, id=None, order=None, desc=None, format=None):
230        """Create a link corresponding to this query.
231
232        :param href: the `Href` object used to build the URL
233        :param id: optionally set or override the report `id`
234        :param order: optionally override the order parameter of the query
235        :param desc: optionally override the desc parameter
236        :param format: optionally override the format of the query
237
238        Note: `get_resource_url` of a 'query' resource?
239        """
240        if not isinstance(href, Href):
241            href = href.href # compatibility with the `req` of the 0.10 API
242        if id is None:
243            id = self.id
244        if desc is None:
245            desc = self.desc
246        if order is None:
247            order = self.order
248        cols = self.get_columns()
249        # don't specify the columns in the href if they correspond to
250        # the default columns, in the same order.  That keeps the query url
251        # shorter in the common case where we just want the default columns.
252        if cols == self.get_default_columns():
253            cols = None
254        return href.query(report=id,
255                          order=order, desc=desc and 1 or None,
256                          group=self.group or None,
257                          groupdesc=self.groupdesc and 1 or None,
258                          col=cols,
259                          row=self.rows,
260                          format=format, **self.constraints)
261
262    def to_string(self):
263        """Return a user readable and editable representation of the query.
264
265        Note: for now, this is an "exploded" query href, but ideally should be
266        expressed in TracQuery language.
267        """
268        query_string = self.get_href(Href(''))
269        if query_string and '?' in query_string:
270            query_string = query_string.split('?', 1)[1]
271        return 'query:?' + query_string.replace('&', '\n&\n')
272
273    def get_sql(self, req=None):
274        """Return a (sql, params) tuple for the query."""
275        if not self.cols:
276            self.get_columns()
277
278        enum_columns = ('resolution', 'priority', 'severity', 'relative_size')
279        # Build the list of actual columns to query
280        cols = self.cols[:]
281        def add_cols(*args):
282            for col in args:
283                if not col in cols:
284                    cols.append(col)
285        if self.group and not self.group in cols:
286            add_cols(self.group)
287        if self.rows:
288            add_cols('reporter', *self.rows)
289        add_cols('priority', 'time', 'changetime', self.order)
290        cols.extend([c for c in self.constraints.keys() if not c in cols])
291
292        custom_fields = [f['name'] for f in self.fields if 'custom' in f]
293
294        sql = []
295        sql.append("SELECT " + ",".join(['t.%s AS %s' % (c, c) for c in cols
296                                         if c not in custom_fields]))
297        sql.append(",priority.value AS priority_value")
298        for k in [k for k in cols if k in custom_fields]:
299            sql.append(",%s.value AS %s" % (k, k))
300        sql.append("\nFROM ticket AS t")
301
302        # Join with ticket_custom table as necessary
303        for k in [k for k in cols if k in custom_fields]:
304           sql.append("\n  LEFT OUTER JOIN ticket_custom AS %s ON " \
305                      "(id=%s.ticket AND %s.name='%s')" % (k, k, k, k))
306
307        # Join with the enum table for proper sorting
308        for col in [c for c in enum_columns
309                    if c == self.order or c == self.group or c == 'priority']:
310            sql.append("\n  LEFT OUTER JOIN enum AS %s ON "
311                       "(%s.type='%s' AND %s.name=%s)"
312                       % (col, col, col, col, col))
313
314        # Join with the version/milestone tables for proper sorting
315        for col in [c for c in ['milestone', 'version']
316                    if c == self.order or c == self.group]:
317            sql.append("\n  LEFT OUTER JOIN %s ON (%s.name=%s)"
318                       % (col, col, col))
319
320        def get_constraint_sql(name, value, mode, neg):
321            if name not in custom_fields:
322                name = 't.' + name
323            else:
324                name = name + '.value'
325            value = value[len(mode) + neg:]
326
327            if mode == '':
328                return ("COALESCE(%s,'')%s=%%s" % (name, neg and '!' or ''),
329                        value)
330            if not value:
331                return None
332            db = self.env.get_db_cnx()
333            value = db.like_escape(value)
334            if mode == '~':
335                value = '%' + value + '%'
336            elif mode == '^':
337                value = value + '%'
338            elif mode == '$':
339                value = '%' + value
340            return ("COALESCE(%s,'') %s%s" % (name, neg and 'NOT ' or '',
341                                              db.like()),
342                    value)
343
344        clauses = []
345        args = []
346        for k, v in self.constraints.items():
347            if req:
348                v = [val.replace('$USER', req.authname) for val in v]
349            # Determine the match mode of the constraint (contains,
350            # starts-with, negation, etc.)
351            neg = v[0].startswith('!')
352            mode = ''
353            if len(v[0]) > neg and v[0][neg] in ('~', '^', '$'):
354                mode = v[0][neg]
355
356            # Special case id ranges
357            if k == 'id':
358                ranges = Ranges()
359                for r in v:
360                    r = r.replace('!', '')
361                    ranges.appendrange(r)
362                ids = []
363                id_clauses = []
364                for a,b in ranges.pairs:
365                    if a == b:
366                        ids.append(str(a))
367                    else:
368                        id_clauses.append('id BETWEEN %s AND %s')
369                        args.append(a)
370                        args.append(b)
371                if ids:
372                    id_clauses.append('id IN (%s)' % (','.join(ids)))
373                if id_clauses:
374                    clauses.append('%s(%s)' % (neg and 'NOT ' or '',
375                                               ' OR '.join(id_clauses)))
376            # Special case for exact matches on multiple values
377            elif not mode and len(v) > 1:
378                if k not in custom_fields:
379                    col = 't.' + k
380                else:
381                    col = k + '.value'
382                clauses.append("COALESCE(%s,'') %sIN (%s)"
383                               % (col, neg and 'NOT ' or '',
384                                  ','.join(['%s' for val in v])))
385                args += [val[neg:] for val in v]
386            elif len(v) > 1:
387                constraint_sql = filter(None,
388                                        [get_constraint_sql(k, val, mode, neg)
389                                         for val in v])
390                if not constraint_sql:
391                    continue
392                if neg:
393                    clauses.append("(" + " AND ".join(
394                        [item[0] for item in constraint_sql]) + ")")
395                else:
396                    clauses.append("(" + " OR ".join(
397                        [item[0] for item in constraint_sql]) + ")")
398                args += [item[1] for item in constraint_sql]
399            elif len(v) == 1:
400                constraint_sql = get_constraint_sql(k, v[0], mode, neg)
401                if constraint_sql:
402                    clauses.append(constraint_sql[0])
403                    args.append(constraint_sql[1])
404
405        clauses = filter(None, clauses)
406        if clauses:
407            sql.append("\nWHERE " + " AND ".join(clauses))
408
409        sql.append("\nORDER BY ")
410        order_cols = [(self.order, self.desc)]
411        if self.group and self.group != self.order:
412            order_cols.insert(0, (self.group, self.groupdesc))
413        for name, desc in order_cols:
414            if name not in custom_fields:
415                col = 't.' + name
416            else:
417                col = name + '.value'
418            # FIXME: This is a somewhat ugly hack.  Can we also have the
419            #        column type for this?  If it's an integer, we do first
420            #        one, if text, we do 'else'
421            if name in ('id', 'time', 'changetime'):
422                if desc:
423                    sql.append("COALESCE(%s,0)=0 DESC," % col)
424                else:
425                    sql.append("COALESCE(%s,0)=0," % col)
426            else:
427                if desc:
428                    sql.append("COALESCE(%s,'')='' DESC," % col)
429                else:
430                    sql.append("COALESCE(%s,'')=''," % col)
431            if name in enum_columns:
432                if desc:
433                    sql.append("%s.value DESC" % name)
434                else:
435                    sql.append("%s.value" % name)
436            elif name in ('milestone', 'version'):
437                if name == 'milestone': 
438                    time_col = 'milestone.due'
439                else:
440                    time_col = 'version.time'
441                if desc:
442                    sql.append("COALESCE(%s,0)=0 DESC,%s DESC,%s DESC"
443                               % (time_col, time_col, col))
444                else:
445                    sql.append("COALESCE(%s,0)=0,%s,%s"
446                               % (time_col, time_col, col))
447            else:
448                if desc:
449                    sql.append("%s DESC" % col)
450                else:
451                    sql.append("%s" % col)
452            if name == self.group and not name == self.order:
453                sql.append(",")
454        if self.order != 'id':
455            sql.append(",t.id")
456           
457        # Limit number of records
458        if self.limit:
459            sql.append("\nLIMIT %s")
460            args.append(self.limit)       
461
462        return "".join(sql), args
463
464    def template_data(self, context, tickets, orig_list=None, orig_time=None):
465        constraints = {}
466        for k, v in self.constraints.items():
467            constraint = {'values': [], 'mode': ''}
468            for val in v:
469                neg = val.startswith('!')
470                if neg:
471                    val = val[1:]
472                mode = ''
473                if val[:1] in ('~', '^', '$'):
474                    mode, val = val[:1], val[1:]
475                constraint['mode'] = (neg and '!' or '') + mode
476                constraint['values'].append(val)
477            constraints[k] = constraint
478
479        cols = self.get_columns()
480        labels = dict([(f['name'], f['label']) for f in self.fields])
481
482        # TODO: remove after adding time/changetime to the api.py
483        labels['changetime'] = _('Modified')
484        labels['time'] = _('Created')
485
486        headers = [{
487            'name': col, 'label': labels.get(col, _('Ticket')),
488            'href': self.get_href(context.href, order=col,
489                                  desc=(col == self.order and not self.desc))
490        } for col in cols]
491
492        fields = {}
493        for field in self.fields:
494            if field['type'] == 'textarea':
495                continue
496            field_data = {}
497            field_data.update(field)
498            del field_data['name']
499            fields[field['name']] = field_data
500
501        modes = {}
502        modes['text'] = [
503            {'name': _("contains"), 'value': "~"},
504            {'name': _("doesn't contain"), 'value': "!~"},
505            {'name': _("begins with"), 'value': "^"},
506            {'name': _("ends with"), 'value': "$"},
507            {'name': _("is"), 'value': ""},
508            {'name': _("is not"), 'value': "!"}
509        ]
510        modes['select'] = [
511            {'name': _("is"), 'value': ""},
512            {'name': _("is not"), 'value': "!"}
513        ]
514
515        groups = {}
516        groupsequence = []
517        for ticket in tickets:
518            if orig_list:
519                # Mark tickets added or changed since the query was first
520                # executed
521                if ticket['time'] > orig_time:
522                    ticket['added'] = True
523                elif ticket['changetime'] > orig_time:
524                    ticket['changed'] = True
525            if self.group:
526                group_key = ticket[self.group]
527                groups.setdefault(group_key, []).append(ticket)
528                if not groupsequence or groupsequence[-1] != group_key:
529                    groupsequence.append(group_key)
530        groupsequence = [(value, groups[value]) for value in groupsequence]
531
532        return {'query': self,
533                'context': context,
534                'col': cols,
535                'row': self.rows,
536                'constraints': constraints,
537                'labels': labels,
538                'headers': headers,
539                'fields': fields,
540                'modes': modes,
541                'tickets': tickets,
542                'groups': groupsequence or [(None, tickets)]}
543
544
545class QueryModule(Component):
546
547    implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider,
548               IContentConverter)
549               
550    default_query = Option('query', 'default_query', 
551                            default='status!=closed&owner=$USER', 
552                            doc='The default query for authenticated users.') 
553   
554    default_anonymous_query = Option('query', 'default_anonymous_query', 
555                               default='status!=closed&cc~=$USER', 
556                               doc='The default query for anonymous users.') 
557
558    # IContentConverter methods
559    def get_supported_conversions(self):
560        yield ('rss', _('RSS Feed'), 'xml',
561               'trac.ticket.Query', 'application/rss+xml', 8)
562        yield ('csv', _('Comma-delimited Text'), 'csv',
563               'trac.ticket.Query', 'text/csv', 8)
564        yield ('tab', _('Tab-delimited Text'), 'tsv',
565               'trac.ticket.Query', 'text/tab-separated-values', 8)
566
567    def convert_content(self, req, mimetype, query, key):
568        if key == 'rss':
569            return self.export_rss(req, query)
570        elif key == 'csv':
571            return self.export_csv(req, query, mimetype='text/csv')
572        elif key == 'tab':
573            return self.export_csv(req, query, '\t',
574                                   mimetype='text/tab-separated-values')
575
576    # INavigationContributor methods
577
578    def get_active_navigation_item(self, req):
579        return 'tickets'
580
581    def get_navigation_items(self, req):
582        from trac.ticket.report import ReportModule
583        if 'TICKET_VIEW' in req.perm and \
584                not self.env.is_component_enabled(ReportModule):
585            yield ('mainnav', 'tickets',
586                   tag.a(_('View Tickets'), href=req.href.query()))
587
588    # IRequestHandler methods
589
590    def match_request(self, req):
591        return req.path_info == '/query'
592
593    def process_request(self, req):
594        req.perm.assert_permission('TICKET_VIEW')
595
596        constraints = self._get_constraints(req)
597        if not constraints and not 'order' in req.args:
598            # If no constraints are given in the URL, use the default ones.
599            if req.authname and req.authname != 'anonymous':
600                qstring = self.default_query
601                user = req.authname
602            else:
603                email = req.session.get('email')
604                name = req.session.get('name')
605                qstring = self.default_anonymous_query
606                user = email or name or None 
607                     
608            if user: 
609                qstring = qstring.replace('$USER', user) 
610            self.log.debug('QueryModule: Using default query: %s', qstring) 
611            constraints = Query.from_string(self.env, qstring).constraints
612            # Ensure no field constraints that depend on $USER are used
613            # if we have no username.
614            for field, vals in constraints.items(): 
615                for val in vals: 
616                    if val.endswith('$USER'): 
617                        del constraints[field] 
618
619        cols = req.args.get('col')
620        if isinstance(cols, basestring):
621            cols = [cols]
622        # Since we don't show 'id' as an option to the user,
623        # we need to re-insert it here.           
624        if cols and 'id' not in cols: 
625            cols.insert(0, 'id')
626        rows = req.args.get('row', [])
627        if isinstance(rows, basestring):
628            rows = [rows]
629        query = Query(self.env, req.args.get('report'),
630                      constraints, cols, req.args.get('order'),
631                      'desc' in req.args, req.args.get('group'),
632                      'groupdesc' in req.args, 'verbose' in req.args,
633                      rows,
634                      req.args.get('limit'))
635
636        if 'update' in req.args:
637            # Reset session vars
638            for var in ('query_constraints', 'query_time', 'query_tickets'):
639                if var in req.session:
640                    del req.session[var]
641            req.redirect(query.get_href(req.href))
642
643        # Add registered converters
644        for conversion in Mimeview(self.env).get_supported_conversions(
645                                             'trac.ticket.Query'):
646            add_link(req, 'alternate',
647                     query.get_href(req.href, format=conversion[0]),
648                     conversion[1], conversion[4], conversion[0])
649
650        format = req.args.get('format')
651        if format:
652            Mimeview(self.env).send_converted(req, 'trac.ticket.Query', query,
653                                              format, 'query')
654
655        return self.display_html(req, query)
656
657    # Internal methods
658
659    def _get_constraints(self, req):
660        constraints = {}
661        ticket_fields = [f['name'] for f in
662                         TicketSystem(self.env).get_ticket_fields()]
663        ticket_fields.append('id')
664
665        # For clients without JavaScript, we remove constraints here if
666        # requested
667        remove_constraints = {}
668        to_remove = [k[10:] for k in req.args.keys()
669                     if k.startswith('rm_filter_')]
670        if to_remove: # either empty or containing a single element
671            match = re.match(r'(\w+?)_(\d+)$', to_remove[0])
672            if match:
673                remove_constraints[match.group(1)] = int(match.group(2))
674            else:
675                remove_constraints[to_remove[0]] = -1
676
677        for field in [k for k in req.args.keys() if k in ticket_fields]:
678            vals = req.args[field]
679            if not isinstance(vals, (list, tuple)):
680                vals = [vals]
681            if vals:
682                mode = req.args.get(field + '_mode')
683                if mode:
684                    vals = [mode + x for x in vals]
685                if field in remove_constraints:
686                    idx = remove_constraints[field]
687                    if idx >= 0:
688                        del vals[idx]
689                        if not vals:
690                            continue
691                    else:
692                        continue
693                constraints[field] = vals
694
695        return constraints
696
697    def display_html(self, req, query):
698        db = self.env.get_db_cnx()
699        tickets = query.execute(req, db)
700
701        # The most recent query is stored in the user session;
702        orig_list = rest_list = None
703        orig_time = datetime.now(utc)
704        query_time = int(req.session.get('query_time', 0))
705        query_time = datetime.fromtimestamp(query_time, utc)
706        query_constraints = unicode(query.constraints)
707        if query_constraints != req.session.get('query_constraints') \
708                or query_time < orig_time - timedelta(hours=1):
709            # New or outdated query, (re-)initialize session vars
710            req.session['query_constraints'] = query_constraints
711            req.session['query_tickets'] = ' '.join([str(t['id'])
712                                                     for t in tickets])
713        else:
714            orig_list = [int(id) for id
715                         in req.session.get('query_tickets', '').split()]
716            rest_list = orig_list[:]
717            orig_time = query_time
718
719        # Find out which tickets originally in the query results no longer
720        # match the constraints
721        if rest_list:
722            for tid in [t['id'] for t in tickets if t['id'] in rest_list]:
723                rest_list.remove(tid)
724            for rest_id in rest_list:
725                try:
726                    ticket = Ticket(self.env, int(rest_id), db=db)
727                    data = {'id': ticket.id, 'time': ticket.time_created,
728                            'changetime': ticket.time_changed, 'removed': True,
729                            'href': req.href.ticket(ticket.id)}
730                    data.update(ticket.values)
731                except TracError, e:
732                    data = {'id': rest_id, 'time': 0, 'changetime': 0,
733                            'summary': tag.em(e)}
734                tickets.insert(orig_list.index(rest_id), data)
735
736        context = Context.from_request(req, 'query')
737        data = query.template_data(context, tickets, orig_list, orig_time)
738
739        # For clients without JavaScript, we add a new constraint here if
740        # requested
741        constraints = data['constraints']
742        if 'add' in req.args:
743            field = req.args.get('add_filter')
744            if field:
745                constraint = constraints.setdefault(field, {})
746                constraint.setdefault('values', []).append('')
747                # FIXME: '' not always correct (e.g. checkboxes)
748
749        req.session['query_href'] = query.get_href(context.href)
750        req.session['query_time'] = to_timestamp(orig_time)
751        req.session['query_tickets'] = ' '.join([str(t['id'])
752                                                 for t in tickets])
753        title = _('Custom Query')
754
755        # Only interact with the report module if it is actually enabled.
756        #
757        # Note that with saved custom queries, there will be some convergence
758        # between the report module and the query module.
759        from trac.ticket.report import ReportModule
760        if 'REPORT_VIEW' in req.perm and \
761               self.env.is_component_enabled(ReportModule):
762            data['report_href'] = req.href.report()
763            add_ctxtnav(req, _('Available Reports'), req.href.report())
764            add_ctxtnav(req, _('Custom Query'))
765            if query.id:
766                cursor = db.cursor()
767                cursor.execute("SELECT title,description FROM report "
768                               "WHERE id=%s", (query.id,))
769                for title, description in cursor:
770                    data['report_resource'] = Resource('report', query.id)
771                    data['description'] = description
772        else:
773            data['report_href'] = None
774        data.setdefault('report', None)
775        data.setdefault('description', None)
776        data['title'] = title
777
778        data['all_columns'] = query.get_all_columns()
779        # Don't allow the user to remove the id column       
780        data['all_columns'].remove('id')
781        data['all_textareas'] = query.get_all_textareas()
782
783        add_stylesheet(req, 'common/css/report.css')
784        add_script(req, 'common/js/query.js')
785
786        return 'query.html', data, None
787
788    def export_csv(self, req, query, sep=',', mimetype='text/plain'):
789        content = StringIO()
790        cols = query.get_columns()
791        writer = csv.writer(content, delimiter=sep)
792        writer = csv.writer(content, delimiter=sep, quoting=csv.QUOTE_MINIMAL)
793        writer.writerow([unicode(c).encode('utf-8') for c in cols])
794
795        context = Context.from_request(req)
796        results = query.execute(req, self.env.get_db_cnx())
797        for result in results:
798            ticket = Resource('ticket', result['id'])
799            if 'TICKET_VIEW' in req.perm(ticket):
800                values = []
801                for col in cols:
802                    value = result[col]
803                    if col in ('cc', 'reporter'):
804                        value = Chrome(self.env).format_emails(context(ticket),
805                                                               value)
806                    values.append(unicode(value).encode('utf-8'))
807                writer.writerow(values)
808        return (content.getvalue(), '%s;charset=utf-8' % mimetype)
809
810    def export_rss(self, req, query):
811        if 'description' not in query.rows:
812            query.rows.append('description')
813        db = self.env.get_db_cnx()
814        results = query.execute(req, db)
815        query_href = req.abs_href.query(group=query.group,
816                                        groupdesc=(query.groupdesc and 1
817                                                   or None),
818                                        row=query.rows, 
819                                        **query.constraints)
820        data = {
821            'context': Context.from_request(req, 'query', absurls=True),
822            'results': results,
823            'query_href': query_href
824        }
825        output = Chrome(self.env).render_template(req, 'query.rss', data,
826                                                  'application/rss+xml')
827        return output, 'application/rss+xml'
828
829    # IWikiSyntaxProvider methods
830   
831    def get_wiki_syntax(self):
832        return []
833   
834    def get_link_resolvers(self):
835        yield ('query', self._format_link)
836
837    def _format_link(self, formatter, ns, query, label):
838        if query.startswith('?'):
839            return tag.a(label, class_='query',
840                         href=formatter.href.query() + query.replace(' ', '+'))
841        else:
842            try:
843                query = Query.from_string(self.env, query)
844                return tag.a(label,
845                             href=query.get_href(formatter.context.href),
846                             class_='query')
847            except QuerySyntaxError, e:
848                return tag.em(_('[Error: %(error)s]', error=e), class_='error')
849
850
851class TicketQueryMacro(WikiMacroBase):
852    """Macro that lists tickets that match certain criteria.
853   
854    This macro accepts a comma-separated list of keyed parameters,
855    in the form "key=value".
856
857    If the key is the name of a field, the value must use the same syntax as
858    for `query:` wiki links (but '''not''' the variant syntax starting with
859    "?").
860
861    The optional `format` parameter determines how the list of tickets is
862    presented:
863     - '''list''' -- the default presentation is to list the ticket ID next
864       to the summary, with each ticket on a separate line.
865     - '''compact''' -- the tickets are presented as a comma-separated
866       list of ticket IDs.
867     - '''count''' -- only the count of matching tickets is displayed
868     - '''table'''  -- a view similar to the custom query view (but without
869       the controls)
870
871    The optional `order` parameter sets the field used for ordering tickets
872    (defaults to '''id''').
873
874    The optional `group` parameter sets the field used for grouping tickets
875    (defaults to not being set).
876
877    The optional `groupdesc` parameter indicates whether the natural display
878    order of the groups should be reversed (defaults to '''false''').
879
880    The optional `verbose` parameter can be set to a true value in order to
881    get the description for the listed tickets. For '''table''' format only.
882    ''deprecated in favor of the row parameter''.
883
884    For compatibility with Trac 0.10, if there's a second positional parameter
885    given to the macro, it will be used to specify the `format`.
886    Also, using "&" as a field separator still works but is deprecated.
887    """
888
889    def expand_macro(self, formatter, name, content):
890        req = formatter.req
891        query_string = ''
892        argv, kwargs = parse_args(content, strict=False)
893        if len(argv) > 0 and not 'format' in kwargs: # 0.10 compatibility hack
894            kwargs['format'] = argv[0]
895
896        format = kwargs.pop('format', 'list').strip().lower()
897        query_string = '&'.join(['%s=%s' % item
898                                 for item in kwargs.iteritems()])
899
900        query = Query.from_string(self.env, query_string)
901        tickets = query.execute(req)
902
903        if format == 'count':
904            cnt = tickets and len(tickets) or 0
905            return tag.span(cnt, title='%d tickets for which %s' %
906                            (cnt, query_string), class_='query_count')
907        if tickets:
908            def ticket_anchor(ticket):
909                return tag.a('#%s' % ticket['id'],
910                             class_=ticket['status'],
911                             href=req.href.ticket(int(ticket['id'])),
912                             title=shorten_line(ticket['summary']))
913            def ticket_groups():
914                groups = []
915                for v, g in groupby(tickets, lambda t: t[query.group]):
916                    q = Query.from_string(self.env, query_string)
917                    # produce the hint for the group
918                    q.group = q.groupdesc = None
919                    order = q.order
920                    q.order = None
921                    title = "%s %s tickets matching %s" % (v, query.group,
922                                                           q.to_string())
923                    # produce the href for the query corresponding to the group
924                    q.constraints[str(query.group)] = v
925                    q.order = order
926                    href = q.get_href(formatter.context)
927                    groups.append((v, [t for t in g], href, title))
928                return groups
929
930            if format == 'compact':
931                if query.group:
932                    groups = [tag.a('#%s' % ','.join([str(t['id'])
933                                                      for t in g]),
934                                    href=href, class_='query', title=title)
935                              for v, g, href, title in ticket_groups()]
936                    return tag(groups[0], [(', ', g) for g in groups[1:]])
937                else:
938                    alist = [ticket_anchor(ticket) for ticket in tickets]
939                    return tag.span(alist[0], *[(', ', a) for a in alist[1:]])
940            elif format == 'table':
941                db = self.env.get_db_cnx()
942                tickets = query.execute(req, db)
943                data = query.template_data(formatter.context, tickets)
944
945                add_stylesheet(req, 'common/css/report.css')
946               
947                return Chrome(self.env).render_template(
948                    req, 'query_results.html', data, None, fragment=True)
949            else:
950                if query.group:
951                    return tag.div(
952                        [(tag.p(tag.a(query.group, ' ', v, href=href,
953                                      class_='query', title=title)),
954                          tag.dl([(tag.dt(ticket_anchor(t)),
955                                   tag.dd(t['summary'])) for t in g],
956                                 class_='wiki compact'))
957                         for v, g, href, title in ticket_groups()])
958                else:
959                    return tag.div(tag.dl([(tag.dt(ticket_anchor(ticket)),
960                                            tag.dd(ticket['summary']))
961                                           for ticket in tickets],
962                                          class_='wiki compact'))
963        else:
964            return tag.span(_("No results"), class_='query_no_results')
Note: See TracBrowser for help on using the browser.