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