Package trac :: Package ticket :: Module query

Source Code for Module trac.ticket.query

   1  # -*- coding: utf-8 -*- 
   2  # 
   3  # Copyright (C) 2004-2020 Edgewall Software 
   4  # Copyright (C) 2004-2005 Christopher Lenz <[email protected]> 
   5  # Copyright (C) 2005-2007 Christian Boos <[email protected]> 
   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 https://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 https://trac.edgewall.org/log/. 
  15  # 
  16  # Author: Christopher Lenz <[email protected]> 
  17   
  18  from __future__ import with_statement 
  19   
  20  from cStringIO import StringIO 
  21  from datetime import datetime, timedelta 
  22  from itertools import groupby 
  23  from math import ceil 
  24  import csv 
  25  import re 
  26   
  27  from genshi.builder import tag 
  28   
  29  from trac.config import Option, IntOption 
  30  from trac.core import * 
  31  from trac.db import get_column_names 
  32  from trac.mimeview.api import IContentConverter, Mimeview 
  33  from trac.resource import Resource 
  34  from trac.ticket.api import TicketSystem 
  35  from trac.ticket.model import Milestone, group_milestones 
  36  from trac.util import Ranges, as_bool, as_int 
  37  from trac.util.datefmt import datetime_now, format_datetime, from_utimestamp, \ 
  38                                parse_date, to_timestamp, to_utimestamp, utc, \ 
  39                                user_time 
  40  from trac.util.presentation import Paginator 
  41  from trac.util.text import empty, shorten_line, quote_query_string 
  42  from trac.util.translation import _, tag_, cleandoc_, ngettext 
  43  from trac.web import arg_list_to_args, parse_arg_list, IRequestHandler 
  44  from trac.web.href import Href 
  45  from trac.web.chrome import (INavigationContributor, Chrome, 
  46                               add_ctxtnav, add_link, add_script, 
  47                               add_script_data, add_stylesheet, add_warning, 
  48                               auth_link, web_context) 
  49  from trac.wiki.api import IWikiSyntaxProvider 
  50  from trac.wiki.formatter import system_message 
  51  from trac.wiki.macros import WikiMacroBase # TODO: should be moved in .api 
52 53 54 -class QuerySyntaxError(TracError):
55 """Exception raised when a ticket query cannot be parsed from a string."""
56
57 58 -class QueryValueError(TracError):
59 """Exception raised when a ticket query has bad constraint values."""
60 - def __init__(self, errors):
61 TracError.__init__(self, _('Invalid query constraint value')) 62 self.errors = errors
63
64 65 -class Query(object):
66 substitutions = ['$USER'] 67 clause_re = re.compile(r'(?P<clause>\d+)_(?P<field>.+)$') 68
69 - def __init__(self, env, report=None, constraints=None, cols=None, 70 order=None, desc=0, group=None, groupdesc=0, verbose=0, 71 rows=None, page=None, max=None, format=None):
72 self.env = env 73 self.id = report # if not None, it's the corresponding saved query 74 constraints = constraints or [] 75 if isinstance(constraints, dict): 76 constraints = [constraints] 77 self.constraints = constraints 78 synonyms = TicketSystem(self.env).get_field_synonyms() 79 self.order = synonyms.get(order, order) # 0.11 compatibility 80 self.desc = desc 81 self.group = group 82 self.groupdesc = groupdesc 83 self.format = format 84 self.default_page = 1 85 self.items_per_page = QueryModule(self.env).items_per_page 86 87 # getting page number (default_page if unspecified) 88 if not page: 89 page = self.default_page 90 try: 91 self.page = int(page) 92 if self.page < 1: 93 raise ValueError() 94 except ValueError: 95 raise TracError(_('Query page %(page)s is invalid.', page=page)) 96 97 # max=0 signifies showing all items on one page 98 # max=n will show precisely n items on all pages except the last 99 # max<0 is invalid 100 if max in ('none', ''): 101 max = 0 102 103 if max is None: # meaning unspecified 104 max = self.items_per_page 105 try: 106 self.max = int(max) 107 if self.max < 0: 108 raise ValueError() 109 except ValueError: 110 raise TracError(_('Query max %(max)s is invalid.', max=max)) 111 112 if self.max == 0: 113 self.has_more_pages = False 114 self.offset = 0 115 else: 116 self.has_more_pages = True 117 self.offset = self.max * (self.page - 1) 118 119 if rows == None: 120 rows = [] 121 if verbose and 'description' not in rows: # 0.10 compatibility 122 rows.append('description') 123 self.fields = TicketSystem(self.env).get_ticket_fields() 124 self.time_fields = set(f['name'] for f in self.fields 125 if f['type'] == 'time') 126 field_names = set(f['name'] for f in self.fields) 127 self.cols = [c for c in cols or [] if c in field_names or 128 c == 'id'] 129 self.rows = [c for c in rows if c in field_names] 130 if self.order != 'id' and self.order not in field_names: 131 self.order = 'priority' 132 133 if self.group not in field_names: 134 self.group = None 135 136 constraint_cols = {} 137 for clause in self.constraints: 138 for k, v in clause.items(): 139 if k == 'id' or k in field_names: 140 constraint_cols.setdefault(k, []).append(v) 141 else: 142 clause.pop(k) 143 self.constraint_cols = constraint_cols
144 145 _clause_splitter = re.compile(r'(?<!\\)&') 146 _item_splitter = re.compile(r'(?<!\\)\|') 147 148 @classmethod
149 - def from_string(cls, env, string, **kw):
150 kw_strs = ['order', 'group', 'page', 'max', 'format'] 151 kw_arys = ['rows'] 152 kw_bools = ['desc', 'groupdesc', 'verbose'] 153 kw_synonyms = {'row': 'rows'} 154 # i18n TODO - keys will be unicode 155 synonyms = TicketSystem(env).get_field_synonyms() 156 constraints = [{}] 157 cols = [] 158 report = None 159 def as_str(s): 160 if isinstance(s, unicode): 161 return s.encode('utf-8') 162 return s
163 for filter_ in cls._clause_splitter.split(string): 164 if filter_ == 'or': 165 constraints.append({}) 166 continue 167 filter_ = filter_.replace(r'\&', '&').split('=', 1) 168 if len(filter_) != 2: 169 raise QuerySyntaxError(_('Query filter requires field and ' 170 'constraints separated by a "="')) 171 field, values = filter_ 172 # from last chars of `field`, get the mode of comparison 173 mode = '' 174 if field and field[-1] in ('~', '^', '$') \ 175 and not field in cls.substitutions: 176 mode = field[-1] 177 field = field[:-1] 178 if field and field[-1] == '!': 179 mode = '!' + mode 180 field = field[:-1] 181 if not field: 182 raise QuerySyntaxError(_('Query filter requires field name')) 183 field = kw_synonyms.get(field, field) 184 # add mode of comparison and remove escapes 185 processed_values = [mode + val.replace(r'\|', '|') 186 for val in cls._item_splitter.split(values)] 187 if field in kw_strs: 188 kw[as_str(field)] = processed_values[0] 189 elif field in kw_arys: 190 kw.setdefault(as_str(field), []).extend(processed_values) 191 elif field in kw_bools: 192 kw[as_str(field)] = as_bool(processed_values[0]) 193 elif field == 'col': 194 cols.extend(synonyms.get(value, value) 195 for value in processed_values) 196 elif field == 'report': 197 report = processed_values[0] 198 else: 199 constraints[-1].setdefault(synonyms.get(field, field), 200 []).extend(processed_values) 201 constraints = filter(None, constraints) 202 report = kw.pop('report', report) 203 return cls(env, report, constraints=constraints, cols=cols, **kw)
204
205 - def get_columns(self):
206 if not self.cols: 207 self.cols = self.get_default_columns() 208 if not 'id' in self.cols: 209 # make sure 'id' is always present (needed for permission checks) 210 self.cols.insert(0, 'id') 211 return self.cols
212
213 - def get_all_textareas(self):
214 return [f['name'] for f in self.fields if f['type'] == 'textarea']
215
216 - def get_all_columns(self):
217 # Prepare the default list of columns 218 cols = ['id'] 219 cols += [f['name'] for f in self.fields if f['type'] != 'textarea'] 220 for col in ('reporter', 'keywords', 'cc'): 221 if col in cols: 222 cols.remove(col) 223 cols.append(col) 224 225 def sort_columns(col1, col2): 226 constrained_fields = self.constraint_cols.keys() 227 if 'id' in (col1, col2): 228 # Ticket ID is always the first column 229 return -1 if col1 == 'id' else 1 230 elif 'summary' in (col1, col2): 231 # Ticket summary is always the second column 232 return -1 if col1 == 'summary' else 1 233 elif col1 in constrained_fields or col2 in constrained_fields: 234 # Constrained columns appear before other columns 235 return -1 if col1 in constrained_fields else 1 236 return 0
237 cols.sort(sort_columns) 238 return cols 239
240 - def get_default_columns(self):
241 cols = self.get_all_columns() 242 243 # Semi-intelligently remove columns that are restricted to a single 244 # value by a query constraint. 245 for col in [k for k in self.constraint_cols.keys() 246 if k != 'id' and k in cols]: 247 constraints = self.constraint_cols[col] 248 for constraint in constraints: 249 if not (len(constraint) == 1 and constraint[0] 250 and not constraint[0][0] in '!~^$' and col in cols 251 and col not in self.time_fields): 252 break 253 else: 254 cols.remove(col) 255 if col == 'status' and 'resolution' in cols: 256 for constraint in constraints: 257 if 'closed' in constraint: 258 break 259 else: 260 cols.remove('resolution') 261 if self.group in cols: 262 cols.remove(self.group) 263 264 # Only display the first seven columns by default 265 cols = cols[:7] 266 # Make sure the column we order by is visible, if it isn't also 267 # the column we group by 268 if not self.order in cols and not self.order == self.group: 269 cols[-1] = self.order 270 return cols
271
272 - def count(self, req=None, db=None, cached_ids=None, authname=None, 273 tzinfo=None, locale=None):
274 """Get the number of matching tickets for the present query. 275 276 :since 1.0: the `db` parameter is no longer needed and will be 277 removed in version 1.1.1 278 :since 1.0.17: the `tzinfo` parameter is deprecated and will be 279 removed in version 1.5.1 280 :since 1.0.17: the `locale` parameter is deprecated and will be 281 removed in version 1.5.1 282 """ 283 sql, args = self.get_sql(req, cached_ids, authname, tzinfo, locale) 284 return self._count(sql, args)
285
286 - def _count(self, sql, args):
287 cnt = self.env.db_query("SELECT COUNT(*) FROM (%s) AS x" 288 % sql, args)[0][0] 289 # "AS x" is needed for MySQL ("Subqueries in the FROM Clause") 290 self.env.log.debug("Count results in Query: %d", cnt) 291 return cnt
292
293 - def execute(self, req=None, db=None, cached_ids=None, authname=None, 294 tzinfo=None, href=None, locale=None):
295 """Retrieve the list of matching tickets. 296 297 :since 1.0: the `db` parameter is no longer needed and will be removed 298 in version 1.1.1 299 :since 1.0.17: the `tzinfo` parameter is deprecated and will be 300 removed in version 1.5.1 301 :since 1.0.17: the `locale` parameter is deprecated and will be 302 removed in version 1.5.1 303 """ 304 if req is not None: 305 href = req.href 306 with self.env.db_query as db: 307 cursor = db.cursor() 308 309 self.num_items = 0 310 sql, args = self.get_sql(req, cached_ids, authname, tzinfo, locale) 311 self.num_items = self._count(sql, args) 312 313 if self.num_items <= self.max: 314 self.has_more_pages = False 315 316 if self.has_more_pages: 317 max = self.max 318 if self.group: 319 max += 1 320 sql = sql + " LIMIT %d OFFSET %d" % (max, self.offset) 321 if (self.page > int(ceil(float(self.num_items) / self.max)) and 322 self.num_items != 0): 323 raise TracError(_("Page %(page)s is beyond the number of " 324 "pages in the query", page=self.page)) 325 326 cursor.execute(sql, args) 327 columns = get_column_names(cursor) 328 fields = [] 329 for column in columns: 330 fields += [f for f in self.fields if f['name'] == column] or \ 331 [None] 332 results = [] 333 334 column_indices = range(len(columns)) 335 for row in cursor: 336 result = {} 337 for i in column_indices: 338 name, field, val = columns[i], fields[i], row[i] 339 if name == 'reporter': 340 val = val or 'anonymous' 341 elif name == 'id': 342 val = int(val) 343 if href is not None: 344 result['href'] = href.ticket(val) 345 elif name in self.time_fields: 346 val = from_utimestamp(val) 347 elif field and field['type'] == 'checkbox': 348 val = as_bool(val) 349 elif val is None: 350 val = '' 351 result[name] = val 352 results.append(result) 353 cursor.close() 354 return results
355
356 - def get_href(self, href, id=None, order=None, desc=None, format=None, 357 max=None, page=None):
358 """Create a link corresponding to this query. 359 360 :param href: the `Href` object used to build the URL 361 :param id: optionally set or override the report `id` 362 :param order: optionally override the order parameter of the query 363 :param desc: optionally override the desc parameter 364 :param format: optionally override the format of the query 365 :param max: optionally override the max items per page 366 :param page: optionally specify which page of results (defaults to 367 the first) 368 369 Note: `get_resource_url` of a 'query' resource? 370 """ 371 if not isinstance(href, Href): 372 href = href.href # compatibility with the `req` of the 0.10 API 373 374 if format is None: 375 format = self.format 376 if format == 'rss': 377 max = self.items_per_page 378 page = self.default_page 379 380 if id is None: 381 id = self.id 382 if desc is None: 383 desc = self.desc 384 if order is None: 385 order = self.order 386 if max is None: 387 max = self.max 388 if page is None: 389 page = self.page 390 391 cols = self.get_columns() 392 # don't specify the columns in the href if they correspond to 393 # the default columns, page and max in the same order. That keeps the 394 # query url shorter in the common case where we just want the default 395 # columns. 396 if cols == self.get_default_columns(): 397 cols = None 398 if page == self.default_page: 399 page = None 400 if max == self.items_per_page: 401 max = None 402 403 constraints = [] 404 for clause in self.constraints: 405 constraints.extend(clause.iteritems()) 406 constraints.append(("or", empty)) 407 del constraints[-1:] 408 409 return href.query(constraints, 410 report=id, 411 order=order, desc=1 if desc else None, 412 group=self.group or None, 413 groupdesc=1 if self.groupdesc else None, 414 col=cols, 415 row=self.rows, 416 max=max, 417 page=page, 418 format=format)
419
420 - def to_string(self):
421 """Return a user readable and editable representation of the query. 422 423 Note: for now, this is an "exploded" query href, but ideally should be 424 expressed in TracQuery language. 425 """ 426 query_string = self.get_href(Href('')) 427 query_string = query_string.split('?', 1)[-1] 428 return 'query:?' + query_string.replace('&', '\n&\n')
429
430 - def get_sql(self, req=None, cached_ids=None, authname=None, tzinfo=None, 431 locale=None):
432 """Return a (sql, params) tuple for the query. 433 434 :since 1.0.17: the `tzinfo` parameter is deprecated and will be 435 removed in version 1.5.1 436 :since 1.0.17: the `locale` parameter is deprecated and will be 437 removed in version 1.5.1 438 """ 439 if req is not None: 440 authname = req.authname 441 self.get_columns() 442 db = self.env.get_read_db() 443 444 # Build the list of actual columns to query 445 cols = [] 446 def add_cols(*args): 447 for col in args: 448 if not col in cols: 449 cols.append(col)
450 add_cols(*self.cols) # remove duplicated cols 451 if self.group and not self.group in cols: 452 add_cols(self.group) 453 if self.rows: 454 add_cols('reporter', *self.rows) 455 add_cols('status', 'priority', 'time', 'changetime', self.order) 456 add_cols(*list(self.constraint_cols)) 457 458 custom_fields = set(f['name'] for f in self.fields if f.get('custom')) 459 list_fields = set(f['name'] for f in self.fields 460 if f['type'] == 'text' and 461 f.get('format') == 'list') 462 cols_custom = [k for k in cols if k in custom_fields] 463 use_joins = len(cols_custom) <= 1 464 enum_columns = [col for col in ('resolution', 'priority', 'severity', 465 'type') 466 if col not in custom_fields and 467 col in ('priority', self.order, self.group)] 468 joined_columns = [col for col in ('milestone', 'version') 469 if col not in custom_fields and 470 col in (self.order, self.group)] 471 472 sql = [] 473 sql.append("SELECT " + ",".join('t.%s AS %s' % (c, c) for c in cols 474 if c not in custom_fields)) 475 if 'priority' in enum_columns: 476 sql.append(",priority.value AS priority_value") 477 478 if use_joins: 479 # Use LEFT OUTER JOIN for ticket_custom table 480 sql.extend(",%(qk)s.value AS %(qk)s" % {'qk': db.quote(k)} 481 for k in cols_custom) 482 sql.append("\nFROM ticket AS t") 483 sql.extend("\n LEFT OUTER JOIN ticket_custom AS %(qk)s ON " 484 "(%(qk)s.ticket=t.id AND %(qk)s.name='%(k)s')" 485 % {'qk': db.quote(k), 'k': k} for k in cols_custom) 486 else: 487 # Use MAX(CASE ... END) ... GROUP BY ... for ticket_custom table 488 sql.extend(",c.%(qk)s AS %(qk)s" % {'qk': db.quote(k)} 489 for k in cols_custom) 490 sql.append("\nFROM ticket AS t" 491 "\n LEFT OUTER JOIN (SELECT\n ticket AS id") 492 sql.extend(",\n MAX(CASE WHEN name='%s' THEN value END) AS %s" % 493 (k, db.quote(k)) for k in cols_custom) 494 sql.append("\n FROM ticket_custom AS tc") 495 sql.append("\n WHERE name IN (%s)" % 496 ','.join("'%s'" % k for k in cols_custom)) 497 sql.append("\n GROUP BY tc.ticket) AS c ON c.id=t.id") 498 499 # Join with the enum table for proper sorting 500 sql.extend("\n LEFT OUTER JOIN enum AS %(col)s ON " 501 "(%(col)s.type='%(type)s' AND %(col)s.name=t.%(col)s)" % 502 {'col': col, 503 'type': 'ticket_type' if col == 'type' else col} 504 for col in enum_columns) 505 506 # Join with the version/milestone tables for proper sorting 507 sql.extend("\n LEFT OUTER JOIN %(col)s ON (%(col)s.name=%(col)s)" 508 % {'col': col} for col in joined_columns) 509 510 def get_timestamp(date): 511 if date: 512 try: 513 return to_utimestamp(user_time(req, parse_date, date)) 514 except TracError, e: 515 errors.append(unicode(e)) 516 return None 517 518 def get_constraint_sql(name, value, mode, neg): 519 if name not in custom_fields: 520 col = 't.' + name 521 elif use_joins: 522 col = db.quote(name) + '.value' 523 else: 524 col = 'c.' + db.quote(name) 525 value = value[len(mode) + neg:] 526 527 if name in self.time_fields: 528 if '..' in value: 529 (start, end) = [each.strip() for each in 530 value.split('..', 1)] 531 else: 532 (start, end) = (value.strip(), '') 533 col_cast = db.cast(col, 'int64') 534 start = get_timestamp(start) 535 end = get_timestamp(end) 536 if start is not None and end is not None: 537 return ("%s(%s>=%%s AND %s<%%s)" % ('NOT ' if neg else '', 538 col_cast, col_cast), 539 (start, end)) 540 elif start is not None: 541 return ("%s%s>=%%s" % ('NOT ' if neg else '', col_cast), 542 (start, )) 543 elif end is not None: 544 return ("%s%s<%%s" % ('NOT ' if neg else '', col_cast), 545 (end, )) 546 else: 547 return None 548 549 if mode == '~' and name in list_fields: 550 words = value.split() 551 clauses, args = [], [] 552 for word in words: 553 cneg = '' 554 if word.startswith('-'): 555 cneg = 'NOT ' 556 word = word[1:] 557 if not word: 558 continue 559 clauses.append("COALESCE(%s,'') %s%s" % (col, cneg, 560 db.like())) 561 args.append('%' + db.like_escape(word) + '%') 562 if not clauses: 563 return None 564 return (('NOT ' if neg else '') 565 + '(' + ' AND '.join(clauses) + ')', args) 566 567 if mode == '': 568 return ("COALESCE(%s,'')%s=%%s" % (col, '!' if neg else ''), 569 (value, )) 570 571 if not value: 572 return None 573 value = db.like_escape(value) 574 if mode == '~': 575 value = '%' + value + '%' 576 elif mode == '^': 577 value = value + '%' 578 elif mode == '$': 579 value = '%' + value 580 return ("COALESCE(%s,'') %s%s" % (col, 'NOT ' if neg else '', 581 db.like()), 582 (value, )) 583 584 def get_clause_sql(constraints): 585 db = self.env.get_read_db() 586 clauses = [] 587 for k, v in constraints.iteritems(): 588 if authname is not None: 589 v = [val.replace('$USER', authname) for val in v] 590 # Determine the match mode of the constraint (contains, 591 # starts-with, negation, etc.) 592 neg = v[0].startswith('!') 593 mode = '' 594 if len(v[0]) > neg and v[0][neg] in ('~', '^', '$'): 595 mode = v[0][neg] 596 597 # Special case id ranges 598 if k == 'id': 599 ranges = Ranges() 600 for r in v: 601 r = r.replace('!', '') 602 try: 603 ranges.appendrange(r) 604 except Exception: 605 errors.append(_('Invalid ticket id list: ' 606 '%(value)s', value=r)) 607 ids = [] 608 id_clauses = [] 609 for a, b in ranges.pairs: 610 if a == b: 611 ids.append(str(a)) 612 else: 613 id_clauses.append('t.id BETWEEN %s AND %s') 614 args.append(a) 615 args.append(b) 616 if ids: 617 id_clauses.append('t.id IN (%s)' % (','.join(ids))) 618 if id_clauses: 619 clauses.append('%s(%s)' % ('NOT 'if neg else '', 620 ' OR '.join(id_clauses))) 621 # Special case for exact matches on multiple values 622 elif not mode and len(v) > 1 and k not in self.time_fields: 623 if k not in custom_fields: 624 col = 't.' + k 625 elif use_joins: 626 col = db.quote(k) + '.value' 627 else: 628 col = 'c.' + db.quote(k) 629 clauses.append("COALESCE(%s,'') %sIN (%s)" 630 % (col, 'NOT ' if neg else '', 631 ','.join(['%s' for val in v]))) 632 args.extend([val[neg:] for val in v]) 633 elif v: 634 constraint_sql = [get_constraint_sql(k, val, mode, neg) 635 for val in v] 636 constraint_sql = filter(None, constraint_sql) 637 if not constraint_sql: 638 continue 639 if neg: 640 clauses.append("(" + " AND ".join( 641 [item[0] for item in constraint_sql]) + ")") 642 else: 643 clauses.append("(" + " OR ".join( 644 [item[0] for item in constraint_sql]) + ")") 645 for item in constraint_sql: 646 args.extend(item[1]) 647 return " AND ".join(clauses) 648 649 args = [] 650 errors = [] 651 clauses = filter(None, (get_clause_sql(c) for c in self.constraints)) 652 if clauses: 653 sql.append("\nWHERE ") 654 sql.append(" OR ".join('(%s)' % c for c in clauses)) 655 if cached_ids: 656 sql.append(" OR ") 657 sql.append("t.id IN (%s)" % 658 (','.join([str(id) for id in cached_ids]))) 659 660 sql.append("\nORDER BY ") 661 order_cols = [(self.order, self.desc)] 662 if self.group and self.group != self.order: 663 order_cols.insert(0, (self.group, self.groupdesc)) 664 665 for name, desc in order_cols: 666 if name in enum_columns: 667 col = name + '.value' 668 elif name not in custom_fields: 669 col = 't.' + name 670 elif use_joins: 671 col = db.quote(name) + '.value' 672 else: 673 col = 'c.' + db.quote(name) 674 desc = ' DESC' if desc else '' 675 # FIXME: This is a somewhat ugly hack. Can we also have the 676 # column type for this? If it's an integer, we do first 677 # one, if text, we do 'else' 678 if name == 'id' or name in self.time_fields: 679 sql.append("COALESCE(%s,0)=0%s," % (col, desc)) 680 else: 681 sql.append("COALESCE(%s,'')=''%s," % (col, desc)) 682 if name in enum_columns: 683 # These values must be compared as ints, not as strings 684 sql.append(db.cast(col, 'int') + desc) 685 elif name == 'milestone' and name not in custom_fields: 686 sql.append("COALESCE(milestone.completed,0)=0%s," 687 "milestone.completed%s," 688 "COALESCE(milestone.due,0)=0%s,milestone.due%s," 689 "%s%s" % (desc, desc, desc, desc, col, desc)) 690 elif name == 'version' and name not in custom_fields: 691 sql.append("COALESCE(version.time,0)=0%s,version.time%s,%s%s" 692 % (desc, desc, col, desc)) 693 else: 694 sql.append("%s%s" % (col, desc)) 695 if name == self.group and not name == self.order: 696 sql.append(",") 697 if self.order != 'id': 698 sql.append(",t.id") 699 700 if errors: 701 raise QueryValueError(errors) 702 return "".join(sql), args 703 704 @staticmethod
705 - def get_modes():
706 modes = {} 707 modes['text'] = [ 708 {'name': _("contains"), 'value': "~"}, 709 {'name': _("doesn't contain"), 'value': "!~"}, 710 {'name': _("begins with"), 'value': "^"}, 711 {'name': _("ends with"), 'value': "$"}, 712 {'name': _("is"), 'value': ""}, 713 {'name': _("is not"), 'value': "!"}, 714 ] 715 modes['textarea'] = [ 716 {'name': _("contains"), 'value': "~"}, 717 {'name': _("doesn't contain"), 'value': "!~"}, 718 ] 719 modes['select'] = [ 720 {'name': _("is"), 'value': ""}, 721 {'name': _("is not"), 'value': "!"}, 722 ] 723 modes['id'] = [ 724 {'name': _("is"), 'value': ""}, 725 {'name': _("is not"), 'value': "!"}, 726 ] 727 return modes
728
729 - def template_data(self, context, tickets, orig_list=None, orig_time=None, 730 req=None):
731 clauses = [] 732 for clause in self.constraints: 733 constraints = {} 734 for k, v in clause.items(): 735 constraint = {'values': [], 'mode': ''} 736 for val in v: 737 neg = val.startswith('!') 738 if neg: 739 val = val[1:] 740 mode = '' 741 if val[:1] in ('~', '^', '$') \ 742 and not val in self.substitutions: 743 mode, val = val[:1], val[1:] 744 if req: 745 val = val.replace('$USER', req.authname) 746 constraint['mode'] = ('!' if neg else '') + mode 747 constraint['values'].append(val) 748 constraints[k] = constraint 749 clauses.append(constraints) 750 751 cols = self.get_columns() 752 labels = TicketSystem(self.env).get_ticket_field_labels() 753 wikify = set(f['name'] for f in self.fields 754 if f['type'] == 'text' and 755 f.get('format') == 'wiki') 756 wikifyblock = set(f['name'] for f in self.fields 757 if f['type'] == 'textarea' and 758 f.get('format') == 'wiki') 759 wikifyblock.add('description') 760 761 headers = [{ 762 'name': col, 'label': labels.get(col, _('Ticket')), 763 'wikify': col in wikify, 764 'wikifyblock': col in wikifyblock, 765 'href': self.get_href(context.href, order=col, 766 desc=(col == self.order and not self.desc)) 767 } for col in cols] 768 769 fields = {'id': {'type': 'id', 'label': _("Ticket")}} 770 for field in self.fields: 771 name = field['name'] 772 if name == 'owner' and field['type'] == 'select': 773 # Make $USER work when restrict_owner = true 774 field = field.copy() 775 field['options'].insert(0, '$USER') 776 if name == 'milestone' and not field.get('custom'): 777 field = field.copy() 778 milestones = [Milestone(self.env, opt) 779 for opt in field['options']] 780 milestones = [m for m in milestones 781 if 'MILESTONE_VIEW' in context.perm(m.resource)] 782 groups = group_milestones(milestones, True) 783 field['options'] = [] 784 field['optgroups'] = [ 785 {'label': label, 'options': [m.name for m in milestones]} 786 for (label, milestones) in groups] 787 fields[name] = field 788 789 groups = {} 790 groupsequence = [] 791 for ticket in tickets: 792 if orig_list: 793 # Mark tickets added or changed since the query was first 794 # executed 795 if ticket['time'] > orig_time: 796 ticket['_added'] = True 797 elif ticket['changetime'] > orig_time: 798 ticket['_changed'] = True 799 if self.group: 800 group_key = ticket[self.group] 801 groups.setdefault(group_key, []).append(ticket) 802 if not groupsequence or group_key not in groupsequence: 803 groupsequence.append(group_key) 804 groupsequence = [(value, groups[value]) for value in groupsequence] 805 806 # detect whether the last group continues on the next page, 807 # by checking if the extra (max+1)th ticket is in the last group 808 last_group_is_partial = False 809 if groupsequence and self.max and len(tickets) == self.max + 1: 810 del tickets[-1] 811 if len(groupsequence[-1][1]) == 1: 812 # additional ticket started a new group 813 del groupsequence[-1] # remove that additional group 814 else: 815 # additional ticket stayed in the group 816 last_group_is_partial = True 817 del groupsequence[-1][1][-1] # remove the additional ticket 818 819 results = Paginator(tickets, 820 self.page - 1, 821 self.max, 822 self.num_items) 823 824 if req: 825 if results.has_next_page: 826 next_href = self.get_href(req.href, max=self.max, 827 page=self.page + 1) 828 add_link(req, 'next', next_href, _('Next Page')) 829 830 if results.has_previous_page: 831 prev_href = self.get_href(req.href, max=self.max, 832 page=self.page - 1) 833 add_link(req, 'prev', prev_href, _('Previous Page')) 834 else: 835 results.show_index = False 836 837 pagedata = [] 838 shown_pages = results.get_shown_pages(21) 839 for page in shown_pages: 840 pagedata.append([self.get_href(context.href, page=page), None, 841 str(page), _('Page %(num)d', num=page)]) 842 843 results.shown_pages = [dict(zip(['href', 'class', 'string', 'title'], 844 p)) for p in pagedata] 845 results.current_page = {'href': None, 'class': 'current', 846 'string': str(results.page + 1), 847 'title':None} 848 849 return {'query': self, 850 'context': context, 851 'col': cols, 852 'row': self.rows, 853 'clauses': clauses, 854 'headers': headers, 855 'fields': fields, 856 'modes': self.get_modes(), 857 'tickets': tickets, 858 'groups': groupsequence or [(None, tickets)], 859 'last_group_is_partial': last_group_is_partial, 860 'paginator': results}
861
862 -class QueryModule(Component):
863 864 implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider, 865 IContentConverter) 866 867 default_query = Option('query', 'default_query', 868 default='status!=closed&owner=$USER', 869 doc="""The default query for authenticated users. The query is either 870 in [TracQuery#QueryLanguage query language] syntax, or a URL query 871 string starting with `?` as used in `query:` 872 [TracQuery#UsingTracLinks Trac links]. 873 (''since 0.11.2'')""") 874 875 default_anonymous_query = Option('query', 'default_anonymous_query', 876 default='status!=closed&cc~=$USER', 877 doc="""The default query for anonymous users. The query is either 878 in [TracQuery#QueryLanguage query language] syntax, or a URL query 879 string starting with `?` as used in `query:` 880 [TracQuery#UsingTracLinks Trac links]. 881 (''since 0.11.2'')""") 882 883 items_per_page = IntOption('query', 'items_per_page', 100, 884 """Number of tickets displayed per page in ticket queries, 885 by default (''since 0.11'')""") 886 887 # IContentConverter methods 888
889 - def get_supported_conversions(self):
890 yield ('rss', _('RSS Feed'), 'xml', 891 'trac.ticket.Query', 'application/rss+xml', 8) 892 yield ('csv', _('Comma-delimited Text'), 'csv', 893 'trac.ticket.Query', 'text/csv', 8) 894 yield ('tab', _('Tab-delimited Text'), 'tsv', 895 'trac.ticket.Query', 'text/tab-separated-values', 8)
896
897 - def convert_content(self, req, mimetype, query, key):
898 if key == 'rss': 899 return self._export_rss(req, query) 900 elif key == 'csv': 901 return self._export_csv(req, query, mimetype='text/csv') 902 elif key == 'tab': 903 return self._export_csv(req, query, '\t', 904 mimetype='text/tab-separated-values')
905 906 # INavigationContributor methods 907
908 - def get_active_navigation_item(self, req):
909 return 'tickets'
910
911 - def get_navigation_items(self, req):
912 from trac.ticket.report import ReportModule 913 if 'TICKET_VIEW' in req.perm and \ 914 (not self.env.is_component_enabled(ReportModule) or 915 'REPORT_VIEW' not in req.perm): 916 yield ('mainnav', 'tickets', 917 tag.a(_('View Tickets'), href=req.href.query()))
918 919 # IRequestHandler methods 920
921 - def match_request(self, req):
922 return req.path_info == '/query'
923
924 - def process_request(self, req):
925 req.perm.assert_permission('TICKET_VIEW') 926 report_id = as_int(req.args.getfirst('report'), None) 927 if report_id: 928 req.perm('report', report_id).assert_permission('REPORT_VIEW') 929 930 constraints = self._get_constraints(req) 931 args = req.args 932 if not constraints: 933 # If no constraints are given in the URL, use the default ones. 934 if req.authname and req.authname != 'anonymous': 935 qstring = self.default_query 936 user = req.authname 937 else: 938 email = req.session.get('email') 939 name = req.session.get('name') 940 qstring = self.default_anonymous_query 941 user = email or name or None 942 943 self.log.debug('QueryModule: Using default query: %s', qstring) 944 if qstring.startswith('?'): 945 arg_list = parse_arg_list(qstring[1:]) 946 args = arg_list_to_args(arg_list) 947 constraints = self._get_constraints(arg_list=arg_list) 948 else: 949 query = Query.from_string(self.env, qstring) 950 args.setdefault('col', query.cols) 951 args.setdefault('desc', query.desc) 952 args.setdefault('group', query.group) 953 args.setdefault('groupdesc', query.groupdesc) 954 args.setdefault('max', query.max) 955 args.setdefault('order', query.order) 956 constraints = query.constraints 957 958 # Substitute $USER, or ensure no field constraints that depend 959 # on $USER are used if we have no username. 960 for clause in constraints: 961 for field, vals in clause.items(): 962 for (i, val) in enumerate(vals): 963 if user: 964 vals[i] = val.replace('$USER', user) 965 elif val.endswith('$USER'): 966 del clause[field] 967 break 968 969 cols = args.get('col') 970 if isinstance(cols, basestring): 971 cols = [cols] 972 # Since we don't show 'id' as an option to the user, 973 # we need to re-insert it here. 974 if cols and 'id' not in cols: 975 cols.insert(0, 'id') 976 rows = args.get('row', []) 977 if isinstance(rows, basestring): 978 rows = [rows] 979 format = req.args.get('format') 980 max = args.get('max') 981 if max is None and format in ('csv', 'tab'): 982 max = 0 # unlimited unless specified explicitly 983 order = args.get('order') 984 if isinstance(order, (list, tuple)): 985 order = order[0] if order else None 986 group = args.get('group') 987 if isinstance(group, (list, tuple)): 988 group = group[0] if group else None 989 query = Query(self.env, report_id, 990 constraints, cols, order, as_bool(args.get('desc')), 991 group, as_bool(args.get('groupdesc')), 992 as_bool(args.get('verbose')), rows, args.get('page'), 993 max) 994 995 if 'update' in req.args: 996 # Reset session vars 997 for var in ('query_constraints', 'query_time', 'query_tickets'): 998 if var in req.session: 999 del req.session[var] 1000 req.redirect(query.get_href(req.href)) 1001 1002 # Add registered converters 1003 for conversion in Mimeview(self.env).get_supported_conversions( 1004 'trac.ticket.Query'): 1005 format_ = conversion[0] 1006 conversion_href = query.get_href(req.href, format=format_) 1007 if format_ == 'rss': 1008 conversion_href = auth_link(req, conversion_href) 1009 add_link(req, 'alternate', conversion_href, conversion[1], 1010 conversion[4], format_) 1011 1012 if format: 1013 filename = 'query' if format != 'rss' else None 1014 Mimeview(self.env).send_converted(req, 'trac.ticket.Query', query, 1015 format, filename=filename) 1016 1017 return self.display_html(req, query)
1018 1019 # Internal methods 1020 1021 remove_re = re.compile(r'rm_filter_\d+_(.+)_(\d+)$') 1022 add_re = re.compile(r'add_(\d+)$') 1023
1024 - def _get_constraints(self, req=None, arg_list=[]):
1025 fields = TicketSystem(self.env).get_ticket_fields() 1026 synonyms = TicketSystem(self.env).get_field_synonyms() 1027 fields = dict((f['name'], f) for f in fields) 1028 fields['id'] = {'type': 'id'} 1029 fields.update((k, fields[v]) for k, v in synonyms.iteritems()) 1030 1031 clauses = [] 1032 if req is not None: 1033 # For clients without JavaScript, we remove constraints here if 1034 # requested 1035 remove_constraints = {} 1036 for k in req.args: 1037 match = self.remove_re.match(k) 1038 if match: 1039 field = match.group(1) 1040 if fields[field]['type'] == 'radio': 1041 index = -1 1042 else: 1043 index = int(match.group(2)) 1044 remove_constraints[k[10:match.end(1)]] = index 1045 1046 # Get constraints from form fields, and add a constraint if 1047 # requested for clients without JavaScript 1048 add_num = None 1049 constraints = {} 1050 for k in req.args: 1051 match = self.add_re.match(k) 1052 if match: 1053 add_num = match.group(1) 1054 continue 1055 match = Query.clause_re.match(k) 1056 if not match: 1057 continue 1058 field = match.group('field') 1059 clause_num = int(match.group('clause')) 1060 if field not in fields: 1061 continue 1062 # use list() to avoid modification of a list in req.args 1063 vals = list(req.args.getlist(k)) 1064 if vals: 1065 mode = req.args.get(k + '_mode') 1066 if mode: 1067 vals = [mode + x for x in vals] 1068 if fields[field]['type'] == 'time': 1069 ends = req.args.getlist(k + '_end') 1070 if ends: 1071 vals = [start + '..' + end 1072 for (start, end) in zip(vals, ends)] 1073 if k in remove_constraints: 1074 idx = remove_constraints[k] 1075 if 0 <= idx < len(vals): 1076 del vals[idx] 1077 if not vals: 1078 continue 1079 else: 1080 continue 1081 field = synonyms.get(field, field) 1082 clause = constraints.setdefault(clause_num, {}) 1083 clause.setdefault(field, []).extend(vals) 1084 if add_num is not None: 1085 field = req.args.get('add_filter_' + add_num, 1086 req.args.get('add_clause_' + add_num)) 1087 if field: 1088 clause = constraints.setdefault(int(add_num), {}) 1089 modes = Query.get_modes().get(fields[field]['type']) 1090 mode = modes[0]['value'] if modes else '' 1091 clause.setdefault(field, []).append(mode) 1092 clauses.extend(each[1] for each in sorted(constraints.iteritems())) 1093 1094 # Get constraints from query string 1095 clauses.append({}) 1096 for field, val in arg_list or req.arg_list: 1097 if field == "or": 1098 clauses.append({}) 1099 elif field in fields: 1100 clauses[-1].setdefault(field, []).append(val) 1101 clauses = filter(None, clauses) 1102 1103 return clauses
1104
1105 - def display_html(self, req, query):
1106 # The most recent query is stored in the user session; 1107 orig_list = None 1108 orig_time = datetime_now(utc) 1109 query_time = int(req.session.get('query_time', 0)) 1110 query_time = datetime.fromtimestamp(query_time, utc) 1111 query_constraints = unicode(query.constraints) 1112 try: 1113 if query_constraints != req.session.get('query_constraints') \ 1114 or query_time < orig_time - timedelta(hours=1): 1115 tickets = query.execute(req) 1116 # New or outdated query, (re-)initialize session vars 1117 req.session['query_constraints'] = query_constraints 1118 req.session['query_tickets'] = ' '.join([str(t['id']) 1119 for t in tickets]) 1120 else: 1121 orig_list = [int(id) for id 1122 in req.session.get('query_tickets', '').split()] 1123 tickets = query.execute(req, cached_ids=orig_list) 1124 orig_time = query_time 1125 except QueryValueError, e: 1126 tickets = [] 1127 for error in e.errors: 1128 add_warning(req, error) 1129 1130 context = web_context(req, 'query') 1131 owner_field = [f for f in query.fields if f['name'] == 'owner'] 1132 if owner_field: 1133 TicketSystem(self.env).eventually_restrict_owner(owner_field[0]) 1134 data = query.template_data(context, tickets, orig_list, orig_time, req) 1135 1136 req.session['query_href'] = query.get_href(context.href) 1137 req.session['query_time'] = to_timestamp(orig_time) 1138 req.session['query_tickets'] = ' '.join([str(t['id']) 1139 for t in tickets]) 1140 title = _('Custom Query') 1141 1142 # Only interact with the report module if it is actually enabled. 1143 # 1144 # Note that with saved custom queries, there will be some convergence 1145 # between the report module and the query module. 1146 from trac.ticket.report import ReportModule 1147 if 'REPORT_VIEW' in req.perm and \ 1148 self.env.is_component_enabled(ReportModule): 1149 data['report_href'] = req.href.report() 1150 add_ctxtnav(req, _('Available Reports'), req.href.report()) 1151 add_ctxtnav(req, _('Custom Query'), req.href.query()) 1152 report_id = as_int(query.id, None) 1153 if report_id is not None: 1154 for title, description in self.env.db_query(""" 1155 SELECT title, description FROM report WHERE id=%s 1156 """, (report_id,)): 1157 data['report_resource'] = Resource('report', report_id) 1158 data['description'] = description 1159 else: 1160 data['report_href'] = None 1161 1162 data.setdefault('report', None) 1163 data.setdefault('description', None) 1164 data['title'] = title 1165 1166 data['all_columns'] = query.get_all_columns() 1167 # Don't allow the user to remove the id column 1168 data['all_columns'].remove('id') 1169 data['all_textareas'] = query.get_all_textareas() 1170 1171 properties = dict((name, dict((key, field[key]) 1172 for key in ('type', 'label', 'options', 1173 'optgroups') 1174 if key in field)) 1175 for name, field in data['fields'].iteritems()) 1176 add_script_data(req, properties=properties, modes=data['modes']) 1177 1178 add_stylesheet(req, 'common/css/report.css') 1179 Chrome(self.env).add_jquery_ui(req) 1180 add_script(req, 'common/js/query.js') 1181 1182 return 'query.html', data, None
1183
1184 - def export_csv(self, req, query, sep=',', mimetype='text/plain'):
1185 """:deprecated: since 1.0.6, use `_export_csv` instead. Will be 1186 removed in 1.3.1. 1187 """ 1188 content, content_type = self._export_csv(req, query, sep, mimetype) 1189 return ''.join(content), content_type
1190
1191 - def _export_csv(self, req, query, sep=',', mimetype='text/plain'):
1192 def iterate(): 1193 out = StringIO() 1194 writer = csv.writer(out, delimiter=sep, quoting=csv.QUOTE_MINIMAL) 1195 1196 def writerow(values): 1197 writer.writerow([unicode(value).encode('utf-8') 1198 for value in values]) 1199 rv = out.getvalue() 1200 out.truncate(0) 1201 return rv
1202 1203 yield '\xef\xbb\xbf' # BOM 1204 1205 cols = query.get_columns() 1206 yield writerow(cols) 1207 1208 chrome = Chrome(self.env) 1209 context = web_context(req) 1210 results = query.execute(req) 1211 fields = dict((f['name'], f) for f in query.fields) 1212 for result in results: 1213 ticket = Resource('ticket', result['id']) 1214 if 'TICKET_VIEW' in req.perm(ticket): 1215 values = [] 1216 for col in cols: 1217 value = result[col] 1218 field = fields.get(col) 1219 if col in ('cc', 'owner', 'reporter'): 1220 value = chrome.format_emails(context.child(ticket), 1221 value) 1222 elif col in query.time_fields: 1223 value = format_datetime(value, '%Y-%m-%d %H:%M:%S', 1224 tzinfo=req.tz) 1225 elif field and field['type'] == 'checkbox': 1226 value = '1' if value else '0' 1227 values.append(value) 1228 yield writerow(values)
1229 1230 return iterate(), '%s;charset=utf-8' % mimetype 1231
1232 - def export_rss(self, req, query):
1233 """:deprecated: since 1.0.6, use `_export_rss` instead. Will be 1234 removed in 1.3.1. 1235 """ 1236 content, content_type = self._export_rss(req, query) 1237 return ''.join(content), content_type
1238
1239 - def _export_rss(self, req, query):
1240 context = web_context(req, 'query', absurls=True) 1241 query_href = query.get_href(context.href) 1242 if 'description' not in query.rows: 1243 query.rows.append('description') 1244 results = query.execute(req) 1245 data = { 1246 'context': context, 1247 'results': results, 1248 'query_href': query_href 1249 } 1250 output = Chrome(self.env).render_template(req, 'query.rss', data, 1251 'application/rss+xml', 1252 iterable=True) 1253 return output, 'application/rss+xml'
1254 1255 # IWikiSyntaxProvider methods 1256
1257 - def get_wiki_syntax(self):
1258 return []
1259 1262 1277
1278 1279 -class TicketQueryMacro(WikiMacroBase):
1280 _domain = 'messages' 1281 _description = cleandoc_( 1282 """Wiki macro listing tickets that match certain criteria. 1283 1284 This macro accepts a comma-separated list of keyed parameters, 1285 in the form "key=value". 1286 1287 If the key is the name of a field, the value must use the syntax 1288 of a filter specifier as defined in TracQuery#QueryLanguage. 1289 Note that this is ''not'' the same as the simplified URL syntax 1290 used for `query:` links starting with a `?` character. Commas (`,`) 1291 can be included in field values by escaping them with a backslash (`\`). 1292 1293 Groups of field constraints to be OR-ed together can be separated by a 1294 literal `or` argument. 1295 1296 In addition to filters, several other named parameters can be used 1297 to control how the results are presented. All of them are optional. 1298 1299 The `format` parameter determines how the list of tickets is 1300 presented: 1301 - '''list''' -- the default presentation is to list the ticket ID next 1302 to the summary, with each ticket on a separate line. 1303 - '''compact''' -- the tickets are presented as a comma-separated 1304 list of ticket IDs. 1305 - '''count''' -- only the count of matching tickets is displayed 1306 - '''table''' -- a view similar to the custom query view (but without 1307 the controls) 1308 - '''progress''' -- a view similar to the milestone progress bars 1309 1310 The `max` parameter can be used to limit the number of tickets shown 1311 (defaults to '''0''', i.e. no maximum). 1312 1313 The `order` parameter sets the field used for ordering tickets 1314 (defaults to '''id'''). 1315 1316 The `desc` parameter indicates whether the order of the tickets 1317 should be reversed (defaults to '''false'''). 1318 1319 The `group` parameter sets the field used for grouping tickets 1320 (defaults to not being set). 1321 1322 The `groupdesc` parameter indicates whether the natural display 1323 order of the groups should be reversed (defaults to '''false'''). 1324 1325 The `verbose` parameter can be set to a true value in order to 1326 get the description for the listed tickets. For '''table''' format only. 1327 ''deprecated in favor of the `rows` parameter'' 1328 1329 The `rows` parameter can be used to specify which field(s) should 1330 be viewed as a row, e.g. `rows=description|summary` 1331 1332 The `col` parameter can be used to specify which fields should 1333 be viewed as columns. For '''table''' format only. 1334 1335 For compatibility with Trac 0.10, if there's a last positional parameter 1336 given to the macro, it will be used to specify the `format`. 1337 Also, using "&" as a field separator still works (except for `order`) 1338 but is deprecated. 1339 """) 1340 1341 _comma_splitter = re.compile(r'(?<!\\),') 1342 1343 @staticmethod
1344 - def parse_args(content):
1345 """Parse macro arguments and translate them to a query string.""" 1346 clauses = [{}] 1347 argv = [] 1348 kwargs = {} 1349 for arg in TicketQueryMacro._comma_splitter.split(content or ''): 1350 arg = arg.replace(r'\,', ',') 1351 m = re.match(r'\s*[^=]+=', arg) 1352 if m: 1353 kw = arg[:m.end() - 1].strip() 1354 value = arg[m.end():] 1355 if kw in ('order', 'max', 'format', 'col'): 1356 kwargs[kw] = value 1357 else: 1358 clauses[-1][kw] = value 1359 elif arg.strip() == 'or': 1360 clauses.append({}) 1361 else: 1362 argv.append(arg) 1363 clauses = filter(None, clauses) 1364 1365 if len(argv) > 0 and not 'format' in kwargs: # 0.10 compatibility hack 1366 kwargs['format'] = argv[0] 1367 if 'order' not in kwargs: 1368 kwargs['order'] = 'id' 1369 if 'max' not in kwargs: 1370 kwargs['max'] = '0' # unlimited by default 1371 1372 format = kwargs.pop('format', 'list').strip().lower() 1373 if format in ('list', 'compact'): # we need 'status' and 'summary' 1374 if 'col' in kwargs: 1375 kwargs['col'] = 'status|summary|' + kwargs['col'] 1376 else: 1377 kwargs['col'] = 'status|summary' 1378 1379 query_string = '&or&'.join('&'.join('%s=%s' % item 1380 for item in clause.iteritems()) 1381 for clause in clauses) 1382 return query_string, kwargs, format
1383
1384 - def expand_macro(self, formatter, name, content):
1385 req = formatter.req 1386 query_string, kwargs, format = self.parse_args(content) 1387 if query_string: 1388 query_string += '&' 1389 query_string += '&'.join('%s=%s' % item 1390 for item in kwargs.iteritems()) 1391 query = Query.from_string(self.env, query_string) 1392 1393 if format == 'count': 1394 cnt = query.count(req) 1395 title = ngettext("%(num)d ticket for which %(query)s", 1396 "%(num)d tickets for which %(query)s", 1397 cnt, query=query_string) 1398 return tag.span(cnt, title=title, class_='query_count') 1399 1400 try: 1401 tickets = query.execute(req) 1402 except QueryValueError, e: 1403 self.log.warn(e) 1404 return system_message(_("Error executing TicketQuery macro"), e) 1405 1406 if format == 'table': 1407 data = query.template_data(formatter.context, tickets, 1408 req=formatter.context.req) 1409 1410 add_stylesheet(req, 'common/css/report.css') 1411 1412 return Chrome(self.env).render_template( 1413 req, 'query_results.html', data, None, fragment=True) 1414 1415 if format == 'progress': 1416 from trac.ticket.roadmap import (RoadmapModule, 1417 apply_ticket_permissions, 1418 get_ticket_stats, 1419 grouped_stats_data) 1420 1421 add_stylesheet(req, 'common/css/roadmap.css') 1422 1423 def query_href(extra_args, group_value=None): 1424 q = query_string + ''.join('&%s=%s' % (kw, v) 1425 for kw in extra_args 1426 if kw not in ['group', 'status'] 1427 for v in extra_args[kw]) 1428 q = Query.from_string(self.env, q) 1429 args = {} 1430 if q.group: 1431 args[q.group] = group_value 1432 q.groupdesc = 0 # avoid groupdesc=1 in query string 1433 q.group = extra_args.get('group') 1434 if 'status' in extra_args: 1435 args['status'] = extra_args['status'] 1436 for constraint in q.constraints: 1437 constraint.update(args) 1438 if not q.constraints: 1439 q.constraints.append(args) 1440 return q.get_href(formatter.context)
1441 chrome = Chrome(self.env) 1442 tickets = apply_ticket_permissions(self.env, req, tickets) 1443 stats_provider = RoadmapModule(self.env).stats_provider 1444 by = query.group 1445 if not by: 1446 stat = get_ticket_stats(stats_provider, tickets) 1447 data = { 1448 'stats': stat, 1449 'stats_href': query_href(stat.qry_args), 1450 'interval_hrefs': [query_href(interval['qry_args']) 1451 for interval in stat.intervals], 1452 'legend': True, 1453 } 1454 return tag.div( 1455 chrome.render_template(req, 'progress_bar.html', data, 1456 None, fragment=True), 1457 class_='trac-progress') 1458 1459 def per_group_stats_data(gstat, group_name): 1460 return { 1461 'stats': gstat, 1462 'stats_href': query_href(gstat.qry_args, group_name), 1463 'interval_hrefs': [query_href(interval['qry_args'], 1464 group_name) 1465 for interval in gstat.intervals], 1466 'percent': '%d / %d' % (gstat.done_count, 1467 gstat.count), 1468 'legend': False, 1469 }
1470 1471 groups = grouped_stats_data(self.env, stats_provider, tickets, by, 1472 per_group_stats_data) 1473 if query.groupdesc: 1474 groups.reverse() 1475 data = { 1476 'groups': groups, 'grouped_by': by, 1477 'summary': _("Ticket completion status for each %(group)s", 1478 group=by), 1479 } 1480 return tag.div( 1481 chrome.render_template(req, 'progress_bar_grouped.html', data, 1482 None, fragment=True), 1483 class_='trac-groupprogress') 1484 1485 # Formats above had their own permission checks, here we need to 1486 # do it explicitly: 1487 1488 tickets = [t for t in tickets 1489 if 'TICKET_VIEW' in req.perm('ticket', t['id'])] 1490 1491 if not tickets: 1492 return tag.span(_("No results"), class_='query_no_results') 1493 1494 def ticket_anchor(ticket): 1495 return tag.a('#%s' % ticket['id'], 1496 class_=ticket['status'], 1497 href=req.href.ticket(int(ticket['id'])), 1498 title=shorten_line(ticket['summary'])) 1499 1500 def ticket_groups(): 1501 groups = [] 1502 for v, g in groupby(tickets, lambda t: t[query.group]): 1503 q = Query.from_string(self.env, query_string) 1504 # produce the hint for the group 1505 q.group = q.groupdesc = None 1506 order = q.order 1507 q.order = None 1508 title = _("%(groupvalue)s %(groupname)s tickets matching " 1509 "%(query)s", groupvalue=v, groupname=query.group, 1510 query=q.to_string()) 1511 # produce the href for the query corresponding to the group 1512 for constraint in q.constraints: 1513 constraint[str(query.group)] = v 1514 q.order = order 1515 href = q.get_href(formatter.context) 1516 groups.append((v, [t for t in g], href, title)) 1517 return groups 1518 1519 if format == 'compact': 1520 if query.group: 1521 groups = [(v, ' ', 1522 tag.a('#%s' % u',\u200b'.join(str(t['id']) 1523 for t in g), 1524 href=href, class_='query', title=title)) 1525 for v, g, href, title in ticket_groups()] 1526 return tag(groups[0], [(', ', g) for g in groups[1:]]) 1527 else: 1528 alist = [ticket_anchor(ticket) for ticket in tickets] 1529 return tag.span(alist[0], *[(', ', a) for a in alist[1:]]) 1530 else: 1531 if query.group: 1532 return tag.div( 1533 [(tag.p(tag_('%(groupvalue)s %(groupname)s tickets:', 1534 groupvalue=tag.a(v, href=href, class_='query', 1535 title=title), 1536 groupname=query.group)), 1537 tag.dl([(tag.dt(ticket_anchor(t)), 1538 tag.dd(t['summary'])) for t in g], 1539 class_='wiki compact')) 1540 for v, g, href, title in ticket_groups()]) 1541 else: 1542 return tag.div(tag.dl([(tag.dt(ticket_anchor(ticket)), 1543 tag.dd(ticket['summary'])) 1544 for ticket in tickets], 1545 class_='wiki compact')) 1546
1547 - def is_inline(self, content):
1548 query_string, kwargs, format = self.parse_args(content) 1549 return format in ('count', 'compact')
1550