30
30
from trac.resource import Resource
31
31
from trac.ticket.api import TicketSystem
32
32
from trac.util import Ranges
33
from trac.util.compat import groupby
33
from trac.util.compat import groupby, set
34
34
from trac.util.datefmt import to_timestamp, utc
35
35
from trac.util.presentation import Paginator
36
36
from trac.util.text import shorten_line
37
37
from trac.util.translation import _
38
from trac.web import IRequestHandler
38
from trac.web import parse_query_string, IRequestHandler
39
39
from trac.web.href import Href
40
40
from trac.web.chrome import add_ctxtnav, add_link, add_script, add_stylesheet, \
41
41
INavigationContributor, Chrome
53
53
def __init__(self, env, report=None, constraints=None, cols=None,
54
54
order=None, desc=0, group=None, groupdesc=0, verbose=0,
55
rows=None, page=None, max=None):
55
rows=None, page=None, max=None, format=None):
57
57
self.id = report # if not None, it's the corresponding saved query
58
58
self.constraints = constraints or {}
121
122
def from_string(cls, env, string, **kw):
122
123
filters = string.split('&')
123
kw_strs = ['order', 'group', 'page', 'max']
124
kw_strs = ['order', 'group', 'page', 'max', 'format']
124
125
kw_arys = ['rows']
125
126
kw_bools = ['desc', 'groupdesc', 'verbose']
127
kw_synonyms = {'row': 'rows'}
128
130
for filter_ in filters:
130
132
if len(filter_) != 2:
131
133
raise QuerySyntaxError(_('Query filter requires field and '
132
134
'constraints separated by a "="'))
133
field,values = filter_
135
field, values = filter_
136
# from last chars of `field`, get the mode of comparison
138
if field and field[-1] in ('~', '^', '$') \
139
and not field in cls.substitutions:
142
if field and field[-1] == '!':
135
146
raise QuerySyntaxError(_('Query filter requires field name'))
136
# from last char of `field`, get the mode of comparison
138
if field[-1] in ('~', '^', '$') \
139
and not field in cls.substitutions:
147
field = kw_synonyms.get(field, field)
145
148
processed_values = []
146
149
for val in values.split('|'):
147
val = neg + mode + val # add mode of comparison
150
val = mode + val # add mode of comparison
148
151
processed_values.append(val)
153
if isinstance(field, unicode):
154
field = field.encode('utf-8')
151
155
if field in kw_strs:
152
156
kw[field] = processed_values[0]
153
157
elif field in kw_arys:
154
kw[field] = processed_values
158
kw.setdefault(field, []).extend(processed_values)
155
159
elif field in kw_bools:
157
161
elif field == 'col':
158
162
cols.extend(processed_values)
160
constraints[field] = processed_values
164
constraints.setdefault(field, []).extend(processed_values)
161
165
except UnicodeError:
162
166
pass # field must be a str, see `get_href()`
163
167
report = constraints.pop('report', None)
187
191
# TODO: fix after adding time/changetime to the api.py
188
192
cols += ['time', 'changetime']
190
# Semi-intelligently remove columns that are restricted to a single
191
# value by a query constraint.
192
for col in [k for k in self.constraints.keys()
193
if k != 'id' and k in cols]:
194
constraint = self.constraints[col]
195
if len(constraint) == 1 and constraint[0] \
196
and not constraint[0][0] in ('!', '~', '^', '$'):
199
if col == 'status' and not 'closed' in constraint \
200
and 'resolution' in cols:
201
cols.remove('resolution')
202
if self.group in cols:
203
cols.remove(self.group)
205
194
def sort_columns(col1, col2):
206
195
constrained_fields = self.constraints.keys()
207
196
if 'id' in (col1, col2):
220
209
def get_default_columns(self):
221
all_cols = self.get_all_columns()
210
cols = self.get_all_columns()
212
# Semi-intelligently remove columns that are restricted to a single
213
# value by a query constraint.
214
for col in [k for k in self.constraints.keys()
215
if k != 'id' and k in cols]:
216
constraint = self.constraints[col]
217
if len(constraint) == 1 and constraint[0] \
218
and not constraint[0][0] in '!~^$' and col in cols:
220
if col == 'status' and not 'closed' in constraint \
221
and 'resolution' in cols:
222
cols.remove('resolution')
223
if self.group in cols:
224
cols.remove(self.group)
222
226
# Only display the first seven columns by default
224
228
# Make sure the column we order by is visible, if it isn't also
225
229
# the column we group by
226
230
if not self.order in cols and not self.order == self.group:
500
514
args.append(constraint_sql[1])
502
516
clauses = filter(None, clauses)
503
if clauses or cached_ids:
504
518
sql.append("\nWHERE ")
506
519
sql.append(" AND ".join(clauses))
509
521
sql.append(" OR ")
510
sql.append("id in (%s)" % (','.join(
511
[str(id) for id in cached_ids])))
522
sql.append("id in (%s)" % (','.join(
523
[str(id) for id in cached_ids])))
513
525
sql.append("\nORDER BY ")
514
526
order_cols = [(self.order, self.desc)]
519
531
col = name + '.value'
521
533
col = 't.' + name
534
desc = desc and ' DESC' or ''
522
535
# FIXME: This is a somewhat ugly hack. Can we also have the
523
536
# column type for this? If it's an integer, we do first
524
537
# one, if text, we do 'else'
525
538
if name in ('id', 'time', 'changetime'):
527
sql.append("COALESCE(%s,0)=0 DESC," % col)
529
sql.append("COALESCE(%s,0)=0," % col)
539
sql.append("COALESCE(%s,0)=0%s," % (col, desc))
532
sql.append("COALESCE(%s,'')='' DESC," % col)
534
sql.append("COALESCE(%s,'')=''," % col)
541
sql.append("COALESCE(%s,'')=''%s," % (col, desc))
535
542
if name in enum_columns:
536
543
# These values must be compared as ints, not as strings
537
544
db = self.env.get_db_cnx()
539
sql.append(db.cast(col, 'int') + ' DESC')
541
sql.append(db.cast(col, 'int'))
542
elif name in ('milestone', 'version'):
543
if name == 'milestone':
544
time_col = 'milestone.due'
546
time_col = 'version.time'
548
sql.append("COALESCE(%s,0)=0 DESC,%s DESC,%s DESC"
549
% (time_col, time_col, col))
551
sql.append("COALESCE(%s,0)=0,%s,%s"
552
% (time_col, time_col, col))
545
sql.append(db.cast(col, 'int') + desc)
546
elif name == 'milestone':
547
sql.append("COALESCE(milestone.completed,0)=0%s,"
548
"milestone.completed%s,"
549
"COALESCE(milestone.due,0)=0%s,milestone.due%s,"
550
"%s%s" % (desc, desc, desc, desc, col, desc))
551
elif name == 'version':
552
sql.append("COALESCE(version.time,0)=0%s,version.time%s,%s%s"
553
% (desc, desc, col, desc))
555
sql.append("%s DESC" % col)
557
sql.append("%s" % col)
555
sql.append("%s%s" % (col, desc))
558
556
if name == self.group and not name == self.order:
560
558
if self.order != 'id':
590
590
'name': col, 'label': labels.get(col, _('Ticket')),
591
'wikify': col in wikify,
591
592
'href': self.get_href(context.href, order=col,
592
593
desc=(col == self.order and not self.desc))
593
594
} for col in cols]
596
597
for field in self.fields:
597
if field['type'] == 'textarea':
598
if field['name'] == 'owner' and field['type'] == 'select':
599
# Make $USER work when restrict_owner = true
600
field['options'].insert(0, '$USER')
600
602
field_data.update(field)
601
603
del field_data['name']
694
700
implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider,
695
701
IContentConverter)
697
default_query = Option('query', 'default_query',
698
default='status!=closed&owner=$USER',
699
doc='The default query for authenticated users.')
703
default_query = Option('query', 'default_query',
704
default='status!=closed&owner=$USER',
705
doc="""The default query for authenticated users. The query is either
706
in [TracQuery#QueryLanguage query language] syntax, or a URL query
707
string starting with `?` as used in `query:`
708
[TracQuery#UsingTracLinks Trac links].
709
(''since 0.11.2'')""")
701
711
default_anonymous_query = Option('query', 'default_anonymous_query',
702
default='status!=closed&cc~=$USER',
703
doc='The default query for anonymous users.')
712
default='status!=closed&cc~=$USER',
713
doc="""The default query for anonymous users. The query is either
714
in [TracQuery#QueryLanguage query language] syntax, or a URL query
715
string starting with `?` as used in `query:`
716
[TracQuery#UsingTracLinks Trac links].
717
(''since 0.11.2'')""")
705
719
items_per_page = IntOption('query', 'items_per_page', 100,
706
720
"""Number of tickets displayed per page in ticket queries,
745
759
req.perm.assert_permission('TICKET_VIEW')
747
761
constraints = self._get_constraints(req)
748
763
if not constraints and not 'order' in req.args:
749
764
# If no constraints are given in the URL, use the default ones.
750
765
if req.authname and req.authname != 'anonymous':
751
qstring = self.default_query
766
qstring = self.default_query
754
769
email = req.session.get('email')
755
770
name = req.session.get('name')
756
qstring = self.default_anonymous_query
757
user = email or name or None
771
qstring = self.default_anonymous_query
772
user = email or name or None
760
qstring = qstring.replace('$USER', user)
761
self.log.debug('QueryModule: Using default query: %s', str(qstring))
762
constraints = Query.from_string(self.env, qstring).constraints
763
# Ensure no field constraints that depend on $USER are used
764
# if we have no username.
765
for field, vals in constraints.items():
767
if val.endswith('$USER'):
768
del constraints[field]
774
self.log.debug('QueryModule: Using default query: %s', str(qstring))
775
if qstring.startswith('?'):
776
ticket_fields = [f['name'] for f in
777
TicketSystem(self.env).get_ticket_fields()]
778
ticket_fields.append('id')
779
args = parse_query_string(qstring[1:])
780
constraints = dict([(k, args.getlist(k)) for k in args
781
if k in ticket_fields])
783
constraints = Query.from_string(self.env, qstring).constraints
784
# Substitute $USER, or ensure no field constraints that depend
785
# on $USER are used if we have no username.
786
for field, vals in constraints.items():
787
for (i, val) in enumerate(vals):
789
vals[i] = val.replace('$USER', user)
790
elif val.endswith('$USER'):
791
del constraints[field]
770
cols = req.args.get('col')
794
cols = args.get('col')
771
795
if isinstance(cols, basestring):
773
797
# Since we don't show 'id' as an option to the user,
774
798
# we need to re-insert it here.
775
799
if cols and 'id' not in cols:
776
800
cols.insert(0, 'id')
777
rows = req.args.get('row', [])
801
rows = args.get('row', [])
778
802
if isinstance(rows, basestring):
780
804
format = req.args.get('format')
781
max = req.args.get('max')
805
max = args.get('max')
782
806
if max is None and format in ('csv', 'tab'):
783
807
max = 0 # unlimited unless specified explicitly
784
808
query = Query(self.env, req.args.get('report'),
785
constraints, cols, req.args.get('order'),
786
'desc' in req.args, req.args.get('group'),
787
'groupdesc' in req.args, 'verbose' in req.args,
809
constraints, cols, args.get('order'),
810
'desc' in args, args.get('group'),
811
'groupdesc' in args, 'verbose' in args,
789
req.args.get('page'),
792
816
if 'update' in req.args:
1109
1137
if format == 'compact':
1110
1138
if query.group:
1111
groups = [tag.a('#%s' % ','.join([str(t['id'])
1113
href=href, class_='query', title=title)
1140
tag.a('#%s' % ','.join([str(t['id']) for t in g]),
1141
href=href, class_='query', title=title))
1114
1142
for v, g, href, title in ticket_groups()]
1115
1143
return tag(groups[0], [(', ', g) for g in groups[1:]])