1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
55 """Exception raised when a ticket query cannot be parsed from a string."""
56
59 """Exception raised when a ticket query has bad constraint values."""
63
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
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)
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
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
98
99
100 if max in ('none', ''):
101 max = 0
102
103 if max is None:
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:
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
150 kw_strs = ['order', 'group', 'page', 'max', 'format']
151 kw_arys = ['rows']
152 kw_bools = ['desc', 'groupdesc', 'verbose']
153 kw_synonyms = {'row': 'rows'}
154
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
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
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
206 if not self.cols:
207 self.cols = self.get_default_columns()
208 if not 'id' in self.cols:
209
210 self.cols.insert(0, 'id')
211 return self.cols
212
214 return [f['name'] for f in self.fields if f['type'] == 'textarea']
215
217
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
229 return -1 if col1 == 'id' else 1
230 elif 'summary' in (col1, col2):
231
232 return -1 if col1 == 'summary' else 1
233 elif col1 in constrained_fields or col2 in constrained_fields:
234
235 return -1 if col1 in constrained_fields else 1
236 return 0
237 cols.sort(sort_columns)
238 return cols
239
241 cols = self.get_all_columns()
242
243
244
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
265 cols = cols[:7]
266
267
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
287 cnt = self.env.db_query("SELECT COUNT(*) FROM (%s) AS x"
288 % sql, args)[0][0]
289
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
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
393
394
395
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
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
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)
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
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
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
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
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
591
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
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
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
676
677
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
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
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
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
794
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
807
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
813 del groupsequence[-1]
814 else:
815
816 last_group_is_partial = True
817 del groupsequence[-1][1][-1]
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
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
888
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
907
910
918
919
920
923
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
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
959
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
973
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
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
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
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
1020
1021 remove_re = re.compile(r'rm_filter_\d+_(.+)_(\d+)$')
1022 add_re = re.compile(r'add_(\d+)$')
1023
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
1034
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
1047
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
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
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
1106
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
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
1143
1144
1145
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
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'
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
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
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
1256
1259
1261 yield ('query', self._format_link)
1262
1277
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
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:
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'
1371
1372 format = kwargs.pop('format', 'list').strip().lower()
1373 if format in ('list', 'compact'):
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
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
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
1486
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
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
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
1550