~ubuntu-branches/ubuntu/precise/trac/precise

« back to all changes in this revision

Viewing changes to trac/ticket/query.py

  • Committer: Bazaar Package Importer
  • Author(s): W. Martin Borgert
  • Date: 2009-09-15 21:43:38 UTC
  • mfrom: (1.1.15 upstream)
  • Revision ID: james.westby@ubuntu.com-20090915214338-q3ecy6qxwxfzf9y8
Tags: 0.11.5-2
* Set exec bit for *_frontends (Closes: #510441), thanks to Torsten
  Landschoff for the patch.
* Move python-psycopg2 and python-mysql from Suggests to Depends as
  alternative to python-psqlite2 (Closes: #513117).
* Use debhelper 7 (Closes: #497862).
* Don't compress *-hook files and don't install MS-Windows *.cmd
  files (Closes: #526142), thanks to Jan Dittberner for the patch.
* Add README.source to point to dpatch.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
1
# -*- coding: utf-8 -*-
2
2
#
3
 
# Copyright (C) 2004-2008 Edgewall Software
 
3
# Copyright (C) 2004-2009 Edgewall Software
4
4
# Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de>
5
5
# Copyright (C) 2005-2007 Christian Boos <cboos@neuf.fr>
6
6
# All rights reserved.
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
52
52
 
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):
56
56
        self.env = env
57
57
        self.id = report # if not None, it's the corresponding saved query
58
58
        self.constraints = constraints or {}
60
60
        self.desc = desc
61
61
        self.group = group
62
62
        self.groupdesc = groupdesc
 
63
        self.format = format
63
64
        self.default_page = 1
64
65
        self.items_per_page = QueryModule(self.env).items_per_page
65
66
 
120
121
 
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'}
126
128
        constraints = {}
127
129
        cols = []
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
 
137
            mode = ''
 
138
            if field and field[-1] in ('~', '^', '$') \
 
139
                                and not field in cls.substitutions:
 
140
                mode = field[-1]
 
141
                field = field[:-1]
 
142
            if field and field[-1] == '!':
 
143
                mode = '!' + mode
 
144
                field = field[:-1]
134
145
            if not field:
135
146
                raise QuerySyntaxError(_('Query filter requires field name'))
136
 
            # from last char of `field`, get the mode of comparison
137
 
            mode, neg = '', ''
138
 
            if field[-1] in ('~', '^', '$') \
139
 
                                and not field in cls.substitutions:
140
 
                mode = field[-1]
141
 
                field = field[:-1]
142
 
            if field[-1] == '!':
143
 
                neg = '!'
144
 
                field = field[:-1]
 
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)
149
152
            try:
150
 
                field = str(field)
 
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:
156
160
                    kw[field] = True
157
161
                elif field == 'col':
158
162
                    cols.extend(processed_values)
159
163
                else:
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']
189
193
 
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 ('!', '~', '^', '$'):
197
 
                if col in cols:
198
 
                    cols.remove(col)
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)
204
 
 
205
194
        def sort_columns(col1, col2):
206
195
            constrained_fields = self.constraints.keys()
207
196
            if 'id' in (col1, col2):
218
207
        return cols
219
208
 
220
209
    def get_default_columns(self):
221
 
        all_cols = self.get_all_columns()
 
210
        cols = self.get_all_columns()
 
211
        
 
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:
 
219
                cols.remove(col)
 
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)
 
225
 
222
226
        # Only display the first seven columns by default
223
 
        cols = all_cols[:7]
 
227
        cols = cols[:7]
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:
241
245
        #                    tuple([repr(a) for a in args]))
242
246
 
243
247
        cnt = 0
244
 
        cursor.execute(count_sql, args);
 
248
        try:
 
249
            cursor.execute(count_sql, args);
 
250
        except:
 
251
            db.rollback()
 
252
            raise
245
253
        for cnt, in cursor:
246
254
            break
247
255
        self.env.log.debug("Count results in Query: %d" % cnt)
269
277
                                  'pages in the query', page=self.page))
270
278
 
271
279
        self.env.log.debug("Query SQL: " + sql % tuple([repr(a) for a in args]))     
272
 
        cursor.execute(sql, args)
 
280
        try:
 
281
            cursor.execute(sql, args)
 
282
        except:
 
283
            db.rollback()
 
284
            raise
273
285
        columns = get_column_names(cursor)
274
286
        fields = []
275
287
        for column in columns:
295
307
                elif field and field['type'] == 'checkbox':
296
308
                    try:
297
309
                        val = bool(int(val))
298
 
                    except TypeError, ValueError:
 
310
                    except (TypeError, ValueError):
299
311
                        val = False
300
312
                result[name] = val
301
313
            results.append(result)
320
332
        if not isinstance(href, Href):
321
333
            href = href.href # compatibility with the `req` of the 0.10 API
322
334
 
 
335
        if format is None:
 
336
            format = self.format
323
337
        if format == 'rss':
324
338
            max = self.items_per_page
325
339
            page = self.default_page
383
397
            add_cols(self.group)
384
398
        if self.rows:
385
399
            add_cols('reporter', *self.rows)
386
 
        add_cols('priority', 'time', 'changetime', self.order)
 
400
        add_cols('status', 'priority', 'time', 'changetime', self.order)
387
401
        cols.extend([c for c in self.constraints.keys() if not c in cols])
388
402
 
389
403
        custom_fields = [f['name'] for f in self.fields if 'custom' in f]
500
514
                    args.append(constraint_sql[1])
501
515
 
502
516
        clauses = filter(None, clauses)
503
 
        if clauses or cached_ids:
 
517
        if clauses:
504
518
            sql.append("\nWHERE ")
505
 
        if clauses:
506
519
            sql.append(" AND ".join(clauses))
507
 
        if cached_ids:
508
 
            if clauses:
 
520
            if cached_ids:
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])))
512
524
            
513
525
        sql.append("\nORDER BY ")
514
526
        order_cols = [(self.order, self.desc)]
519
531
                col = name + '.value'
520
532
            else:
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'):
526
 
                if desc:
527
 
                    sql.append("COALESCE(%s,0)=0 DESC," % col)
528
 
                else:
529
 
                    sql.append("COALESCE(%s,0)=0," % col)
 
539
                sql.append("COALESCE(%s,0)=0%s," % (col, desc))
530
540
            else:
531
 
                if desc:
532
 
                    sql.append("COALESCE(%s,'')='' DESC," % col)
533
 
                else:
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()
538
 
                if desc:
539
 
                    sql.append(db.cast(col, 'int') + ' DESC')
540
 
                else:
541
 
                    sql.append(db.cast(col, 'int'))
542
 
            elif name in ('milestone', 'version'):
543
 
                if name == 'milestone': 
544
 
                    time_col = 'milestone.due'
545
 
                else:
546
 
                    time_col = 'version.time'
547
 
                if desc:
548
 
                    sql.append("COALESCE(%s,0)=0 DESC,%s DESC,%s DESC"
549
 
                               % (time_col, time_col, col))
550
 
                else:
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))
553
554
            else:
554
 
                if desc:
555
 
                    sql.append("%s DESC" % col)
556
 
                else:
557
 
                    sql.append("%s" % col)
 
555
                sql.append("%s%s" % (col, desc))
558
556
            if name == self.group and not name == self.order:
559
557
                sql.append(",")
560
558
        if self.order != 'id':
581
579
 
582
580
        cols = self.get_columns()
583
581
        labels = dict([(f['name'], f['label']) for f in self.fields])
 
582
        wikify = set([f['name'] for f in self.fields 
 
583
                      if f['type'] == 'text' and f.get('format') == 'wiki'])
584
584
 
585
585
        # TODO: remove after adding time/changetime to the api.py
586
586
        labels['changetime'] = _('Modified')
588
588
 
589
589
        headers = [{
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]
594
595
 
595
596
        fields = {}
596
597
        for field in self.fields:
597
 
            if field['type'] == 'textarea':
598
 
                continue
 
598
            if field['name'] == 'owner' and field['type'] == 'select':
 
599
                # Make $USER work when restrict_owner = true
 
600
                field['options'].insert(0, '$USER')
599
601
            field_data = {}
600
602
            field_data.update(field)
601
603
            del field_data['name']
610
612
            {'name': _("is"), 'value': ""},
611
613
            {'name': _("is not"), 'value': "!"}
612
614
        ]
 
615
        modes['textarea'] = [
 
616
            {'name': _("contains"), 'value': "~"},
 
617
            {'name': _("doesn't contain"), 'value': "!~"},
 
618
        ]
613
619
        modes['select'] = [
614
620
            {'name': _("is"), 'value': ""},
615
621
            {'name': _("is not"), 'value': "!"}
694
700
    implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider,
695
701
               IContentConverter)
696
702
               
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'')""") 
700
710
    
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'')""") 
704
718
 
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')
746
760
 
747
761
        constraints = self._get_constraints(req)
 
762
        args = req.args
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 
752
 
                user = req.authname 
 
766
                qstring = self.default_query
 
767
                user = req.authname
753
768
            else:
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
758
773
                      
759
 
            if user: 
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(): 
766
 
                for val in vals: 
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])
 
782
            else:
 
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):
 
788
                        if user:
 
789
                            vals[i] = val.replace('$USER', user)
 
790
                        elif val.endswith('$USER'):
 
791
                            del constraints[field]
 
792
                            break
769
793
 
770
 
        cols = req.args.get('col')
 
794
        cols = args.get('col')
771
795
        if isinstance(cols, basestring):
772
796
            cols = [cols]
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):
779
803
            rows = [rows]
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,
788
812
                      rows,
789
 
                      req.args.get('page'), 
 
813
                      args.get('page'), 
790
814
                      max)
791
815
 
792
816
        if 'update' in req.args:
805
829
 
806
830
        if format:
807
831
            Mimeview(self.env).send_converted(req, 'trac.ticket.Query', query,
808
 
                                              format, 'query')
 
832
                                              format, filename=None)
809
833
 
810
834
        return self.display_html(req, query)
811
835
 
872
896
            orig_time = query_time
873
897
 
874
898
        context = Context.from_request(req, 'query')
 
899
        owner_field = [f for f in query.fields if f['name'] == 'owner']
 
900
        if owner_field:
 
901
            TicketSystem(self.env).eventually_restrict_owner(owner_field[0])
875
902
        data = query.template_data(context, tickets, orig_list, orig_time, req)
876
903
 
877
904
        # For clients without JavaScript, we add a new constraint here if
926
953
    def export_csv(self, req, query, sep=',', mimetype='text/plain'):
927
954
        content = StringIO()
928
955
        cols = query.get_columns()
929
 
        writer = csv.writer(content, delimiter=sep)
930
956
        writer = csv.writer(content, delimiter=sep, quoting=csv.QUOTE_MINIMAL)
931
957
        writer.writerow([unicode(c).encode('utf-8') for c in cols])
932
958
 
1089
1115
                         class_=ticket['status'],
1090
1116
                         href=req.href.ticket(int(ticket['id'])),
1091
1117
                         title=shorten_line(ticket['summary']))
 
1118
 
1092
1119
        def ticket_groups():
1093
1120
            groups = []
1094
1121
            for v, g in groupby(tickets, lambda t: t[query.group]):
1097
1124
                q.group = q.groupdesc = None
1098
1125
                order = q.order
1099
1126
                q.order = None
1100
 
                title = "%s %s tickets matching %s" % (v, query.group,
1101
 
                                                       q.to_string())
 
1127
                title = _("%(groupvalue)s %(groupname)s tickets matching "
 
1128
                          "%(query)s", groupvalue=v, groupname=query.group,
 
1129
                          query=q.to_string())
1102
1130
                # produce the href for the query corresponding to the group
1103
1131
                q.constraints[str(query.group)] = v
1104
1132
                q.order = order
1108
1136
 
1109
1137
        if format == 'compact':
1110
1138
            if query.group:
1111
 
                groups = [tag.a('#%s' % ','.join([str(t['id'])
1112
 
                                                  for t in g]),
1113
 
                                href=href, class_='query', title=title)
 
1139
                groups = [(v, ' ', 
 
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:]])
1116
1144
            else: