| 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 | |
|---|
| 18 | import csv |
|---|
| 19 | from datetime import datetime, timedelta |
|---|
| 20 | import re |
|---|
| 21 | from StringIO import StringIO |
|---|
| 22 | |
|---|
| 23 | from genshi.builder import tag |
|---|
| 24 | |
|---|
| 25 | from trac.core import * |
|---|
| 26 | from trac.db import get_column_names |
|---|
| 27 | from trac.mimeview.api import Mimeview, IContentConverter, Context |
|---|
| 28 | from trac.perm import IPermissionRequestor |
|---|
| 29 | from trac.resource import Resource |
|---|
| 30 | from trac.ticket.api import TicketSystem |
|---|
| 31 | from trac.ticket.model import Ticket |
|---|
| 32 | from trac.util import Ranges |
|---|
| 33 | from trac.util.compat import groupby |
|---|
| 34 | from trac.util.datefmt import to_timestamp, utc |
|---|
| 35 | from trac.util.html import escape, unescape |
|---|
| 36 | from trac.util.text import shorten_line, CRLF |
|---|
| 37 | from trac.util.translation import _ |
|---|
| 38 | from trac.web import IRequestHandler |
|---|
| 39 | from trac.web.href import Href |
|---|
| 40 | from trac.web.chrome import add_ctxtnav, add_link, add_script, add_stylesheet, \ |
|---|
| 41 | INavigationContributor, Chrome |
|---|
| 42 | from trac.wiki.api import IWikiSyntaxProvider, parse_args |
|---|
| 43 | from trac.wiki.macros import WikiMacroBase # TODO: should be moved in .api |
|---|
| 44 | from trac.config import Option |
|---|
| 45 | |
|---|
| 46 | class QuerySyntaxError(Exception): |
|---|
| 47 | """Exception raised when a ticket query cannot be parsed from a string.""" |
|---|
| 48 | |
|---|
| 49 | |
|---|
| 50 | class 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 | |
|---|
| 545 | class 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 | |
|---|
| 851 | class 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') |
|---|