Mercurial > genshi > mirror
diff examples/trac/trac/ticket/query.py @ 39:93b4dcbafd7b trunk
Copy Trac to main branch.
author | cmlenz |
---|---|
date | Mon, 03 Jul 2006 18:53:27 +0000 |
parents | |
children |
line wrap: on
line diff
new file mode 100644 --- /dev/null +++ b/examples/trac/trac/ticket/query.py @@ -0,0 +1,723 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2004-2006 Edgewall Software +# Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de> +# Copyright (C) 2005-2006 Christian Boos <cboos@neuf.fr> +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz <cmlenz@gmx.de> + +import re +from StringIO import StringIO +import time + +from trac.core import * +from trac.db import get_column_names +from trac.perm import IPermissionRequestor +from trac.ticket import Ticket, TicketSystem +from trac.util.datefmt import format_datetime, http_date +from trac.util.text import shorten_line, CRLF +from trac.util.markup import escape, html, unescape +from trac.web import IRequestHandler +from trac.web.chrome import add_link, add_script, add_stylesheet, \ + INavigationContributor +from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider +from trac.wiki.macros import WikiMacroBase +from trac.mimeview.api import Mimeview, IContentConverter + +class QuerySyntaxError(Exception): + """Exception raised when a ticket query cannot be parsed from a string.""" + + +class Query(object): + + def __init__(self, env, constraints=None, order=None, desc=0, group=None, + groupdesc = 0, verbose=0): + self.env = env + self.constraints = constraints or {} + self.order = order + self.desc = desc + self.group = group + self.groupdesc = groupdesc + self.verbose = verbose + self.fields = TicketSystem(self.env).get_ticket_fields() + self.cols = [] # lazily initialized + + if self.order != 'id' \ + and self.order not in [f['name'] for f in self.fields]: + # order by priority by default + self.order = 'priority' + + if self.group not in [f['name'] for f in self.fields]: + self.group = None + + def from_string(cls, env, string, **kw): + filters = string.split('&') + kw_strs = ['order', 'group'] + kw_bools = ['desc', 'groupdesc', 'verbose'] + constraints = {} + for filter in filters: + filter = filter.split('=') + if len(filter) != 2: + raise QuerySyntaxError, 'Query filter requires field and ' \ + 'constraints separated by a "="' + field,values = filter + if not field: + raise QuerySyntaxError, 'Query filter requires field name' + values = values.split('|') + mode, neg = '', '' + if field[-1] in ('~', '^', '$'): + mode = field[-1] + field = field[:-1] + if field[-1] == '!': + neg = '!' + field = field[:-1] + values = map(lambda x: neg + mode + x, values) + try: + field = str(field) + if field in kw_strs: + kw[field] = values[0] + elif field in kw_bools: + kw[field] = True + else: + constraints[field] = values + except UnicodeError: + pass # field must be a str, see `get_href()` + return cls(env, constraints, **kw) + from_string = classmethod(from_string) + + def get_columns(self): + if self.cols: + return self.cols + + # FIXME: the user should be able to configure which columns should + # be displayed + cols = ['id'] + cols += [f['name'] for f in self.fields if f['type'] != 'textarea'] + for col in ('reporter', 'keywords', 'cc'): + if col in cols: + cols.remove(col) + cols.append(col) + + # Semi-intelligently remove columns that are restricted to a single + # value by a query constraint. + for col in [k for k in self.constraints.keys() if k in cols]: + constraint = self.constraints[col] + if len(constraint) == 1 and constraint[0] \ + and not constraint[0][0] in ('!', '~', '^', '$'): + if col in cols: + cols.remove(col) + if col == 'status' and not 'closed' in constraint \ + and 'resolution' in cols: + cols.remove('resolution') + if self.group in cols: + cols.remove(self.group) + + def sort_columns(col1, col2): + constrained_fields = self.constraints.keys() + # Ticket ID is always the first column + if 'id' in [col1, col2]: + return col1 == 'id' and -1 or 1 + # Ticket summary is always the second column + elif 'summary' in [col1, col2]: + return col1 == 'summary' and -1 or 1 + # Constrained columns appear before other columns + elif col1 in constrained_fields or col2 in constrained_fields: + return col1 in constrained_fields and -1 or 1 + return 0 + cols.sort(sort_columns) + + # Only display the first eight columns by default + # FIXME: Make this configurable on a per-user and/or per-query basis + self.cols = cols[:7] + if not self.order in self.cols and not self.order == self.group: + # Make sure the column we order by is visible, if it isn't also + # the column we group by + self.cols[-1] = self.order + + return self.cols + + def execute(self, req, db=None): + if not self.cols: + self.get_columns() + + sql, args = self.get_sql() + self.env.log.debug("Query SQL: " + sql % tuple([repr(a) for a in args])) + + if not db: + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute(sql, args) + columns = get_column_names(cursor) + results = [] + for row in cursor: + id = int(row[0]) + result = {'id': id, 'href': req.href.ticket(id)} + for i in range(1, len(columns)): + name, val = columns[i], row[i] + if name == self.group: + val = val or 'None' + elif name == 'reporter': + val = val or 'anonymous' + elif name in ['changetime', 'time']: + val = int(val) + elif val is None: + val = '--' + result[name] = val + results.append(result) + cursor.close() + return results + + def get_href(self, req, order=None, desc=None, format=None): + # FIXME: only use .href from that 'req' for now + if desc is None: + desc = self.desc + if order is None: + order = self.order + return req.href.query(order=order, desc=desc and 1 or None, + group=self.group or None, + groupdesc=self.groupdesc and 1 or None, + verbose=self.verbose and 1 or None, + format=format, **self.constraints) + + def get_sql(self): + """Return a (sql, params) tuple for the query.""" + if not self.cols: + self.get_columns() + + # Build the list of actual columns to query + cols = self.cols[:] + def add_cols(*args): + for col in args: + if not col in cols: + cols.append(col) + if self.group and not self.group in cols: + add_cols(self.group) + if self.verbose: + add_cols('reporter', 'description') + add_cols('priority', 'time', 'changetime', self.order) + cols.extend([c for c in self.constraints.keys() if not c in cols]) + + custom_fields = [f['name'] for f in self.fields if f.has_key('custom')] + + sql = [] + sql.append("SELECT " + ",".join(['t.%s AS %s' % (c, c) for c in cols + if c not in custom_fields])) + sql.append(",priority.value AS priority_value") + for k in [k for k in cols if k in custom_fields]: + sql.append(",%s.value AS %s" % (k, k)) + sql.append("\nFROM ticket AS t") + + # Join with ticket_custom table as necessary + for k in [k for k in cols if k in custom_fields]: + sql.append("\n LEFT OUTER JOIN ticket_custom AS %s ON " \ + "(id=%s.ticket AND %s.name='%s')" % (k, k, k, k)) + + # Join with the enum table for proper sorting + for col in [c for c in ('status', 'resolution', 'priority', 'severity') + if c == self.order or c == self.group or c == 'priority']: + sql.append("\n LEFT OUTER JOIN enum AS %s ON " + "(%s.type='%s' AND %s.name=%s)" + % (col, col, col, col, col)) + + # Join with the version/milestone tables for proper sorting + for col in [c for c in ['milestone', 'version'] + if c == self.order or c == self.group]: + sql.append("\n LEFT OUTER JOIN %s ON (%s.name=%s)" + % (col, col, col)) + + def get_constraint_sql(name, value, mode, neg): + if name not in custom_fields: + name = 't.' + name + else: + name = name + '.value' + value = value[len(mode) + neg:] + + if mode == '': + return ("COALESCE(%s,'')%s=%%s" % (name, neg and '!' or ''), + value) + if not value: + return None + + if mode == '~': + value = '%' + value + '%' + elif mode == '^': + value = value + '%' + elif mode == '$': + value = '%' + value + return ("COALESCE(%s,'') %sLIKE %%s" % (name, neg and 'NOT ' or ''), + value) + + clauses = [] + args = [] + for k, v in self.constraints.items(): + # Determine the match mode of the constraint (contains, starts-with, + # negation, etc) + neg = v[0].startswith('!') + mode = '' + if len(v[0]) > neg and v[0][neg] in ('~', '^', '$'): + mode = v[0][neg] + + # Special case for exact matches on multiple values + if not mode and len(v) > 1: + if k not in custom_fields: + col = 't.' + k + else: + col = k + '.value' + clauses.append("COALESCE(%s,'') %sIN (%s)" + % (col, neg and 'NOT ' or '', + ','.join(['%s' for val in v]))) + args += [val[neg:] for val in v] + elif len(v) > 1: + constraint_sql = filter(None, + [get_constraint_sql(k, val, mode, neg) + for val in v]) + if not constraint_sql: + continue + if neg: + clauses.append("(" + " AND ".join([item[0] for item in constraint_sql]) + ")") + else: + clauses.append("(" + " OR ".join([item[0] for item in constraint_sql]) + ")") + args += [item[1] for item in constraint_sql] + elif len(v) == 1: + constraint_sql = get_constraint_sql(k, v[0], mode, neg) + if constraint_sql: + clauses.append(constraint_sql[0]) + args.append(constraint_sql[1]) + + clauses = filter(None, clauses) + if clauses: + sql.append("\nWHERE " + " AND ".join(clauses)) + + sql.append("\nORDER BY ") + order_cols = [(self.order, self.desc)] + if self.group and self.group != self.order: + order_cols.insert(0, (self.group, self.groupdesc)) + for name, desc in order_cols: + if name not in custom_fields: + col = 't.' + name + else: + col = name + '.value' + if name == 'id': + # FIXME: This is a somewhat ugly hack. Can we also have the + # column type for this? If it's an integer, we do first + # one, if text, we do 'else' + if desc: + sql.append("COALESCE(%s,0)=0 DESC," % col) + else: + sql.append("COALESCE(%s,0)=0," % col) + else: + if desc: + sql.append("COALESCE(%s,'')='' DESC," % col) + else: + sql.append("COALESCE(%s,'')=''," % col) + if name in ['status', 'resolution', 'priority', 'severity']: + if desc: + sql.append("%s.value DESC" % name) + else: + sql.append("%s.value" % name) + elif col in ['t.milestone', 't.version']: + time_col = name == 'milestone' and 'milestone.due' or 'version.time' + if desc: + sql.append("COALESCE(%s,0)=0 DESC,%s DESC,%s DESC" + % (time_col, time_col, col)) + else: + sql.append("COALESCE(%s,0)=0,%s,%s" + % (time_col, time_col, col)) + else: + if desc: + sql.append("%s DESC" % col) + else: + sql.append("%s" % col) + if name == self.group and not name == self.order: + sql.append(",") + if self.order != 'id': + sql.append(",t.id") + + return "".join(sql), args + + +class QueryModule(Component): + + implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider, + IContentConverter) + + # IContentConverter methods + def get_supported_conversions(self): + yield ('rss', 'RSS Feed', 'xml', + 'trac.ticket.Query', 'application/rss+xml', 8) + yield ('csv', 'Comma-delimited Text', 'csv', + 'trac.ticket.Query', 'text/csv', 8) + yield ('tab', 'Tab-delimited Text', 'tsv', + 'trac.ticket.Query', 'text/tab-separated-values', 8) + + def convert_content(self, req, mimetype, query, key): + if key == 'rss': + return self.export_rss(req, query) + elif key == 'csv': + return self.export_csv(req, query, mimetype='text/csv') + elif key == 'tab': + return self.export_csv(req, query, '\t', 'text/tab-separated-values') + + # INavigationContributor methods + + def get_active_navigation_item(self, req): + return 'tickets' + + def get_navigation_items(self, req): + from trac.ticket.report import ReportModule + if req.perm.has_permission('TICKET_VIEW') and \ + not self.env.is_component_enabled(ReportModule): + yield ('mainnav', 'tickets', + html.A('View Tickets', href=req.href.query())) + + # IRequestHandler methods + + def match_request(self, req): + return req.path_info == '/query' + + def process_request(self, req): + req.perm.assert_permission('TICKET_VIEW') + + constraints = self._get_constraints(req) + if not constraints and not req.args.has_key('order'): + # avoid displaying all tickets when the query module is invoked + # with no parameters. Instead show only open tickets, possibly + # associated with the user + constraints = {'status': ('new', 'assigned', 'reopened')} + if req.authname and req.authname != 'anonymous': + constraints['owner'] = (req.authname,) + else: + email = req.session.get('email') + name = req.session.get('name') + if email or name: + constraints['cc'] = ('~%s' % email or name,) + + query = Query(self.env, constraints, req.args.get('order'), + req.args.has_key('desc'), req.args.get('group'), + req.args.has_key('groupdesc'), + req.args.has_key('verbose')) + + if req.args.has_key('update'): + # Reset session vars + for var in ('query_constraints', 'query_time', 'query_tickets'): + if req.session.has_key(var): + del req.session[var] + req.redirect(query.get_href(req)) + + # Add registered converters + for conversion in Mimeview(self.env).get_supported_conversions( + 'trac.ticket.Query'): + add_link(req, 'alternate', + query.get_href(req, format=conversion[0]), + conversion[1], conversion[3]) + + constraints = {} + for k, v in query.constraints.items(): + constraint = {'values': [], 'mode': ''} + for val in v: + neg = val.startswith('!') + if neg: + val = val[1:] + mode = '' + if val[:1] in ('~', '^', '$'): + mode, val = val[:1], val[1:] + constraint['mode'] = (neg and '!' or '') + mode + constraint['values'].append(val) + constraints[k] = constraint + req.hdf['query.constraints'] = constraints + + format = req.args.get('format') + if format: + Mimeview(self.env).send_converted(req, 'trac.ticket.Query', query, + format, 'query') + + self.display_html(req, query) + return 'query.cs', None + + # Internal methods + + def _get_constraints(self, req): + constraints = {} + ticket_fields = [f['name'] for f in + TicketSystem(self.env).get_ticket_fields()] + + # For clients without JavaScript, we remove constraints here if + # requested + remove_constraints = {} + to_remove = [k[10:] for k in req.args.keys() + if k.startswith('rm_filter_')] + if to_remove: # either empty or containing a single element + match = re.match(r'(\w+?)_(\d+)$', to_remove[0]) + if match: + remove_constraints[match.group(1)] = int(match.group(2)) + else: + remove_constraints[to_remove[0]] = -1 + + for field in [k for k in req.args.keys() if k in ticket_fields]: + vals = req.args[field] + if not isinstance(vals, (list, tuple)): + vals = [vals] + if vals: + mode = req.args.get(field + '_mode') + if mode: + vals = map(lambda x: mode + x, vals) + if remove_constraints.has_key(field): + idx = remove_constraints[field] + if idx >= 0: + del vals[idx] + if not vals: + continue + else: + continue + constraints[field] = vals + + return constraints + + def _get_constraint_modes(self): + modes = {} + modes['text'] = [ + {'name': "contains", 'value': "~"}, + {'name': "doesn't contain", 'value': "!~"}, + {'name': "begins with", 'value': "^"}, + {'name': "ends with", 'value': "$"}, + {'name': "is", 'value': ""}, + {'name': "is not", 'value': "!"} + ] + modes['select'] = [ + {'name': "is", 'value': ""}, + {'name': "is not", 'value': "!"} + ] + return modes + + def display_html(self, req, query): + req.hdf['title'] = 'Custom Query' + add_stylesheet(req, 'common/css/report.css') + add_script(req, 'common/js/query.js') + + db = self.env.get_db_cnx() + + for field in query.fields: + if field['type'] == 'textarea': + continue + hdf = {} + hdf.update(field) + del hdf['name'] + req.hdf['query.fields.' + field['name']] = hdf + req.hdf['query.modes'] = self._get_constraint_modes() + + # For clients without JavaScript, we add a new constraint here if + # requested + if req.args.has_key('add'): + field = req.args.get('add_filter') + if field: + idx = 0 + if query.constraints.has_key(field): + idx = len(query.constraints[field]) + req.hdf['query.constraints.%s.values.%d' % (field, idx)] = '' + + cols = query.get_columns() + labels = dict([(f['name'], f['label']) for f in query.fields]) + for idx, col in enumerate(cols): + req.hdf['query.headers.%d' % idx] = { + 'name': col, 'label': labels.get(col, 'Ticket'), + 'href': query.get_href(req, order=col, + desc=(col == query.order and + not query.desc)) + } + + href = req.href.query(group=query.group, + groupdesc=query.groupdesc and 1 or None, + verbose=query.verbose and 1 or None, + **query.constraints) + req.hdf['query.order'] = query.order + req.hdf['query.href'] = href + if query.desc: + req.hdf['query.desc'] = True + if query.group: + req.hdf['query.group'] = query.group + if query.groupdesc: + req.hdf['query.groupdesc'] = True + if query.verbose: + req.hdf['query.verbose'] = True + + tickets = query.execute(req, db) + req.hdf['query.num_matches'] = len(tickets) + + # The most recent query is stored in the user session + orig_list = rest_list = None + orig_time = int(time.time()) + query_constraints = unicode(query.constraints) + if query_constraints != req.session.get('query_constraints') \ + or int(req.session.get('query_time', 0)) < orig_time - 3600: + # New or outdated query, (re-)initialize session vars + req.session['query_constraints'] = query_constraints + req.session['query_tickets'] = ' '.join([str(t['id']) for t in tickets]) + else: + orig_list = [int(id) for id in req.session.get('query_tickets', '').split()] + rest_list = orig_list[:] + orig_time = int(req.session.get('query_time', 0)) + req.session['query_href'] = query.get_href(req) + req.session['query_time'] = orig_time + + # Find out which tickets originally in the query results no longer + # match the constraints + if rest_list: + for tid in [t['id'] for t in tickets if t['id'] in rest_list]: + rest_list.remove(tid) + for rest_id in rest_list: + try: + ticket = Ticket(self.env, int(rest_id), db=db) + data = {'id': ticket.id, 'time': ticket.time_created, + 'changetime': ticket.time_changed, 'removed': True, + 'href': req.href.ticket(ticket.id)} + data.update(ticket.values) + except TracError, e: + data = {'id': rest_id, 'time': 0, 'changetime': 0, + 'summary': html.EM(e)} + tickets.insert(orig_list.index(rest_id), data) + + num_matches_group = {} + for ticket in tickets: + if orig_list: + # Mark tickets added or changed since the query was first + # executed + if int(ticket['time']) > orig_time: + ticket['added'] = True + elif int(ticket['changetime']) > orig_time: + ticket['changed'] = True + for field, value in ticket.items(): + if field == query.group: + num_matches_group[value] = num_matches_group.get(value, 0)+1 + if field == 'time': + ticket[field] = format_datetime(value) + elif field == 'description': + ticket[field] = wiki_to_html(value or '', self.env, req, db) + else: + ticket[field] = value + + req.hdf['query.results'] = tickets + req.hdf['query.num_matches_group'] = num_matches_group + req.session['query_tickets'] = ' '.join([str(t['id']) for t in tickets]) + + # Kludge: only show link to available reports if the report module is + # actually enabled + from trac.ticket.report import ReportModule + if req.perm.has_permission('REPORT_VIEW') and \ + self.env.is_component_enabled(ReportModule): + req.hdf['query.report_href'] = req.href.report() + + def export_csv(self, req, query, sep=',', mimetype='text/plain'): + content = StringIO() + cols = query.get_columns() + content.write(sep.join([col for col in cols]) + CRLF) + + results = query.execute(req, self.env.get_db_cnx()) + for result in results: + content.write(sep.join([unicode(result[col]).replace(sep, '_') + .replace('\n', ' ') + .replace('\r', ' ') + for col in cols]) + CRLF) + return (content.getvalue(), '%s;charset=utf-8' % mimetype) + + def export_rss(self, req, query): + query.verbose = True + db = self.env.get_db_cnx() + results = query.execute(req, db) + for result in results: + result['href'] = req.abs_href.ticket(result['id']) + if result['reporter'].find('@') == -1: + result['reporter'] = '' + if result['description']: + # unicode() cancels out the Markup() returned by wiki_to_html + descr = wiki_to_html(result['description'], self.env, req, db, + absurls=True) + result['description'] = unicode(descr) + if result['time']: + result['time'] = http_date(result['time']) + req.hdf['query.results'] = results + req.hdf['query.href'] = req.abs_href.query(group=query.group, + groupdesc=query.groupdesc and 1 or None, + verbose=query.verbose and 1 or None, + **query.constraints) + return (req.hdf.render('query_rss.cs'), 'application/rss+xml') + + # IWikiSyntaxProvider methods + + def get_wiki_syntax(self): + return [] + + def get_link_resolvers(self): + yield ('query', self._format_link) + + def _format_link(self, formatter, ns, query, label): + if query[0] == '?': + return html.A(label, class_='query', + href=formatter.href.query() + query.replace(' ', '+')) + else: + from trac.ticket.query import Query, QuerySyntaxError + try: + query = Query.from_string(formatter.env, query) + return html.A(label, href=query.get_href(formatter), # Hack + class_='query') + except QuerySyntaxError, e: + return html.EM('[Error: %s]' % e, class_='error') + + +class TicketQueryMacro(WikiMacroBase): + """Macro that lists tickets that match certain criteria. + + This macro accepts two parameters, the second of which is optional. + + The first parameter is the query itself, and uses the same syntax as for + {{{query:}}} wiki links. The second parameter determines how the list of + tickets is presented: the default presentation is to list the ticket ID next + to the summary, with each ticket on a separate line. If the second parameter + is given and set to '''compact''' then the tickets are presented as a + comma-separated list of ticket IDs. + """ + + def render_macro(self, req, name, content): + query_string = '' + compact = 0 + argv = content.split(',') + if len(argv) > 0: + query_string = argv[0] + if len(argv) > 1: + if argv[1].strip().lower() == 'compact': + compact = 1 + + buf = StringIO() + + query = Query.from_string(self.env, query_string) + query.order = 'id' + tickets = query.execute(req) + if tickets: + if compact: + links = [] + for ticket in tickets: + href = req.href.ticket(int(ticket['id'])) + summary = escape(shorten_line(ticket['summary'])) + a = '<a class="%s ticket" href="%s" title="%s">#%s</a>' % \ + (ticket['status'], href, summary, ticket['id']) + links.append(a) + buf.write(', '.join(links)) + else: + buf.write('<dl class="wiki compact">') + for ticket in tickets: + href = req.href.ticket(int(ticket['id'])) + dt = '<dt><a class="%s ticket" href="%s">#%s</a></dt>' % \ + (ticket['status'], href, ticket['id']) + buf.write(dt) + buf.write('<dd>%s</dd>' % (escape(ticket['summary']))) + buf.write('</dl>') + + return buf.getvalue()