Package trac :: Package ticket :: Module query

Source Code for Module trac.ticket.query

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