1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
54 """Exception raised when a ticket query cannot be parsed from a string."""
55
58 """Exception raised when a ticket query has bad constraint values."""
60 TracError.__init__(self, _("Invalid query constraint value"))
61 self.errors = errors
62
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
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)
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
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 is 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 = {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
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 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
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 'id' not 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 constrained_fields = set(self.constraint_cols)
225
226 def sort_columns(name):
227 if name == 'id':
228 return 1
229 if name == 'summary':
230 return 2
231 if name in constrained_fields:
232 return 3
233 return 4
234 cols.sort(key=sort_columns)
235 return cols
236
238 cols = self.get_all_columns()
239
240
241
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
262 cols = cols[:7]
263
264
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
282 cnt = self.env.db_query("SELECT COUNT(*) FROM (%s) AS x"
283 % sql, args)[0][0]
284
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
377
378
379
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
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
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)
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
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
472
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
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
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
594
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
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
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
681
682
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
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
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
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
805
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
818
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
824 del groupsequence[-1]
825 else:
826
827 last_group_is_partial = True
828 del groupsequence[-1][1][-1]
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
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
904
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
923
926
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
934
937
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
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
973
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
985
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
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
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
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
1026
1027 remove_re = re.compile(r'rm_filter_\d+_(.+)_(\d+)$')
1028 add_re = re.compile(r'add_(\d+)$')
1029
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
1040
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
1053
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
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
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
1112
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
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
1148
1149
1150
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
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'
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
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
1251
1254
1256 yield ('query', self._format_link)
1257
1272
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
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:
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'
1370
1371 format = kwargs.pop('format', 'list').strip().lower()
1372 if format in ('list', 'compact'):
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
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
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
1489
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
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
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
1553